Proven C BookEnglish GitHub

60 선언을 읽는 법 — 두 가지 독법과 typedef

먼저 알아야 할 것

59장 함수를 값으로 · 함수 포인터의 표기
38장 배열 · 배열 선언자

돌아보기

59장에서 함수 포인터를 배우며 int (*(*s)(void))(int); 같은 선언이 나왔고, “읽기 어려우니 typedef를 쓴다”고만 하고 지나갔다. 그런데 이런 선언은 대체 무슨 규칙으로 만들어지는가 — 왜 이렇게 꼬이는가?

답. C의 선언은 타입을 적는 것이 아니라 그 이름을 어떻게 쓰는지를 적는 것이기 때문이다. 데니스 리치의 설계 원칙이 “선언은 사용을 닮는다”였다 (declaration reflects use). int *p;는 “*p가 int다”라고 읽히고, int a[3];은 “a[3]이 int다”라고 읽힌다. 이 원칙은 단순한 경우에 우아하지만, *[]()가 겹치면 우선순위 때문에 괄호가 끼어들며 금세 험해진다. 그래서 읽는 규칙이 따로 필요해진 것이다.

이 장의 필요성과 맥락

C 에서 가장 악명 높은 자리를 10부 끝에 두는 데는 이유가 있다. 험한 선언은 배열 (38장)·포인터(35장)·함수 포인터(59장)가 겹칠 때 생기는데, 셋이 다 모인 것이 바로 지금이기 때문이다. 더 일찍 가르치면 읽을 예가 없고, 더 늦추면 이미 겁먹은 뒤다.

이 장이 끝나면

C에서 가장 악명 높은 자리 — char *(*table[4])(int) 같은 선언을 읽어 내는 법을 익힌다. 원리는 두 가지뿐이다: 바깥에서 안으로 읽는 법과 가장 안쪽의 이름에서 바깥으로 읽는 법. 둘의 쓰임이 다르다는 것, 그리고 typedef로 이 험한 선언을 층층이 풀어 쓰는 법까지. 마지막으로 이 일을 대신해 주는 도구도 소개한다.

이 장에서 답할 질문

  1. 이 순서를 외우기 어렵다. 더 간단한 요령은 없는가?
  2. “시계 방향 나선 규칙”이라는 것도 들어 봤는데, 그것과 같은 것인가?
  3. typedef의 문법이 이상하다 — 왜 typedef 새이름 = 타입이 아닌가?
  4. 그러면 이런 선언을 읽는 연습은 왜 하는가?
  5. 도구가 있으면 굳이 손으로 읽는 법을 배워야 하는가?

60.1 두 가지 독법

먼저 규칙 하나만 챙기면 된다. 선언에서 이름 곁에 붙는 세 기호의 힘이 다르다.

이 힘의 차이 위에서 두 가지 독법이 나온다.

독법 ① — 가장 안쪽 이름에서 바깥으로(오른쪽-왼쪽 규칙). 식별자를 찾아 거기서 시작해, 오른쪽을 먼저 보고 그다음 왼쪽을 보며 번갈아 바깥으로 나간다. 괄호를 만나면 그 안을 다 읽고 밖으로 나간다. 읽어 나가는 말이 영어 어순 그대로 이어진다는 것이 이 방법의 묘미다.

독법 ② — 바깥에서 안으로. 타입 이름(맨 왼쪽의 int, char 같은 것) 에서 시작해 “이것은 무엇의 무엇인가”를 감싸 들어간다. 식별자가 아예 없는 선언 — 캐스트나 sizeof에 적는 추상 선언자 — 에서는 시작할 이름이 없으므로 이쪽이 유일한 길이다.

두 독법은 같은 결론에 도달한다. 상황에 따라 편한 쪽을 쓰면 된다.

60.2 독법 ① 실습 — 영어 어순으로 이어 읽기

examples/ch60/decl.c

/* 선언을 읽는 두 가지 방법을 눈으로 확인한다.
   같은 뜻을 typedef 로 쌓아 만든 타입과 원래 선언이 정말 같은지도 검사한다. */
#include <stdio.h>

/* ① 별 하나 차이 ─────────────────────────────────────── */
int  *pa[3];      /* pa: 배열[3] of 포인터 to int   */
int (*ap)[3];     /* ap: 포인터 to 배열[3] of int   */

/* ② 함수가 끼면 ──────────────────────────────────────── */
int  *f(void);    /* f: 함수(void) 반환 포인터 to int */
int (*g)(void);   /* g: 포인터 to 함수(void) 반환 int */

/* ③ 악명 높은 형태: 배열[4] of 포인터 to 함수(int) 반환 포인터 to char */
char *(*table[4])(int);

/* ④ 같은 타입을 typedef 로 한 겹씩 쌓아 만든다 */
typedef char           *charptr;        /* 포인터 to char                  */
typedef charptr         handler(int);   /* 함수(int) 반환 charptr          */
typedef handler        *handler_ptr;    /* 포인터 to 그 함수               */
typedef handler_ptr     table4[4];      /* 배열[4] of 그 포인터            */

