1P by GN⁺ 18시간전 | ★ favorite | 댓글 1개
  • C 언어 규칙은 포인터 비교, 별칭, 널 포인터, 초기화되지 않은 값처럼 단순해 보이는 코드도 정의되지 않은 동작으로 만들 수 있음
  • 정수 상수와 sizeof, 문자 상수, uint8_t 산술은 타입 선택과 정수 승격 때문에 플랫폼·표기·중간 대입 위치에 따라 결과가 달라질 수 있음
  • 함수 선언의 foo()foo(void), 프로토타입 부재, 기본 인자 승격, 반환값 없는 함수는 C와 C++ 에서 합법성이나 동작이 다르게 갈림
  • 배열은 포인터가 아니며, 배열 매개변수는 포인터로 조정되고, a, &a, &a[0]는 같은 주소라도 타입이 달라 교환해 쓸 수 없음
  • 연산자 우선순위와 평가 순서는 별개이며, switch 본문 구조와 임시 객체 수명까지 포함해 표준 문구가 실제 실행 결과를 좌우함

정의되지 않은 동작과 포인터 규칙

  • 포인터 비교와 엄격한 별칭 규칙

    • 같은 타입의 포인터 pq가 같은 주소를 가리켜도, 서로 다른 객체에서 유래했고 같은 aggregate 또는 union 객체의 일부가 아니라면 p == q 비교는 정의되지 않은 동작이 될 수 있음
    • 포인터가 단순한 숫자 주소보다 더 추상적이라는 점은 관련 글에서 이어짐
    • int 객체를 short lvalue로 접근하면 strict aliasing 규칙에 따라 정의되지 않은 동작이 됨
    • unsigned char 포인터는 예외적으로 어떤 객체든 별칭(alias)할 수 있어, int 객체를 unsigned char lvalue로 접근하는 것은 합법임
    • unsigned char는 패딩 비트와 trap representation이 없다는 보장이 있으며, C11부터는 signed char도 패딩 비트가 없다고 보장됨
    • 타입 기반 별칭 분석은 관련 글에서 다룸
  • 널 포인터와 포인터 표현

    • 널 포인터의 비트 표현이 반드시 모든 비트 0일 필요는 없음
    • C 표준은 null pointer constant를 정의하지만, 실행 시점의 널 포인터 표현이나 일반 포인터 표현은 정의하지 않음
    • Symbolics Lisp Machine 3600은 숫자 포인터 대신 <array-object, index> 형태의 튜플을 사용하며, 널 포인터 표현은 <nil, 0>
    • 추가 예시는 clc FAQ 5.17에 있음
    • 상수 0은 문맥에 따라 정수 또는 널 포인터가 되며, (void *)0은 널 포인터로 평가됨
    • 표현식 e0으로 평가된다고 해서 (void *)e가 널 포인터가 된다는 보장은 없음
    • 오직 null pointer constant가 포인터 타입으로 변환될 때만 널 포인터와 같다고 보장됨
    • 널 포인터에 대한 산술은 정의되지 않은 동작이므로, e가 널 포인터라도 e + 0이 널 포인터라는 보장은 없음
  • 초기화되지 않은 값

    • 초기화되지 않은 자동 저장 기간 객체를 읽을 때, 그 객체가 register 저장 클래스가 될 수 있고 주소가 한 번도 취해지지 않았다면 C11 § 6.3.2.1 ¶ 2에 따라 정의되지 않은 동작이 됨
    • 이 규칙은 DR338에서 다루는 Intel Itanium 아키텍처와 연결됨
    • Itanium의 일반 정수 레지스터는 64비트와 trap bit 하나를 가지며, 이 trap bit는 레지스터가 초기화되었는지 나타내는 NaT(not-a-thing)임
    • 변수의 주소를 취하면 해당 조건은 사라지지만, 값은 indeterminate이며 trap representation 또는 unspecified value가 될 수 있음
    • trap representation을 읽으면 C11 § 6.2.6.1 ¶ 5에 따라 정의되지 않은 동작이 됨
    • unspecified value라면 x != x의 결과도 true 또는 false가 될 수 있고, int x가 unspecified이면 x *= 0 이후에도 x가 0이라는 보장이 없음
    • indeterminate와 unspecified value는 DR260, DR451, N1793, N1818, N2012, N2013, N2221에서 논의됨
  • unsigned charmemcpy

    • unsigned char 타입은 C11 § 6.2.6.1 ¶ 3에 따라 trap representation이 없으므로 초기값은 unspecified임
    • StackOverflow의 C 위원회 멤버 답변은 표준 라이브러리 함수 memcpy 호출 뒤 x의 값이 specified가 되어야 하며, 이 해석에서는 x != xfalse가 된다고 봄
    • C 표준에서 이를 뒷받침하는 근거는 명확하지 않고, DR451의 위원회 응답은 indeterminate value에 라이브러리 함수를 사용하면 정의되지 않은 동작이라고 해 이 해석과 충돌함
    • 이 질문은 열린 상태로 남아 있으며, 추가 논의는 Uninitialized Reads에 있음

