Lobsters

Attention people who think UB is fine

UB는 괜찮다고 생각하는 사람들에게

C++의 정의되지 않은 동작(Undefined Behavior, UB)은 단순히 실행 결과를 예측하기 어렵다는 뜻이 아닙니다. GCC와 Clang의 어셈블리 예제로 UB가 함수의 명령어를 없애거나 예상 밖의 메모리 쓰기와 충돌로 이어질 수 있음을 보여주고, 댓글에서는 그 사례의 범위와 실용적인 대응을 두고 논쟁합니다.

에디터 노트

이 사례에서 가장 설득력 있는 점은 UB를 단순한 하드웨어 동작이나 세그멘테이션 오류로 축소할 수 없다는 것입니다. 제시된 어셈블리에서는 `serve_hot_chocolate()`가 반환하지 않고, 널 포인터 쓰기는 코드에 적힌 99 대신 0을 기록한 뒤 중단합니다. 다만 함수 끝에 반환 명령이 없다는 출력은 실행 흐름이 다음 함수로 이어질 수 있음을 보여줄 뿐, 모든 빌드에서 `explode()`가 호출된다는 보장은 아닙니다. 댓글의 이견은 예제의 요지를 지우기보다 적용 범위를 가립니다. `std::unreachable()`나 널 포인터 역참조는 잘못된 코드라는 점이 분명해도, 그 사실만으로 모든 UB의 위험과 실제 발생 빈도를 설명하지는 못합니다. 반대로 포인터 유효성을 가정해 메모리 접근 순서와 반복문을 바꾸는 최적화가 가능하다는 설명은 UB가 왜 컴파일러에 여지를 주는지 구체적으로 보여줍니다. C 개발자가 표준의 보장과 대상 구현의 확장을 구분하고 분석 도구를 쓰라는 조언은 실용적이지만, 이 사례처럼 최적화가 코드의 직관적 해석을 벗어날 수 있다는 언어 차원의 문제까지 없애지는 않습니다.

AI 요약

C와 C++의 정의되지 않은 동작(Undefined Behavior, UB)을 두고 "결과를 알 수 있고 최악의 경우 세그멘테이션 오류가 난다"고 보는 생각을 반박합니다. 저자는 메모리 매핑 장치의 명령 레지스터를 흉내 낸 volatile int device_command와 std::unreachable() 예제를 제시합니다. serve_hot_chocolate()는 레지스터에 42를 쓴 뒤 UB를 일으키고, 뒤이어 선언한 explode()는 3을 씁니다. GCC 16.2의 -O1 출력에서 serve_hot_chocolate()에는 ret 명령이 없습니다. 저자는 함수가 끝난 뒤 다음 메모리의 코드로 실행이 이어질 수 있다고 설명하며 이를 "폭발하는 핫초콜릿"에 빗댑니다.

널 포인터 역참조 예제에서도 결과는 단순히 주소 0에 99를 쓰고 충돌하는 모습과 다릅니다. GCC 출력은 주소 0에 0을 쓰고 ud2 명령으로 중단합니다. 99는 생성된 코드에 나타나지 않습니다. Clang의 출력도 std::unreachable() 예제에서 serve_hot_chocolate()의 끝에 반환 명령을 넣지 않습니다. 반면 널 포인터에 10을 더한 뒤 역참조하면 GCC 출력은 주소 40에 99를 씁니다. 저자는 이 세 결과가 같은 UB 지점에서도 달라질 수 있다고 강조합니다. 컴파일러가 모든 UB를 찾아내거나, UB가 발생하면 반드시 특정한 방식으로 처리해야 한다는 보장은 없습니다.

Lobsters 반응

  • @olliej — UB를 서로 다른 동작을 허용하는 규칙과 혼동하면 안 됩니다. 구현 정의 동작(implementation-defined behavior)은 별도로 존재합니다. 표준이 특정 상황의 동작을 UB로 정하면 컴파일러에는 그 결과를 제한하는 조건이 없으며, 어떤 구현이나 하드웨어를 쓰는지도 이를 바꾸지 않습니다.
  • @davmac — 실제로 도달 가능한 곳에 std::unreachable()를 넣거나 널 포인터를 역참조하는 사례는 극단적이며, 그런 예만으로 모든 UB가 늘 예측 불가능한 결과를 낸다고 일반화하면 과장입니다. std::unreachable()는 컴파일러 최적화를 돕는 용도라 잘못 쓰면 안전장치가 없다는 점은 맞습니다. 버퍼 초과나 해제 후 사용 같은 포인터 UB는 더 미묘하고 위험한 결과를 낳을 수 있지만, 모든 UB를 한데 묶어 설명하면 오해를 부릅니다.
  • @fanf — C 위원회도 표준에서 UB를 가능한 한 줄이려는 작업을 하고 있습니다. C로 계속 작업한다면 Annex J를 공부하고 경고를 켜며, 테스트와 sanitizer, 정적 분석을 활용하는 것이 실용적인 대응입니다.
  • @robn — C를 매일 업무에서 쓰며 경고, 테스트, sanitizer, 정적 분석을 활용합니다. C의 약점을 논하는 데 그치기보다 선택한 도구의 장점을 살리고 약점을 줄이는 방법을 익히는 편이 낫습니다.
  • @david_chisnall — 컴파일러가 포인터가 유효하다고 가정하면 메모리 접근을 옮기거나 반복문을 벡터화할 수 있습니다. 널 포인터 접근이 원래 코드 순서와 정확히 같은 시점에 충돌해야 한다고 요구하면 이런 최적화가 크게 제한됩니다. Rust는 타입 시스템과 borrow checker로 유효한 포인터 사용을 확인한 뒤 같은 최적화를 수행합니다. C는 그 보장을 프로그래머의 책임으로 둡니다.
  • @n3t — 첫 번째 예제는 UB가 호출되지 않은 함수를 실행하는 사례를 다룬 2017년 글을 떠올리게 합니다.

원문: blog.habets.se / 번역·요약: Trawling