/* 두 길로 만든 타입이 정말 같은지 컴파일 시간에 검사한다 */
static_assert(sizeof(table4) == sizeof(table), "they must be the same type");

static char *shout(int n)  { (void)n; return "shout"; }
static char *quiet(int n)  { (void)n; return "quiet"; }

int main(void)
{
    printf("int  *pa[3]  : whole %zu, element %zu  -> %zu pointers\n",
           sizeof pa, sizeof pa[0], sizeof pa / sizeof pa[0]);
    printf("int (*ap)[3] : whole %zu, pointee %zu\n",
           sizeof ap, sizeof *ap);

    /* ③ 을 실제로 채워 쓴다 */
    table[0] = shout;
    table[1] = quiet;
    printf("table[0](1) = %s, table[1](2) = %s\n", table[0](1), table[1](2));

    /* ④ 의 typedef 로 만든 변수도 같은 자리에 그대로 들어간다 */
    table4 other = { quiet, shout };
    printf("other[0](3) = %s   (the same type, made with a typedef)\n", other[0](3));

    /* 식별자가 없는 형태(추상 선언자): 캐스트와 sizeof 에서 쓴다 */
    printf("sizeof(char *(*)(int)) = %zu   (an unnamed function pointer type)\n",
           sizeof(char *(*)(int)));
    return 0;
}

실행 결과

int  *pa[3]  : whole 24, element 8  -> 3 pointers
int (*ap)[3] : whole 8, pointee 12
table[0](1) = shout, table[1](2) = quiet
other[0](3) = quiet   (the same type, made with a typedef)
sizeof(char *(*)(int)) = 8   (an unnamed function pointer type)

예제의 선언들을 하나씩 읽어 본다. 읽는 말은 영어로 이어 붙이는 편이 자연스럽다 — C의 선언 문법이 영어 어순을 따라 설계됐기 때문이다.

int *pa[3];

  1. 식별자 pa에서 시작한다 — “pa is”
  2. 오른쪽을 본다: [3] — “array 3 of”
  3. 오른쪽이 끝났으니 왼쪽을 본다: * — “pointer to”
  4. 남은 것: int — “int”

이어 읽으면 “pa is array 3 of pointer to int” — 포인터 세 개짜리 배열이다. 예제가 전체 24바이트, 원소 8바이트로 그것을 확인했다.

int (*ap)[3];

  1. ap에서 시작 — “ap is”
  2. 오른쪽은 괄호의 끝이므로 갈 수 없다. 왼쪽을 본다: * — “pointer to”
  3. 괄호를 벗어난다. 다시 오른쪽: [3] — “array 3 of”
  4. 남은 것: int

“ap is pointer to array 3 of int” — 포인터 하나(8바이트)이고, 가리키는 것이 12바이트짜리 배열이다. 예제의 두 번째 줄이 그것이다.

char *(*table[4])(int); — 이 장의 주인공이다.

  1. table에서 시작 — “table is”
  2. 오른쪽: [4] — “array 4 of”
  3. 왼쪽: * — “pointer to”
  4. 괄호 밖 오른쪽: (int) — “function (int) returning”
  5. 왼쪽: * — “pointer to”
  6. 남은 것: char

“table is array 4 of pointer to function (int) returning pointer to char” — 정수 하나를 받아 문자열을 돌려주는 함수들의 표다. 예제에서 실제로 두 함수를 넣고 불러 보았다.

60.2.1 이 읽기에는 이름이 있다 — 우경식(boustrophedon)

방금 한 일을 다시 보면 눈이 오른쪽 → 왼쪽 → 오른쪽으로 갈지자를 그렸다. 이 움직임에는 오래된 이름이 있다. 우경식(牛耕式, boustrophedon)이다. 그리스어 βοῦς(소)와 στρέφειν(돌다)을 합친 말로, 소가 쟁기를 끌고 밭 끝까지 갔다가 돌아서 반대 방향으로 오는 모양을 뜻한다. 고대 그리스 비문에는 실제로 한 줄은 왼쪽에서 오른쪽, 다음 줄은 오른쪽에서 왼쪽으로 새긴 것들이 있고, 그것을 우경식 서법이라 부른다.

C 선언을 이 낱말로 처음 부른 사람은 페터르 판데르린던이다. 그는 이렇게 적었다 — “C의 선언은 우경식으로 읽는다. 즉 오른쪽에서 왼쪽으로, 왼쪽에서 오른쪽으로 번갈아 가며. 그런 것을 가리키는 낱말이 따로 있으리라고 누가 생각이나 했겠는가!”1

이름을 알아 두면 좋은 이유는 하나다. 독법 ①이 임의의 요령이 아니라 하나의 꼴을 가진 절차라는 것이 이름에 담기기 때문이다. 같은 책이 그 절차를 우선순위 규칙(precedence rule)으로 압축해 놓았다. 우리가 앞에서 문장으로 풀어 쓴 것과 같은 것인데, 세 번째 항목은 아직 다루지 않았다.