정수 상수, 승격, sizeof

  • 정수 상수의 표기와 타입

    • 접미사가 없는 10진 정수 상수는 항상 signed 타입 목록에서 선택되지만, 8진·16진 상수는 signed 또는 unsigned 타입이 될 수 있음
    • C17 § 6.4.4.1에 따라 정수 상수의 타입은 해당 값을 표현할 수 있는 목록의 첫 번째 타입으로 정해짐
    • 접미사가 없을 때 10진 상수는 int, long int, long long int 순서이고, 8진·16진 상수는 int, unsigned int, long int, unsigned long int, long long int, unsigned long long int 순서임
    • INT_MAX+1부터 UINT_MAX 사이의 상수는 10진인지 16진인지에 따라 타입이 달라질 수 있고, 가변 인자 함수 호출처럼 ABI에 민감한 코드에서 차이를 만들 수 있음
    • Arm 32-bit architecture ABI에서는 intlong이 32비트로 레지스터 하나에 전달되고, long long은 64비트로 레지스터 두 개에 전달됨
    • int가 32비트인 플랫폼에서는 -1 < 0x8000true가 되고, int가 16비트인 플랫폼에서는 false가 되어 이식성 문제가 생길 수 있음
    • generic selection, C++ 오버로드 함수, sizeof(0x80000000) == sizeof(2147483648) 같은 표현식에서도 상수 타입 차이가 결과를 바꿀 수 있음
  • sizeof(int) > -1

    • sizeof 연산자는 size_t 타입의 unsigned integer를 반환함
    • C11 § 6.3.1.8의 usual arithmetic conversions에 따라 signed 피연산자가 unsigned 피연산자보다 낮은 rank를 가지면 같은 rank의 unsigned 타입으로 변환됨
    • -1에 해당하는 signed integer는 unsigned로 변환될 때 해당 rank의 최대 unsigned integer가 됨
    • 따라서 sizeof(int) > -1은 항상 false로 평가됨
  • 문자 상수의 타입

    • C에서 문자 상수는 C11 § 6.4.4.4 ¶ 10에 따라 int 타입임
    • 따라서 sizeof(char) == sizeof('x')가 항상 true라는 보장은 없고, sizeof(int) == sizeof('x')만 보장됨
    • integer character constant는 하나 이상의 multibyte character 시퀀스일 수 있어 'abc'도 유효하며, 그 표현은 구현 정의임
    • 단일 문자를 포함한 integer character constant의 값은 같은 단일 문자를 나타내는 char 타입 객체의 정수 표현과 같음
  • uint8_t 산술과 나눗셈

    • a, b, c가 읽기 전에 초기화되어 있어도, 정수 승격과 중간 대입 위치 때문에 xz의 값이 다를 수 있음
    • 각 변수 값은 int 크기로 승격된 뒤 덧셈과 나눗셈이 수행되고, 각 대입 결과는 해당 변수 타입으로 truncate되어 저장됨
    • 예를 들어 a=255, b=1, c=2이면 x((255 + 1) / 2) % 256 = 128이 됨
    • 중간 변수 y(255 + 1) % 256 = 0이 되고, 그 뒤 z(0 / 2) % 256 = 0이 되어 128 != 0
    • unsigned integer overflow는 정의된 동작임
    • 모듈로 연산은 덧셈에 대해 분배되므로 나눗셈을 덧셈으로 바꾸면 xz는 항상 같음
    • 첫 대입을 uint8_t x = ((uint8_t)(a + b)) / c;로 바꿔도 xz는 항상 같아짐
  • const 변수와 variable length array

    • const로 한정된 변수 nm을 배열 크기로 사용해도, 이들은 C의 integer constant expression이 아님
    • C11 § 6.6 ¶ 6에서 integer constant expression은 integer constant, enumeration constant, character constant, 결과가 integer constant인 sizeof, _Alignof, cast의 즉시 피연산자인 floating constant 등으로 제한됨
    • 배열 크기 표현식이 integer constant expression이 아니면 C11 § 6.7.6.2 ¶ 4에 따라 variable length array가 됨
    • variable length array는 file scope에서 허용되지 않아 전역 배열 x가 있는 compilation unit은 컴파일되지 않음
    • block scope에서는 variable length array가 허용되므로 지역 배열 y가 있는 compilation unit은 컴파일될 수 있음
    • variable length array는 구현이 지원하지 않아도 되는 conditional feature이므로, 이를 지원하지 않는 컴파일러에서는 block scope 예도 컴파일되지 않을 수 있음
    • C++에서는 두 compilation unit이 모두 컴파일되며, C++에는 variable length array 개념이 없어 y는 42개 원소를 가진 일반 배열로 컴파일됨

