잇창명 개발 블로그

C 타입 시스템 제대로 알고 가기

이 글은 단축링크 https://eatch.dev/s/ctype으로도 들어올 수 있습니다.

C 코딩을 할 때, 다음 중 가장 "올바른" 코딩 스타일은 무엇일까요?

물론 정답은 없습니다. 코딩 스타일이 원래 스페이스냐 탭이냐로 싸우는 주제잖아요. 스페이스 3칸 너비의 탭(???)이나 int*x;처럼 누가 봐도 오답인 것이 있긴 합니다.

저라면 이 질문에 무조건 int *x;라고 답할 것입니다. 물론 근거 없이 int *x;라고 우기는 것은 아닙니다. 그동안 C 타입 시스템에 관심이 있어서 cppreference 문서를 이것저것 읽어봤는데, 읽으면 읽을수록 int* x;가 아니라 int *x;가 맞다는 확신이 들더라고요. 추가로 그동안 될 거라고 생각조차 못 했던 문법도 여러 가지 알게 되었습니다.

네. 이 글은 C 타입 시스템에 대해 정리하는 글입니다. C의 타입 시스템을 여러 부분으로 나누어 하나씩 있는 그대로 설명하고, 주의할 점이 있으면 같이 적으려고 합니다.

위에서 언급한 int *x; 이외에도 C 타입 시스템을 더 잘 이해한다면...

  1. int *(*(*x)(char *))[64]; 같은 헷갈리는 선언을 그나마 쉽게 읽을 수 있습니다.
    • 근데 웬만하면 이런 식으로 선언하지 말아주세요.
  2. 배열과 포인터가 정확히 어떻게 다른지를 이해할 수 있습니다.
  3. 단 한 번의 malloc 호출로 다차원 배열을 동적 할당받을 수 있습니다.
  4. typedef가 조금 더 직관적으로 다가올 수도 있습니다.
  5. const int *xint const *xint * const x의 차이를 이해할 수 있습니다.

오호! 3번은 금시초문, 나머지는 헷갈리는 것들이군요. 여기서 뿌린 떡밥은 글이 끝나기 전까지 전부 회수할 예정이니 걱정 말아주세요. 이 글을 읽으면서 C 타입 시스템은 왜 그렇게 복잡한지 조금이라도 이해할 수 있게 된다면 좋겠습니다.

본격적으로 시작하기 전에 참고해주셨으면 하는 점이 있다면...

피드백은 글 맨 아래에 있는 댓글창으로 부탁드립니다.

C 표준 확인하고 가세요

본격적으로 시작하기 전에 잠깐 삼천포로 빠지겠습니다. C 표준은 그동안 여러 번의 개정을 거쳤습니다.

C 표준이 있기 전에는 The C Programming Language(hello, world를 대중화시킨 그 책이 맞습니다)가 사실상의 표준 역할을 했습니다. 이 시절의 C "표준"을 저자 이름의 앞글자를 따서 K&R C라고 합니다. 이후 1989년에 최초로 C89라는 표준이 생겼고, 이후 C95, C99, C11, C17까지 4번의 개정을 거쳤습니다. C17은 기능 추가 없이 결함만 수정했습니다.

이런 내용을 알아야 하는 이유는... 개정판에 따라 쓸 수 있는 문법과 없는 문법이 나뉘기 때문입니다. 예를 들어서 C99 이전까지는 for문 안에 선언을 할 수 없었습니다.

for(int i = 0; i < 8; i++) // 'for' loop initial declarations are only allowed in C99 or C11 mode
	printf("%d\n", i);

이 글에서 표준을 인용할 경우 C17 표준 최종안에서 인용한 것임을 밝힙니다. 실제 C17 표준은 ISO에서 유료로 판매하고 있으며, 이전 개정판은 ANSI에서 구매하거나 최종안을 열람할 수 있습니다.

이 글에서는 필요할 때마다 표준 개정판 표기를 넣을 예정입니다. 예를 들어 (C11~)은 C11에 추가된 기능이라는 의미입니다. 표준 문제로 컴파일이 안 된다면 컴파일러에 다음과 같이 플래그를 넣어주세요.

컴파일러를 직접 다루지 않는 IDE 환경이라도 보통 설정에서 컴파일러 플래그나 표준 개정판을 바꿀 수 있으며, 제가 모든 IDE를 써본 게 아니기 때문에 자세한 방법은 구글링을 해 보세요. 아니면 IDE 자체를 업데이트하는 것도 방법입니다(예시2).

선언문

사실 C의 타입 시스템은 선언문과 떼려야 뗄 수 없는 관계입니다. C 프로그래밍을 하면서 타입을 적어넣는 곳이라고 하면 십중팔구 선언문이니 그럴 수밖에 없죠. 그런 의미로 선언문부터 시작해 봅시다.

기본적인 선언문의 구조는 다음과 같습니다(초기화 구문은 생각하지 않습니다).

BaseType declarator;

BaseTypedeclarator의 두 부분으로 나뉘는데, 간단히 말해 BaseType은 선언할 것의 기본 타입, declarator는 선언할 것의 이름을 나타냅니다. declarator는 0개 이상을 콤마로 구분해서 작성할 수 있는데, 구조체 선언이 아닌 이상 진짜로 0개를 선언하면 의미가 없죠.

declarator는 선언할 식별자3 이외에도 *[] 등을 포함합니다. 즉, int *x;라고 썼다면 BaseTypeint, declarator*x입니다. 이제 int *x;가 맞는 이유가 확실해졌습니다.

잠시만요, 그래도 너무 성급한 거 아닌가요? *가 왜 declarator로 들어가는데요? 당연히 이것도 근거가 없는 게 아닙니다. 다음 코드를 생각해 봅시다.

int* x, y;

누구나 한 번쯤 "이렇게 쓰면 xy 모두 포인터겠지??"라고 생각하다가 x만 포인터인 걸 깨닫고 놀랐던 경험이 있을 겁니다. 이런 의외의 동작은 언어 단계에서

로 묶은 결과인데, 이 동작을 납득한다면 *declarator로 들어가는 것에는 더 의문이 없을 것 같습니다. 하지만 declarator에는 더 깊은 의미가 있는데...

declarator에 숨은 철학

declaratorBaseType을 얻기 위해 거치는 연산을 나타냅니다.

아무 IDE(정 어렵다면 ideone이나 replit)을 붙잡고 따라해 보세요. printf 같은 함수에 넣으면 컴파일러가 타입 체킹을 해주니 확인하는 건 어렵지 않을 겁니다.

과연 우연일까요? 딱히 그래보이지는 않네요. 사전 지식 없이 C 타입 시스템을 처음 접하면 헷갈리게 보이는 이유가 바로 이것이었습니다. 이 내용은 cppreference.com에서도 언급하고 있습니다.

이 얘기는 파생 타입 얘기할 때 마저 하겠습니다.

선언문 해부하기

선언문은 이 글에서 언급할 거의 모든 개념을 함축하고 있습니다. 생각보다 많은 것들이 단 하나의 선언문 문법을 따릅니다.

BaseType에는 다음과 같은 것들이 올 수 있으며, 무엇이 들어가느냐에 따라 그 선언의 성질을 바꿀 수 있습니다.

여기 오는 모든 것은 단어 단위로 순서와 상관 없기 때문에 아래에 주어지는 쌍은 모두 같은 타입을 가리킵니다.