내용
A선언은 이름에서 시작해 우선순위 순서로 읽는다
B.1가장 센 것: 선언의 일부를 묶고 있는 괄호
B.2그다음: 후위 연산자 — ()(함수)와 [](배열)
B.3그다음: 전위 연산자 — *(…을 가리키는 포인터)
Cconst·volatile타입 지정자 옆(int, long 등)에 있으면 그 타입에 붙는다. 그렇지 않으면 바로 왼쪽의 별표에 붙는다

표 60.1

C항이 실무에서 가장 자주 사고를 낸다. const가 어디에 붙는지가 뜻을 완전히 뒤집기 때문이다.

examples/ch60/precedence.c

/* 우경식(牛耕式) 읽기의 우선순위 규칙을 눈으로 확인한다.
   특히 const/volatile 이 「왼쪽의 별표에 붙는가, 타입에 붙는가」를 실물로 가른다. */
#include <stdio.h>

static int one = 1, two = 2;

int main(void)
{
    /* ① const 가 타입 지정자 옆에 있다 → 타입에 붙는다(가리키는 것이 읽기 전용) */
    const int *pci  = &one;     /* 「int const 를 가리키는 포인터」 */
    int const *pci2 = &one;     /* 위와 완전히 같다 — 순서만 다르다 */

    /* ② const 가 타입 지정자 옆이 아니다 → 바로 왼쪽의 별표에 붙는다 */
    int *const cpi = &one;      /* 「int 를 가리키는, 읽기 전용 포인터」 */

    /* ③ 둘 다 */
    const int *const cpci = &one;

    /* ①은 가리키는 곳을 바꿀 수 있다.  *pci = 9; 는 오류다. */
    pci = &two;
    pci2 = &two;
    printf("(1) *pci=%d  *pci2=%d   <- the pointer itself can move\n", *pci, *pci2);

    /* ②는 반대다.  cpi = &two; 는 오류이고, 가리키는 값은 바꿀 수 있다. */
    *cpi = 42;
    printf("(2) *cpi=%d   one=%d      <- the value can be changed\n", *cpi, one);

    printf("(3) *cpci=%d              <- read-only on both sides\n", *cpci);

    /* ④ 우경식으로 읽는 실물: 오른쪽 먼저, 막히면 왼쪽 */
    char *const *next = NULL;   /* next 는 「char 를 가리키는 읽기 전용 포인터」를
                                   가리키는 포인터 */
    printf("(4) sizeof next = %zu   (one pointer)\n", sizeof next);

    /* ⑤ 태그를 붙여 두었기에 자기 자신을 가리킬 수 있다 */
    struct node_tag { int datum; struct node_tag *next; };
    struct node_tag b = { 2, NULL };
    struct node_tag a = { 1, &b };
    printf("(5) a.datum=%d -> a.next->datum=%d\n", a.datum, a.next->datum);

    return 0;
}

실행 결과

(1) *pci=2  *pci2=2   <- the pointer itself can move
(2) *cpi=42   one=42      <- the value can be changed
(3) *cpci=42              <- read-only on both sides
(4) sizeof next = 8   (one pointer)
(5) a.datum=1 -> a.next->datum=2

예제의 앞 세 줄이 그 갈림이다.

선언C항으로 읽으면무엇이 읽기 전용인가
const int *pconstint 옆 → 타입에 붙는다가리키는 값
int const *p같다 — 순서만 다르다가리키는 값
int *const pconst가 타입 옆이 아니다 → 왼쪽 *에 붙는다포인터 자신
const int *const p둘 다값과 포인터 모두

표 60.2

예제가 이것을 실물로 갈랐다. const int *pcipci = &two;옮길 수는 있고, int *const cpi*cpi = 42;값을 바꿀 수는 있다. 반대쪽은 각각 컴파일 오류다 — assignment of read-only location '*pci'assignment of read-only variable 'cpi'.

문. 이 순서를 외우기 어렵다. 더 간단한 요령은 없는가?

답. 있다. const는 바로 왼쪽 것을 꾸민다. 왼쪽에 아무것도 없으면 오른쪽 것을 꾸민다. 이 한 문장이 위 표의 C항과 같은 말이다.

그래서 어떤 사람들은 아예 int const *p처럼 const를 언제나 오른쪽에 적는 관행(이른바 “동쪽 const”, east const)을 쓴다. 그러면 예외 없이 “왼쪽 것을 꾸민다” 한 규칙만 남는다. 다만 표준 헤더와 대부분의 기존 코드가 const int *를 쓰므로, 읽을 때는 두 표기를 다 알아야 한다.

60.2.2 지워 가며 읽기