함수 선언, 반환값, linkage

  • foo()foo(void)

    • foo() 형태의 함수 선언은 인자 개수와 타입을 모르는 함수를 선언하고, foo(void)는 인자가 없는 nullary function을 선언함
    • 이 차이는 함수 선언·정의·프로토타입 관련 글에서 다룸
    • 인자 목록이 없는 선언은 함수 이름만 도입하고 인자 수와 타입을 정의하지 않기 때문에, 뒤의 함수 정의와 결합해 합법일 수 있음
    • 프로토타입 없이 함수가 호출되면 default argument promotions가 적용되어 floatdouble로 승격됨
    • 승격 후의 함수 타입이 실제 함수 정의의 타입과 호환되지 않으면 선언과 정의의 조합은 유효하지 않음
    • 선언이 없는 함수 호출은 C에서는 암시적 함수가 허용되어 컴파일될 수 있지만, C++에서는 컴파일 오류임
    • 선언 없이 bar(42) 같은 호출을 하면 정수 인자 승격이 적용되어 42int로 표현되므로, bar가 어떤 반환 타입 T에 대해 T (*)(int)와 호환되지 않으면 정의되지 않은 동작이 됨
  • 값을 반환하지 않는 value-returning 함수

    • 반환 타입이 int인 함수가 값을 반환하지 않아도, C에서는 호출 결과 값을 사용하지 않는 한 합법일 수 있음
    • K&R C에는 void 타입이 없었고 타입을 생략하면 기본 타입 int가 가정되었기 때문에, 값을 반환하지 않는 함수와 암시적 int 규칙이 역사적으로 연결되어 있음
    • 암시적 int 규칙은 C99에서 폐지되었으며, 관련 논의는 N661C99 rationale에 있음
    • C17 § 6.9.1 ¶ 12는 함수 끝의 }에 도달했고 호출자가 함수 호출 값을 사용하면 정의되지 않은 동작이라고 규정함
    • C++98 § 6.6.3 ¶ 2에서는 value-returning 함수의 끝으로 흘러나가는 것 자체가 값 없는 return과 같고, value-returning 함수에서는 정의되지 않은 동작이 됨
    • C++ 컴파일러는 어떤 분기에서 abort_program()이 종료하는지 일반적으로 증명할 수 없기 때문에 이런 경우 오류가 아니라 진단만 낼 수 있음
  • linkage와 extern

    • 이전 선언이 보이는 스코프에서 extern으로 같은 식별자를 다시 선언하면, 나중 선언의 linkage는 이전 선언의 linkage와 같음
    • C17 § 6.2.2 ¶ 4는 이전 선언이 internal 또는 external linkage를 지정했다면 이후 extern 선언도 같은 linkage를 갖는다고 규정함
    • 이전 선언이 보이지 않거나 이전 선언에 linkage가 없으면 extern 식별자는 external linkage를 가짐
    • 반대 순서의 선언 조합은 정의되지 않은 동작이 될 수 있으며, GCC와 Clang이 이를 잡아냄

