블로그 메인으로
블로그 메인으로

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

약 72분 분량 · 작성 · 수정 #C#언어

현재 블로그 이전 작업 중입니다. 이전이 어느 정도 완료되면 https://eatch.dev/s/ctype 링크를 통해서도 접속 가능하도록 조치하겠습니다.

C로 코딩을 할 때, 가장 '올바른' 코딩 스타일은 무엇일까요?

  1. int*x;
  2. int* x;
  3. int *x;
  4. int * x;

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

이 글을 이런 질문으로 시작하는 이유가 있는데, 2020년 12월에 "int* x;는 틀리고 int *x;가 맞다"는 논지의 글을 쓰려다 정신을 차려보니 제가 아는 C의 타입 시스템을 통째로 다룬 글을 써버렸기 때문입니다. 이 글은 개발 블로그를 새로 개발하면서 약 4년만에 재작성한 버전입니다. 일단 쓰고 나니 이리저리 흩어져 있던 C라는 언어에 관한 지식을 통합적으로 이해하는 기틀을 다질 수 있었고 미래의 저 자신이 참고할 수 있는 자료로도 많이 도움이 됐는데, 이번 재작성으로 더 읽기 쉽고 많은 도움이 되었으면 좋겠습니다.

C의 타입 시스템은 제가 처음 생각했던 것보다 언어의 꽤 큰 부분을 차지합니다. C의 타입 시스템을 잘 이해하면 할 수 있는 것들을 몇 가지 적자면 이렇습니다. 아래의 7가지 떡밥은 글이 끝나기 전에 전부 회수할 것을 약속드립니다.

  1. int* x;가 틀리고 int *x;가 맞는지 설명할 수 있다.
  2. typedef를 자유자재로 사용할 수 있다.
  3. const int *x;int const *x;int * const x;의 차이를 이해할 수 있다.
  4. int *(*(*x)(char *))[64];와 같은 헷갈리는 선언을 그나마 쉽게 읽을 수 있다.
    • 그런데 웬만하면 이런 식으로 선언하지 말고 typedef를 써 주세요.
  5. 왜 다차원 배열의 맨 처음 길이만 생략할 수 있는지 이해할 수 있다.
  6. 배열과 포인터가 정확히 어떻게 다른지 이해할 수 있다.
  7. 단 한 번의 malloc 호출로 다차원 배열을 동적 할당받을 수 있다.

참고

  • C의 타입 시스템에서 제가 설명할 수 있을 정도로 이해한 모든 부분을 적당히 얕게(치트시트로 활용할 수 있을 정도로) 설명합니다. 더 깊은 세부사항은 검색해서 확인해 주세요. cppreference.com의 자료를 권장드립니다.
    • 제가 C의 타입 시스템을 완전히 이해하지는 못했기 때문에 어쩔 수 없이 빠진 부분이 있습니다. 현재 타입별 크기와 정렬, C11 _Alignas, C11 _Atomic은 다루고 있지 않습니다.
    • 번역어로 어느 정도 통용되는 단어와 제가 임의로 번역한 번역어가 어느 정도 섞여 있습니다. 검색에 도움이 되도록 대부분의 용어에 원어를 병기하고, 원어로부터 매우 쉽게 유추할 수 없는 임의 번역어는 별표(*)로 표기했습니다.
  • C에 대한 어느 정도의 기초 지식을 가정합니다. C를 처음 배우시는 분이라면 아쉽지만 어느 정도 언어에 익숙해진 뒤 다시 찾아와 주세요.
  • 업데이트할 것이나 빠진 것이 생기면 제가 그때그때 찾아와서 업데이트할 예정입니다. 글의 맨 위에 수정한 날짜가 있으니 자주 찾아와서 바뀐 것이 있는지 확인해 보세요.
  • 글의 구조는 전체를 순서대로 읽는 것보다는 필요한 부분을 발췌독할 때에 초점을 맞추어 구성했으며, 순서대로 읽을 경우 이후 내용에서 언급하는 사전 지식을 놓칠 수 있습니다. 소제목 링크가 있다면 해당 내용을 먼저 읽어주세요.
  • 코드 블록 안의 코드가 파일 범위인지 블록 범위인지는 따로 표기하지 않지만, 구분이 필요할 경우 주변 맥락에서 유추할 수 있습니다.
  • 코드 블록 안에 작성한 이모지의 의미는 다음과 같습니다.
    • ✅: 올바르게 컴파일 및 실행되는 코드입니다.
    • ✅👎: 올바르게 컴파일 및 실행되는 코드이지만 권장되지 않는 코딩 스타일을 사용하고 있습니다.
    • 🔥: 컴파일이 되지만 논리적인 오류가 있는 코드입니다.
    • ❌: 원칙적으로 컴파일이 되지 않는 코드입니다. 컴파일러에서 언어 확장을 지원할 경우 컴파일이 될 수도 있습니다.

배경지식: 그대들은 무슨 개정판으로 코딩할 것인가

본격적으로 시작하기 전에 잠깐 C의 역사 얘기를 하겠습니다.

C는 1972년에 벨 연구소의 데니스 리치Dennis Ritchie가 처음 개발했는데, 그 당시에는 C 표준이 아직 없어서 1978년에 브라이언 커니핸Brian Kernighan과 공저한 The C Programming Language가 사실상의 표준 역할을 했습니다. 이 시절의 C '표준'을 저자 이름의 앞글자를 따서 K&R C라고도 합니다. (재미있는 사실! 이 책은 "hello, world"의 대중화에도 결정적인 영향을 미쳤습니다.)

이후 C의 규격이 국제 표준으로 확립된 것은 1989년이 되어서였고(이 표준을 C89나 C90, ANSI C라고도 합니다), 이후 C95, C99, C11, C17, C23까지 5번의 개정을 거쳤습니다. C17은 기능 추가 없이 기존 표준의 결함만 수정하였고, 현재 작업 중인 다음 개정판에는 C2Y라는 가제가 붙어 있습니다.

여기서 언급한 C의 표준 개정판이 언어 사용에 큰 영향을 미치는데, 예를 들어 C99 이전까지는 for문의 초기화 절 안에서 변수를 선언할 수 없었습니다.

// ❌ C99 이전의 개정판에서는 `for`문의 초기화 절에서 변수를 선언할 수 없습니다.
for(int i = 0; i < 8; i++)
	printf("%d\n", i);

위와 같은 이유로 이 글에서도 필요한 곳에 표준 개정판 표기를 삽입하고 있습니다. 예를 들어 C11은 C11에 추가된 기능이라는 의미입니다. 다른 표준 개정판을 사용하려면 컴파일러에 플래그를 넣어주세요. 아래 플래그는 GCC/Clang 기준입니다.

컴파일러를 직접 다루지 않는 IDE 환경이라도 웬만하면 설정에서 컴파일러 플래그나 표준 개정판을 설정할 수 있습니다. IDE별로 구체적인 방법은 해당 IDE의 문서를 읽거나 검색해서 확인해볼 수 있습니다.

표준 문서를 직접 읽어보려면

C의 표준(C23)은 [ISO9899]로 등록되어 있고, 2025년 3월 3일 현재 221 스위스 프랑(한화 약 35만 7천 원)으로 판매하고 있으며, 이전 개정판은 ANSI에서 판매하고 있습니다.

그 대신 C99부터 C23까지의 최종안은 무료로 열람 가능합니다. 이 글도 C23의 최종안인 N3220을 참고하여 작성했습니다.

배경지식: 표준에서 정하지 않는 것들

주의

이 단락에는 오개념이 있을 수 있습니다. 잘못된 내용을 찾으셨다면 꼭 알려주세요.

추가로, C 표준(이하 "표준")에서는 언어의 모든 세부사항을 완전히 못박아두지 않으며, 대표적으로 다음 네 종류의 '표준에서 정하지 않는 것들'을 통해 컴파일러가 컴파일 과정에서 다른 동작을 보이거나, 코드를 최적화하거나, 언어 확장을 구현할 재량을 어느 정도 보장합니다.

참고

'구현체'의 의미에 관하여

표준에서는 [C 표준을 구현하는] '구현체implementation'를 다음과 같이 정의하고 있습니다. 이 글에서는 그냥 '컴파일러'와 같은 말이라고 생각해도 되지만, 실제로는 컴파일되는 과정과 실행되는 환경을 아우르기 때문에 그것보다는 더 복잡합니다.

구현체

특정한 제어 설정이 적용된 특정한 번역 환경에서 실행되는 특정한 소프트웨어의 집합으로서, 특정한 실행 환경에서 실행되도록 프로그램 번역을 수행하고 그러한 환경에서 함수의 실행을 지원하는 것

implementation

particular set of software, running in a particular translation environment under particular control options, that performs translation of programs for, and supports execution of functions in, a particular execution environment

— C23 표준 최종안, 3.15/1

모든 환경과 모든 컴파일러에서 똑같이 돌아갈 것이라고 믿고 이런 동작을 함부로 구현했다가는 나쁜 일이 생길 수 있으니 유의해야 합니다. 무엇을 언제 써도 되는지는 웬만하면 아래를 참고하면 될 것이라고 생각합니다.

선언문declaration

참고

여기서 1번 떡밥을 부분적으로 회수합니다.

int* x;가 틀리고 int *x;가 맞는지 설명할 수 있다.

C에서 타입을 가장 많이 적는 곳이 선언문인 만큼, 선언문의 구조만 봐도 C의 타입 시스템이 어떻게 돌아가는지 어느 정도 이해할 수 있습니다. 사실 선언문이 아닐 것 같은데 의외로 똑같은 규칙을 따르는 선언문인 경우도 꽤 많습니다.

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

<specifiers> <declarator>;

선언문은 <specifiers><declarator>의 두 부분으로 나눌 수 있는데, <specifiers>는 선언할 것의 기초적인 타입 등의 정보를 나타내고 1개 이상을 띄어쓰기로 구분해서 작성하는 한편, <declarator>는 선언할 것의 이름과 추가 정보를 나타내고 0개 이상을 반점으로 구분해서 작성합니다. <specifiers>는 그 뒤에 따라붙는 모든 <declarator>에 동일하게 적용됩니다.

1번 떡밥을 회수하겠다고 약속했으니 우선 간단한 실험을 해봅시다. 포인터 표기(*)는 <specifiers>에 속할까요, <declarator>에 속할까요? 아래와 같은 코드를 작성해보면 바로 알 수 있습니다.

// 실제 출력값은 구현체에 따라 바뀔 수 있습니다.
// 64비트 기기에서는 웬만하면 아래와 같이 출력됩니다.

char* a, b;
printf("%d %d", sizeof a, sizeof b); // 8 1

ab의 크기가 다르네요! 크기가 다르다는 것은 곧 다른 타입이라는 의미입니다. *<specifiers>에 속했다면 이런 동작을 보일 이유가 없으므로 *<declarator>에 속한다는 것을 알 수 있습니다. 더 구체적으로는 <specifiers>char, <declarator>* a 한 묶음, b 한 묶음으로 해석되었습니다. 이런 동작의 연장선에서 int* x;보다 int *x;가 실제 언어의 동작에 더 가까우므로 더욱 올바르다는 주장을 할 수 있겠습니다.

사실 위에서는 언어가 '어떻게' 동작하는지만 보였지 '왜'에 대한 언급은 하지 않았는데, 글의 구성상 여기 말고 아래의 #<declarator>가 그렇게 구현된 이유에서 살펴보겠습니다. 궁금하시다면 해당 부분을 먼저 읽고 오셔도 됩니다.

<specifiers> 자세히 살펴보기

<specifiers>에는 다음과 같은 것들이 임의의 순서대로 올 수 있습니다.

이때 "임의의 순서대로"라는 것은 단어 단위이기 때문에 서로 다른 종류의 키워드를 아무 순서대로 섞어 쓸 수 있습니다. 예를 들어 아래에서 등호 양쪽의 코드는 모두 같은 의미입니다. 가독성을 위해 웬만하면 이러지 말고 순서를 정해서 작성해 주세요.

타입 지정자type specifiers

타입 지정자는 선언되는 것의 기본적인 타입 정보를 나타냅니다. 단, 타입 지정자만으로 모든 의미가 결정되는 것은 아니고 다른 지정자나 파생 타입 표기 등으로 추가적인 의미를 더할 수 있습니다. 여기에 올 수 있는 것들은 크게 네 가지, 조금 더 작게는 여섯 가지로 분류할 수 있습니다.

기본 타입

여기서 기본 타입이란 표준·사용자 라이브러리나 매크로 등에서 제공하지 않는, 언어 자체에서 제공하는 타입을 의미합니다. 현재 C에서 제공하는 기본 타입은 다음과 같습니다.

주의

아래의 분류는 표준에서 정의하는 것과 다르며, 키워드 조합의 직교성을 기준으로 삼았습니다.