같은 책이 함께 권한 요령이 하나 더 있다. 읽은 부분을 지우는 것이다. 눈으로만 훑으면 어디까지 처리했는지 놓치는데, 처리한 조각을 지우면 남은 것이 곧 다음 할 일이 된다. 종이에 옮겨 적으며 읽으면 실수가 거의 나지 않는 이유가 이것이다.

char *const *(*next)();를 그렇게 읽어 보자. 굵은 것이 지금 보는 조각이다.

남은 선언본 것이어 읽은 문장
char *const *(*next)();식별자next 는
char *const *(*)();괄호 안 *… 포인터 to
char *const *();바깥 ()… 함수() 반환
char *const *;*… 포인터 to
char *const;C항 — 왼쪽 *에 붙는다… 읽기 전용
char *;*… 포인터 to
char남은 타입… char

표 60.3

이어 붙이면 “next는 함수를 가리키는 포인터인데, 그 함수는 char를 가리키는 읽기 전용 포인터를 가리키는 포인터를 돌려준다”가 된다. 이 선언은 지어낸 것이 아니라 telnet의 소스에서 나온 실물이다.

문. “시계 방향 나선 규칙”이라는 것도 들어 봤는데, 그것과 같은 것인가?

답. 같은 것을 외우기 쉽게 그린 그림이고, 대개 잘 통한다. 다만 나선 규칙은 오른쪽-왼쪽 규칙의 요약이라 몇몇 형태에서 헷갈리게 그려진다는 지적이 오래전부터 있었다. 규칙을 정확히 적으면 이렇다 — 식별자에서 시작해, 갈 수 있는 한 오른쪽을 먼저 읽고, 더 갈 수 없으면 왼쪽을 읽는다. 괄호는 그 경계다. 나선 그림은 이 문장의 시각적 별명으로 쓰면 된다.

60.3 독법 ② — 이름이 없을 때

캐스트와 sizeof에는 이름이 없는 타입을 적는다. 예제 마지막 줄의 sizeof(char *(*)(int))가 그것이다. 이런 형태를 추상 선언자라 부르고, 읽는 법은 간단하다 — 이름이 있어야 할 자리를 찾아 거기에 이름을 하나 얹어 놓고 독법 ①로 읽는다.

char *(*)(int)        /* 이름이 없다 */
char *(*x)(int)       /* 빈자리에 x 를 넣으면 */
                      /* "x is pointer to function (int) returning pointer to char" */

빈자리는 대개 (*)의 별표 뒤, 또는 []()의 앞이다. 이 요령 덕에 독법 ①의 근육을 그대로 쓸 수 있다.

그러면 독법 ②는 언제 쓰는가. 함수 포인터를 인자로 받는 선언처럼, 껍질이 여러 겹인데 어느 것이 바깥인지부터 알고 싶을 때다.

void qsort(void *base, size_t n, size_t size,
           int (*cmp)(const void *, const void *));

바깥에서 안으로 읽으면 “이것은 함수 qsort이고, 반환은 void, 인자는 넷인데 마지막은 함수 포인터”라는 뼈대가 먼저 잡힌다. 세부는 그다음에 독법 ①로 확인한다. 실무에서 남의 헤더를 읽을 때는 이 순서가 편하다.

60.4 typedef — 층을 나누어 이름을 붙인다

험한 선언을 다루는 가장 실용적인 답은 이름을 붙여 층을 나누는 것이다. 예제 ④가 그 시연이다.

typedef char       *charptr;        /* 포인터 to char        */
typedef charptr     handler(int);   /* 함수(int) 반환 charptr */
typedef handler    *handler_ptr;    /* 포인터 to 그 함수      */
typedef handler_ptr table4[4];      /* 배열[4] of 그 포인터   */

네 줄이 각각 한 겹씩만 얹는다. 마지막 table4는 예제 ③의 char *(*table[4])(int)정확히 같은 타입이고, 예제의 static_assert가 그것을 컴파일 시간에 확인한다. 실무에서는 이렇게까지 잘게 쪼개기보다 한두 겹만 이름을 붙이는 경우가 많다.

typedef char *(*handler_fn)(int);   /* 한 겹으로 충분할 때 */
handler_fn table[4];

60.4.1 어디에 쓰고 어디에 쓰지 않을 것인가

층을 나누는 법은 알았는데, 언제 나눌 것인가. 여기에 대해서는 30년 넘게 인용되는 어림규칙이 있다. 판데르린던이 「typedef를 다루는 요령」이라는 상자에 적어 둔 세 가지다.2

실제 사례. 판데르린던의 세 가지 어림규칙 (1994)

structtypedef를 붙이느라 애쓰지 마라. 그렇게 해서 얻는 것은 struct라는 낱말을 안 쓰게 되는 것뿐인데, 그건 애초에 감출 게 아니라는 신호다.

typedef를 쓸 곳은 셋이다. 배열·구조체·포인터·함수가 결합된 타입. 이식 가능한 타입 — 20비트 이상이 필요하면 typedef로 두고, 옮길 때 그 한 줄만 고친다. 그리고 복잡한 캐스트에 붙일 이름.