가독성을 위해 웬만하면 하지 말아주세요.

declarator는 다음 중 하나를 만족하는 것입니다. 실제로는 조금 더 복잡한데, 글을 쓰면서 하나하나 짚어볼 예정입니다.

낮선 단어를 마구 써내려가면서 설명을 안 했으니 전혀 이해가 안 돼도 잘못된 건 아닙니다. 이제부터는 여기에 나온 개념들을 하나하나 짚어보겠습니다.

타입 지정자

이 글에서는 타입을 크게 기본 타입, 파생 타입, 복합 타입으로 나누며, 이중 파생 타입과 복합 타입은 다른 글에서와 조금 다른 의미로 사용합니다.

원래는 포인터, 배열, 함수, 구조체, 공용체, _Atomic을 모두 파생 타입이라고 합니다.

기본 타입

C에 정의된 (라이브러리 지원이나 매크로를 제외하고) 기본 타입에는 다음과 같은 것들이 있습니다. C 표준에서는 대분류를 다르게 하긴 하지만 여기서는 키워드끼리 조합할 때 가장 간단하게 되도록 묶었습니다.

참고로 표준 문서에서는 특정한 타입들의 집합에 별도로 이름을 붙여 언급합니다. 이 글에서도 아래 분류를 사용하되, 혼동을 피하기 위해 기울임꼴로 작성하겠습니다.

파생 타입과 복합 타입을 이용해 위 타입들을 여러 방법으로 조합할 수도 있습니다.

파생 타입 [derived type]

특정 연산을 거치면 BaseType이 되는 변수를 선언할 수 있으며, 크게 세 가지가 있습니다.

포인터

BaseType *x;

BaseType 타입의 값을 가리키는 주소를 저장합니다. *x 연산을 거치면 BaseType이 됩니다.

배열

BaseType x[n];

BaseType 타입의 값 n개를 메모리상의 연속적인 위치에 저장합니다. x[i] 연산을 거치면 BaseType이 됩니다.

가변 크기 배열 (C99~)

n이 컴파일 타임 상수가 아닐 경우 이를 "가변 크기 배열"(variable-length array, VLA)이라고 하며, VLA의 타입 혹은 여기서 파생되는 타입을 가변 타입5이라고 합니다. 일반 배열과 달리 컴파일 타임에는 크기가 결정되지 않고, 선언문 부분까지 실행될 때에서야 완전히 결정됩니다.

VLA는 선언하면서 초기화를 할 수 없고, 선언한 뒤에 직접 값을 넣어야 합니다.

int x = 5;
int y[x]; // OK: VLA with size x
int z[2*x] = { 0 }; // variable-sized object may not be initialized

모든 컴파일러에서 VLA를 지원하는 것은 아닌데(대표적으로 MSVC가 그렇습니다), 지원하지 않는 컴파일러의 경우 매크로 __STDC_NO_VLA__가 1로 정의되어 있습니다 (C11~).

함수

BaseType x(args...);

일급 객체로 지원되지만 않을 뿐이지, 의외로 함수도 타입으로 볼 수 있습니다. 정해진 타입의 인자를 받아 값을 계산합니다. x(args...) 연산을 거치면 BaseType이 됩니다.

왜 함수 포인터가 없냐고 할 수도 있겠지만, 궁극적으로는 그냥 함수의 포인터이기 때문에 별도 문단으로 작성하지 않았습니다.

가변 인자 함수

함수에 매개변수가 하나 이상 있을 경우, 마지막에 매개변수 대신 ...를 하나 더 달면 가변 인자 함수를 만들 수 있습니다. ... 부분에 추가로 전달된 인자는 #include <stdarg.h>를 하고 va_start, va_arg, va_end로 접근할 수 있습니다.

#include <stdarg.h>

// ...

int sum(int count, ...) {
	int result = 0;
	va_list args;
	va_start(args, count);
	while(count--)
		result += va_arg(args, int);
	va_end(args);
	return result;
}

암시적 타입 변환에서 자세히 살펴보겠지만, ... 부분에 float를 전달하면 double로, 비트 필드를 포함해 부호 유무와 무관하게 int보다 작은 정수 타입을 전달하면 int 혹은 unsigned int로 변환됩니다. printf 함수에서 floatdouble을 구분 없이 %f로 출력하는 것도 이런 이유입니다. float _Complexfloat _Imaginary는 변환되지 않습니다.

파생 타입 조합하기

위에서 확인했듯이 파생 타입도 임의의 순서로 조합할 수 있습니다. 예를 들어...

int *x[10];

이라고 적었을 경우 *x[1]을 하면 int가 됩니다. 즉, xint의 포인터의 배열입니다. 이렇게 적으면 어떤 순서로 읽어야 되는지 정말 헷갈리는데(*이 먼저? []이 먼저?), 웬만한 상황에서 통하는 간단한 규칙이 있습니다.

후위 연산자가 전위 연산자보다 우선순위가 높다.

웬만한 언어들의 연산자 우선순위 표에서 이러한 경향성을 찾아볼 수 있었습니다(경향성입니다. 물론 예외가 있습니다). 잠깐 삼천포로 빠지자면, 예를 들어 C의 연산자 우선순위 중 단항 연산자 부분은 이렇습니다.

  1. (후위 연산자)
    • 후위 증감 ++/--
    • 함수 호출 ()
    • 배열 참조 []
    • 구조체/공용체 멤버 참조 .
    • 포인터를 통한 멤버 참조 ->
  2. (전위 연산자)
    • 전위 증감 ++/--
    • 단항 부호 +/-
    • 논리 NOT !
    • 비트 NOT ~
    • 타입 변환 (Type)
    • 역참조 *
    • 주소 &
    • sizeof, _Alignof

JavaScript에서는 이렇습니다.

  1. (후위 연산자)
    • 멤버 접근 .
    • 계산된 멤버 접근 []
    • 함수 호출 ()
    • 조건부 체이닝6 ?.
    • 인자 목록 있는 new ...() (이것과 new ...는 예외로 치겠습니다)
  2. 인자 목록 없는 new ...
  3. (후위 연산자) 후위 증감 ++/--
  4. (전위 연산자)
    • 논리 NOT !
    • 비트 NOT ~
    • 단항 부호 +/-
    • 전위 증감 ++/--
    • typeof, void, delete, await

정확한 이유는 모르겠지만, 일단 저는 -f()(-f)()으로 해석하면 곤란하다는 논리로 받아들이고 있습니다.

다시 타입 시스템 얘기로 돌아오자면, C의 파생 타입에는 크게 3가지가 있었고 하나는 전위, 나머지는 후위입니다.

괄호가 없으면 후위가 먼저고 전위가 나중이기 때문에, 포인터 연산 직후에 배열/함수 연산을 해야 한다면 괄호가 필요하게 됩니다. 예를 들어 위에서 언급한 int의 포인터의 배열 int *x[10];과 달리 int (*x)[10];int의 배열의 포인터입니다.

같은 논리로 왜 함수 포인터를 선언하려면 괄호가 하나 더 필요한지도 설명할 수 있습니다. int (*x)()int를 반환하는 함수의 포인터인데, 명백하게 포인터 연산이 먼저고 함수 호출이 나중이기 때문에 연산자 우선순위에 따라 괄호가 필요합니다. int *x()라고 썼다면 포인터를 반환하는 함수로 취급되었을 것입니다.