주의

  • 위에 나열된 타입 중 구현체에서 복소수와 십진 부동소숫점을 구현하지 않거나, 이진 부동소숫점을 [IEEE754](이하 '부동소숫점 표준')에 어긋나게 구현할 수 있습니다. 각각의 구현 여부는 다음 매크로 상수로 확인할 수 있습니다.

    • 이진 부동소숫점 (부록 F)
      • __STDC_IEC_559__ (C23부터 비권장): 부동소숫점 표준을 만족할 경우 1로 정의됩니다.
      • C23 __STDC_IEC_60559_BFP__: 부동소숫점 표준을 만족할 경우 202311L로 정의됩니다.
    • 복소수 (부록 G)
      • __STDC_IEC_559_COMPLEX__ (C23부터 비권장): 지원할 경우 1로 정의됩니다.
        • C11 이전까지는 이 매크로 상수가 있어도 순허수를 지원하지 않을 수 있었습니다. C11부터는 순허수를 반드시 지원하도록 변경되었습니다.
      • C23 __STDC_IEC_60559_COMPLEX__: 지원할 경우 202311L로 정의됩니다.
      • __STDC_NO_COMPLEX__: 복소수나 <complex.h>를 지원하지 않을 경우 1로 정의됩니다.
    • 십진 부동소숫점 (부록 F 일부)
      • __STDC_IEC_60559_DFP__: 지원할 경우 202311L로 정의됩니다.
  • charsigned charunsigned char와 같은 타입이 아닌 별개의 타입입니다. 더 자세히는, charsigned char 혹은 unsigned char 중 하나와 같은 동작을 하지만 어느 것을 택할지는 구현체에서 정의합니다.

    charsigned char, unsigned char문자 타입으로 통칭한다. 구현체는 char의 범위와 표현, 동작이 signed char 혹은 unsigned char 중 하나와 일치하도록 정의하여야 한다.

    The three types char, signed char, and unsigned char are collectively called the character types. The implementation shall define char to have the same range, representation, and behavior as either signed char or unsigned char.

    — C23 표준 최종안, 6.2.5/20

  • 정수 타입의 크기도 구현체에서 정의합니다. 표준에서 추가적인 제한을 두고 있는데, 결과적으로 다음이 성립합니다.

    • char는 8비트 이상

    • shortint는 16비트 이상

    • long은 32비트 이상

    • long long은 64비트 이상

    • 1 = sizeof(char)sizeof(short)sizeof(int)sizeof(long)sizeof(long long)

    • 재미있는 사실! 1바이트가 몇 비트인지도 구현체에서 정의합니다. 과거에는 컴퓨터마다 1바이트를 1비트부터 48비트까지 다양한 값으로 정했다고 하는데, 현대의 범용 컴퓨터는 사실상 8비트 바이트로 통일되었으므로 특정 분야가 아니면 큰 의미가 없습니다.3

      용어 관련 참고 2: 바이트는 연속된 비트의 열로 이루어지며, 그 개수는 구현체에서 정의한다.

      Note 2 to entry: A byte is composed of a contiguous sequence of bits, the number of which is implementation-defined.

      — C17 표준 최종안, 3.6/3

표준에서의 타입 분류

표준에서는 추가로 특정한 타입을 통칭하는 용어를 몇 가지 정의하고 있습니다.

주의

'개체'라는 용어에 관하여

이 글에서 사용하는 "개체"라는 용어는 object의 번역어인데, 이 object는 객체지향 패러다임에서의 객체object와 다른 개념이며, 표준에서는 다음과 같이 정의하고 있습니다.

개체

실행 환경에서 자료가 저장되는 영역으로, 그 내용이 값을 표현할 수 있는 것

object

region of data storage in the execution environment, the contents of which can represent values

— C23 표준 최종안, 3.18/1

더 나아가 좌측값은 개체를, 우측값은 값을 가리키는 것으로 생각할 수 있습니다.

이 글에서는 객체지향 패러다임에서의 객체와 혼동을 막기 위해 object를 개체로 번역합니다.

사용자 정의 타입과 주의사항

아래의 구조체, 공용체, 열거형은 모두 기존의 타입과 다른 새로운 타입을 정의하고, 그 타입에 태그(struct/union/enum 바로 다음에 오는 식별자)를 붙일 수 있다는 점에서 비슷하며, 이 이유로 이 글에서는 '사용자 정의 타입'으로 따로 분류합니다. 사용자 정의 타입을 설명하기 전에 공통되는 사용 방법과 주의사항을 여기에 작성합니다.

새로운 타입은 다음과 같은 세 가지 방식으로 정의할 수 있습니다.

일단 선언하고 나면 태그 타입을 포함해 다음과 같은 형태로 쓸 수 있습니다.

태그 타입 없이 태그만 단독으로 사용할 수는 없지만, typedef를 통해 동의어를 만들어서 쓸 수 있습니다.

struct Foo {
	int x;
};

// ✅ `Foo`가 `struct Foo`의 동의어로 선언되었습니다.
typedef struct Foo Foo;

// ✅ `Foo` (aka `struct Foo`) 타입의 변수가 선언되었습니다.
Foo foo;

// ✅ `Bar`가 익명 구조체 (a)의 동의어로 선언되었습니다.
typedef struct /* (a) */ {
	int x;
} Bar;

// ✅ `Bar` (aka 구조체 (a)) 타입의 변수가 선언되었습니다.
Bar bar;

의외로 태그 이름이 같은 구조체와 공용체, 열거형은 동시에 선언할 수 없습니다.

// ✅ `struct Foo`가 선언되었습니다.
struct Foo;

// ❌ 태그 이름 `Foo`가 중복됩니다.
union Foo;

// ❌ 태그 이름 `Foo`가 중복됩니다.
enum Foo;

타입 선언도 변수처럼 블록 범위를 가지며, 블록 안에서 블록 밖에 있는 같은 이름의 타입을 가릴 수 있습니다.

// ✅ `struct Foo` (a)가 선언되었습니다.
struct Foo /* (a) */ {
	int x;
};

// ✅ `struct Foo` (a) 타입의 변수 `outer`가 정의되었습니다.
struct Foo outer = { 1 };

void fn(void) {
	// ✅ `struct Foo` (b)가 선언과 동시에 정의되었습니다.
	// 이 타입은 `fn`의 본문 안에서 `struct Foo` (a)를 가리며,
	// `fn` 밖에서는 `struct Foo` (a)를 계속 사용할 수 있습니다.
	struct Foo /* (b) */ {
		int x;
	};

	// ✅ `struct Foo` (b) 타입의 변수 `inner`가 정의되었습니다.
	struct Foo inner = { 1 };

	// ❌ `struct Foo` (a) 타입을 가지는 `outer`를 `struct Foo` (b) 타입을 가지는 `inner`에 대입할 수 없습니다.
	inner = outer;
}

void fn2(void) {
	// ✅ 가려지지 않은 `struct Foo` (a) 타입의 변수가 정의되었습니다.
	struct Foo inner = outer;
}

void fn3(void) {
	// ✅ `struct Foo` (c)가 선언되었습니다.
	// `fn` 안에서처럼 구조체를 완전히 정의하지 않아도 선언만으로 `struct Foo` (a)가 가려집니다.
	struct Foo /* (c) */;

	// ❌ `struct Foo` (c)가 선언은 되었지만 정의되지 않았으므로 그 타입의 변수를 선언할 수 없습니다.
	struct Foo x;
}
구조체

구조체의 '풀 네임'이 structure라는 것을 알고 계셨나요? 저는 C를 시작한 이후로 구조체를 항상 struct로만 접해 와서 오히려 structure가 더 어색하게 느껴집니다.

구조체는 하나 이상의 값을 멤버로 가지는 값으로, 멤버들은 각자의 메모리 공간이 있어서 서로를 침범하지 않습니다.

빈 구조체(엄밀히는 기명named 멤버가 없는 구조체)를 정의하려고 하면 비정의 동작이 됩니다. GCC 등 일부 컴파일러에서는 언어 확장으로 빈 구조체를 지원하기도 합니다.

// 🔥 UB: 구조체에 기명 멤버가 없습니다.
struct Empty {};
비트필드bit-fields

참고

여기서는 설명의 편의를 위해 int가 32비트를 차지하고 2의 보수 표현을 사용하는 것으로 가정합니다.

더 알아보기

표준에서 허용하는 부호 있는 정수 표현

표준에서는 부호 있는 정수를 다음 셋 중 하나의 방법으로 표현하도록 규정하고 있습니다. 세 방법 모두 부호 비트가 0이면 그 비트를 무시하고 이진법으로 읽습니다.

  • 부호와 절댓값sign and magnitude 표현: 음의 부호를 붙이는 연산이 부호 비트를 반전하는 연산에 대응합니다.
  • 1의 보수one's complement 표현: 음의 부호를 붙이는 연산이 모든 비트를 반전하는 연산에 대응합니다.
  • 2의 보수two's complement 표현: 음의 부호를 붙이는 연산이 모든 비트를 반전하고 1을 더하는 연산에 대응합니다.

위 세 방식 중 2의 보수 표현이 표현 범위가 더 넓고 부호 없는 덧셈·뺄셈·곱셈 연산을 그대로 사용할 수 있다는 장점을 가지며, 대부분의 컴퓨터에서 채택되고 있습니다.

C23부터는 표준에서 부호와 절댓값, 1의 보수 표현이 폐지되고 모든 부호 있는 정수가 2의 보수로 표현됩니다.

구조체 안의 메모리 영역을 비트 단위로 지정해 사용할 수 있고, 구조체 멤버 바로 다음에 콜론(:)과 비트 수를 적으면 됩니다. 비트 수는 기반으로 하는 타입의 크기보다 클 수 없습니다.

struct Foo {
	// ✅ 3비트를 차지하는 부호 없는 비트필드 멤버 `a`가 선언되었습니다.
	// `a`의 범위는 0 이상 7 이하입니다.
	unsigned int a: 3;
	// ✅ 4비트를 차지하는 부호 있는 비트필드 멤버 `b`가 선언되었습니다.
	// `b`의 범위는 -8 이상 7 이하입니다.
	signed int b: 4;
};

비트필드의 기반 타입으로 사용할 수 있는 타입은 다음과 같습니다.

비트필드가 연속될 경우 공간이 남으면 반드시 연속된 비트를 사용합니다. 공간이 남지 않을 때 다음 개체로 넘어갈지 여러 개체에 걸칠지는 구현체에서 정의합니다.

멤버 이름을 생략한 비트필드도 만들 수 있습니다.

struct Foo {
	// ✅ 기명 비트필드 멤버 `a`와 `b`, 무기명 비트필드 멤버 1개가 선언되었습니다.
	// `a`와 `b`는 메모리상에서 4비트 떨어져 있습니다.
	unsigned int
		a: 3,
		: 4,
		b: 5;
};

무기명 비트필드의 경우 0비트짜리의 특수한 비트필드가 허용되는데, 다음 비트필드를 같은 개체에 연속으로 할당하지 않고 다음 개체로 넘기는 역할을 합니다.

struct Foo {
	// ✅ 기명 비트필드 멤버 `a`와 `b`가 선언되었습니다.
	// `a`와 `b`는 메모리상에서 서로 다른 `unsigned int` 개체에 속하며, 29비트 떨어져 있습니다.
	unsigned int
		a: 3,
		: 0,
		b: 5;
};
C99 유연 배열 멤버*flexible array members

구조체에 기명 멤버가 있을 경우 맨 마지막에 불완전 배열 멤버를 하나 더 선언할 수 있고, 이 멤버를 유연 배열 멤버라고 합니다.

이 멤버는 그 자체로 구조체에 포함되지는 않지만(초기화할 수도 없고, 대입 연산이나 sizeof에서도 제외됩니다), 의도적으로 구조체의 크기보다 큰 메모리를 할당하고 남는 부분에 접근하는 데 사용할 수 있습니다. 단, 실제로 할당된 메모리 밖을 참조할 수는 없습니다.

struct Foo {
	int a;
	// ✅ `b`가 유연 배열 멤버로 선언되었습니다.
	long long b[];
};

// ✅ `struct Foo` 타입의 `foo`가 정의되었습니다.
struct Foo foo = { 1 };
// 🔥 UB: 추가로 할당된 공간이 없으므로 유연 배열 멤버를 사용할 수 없습니다.
foo.b[0] = 1;
// ✅ 공간이 할당되지 않았지만 포인터는 만들 수 있습니다.
// 이 너머의 포인터를 만드는 것은 UB입니다.
long long *ptr = &foo.b[0];
// ❌ 유연 배열 멤버를 초기화할 수 없습니다.
struct Foo foo2 = { 1, { 2 } };

// ✅ `struct Foo` + `long long` 2개 + 1바이트 크기의 공간이 할당되었습니다.
// 유연 배열 멤버 `bar->b`는 `long long [2]`처럼 사용할 수 있습니다.
struct Foo *bar = malloc(sizeof(struct Foo) + 2*sizeof(long long) + 1);
bar->a = 0;
// ✅ `*bar`의 유연 배열 멤버에 접근했습니다.
bar->b[1] = 2;
// 🔥 UB: 남는 1바이트는 `long long`의 크기보다 작으므로 접근할 수 없습니다.
bar->b[2] = 3;

유연 배열 멤버는 추가로 할당된 공간이 없더라도 길이가 1인 배열처럼 동작합니다. 단, 배열의 원소에 실제로 접근할 수는 없습니다.

C11 익명 구조체anonymous structs

무기명 구조체를 구조체나 공용체의 멤버로 선언할 수 있고, 이 멤버를 익명 구조체라고 합니다.

안긴 익명 구조체의 멤버는 안은 구조체나 공용체에 직접 속하는 멤버처럼 사용할 수 있으며, 익명 구조체를 재귀적으로 사용할 수도 있습니다.

struct Foo {
	// ✅ `a`가 `struct Foo`의 멤버로 선언되었습니다.
	int a;

	// ✅ 익명 구조체 멤버 (a)가 `struct Foo`의 멤버로 선언되었습니다.
	struct {
		// ✅ `b`가 익명 구조체 (a)의 멤버로 선언되었습니다.
		// `b`는 `struct Foo`의 멤버처럼 사용할 수 있습니다.
		int b;

		// ✅ 익명 구조체 멤버 (b)가 익명 구조체 (a)의 멤버로 선언되었습니다.
		struct {
			// ✅ `c`가 익명 구조체 (b)의 멤버로 선언되었습니다.
			// `c`는 `struct Foo`의 멤버처럼 사용할 수 있습니다.
			int c;
		} /* (b) */;
	} /* (a) */;

	struct {
		// ✅ `d`가 `e`의 멤버로 선언되었습니다.
		// `e`는 익명 구조체 멤버가 아니므로
		// `d`를 `struct Foo`의 멤버처럼 사용할 수 없습니다.
		int d;
	} e;
};

