60 선언을 읽는 법 — 두 가지 독법과 typedef
먼저 알아야 할 것
돌아보기
59장에서 함수 포인터를 배우며 int (*(*s)(void))(int); 같은 선언이 나왔고, “읽기 어려우니 typedef를 쓴다”고만 하고 지나갔다. 그런데 이런 선언은 대체 무슨 규칙으로 만들어지는가 — 왜 이렇게 꼬이는가?
답. C의 선언은 타입을 적는 것이 아니라 그 이름을 어떻게 쓰는지를 적는 것이기 때문이다. 데니스 리치의 설계 원칙이 “선언은 사용을 닮는다”였다 (declaration reflects use). int *p;는 “*p가 int다”라고 읽히고, int a[3];은 “a[3]이 int다”라고 읽힌다. 이 원칙은 단순한 경우에 우아하지만, *와 []와 ()가 겹치면 우선순위 때문에 괄호가 끼어들며 금세 험해진다. 그래서 읽는 규칙이 따로 필요해진 것이다.
이 장의 필요성과 맥락
이 장이 끝나면
char *(*table[4])(int) 같은 선언을 읽어 내는 법을 익힌다. 원리는 두 가지뿐이다: 바깥에서 안으로 읽는 법과 가장 안쪽의 이름에서 바깥으로 읽는 법. 둘의 쓰임이 다르다는 것, 그리고 typedef로 이 험한 선언을 층층이 풀어 쓰는 법까지. 마지막으로 이 일을 대신해 주는 도구도 소개한다.이 장에서 답할 질문
- 이 순서를 외우기 어렵다. 더 간단한 요령은 없는가?
- “시계 방향 나선 규칙”이라는 것도 들어 봤는데, 그것과 같은 것인가?
typedef의 문법이 이상하다 — 왜typedef 새이름 = 타입이 아닌가?- 그러면 이런 선언을 읽는 연습은 왜 하는가?
- 도구가 있으면 굳이 손으로 읽는 법을 배워야 하는가?
60.1 두 가지 독법
먼저 규칙 하나만 챙기면 된다. 선언에서 이름 곁에 붙는 세 기호의 힘이 다르다.
[](배열)와()(함수)는 이름의 오른쪽에 붙고,*(포인터)는 왼쪽에 붙는다.- 오른쪽 둘이 왼쪽 하나보다 세다. 그래서
int *a[3]은 “포인터의 배열” 이지 “배열의 포인터”가 아니다. - 순서를 뒤집고 싶으면 괄호로 묶는다 —
int (*a)[3].
이 힘의 차이 위에서 두 가지 독법이 나온다.
독법 ① — 가장 안쪽 이름에서 바깥으로(오른쪽-왼쪽 규칙). 식별자를 찾아 거기서 시작해, 오른쪽을 먼저 보고 그다음 왼쪽을 보며 번갈아 바깥으로 나간다. 괄호를 만나면 그 안을 다 읽고 밖으로 나간다. 읽어 나가는 말이 영어 어순 그대로 이어진다는 것이 이 방법의 묘미다.
독법 ② — 바깥에서 안으로. 타입 이름(맨 왼쪽의 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];
- 식별자
pa에서 시작한다 — “pa is” - 오른쪽을 본다:
[3]— “array 3 of” - 오른쪽이 끝났으니 왼쪽을 본다:
*— “pointer to” - 남은 것:
int— “int”
이어 읽으면 “pa is array 3 of pointer to int” — 포인터 세 개짜리 배열이다. 예제가 전체 24바이트, 원소 8바이트로 그것을 확인했다.
int (*ap)[3];
ap에서 시작 — “ap is”- 오른쪽은 괄호의 끝이므로 갈 수 없다. 왼쪽을 본다:
*— “pointer to” - 괄호를 벗어난다. 다시 오른쪽:
[3]— “array 3 of” - 남은 것:
int
“ap is pointer to array 3 of int” — 포인터 하나(8바이트)이고, 가리키는 것이 12바이트짜리 배열이다. 예제의 두 번째 줄이 그것이다.
char *(*table[4])(int); — 이 장의 주인공이다.
table에서 시작 — “table is”- 오른쪽:
[4]— “array 4 of” - 왼쪽:
*— “pointer to” - 괄호 밖 오른쪽:
(int)— “function (int) returning” - 왼쪽:
*— “pointer to” - 남은 것:
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 | 그다음: 전위 연산자 — *(…을 가리키는 포인터) |
| C | const·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 *p | const가 int 옆 → 타입에 붙는다 | 가리키는 값 |
int const *p | 같다 — 순서만 다르다 | 가리키는 값 |
int *const p | const가 타입 옆이 아니다 → 왼쪽 *에 붙는다 | 포인터 자신 |
const int *const p | 둘 다 | 값과 포인터 모두 |
표 60.2
예제가 이것을 실물로 갈랐다. const int *pci는 pci = &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)
① struct에 typedef를 붙이느라 애쓰지 마라. 그렇게 해서 얻는 것은 struct라는 낱말을 안 쓰게 되는 것뿐인데, 그건 애초에 감출 게 아니라는 신호다.
② typedef를 쓸 곳은 셋이다. 배열·구조체·포인터·함수가 결합된 타입. 이식 가능한 타입 — 20비트 이상이 필요하면 typedef로 두고, 옮길 때 그 한 줄만 고친다. 그리고 복잡한 캐스트에 붙일 이름.
③ 구조체 정의에는 필요 없어 보여도 항상 태그를 붙여라. 나중에 필요해진다.
세 가지를 오늘 기준으로 하나씩 확인해 본다.
①은 지금도 유효하다. 앞의 반례 상자에서 본 그대로다 — typedef로 struct를 감추면 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);처음 보면 괄호의 숲이지만, 앞 절의 절차를 그대로 밟으면 다섯 걸음이면 끝난다. 이름에서 시작해, 오른쪽을 먼저 보고, 막히면 왼쪽으로 간다.
| 걸음 | 보고 있는 것 | 지금까지 읽은 문장 |
|---|---|---|
| 1 | signal | signal 은 |
| 2 | 오른쪽 (int sig, void (*func)(int)) | … int와 “int를 받아 void를 돌려주는 함수를 가리키는 포인터”를 받는 함수 |
| 3 | 왼쪽 * | … 포인터를 돌려주는 |
| 4 | 바깥 괄호 밖 오른쪽 (int) | … 그 포인터는 “int를 받아…”인 함수를 가리키는 |
| 5 | 맨 왼쪽 void | … void를 돌려주는 함수를 가리키는 포인터를 돌려주는 함수 |
표 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
선언을 읽는 근육을 얻었다. 다음 장부터는 표준 라이브러리의 지형으로 들어간다 — 지금까지 배운 언어 위에, 세상이 반세기 동안 쌓아 온 함수들이 어떤 계약과 함정을 안고 있는지 보는 부다.