그런데 이런 체계에서는 타입이 복잡해질수록 한눈에 알아보기가 어렵습니다. 바로 아래에 악명높은 C 타입 읽기를 별도로 설명하겠습니다.

C 타입 쉽게 읽는 법

🙋‍♂️ 오래 기다리셨습니다! 이 문단에서 1번 떡밥을 회수합니다.

int (*x[8][64])(char *)를 예로 들어보겠습니다. 아래의 단계들을 하나씩 밟아나가며 하나의 영어 문장을 완성합니다.

  1. 식별자를 찾은 뒤 거기서부터 파생 타입을 읽기 시작한다.
    • "x is a..."
  2. 오른쪽으로 훑으면서 파생 타입이 보일 때마다 차례대로 추가한다. 닫는 괄호가 보이거나 선언이 끝나면 멈춘다.
    • "x is a [8] of [64] of..."
  3. 왼쪽으로 훑으면서 똑같은 작업을 한다. 여는 괄호가 보이거나 선언이 끝나면 멈춘다.
    • "x is a [8] of [64] of * of..."
  4. 지금까지 읽은 선언이 괄호로 감싸져 있을 경우 2번으로 돌아간 뒤 마지막으로 읽은 여닫는 괄호부터 읽는다.
    • "x is a [8] of [64] of * of (char *) of..."
    • 1~4번의 내용은 "우선순위에 따라 읽는다"로 요약할 수 있습니다.
  5. 더 이상 읽을 것이 없으면 BaseType으로 마무리한다.
    • "x is a [8] of [64] of * of (char *) of int"

이 문장을 자연어로 바꾸거나("x is an array of size 8×64 of pointer to function (char *) returning int"), 한국어가 더 익숙하다면 완성된 문장을 거꾸로 읽어서 "int를 반환하는 함수 (char *)의 포인터 8×64개짜리 배열"(😱)로 만들 수 있고, 영어 문장 기준으로 등장하는 순서대로 연산하면(즉, [] 다음에 [] 다음에 * 다음에 ()의 순서대로 벗겨내면) BaseTypeint를 얻을 수 있습니다. 이때 가장 왼쪽에 있는 파생 타입이 가장 먼저 벗겨지므로 편의상 "최외곽 타입"이라 하겠습니다. (국어사전에 없는 단어긴 하지만 한자 뜻은 맞지 않나요? 아닌가?)

이렇게 읽어낸 타입을 도로 C 문법으로 돌려놓는 것도 간단합니다. 왼쪽부터 우선순위에 맞게 달아놓은 뒤([]()는 오른쪽, *는 왼쪽에 추가합니다. * 다음에 []이나 ()가 와야 한다면 괄호를 씌웁니다.) 가장 왼쪽에 BaseType을 적으면 됩니다.

시간이 남는다면 다른 타입에도 연습해보는 게 어떨까요? 아래의 타입을 편한 방식으로 읽어 봅시다. 답은 주석에 있습니다.

  1. float **x[4][8];7
  2. int *(*foo)(int []);8
  3. char **(*(*z)[123])[456];9
  4. int *(*(*x)(char *))[64]; (이 글의 맨 위에서 얘기했던 그 선언문입니다.)10

함수와 매개변수

사실 C의 함수 원형/정의도 위에서 설명한 타입 시스템으로 나타낼 수 있습니다.

char *foo();

문자열을 반환하는 전형적인 함수입니다. 이것도 선언이라고 치고 읽으면(선언이 맞긴 하지만) 다음과 같습니다.

foo is a () of * of char.

즉, "char의 포인터(aka 문자열)를 반환하는 함수"입니다. 또 declarator의 모양대로 *foo()를 하면 char가 됩니다. 위에서 작성했던 내용과 완전히 일치하네요!

아니면, 배열의 포인터를 반환하는 함수도 작성할 수 있을까요? 물론이죠.

int (*bar())[8]; // a () of * of [8] of int

다만 타입 조합을 아무렇게나 할 수는 없고, 제한이 있습니다.

배열과 함수는 포인터로 바뀐다

🙋‍♂️ 이 문단에서 2번 떡밥을 회수합니다.

위에서 살펴보았던 조합 제한을 살펴보면 배열을 포인터로, 함수를 함수 포인터로 바꾸는/바꿔야 하는 동작이 공통적으로 보입니다. C++를 깊게 배우신 분이라면 뭔가 익숙할 것 같네요! 사실 C 표준에 따르면 이런 동작은 일부 예외를 제외하고 표현식을 다루는 모든 상황에서 발생합니다. C17 표준에서 6.3.2.1의 일부분을 한국어로 번역하면 다음과 같습니다.

  • (/3) sizeof 연산자, 단항 & 연산자의 피연산자이거나 문자열 리터럴로서 배열을 초기화할 때를 제외하면 "type의 배열" 타입을 가지는 표현식은 배열 객체의 첫 원소를 가리키는, "type의 포인터" 타입을 가지는 표현식으로 변환되며, 좌측값11이 아니다. 그 배열 객체가 레지스터 기억 영역 분류를 가질 경우의 동작은 정의되지 않는다.

  • (/4) 함수 지시자12란 함수 타입을 가지는 표현식을 말한다. sizeof 연산자나 단항 & 연산자의 피연산자일 때를 제외하면 "type을 반환하는 함수" 타입을 가지는 함수 지시자는 "type을 반환하는 함수의 포인터" 타입을 가지는 표현식으로 변환된다.

위의 두 조항은 결국 "배열과 함수는 일급 객체가 아니다"로 귀결됩니다. 배열과 함수는 선언만 할 수 있고 대입하려고 하면 포인터로 바뀌기 때문에 대입할 수 없습니다. 자연스럽게 복사할 수도 없게 됩니다. 과거에는 배열을 통째로 복사하는 게 느려서 배열 복사를 최대한 지양해야 했고, 함수는 애초에 크기를 알 수 없어 복사도 불가능하니 포인터를 대신 넘기도록 했을 거라는 추측이 가능하겠습니다. 이외에도 포인터, 배열, 함수에 관한 여러 가지 특이한 동작도 이 두 조항으로 인한 것입니다.

"배열과 포인터가 비슷하다"는 이야기가 이것 때문에 나오는 것 같습니다. 위의 이유로 비슷하게 쓸 수 있는 것뿐이지, 배열과 포인터는 전혀 다른 타입입니다.

응용: malloc 한 번으로 다차원 배열 동적 할당하기

🙋‍♂️ 이 문단에서 3번 떡밥을 회수합니다.

2차원 배열 동적 할당은 보통 아래와 같은 방법으로 배웠을 겁니다.

int **array_2d = malloc(n*sizeof(int *));
for(int i = 0; i < n; i++)
	array_2d[i] = malloc(m*sizeof(int));

malloc을 n+1번 호출합니다. jagged array13를 의도한 것이라면 저 방법이 맞겠지만, 그렇지 않으면 꽤 비효율적인 코드입니다. 나중에 다 쓰고 반환할 때도 free를 n+1번 호출해야 하고, n+1번 malloc받은 공간이 연속이라는 보장도 없습니다.

그렇다고 int **array_2d에 무턱대고 2차원 배열 전체를 malloc할 수도 없습니다(배열의 포인터와 포인터의 포인터는 다릅니다). 제가 과거에 함수에서 2차원 배열을 int **로 받으려다가 포기하고 void *로 바꿔서 전달했던 적이 있었습니다.