struct Foo foo;
// ✅ `struct Foo`의 멤버
// `a`에 접근했습니다.
foo.a = 1;
// ✅ `struct Foo`의 멤버인
// 익명 구조체 (a)의 멤버
// `b`에 접근했습니다.
foo.b = 1;
// ✅ `struct Foo`의 멤버인
// 익명 구조체 (a)의 멤버인
// 익명 구조체 (b)의 멤버
// `c`에 접근했습니다.
foo.c = 1;
// ❌ `e`는 익명 구조체 멤버가 아니므로 `e`의 멤버인 `d`에 직접 접근할 수 없습니다.
foo.d = 1;
공용체

공용체는 구조체처럼 하나 이상의 값을 멤버로 가지지만, 모든 멤버가 같은 메모리 공간을 공유하기 때문에 한 멤버에 쓰면 다른 멤버가 덮어쓰입니다. 구조체만큼 자주 쓰이지는 않지만, 합 타입sum types을 모사하거나 C99 타입 퍼닝type punning 등 저수준 조작을 하는 등의 용도로 사용할 수 있습니다.

구조체와 같이 빈 공용체(엄밀히는 기명named 멤버가 없는 공용체)를 정의하려고 하면 비정의 동작이 됩니다. GCC 등 일부 컴파일러에서는 언어 확장으로 빈 공용체를 지원하기도 합니다.

// 🔥 UB: 공용체에 기명 멤버가 없습니다.
union Empty {};
비트필드bit-fields

구조체와 같이 비트필드 멤버를 선언할 수 있으며, 모든 비트필드가 같은 메모리 공간을 공유합니다. 이때 무기명 : 0 비트필드는 의미가 없습니다.

C11 익명 공용체anonymous unions

구조체와 같이 무기명 공용체도 구조체나 공용체의 멤버로 선언할 수 있고, 이 멤버를 익명 공용체라고 합니다.

익명 구조체와 익명 공용체는 같은 방법으로 사용할 수 있고, 서로 혼용할 수도 있습니다.

열거형

열거형은 하나 이상의 열거형 상수를 거느리는 타입입니다.

열거형은 타입 시스템상으로는 다른 어떤 타입과도 다른 새로운 타입이지만, 내부적으로는 열거형마다 기반으로 하는 타입이 있어 그 타입처럼 동작합니다. 열거형의 기반이 될 수 있는 타입은 모든 멤버의 값을 표현할 수 있는 다음 타입 중 구현체가 정의하는 것으로 합니다.

열거형의 첫 멤버의 값은 0이며, 나머지 멤버는 따로 정하지 않을 경우 이전 멤버에 1을 더한 값을 가집니다. 멤버 이름 바로 다음에 등호(=)와 원하는 값을 적어서 멤버의 값을 직접 설정할 수 있으며, 이 과정에서 값이 중복되어도 상관 없습니다.

enum Foo {
	// ✅ `A`의 값이 0으로 정의되었습니다.
	A,
	// ✅ `B`의 값이 A + 1 = 1로 정의되었습니다.
	B,
	// ✅ `C`의 값이 5로 정의되었습니다.
	C = 5,
	// ✅ `D`의 값이 C + 1 = 6으로 정의되었습니다.
	D,
	// ✅ `E`의 값이 0으로 정의되었습니다.
	// `A`와 중복되는 값을 가지지만 의미상의 문제는 없습니다.
	E = 0
};
C23 고정 기반 타입*fixed underlying type

사용자가 열거형의 기반 타입을 직접 설정할 수 있습니다.

// ✅ `char`를 기반 타입으로 가지는 `enum Foo`가 정의되었습니다.
enum Foo: char {
	// ✅ `A`의 값이 문자 'A'로 정의되었습니다.
	A = 'A'
};

참고

C23에 고정 기반 타입이 추가되면서 기반 타입*underlying type과 열거 멤버 타입*enumeration member type이 명문화되는 등 타입 시스템이 많이 복잡해졌습니다. 아직 글쓴이의 이해도가 높지 않아 해당 내용은 이 글에서 다루지 않습니다.

typedef 타입 동의어

참고

여기서 2번 떡밥을 회수합니다.

typedef를 자유자재로 사용할 수 있다.

위에서 "사실 선언문이 아닐 것 같은데 의외로 똑같은 규칙을 따르는 선언문인 경우도 꽤 많"다고 했던가요? 혹시 typedef 선언도 똑같은 규칙을 따르는 선언문이라는 걸 알고 계셨나요?

일반적인 변수 선언문에 typedef만 추가하면 변수 대신 타입 동의어를 선언하는 선언문이 되며, 파생 선언자도 그대로 사용할 수 있습니다.

// ✅ `struct List`의 선언 및 정의와 동시에 두 개의 타입 동의어가 선언되었습니다.
// `ListNode`는 `struct List`의 동의어입니다.
// `ListPtr`는 `struct List *`, 즉 `struct List`의 포인터의 동의어입니다.
typedef struct List {
	int value;
	struct List *next;
} ListNode, *ListPtr;

typedef로 함수 타입의 동의어도 선언할 수 있고, 이렇게 선언한 타입으로 함수 원형을 선언할 수도 있습니다. 단, 함수 정의에는 사용할 수 없습니다.

// ✅ `IntFn`이 `int (int)`의 동의어로 선언되었습니다.
typedef int IntFn(int);

// ✅ `IntFn foo` (aka `int foo(int)`)가 선언되었습니다.
IntFn foo;

// ✅ 기존에 선언했던 `foo`가 정의되었습니다.
int foo(int x) {
	return x;
}
// ❌ `int (int)`로 선언된 `foo`를 `int (long)` 타입으로 정의할 수 없습니다.
int foo(long x) {
	return x;
}
// ❌ 함수 괄호 없이 함수 본문을 정의할 수 없습니다.
IntFn foo {
	return 0;
}

이런 동작이 가능한 이유는 typedef이론상으로는 기억 부류 지정자이기 때문입니다. 기억 부류 지정자도 <specifiers>이기 때문에 순서와 상관 없이 쓸 수 있지만, 아까 말했듯이 이렇게 하지는 말아주세요.

// ✅👎 `Foo`가 `long`의 동의어로 선언되었습니다.
int typedef long Foo;
C23 typeof()

typeof() 연산자를 통해 다른 표현식에서 타입을 가져와 <specifiers>로 사용할 수 있습니다.

// ✅ 익명의 구조체 (a)와 그 타입을 가지는 변수 `foo`가 선언되었습니다.
// 구조체 (a) 타입은 `typeof()` 이외의 방법으로는 더 이상 가리킬 수 없습니다.
struct /* (a) */ {
	int foo;
} foo = { 1 };

// ✅ `typeof(foo)` (aka 구조체 (a)) 타입의 변수 `bar`가 선언되었습니다.
typeof(foo) bar = foo;

typeof_unqual()은 표현식에서 원자성과 한정자가 제거된 타입을 가져옵니다[한정자qualifier를 제거하기 때문에 unqual(ified)입니다].

typeof()typeof_unqual()로 어떤 표현식의 타입이든 구할 수 있지만, 예외적으로 비트필드의 타입은 구할 수 없습니다.

기억 부류 지정자*storage class specifiers

C에는 다음과 같이 좁게는 다섯 개, 넓게는 일곱 개의 기억 부류 지정자가 정의되어 있습니다. 지정자마다 서로 다른 기억 기간연결성이 부여되어 있는데, 자세한 설명은 해당 단락에서 확인할 수 있습니다.

함수나 개체의 종류별로 사용할 수 있는 기억 부류 지정자는 다음과 같습니다.

기억 기간*storage duration

기억 기간은 개체가 언제 할당되고 해제되는지를 나타내는 개념으로, 다음과 같이 네 종류가 있습니다.

연결성*linkage

연결성은 프로그램을 이루는 번역 단위*translation unit 안에서 어떤 식별자가 같은 개체를 가리키는지 나타내는 개념으로, 다음과 같이 세 종류가 있습니다.

한정자qualifiers

참고

여기서 3번 떡밥을 회수합니다.

const int *x;int const *x;int * const x;의 차이를 이해할 수 있다.

C에는 세 종류의 한정자가 있으며, 개체와 상호작용을 하는 데 제한을 두는 역할을 합니다. 이렇게 바뀐 개체의 의미는 안전성이나 컴파일러 최적화에 영향을 미칩니다.

주의

C11 _Atomic도 한정자처럼 사용할 수 있지만, 개체와의 상호작용 방식만을 제한하는 다른 세 한정자와 달리 _Atomic을 사용하면 기존의 타입이 크기나 정렬성, 표현 방식이 다를 수 있는 원자적 타입으로 변경됩니다. 제가 이해한 바로는 한정자 _Atomic은 타입 지정자 _Atomic()의 문법적 설탕에 가깝습니다.

이 글에서는 _Atomic을 다루지 않습니다.

한정자의 위치

한정자는 <specifiers> 자리와 포인터 선언자(*) 바로 뒤의 두 자리에 올 수 있습니다.

물론 두 방식을 동시에 사용할 수도 있습니다.

const 한정자

const 한정자는 개체에 '쓰기 연산 금지'의 의미를 더합니다. 일단 초기화가 끝난 const 개체(나 const인 멤버가 하나라도 있는 개체)에는 쓰기 연산을 할 수 없으며, 컴파일러가 이 성질을 이용해 최적화를 할 수 있습니다.

// ✅ `const` 한정된 변수가 선언되었습니다.
const int x = 5;
// ❌ `const` 한정된 개체에 쓸 수 없습니다.
x = 6;

// ✅ `const` 한정된 멤버가 있는 구조체가 선언되었습니다.
struct {
	int x;
	const int y;
} y = { 1, 2 }, z = { 3, 4 };
// ❌ `const` 한정된 멤버가 있으므로 쓸 수 없습니다.
y = z;
volatile 한정자

volatile 한정자는 개체에 '최적화 금지'의 의미를 더합니다. volatile 한정된 개체의 읽기/쓰기 연산은 부작용side effect으로 취급되어 최적화 없이 있는 그대로 실행됩니다. 입출력 신호가 메모리 매핑이 되어 있거나, 벤치마크 등 최적화를 해서는 안 되는 상황 등에 사용합니다.

volatile의 실제 효과를 살펴보려면 컴파일을 거친 뒤 어셈블리 코드를 확인해야 합니다. Compiler Explorer에서 아래 코드를 -O2 (중간 단계의 최적화) 플래그로 컴파일하면 다음과 같은 출력을 확인할 수 있습니다.

int foo(void) {
	int x = 0;
	for(int i = 0; i < 100; i++)
		x++;
	return x;
}

int bar(void) {
	volatile int x = 0;
	for(int i = 0; i < 100; i++)
		x++;
	return x;
}
foo:
	mov     eax, 100
	ret

bar:
	mov     DWORD PTR [rsp-4], 0
	mov     edx, 100
.L4:
	mov     eax, DWORD PTR [rsp-4]
	add     eax, 1
	mov     DWORD PTR [rsp-4], eax
	sub     edx, 1
	jne     .L4
	mov     eax, DWORD PTR [rsp-4]
	ret

독자의 편의를 위해 도로 의사코드로 돌려놓으면 다음과 같습니다. eax, edx, rsp는 CPU 레지스터이고, 포인터 산술은 바이트 단위인 것으로 가정합니다.

int foo() {
	eax = 100;
	return eax;
}

int bar() {
	*(rsp - 4) = 0;
	edx = 100;
L4:
	eax = *(rsp - 4);
	eax++;
	*(rsp - 4) = eax;
	edx--;
	if(edx != 0)
		goto L4;
	eax = *(rsp - 4);
	return eax;
}

foo는 사실상 return 100;으로 최적화된 반면, barx를 0으로 초기화하는 동작, 루프 안에서 읽고 쓰는 동작, return문에서 다시 읽는 동작까지 모두 보존되어 있습니다.

restrict 한정자

주의

이 단락에는 오개념이 있을 수 있습니다. 잘못된 내용을 찾으셨다면 꼭 알려주세요.

restrict 한정자는 개체를 직접 한정할 수 없으며, 개체를 가리키는 포인터나 C23 그런 포인터를 담는 1차원 이상의 배열만을 한정할 수 있습니다.

// ✅ `int *`가 `restrict` 한정되었습니다.
int *restrict x;

// ❌ 포인터가 아닌 `int`를 직접 `restrict` 한정할 수 없습니다.
restrict int y;

restrict 한정자는 개체의 포인터에 '에일리어싱aliasing 금지'의 의미를 더합니다. restrict 포인터가 있는 블록에서 그 포인터를 통해 어떤 개체에 직·간접적으로 쓰기 연산을 했다면 그 개체는 그 블록 안에서 그 포인터로만 참조할 수 있으며, 다른 경로로 참조할 경우 (컴파일러가 확인할 수 없으므로) 비정의 동작이 됩니다.

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

int x = 1, y = 1;

// ✅ `x`와 `y`가 올바르게 수정되었습니다.
foo(&x, &y);

// 🔥 UB: `restrict` 한정된 두 인자가 같은 개체를 가리킬 수 없습니다.
foo(&x, &x);

restrict 포인터를 다른 restrict 포인터 변수에 대입하려고 하면 에일리어싱이 되므로 비정의 동작이 됩니다. 단, 다음의 두 경우는 예외입니다.

restrict의 효과도 어셈블리 코드를 읽어야 쉽게 확인할 수 있습니다. Compiler Explorer에서 아래 코드를 -O2 (중간 단계의 최적화) 플래그로 컴파일하면 다음과 같은 출력을 확인할 수 있습니다.

void foo(int *x, int *y) {
	*y += *x;
	*x += *y;
}