한정자와 불완전 타입

  • 함수 매개변수의 const

    • 함수 선언에서 매개변수 xconst로 한정되고 함수 정의에서는 그렇지 않으며 함수 본문에서 x에 값을 써도 합법임
    • C11 § 6.7.6.3 ¶ 15에 따라 함수 매개변수 타입 호환성과 composite type을 판단할 때, qualified type으로 선언된 각 매개변수는 unqualified version으로 취급됨
    • 같은 주제는 DR040에서도 다뤄짐
  • 함수 반환 타입의 const

    • 함수 정의의 반환 타입만 const로 한정되고 선언은 그렇지 않은 경우의 정답은 단순히 맞거나 틀리다고 보기 어려움
    • 전체적인 합의는 rvalue의 한정자는 무시되어야 한다는 쪽이지만, C11까지의 표준 문구는 이를 명시적으로 다루지 않았음
    • C17에서는 cast, lvalue conversion, function declarator에서 rvalue 한정자를 무시해야 한다는 점이 명확해짐
    • C17 § 6.7.6.3 ¶ 5에는 함수가 반환하는 타입이 Tunqualified version이라고 명시되었고, 이 문구는 C17에서 추가됨
    • 반환 타입의 const 한정이 달라도 함수 타입 대입이 합법이 될 수 있음
    • 추가 논의는 DR423DR481에 있음
  • 불완전 구조체와 전역 변수

    • 전역 변수 선언 시점에 struct foo가 불완전 타입이라 크기를 알 수 없어도, 이후 같은 translation unit에서 타입이 완성되는 경우 특정 상황에서는 허용됨
    • 전역 변수나 불완전 타입 배열에도 비슷한 논리가 적용됨
    • 이 내용은 DR016에서도 다뤄짐
  • void 타입의 external object

    • 내부 linkage를 가진 void 타입 변수 선언은 합법이 아니지만, external linkage를 가진 void 타입 변수 선언은 문법상 합법이고 C11 표준 어디에도 명시적으로 금지되어 있지 않음
    • C11 § 6.2.5 ¶ 19에 따르면 void 타입은 값의 빈 집합으로 구성된 완성될 수 없는 불완전 객체 타입
    • C11 § 6.3.2.1 ¶ 1은 lvalue를 void가 아닌 객체 타입의 표현식으로 정의하므로, void 타입 객체 이름 foo는 유효한 lvalue가 아님
    • C11 기준으로 external void 객체에 대해 의미 있고 conforming한 연산은 떠올리기 어려움
    • DR012는 타입을 const void로 바꾸면 객체 foo의 주소를 취하는 것이 합법이라고 다루며, 이는 의도된 기능보다는 oversight처럼 보임
  • 포인터-const 변환

배열, 문자열 리터럴, 포인터 조정

  • 배열은 포인터가 아님

    • 배열 초기화와 포인터 초기화는 동등하지 않음
    • 첫 번째 형태는 자동 또는 정적 저장 기간의 수정 가능한 배열을 초기화함
    • 두 번째 형태는 정적 저장 기간을 가진 배열을 가리키는 포인터를 초기화하며, 그 배열은 반드시 수정 가능하지 않음
    • 배열은 포인터가 아니며, 자세한 내용은 관련 글에서 다룸
  • a, &a, &a[0]

    • int a[42];에서 a, &a, &a[0]는 모두 배열의 첫 번째 원소 주소로 평가됨
    • 하지만 세 표현식의 타입은 서로 다르므로 상호 교환해 사용할 수 없음
    • 자세한 내용은 관련 글에서 다룸
  • 배열 매개변수와 지역 배열

    • 함수 매개변수 타입이 “T의 배열”이면 “T를 가리키는 포인터”로 조정됨
    • 매개변수 xint[42]처럼 보여도 실제로는 int *로 취급됨
    • 지역 변수 yint[42]이면 sizeof(y)42 * sizeof(int)
    • 일반적으로 객체 포인터 크기는 정수 42개의 크기와 같지 않으므로 sizeof(x) == sizeof(y)는 보통 false
    • 자세한 내용은 관련 글에서 다룸