지금까지 배운 타입 시스템을 잘 활용하면 대신 이런 코드를 짤 수 있습니다.

int (*array_2d)[m] = malloc(n*sizeof(int [m]));

우리가 궁극적으로 만들고자 하는 배열은 int array_2d[n][m];처럼 동작해야 합니다. "a [n] of [m] of int"인데, 동적 할당을 해야 하니 [n]은 쓸 수 없습니다.

다행히 malloc은 연속된 공간을 할당해주기 때문에 배열과 다름없이 쓸 수 있으니 최외곽에 [n] 대신 *을 써서(6.3.2.1/3에 의해 항상 이루어지던 변환입니다!) "a * of [m] of int", 즉 int (*array_2d)[m];으로 대신 선언할 수 있습니다. 나머지는 malloc에 맡깁시다.

어차피 int [n][m]int [m] n개나 int n×m개를 연속으로 이어붙인 것이나 다름없으므로 malloc에 전달할 사이즈는 sizeof(int [n][m]), n*sizeof(int [m]), n*sizeof *array_2d, n*m*sizeof(int), n*m*sizeof **array_2d 중 뭐든 상관없습니다.

물론 3차원 이상의 배열도 이렇게 할당할 수 있습니다. 쓸 일이 자주 있을지는 모르겠네요.

int (*array_3d)[b][c] = malloc(a*sizeof(int [b][c]));
int (*array_4d)[b][c][d] = malloc(a*sizeof(int [b][c][d]));
// ...

응용: 사실 C++도 대체로 같습니다

이 문단은 2021년 10월 11일에 추가로 작성했습니다.

개인적으로 엄청나게 복잡한 C++를 싫어하지만, 위의 타입 조합에 관한 내용은 C++에도 대체로 비슷하게 적용됩니다. 자세히 얘기하자면 다음과 같습니다.

이외에 C++ 타입 시스템에는 ::이나 <>이나 ... 같은 게 잔뜩 끼얹어져서 C 타입 시스템과는 거리가 어느 정도 멀어진 감이 있습니다.

새로운 BaseType 만들기

지금까지 BaseType에 기본 타입이 오는 예시만을 들었었는데, 유저가 직접 새로운 BaseType을 정의할 수 있습니다.

typedef

🙋‍♂️ 이 문단에서 4번 떡밥을 회수합니다.

typedef 선언의 기본 형태는 다음과 같습니다.

typedef BaseType declarator;

그냥 선언에 typedef만 붙이면 typedef 선언이 되며, 그 타입을 갖는 변수가 아니라 그 타입과 의미가 같은 새로운 타입을 선언합니다. 예를 들어 typedef int *Foo[8];을 하면 타입 Foo는 타입 이름 int *[8]과 같은 의미가, 변수 선언 Foo *x;int *(*x)[8];과 같은 의미가 됩니다.

typedef 선언도 한꺼번에 여러 개를 선언할 수 있는데, 이를 잘 활용하면 아래와 같이 연결 리스트 타입을 선언하면서 그 타입에 대한 포인터도 한꺼번에 선언할 수 있습니다.

typedef struct List {
    int value;
    struct List *next;
} ListNode, *ListPtr;

typedef로 함수 타입도 선언할 수 있고, 타입 체킹도 올바르게 됩니다. 이런 형태는 함수 원형에만 쓸 수 있고, 정의할 때는 괄호가 필요하기 때문에 불가능합니다.

typedef int IntFn(int);

IntFn foo;

int foo(int x) { // OK
   return x;
}
int foo(long x) { // conflicting types for 'foo'
   return x;
}
IntFn foo { // expected ';' after top level declarator
    return 0;
}

typedef도 일반적인 선언과 같이 블록 범위를 가질 수 있습니다. 안쪽에 있는 typedef가 바깥쪽의 typedef를 가립니다.

// sizeof(int) = 4라고 가정합니다.

typedef int foo; // (1); sizeof(foo) = 4

int main() {
    typedef int foo[8]; // (2); sizeof(foo) = 32
    printf("%ld\n", sizeof(foo)); // 32; bound to (2)
    {
        typedef int foo[64]; // (3); sizeof(foo) = 256
        printf("%ld\n", sizeof(foo)); // 256; bound to (3)
    }
    printf("%ld\n", sizeof(foo)); // 32; bound to (2)
    return 0;
}

재미있는 사실! 의외로 typedef는 이론상 기억 영역 분류 지정자입니다. 물론 typedef는 기억 영역 분류와 관련해 어떤 동작도 하지 않습니다. 위에서 "BaseType은 기억 영역 분류 지정자를 포함하며, 순서는 상관 없다"고 했던 걸 생각하면 의외로 이렇게도 할 수 있습니다.

int typedef long foo; // = typedef long foo;

이 글을 쓰면서 벌써 여러 번 하는 얘기지만... 하지 말아주세요.

복합 타입

복합 타입 선언은 크게 이름 부분과 정의 부분으로 나뉘고, 포함하느냐 마느냐에 따라 서로 다른 의미를 갖습니다. 아래 세 종류의 구문 모두 BaseType으로 취급되기 때문에 변수 선언까지 할 수 있습니다.

일단 선언하고 나면 다음과 같은 형태로 쓸 수 있습니다. 실제로 변수 선언에 사용하려면 이전 위치에서 정의를 끝마친 상태여야 합니다.

실제 이름 앞에 struct/union/enum 부분이 공통적으로 들어가는데, 이 부분을 떼고 Foo로만 쓰려면 명시적으로 typedef를 해야 합니다. 또한 이름만 같은 struct/union/enum 타입을 동시에 정의할 수는 없습니다.

struct Foo {}; // OK, defines struct Foo
typedef struct Foo Foo; // OK, Foo = struct Foo
union Foo {}; // use of 'Foo' with tag type that does not match previous declaration

복합 타입도 일반적인 선언이나 typedef와 같이 블록 범위를 가질 수 있습니다. 안쪽에 있는 타입이 바깥쪽의 타입을 가립니다.

enum Foo { a, b, c };

int main() {
    enum Foo { d, e, f };
    enum Foo foo = e; // OK, second variant of inner 'enum Foo'
    foo = a; // implicit conversion from enumeration type 'enum Foo' to different enumeration type 'enum Foo'
    return 0;
}

구조체

구조체는 여러 개의 멤버를 하나의 타입으로 묶은 자료구조입니다. 모든 멤버는 각각 고유한 값을 가질 수 있습니다.

빈 구조체(엄밀히는 이름이 붙은 멤버가 없는 구조체)를 정의하려고 하면 정의되지 않은 동작이 되며, 공용체에 대해서도 같습니다. GCC 등 일부 컴파일러에서는 언어 확장으로 빈 구조체를 지원하고 있습니다.

struct EmptyStruct {}; // UB
비트 필드

구조체 내부에서는 비트 필드 기능을 이용해 변수를 비트 단위로 끊어서 사용하는 것이 가능합니다. 비트 필드에 사용할 수 있는 타입은 다음과 같습니다.

멤버 변수 옆에 : (비트 수)를 추가함으로써 그만큼의 비트만 사용하는 멤버를 선언할 수 있습니다. 같은 선언문에서와 선언했는지와 무관하게 비트 필드 변수가 연속될 경우 메모리에서 연속된 비트를 사용합니다. 비트 수는 기반으로 하는 타입의 비트 수를 초과할 수 없습니다.

// int가 32비트임을 가정합니다.