void bar(int *restrict x, int *restrict y) {
	*y += *x;
	*x += *y;
}
foo:
	mov     eax, DWORD PTR [rdi]
	add     eax, DWORD PTR [rsi]
	mov     DWORD PTR [rsi], eax
	add     DWORD PTR [rdi], eax
	ret

bar:
	mov     eax, DWORD PTR [rdi]
	mov     edx, DWORD PTR [rsi]
	add     edx, eax
	add     eax, edx
	mov     DWORD PTR [rsi], edx
	mov     DWORD PTR [rdi], eax
	ret

역시 의사코드로 돌려놓으면 다음과 같습니다. eax, edx, rdi, rsi는 CPU 레지스터입니다.

void foo(rdi, rsi) {
	eax = *rdi;
	eax += *rsi;
	*rsi = eax;
	*rdi += eax;
}

void bar(rdi, rsi) {
	eax = *rdi;
	edx = *rsi;
	edx += eax;
	eax += edx;
	*rsi = edx;
	*rdi = eax;
}

foo에서는 (x == y인 경우) *y를 수정하면 *x가 영향을 받을 수도 있지만, bar에서는 그렇지 않다는 의미가 더해졌기 때문에 컴파일러가 최적화한 명령을 출력할 수 있습니다.

얼핏 보면 bar의 명령어 수가 foo보다 오히려 많아진 것처럼 보이지만, 현대의 CPU는 여러 가지 최적화 기법으로 인해 명령어를 잘 배열하면 오히려 더 빨리 처리할 수 있으며, bar는 CPU가 더 효율적으로 실행할 수 있다고 컴파일러가 판단한 어셈블리 코드입니다.

주의

실제로 어느 쪽이 더 빨리 실행되는지는 상황에 따라 다릅니다. 실제로 제 컴퓨터에서 실행했을 때는 bar가 0.9% 정도 빨랐지만, 위 코드를 조금 수정해서 실행했을 때는 foo가 빨랐던 경우도 있습니다. 최적화를 할 때 감으로 하지 말고 무조건 실제 성능을 측정해보라는 말이 괜히 있는 게 아닙니다.

더 알아보기

restrict 포인터의 엄밀한 동작

표준에서 restrict 포인터의 엄밀한 정의를 살펴보면 다음과 같이 매우 복잡한 내용이 나옵니다. 저도 완전히 이해하지는 못했습니다.

6.7.4.2 restrict의 형식적 정의

  1. 타입 T의 restrict 한정된 포인터인 개체 P를 지시하는 수단을 제공하는 일반 식별자의 선언을 D라고 한다.
  2. D가 블록 안에 등장하고 D의 기억 부류가 extern이 아닐 경우, 그 블록을 B라고 한다. D가 함수 정의의 매개변수 선언 목록에 등장할 경우, 그 함수에 대응하는 블록을 B라고 한다. 이외의 경우에는 main의 블록 (혹은 독립된 환경의 경우 프로그램 시작 시 호출되는 함수의 블록)을 B라고 한다.
  3. 이하의 내용에서, (B 안에서 E를 실행하기 이전의 어떤 시퀀스 포인트에서) PP가 기존에 가리키던 배열 개체의 사본을 가리키도록 수정하면 포인터 표현식 E의 값이 바뀔 때, E가 개체 P기반한다고 정의한다.139) '기반한다'는 개념은 포인터 타입을 가지는 표현식에 대해서만 정의됨에 유의하라.
  4. B의 각 실행 내에서, &LP에 기반하는 모든 좌측값을 L이라고 한다. L을 사용해 개체 X가 지시하는 값에 접근하고, X 역시 (어떤 수단으로든) 수정될 경우, 다음 요구사항이 적용된다: T는 const 한정되어서는 안 된다. X의 값에 접근하는 다른 모든 좌측값 역시 그 주소가 P에 기반하여야 한다. 본 조항에 대해 판단할 때 X를 수정하는 모든 접근은 P 역시 수정하는 것으로 취급하여야 한다. 다른 restrict 한정된 포인터 P2에 기반하며 블록 B2에 소속되는 포인터 표현식 EP에 대입되는 경우, B2의 실행이 B의 실행 이전에 시작되거나, B2의 실행이 대입 이전에 종료되어야 한다. 이상의 요구사항이 충족되지 않을 경우의 동작은 정의되지 않는다.
  5. 여기서 B의 실행이란 프로그램의 실행 중 B에 소속되고, 스칼라 타입과 자동 기억 기간을 가지는 개체의 수명에 대응하는 부분을 말한다.

139) 즉, EP를 통해 간접적으로 참조되는 개체의 값이 아니라 P의 값에 직접 의존하는 경우를 말한다. 예를 들어, 식별자 p(int **restrict) 타입을 가질 경우, 포인터 표현식 pp+1p가 지시하는 restrict 한정된 포인터 개체에 기반하지만, 포인터 표현식 *pp[1]은 그렇지 않다.

6.7.4.2 Formal definition of restrict

  1. Let D be a declaration of an ordinary identifier that provides a means of designating an object P as a restrict-qualified pointer to type T.
  2. If D appears inside a block and does not have storage class extern, let B denote the block. If D appears in the list of parameter declarations of a function definition, let B denote the associated block. Otherwise, let B denote the block of main (or the block of whatever function is called at program startup in a freestanding environment).
  3. In what follows, a pointer expression E is said to be based on object P if (at some sequence point in the execution of B prior to the evaluation of E) modifying P to point to a copy of the array object into which it formerly pointed would change the value of E.139) Note that "based" is defined only for expressions with pointer types.
  4. During each execution of B, let L be any lvalue that has &L based on P. If L is used to access the value of the object X that it designates, and X is also modified (by any means), then the following requirements apply: T shall not be const-qualified. Every other lvalue used to access the value of X shall also have its address based on P. Every access that modifies X shall be considered also to modify P, for the purposes of this subclause. If P is assigned the value of a pointer expression E that is based on another restrict pointer object P2, associated with block B2, then either the execution of B2 shall begin before the execution of B, or the execution of B2 shall end prior to the assignment. If these requirements are not met, then the behavior is undefined.
  5. Here an execution of B means that portion of the execution of the program that would correspond to the lifetime of an object with scalar type and automatic storage duration associated with B.

139) In other words, E depends on the value of P itself rather than on the value of an object referenced indirectly through P. For example, if identifier p has type (int **restrict), then the pointer expressions p and p+1 are based on the restricted pointer object designated by p, but the pointer expressions *p and p[1] are not.

— C23 표준 최종안, 6.7.4.2

다른 타입과의 상호작용

(함수 매개변수를 제외하고) 배열의 한정을 나타내는 문법은 없지만, 배열 타입을 나타내는 typedef 동의어를 한정하는 것은 가능합니다. 이 경우에는 배열의 모든 원소가 한정되지만, 배열 자체는 한정되지 않습니다. C23 이후에는 배열의 한정 여부가 항상 원소의 한정 여부를 따라갑니다.

// ✅ `Arr`가 `int [8]`의 동의어로 선언되었습니다.
typedef int Arr[8];

// ✅ `const Arr` (aka `const` 한정된 `int [8]`) 타입의 변수가 선언되었습니다.
const Arr arr = { 0, 1, 2, 3, 4, 5, 6, 7 };

// ❌ `const` 한정된 배열 원소에 쓸 수 없습니다.
arr[4] = 40;

한정자는 개체의 의미를 바꾸는 언어 기능이므로 함수를 한정할 수 없습니다. 위에서처럼 함수 타입의 typedef 동의어를 한정하려고 하면 const/volatile은 비정의 동작, restrict는 컴파일 오류가 발생합니다.

구조체나 공용체를 한정할 수 있으며, 그 구조체나 공용체의 모든 원소가 한정되는 효과를 가집니다.

// ✅ `foo`가 `const` 한정된 구조체 (a) 타입을 가지는 변수로 정의되었습니다.
const struct /* (a) */ {
	int x;
} foo = { 1 };

// ❌ 구조체 (a)가 `const` 한정되었으므로 원소에도 쓸 수 없습니다.
foo.x = 2;

한정되지 않은 타입의 포인터(T *)는 한정된 타입의 포인터(const T */volatile T */restrict T *)로 암시적 변환이 가능하고 대입 역시 가능하지만, 그 역은 한정자의 의미를 위반하므로 성립하지 않습니다. 이중 포인터 간의 암시적 변환은 불가능하며, 명시적 변환을 해야 합니다.

참고

이 규칙은 다음과 같이 이해하면 좋습니다.

  • 쓰기 연산을 해도 되는 포인터(T *)를 쓰기 연산을 하지 않는 포인터(const T *)에 대입해도 된다.
  • 쓰기 연산을 하면 안 되는 포인터(const T *)를 쓰기 연산을 할 수도 있는 포인터(T *)에 대입하면 안 된다.
  • 최적화를 해도 되는 포인터(T *)를 최적화를 하지 않는 포인터(volatile T *)에 대입해도 된다.
  • 최적화를 하면 안 되는 포인터(volatile T *)를 최적화를 할 수도 있는 포인터(T *)에 대입하면 안 된다.
  • 에일리어싱을 해도 되는 포인터(T **)를 에일리어싱을 하지 않는 포인터(T *restrict *)에 대입해도 된다.
  • 에일리어싱을 하면 안 되는 포인터(T *restrict *)를 에일리어싱을 할 수도 있는 포인터(T **)에 대입하면 안 된다.
void *p1, **pp1;

// ✅ `void *`가 `const void *`로 암시적 변환되었습니다.
const void *p2 = p1;

// ❌ `const void *`를 `void *`로 암시적 변환할 수 없습니다.
void *p3 = p2;

// ❌ `void **`를 `const void **`로 암시적 변환할 수 없습니다.
const void **pp2 = pp1;

명시적 포인터 변환 등을 통해 const/volatile 한정된 개체에 억지로 접근하려고 하면 당연히 비정의 동작이 됩니다. 특히 volatile은 읽기 연산도 비정의 동작입니다.

const int x = 1;
volatile int y = 2;

// 🔥 UB: `const` 한정되지 않은 포인터로 `const` 개체를 수정할 수 없습니다.
*(int *)&x = 3;

// 🔥 UB: `volatile` 한정되지 않은 포인터로 `volatile` 개체에 접근할 수 없습니다.
(void)*(int *)&y;

함수 지정자*function specifiers

C에는 두 종류의 함수 지정자가 있으며, 함수의 성질을 바꾸는 역할을 합니다. 위의 한정자와 비슷하게 컴파일러 최적화에 영향을 미칩니다.

C99 inline

inline 함수 지정자는 해당 함수를 인라인inline 처리하는 것이 좋다는 최적화 힌트를 남깁니다(실제로 이런 최적화가 일어난다는 보장은 없습니다). inline의 의미를 보장하기 위해 static이 아닌(즉, extern인) inline 함수는 함수 본문 안에 const가 아닌 static 변수를 선언할 수 없으며, 파일 범위의 static 변수도 사용할 수 없다는 규칙이 추가되지만, inline 함수를 재귀적으로 호출하는 것은 금지되지 않으며, 어느 정도 실제로 최적화가 되는 경우도 있습니다.

C11 _Noreturn

주의

C23 이전에는 <stdnoreturn.h>에서 _Noreturn에 해당하는 매크로 noreturn을 제공했습니다. C23에 어트리뷰트 [[noreturn]]이 추가되면서 기존의 함수 지정자 _Noreturn은 비권장 기능이, noreturn은 비권장 매크로가 되었습니다.

_Noreturn은 함수에 '반환하지 않음'의 의미를 더합니다. _Noreturn 함수가 호출된 뒤 실제로 return하면 비정의 동작이 되며, 컴파일러가 이 성질을 이용해 이 함수 뒤에 오는 코드를 삭제하는 최적화를 할 수 있습니다.

_Noreturn 함수가 반환하는 대신 취할 수 있는 동작의 예로는 아래와 같은 것들이 있습니다.

더 알아보기

다른 언어에서 '반환값 없음'과 '반환하지 않음'의 구분

void가 함수의 반환 타입에서 특별한 의미를 가지는 C나 Java 등의 주류 언어와 달리, Haskell이나 Rust 등 타입 시스템이 발달한 일부 언어에서는 하나의 값이 속하는 '단위 타입unit type'과 아무런 값도 속하지 않는 '바닥 타입bottom type'을 구분해 더욱 일관적으로 '반환하지 않음'을 표현합니다.

  • 단위 타입(Rust와 Haskell 모두 ())을 반환하는 함수는 반환할 수는 있지만 반환값이 없다고 볼 수 있습니다(가능한 값이 하나뿐이기 때문에 무슨 값인지 확인하는 의미가 없으므로). C에서 void를 반환하는 함수와 같은 역할을 합니다.
  • 바닥 타입(Rust에서는 실험 기능인 !, Haskell에서는 Void)을 반환하는 함수는 반환 자체를 할 수 없습니다(타입이 맞는 반환할 값을 만들 수 없으므로). C에서 _Noreturn이 있는 함수와 같은 역할을 합니다.

이런 체계에서는 바닥 타입 역시 일종의 '일급 타입'이므로 다른 타입과 조합해 더욱 풍부한 의미를 담을 수 있다는 장점이 있습니다. 예를 들어 Rust에서 Result<T, !>실패할 수 없는 결과를, Haskell에서 Void -> a호출할 수 없는 함수를 나타냅니다.

<declarator> 자세히 살펴보기

<declarator>는 기본적으로 선언하는 것의 이름인 식별자identifier가 중심이 되고, 추가로 다음과 같은 것들로 꾸밀 수 있습니다.

<declarator>가 그렇게 구현된 이유

참고

여기서 1번 떡밥을 마저 회수합니다.

int* x;가 틀리고 int *x;가 맞는지 설명할 수 있다.

<declarator><specifiers> 타입을 얻기 위해 거치는 연산을 나타냅니다.