연산자, 평가 순서, 제어 흐름

  • x+++y

    • C에서는 C++처럼 새 연산자를 정의할 수 없으므로 +++ 같은 새 연산자는 없음
    • x+++y는 기존 연산자의 조합으로 해석되며 (x++) + y와 동등함
    • --*--p도 새 연산자가 아니라 기존 연산자의 조합임
    • --*--p--(*(--p))와 동등하며, 예시에서는 -1로 평가되고 부작용으로 x[0]-1을 대입함
  • 산술 피연산자의 평가 순서

    • 연산자 우선순위는 잘 정의되어 있지만, 산술 피연산자의 평가 순서는 정의되어 있지 않음
    • (x=1) + (x=2)는 두 대입의 순서가 정의되지 않아 x의 최종값이 1인지 2인지 정해지지 않으므로 정의되지 않은 동작임
    • -std=c11 -O2 옵션에서 GCC 8.2.1은 예시 표현식을 4로, Clang 7.0.0은 3으로 평가함
  • 논리 연산자의 평가 순서

    • 논리 연산자 &&||에서는 피연산자의 평가 순서도 잘 정의됨
    • C 표준 표현으로는 첫 번째 피연산자 평가와 두 번째 피연산자 평가 사이에 sequence point가 존재함
    • 예시에서는 먼저 x=1이 평가되어 true가 되고, 이어서 x=2가 평가되어 역시 true가 되므로 전체 표현식은 true가 됨
  • switch의 자유로운 본문 구조

    • switch 문 본문은 임의의 statement가 될 수 있어, loop와 if가 섞인 구조도 합법일 수 있음
    • 제어 표현식이 항상 falseif 문 안쪽의 true branch라도 case label이 있으면 해당 문장은 live가 되며 printf("1");은 dead code가 아님
    • case 2로 점프하면 loop의 clause-1과 제어 표현식이 실행되지 않을 수 있으므로, 변수 i는 미리 초기화되어 있어야 함
    • case 1break가 없어 fall through가 일어나더라도, case 1iftrue branch에 있고 case 2false branch에 있으면 case 2를 건너뛰고 case 3으로 계속될 수 있음
    • 세 번의 호출 foo(0); foo(1); foo(2); 뒤 콘솔 출력은 02313223이 됨
    • loop와 switch를 섞은 유명한 실제 예시는 Duff's device

임시 객체 수명과 C 표준 버전 차이

  • 특정 코드 조각은 C11에서는 정의되지 않은 동작이지만, C99에서는 그렇지 않을 수 있음
  • C11에서는 특정 객체의 수명이 줄어들어, 함수 호출이 반환한 객체가 오른쪽 항이 평가되는 동안까지만 살아 있음
  • C99에서는 같은 객체가 enclosing block 끝까지 살아 있음
  • 수명이 끝난 객체를 참조하면 C11 § 6.2.4 ¶ 2에 따라 정의되지 않은 동작
  • C99에서도 automatic storage duration 객체의 수명은 가장 가까운 enclosing block에 묶이므로, 해당 블록 밖에서 객체를 참조하면 정의되지 않은 동작임
  • C11 § 6.2.4 ¶ 8은 구조체 또는 union 타입의 non-lvalue expression이 array member를 포함하면 automatic storage duration과 temporary lifetime을 가진 객체를 참조한다고 규정함
  • 이 임시 객체의 수명은 표현식이 평가될 때 시작되고, 포함하는 full expression 또는 full declarator 평가가 끝날 때 종료됨
  • temporary lifetime을 가진 객체를 수정하려는 시도는 정의되지 않은 동작임
  • 해당 예시는 N1285에서 가져온 것이며, 추가 논의도 거기에 있음