struct Foo {
	unsigned int
		a: 7, // 7 bits; 0...127
		b: 3; // 3 bits; 0...7
	// 22 bits unused
	unsigned int c: 33; // width of bit-field 'c' (33 bits) exceeds width of its type (32 bits)
};

변수명을 생략하면 원하는 수의 비트를 "낭비할" 수 있습니다.

struct Foo {
	unsigned int
		: 7, // 7 bits unused
		b: 3; // 3 bits; 0...7
	// 22 bits unused
};

0비트짜리 비트 필드를 선언하면 그 다음 비트 필드 변수는 연속된 비트를 사용하지 않고 강제로 새로운 비트를 사용합니다. 이때는 변수명을 반드시 생략해야 합니다.

struct Foo {
	unsigned int
		x: 4, // 4 bits; 0...15
		: 0, // 28 bits unused; continue to next unsigned int
		y: 4; // 4 bits; 0...15
	// 28 bits unused
};
유연 배열 멤버 [flexible array member] (C99~)

구조체에 멤버가 하나 이상 있을 경우, 마지막에 불완전한(즉, 크기를 생략한) 타입의 배열 멤버를 하나 더 선언할 수 있습니다. 이때 구조체의 크기는 마지막 배열을 포함하지 않으며, 그 크기 이상의 메모리를 할당할 경우 남은 공간은 마지막 배열 멤버를 통해 접근할 수 있습니다.

struct Foo {
	int x, y[];
};

// struct Foo, but y behaves like int [4]
struct Foo *foo = malloc(sizeof(struct Foo) + 4*sizeof(int));
foo->y[3] = 123;
익명 구조체/공용체 멤버 (C11~)

구조체/공용체 안에 익명 구조체/공용체 멤버를 선언할 때는 멤버의 이름을 생략할 수 있습니다. 이때는 안쪽 멤버를 바깥쪽 멤버인 것처럼 사용할 수 있습니다. 중첩도 가능합니다.

struct Foo {
	struct {
		int a;
		int b;
	}; // anonymous struct
	int c;
};

struct Foo foo;
foo.a = 1; // foo.(anonymous).a
foo.b = 2; // foo.(anonymous).b
foo.c = 3; // foo.c

공용체

구조체와 같이 여러 개의 멤버를 선언할 수 있지만, 모든 멤버가 같은 메모리를 공유하며 한 멤버에 대입하면 다른 멤버의 값을 덮어씁니다.

union Foo {
	char x, y[16];
};

union Foo foo;
strcpy(foo.y, "foo");
// foo.x is now 'f'

열거형

구조체와 공용체와는 조금 다른데, 여러 가지 원소를 정의하고 그 중에 하나를 사용할 수 있도록 합니다. 내부적으로는 그 열거형의 모든 값을 표현할 수 있는 정수 타입 중 컴파일러가 정하는(implementation-defined) 타입처럼 동작합니다.

enum Foo {
	// 원소 1개 이상
	Bar,
	Baz,
	Quux
};

enum Foo foo = Bar;
foo = 5; // OK

열거형의 첫 원소의 값은 0이며, 따로 정하지 않을 경우 (이전 원소의 값) + 1의 값을 가집니다. = (값)을 추가해서 강제로 다른 값으로 설정할 수 있습니다.

enum Foo {
	A, // 0
	B, // 1
	C = 123, // 123
	D // 124
};

열거형의 모든 원소 역시 같은 범위 내에서 같은 이름으로 사용할 수 있으므로, 같은 범위 내의 변수명이나 다른 열거형 원소와 중복될 수 없습니다.

{
	int x; // OK
	enum { x }; // redefinition of 'x'
}
{
	enum { x }; // OK, introduces x
	enum { x }; // redefinition of enumerator 'x'
}

빈 열거형은 만들 수 없습니다. 위의 빈 구조체/공용체와 달리 GCC에서도 언어 확장으로 지원하지 않습니다.

enum Foo {}; // use of empty enum

불완전 타입 [incomplete type]

변수를 메모리에 할당하려면 타입의 크기를 알아야 하는데, 충분한 정보가 없어 크기를 결정할 수 없는 경우도 있습니다. 이런 상태의 타입을 불완전 타입이라고 합니다. 불완전 타입은 선언이나 타입 변환 등 대부분의 타입을 요구하는 곳에 사용할 수 없습니다.

불완전 타입은 다음과 같이 크게 세 종류로 나뉩니다. 불완전 타입을 완전한 타입으로 만드는 것을 "완성한다"고 하겠습니다.

타입의 완전성에 따라 파생 여부 역시 나뉩니다.

타입 이름 [type name]

지금까지 C 타입을 '선언 형태'로만 작성했는데, 가끔씩 식별자가 없는 다른 형태로 타입을 작성해야 할 필요가 있습니다.

int *x;
return (void *)y;

2번째 줄과 같은 모양을 타입 이름이라 하는데, 선언 형태에서 식별자만 지우면 됩니다! 예를 들어 int (*x[8])(void);의 타입 이름은 int (*[8])(void)가 됩니다.

단 이 방법이 먹히지 않을 때가 있는데, 식별자가 단독으로 괄호로 둘러싸여 있었다면(int (foo)(void);) 식별자를 지우고 나서는 함수 괄호로 취급됩니다(int ()(void), aka () of (void) of int). 식별자가 없어지면서 빈 괄호에 다른 의미가 부여된 건데, 이런 현상을 막기 위해서 불필요한 괄호는 지우는 것을 권장드립니다.

이미 예상하셨겠지만, 저는 이런 이유로 sizeof(int*)조차도 sizeof(int *)가 맞다고 봅니다. 물론 sizeof(int )가 맞다고 우기기까지 하지는 않습니다.

범위 [scope]

원래는 글에 포함하지 않으려고 했는데, 이곳저곳에서 언급하니까 한 번쯤 짚고 넘어가야 할 것 같네요. 시작하자마자 이 얘기부터 하면 읽다가 튕겨져나가는 분들이 많을 것 같아 중간에 끼워넣었습니다.

C에서 모든 식별자는 범위를 가지며, 그 범위 안에 있는 코드만 그 식별자를 사용할 수 있습니다. C에는 크게 두(+2) 가지의 범위가 있습니다. typedef와 복합 타입 선언을 포함해 모든 선언은 아래의 범위 규칙을 따릅니다.

블록 범위

블록({ ... }, 매개변수와 함수 본문을 포함합니다) 안에 선언한 식별자는 선언이 끝나는 곳부터(초기화가 될 경우에는 초기화 표현식 바로 앞부터) 그 블록이 끝날 때까지 사용할 수 있습니다. 안쪽 블록과 바깥쪽 블록에서 같은 식별자를 선언한 경우 안쪽이 바깥쪽을 가립니다.

int x = 5;
{
	int x = 7;
	printf("%d", x); // 7
} // scope of the latter x ends here
printf("%d", x); // 5

if문, switch문, for문, while문, do-while문도 별도의 블록 범위를 생성하며, 본문의 블록 범위를 공유하지 않습니다. (C99~) 매개변수의 범위는 함수 본문의 블록 범위를 공유합니다.

for(int i = /* scope of i begins here */ 0; i < 10; i++) {
	foo(i); // OK
} // scope of i ends here
foo(i); // use of undeclared identifier 'i'

열거형 원소도 열거형의 범위를 따르지만, 다른 선언문과는 달리 범위가 = 식 뒤부터 시작됩니다. 즉, 이전에 선언했던 같은 이름의 열거형 원소를 참조할 수 있습니다.