아무 IDE(정 어렵다면 Ideone이나 Compiler Explorer)를 붙잡고 아래 코드를 입력해 보세요.

// 공통 준비 코드

// 타입 확인을 위한 구조체
typedef struct {
	int x;
} Foo;

// 값을 이 함수에 넣어서 경고 없이 컴파일이 된다면 타입이 일치하는 것입니다.
void typecheck(Foo x) {}

<declarator>의 모양과 거치는 표현식의 모양이 완전히 일치합니다. 여기서는 포인터와 배열 타입만 살펴봤지만, 함수 타입도 이 법칙을 만족합니다. 이것 때문에 사전 지식 없이 처음 C를 접하면 타입 시스템이 헷갈리게 느껴질 수 있는데, 일관적인 시스템이라는 것은 인정하지만 굳이 이렇게 비직관적인 방법을 택해야 했는지는 잘 모르겠습니다.

포인터

T *x;

포인터는 개체나 함수의 메모리상의 주소를 값으로 가지는 타입입니다. 단항 역참조 연산 *x를 거치면 타입 T의 값을 얻을 수 있습니다.

배열

T x[n];

배열은 같은 타입의 값 여러 개를 메모리상의 인접한 곳에 저장하는 타입입니다. n은 양수여야 하며, 0 이상 n 미만의 i에 대해 배열 원소 접근 연산 x[i]를 거치면 타입 T의 값을 얻을 수 있습니다.

n을 생략할 수 있으며(이 글에서는 '미지의 길이*unknown size'라고 하겠습니다), 이때는 불완전 타입이 됩니다.

다차원 배열

배열의 배열이나 배열의 배열의 배열 따위를 따로 다차원 배열이라고도 하는데, 언어상에서 특별히 다르게 동작하는 것은 아닙니다.

가변 길이 배열variable-length arrays

주의

가변 길이 배열은 구현체에서 선택적으로 지원할 수 있으며, C11 매크로 상수 __STDC_NO_VLA__1로 정의되었을 경우 구현체가 가변 길이 배열이나 가변 수식 타입을 지원하지 않습니다.

C23부터는 가변 길이 배열과 가변 수식 타입의 지원이 필수가 되었습니다. 단, 매크로 상수 __STDC_NO_VLA__가 1로 정의되었을 경우 구현체가 자동 기억 기간을 가지는 가변 길이 배열을 지원하지 않습니다.

n에 컴파일 타임 상수가 아닌 표현식을 넣을 수 있으며, 이를 가변 길이 배열이라고 합니다. 컴파일 타임에 길이가 완전히 결정되는 일반 배열과 달리 실제로 선언이 완료된 뒤에야 길이 표현식을 평가한 값으로 길이가 결정됩니다.

가변 길이 배열이 선언될 때 n이 0 이하의 값으로 평가될 경우에는 비정의 동작이 됩니다.

가변 길이 배열은 선언과 동시에 초기화할 수 없습니다. 단, C23에 추가된 공백 초기화*empty-initialization(정수는 0으로, 실수는 +0으로, 포인터는 널로, 배열/구조체는 모든 멤버를 재귀적으로, 공용체는 첫 멤버를 재귀적으로 공백 초기화)는 가변 길이 배열에도 사용 가능합니다.

int x;

// ❌ 가변 길이 배열을 선언과 동시에 초기화할 수 없습니다.
int foo[x] = { 1 };

// ✅ 가변 길이 배열을 공백 초기화할 수 있습니다 (C23).
int bar[x] = {};

가변 길이 배열은 자동이나 할당 기억 기간만을 가질 수 있으며, 연결성은 가질 수 없습니다.

int x, y;

// ✅ 자동 기억 기간을 가지는 가변 길이 배열이 선언되었습니다.
int vla_auto[x];

// ✅ 할당 기억 기간을 가지는 가변 길이 배열이 할당되었습니다.
int (*vla_alloc)[x] = malloc(sizeof(int [x]));

// ✅ 가변 길이 배열의 배열이 할당되었습니다.
int (*vla_multi)[x] = malloc(y * sizeof(int [x]));

가변 길이 배열이 포함된 타입을 가변 수식 타입*variably-modified types이라고도 합니다. 가변 수식 타입은 블록 범위나 함수 원형에서만 선언할 수 있으며, 구조체나 공용체는 가변 수식 타입의 멤버를 가질 수 없습니다.

int x;

// ❌ 파일 범위에서 가변 길이 배열을 선언할 수 없습니다.
int foo[x];

// ❌ 파일 범위에서 가변 수식 타입의 변수를 선언할 수 없습니다.
int (*bar)[x];

// ✅ 함수 원형에서 가변 길이 배열이 함수의 인자로 선언되었습니다.
int baz(int y, int [y]);

void fn(void) {
	// ❌ 구조체가 가변 길이 배열을 멤버로 가질 수 없습니다.
	struct Foo {
		int foo[x];
	};
}

함수

T x(...);

함수는 0개 이상의 인자를 받아 값을 반환하는 연산을 나타내는 타입입니다. 함수 호출 연산 x(...)를 거치면 타입 T의 값을 얻을 수 있습니다.

함수는 우측값을 반환하므로 함수의 반환 타입에 한정자를 붙이는 것은 의미가 없으며, 한정자를 붙이지 않은 것처럼 동작합니다.

함수 포인터

함수 타입을 가리키는 포인터를 따로 함수 포인터라고도 하는데, 선언할 때의 문법으로 인해 자칫 함수 포인터를 특별하게 취급하는 것으로 오해할 수 있지만 언어상에서 특별히 다르게 동작하는 것은 아닙니다. 개체가 아닌 함수의 특성상 표준에서는 개체의 포인터와 함수 포인터를 어느 정도 구분하긴 합니다.

가변 인자 함수variadic functions

함수에 매개변수가 하나 이상 있을 경우, 매개변수 목록의 끝에 ...를 추가해 printf처럼 가변 개수의 인자를 받을 수 있습니다. C23부터는 일반 매개변수 없이 가변 인자만 받는 함수도 만들 수 있습니다.

가변 인자 함수에는 호출할 때마다 다른 개수와 타입의 인자를 전달할 수 있습니다. ... 부분으로 전달된 가변 인자에는 <stdarg.h>에서 제공하는 va_start, va_arg, va_end로 접근할 수 있습니다.

#include <stdarg.h>

// ✅ 고정 매개변수 1개를 포함한 가변 인자 함수가 정의되었습니다.
int sum(int count, ...) {
	int result = 0;

	va_list args;
	va_start(args, count);
	for(int i = 0; i < count; i++)
		result += va_arg(args, int);
	va_end(args);
	return result;
}
K&R 스타일 함수 선언

현재의 C 표준이 있기 전에는 함수를 선언하는 방법이 지금과 많이 달랐습니다. 대표적으로는 함수 원형의 개념이 없었기 때문에 함수를 호출할 떄 타입 검사나 인자의 개수 확인이 이루어지지 않았으며, 함수를 잘못 호출하면 컴파일 오류가 발생하는 대신 비정의 동작이 됩니다. 심지어 C17까지도 매개변수 목록을 생략한 함수 선언 T fn(); 역시 함수 원형이 없는 K&R 스타일 함수 선언입니다.

// ✅👎 K&R 스타일 함수가 정의되었습니다.
// 매개변수 목록에 타입이 작성되어 있지 않습니다.
int old_style_fn(x)
	// 매개변수의 타입 정보가 매개변수 목록 바깥에 있습니다.
	int x;
{
	return x;
}

// ✅👎 매개변수 목록이 생략된 K&R 스타일 함수가 정의되었습니다.
// 이 함수에는 이론상 몇 개의 인자를 넘겨주어도 되지만,
// 같은 프로그램 내에서 `fn`을 다른 개수의 인자로 호출할 경우 비정의 동작이 됩니다.
void fn() {
	int x = 1;
	double y = 1.2;

	// ✅ `old_style_fn`에 `int`를 전달했습니다.
	old_style_fn(x);
	// 🔥 UB: `old_style_fn`에 `double`을 전달할 수 없습니다.
	old_style_fn(y);
	// 🔥 UB: `old_style_fn`에 잘못된 개수의 인자를 전달할 수 없습니다.
	old_style_fn();
}

현재 우리가 알고 있는 함수 원형의 개념은 최초의 표준인 C89에 와서야 정립되었습니다. 매개변수가 없는 함수 원형을 선언하려면 T fn(void);와 같이 매개변수 목록 자리에 void를 넣어야 합니다.

C23부터는 K&R 스타일 함수 선언이 완전히 폐지되며, 매개변수 목록이 생략된 T fn(); 역시 T fn(void);와 같은 의미를 가집니다.

매개변수 타입의 변환

함수는 배열이나 함수를 인자로 받을 수 없습니다. 함수의 매개변수 목록에 배열이나 함수 타입을 작성할 수 있지만, 각각 포인터와 함수 포인터로 일괄 변환됩니다.

// ✅ `int (int *, int [], int (int), int [][4])` (aka `int (int *, int *, int (*)(int), int (*)[4])`) 타입의 함수가 선언되었습니다.
int foo        (int *a, int  b[], int   c (int), int   d [4][4]);

// 위 함수의 매개변수 목록은 아래처럼 변환됩니다.
int foo_decayed(int *a, int *b  , int (*c)(int), int (*d)   [4]);

이 동작은 아래에서 설명할 값 변형과 직접적으로 관련되어 있습니다.

가변 길이 배열을 인자로 전달할 수 있습니다. 단, 함수 원형에서는 모든 가변 수식 타입이 [*]으로 변환됩니다.

// ✅ `int (int n, int [n][n])` (aka `int (int, int (*)[*])`) 타입의 함수가 선언되었습니다.
int foo        (int n, int   arr [n][n]);

// 위 함수의 매개변수 목록은 아래처럼 변환됩니다.
int foo_decayed(int n, int (*arr)   [*]);
배열 매개변수의 static

매개변수로 (포인터로 변환되는) 배열을 전달할 때 한정자 이외에도 static 키워드를 추가할 수 있습니다. 이 경우에는 널이 아니며 최소한 병기된 길이 이상의 배열을 가리키는 포인터만 인자로 전달해야 합니다.

static int arr4[4], arr8[8];

// ✅ `static`이 병기된 배열 매개변수를 가지는 함수 `foo`가 선언되었습니다.
// 매개변수 `x`에는 길이가 8 이상인 배열만 전달해야 합니다.
void foo(int x[static 8]) {}

void bar() {
// ✅ `foo`에 길이가 8인 배열의 포인터를 인자로 전달했습니다.
foo(arr8);
// 🔥 UB: `foo`에 길이가 8 미만인 배열의 포인터를 전달할 수 없습니다.
foo(arr4);
// 🔥 UB: `foo`에 널 포인터를 전달할 수 없습니다.
foo(NULL);
}

파생 타입 조합하기

파생 타입을 여러 가지 순서로 조합해 포인터를 반환하는 함수, 배열을 가리키는 포인터, 다중 포인터 등 여러 가지 타입을 만들 수 있습니다. 단, 모든 경우의 수가 허용되지는 않으며 다음과 같은 조합은 금지됩니다.

파생 선언자는 의도적으로 표현식의 모양과 일치하도록 정해졌기 때문에 파생 타입을 올바르게 조합하려면 단항 연산자의 우선순위를 익혀야 합니다. C의 연산자 우선순위 중 역참조, 배열, 함수 연산자만 살펴보면 다음과 같습니다.

  1. 후위 연산자
    • 함수 호출 ()
    • 배열 원소 접근 []
  2. 전위 연산자
    • 역참조 *

순위가 같은 연산자끼리는 안쪽에서 바깥쪽으로 실행됩니다.

참고

위에 제시된 연산자뿐만 아니라 C의 다른 단항 연산자, 그리고 다른 언어의 단항 연산자도 후위 연산자의 우선순위가 전위 연산자보다 높은 경향을 띠는데, 왜 이런 현상이 일어나는지는 처음 글을 쓴 지 거의 4년 반이 지난 지금도 알지 못하고 있습니다. 일단 저는 -f()-(f())가 아니라 (-f)()로 파싱되면 곤란하다는 논리로 받아들이고 있습니다.

위의 연산자 우선순위에 따라 함수 호출과 배열 원소 접근이 역참조보다 우선순위가 높기 때문에 역참조 직후에 함수 호출이나 배열 원소 접근이 필요하다면 괄호를 씌워야 합니다. 역시 같은 논리로 함수의 포인터나 배열의 포인터가 필요하다면 괄호를 씌워야 합니다.

// ✅ `int`를 반환하는 함수 포인터가 선언되었습니다.
// 함수 선언자 `(void)`가 포인터 선언자 `*`보다 우선순위가 높으므로 괄호가 필요합니다.
int (*foo)(void);

// 함수 포인터를 역참조하고 바로 호출할 때 괄호가 필요한 것과 같은 이유입니다.
(*foo)();

// 괄호를 생략하면 포인터를 반환하는 함수가 됩니다.
int *foo2(void);

// ✅ `int`의 배열의 포인터가 선언되었습니다.
// 배열 선언자 `[4]`가 포인터 선언자 `*`보다 우선순위가 높으므로 괄호가 필요합니다.
int (*bar)[4];

// 배열의 포인터를 역참조하고 바로 원소에 접근할 때 괄호가 필요한 것과 같은 이유입니다.
(*bar)[1];

// 괄호를 생략하면 포인터의 배열이 됩니다.
int *bar[4];

또한, 표현식은 안쪽에서 바깥쪽으로 실행되므로 논리적으로 가장 바깥에서 꾸며주는 파생 선언자가 코드상에서는 가장 안쪽으로 들어갑니다.

// ✅ `int`의 배열의 포인터가 선언되었습니다.
// 포인터 선언자 `*`가 안쪽에 있지만 논리적으로는 가장 바깥에 있습니다.
int (*foo)[3];