댓글과 토론

Lobste.rs 의견들
  • 문항 4는 C23에서는 유효하지 않지만, 그전에는 유효했음
    문항 10은 정답도 오답도 아니라서 객관식이라고 하기엔 좀 거슬림
    문항 15는 특히 문항 13과 관련해서 기술적으로 틀렸고, 문항 20은 “명시되지 않음”이라 역시 어느 답도 아님
    문항 30은 읽기에 따라 애매함
    그래도 31개 중 27개를 맞혔고, 컴파일러 개발자라는 점이 조금은 도움이 됨

  • 네 문제쯤 풀고 나니, C가 단순해서 사이드 프로젝트에 써볼 만하다는 남아 있던 감각이 사라짐

    • GCC나 clang에서는 -std=<language-standard> -pedantic -Wall -Wextra를 쓰고 경고가 나올 때마다 실제로 고치며, 포인터 캐스팅과 포인터 조작을 최대한 피하면 큰 함정은 없을 것 같음
      요즘 GCC/clang 경고는 꽤 좋고, <language-standard>는 c89, c99, c11, c23을 쓸 수 있음
    • C는 단순하지만 정의되지 않은 동작을 둘러싼 곡예는 단순하지 않음
      tcc 같은, 이상한 최적화를 하지 않는 컴파일러를 쓰면 기묘한 놀라움은 덜 겪게 됨
  • 그냥 “여기서 가장 말도 안 되는 동작이 뭐일까?” 기준으로 골라서 32개 중 21개를 맞힘
    틀린 것 대부분은 그 말도 안 됨의 수준을 충분히 깊게 생각하지 않아서였음
    C는 15년 넘게 전에 조금 건드려본 정도인데, 이런 퀴즈를 보니 다시 해보고 싶어지지는 않음

    • 참고로 ChatGPT는 각 답 뒤에 나오는 추가 설명을 보지 않은 상태에서 32개 중 22개를 맞혔음
  • C23 기준으로는 문항 4의 답이 유효하지 않음

  • 흥미롭게도 한동안 C를 쓰지 않았는데 32개 중 27개를 맞힘
    이런 것 때문에 정적 검사기와 린터에 의존해 왔음

  • 문항 1부터 이미 찝찝했음
    그 포인터들이 어디서 올 수 있는지는 고려하지 않았고, 거기서 말한 경우가 성립하려면 매우 특수한 조건이 필요함
    대부분의 경우에는 포인터를 만들려고 하는 것 자체가 정의되지 않은 동작이지만, 그래도 공정하다고 볼 수는 있겠음
    문항 3은 정말 놀라웠고, 또 하나의 C 함정이었음
    애초에 C 정수 리터럴에 확정 타입이 있다는 게 굉장히 짜증남
    정수 승격 규칙이 어느 정도는 맞춰주지만 오류의 근원이기도 함
    현대 언어는 대부분, 혹은 모든 암시적 숫자 캐스팅을 금지하고, 가능하면 문맥에서 리터럴 타입을 추론하며, 불가능하면 명시적 캐스팅을 요구해야 함
    문항 6 이후로는 테스트를 믿지 못해서 포기했음
    처음에는 문항 5의 답이 사실상 문항 6을 틀리게 만들도록 설계돼 있었기 때문이었지만, 다시 보니 문항 6 자체가 틀린 듯함
    해설은 함수 호출이 정의되지 않은 동작이라고 하지만, 문제는 함수 정의가 합법인지 물었고, 아마 합법이었을 가능성이 큼

    • 메모리에서 두 배열이 인접해 있고, 하나의 첫 원소와 다른 하나의 마지막 원소 바로 다음을 가리키면 그런 상황이 됨
      그리고 그게 아주 드문 경우는 아닌 것 같음
  • switch() 문제는 정말 좋았음
    까다롭지만 머릿속에서 풀어내는 과정이 아주 재미있었음