구조체 정의에는 필요 없어 보여도 항상 태그를 붙여라. 나중에 필요해진다.

세 가지를 오늘 기준으로 하나씩 확인해 본다.

①은 지금도 유효하다. 앞의 반례 상자에서 본 그대로다 — typedefstruct를 감추면 const가 어디 붙는지가 흐려지고, 그것이 struct를 감춘 대가다. 리눅스 커널 코딩 규약이 같은 결론을 못박아 두었다. 감추는 것이 설계인 불투명 타입만 예외다.

②는 우리가 이미 하고 있는 것이고, 여기에 오늘의 사정을 하나 보태야 한다. “20비트 이상이 필요하면 typedef로 두라”는 조언은 1994년에는 직접 만들어 써야 하는 일이었다. 지금은 표준이 그 일을 대신해 준다 — <stdint.h>int32_t·int_least32_t·int_fast32_t가 정확히 그 목적의 typedef다(27장). ★ 그러므로 오늘의 판본은 이렇다: 직접 만들기 전에 표준에 이미 있는지 먼저 보라. 없을 때만 만든다.

③은 재미있게도 두 가지 이유에서 옳다. 하나는 판데르린던이 말한 “나중에 필요해진다”이고, 다른 하나는 지금 당장 필요한 경우가 있다는 것이다. 자기 자신을 가리키는 구조체가 그렇다.

typedef struct { int datum; node *next; } node;   /* 안 된다 */

node라는 이름은 ;에 이르러서야 생기므로 안쪽에서는 아직 쓸 수 없다. 실제로 컴파일하면 error: unknown type name 'node'가 난다. 태그가 있으면 문제가 사라진다 — 태그는 struct 다음에 곧바로 생기기 때문이다.

typedef struct node_tag { int datum; struct node_tag *next; } node;   /* 된다 */

예제 ⑤가 이 형태로 두 마디를 이어 붙였다. 앞 장들에서 우리가 구조체 태그에 _tag를 붙여 온 것도 같은 이유다 — 같은 이름을 서로 다른 두 가지에 쓰지 않으려는 것이다(55장의 이름 공간).

문. typedef의 문법이 이상하다 — 왜 typedef 새이름 = 타입이 아닌가?

답. typedef저장 클래스 지정자의 자리에 오는 낱말이기 때문이다. 문법적으로 typedef int count;static int count;extern int count;와 같은 자리에 놓인다. 그래서 이렇게 읽으면 된다 — “이 선언에서 변수가 생길 자리에, 대신 타입 이름이 생긴다.”

이 이해가 실전에서 값을 한다. typedef int arr[3];을 보면 “만약 typedef가 없었다면 arr은 int 세 개짜리 배열 변수였겠구나 → 그러니 arr은 그 타입의 이름이구나”로 풀린다. 선언 독법이 그대로 재사용된다.

반례. 포인터를 typedef 뒤에 숨기기

typedef struct node *node;      /* 포인터라는 사실이 이름에서 사라진다 */
void f(const node n);           /* const 는 무엇에 붙는가? */

const node n은 “가리키는 대상이 const”가 아니라 “포인터 자신이 const”다(struct node *const). 이름만 보고는 알 수 없어서 흔히 틀린다.

실무 관행은 둘로 갈린다. 포인터를 숨기지 않는다(권장) — 타입 이름은 struct node로 두고 struct node *라고 적는다. 또는 숨긴다면 이름에 표시한다node_ptr처럼. 리눅스 커널의 코딩 규약이 “포인터 typedef를 쓰지 말라”고 못박은 것도 같은 이유다.

다만 예외가 있다. 불투명 타입(opaque type)을 만들 때는 일부러 숨긴다 — 사용자가 속을 몰라야 하는 핸들(예: FILE *의 자리)에서는 감추는 것이 설계다. 감추는 것과 실수로 잃는 것을 구별하면 된다.

흔한 오해. typedef는 새 타입을 만든다”

만들지 않는다. typedef별명(alias)을 만들 뿐이라, 원래 타입과 완전히 같은 것으로 취급된다.

typedef int meters;
typedef int seconds;
meters  d = 10;
seconds t = 5;
d = t;              /* 경고 없음 — 둘 다 그냥 int 다 */

“미터에 초를 대입”하는 실수를 컴파일러가 잡아 주기를 바랐다면 실망하게 된다. 타입을 정말로 구별하고 싶으면 구조체로 감싸야 한다 — struct meters { int v; }; 이러면 대입이 오류가 된다(46장의 구조체가 값이라는 성질 덕이다). C++의 강한 타입 별명 같은 장치는 C에 없다.

60.5 실물 — 실무에서 실제로 쓰인 선언들

지금까지 본 것은 연습용이 아니다. 실제로 출하된 라이브러리의 선언을 그대로 가져와 절차대로 읽어 보자. 겁먹을 필요가 없다는 것을 확인하는 것이 이 절의 목적이다.