// ✅ `int`를 반환하는 함수 포인터의 배열이 선언되었습니다.
// 배열 선언자 `[2]`가 함수 선언자 `(int)`보다 안쪽에 있지만 논리적으로는 배열 선언자가 더 바깥에 있습니다.
// 즉, 이 변수는 함수가 아닌 배열입니다.
int (*bar[2])(int);
파생 타입 쉽게 읽기

참고

여기서 4번 떡밥을 회수합니다.

int *(*(*x)(char *))[64];와 같은 헷갈리는 선언을 그나마 쉽게 읽을 수 있다.

위에서 언급한 '표현식 논리'를 이해했다면 C 타입을 작성하는 것뿐만 아니라 읽는 데도 응용할 수 있습니다. int (*x[8][64])(char *);를 예로 들면, 아래의 단계를 하나씩 밟아나가며 하나의 영어 문장을 완성합니다.

  1. 식별자를 찾는다. 이 식별자가 파생 타입 읽기의 기준점이 된다.
    • "x is a..."
  2. 우선순위에 따라 파생 선언자를 읽는다.
    1. 오른쪽으로 훑으면서 파생 타입이 보일 때마다 차례대로 추가한다. 닫는 괄호가 보이거나 <declarator>가 끝나면 멈춘다.
      • "x is a [8] of [64] of..."
    2. 왼쪽으로 훑으면서 파생 타입이 보일 때마다 차례대로 추가한다. 여는 괄호가 보이거나 <declarator>가 끝나면 멈춘다.
      • "x is a [8] of [64] of * of..."
    3. <declarator> 전체를 읽을 때까지 위의 과정을 반복한다.
      • "x is a [8] of [64] of * of (char *) of..."
  3. 더 이상 읽을 것이 없으면 <specifiers>로 마무리한다.
    • "x is a [8] of [64] of * of (char *) of int."
  4. 완성된 문장을 자연어로 바꿀 수 있다.
    • "x is an array of size 8×64, of pointers, to functions that accept a pointer to char, returning int."
  5. 완성된 자연어 문장을 거꾸로 읽으면 한국어 문장으로도 바꿀 수 있다.
    • "xint를 반환하는, char의 포인터를 받는 함수의, 포인터의, 길이 8×64의 배열이다."

원하는 파생 타입을 코드로 작성하고 싶다면 위 과정을 역순으로 하면 됩니다.

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

  1. float **y[4][8];
  2. int *(*foo)(int []);
  3. char **(*(*z)[123])[456];
  4. int *(*(*x)(char *))[64];
    • 4번 떡밥에서 언급했던 바로 그 타입입니다.

해답

정답 확인하기
  1. float **y[4][8];
    • y is a [4] of [8] of * of * of float.
    • y is an array of size 4×8, of double pointers, to floats.
    • yfloat의, 이중 포인터의, 길이 4×8의 배열이다.
  2. int *(*foo)(int []);
    • foo is a * of (int []) of * of int.
    • foo is a pointer, to a function that accepts an array of ints, returning a pointer, to an int.
    • fooint의, 포인터를 반환하는, int의 배열을 받는 함수의, 포인터이다.
  3. char **(*(*z)[123])[456];
    • z is a * of [123] of * of [456] of * of * of char.
    • z is a pointer, to an array of size 123, of pointers, to arrays of size 456, of double pointers, to chars.
    • zchar의, 이중 포인터의, 길이 456의 배열의, 포인터의, 길이 123의 배열의, 포인터이다.
    • 맥락상 허용될 경우 'char의 이중 포인터'를 '문자열의 포인터'로 치환해도 됩니다.
  4. int *(*(*x)(char *))[64];
    • x is a * of (char *) of * of [64] of * of int.
    • x is a pointer, to a function that accepts a pointer to char, returning a pointer, to an array of size 64, of pointers, to ints.
    • xint의, 포인터의, 길이 64의 배열을 반환하는, char의 포인터를 받는 함수의, 포인터이다.
    • 맥락상 허용될 경우 매개변수로 등장하는 'char의 포인터'를 '문자열'로 치환해도 됩니다.

단독으로 쓰는 타입 이름

타입 변환 연산자나 sizeof 등 선언문 이외에도 타입을 작성하는 곳이 있기 때문에 식별자 없이 타입을 작성해야 하는 경우가 생깁니다. 이 경우에는 (불필요한 괄호가 없다는 가정 하에) 간단히 식별자만 생략해 타입 이름type names으로 만들 수 있습니다.

int v1;
// ✅ `v1`의 타입이 `int`로 작성되었습니다.
sizeof(int);

float v2[5];
// ✅ `v2`의 타입이 `float [5]`로 작성되었습니다.
sizeof(float [5]);

void *v3;
// ✅ `v3`의 타입이 `void *`로 작성되었습니다.
sizeof(void *);

char *v4[64];
// ✅ `v4`의 타입이 `char *[64]`로 작성되었습니다.
sizeof(char *[64]);

char (*v5)[64];
// ✅ `v5`의 타입이 `char (*)[64]`로 작성되었습니다.
sizeof(char (*)[64]);

double (*v6)(double);
// ✅ `v6`의 타입이 `double (*)(double)`로 작성되었습니다.
sizeof(double (*)(double));

int *(*(*x)(char *))[64];
// ✅ `x`의 타입이 `int *(*(*)(char *))[64]`로 작성되었습니다.
sizeof(int *(*(*)(char *))[64]);

단, 불필요한 괄호가 있을 경우 식별자를 생략하면 함수 선언자로 인식될 수 있습니다.

// ✅ `int` 타입의 변수 `v7`이 선언되었습니다.
int (v7);
// 🔥 `v7`의 타입이 아닌 `int ()` (`int`의 함수)로 인식됩니다.
sizeof(int ());

저는 이런 이유로 타입 이름을 작성할 떄도 int*보다 int *가 더욱 올바르다고 봅니다.

보충: 사실 C++도 대체로 같습니다

C++의 타입 시스템에는 C라는 기반 위에 무언가 여러 가지 복잡한 것들이 추가되었고 의미도 많이 바뀌었지만, 적어도 파생 선언자는 어느 정도 비슷하게 사용할 수 있습니다. C와 비교하자면 다음과 같습니다.

C++에서 클래스 T를 사용해 T x();라고 선언했는데 생성자 T::T()가 호출되지 않는 것도 xT를 반환하는 함수(aka T ())로 선언되었기 때문입니다.4

위와 같은 이유로 저는 C++에서 참조를 선언할 때도 T& x;보다 T &x;가 더욱 올바르다고 봅니다.

값 카테고리value categories

C의 모든 표현식에는 타입뿐만 아니라 값 카테고리도 같이 부여되며, 그 표현식이 메모리상의 위치를 나타내는지 메모리와 무관한 순수 값을 나타내는지를 결정합니다.

C에는 다음과 같은 세 가지 값 카테고리가 있습니다.

참고

'좌측값'과 우측값의 형식적인 이름인 '비좌측값 개체non-lvalue object'이라는 이름은 (주로) 대입 연산자 =의 좌변에 올 수 있거나 없다는 의미에서 붙여진 이름이며, '값'이라고 부르지만 실제로는 표현식을 분류하는 개념이기 때문에 다소 비직관적으로 느껴질 수도 있습니다.

Rust에서는 '좌측값'과 '우측값' 대신 실제 의미를 반영해 '자리 표현식place expressions'과 '값 표현식value expressions'이라는 표현을 사용하고 있으며, 특히 대입 연산자의 좌변에 오는 표현식을 '피대입 표현식*assignee expressions'이라고 합니다.

좌측값lvalue

좌측값은 메모리상의 실제 개체를 가리키는 표현식을 나타내며, 실제 메모리 위치에 수행해야 말이 되는('좌측값 맥락lvalue context') 다음과 같은 연산을 할 수 있습니다.

좌측값에 해당하는 표현식은 다음과 같습니다.

참고로 아래의 표현식은 C++에서는 좌측값이지만 C에서는 좌측값이 아닙니다.

수정 가능한 좌측값modifiable lvalue

대부분의 좌측값에는 쓰기 연산을 할 수 있지만, 예외적으로 아래에 해당하는 좌측값에는 쓰기 연산을 할 수 없습니다.

우측값rvalue

우측값의 엄밀한 이름은 '비좌측값 개체non-lvalue object'이지만, 우측값이라는 이름도 자주 사용합니다.

우측값은 메모리상에 존재하지 않고 값으로만 존재하는 '추상적인' 개체 타입의 표현식을 나타냅니다. 메모리상에 없기 떄문에 주소도 획득할 수 없습니다.

좌측값이 아닌 개체 타입의 값은 모두 우측값이며, 좌측값이더라도 메모리 조작이 필요 없을 경우 읽기 연산을 통해 우측값으로 변환됩니다.

함수 지시자*function designator

함수는 개체 타입이 아니므로 좌측값도 우측값도 될 수 없으며, 함수를 가리키는 표현식은 특별히 함수 지시자라고 합니다.

함수는 함수 지시자 그대로는 쓸모가 없으며, 명시적으로든 암시적으로든 항상 함수 포인터로 변환됩니다.

불완전 타입incomplete types

참고

여기서 5번 떡밥을 회수합니다.

왜 다차원 배열의 맨 처음 길이만 생략할 수 있는지 이해할 수 있다.

타입이 선언은 되었는데 아직 완전히 정의되지 않아서 실제로 사용할 수 없는 경우가 있고, 특히 타입의 크기를 알 수 없는 경우가 있으며, 이를 불완전 타입이라고 합니다.

불완전 타입에는 다음과 같은 것들이 있습니다. 이 글에서는 불완전 타입을 완전 타입으로 만드는 것을 '완성한다*complete'고 하겠습니다.

(소스 코드상에서의 위치 기준으로) 아직 완성되지 않은 불완전 타입은 소스 코드에서 나중에 완성되더라도 대부분의 상황에서 타입으로 사용할 수 없습니다. 이는 구조체나 공용체가 자기 자신을 멤버로 가질 수 없게 하는, 즉 재귀적 타입을 차단하는 효과를 가집니다.

// ✅ 불완전 `int []` 타입을 가지는 변수 `arr`가 선언되었습니다.
int arr[];

int foo(void) {
	// ❌ 불완전 타입의 크기를 구할 수 없습니다.
	// `arr`의 타입이 아래에서 완성되지만 이 시점에는 아직 불완전 타입입니다.
	return sizeof(arr);
}

// ✅ `arr`의 정의와 동시에 타입이 `int [1]`로 완성되었습니다.
int arr[1] = { 1 };

struct Foo {
	// ❌ 불완전 타입을 멤버로 가질 수 없습니다.
	// `struct Foo` 타입이 아래에서 완성되지만 이 시점에는 아직 불완전 타입입니다.
	struct Foo child;
	// ✅ 불완전 타입의 포인터 타입의 멤버가 선언되었습니다.
	struct Foo *child_ptr;
};

불완전 타입과 파생 선언자 타입은 다음과 같이 상호작용합니다.

타입 변환

타입 변환은 원래 값의 정보를 최대한 보존하면서 다른 타입의 값으로 변환하는 과정입니다.

C에서는 여러 가지 상황에 여러 가지 타입 변환이 일어나는데, 어떤 상황에 어떻게 변환되는지 정리해 보겠습니다.

호환성

서로 다른 번역 단위에서 같은 개체에 다른 타입을 매기려면 그 의미와 표현이 충분히 비슷해서 같은 타입으로 볼 수 있어야 하는데, 이 경우 두 타입이 '호환된다compatible'고 합니다. 호환되는 타입끼리는 별도의 연산 없이 그대로 변환이 가능합니다.

두 타입이 호환될 조건은 다음과 같습니다. 아래의 조건은 위에서 언급했던 '별도의 연산 없이 그대로 대입해도 문제가 없는 경우'로 요약할 수 있습니다.

목록 펼치기
  • 타입 시스템상에서 완전히 같은 타입일 경우
  • 호환되는 타입의 똑같이 한정된 타입일 경우
  • 호환되는 타입을 가리키는 포인터 타입일 경우
  • 둘 모두가 배열이며, 다음을 모두 만족할 경우
    • 두 타입의 원소 타입이 호환된다.
    • 두 배열의 길이가 컴파일 타임 상수인 경우에 한해, 완전히 같은 길이를 가진다.
  • 둘 모두가 구조체/공용체/열거형 중 같은 종류이며, 다음을 모두 만족할 경우
    • C99 둘 중 하나가 태그를 가지는 경우에 한해, 다른 하나가 같은 태그를 가진다.
    • 둘 모두가 완전 타입인 경우에 한해, 두 타입의 멤버의 개수가 같고, 대응하는 멤버끼리 이름이 일치하고 타입이 호환된다.
    • 구조체에 한해, 멤버가 선언되는 순서가 같다.
    • 구조체와 공용체에 한해, 대응하는 비트필드끼리 같은 비트 수를 가진다.
    • 열거형에 한해, 대응하는 멤버끼리 같은 값을 가진다.
  • 한쪽이 열거형이고, 다른 한쪽이 그 열거형의 기반 타입일 경우
  • 둘 모두가 함수이고, 다음을 모두 만족할 경우
    • 두 함수의 반환 타입이 호환된다.
    • 두 함수 모두 매개변수 목록이 있는 경우에 한해, 매개변수의 개수와 가변 인자 여부가 같으며, 매개변수 타입 변환과 최상위 한정자 제거를 거친 뒤 대응하는 매개변수 타입이 호환된다.
    • 한쪽이 매개변수 목록이 있고 다른 한쪽이 K&R 스타일 선언일 경우에 한해, 전자가 가변 인자 함수가 아니고 전자의 매개변수가 디폴트 인자 승격의 영향을 받지 않는다.
    • 한쪽이 매개변수 목록이 있고 다른 한쪽이 K&R 스타일 정의일 경우에 한해, 두 함수의 매개변수의 개수가 같고 전자의 각 매개변수가 디폴트 인자 승격을 거치고 나서 후자의 대응하는 매개변수와 호환된다.