enum { x = 5 };
{
	enum {
		x = x + 1 /* scope of the latter x begins here */, // 6
		y // 7
	};
}

파일 범위

그렇지 않은 식별자는 선언이 끝나는 곳부터 파일이 끝날 때까지 사용할 수 있습니다. 파일 범위로 선언된 변수는 흔히 "전역 변수"라고도 합니다.

함수 범위

함수 안의 레이블은 그 함수의 본문 전체(레이블보다 앞인 위치를 포함합니다)에서 가리킬 수 있습니다.

void foo() { // scope of label begins here
	goto label;
	{
		label:;
	}
	goto label;
} // scope of label ends here

함수 원형 범위

함수 원형에서 매개변수를 정의할 때 이전 매개변수를 참조할 수 있습니다.

int foo(
	int a, int b,
	int arr[a][b] // OK
);

기억 영역 분류 [storage class]

C에는 auto, register, static, extern, _Thread_local (C11~)의 다섯 종류의 기억 영역 분류 지정자가 있습니다. 위에서 언급했듯이 이론상으로는 typedef도 기억 영역 분류 지정자지만 기억 영역과는 무관한 동작을 하기 때문에 제외합니다.

기억 영역 분류는 크게 기억 기간연결성으로 나뉩니다. 위에서 언급한 다섯 종류의 지정자를 알아보기 전에 먼저 살펴봅시다.

기억 기간

기억 기간은 객체의 메모리 공간이 언제 할당되고, 반환되는지를 결정합니다.

연결성

연결성은 식별자를 다른 범위에서 사용할 수 있는지의 여부를 결정합니다. 한 파일 내에서 한 식별자가 내부 연결성과 외부 연결성을 동시에 띨 수 없습니다.

블록 범위에서 외부 연결성으로 선언했을 때는 조금 복잡한 규칙을 추가로 따르는데,

이 규칙을 활용해 같은 파일 내라도 extern을 사용해 더 나중에 선언했거나 다른 블록에 가려진 기존의 식별자와 연결할 수 있습니다. 물론 애초에 다른 이름을 짓는 게 편합니다.

extern int a; // (1), external linkage
static int b; // (2), internal linkage

void foo() {
	int a; // (3), no linkage
	int b; // (4), no linkage
	{
		extern int a; // OK, refers to (1)
		// ↑ 기존에 보이는 식별자 (3)이 `static`이나 `extern`이 아니므로
		// 이 선언은 외부 연결성을 띱니다.
		// 이는 기존의 (1)과 충돌하지 않습니다.

		extern int b; // variable previously declared 'static' redeclared 'extern'
		// ↑ 이 선언도 (4)에 의해 외부 연결성을 띱니다.
		// 이는 기존의 (2)와 충돌하므로 오류가 발생합니다.
	}
}

기억 영역 분류 지정자 [storage-class specifier]

기억 영역 분류 지정자를 명시하면 변수의 생명주기가 어떻게 되는지(기억 기간), 어디서 사용할 수 있는지(연결성)를 나타낼 수 있습니다.

어떤 선언인지에 따라 어떤 기억 영역 분류를 쓸 수 있는지가 나뉩니다. 생략하면 기본값이 적용됩니다.

한정자 [qualifier]

C에는 세 종류의 한정자가 있으며, 한정자가 붙은 타입은 값을 읽거나 쓸 때의 성질을 바꾸고 컴파일러 최적화에 관여합니다.

여기서는 "qualifier"를 "한정자"로 번역하기로 했으니 "const-qualified"(const 한정자가 추가된 타입)도 "const 한정되다"로 번역하겠습니다.

한정자가 붙는 위치

🙋‍♂️ 이 문단에서 5번 떡밥을 회수합니다.

BaseType에 붙는 경우, 이미 알아봤듯 아무 위치에 추가할 수 있습니다. 원래는 개인적으로 BaseType const로 쓰는 것을 선호했지만, 몇 번 써보니 가독성이 떨어지는 것 같아 const BaseType으로 선회했습니다.

포인터에 붙는 경우 포인터의 바로 뒤에 붙습니다(* const 혹은 *const). Rust를 써본 적이 있다면, 참조에 mut 키워드가 붙는 규칙과 같다(&mut)고 이해하면 외우기 쉽습니다. 개인적으로 C에서도 의도를 명확히 전달하기 위해 포인터와 한정자 사이를 띄우지 않는 것이 좋다고 생각합니다(*const처럼).

함수의 매개변수에서 최외곽 배열이 포인터로 변환되는 동작을 고려해 배열에도 한정자를 붙일 수 있으며, 이 경우 여는 대괄호 바로 뒤에 붙습니다([const 8]). 매개변수로 사용된 최외곽 배열 이외에는 한정자를 붙일 수 없습니다.

이 위치에는 이외에도 static이 붙을 수 있는데([static 4]. 한정자와의 순서는 상관 없습니다), 이 경우에는 인자로 전달받는 포인터가 정해진 크기 이상인 배열의 첫 원소를 가리켜야 합니다. 여기 말고 언급하기 적당한 곳이 없네요.

void foo(int x[static 4]);

int a[4], b[2];
foo(a); // OK
foo(b); // undefined behavior

함수에는 한정자를 붙일 수 없습니다. 함수 포인터에 한정자를 붙이려면 명시적으로 함수 포인터로 선언해야 합니다.

한정자가 여러 개 붙는다면 같은 위치에 띄어쓰기로 구분해서 넣으면 됩니다. 순서는 상관 없습니다.

이제 맨 위에서 던졌던 떡밥을 하나 더 회수해 봅시다. 아래의 네 선언은 이렇게 다릅니다.

  1. const int *x;: xconst int*입니다. xconst가 아니지만, *xconst입니다.
  2. int const *x;: xint const*입니다. 1번과 같습니다.
  3. int *const x;: xint*const입니다. xconst이지만, *xconst가 아닙니다.
  4. const int *const x;: xconst int*const입니다. x*x 모두 const입니다.

const: "변하지 않음"

const 한정자가 붙은 타입이나, 멤버(중첩된 경우를 포함해서) 중 하나라도 const인 복합 타입에는 쓰기 연산을 할 수 없습니다. 선언할 때 초기화는 허용합니다.

const 한정된 변수는 값이 변하지 않기 때문에, 컴파일러가 읽기 전용 영역에 할당하거나 상황에 따라 아예 하드코딩이 되도록 최적화될 수 있습니다.

const int x = 5;
x = 6; // cannot assign to variable 'x' with const-qualified type 'const int'

struct {
	int x;
	const int y;
} y = { 1, 5 }, z = { 6, 7 };
y = z; // cannot assign to variable 'y' with const-qualified data member 'y'
y.y = 9; // cannot assign to non-static data member 'y' with const-qualified type 'const int'

const는 쓰기 연산에만 영향을 미치기 때문에 좌측값이 아닌 표현식에서는 효력을 잃습니다.

volatile: "최적화되지 않음"

volatile 한정자가 붙은 타입의 읽기/쓰기 연산은 부작용14으로 취급되며, 최적화 없이 있는 그대로 실행됩니다. 주로 신호가 불안정하거나, 입출력 신호가 메모리 매핑이 되어 있거나, 벤치마크 등 최적화를 해서는 안 되는 상황에 사용합니다. 이건 코드만으로는 보여드릴 수 없으니 컴파일러에서 출력하는 어셈블리를 직접 확인해 봅시다.