examples/ch60/monster.c

/* 실무에서 실제로 쓰인 난해한 선언들 — 그리고 절차대로 읽으면 풀린다는 것. */
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>

/* ── ① 표준의 signal — C 표준 §7.14.1.1 에 그대로 있는 선언 ────────
      void (*signal(int sig, void (*func)(int)))(int);
   같은 것을 typedef 로 층을 나누면 이렇게 된다. */
typedef void handler_t(int);          /* 함수 타입 자체에 이름을 준다 */
static void on_int(int sig) { (void)sig; }

/* ── ② X11 의 XSetErrorHandler 를 흉내 낸 모양 ───────────────────
      int (*XSetErrorHandler(int (*handler)(Display *, XErrorEvent *)))();
   Display·XErrorEvent 자리에 흉내용 타입을 넣어 그대로 옮겼다. */
typedef struct { int dummy; } Display;
typedef struct { int code; } XErrorEvent;

static int my_error_handler(Display *d, XErrorEvent *e)
{ (void)d; printf("  error handler: code=%d\n", e->code); return 0; }

/* 날것 그대로: "핸들러를 받아, 이전 핸들러를 돌려주는 함수" */
static int (*set_error_handler(int (*handler)(Display *, XErrorEvent *)))
           (Display *, XErrorEvent *)
{
    static int (*current)(Display *, XErrorEvent *);
    int (*prev)(Display *, XErrorEvent *) = current;
    current = handler;
    return prev;
}

/* 같은 것을 typedef 로 — 한 줄이 세 줄이 되고, 세 줄이 다 읽힌다 */
typedef int error_handler_t(Display *, XErrorEvent *);
static error_handler_t *set_error_handler2(error_handler_t *handler)
{ return set_error_handler(handler); }

/* ── ③ 함수를 가리키는 포인터의 배열 — 명령 표(dispatch table) ──── */
static int cmd_add(int a, int b) { return a + b; }
static int cmd_mul(int a, int b) { return a * b; }

/* int (*table[2])(int, int) — "int 둘을 받아 int 를 돌려주는 함수를
   가리키는 포인터"의 배열. 절차대로 읽으면 이렇게 나온다. */
static int (*table[2])(int, int) = { cmd_add, cmd_mul };

/* ── ④ 배열을 가리키는 포인터를 돌려주는 함수 ──────────────────── */
static int (*rows(void))[4]            /* "int[4] 를 가리키는 포인터를 돌려주는 함수" */
{
    static int grid[3][4] = { {1,2,3,4}, {5,6,7,8}, {9,10,11,12} };
    return grid;                        /* int (*)[4] 로 무너진다 */
}

int main(void)
{
    /* ① signal 은 *이전 핸들러*를 돌려준다 — 그래서 반환 타입이 함수 포인터다 */
    handler_t *old = signal(SIGINT, on_int);
    printf("signal returns the previous handler: %s\n",
           old == SIG_ERR ? "SIG_ERR" : "there was one");
    (void)signal(SIGINT, old == SIG_ERR ? SIG_DFL : old);

    /* ② 같은 무늬 — 설치하고, 이전 것을 돌려받는다 */
    puts("\ninstalling an X11-style error handler:");
    int (*prev)(Display *, XErrorEvent *) = set_error_handler(my_error_handler);
    printf("  previous handler: %s\n", prev ? "present" : "none (first install)");
    Display d = { 0 };
    XErrorEvent e = { .code = 42 };
    error_handler_t *prev2 = set_error_handler2(my_error_handler);  /* typedef 판 */
    printf("  the typedef version does the same: previous handler %s\n",
           prev2 == my_error_handler ? "identical" : "different");
    my_error_handler(&d, &e);

    /* ③ 명령 표 */
    printf("\ncommand table: add(3,4)=%d, mul(3,4)=%d\n", table[0](3, 4), table[1](3, 4));

    /* ④ 배열을 가리키는 포인터 */
    int (*g)[4] = rows();
    printf("pointer to an array: g[2][1] = %d (one step is %zu bytes)\n",
           g[2][1], sizeof *g);
    return 0;
}

실행 결과

signal returns the previous handler: there was one

installing an X11-style error handler:
  previous handler: none (first install)
  the typedef version does the same: previous handler identical
  error handler: code=42

command table: add(3,4)=7, mul(3,4)=12
pointer to an array: g[2][1] = 10 (one step is 16 bytes)

60.5.1 ① 표준이 직접 낳은 괴물 — signal

C 표준의 <signal.h>에 이 선언이 그대로 있다(§7.14.1.1).

void (*signal(int sig, void (*func)(int)))(int);

처음 보면 괄호의 숲이지만, 앞 절의 절차를 그대로 밟으면 다섯 걸음이면 끝난다. 이름에서 시작해, 오른쪽을 먼저 보고, 막히면 왼쪽으로 간다.