합성 타입*composite types

같은 개체에 호환되지만 서로 다른 타입을 매길 경우 두 타입의 '교집합'을 찾는데, 이를 합성 타입이라고 합니다. 합성 타입을 구하는 규칙은 다음과 같습니다.

목록 펼치기
  • 배열의 합성 타입
    • 한쪽의 길이가 컴파일 타임 상수일 경우, 그 길이를 가지는 배열이 합성 타입이 된다.
    • C99 한쪽이 길이가 지정된 가변 길이 배열이지만 그 길이가 평가되지 않은 경우, 합성 타입을 구하려고 하면 비정의 동작이 된다.
    • C99 한쪽이 길이가 지정된 가변 길이 배열이고 그 길이가 평가된 경우, 그 길이를 가지는 배열이 합성 타입이 된다.
    • C99 한쪽이 길이가 지정되지 않은 가변 길이 배열일 경우, 길이가 지정되지 않은 가변 길이 배열이 합성 타입이 된다.
    • 둘 모두 미지의 길이를 가질 경우, 미지의 길이를 가지는 배열이 합성 타입이 된다.
    • 합성된 배열의 원소는 두 배열의 원소의 합성 타입을 가진다.
  • 함수의 합성 타입
    • 둘 중 하나가 K&R 스타일 함수이고 다른 하나는 매개변수 목록이 있을 경우, 후자의 매개변수 목록을 가지는 함수 원형이 합성 타입이 된다.
    • 둘 모두 매개변수 목록이 있을 경우, 대응하는 매개변수끼리의 합성 타입을 구한다.

암시적 변환

암시적 변환은 프로그래머가 의도적으로 타입 변환 연산자를 사용하지 않아도 자동으로 타입 변환이 일어나는 경우를 일컫습니다.

보통 산술 변환에서 부호 없는 타입을 '선호하는' 현상을 제외하면 대부분 상식 선에서 변환이 일어나고, 핵심을 요약하자면 다음과 같습니다.

적용되는 상황과 목표 타입

대입할 때처럼 변환conversion as if by assignment

다음과 같은 대입에 준하는 상황에 대입되는 변수의 타입으로 변환됩니다.

디폴트 인자 승격*default argument promotions

인자를 전달할 때 다음과 같이 함수 원형을 참고할 수 없는 상황에 정수 타입은 승격되고, 실수인 floatdouble로 변환됩니다. (허수와 복소수는 변환되지 않습니다.)

보통 산술 변환*usual arithmetic conversions

다음과 같은 산술 연산자를 사용할 때 모든 피연산자가 정수 승격과 함께 아래에서 설명할 공통 실수 타입*common real type에 대응하는 실수/순허수/복소수 타입으로 변환됩니다. 이때 양쪽 모두가 실수이면 실수, 순허수이면 순허수, 이외의 경우에는 복소수 타입이 됩니다.

공통 실수 타입을 구하는 규칙은 다음과 같습니다.

목록 펼치기
  1. C23 피연산자 중 하나라도 십진 부동소숫점 타입일 경우, 다른 피연산자가 이진 부동소숫점이어서는 안 된다.
  2. 양쪽 피연산자의 타입을 아래의 '크기 순서'대로 비교해서 가장 큰 것이 공통 실수 타입이 된다. 해당하는 타입이 없으면 다음으로 넘어간다.
    1. _Decimal128
    2. _Decimal64
    3. _Decimal32
    4. long double
    5. double
    6. float
  3. 이 시점에서는 양쪽 피연산자가 모두 정수 타입으로, 정수 승격을 수행한다.
  4. 양쪽 타입이 같으면 그 타입이 공통 실수 타입이 된다.
  5. 양쪽 타입의 부호 유무가 같으면 둘 중 정수 변환 등급이 높은 타입이 공통 실수 타입이 된다.
  6. 부호 없는 타입의 등급이 다른 타입 이상일 경우 그 타입이 공통 실수 타입이 된다.
  7. 부호 있는 타입이 부호 없는 타입의 값을 모두 표현할 수 있을 경우 그 타입이 공통 실수 타입이 된다.
  8. 부호 없는 타입에 부호를 추가한 타입이 공통 실수 타입이 된다.
정수 승격*integer promotions

정수 승격은 int/unsigned int 미만의 '정수 변환 등급'을 가지는 정수 타입과 기반 타입이 _Bool, int, signed int, unsigned int비트필드intunsigned int로 변환되는 것을 일컫습니다. 기존 타입이나 비트필드의 모든 값을 int로 나타낼 수 있으면 int, 그렇지 않으면 unsigned int가 선택됩니다. 단, C23 비트 정수는 정수 승격을 거치지 않으며, 비트 정수에 기반하는 비트필드는 기반하는 타입으로 변환됩니다.

정수 승격은 대표적으로 디폴트 인자 승격과 보통 산술 변환에서 나타나지만, 다음 연산자에서도 보통 산술 변환 없이 정수 승격이 발생합니다.

정수 변환 등급*integer conversion rank

정수 변환 등급은 다음과 같은 규칙으로 결정되는 추이적 관계입니다. 이렇게 정의된 등급 관계는 보통 산술 변환정수 승격에서 '더 큰 타입'을 찾는 데 사용합니다.

목록 펼치기
  • 부호 있는 정수 타입끼리는 모두 등급이 다르며, 크기가 큰 타입이 등급이 높다.
    • signed char < short < int < long < long long
  • 부호 없는 정수 타입의 등급은 대응하는 부호 있는 타입과 같다.
  • C99 확장 정수 타입과 C23 비트 정수의 등급은 같은 크기를 가지는 표준 정수 타입의 등급보다 낮다.
  • char, signed char, unsigned char의 등급은 모두 같다.
  • _Bool (C23 bool)의 등급은 다른 어떤 표준 정수 타입보다도 낮다.
  • 열거형의 등급은 그 기반 타입과 같다.
  • C23 부호 있는 비트 정수의 등급은 그 미만의 크기를 가지는 어떤 표준 정수 타입이나 비트 정수보다도 높다.
  • 확장 정수 타입끼리, 혹은 C23 비트 정수와 확장 정수 타입 사이의 등급 관계는 구현체 정의 동작에 해당한다.

암시적 변환의 의미

암시적 변환은 다음과 같은 두 단계를 거칩니다.

  1. 필요할 경우, 값 변형
  2. #타입 간 변환 단락의 변환 방법 중 적용 가능한 것 하나
값 변형*value transformations

우측값으로써 사용되는 좌측값이나 함수 지시자는 그 자체로는 직접 사용할 수 없으며, 다음과 같은 변환을 거칩니다.

좌측값 변환lvalue conversion

좌측값 중 배열이 아니고, 좌측값 맥락이나 sizeof 연산자의 인자로 쓰이지 않는 것은 암시적으로 우측값으로 변환됩니다. 이 연산은 메모리상의 위치에서 실제 값을 획득하는 연산, 즉 읽기 연산에 해당하는 변환입니다.

한정자와 원자성은 (좌측값) 개체의 의미를 바꾸는 언어 기능이므로 좌측값 변환을 거치는 타입은 한정자와 원자성을 잃습니다. 아래에서 살펴볼 명시적 변환의 결과도 우측값이므로 한정된 타입으로 명시적 변환을 해도 의미가 없고, 주소 획득과 역참조를 거쳐야 합니다.

int x = 0;

// ❌ 우측값에 대입할 수 없습니다.
(const int)x = 1;
// ❌ 좌측값이므로 `const`가 효력을 가지며, 대입할 수 없습니다.
*(const int *)&x = 1;

// 🔥 우측값을 `volatile` 한정할 수 없으므로 읽기 연산이 보장되지 않습니다.
(void)(volatile int)x;
// ✅ `x`에 대한 읽기 연산이 수행되었습니다.
(void)*(volatile int *)&x;

좌측값 변환을 거치는 값이 불완전 타입을 가지거나, 자동 기억 기간을 가지고 주소가 획득된 적도(scanf 등의 함수에 포인터를 전달해 초기화하는 경우가 있으므로) 초기화나 대입된 적도 없을 경우에는 비정의 동작이 됩니다.

배열에서 포인터로 변환array to pointer conversion

참고

여기서 6번 떡밥을 회수합니다.

배열과 포인터가 정확히 어떻게 다른지 이해할 수 있다.

좌측값 중 배열이고, 좌측값 맥락이나 sizeof/C23 typeof/typeof_unqual 연산자의 인자로도, char * 타입의 초기화에도 쓰이지 않는 것은 좌측값 변환 대신 배열에서 포인터로 변환을 거치며, 암시적으로 그 배열의 첫 번째 원소를 가리키는 우측값 포인터로 변환됩니다.

C에서 배열에서 포인터로 변환이 암시적으로 이루어지기 때문에 배열과 포인터가 비슷하다는 인상을 주는데, 이 둘은 전혀 다른 동작을 하는 서로 다른 타입입니다. 대표적으로 다음과 같은 차이를 보입니다.

register 개체의 주소를 획득할 수 없다는 규칙에 의해, 변환을 거치는 배열이 register일 경우에는 비정의 동작이 됩니다.

함수에서 포인터로 변환function to pointer conversion

함수 지시자이고, 주소 획득(&)이나 sizeof/C23 typeof/typeof_unqual 연산자의 인자로 쓰이지 않는 것은 함수에서 포인터로 변환을 거치며, 암시적으로 그 함수 지시자가 가리키는 함수의 우측값 포인터로 변환됩니다.

위에서 다룬 배열과 포인터의 비슷한 듯 다른 관계가 함수와 함수 포인터에도 비슷하게 적용됩니다.

추가로 함수에서 포인터로 변환에 의해 다음과 같은 재미있는 동작이 성립합니다.

응용: 다차원 배열 할당

참고

여기서 7번 떡밥을 회수합니다.

단 한 번의 malloc 호출로 다차원 배열을 동적 할당받을 수 있다.

사실은 이 글에 '당신은 2차원 배열 동적 할당을 99.9% 잘못 배웠다' 같은 부제목을 넣고 싶었지만 그건 어그로성이 너무 짙어서 하지 않기로 했습니다.

C에서 2차원 (n×m) 배열을 동적 할당할 때는 보통 다음과 같은 방법을 배웁니다.

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

참고

C++에서와 달리, C에서는 암시적 변환 규칙으로 인해 malloc 호출을 명시적으로 타입 변환하지 않아도 됩니다.

이렇게 하면 mallocfreen + 1번 해야 되니 꽤나 불편합니다. 게다가 메모리 공간을 여러 번 할당받으므로 배열 공간 전체가 연속이라는 보장이 없습니다.

길이가 서로 다른 (특히 길이가 도중에 바뀔 수도 있는) 배열 n개의 배열(jagged array라고 합니다)이 필요하다면 일반적으로 이 이외에 뾰족한 방법이 없고, 이 방법을 그대로 쓰면 됩니다. 그 대신, 길이가 m으로 고정된 배열 n개의 배열이라면 더 좋은 방법이 있습니다.

위에서 보았듯 배열은 그 자체로는 사용할 수 없기 때문에 함수의 반환값으로 배열을 반환할 수 없고, malloc 역시 같은 이유로 배열이 아닌 포인터를 반환합니다. 즉, 위의 첫 번째 malloc 호출에서 반환하는 논리적인 타입은 코드에 작성된 그대로 int **가 아닌 int *[m]이 됩니다. 어라, 2차원 배열을 쓴다더니 왜 포인터의 배열이 있나요?

다행히 C에는 배열의 배열 타입(int [n][m])이 있고, malloc에서 반환할 수 있도록 배열의 포인터(int (*)[m])로도 변환할 수 있습니다.

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

이렇게 할당된 arr_2dint [n][m] 타입으로 선언된 것과 같은 메모리 표현을 가지고, 거의 같은 방법으로 사용할 수 있습니다. 컴파일러가 가변 길이 배열을 지원하지 않을 경우에는 사용성이 다소 떨어지겠지만, 이 경우에도 안쪽 배열의 길이를 컴파일 타임에 알 수 있다면(예를 들어 int [n][3]) 문제 없이 사용할 수 있습니다.

이 방법으로 3차원 이상의 고차원 배열도 할당할 수 있으며, 이론상으로는 하이퍼 토마토도 11차원 배열을 할당받아 풀 수 있습니다.

int (*arr_3d)[b][c] = malloc(a*sizeof(int [b][c]));
int (*arr_4d)[b][c][d] = malloc(a*sizeof(int [b][c][d]));

// 차원이 높아질수록 아래와 같이 `sizeof 표현식` 꼴로 작성하는 것이 편합니다.
int (*arr_3d_alt)[b][c] = malloc(a*sizeof *arr_3d_alt);
int (*arr_4d_alt)[b][c][d] = malloc(a*sizeof *arr_4d_alt);

참고로 할당을 해제할 때도 한 번만 해제하면 됩니다.

