C 프로그래밍 언어 퀴즈
(stefansf.de)- C 언어 규칙은 포인터 비교, 별칭, 널 포인터, 초기화되지 않은 값처럼 단순해 보이는 코드도 정의되지 않은 동작으로 만들 수 있음
- 정수 상수와
sizeof, 문자 상수,uint8_t산술은 타입 선택과 정수 승격 때문에 플랫폼·표기·중간 대입 위치에 따라 결과가 달라질 수 있음 - 함수 선언의
foo()와foo(void), 프로토타입 부재, 기본 인자 승격, 반환값 없는 함수는 C와 C++ 에서 합법성이나 동작이 다르게 갈림 - 배열은 포인터가 아니며, 배열 매개변수는 포인터로 조정되고,
a,&a,&a[0]는 같은 주소라도 타입이 달라 교환해 쓸 수 없음 - 연산자 우선순위와 평가 순서는 별개이며,
switch본문 구조와 임시 객체 수명까지 포함해 표준 문구가 실제 실행 결과를 좌우함
정의되지 않은 동작과 포인터 규칙
-
포인터 비교와 엄격한 별칭 규칙
- 같은 타입의 포인터
p와q가 같은 주소를 가리켜도, 서로 다른 객체에서 유래했고 같은 aggregate 또는 union 객체의 일부가 아니라면p == q비교는 정의되지 않은 동작이 될 수 있음 - 포인터가 단순한 숫자 주소보다 더 추상적이라는 점은 관련 글에서 이어짐
int객체를shortlvalue로 접근하면 strict aliasing 규칙에 따라 정의되지 않은 동작이 됨unsigned char포인터는 예외적으로 어떤 객체든 별칭(alias)할 수 있어,int객체를unsigned charlvalue로 접근하는 것은 합법임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와memcpyunsigned 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) > -1sizeof연산자는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타입 객체의 정수 표현과 같음
- C에서 문자 상수는 C11 § 6.4.4.4 ¶ 10에 따라
-
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 arrayconst로 한정된 변수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처럼 보임
- 내부 linkage를 가진
-
포인터-
const변환T가 파생 객체 타입일 때cp대입은 합법이지만,cpp대입이 합법인지에는 짧은 답이 없음- 이 주제는 implicit pointer to const conversion 관련 글에서 다룸
배열, 문자열 리터럴, 포인터 조정
-
배열은 포인터가 아님
- 배열 초기화와 포인터 초기화는 동등하지 않음
- 첫 번째 형태는 자동 또는 정적 저장 기간의 수정 가능한 배열을 초기화함
- 두 번째 형태는 정적 저장 기간을 가진 배열을 가리키는 포인터를 초기화하며, 그 배열은 반드시 수정 가능하지 않음
- 배열은 포인터가 아니며, 자세한 내용은 관련 글에서 다룸
-
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을 대입함
- C에서는 C++처럼 새 연산자를 정의할 수 없으므로
-
산술 피연산자의 평가 순서
- 연산자 우선순위는 잘 정의되어 있지만, 산술 피연산자의 평가 순서는 정의되어 있지 않음
(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문 안쪽의truebranch라도caselabel이 있으면 해당 문장은 live가 되며printf("1");은 dead code가 아님 case 2로 점프하면 loop의 clause-1과 제어 표현식이 실행되지 않을 수 있으므로, 변수i는 미리 초기화되어 있어야 함case 1에break가 없어 fall through가 일어나더라도,case 1이if의truebranch에 있고case 2가falsebranch에 있으면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 같은, 이상한 최적화를 하지 않는 컴파일러를 쓰면 기묘한 놀라움은 덜 겪게 됨
- GCC나
-
그냥 “여기서 가장 말도 안 되는 동작이 뭐일까?” 기준으로 골라서 32개 중 21개를 맞힘
틀린 것 대부분은 그 말도 안 됨의 수준을 충분히 깊게 생각하지 않아서였음
C는 15년 넘게 전에 조금 건드려본 정도인데, 이런 퀴즈를 보니 다시 해보고 싶어지지는 않음- 참고로 ChatGPT는 각 답 뒤에 나오는 추가 설명을 보지 않은 상태에서 32개 중 22개를 맞혔음
-
C23 기준으로는 문항 4의 답이 유효하지 않음
-
흥미롭게도 한동안 C를 쓰지 않았는데 32개 중 27개를 맞힘
이런 것 때문에 정적 검사기와 린터에 의존해 왔음 -
문항 1부터 이미 찝찝했음
그 포인터들이 어디서 올 수 있는지는 고려하지 않았고, 거기서 말한 경우가 성립하려면 매우 특수한 조건이 필요함
대부분의 경우에는 포인터를 만들려고 하는 것 자체가 정의되지 않은 동작이지만, 그래도 공정하다고 볼 수는 있겠음
문항 3은 정말 놀라웠고, 또 하나의 C 함정이었음
애초에 C 정수 리터럴에 확정 타입이 있다는 게 굉장히 짜증남
정수 승격 규칙이 어느 정도는 맞춰주지만 오류의 근원이기도 함
현대 언어는 대부분, 혹은 모든 암시적 숫자 캐스팅을 금지하고, 가능하면 문맥에서 리터럴 타입을 추론하며, 불가능하면 명시적 캐스팅을 요구해야 함
문항 6 이후로는 테스트를 믿지 못해서 포기했음
처음에는 문항 5의 답이 사실상 문항 6을 틀리게 만들도록 설계돼 있었기 때문이었지만, 다시 보니 문항 6 자체가 틀린 듯함
해설은 함수 호출이 정의되지 않은 동작이라고 하지만, 문제는 함수 정의가 합법인지 물었고, 아마 합법이었을 가능성이 큼- 메모리에서 두 배열이 인접해 있고, 하나의 첫 원소와 다른 하나의 마지막 원소 바로 다음을 가리키면 그런 상황이 됨
그리고 그게 아주 드문 경우는 아닌 것 같음
- 메모리에서 두 배열이 인접해 있고, 하나의 첫 원소와 다른 하나의 마지막 원소 바로 다음을 가리키면 그런 상황이 됨
-
switch()문제는 정말 좋았음
까다롭지만 머릿속에서 풀어내는 과정이 아주 재미있었음