걸음보고 있는 것지금까지 읽은 문장
1signalsignal 은
2오른쪽 (int sig, void (*func)(int))int와 “int를 받아 void를 돌려주는 함수를 가리키는 포인터”를 받는 함수
3왼쪽 *포인터를 돌려주는
4바깥 괄호 밖 오른쪽 (int)… 그 포인터는 “int를 받아…”인 함수를 가리키는
5맨 왼쪽 voidvoid를 돌려주는 함수를 가리키는 포인터를 돌려주는 함수

표 60.4

한 문장으로 옮기면 이렇다 — signal은 신호 번호와 핸들러를 받아,이전 핸들러를 돌려주는 함수”다. 반환 타입이 왜 저렇게 험한지도 그 순간 설명된다. 설치하면서 이전 것을 돌려주어야 나중에 되돌릴 수 있기 때문이다. 시연의 첫 줄이 실제로 그 이전 핸들러를 받아 되돌린다.

typedef 한 줄이면 이 선언은 평범해진다.

typedef void handler_t(int);          /* 함수 타입에 이름을 준다 */
handler_t *signal(int sig, handler_t *func);

표준이 이렇게 쓰지 않은 것은 역사 때문이다 — 이 함수는 typedef로 층을 나누는 관행이 자리 잡기 한참 전부터 있었다.

60.5.2 ② 실제로 더 험한 것 — X11의 오류 처리기

X 윈도 시스템의 매뉴얼에 실린 선언이다.

int (*XSetErrorHandler(int (*handler)(Display *, XErrorEvent *)))();

무늬는 signal똑같다. 핸들러를 받아 이전 핸들러를 돌려주는 함수다. 다른 것은 인자가 둘이라 괄호가 한 겹 더 두꺼워 보인다는 것뿐이다. 절차대로 읽으면 걸음 수도 같다.

시연의 두 번째 묶음이 이 무늬를 그대로 옮겨 실제로 돌린다 — 날것 그대로 쓴 set_error_handler와, typedef로 층을 나눈 set_error_handler2가 같은 일을 한다. 후자를 보면 이 험한 선언이 사실은 “핸들러를 받아 핸들러를 돌려주는 함수” 한 줄임이 드러난다.

typedef int error_handler_t(Display *, XErrorEvent *);
error_handler_t *XSetErrorHandler(error_handler_t *handler);

실제 사례. 같은 무늬는 어디에나 있다

“핸들러를 받아 이전 핸들러를 돌려준다”는 무늬는 한 번 알아보면 곳곳에서 보인다. 표준의 signal, X11의 XSetErrorHandler·XSetIOErrorHandler, 그리고 GUI·게임 프레임워크의 콜백 등록 함수 대부분이 이 모양이다. 이유도 같다 — 되돌릴 수 있어야 하기 때문이다. 라이브러리를 끼워 넣은 코드가 자기 일이 끝난 뒤 원래 상태로 돌려놓으려면, 설치할 때 이전 것을 받아 두는 수밖에 없다.

같은 이유로 qsort·bsearch의 비교 함수와 POSIX pthread_create의 시작 루틴(void *(*)(void *))도 함수 포인터를 인자로 받는다. 함수 포인터가 험해 보이는 선언의 대부분은 “동작을 값으로 넘긴다”는 한 가지 발상에서 나온다.

60.5.3 ③ 그리고 정말로 무의미한 것

인터넷의 선언 퀴즈에 단골로 나오는 것들이 있다.

char *(*(*foo[][8])())[];      /* cdecl 문서의 예 */
int (*(*bar[10])(void))(int);

이런 것도 절차는 똑같이 통한다. 첫 번째는 char를 가리키는 포인터의 배열을 가리키는 포인터를 돌려주는 함수를 가리키는 포인터를 가리키는 포인터의, 8열짜리 배열의 배열”이다. 읽어 냈다는 사실보다 중요한 것은 그다음 판단이다 — 이런 선언은 코드에 두지 않는다.

앞의 둘(signal, X11)과 이것의 차이가 이 장의 요점이다. 앞의 둘은 풀어야 할 이유가 있는 선언이다. 실제로 쓰이고, 무늬에 뜻이 있다. 뒤의 것은 뜻이 없다 — 문법이 허용한다는 사실을 보여 줄 뿐이다.

문. 그러면 이런 선언을 읽는 연습은 왜 하는가?

답. 세 가지 실용적 이유가 있다.

첫째, 남의 코드는 고를 수 없다. 표준 헤더, 오래된 라이브러리, 커널 자료구조에는 이런 선언이 그대로 있다. 읽지 못하면 그 함수를 쓸 수 없다.

둘째, 오류 메시지를 읽어야 한다. 함수 포인터 타입이 어긋나면 컴파일러가 int (*)(Display *, XErrorEvent *) 같은 타입을 그대로 출력한다. 절차를 알면 그 메시지가 문장으로 읽힌다.

