C 프로그래밍 언어 퀴즈
5 days ago
8
C 언어 규칙 은 포인터 비교, 별칭, 널 포인터, 초기화되지 않은 값처럼 단순해 보이는 코드도 정의되지 않은 동작으로 만들 수 있음
정수 상수와 sizeof, 문자 상수, uint8_t 산술은 타입 선택과 정수 승격 때문에 플랫폼·표기·중간 대입 위치에 따라 결과가 달라질 수 있음
함수 선언의 foo()와 foo(void), 프로토타입 부재, 기본 인자 승격, 반환값 없는 함수는 C와 C++ 에서 합법성이나 동작이 다르게 갈림
배열은 포인터가 아니며, 배열 매개변수는 포인터로 조정되고, a, &a, &a[0]는 같은 주소라도 타입이 달라 교환해 쓸 수 없음
연산자 우선순위와 평가 순서는 별개이며, switch 본문 구조와 임시 객체 수명까지 포함해 표준 문구 가 실제 실행 결과를 좌우함
정의되지 않은 동작과 포인터 규칙
포인터 비교와 엄격한 별칭 규칙
같은 타입의 포인터 p와 q가 같은 주소를 가리켜도, 서로 다른 객체에서 유래했고 같은 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은 널 포인터로 평가됨
표현식 e가 0으로 평가된다고 해서 (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 char와 memcpy
unsigned char 타입은 C11 § 6.2.6.1 ¶ 3에 따라 trap representation이 없으므로 초기값은 unspecified임
StackOverflow의 C 위원회 멤버 답변 은 표준 라이브러리 함수 memcpy 호출 뒤 x의 값이 specified가 되어야 하며, 이 해석에서는 x != x가 false가 된다고 봄
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 에서는 int와 long이 32비트로 레지스터 하나에 전달되고, long long은 64비트로 레지스터 두 개에 전달됨
int가 32비트인 플랫폼에서는 -1 < 0x8000이 true가 되고, 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가 읽기 전에 초기화되어 있어도, 정수 승격과 중간 대입 위치 때문에 x와 z의 값이 다를 수 있음
각 변수 값은 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는 정의된 동작임
모듈로 연산은 덧셈에 대해 분배되므로 나눗셈을 덧셈으로 바꾸면 x와 z는 항상 같음
첫 대입을 uint8_t x = ((uint8_t)(a + b)) / c;로 바꿔도 x와 z는 항상 같아짐
const 변수와 variable length array
const로 한정된 변수 n과 m을 배열 크기로 사용해도, 이들은 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 가 적용되어 float는 double로 승격됨
승격 후의 함수 타입이 실제 함수 정의의 타입과 호환되지 않으면 선언과 정의의 조합은 유효하지 않음
선언이 없는 함수 호출은 C에서는 암시적 함수가 허용되어 컴파일될 수 있지만, C++에서는 컴파일 오류임
선언 없이 bar(42) 같은 호출을 하면 정수 인자 승격이 적용되어 42는 int로 표현되므로, bar가 어떤 반환 타입 T에 대해 T (*)(int)와 호환되지 않으면 정의되지 않은 동작이 됨
값을 반환하지 않는 value-returning 함수
반환 타입이 int인 함수가 값을 반환하지 않아도, C에서는 호출 결과 값을 사용하지 않는 한 합법일 수 있음
K&R C에는 void 타입이 없었고 타입을 생략하면 기본 타입 int가 가정되었기 때문에, 값을 반환하지 않는 함수와 암시적 int 규칙이 역사적으로 연결되어 있음
암시적 int 규칙은 C99에서 폐지되었으며, 관련 논의는 N661 과 C99 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
함수 선언에서 매개변수 x가 const로 한정되고 함수 정의에서는 그렇지 않으며 함수 본문에서 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에는 함수가 반환하는 타입이 T의 unqualified version 이라고 명시되었고, 이 문구는 C17에서 추가됨
반환 타입의 const 한정이 달라도 함수 타입 대입이 합법이 될 수 있음
추가 논의는 DR423 과 DR481 에 있음
불완전 구조체와 전역 변수
전역 변수 선언 시점에 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를 가리키는 포인터”로 조정됨
매개변수 x가 int[42]처럼 보여도 실제로는 int *로 취급됨
지역 변수 y가 int[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가 섞인 구조도 합법일 수 있음
제어 표현식이 항상 false인 if 문 안쪽의 true branch라도 case label이 있으면 해당 문장은 live가 되며 printf("1");은 dead code가 아님
case 2로 점프하면 loop의 clause-1과 제어 표현식이 실행되지 않을 수 있으므로, 변수 i는 미리 초기화되어 있어야 함
case 1에 break가 없어 fall through가 일어나더라도, case 1이 if의 true branch에 있고 case 2가 false 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 에서 가져온 것이며, 추가 논의도 거기에 있음
Homepage
개발자
C 프로그래밍 언어 퀴즈
🔉 볼륨 줄이기
🔊 볼륨 키우기
🔇 음소거
⏭️ 다음 곡