free(arr_2d);
타입 간 변환
목록 펼치기
  • 호환되는 타입끼리
    • 아무것도 하지 않습니다.
  • 정수끼리
    • 목표 타입이 기존 값을 나타낼 수 있으면 목표 타입의 대응하는 값으로 변환됩니다.
    • 그렇지 않을 경우 목표 타입의 부호 유무에 따라 처리가 다릅니다.
      • unsigned: 목표 타입의 비트 수 에 대해 를 취한 값으로 변환됩니다.
      • signed: 구현체에서 정의하는 방법으로 변환됩니다.
  • 실수끼리
    • 목표 타입이 기존 값을 정확하게 나타낼 수 있으면 목표 타입의 대응하는 값으로 변환됩니다.
    • 목표 타입이 기존 값을 부정확하게 나타낼 수 있으면 가장 가까운 수로 올림하거나 버림합니다. 둘 중 어떤 동작을 하는지는 구현체 정의 동작에 해당합니다.
      • 단, 부동소숫점 표준을 만족할 경우 둘 중 더 가까운 수로 근사합니다.
    • 목표 타입이 나타낼 수 없는 기존 값을 변환하려고 하면 비정의 동작이 됩니다.
      • 단, 부동소숫점 표준을 만족할 경우 부동소숫점 표준을 따릅니다. 정확하지는 않지만 양/음의 무한대로 변환되는 것으로 알고 있습니다.
  • 포인터끼리
    • void *는 아무 개체 타입의 포인터와 변환할 수 있습니다.
      • 기존 포인터를 변환했다가 돌려놓았을 때 비정의 동작이 일어나지 않는다면 그 포인터 값은 원래와 같습니다.
    • 한정되지 않은 타입의 포인터에서 한정된 타입의 포인터로 변환할 수 있습니다. 단, 역방향으로는 변환할 수 없습니다.
      • 변환 전후의 포인터 값은 같습니다.
      • const, volatile, restrict를 독립적으로 판단합니다. 즉, T *에서 const T *뿐만 아니라 volatile T *에서 const volatile T *와 같은 변환도 가능합니다.
    • 정수 상수 0이나 (void *)0, 매크로 표현식 NULL, nullptr는 아무 개체·함수 타입의 널 포인터로 변환할 수 있으며, 널이 아닌 어떤 포인터와도 같지 않음이 보장됩니다. 단, 역방향으로는 변환할 수 없습니다.
  • C99 스칼라 타입에서 _Bool
    • 기존 값이 0과 같을 경우 (C23 산술 타입은 0, 포인터 타입은 널, nullptr_t는 무조건) false, 그렇지 않을 경우 true로 반환됩니다. 이때 0.5나 허수 단위 I 등의 값도 0과 같지만 않으면 무조건 true가 됩니다.
  • 정수에서 실수로
    • 목표 타입이 기존 값을 정확하게 나타낼 수 있으면 목표 타입의 대응하는 값으로 변환됩니다.
    • 목표 타입이 기존 값을 부정확하게 나타낼 수 있으면 가장 가까운 수로 올림하거나 버림합니다. 둘 중 어떤 동작을 하는지는 구현체에서 정의합니다.
      • 단, 부동소숫점 표준을 만족할 경우 둘 중 더 가까운 수로 근사합니다.
    • 목표 타입이 나타낼 수 없는 기존 값을 변환하려고 하면 비정의 동작이 됩니다.
      • 단, 부동소숫점 표준을 만족할 경우 비명시 값으로 변환됩니다.
  • 실수에서 정수로 (_Bool 제외)
    • 기존 값을 0 방향으로 버립니다(즉, 소숫점 아래를 버립니다).
    • 목표 타입이 이 값을 나타낼 수 없을 경우 비정의 동작이 됩니다.
  • 실수-허수-복소수 간
    • 실·허수부를 따로 처리합니다. 아래에서 '실수부'라고 서술한 것은 허수부에 대해서도 동일합니다.
      • 실수부가 기존 타입에 있지만 목표 타입에는 없는 경우 실수부를 버립니다.
      • 실수부가 기존 타입에는 없지만 목표 타입에 있는 경우 +0으로 변환합니다.
      • 실수부가 기존 타입과 목표 타입에 모두 있는 경우 실수끼리 변환 규칙에 준합니다.

명시적 변환

명시적 변환은 전위 (T) 연산자를 사용해 프로그래머가 의도적으로 타입을 변환하는 경우를 일컫습니다. 핵심을 요약하자면 다음과 같습니다.

명시적 변환의 의미

대입할 때처럼 암시적 변환이 가능하다면 그 방법을 사용합니다. 그렇지 않을 경우 다음 중 적용 가능한 것 하나를 거쳐 변환합니다.

단, 부동소숫점 타입의 범위를 벗어나는 값을 그 부동소숫점 타입으로 변환하려고 하면 (같은 타입으로 변환하더라도) 아래의 규칙을 무시하고 그 값을 강제로 잘라내 표현 가능한 범위로 만듭니다.

목록 펼치기
  • 아무 타입에서 void
    • 변환되는 표현식을 평가만 한 뒤 버립니다.
    • 컴파일러가 사용해야 하는 값을 사용하지 않았다는 경고를 출력할 때 이 방법으로 우회할 수 있습니다.
  • 포인터끼리
    • 아무 개체 타입의 포인터끼리 변환할 수 있습니다.
      • 단, 변환한 포인터의 정렬이 잘못되어 있을 때는 비정의 동작이 됩니다.
      • 특히 임의의 문자 타입의 포인터로 변환할 수 있으며, 이 방법으로 개체의 메모리상 표현을 확인하거나 memcpy/memmove로 복사할 수 있습니다.
    • 아무 함수 타입의 포인터끼리 변환할 수 있습니다.
      • 이렇게 변환된 포인터로 호환되지 않는 함수를 호출하려고 하면 비정의 동작이 됩니다.
    • void *의 변환과 같이 기존 포인터를 변환했다가 돌려놓았을 때 비정의 동작이 일어나지 않는다면 그 포인터 값은 원래와 같습니다.
    • 널 포인터를 변환한 결과는 항상 널 포인터입니다.
  • 정수에서 포인터로
    • 구현체에서 정의하는 방법으로 변환됩니다. 변환된 결과가 올바른 포인터라는 보장은 없습니다.
  • 포인터에서 정수로
    • 구현체에서 정의하는 방법으로 변환됩니다. 널 포인터가 0으로 변환된다는 보장은 없습니다.
      • 단, 목표 타입이 나타낼 수 없는 값으로 변환하려고 하면 비정의 동작이 됩니다.

C99 복합 리터럴*compound literals

복합 리터럴은 초기화할 때와 동일한 대괄호 문법을 통해 무기명의 개체를 생성하는 문법입니다. 단, 가변 수식 타입의 개체는 생성할 수 없습니다.

static struct Foo {
	int foo;
} foo;

void fn(void) {
	// ✅ 구조체 타입을 가지는 `foo`에 복합 리터럴을 통해 새로운 값을 대입했습니다.
	foo = (struct Foo){ 123 };

	// ✅ `bar`에 복합 리터럴을 통해 `int` 값을 대입했습니다.
	int bar = (int){ 1 };

	// ✅ `baz`에 복합 리터럴을 통해 무기명 배열을 가리키는 포인터를 대입했습니다.
	double *baz = (double [3]){ 3.14, 3.15, 3.16 };

	// ❌ 복합 리터럴을 통해 가변 길이 배열을 생성할 수 없습니다.
	(char [bar]){};
}

복합 리터럴은 좌측값이므로 좌측값 맥락에서 자유롭게 사용할 수 있습니다.

// ✅ 복합 리터럴의 주소를 획득할 수 있습니다.
&(int){};

// ✅ 복합 리터럴에 쓰기 연산을 할 수 있습니다.
(char){ 'e' } = 'a';

C23부터는 복합 리터럴에도 기억 부류 지정자를 지정할 수 있으며, 이렇게 생성한 개체는 일반적인 선언문을 통해 같은 기억 부류 지정자, 같은 타입, 같은 초기화 값으로 선언한 것처럼 동작합니다. 잘못 사용하면 할당과 관련된 오류를 일으킬 수 있음에 유의해야 합니다.

int *a;
for(int i = 0; i < 100; i++) {
	// `for`문 안에서 `int _temp = { i }; a = &_temp;`를 한 것처럼 동작합니다.
	a = &(int){ i };
	// 이 시점에서 `auto`인 `(int){ i }`의 할당이 해제되고, `a`는 댕글링 포인터가 됩니다.
}
// 🔥 UB: 할당이 해제된 개체에 접근할 수 없습니다.
*a;

// ❌ `register`인 `int`의 주소를 획득할 수 없습니다.
// `register int _temp = { 1 }; &_temp;`처럼 동작합니다.
&(register int){ 1 };

C11 제네릭 선택*generic selection

C에도 의외로 타입 제네릭 연산을 지원하는 언어 기능이 있습니다. _Generic() 안에 표현식('제어 표현식controlling expression'이라고 합니다)과 몇 가지 타입에 따라 원하는 값을 넣으면 컴파일 시에 제어 표현식의 타입을 확인한 뒤 일치하는 것을 선택해줍니다. 단, 일치하는 타입이 없으면 컴파일 오류가 됩니다.

// ✅ `"long long"`이 선택되었습니다.
char *type_str = _Generic(
	123ll,
	int: "int",
	long: "long",
	long long: "long long"
);

// ❌ `char *` 타입의 `"troll"`이 `int`와 일치하지 않습니다.
char *type_str_2 = _Generic(
	"troll",
	int: "int"
);

함수나 가변 수식 타입을 선택하거나 호환되는 타입을 중복으로 작성할 수는 없습니다.

// ❌ 가변 수식 타입인 `int (*)[n]`을 선택할 수 없습니다.
int n = 3, arr[n][n];
_Generic(
	arr,
	int (*)[n]: "int (*)[*]"
);

// ❌ 호환되는 타입인 `int`와 `signed` (aka `int`)를 동시에 작성할 수 없습니다.
_Generic(
	1,
	int: "int",
	signed: "signed"
);

제어 표현식은 값 변형을 거치기 때문에 최상위 한정자, 배열, 함수 타입은 사용할 수 없고 대신 포인터 타입을 사용해야 합니다.

void foo(int);
int bar[3];
const int baz;
const int *quux;

// `void (int)`를 주석 처리할 경우 `"decayed"`가 선택됩니다.
_Generic(
	foo,
	// ❌ 함수 타입인 `void (int)`를 선택할 수 없습니다.
	void (int): "undecayed",
	void (*)(int): "decayed"
);
// ✅ `"decayed"`가 선택됩니다.
_Generic(
	bar,
	int [3]: "undecayed",
	int *: "decayed"
);
// ✅ `"decayed"`가 선택됩니다.
_Generic(
	baz,
	const int: "undecayed",
	int: "decayed"
);
// ✅ `const int (*)`의 `const`는 최상위 한정자가 아니므로 `"qualifier undecayed`가 선택됩니다.
_Generic(
	quux,
	const int *: "qualifier undecayed",
	int *: "qualifier decayed"
);

switch문과 비슷하게 default:를 사용해 어떤 타입과도 일치하지 않는 경우를 확인할 수 있습니다.

// ✅ `"default"`가 선택됩니다.
_Generic(
	(short)1,
	int: "int",
	long: "long",
	long long: "long long",
	default: "default"
);

제어 표현식과 선택되지 않은 표현식은 평가되지 않으며, 제어 표현식의 값 변형 역시 부작용을 일으키지 않습니다.

int dbg(char *msg, int x) {
	printf("debug: %s\n", msg);
	return x;
}

int main(void) {
	// ✅ `debug: int`가 출력됩니다.
	_Generic(
		dbg("ctrl", 1),
		int: dbg("int", 2),
		long: dbg("long", 3),
		long long: dbg("long long", 4)
	);
}

제네릭 선택 문법은 주로 매크로 함수와 같이 사용합니다. 예를 들어 cppreference.com에서는 <tgmath.h>cbrt 매크로의 가능한 구현으로 다음을 제시하고 있습니다.

#define cbrt(X) \
	_Generic( \
		(X), \
		long double: cbrtl, \
		default: cbrt, \
		float: cbrtf \
	)(X)

부록: Visual Studio 서식 설정하기

이 내용은 원래 이전 블로그의 댓글에 답글로 작성했던 내용으로, 다른 독자에게도 도움이 된다고 생각해 본문으로 옮겨 적습니다.

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

  1. 도구(T) - 옵션(O)...을 엽니다.
  2. 왼쪽 트리에서 텍스트 편집기 - C/C++ - 코드 스타일 - 서식 - 간격으로 들어갑니다.
  3. 포인터/참조 맞춤을 찾아 오른쪽 맞춤으로 변경합니다.
비주얼 스튜디오의 옵션 창에서 '포인터/참조 맞춤'이 '오른쪽 맞춤'으로 설정되어 있다.

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

생성형 AI 투명성

이 글을 작성하는 데 생성형 AI를 다음과 같이 사용했습니다.

간접 사용

Claude를 사용해 아래 주제에 대한 보조 조사를 진행했습니다.

  • 8비트 바이트를 사용하지 않는 현대의 컴퓨터 시스템
  • 컴파일러 최적화와 register 키워드의 관계
  • 파일 범위 선언과 extern 지정자의 상호작용
각주

1 다소 부끄러운 고백이지만 제가 실제로 들여쓰기 설정을 이렇게 한 적이 있었습니다. 지금은 4칸 너비의 탭을 쓰고 있습니다.

2 컴퓨터를 포맷해버린다고 쓰긴 했지만 실제로 자주 일어날 만한 일을 꼽자면 엉뚱한 곳에서 전혀 무관한 문제를 일으켜서 최소 몇 시간을 디버깅으로 날리게 만드는 것이 가장 유력하겠습니다.

3 C23의 용어집에서는 '바이트의 비트 수가 구현체 정의 값'이라는 명시적인 언급이 삭제되었지만(3.7), 부록 J의 구현체 정의 동작 목록에는 수록되어 있습니다(J.3.5/1의 (1)).

4 이 현상은 같은 구문을 함수 선언으로도 클래스 객체 초기화로도 읽을 수 있을 때 공통적으로 발생하는 현상이며, 제일 짜증나는 파싱*most vexing parse이라는 이름도 붙어 있습니다.

참고 문헌

[ISO9899] ISO/IEC 9899:2024 Information technology — Programming languages — C

[IEEE754] IEEE 754-2019 IEEE Standard for Floating-Point Arithmetic