Compiler Explorer에서 아래와 같이 volatile 한정자만 다른 두 함수를 -O2 플래그(중간 단계 최적화)를 추가하고 컴파일하면 아래와 같은 결과가 나옵니다. 이미지가 작게 나온다면 우클릭을 하거나 길게 눌러서 원본을 볼 수 있습니다.

int를 사용하는 함수 foo와 volatile int를 사용하는 함수 bar의 어셈블리 출력 비교. foo는 두 줄, bar는 아홉 줄로 출력된다.

foo(상단의 초록색 블록)는 사실상 return 100;과 동일하게 최적화된 반면, bar(나머지 모든 부분)는 대입과 루프, return x;를 할 때의 읽기 연산까지 그대로 어셈블리로 출력되었습니다.

volatile 변수를 volatile로 타입 변환해도 volatile의 효과가 발생하지 않으며, 반드시 volatile 타입의 포인터를 통해 접근해야 합니다.

constvolatile은 상호 독립적이기 때문에 한 변수를 동시에 const/volatile 한정할 수도 있습니다.

restrict (C99~): "접근을 독점함"

😰 여기는 아직도 헷갈리는 부분이 많습니다.

restrict객체 타입의 포인터만을 한정할 수 있으며, 좌측값이 아닌 표현식에서는 효력을 잃습니다. C23부터는 객체 타입의 (임의 차원의) 배열도 restrict 한정할 수 있습니다.

restrict int x; // restrict requires a pointer or reference ('int' is invalid)
int (*restrict y)(void); // pointer to function type 'int (void)' may not be 'restrict' qualified

restrict 포인터가 선언된 블록 안에서, 그 포인터가 가리키는 객체가 수정될 경우 그 객체는 그 포인터로만 읽고 써야 합니다. restrict 포인터로 읽기만 하는 경우에는 상관 없습니다. 컴파일러 오류/경고를 통해 최소한의 안전망을 갖추고 있는 constvolatile과 달리, 위의 조건은 프로그래머가 알아서 충족해야 합니다.

컴파일러는 restrict 포인터가 어떤 포인터와도 다른 객체를 참조한다고 가정하고 최적화할 수 있습니다. 예를 들어, 아래 코드의 barrestrict 포인터를 받지 않는다고 가정하면 x == y인 경우를 고려해 조금 더 긴 어셈블리가 출력되지만, restrict로 인해 x != y가 보장되므로 어셈블리 출력이 줄어듭니다.

void foo(int *restrict x, int *restrict y) {
	int a = *x, b = *y;
}

void bar(int *restrict x, int *restrict y) {
	for(int i = 0; i < 8; i++)
		*x += *y;
}

int a = 0, b = 0;
foo(&a, &a); // OK, only read operations
bar(&a, &b); // OK, each argument points to different object
bar(&a, &a); // undefined behavior

파생 타입과의 관계

여러 한정자끼리(특히 constvolatile이) 비슷한 동작을 공유하기 때문에 별도 문단으로 분리했습니다. 예시는 const로 들겠습니다.

배열을 한정하는 문법은 없지만, const, volatile, restrict 모두 typedef를 이용해 배열을 한정할 수 있습니다. 이때의 동작은 표준 개정판에 따라 다릅니다.

typedef int Arr[8];
const Arr x = { 1, 2, 3, 4, 5, 6, 7, 8 };
x[4] = -5; // cannot assign to variable 'x' with const-qualified type 'const Arr' (aka 'int const[8]')
// ↑ 참고로 컴파일러에서 출력한 타입 이름 'int const[8]'은
// "a (const-qualified `[8]`) of `int`"를 의미합니다.
// 실제 코드에 위와 같이 쓰면
// "a `[8]` of `int const`로 인식됩니다.

함수를 한정하는 문법도 없으며, typedef를 이용해 함수를 const/volatile 한정하려고 하면 정의되지 않은 동작이 되고, restrict 한정하려고 하면 오류가 발생합니다.

구조체나 공용체가 const/volatile 한정되었을 경우 모든 멤버가 동일하게 한정되는 효과를 가집니다.

struct Foo {
	int x;
	const int y;
};
struct Foo a = { 5, 6 };
const struct Foo b = { 7, 8 };
a.x = 9; // OK, int member of struct Foo
b.x = 10; // cannot assign to variable 'b' with const-qualified type 'const struct Foo'

const/volatile 한정되지 않은 타입의 포인터는 한정된 포인터에 대입할 수 있으며(변해도/최적화해도 되는 포인터를 변하지/최적화되지 않는 포인터에 대입), 그 역은 성립하지 않습니다(변하면/최적화되면 안 되는 포인터를 변할/최적화할 수 있는 포인터에 대입). 후자와 이중 이상의 포인터는 타입 변환을 해야 합니다.

int *p = NULL;
const int *q = p; // OK
p = q; // assigning to 'int *' from 'const int *' discards qualifiers
p = (int *)q; // well... OK

int **r = NULL;
const int **s = r; // initializing 'const int **' with an expression of type 'int **' discards qualifiers in nested pointer types

const/volatile 한정되었던 변수의 값에 한정되지 않은 포인터로 억지로 접근하려고 하면 정의되지 않은 동작이 됩니다.

const int x = 1;
*(int *)&x = 2; // undefined behavior

타입 변환

C를 쓰다 보면 원하든 원하지 않든 타입이 바뀌는 경우를 자주 볼 수 있습니다. 어떤 상황에서 무엇을 무엇으로 변환할 수 있고 어떻게 되는지를 정리해 보겠습니다.

암시적 변환

사용자가 명시적으로 (Type) 형태로 타입 변환을 하지 않는 경우입니다. 보통 산술 변환에서 unsigned를 선호하는 현상을 제외하면 대부분 상식 선에서 변환이 일어나고, 중요한 부분만 요약하자면 다음과 같습니다.

암시적 변환이 일어나는 경우는 다음과 같이 세 종류입니다.

모든 암시적 변환은 아래와 같은 단계를 거치며, 상황에 따라 생략할 수 있습니다.

명시적 변환

(Type) 연산자로 특정한 타입의 값을 다른 타입으로 변환할 수 있습니다. 중요한 부분만 요약하자면 다음과 같습니다.

변환 규칙은 다음과 같습니다. 포인터끼리, 혹은 정수와 포인터 간에 정확히 어떻게 변환되는지는 표준에서 명시하지 않는다는 점에 유의해 주세요.

복합 리터럴 [compound literal] (C99~)

이 문단은 2021년 8월 20일에 추가로 작성했습니다.

선언문 바깥에서는 대괄호를 이용해 복합 타입에 대입할 수 없습니다.

struct Foo {
	int foo;
} foo;
foo = { 123 }; // expected expression

이때는 대신 명시적 변환과 비슷한 문법을 이용해 대괄호 선언을 강제로 사용할 수 있습니다. 스칼라 타입, 복합 타입, 배열 타입(길이를 생략할 경우 대괄호 안쪽의 내용에서 추론합니다)을 모두 선언할 수 있습니다.

struct Foo { int foo; } foo;
foo = (struct Foo){ 123 }; // OK
printf("%d\n", foo.foo); // 123

int *bar;
bar = (int []){ 9, 99, 999 }; // OK, int [3]
printf("%d\n", bar[2]); // 999