셋째, 무엇을 typedef로 감쌀지 판단하려면 먼저 읽을 줄 알아야 한다. 이해하지 못한 것에 이름을 붙이면 이름이 거짓말을 한다.

그러나 목적은 어디까지나 읽기다. 쓰기는 언제나 typedef로 층을 나눈다 — 다음 절의 도구는 그 판단을 도와줄 뿐, 대신해 주지는 않는다.

흔한 오해. “복잡한 선언은 천재만 읽는다”

절차가 있으므로 그렇지 않다. 앞 표에서 본 대로 signal은 다섯 걸음이고, X11의 것도 다섯 걸음이다. 걸음의 수는 괄호의 겹 수로 정해지지 사람의 재능으로 정해지지 않는다.

어렵게 느껴지는 진짜 이유는 두 가지다. 하나는 절차 없이 눈으로 훑기 때문 이고, 다른 하나는 한 번에 전체 뜻을 알아내려 하기 때문이다. 기계는 그렇게 읽지 않는다 — 한 겹씩 벗기고, 벗긴 것을 문장에 이어 붙일 뿐이다. 사람도 그 방식을 따라 하면 된다. 종이에 걸음을 적으면서 읽으면 실수가 거의 나지 않는다.

60.6 도구에 맡기기 — cdecl

이 읽기를 대신해 주는 프로그램이 오래전부터 있다. 1980년대에 나와 지금도 관리되고 있는 cdecl이다(현재 관리자 Paul J. Lucas, GPLv3). 선언을 주면 영어로 풀어 주고, 영어로 말하면 선언을 지어 준다.

cdecl> explain char *(*table[4])(int)
cdecl> declare table as array 4 of pointer to function (int) returning pointer to char

첫 줄은 우리가 위에서 손으로 한 일을 대신하고, 둘째 줄은 그 반대 방향 이다 — 영어로 말한 것을 C 선언으로 지어 준다. 리눅스 배포판의 패키지로 설치할 수 있고(cdecl), 설치 없이 브라우저에서 쓰는 cdecl.org도 있다.

문. 도구가 있으면 굳이 손으로 읽는 법을 배워야 하는가?

답. 두 가지 이유로 배워 둘 값이 있다. 첫째, 읽는 일은 늘 일어나지만 도구를 여는 일은 가끔이다. 남의 코드를 훑는 중에 int (*p)[3] 하나를 만날 때마다 브라우저를 열지는 않는다. 둘째, 쓰는 쪽이다. 도구는 읽어 주지만 “이 자리에 어떤 선언을 적어야 하는가”는 결국 규칙을 아는 사람이 정한다 — 그리고 대개의 정답은 이 장의 결론이다: 한 줄로 험하게 적지 말고, typedef로 층을 나누어라.

복습 정리

기억할 것요지
설계 원칙선언은 사용을 닮는다 — 그래서 꼬인다
힘의 차이[]·()(오른쪽)가 *(왼쪽)보다 세다. 괄호로 뒤집는다
독법 ①식별자에서 시작 → 오른쪽 먼저, 그다음 왼쪽 → 영어 어순으로
그 이름우경식(牛耕式, boustrophedon) — 소가 밭 갈듯 좌우로 번갈아
우선순위 규칙괄호 > 후위 ()·[] > 전위 *
const의 자리★ 타입 지정자 옆이면 타입에, 아니면 바로 왼쪽 별표에 붙는다
읽는 요령처리한 조각을 지우며 읽는다 — 남은 것이 다음 할 일
독법 ②바깥에서 안으로. 이름이 없는 추상 선언자에 필수
추상 선언자빈자리에 이름을 얹어 놓고 독법 ①로 읽는다
typedef저장 클래스 자리의 낱말 — 변수 대신 타입 이름이 생긴다
typedef를 쓸 곳결합된 타입 · 이식용 타입(단 표준에 있는지 먼저 볼 것) · 복잡한 캐스트의 이름
구조체 태그★ 필요 없어 보여도 항상 붙인다 — 자기 참조에 당장 필요하다
typedef의 한계별명일 뿐 새 타입이 아니다. 구별하려면 구조체
포인터 숨기기권하지 않는다(const의 뜻이 흐려진다). 예외는 불투명 타입
도구cdecl(explain/declare), cdecl.org

표 60.5

선언을 읽는 근육을 얻었다. 다음 장부터는 표준 라이브러리의 지형으로 들어간다 — 지금까지 배운 언어 위에, 세상이 반세기 동안 쌓아 온 함수들이 어떤 계약과 함정을 안고 있는지 보는 부다.

  1. Peter van der Linden, Expert C Programming: Deep C Secrets, Prentice Hall, 1994 — 선언 해독을 다룬 장. 한국어판은 컴파일러 개발자가 들려주는 C 이야기(정기훈 옮김, 인사이트, 2022)로 나와 있다 — 제목이 원서와 사뭇 달라 찾기 어려우니 적어 둔다.
  2. Peter van der Linden, Expert C Programming 의 「Tips for Working with Typedefs」 상자.