C++에서 float를 int로 변환할 때 잘림 처리된 값이 대상 정수형에 들어가지 않으면 정의되지 않은 동작이 됩니다.
C++에서 float를 int로 변환할 때, 잘림 처리된 float가 대상 정수형에 들어가지 않으면 정의되지 않은 동작이 됩니다. C++에서는 이를 실수로 수행하기 쉽습니다. 많은 코드가 이를 잘못 처리합니다.
void foo(float f) {
int i0 = f;
int i1 = int(f);
int i2 = static_cast<int>(f);
}
이 코드는 -Wall과 -Wextra를 함께 사용해도 경고를 전혀 생성하지 않습니다. -Wconversion은 암시적 변환에 대해서만 경고합니다. 하지만 세 가지 변환은 각각 일부 입력에서 정의되지 않은 동작입니다.
Cppreference의 암시적 변환 페이지의 부동소수점-정수 변환 절에는 다음과 같이 나와 있습니다.
부동소수점 형식의 prvalue는 임의의 정수 형식 prvalue로 변환될 수 있습니다. 소수 부분은 잘립니다. 즉, 소수 부분은 버려집니다. 값이 대상 형식에 들어갈 수 없으면 동작은 정의되지 않습니다(대상 형식이 부호 없는 형식인 경우에도 모듈러 연산은 적용되지 않습니다).
저는 실제 코드에서 이 실수를 여러 번 보았습니다. 예를 들어 C++ Core Guidelines를 지원하는 Microsoft의 Guidelines Support Library에서 말입니다.
GSL은 안전한 축소 변환을 위한 함수인 gsl::narrow를 제공합니다.
gsl::narrow<T>(x)는 부호성 승격이 없는 축소 변환에 대해static_cast<T>(x)를 수행하는 이름 붙은 캐스트입니다. 인수x를 대상 형식T로 표현할 수 없으면 함수는 예외를 던집니다.
저는 이들이 float에서 int로의 변환을 어떻게 처리하는지 궁금했습니다. 문서와 달리, 이들은 이를 처리하지 않는 것으로 드러났습니다. gsl::narrow는 일부 입력에서 정의되지 않은 동작입니다. 저는 이를 댓글에서 지적했지만, 다음과 같은 논리로 일축되었습니다.
내부적으로 UB를 사용하는 것에 관하여: 괜찮습니다. 누군가 우려한다면, UB의 사용은 우리가 대상으로 하는 플랫폼에서 무해합니다(예를 들어, 이러한 형식에서 하드웨어 트랩 표현에 도달하는 일은 없습니다).
정의되지 않은 동작은 실제이지만, 현재의 프로세서와 컴파일러에서는 프로그램이 어쨌든 대개 작동합니다. 컴파일러는 모든 표현 불가능한 입력을 동일한 자리표시자 정수 INT_MIN으로 매핑하는 x86의 CVTTSS2SI 같은 명령어를 선택하는 경향이 있습니다. AArch64에는 포화 처리를 하고 NaN을 0으로 매핑하는 FCVTZS가 있습니다.
프로그램이 충돌하지 않는다는 사실은 문제를 무해해 보이게 만들 수 있습니다. 그렇지 않습니다. 서로 다른 하드웨어에서 서로 다른 결과가 나오는 것은 문제가 됩니다. 더 중요한 점은, 실행되는 모든 정의되지 않은 동작은 문제가 된다는 것입니다. Ralf Jung은 자신의 글 _"하드웨어가 하는 일"은 프로그램이 하는 일이 아닙니다_에서 이를 잘 설명합니다. (그의 블로그를 추천합니다.) 컴파일러가 우연히 다른 변환을 적용하면 코드가 갑자기 작동을 멈출 수 있습니다.
올바른 해결책은 캐스팅 전에 범위 검사를 하는 것입니다. 저는 이를 Rust의 포화 방식에 기반한 개념 증명 라이브러리로 만들었습니다.
Clang과 GCC의 정의되지 않은 동작 검사기로도 이 정의되지 않은 동작을 감지할 수 있습니다. -fsanitize=float-cast-overflow를 참고하세요. 어쨌든 모든 C++ 코드를 UBSan으로 테스트할 것을 권합니다.
잘못된 GSL 논리는 이슈 댓글에서 코드로 들어갔습니다. 문제는 수정되지 않았습니다.