int baz;
baz = (int){ 135 }; // OK
printf("%d\n", baz); // 135

복합 리터럴로 생성한 값은 좌측값이므로 변수에 대입하지 않아도 주소를 얻을 수 있습니다.

struct Foo { int foo; };
printf("%p\n", &(struct Foo){ 123 }); // OK

제네릭 선택 [generic selection] (C11~)

이 문단은 2021년 8월 20일에 추가로 작성했습니다.

C에도 의외로 타입 제네릭 연산을 지원하는 문법이 있습니다. _Generic() 안에 표현식('제어 표현식'이라고 합니다)과 타입에 따라 원하는 값을 넣으면 컴파일 시에 표현식의 타입을 확인해 일치하는 값으로 변환됩니다. 일치하는 타입이 없으면 컴파일 오류가 됩니다.

char *generic_test = _Generic(
	123ll, // long long
	int: "int",
	long: "long",
	long long: "long long"
); // ok, "long long"

char *generic_error = _Generic(
	"troll",
	int: "int"
); // controlling expression type 'char *' not compatible with any generic association type

제어 표현식은 다른 값과 같이 좌측값, 배열, 함수 변환을 거치기 때문에 한정자(스칼라 타입의 경우), 배열, 함수 타입은 사용할 수 없고 대신 포인터 타입을 사용해야 합니다.

void foo(int);
_Generic(
	foo,
	void (int): 1 // type 'void (int)' in generic association not an object type
);

int bar[3];
_Generic(
	bar,
	int [3]: 1
); // controlling expression type 'int *' not compatible with any generic association type

const int baz;
_Generic(
	baz,
	const int: 1
); // controlling expression type 'int' not compatible with any generic association type

const int quux(const int);
_Generic(
	quux,
	const int (*)(const int): 1 // 함수 자체가 아니라 매개변수와 반환값이 한정되었기 때문에 문제가 없습니다.
); // OK

이 문법 안에 들어간 모든 표현식은 일치하는 값을 제외하고 어느 것도 평가되지 않습니다.

int foo(char *msg, int result) {
	printf("foo(\"%s\") called\n", msg);
	return result;
}

int main() {
	printf("%d\n", _Generic(
		foo("controlling expression", 1),
		int: foo("int", 2),
		long: foo("long", 3),
		long long: foo("long long", 4)
	));
    return 0;
}
// foo("int") called
// 2

일치하지 않는 나머지 타입을 잡아내려면 switch문과 비슷하게 default:를 사용할 수 있습니다.

printf("%d\n", _Generic(
	(short)1,
	int: 1,
	long: 2,
	long long: 3,
	default: 99
)); // 99

제네릭 선택 문법은 #define 매크로 함수 안에 넣어서 쓸 수 있습니다. 다음은 cppreference에 올라온 예제를 들여쓰기와 주석만 수정한 것입니다.

#include <stdio.h>
#include <math.h>
 
// <tgmath.h> 매크로 함수 cbrt의 가능한 구현체
#define cbrt(X) _Generic((X),\
	long double: cbrtl,\
	default: cbrt,\
	float: cbrtf\
)(X)
 
int main(void)
{
	double x = 8.0;
	const float y = 3.375;
	// 기본값 cbrt를 선택함
	printf("cbrt(8.0) = %f\n", cbrt(x)); // cbrt(8.0) = 2.000000
	// const float를 float로 변환한 뒤 cbrtf를 선택함
	printf("cbrtf(3.375) = %f\n", cbrt(y)); // cbrtf(3.375) = 1.500000
}

부록: 비주얼 스튜디오 서식 설정하기

이 문단은 2022년 4월 4일에 추가로 작성했습니다.

원래 이 글의 댓글에 답글로 추가했던 내용인데 본문에 넣는 것도 좋을 것 같네요.

비주얼 스튜디오는 포인터와 참조를 왼쪽 정렬(int* x;)하는 것으로 악명이 높습니다. 혹시나 제 글을 읽고 설득되셨다면, 비주얼 스튜디오 설정에서 포인터 정렬을 오른쪽으로 바꿀 수 있는 방법이 있습니다. 비주얼 스튜디오 2019/2022에 해당 설정이 있는 것을 확인했고 이전 버전에도 있는지는 잘 모르겠네요.

비주얼 스튜디오의 설정 화면에서 '포인터/참조 맞춤'이 '오른쪽 맞춤'으로 설정되어 있다.

이외에도 세세한 서식 설정이 많으니 입맛대로 건드려보는 것도 좋겠습니다.

드디어 끝났네요!

원래 "int* x;는 틀리고 int *x;가 맞다"나 3번, 5번 떡밥만 얘기하고 끝내려고 했는데 파고들어가다 보니까 얼떨결에 타입 시스템을 통째로 다루게 됐네요.

이 글을 처음 쓰는 데 대충 열흘이 넘게 걸린 걸로 기억하고 있습니다. 물론 이것저것 조사하면서 긴 글을 쓰는 게 오랜만이라 힘들었지만 글쓴이인 저도 쓰면서 많은 것들을 배워갈 수 있었습니다. 글을 끝까지 읽어주신 여러분께도 무언가 남는 게 있었으면 좋겠네요! 감사합니다 🙇‍♂️

  1. C17 개정판의 특성상 대표적인 C 컴파일러인 GCCClang의 문서에는 __STDC_VERSION__ 매크로를 제외하고 모든 기능이 C17과 같다고 명시하고 있습니다. 

  2. 이 트윗의 내용을 부연하자면, Dev-C++는 컴파일에 TDM-GCC를 씁니다. 이전 버전 Dev-C++는 딸려오는 TDM-GCC도 이전 버전인데(4.x.x), 이때는 플래그를 지정하지 않으면 C89 표준에 컴파일러 확장을 허용하여(-std=gnu90) 컴파일을 했었습니다

  3. identifier. 변수, 함수, 레이블 등을 가리키는 문자열을 의미합니다. 

  4. function specifier. 함수에 부가적인 성질을 부여합니다. inline은 컴파일러가 인라이닝을 해도 되는 함수, _Noreturnexitlongjmp 등의 이유로 인해 절대 반환하거나 끝까지 실행되지 않는 함수를 의미합니다. 이 글에서 자세히 다루지는 않겠습니다. 

  5. variably-modified type. 원문의 의미를 유지하는 적당한 번역어가 없네요... 

  6. optional chaining 

  7. "x is a [4] of [8] of * of * of float

  8. "foo is a * of (int []) of * of int

  9. "z is a * of [123] of * of [456] of * of * of char

  10. "x is a * of (char *) of * of [64] of * of int

  11. lvalue. 메모리상에 "정체"가 있는 값을 말합니다. 다른 이유로 제한되는 경우가 아니면 대입하거나 & 연산자로 주소를 얻을 수 있습니다. (역주) 

  12. function designator (역주) 

  13. 각 행이 서로 다른 길이를 가질 수 있는, "배열의 배열" 자료구조를 말합니다. 그렇지 않은 "2차원 배열" 자료구조인 rectangular array에 대비됩니다. 

  14. 국어사전에서는 부작용을 "바람직하지 못한", "대개 좋지 않은 경우를 이"르는 것으로 정의하지만, 프로그래밍에서는 (주로 함수가) 인자를 받고 값을 반환하는 것 이외에 외부 환경에 영향을 미치는 경우를 말합니다. 

C 타입 시스템 제대로 알고 가기