58 가변 인자 함수
먼저 알아야 할 것
돌아보기
29장에서 가변 인자로 넘어가는 값에는 기본 진급(float→double, 작은 정수→int)이 적용된다고 배웠다. 그런데 왜 하필 그 자리에만 특별한 진급 규칙이 있는가?
답. 받는 쪽이 타입을 모르기 때문이다. 보통의 함수는 원형(24장)에 인자 타입이 적혀 있어 컴파일러가 양쪽을 맞춰 준다. 그러나 가변 인자 자리는 원형에 ...뿐이라 맞출 상대가 없다 — 그래서 “일단 다들 큰 타입으로 통일해서 보내자”는 약속으로 혼란을 줄인 것이다. 이 장은 그 “타입을 모르는 자리”에서 어떻게 값을 꺼내는지, 그리고 그 대가가 무엇인지의 이야기다.
이 장의 필요성과 맥락
printf 를 써 왔으면서 그 비밀은 여기서야 연다. 미룬 이유는 두 가지가 갖춰져야 했기 때문이다 — 기본 인자 승격(29장)과 포인터(35장). 그리고 이 장이 59장 바로 앞인 것도 맞물린다: 가변 인자와 함수 포인터가 「타입 검사가 느슨해지는 두 자리」로 나란히 읽힌다.이 장이 끝나면
printf가 인자를 몇 개든 받을 수 있었던 비밀을 연다. <stdarg.h>의 네 도구, 이 장치가 왜 타입 안전하지 않은지, 그 역사(K&R의 varargs 에서 C89의 stdarg, C23의 정리까지), 그리고 오늘의 대안까지. 22장의 “서식은 계약”이 왜 그토록 엄격한 계약이었는지가 여기서 밝혀진다.이 장에서 답할 질문
- 그러면
_Generic으로 C에 제네릭이 생긴 것인가? printf같은 서식 함수를 감싸는 내 함수를 만들려면 어떻게 하는가 — 예컨대 로그 함수?
58.1 네 개의 도구
<stdarg.h>가 주는 것은 타입 하나와 매크로 셋이다 — va_list(읽개), va_start(시작), va_arg(하나 꺼내기), va_end(닫기). 시연으로 본다.
examples/ch58/va.c
#include <stdarg.h>
#include <stdio.h>
/* 개수를 첫 인자로 받는 방식 — 스스로는 개수를 알 길이 없다 */
int sum_n(int count, ...)
{
va_list ap;
int total = 0;
va_start(ap, count); /* count 다음부터 읽겠다 */
for (int i = 0; i < count; i += 1) {
total += va_arg(ap, int); /* 타입을 '내가' 알려 준다 */
}
va_end(ap); /* 반드시 닫는다 */
return total;
}
/* 끝 표지(sentinel)로 개수를 대신하는 방식 */
int sum_until_zero(int first, ...)
{
va_list ap;
int total = first;
va_start(ap, first);
for (;;) {
int v = va_arg(ap, int);
if (v == 0) { break; }
total += v;
}
va_end(ap);
return total;
}
int main(void)
{
printf("sum_n(4, 1,2,3,4) = %d\n", sum_n(4, 1, 2, 3, 4));
printf("sum_until_zero(1,2,3,0) = %d\n", sum_until_zero(1, 2, 3, 0));
return 0;
}
실행 결과
sum_n(4, 1,2,3,4) = 10
sum_until_zero(1,2,3,0) = 6
핵심은 va_arg(ap, int)의 둘째 인자다 — 꺼낼 값의 타입을 프로그래머가 알려 준다. 함수는 무엇이 왔는지 모르므로, “지금 꺼낼 것은 int다”라고 말해 주는 사람이 필요하고 그 사람이 호출자와 약속을 공유한 작성자다. 이 약속이 어긋나면 — 실제로는 double이 왔는데 int로 꺼내면 — 계약 밖이다(52장). 기억의 잘못된 자리를 읽는 것이니 값이 쓰레기가 되거나 그 뒤의 꺼내기가 전부 어긋난다.
그리고 함수는 인자가 몇 개인지도 모른다. 그래서 개수를 알아내는 길을 호출 규약으로 만들어야 하는데, 시연이 두 관행을 보인다 — 첫 인자로 개수를 받거나(sum_n), 끝을 알리는 표지 값을 약속하거나 (sum_until_zero). printf는 세 번째 길을 쓴다: 서식 문자열이 개수와 타입을 동시에 알려 주는 명세인 것이다. 22장에서 “서식은 계약”이라 한 말의 정확한 무게가 이것이다 — 그 계약이 깨지면 printf는 있지도 않은 인자를 꺼내며 메모리를 뒤진다(22장의 서식 문자열 취약점이 바로 이 메커니즘이었다).
58.2 기본 인자 승격 — ...을 건너는 값은 굵어진다
va_arg가 타입을 물어보는 이유가 하나 더 있다. ...을 건너는 인자는 원래 타입 그대로 가지 않기 때문이다. 29장에서 본 기본 인자 승격(default argument promotion)이 여기서 실제로 일어난다.
| 넘긴 것 | 실제로 도착하는 것 | 그래서 |
|---|---|---|
char, signed char, unsigned char | int(★) | va_arg(ap, char)는 틀렸다 |
short, unsigned short | int(★) | va_arg(ap, int)로 꺼낸다 |
bool | int | 같음 |
float | double | va_arg(ap, float)는 틀렸다 |
int 이상의 정수 | 그대로 | — |
| 포인터 | 그대로 | 단 널 상수는 캐스트가 필요하다 |
표 58.1
규칙을 한 줄로 줄이면 이렇다 — int보다 좁은 정수는 정수 승격을 거치고, float은 double이 된다. 그래서 printf에 float 전용 서식이 없고 (61장), va_arg(ap, float)라고 쓰면 도착하지도 않은 타입을 꺼내는 계약 위반이 된다.
표의 별표에 단서가 하나 붙는다. 정수 승격의 결과는 거의 언제나 int 이지만, 정확히는 “int가 그 타입의 모든 값을 표현할 수 있으면 int, 아니면 unsigned int”다(29장). 오늘의 주류 기계에서는 unsigned short도 int에 다 들어가므로 int가 되지만, short와 int의 폭이 같은 구현 에서는 unsigned short가 unsigned int로 승격된다. 그런 자리에서 va_arg(ap, int)로 꺼내면 부호가 어긋난다 — 꺼낼 때는 실제로 승격된 타입을 써야 한다는 것이 정확한 규칙이다.
널 포인터를 넘길 때의 함정도 여기서 나온다. NULL이 정수 0으로 정의된 구현에서는 ... 자리에 정수 0이 실려 가서, 받는 쪽이 va_arg(ap, char*)로 꺼내면 폭이 어긋난다. C23의 nullptr(36장)이나 명시적 캐스트((char *)0)를 쓰는 이유다.
58.3 근본적 한계 — 함수는 아무것도 모른다
가변 인자 함수는 넘어온 인자의 개수도, 타입도 알지 못한다. 스택에 값이 놓여 있을 뿐, 그것을 설명하는 정보가 함께 오지 않기 때문이다. 그래서 모든 가변 인자 함수는 그 정보를 바깥에서 얻어야 하고, 방법은 결국 셋뿐이다.
| 방식 | 예 | 깨지는 방식 |
|---|---|---|
| 명세 문자열이 알려 준다 | printf("%d %s", ...) | 서식과 인자가 어긋나면 UB. 서식이 변수면 검사도 불가 |
| 개수·타입을 인자로 받는다 | sum_n(3, a, b, c) | 개수를 잘못 세면 없는 인자를 꺼낸다 |
| 끝 표지를 약속한다 | execl(..., (char *)NULL) | 표지를 빠뜨리면 멈추지 않는다 |
표 58.2
세 방식 모두 사람이 약속을 지켜야 성립한다는 공통점이 있다. 컴파일러가 검사해 줄 정보가 애초에 존재하지 않기 때문이다. 85장에서 이것을 “C가 50년째 출하하는 다섯 가지 버그” 중 하나로 꼽는 이유가 여기 있다.
58.4 _Generic — 타입을 컴파일 시간에 붙잡기
C11이 이 문제에 도구 하나를 보탰다. _Generic은 표현식의 타입에 따라 여러 선택지 중 하나를 컴파일 시간에 고르는 문법이다.
#define TYPE_NAME(x) _Generic((x), \
int: "int", \
double: "double", \
const char *: "string", \
default: "other")주의할 점 셋이 있다. 첫째, 고르는 기준은 타입이지 값이 아니다. 둘째, 고르지 않은 가지는 컴파일되지도 않는다 — 그래서 서로 다른 타입에만 맞는 코드를 나란히 둘 수 있다. 셋째, 목록에 없는 타입을 주면 default가 없는 한 컴파일 오류가 난다 — 이 성질이 다음 절의 핵심이다.
examples/ch58/generic.c
#include <stdio.h>
/* C11 _Generic: 표현식의 *타입*에 따라 컴파일 시간에 하나를 고른다.
실행 시간 비용이 0인 것이 핵심이다. */
#define TYPE_NAME(x) _Generic((x), \
_Bool: "bool", \
char: "char", \
int: "int", \
unsigned: "unsigned", \
long: "long", \
double: "double", \
float: "float", \
const char *: "string", \
char *: "string", \
default: "other")
/* 값과 타입 꼬리표를 한 꾸러미로 묶는다 — proven 의 PROVEN_ARG 와 같은 착상 */
enum arg_kind { A_INT, A_DOUBLE, A_STR };
struct arg {
enum arg_kind kind;
union { long i; double d; const char *s; } v;
};
static struct arg arg_from_int(long v) { return (struct arg){ A_INT, { .i = v } }; }
static struct arg arg_from_double(double v) { return (struct arg){ A_DOUBLE, { .d = v } }; }
static struct arg arg_from_str(const char *v) { return (struct arg){ A_STR, { .s = v } }; }
#define ARG(x) _Generic((x), \
int: arg_from_int, \
long: arg_from_int, \
double: arg_from_double, \
float: arg_from_double, \
const char *: arg_from_str, \
char *: arg_from_str)(x)
/* 인자의 타입을 함수가 *알고 받는다* — 서식 문자열을 믿지 않는다 */
static void print_args(const struct arg *a, size_t n)
{
for (size_t i = 0; i < n; i++) {
switch (a[i].kind) {
case A_INT: printf(" [%zu] int %ld\n", i, a[i].v.i); break;
case A_DOUBLE: printf(" [%zu] double %.3f\n", i, a[i].v.d); break;
case A_STR: printf(" [%zu] string %s\n", i, a[i].v.s); break;
}
}
}
/* 배열 리터럴로 개수까지 함께 넘긴다 — 가변 인자가 아니다 */
#define PRINT(...) do { \
struct arg _args[] = { __VA_ARGS__ }; \
print_args(_args, sizeof _args / sizeof _args[0]); \
} while (0)
int main(void)
{
int n = 42;
double x = 3.14159;
const char *s = "hello";
char c = 'A';
bool b = true;
printf("TYPE_NAME: %s %s %s %s %s\n",
TYPE_NAME(n), TYPE_NAME(x), TYPE_NAME(s), TYPE_NAME(c), TYPE_NAME(b));
printf("typed arguments:\n");
PRINT(ARG(n), ARG(x), ARG(s));
return 0;
}
실행 결과
TYPE_NAME: int double string char bool
typed arguments:
[0] int 42
[1] double 3.142
[2] string hello
예제의 뒷부분이 이 장의 결론이다. ARG(x)는 _Generic으로 값의 타입을 알아내, 값과 타입 꼬리표를 함께 담은 구조체를 만든다. 그리고 그것들을 배열에 담아 넘기므로 함수는 개수까지 알게 된다 — 가변 인자를 아예 쓰지 않는 것이다. 서식 문자열이 없으니 서식과 인자가 어긋날 자리도 없다.
58.5 proven의 PROVEN_ARG — 실물의 구조
91장에서 쓴 proven_println("{}", PROVEN_ARG(x))가 정확히 이 구조다. 실제 구현을 뜯어보면 세 조각으로 되어 있다.
① 타입 꼬리표와 값을 담는 꾸러미. 라이브러리는 인자 하나를 이렇게 표현한다 — 어떤 종류인지 알려 주는 열거값과, 값 자체를 담는 공용체 (48장의 태그된 공용체 그대로다).
typedef enum {
PROVEN_ARG_NONE, PROVEN_ARG_I32, PROVEN_ARG_U32,
PROVEN_ARG_I64, PROVEN_ARG_U64, PROVEN_ARG_F64,
PROVEN_ARG_CSTR, PROVEN_ARG_STR_VIEW, PROVEN_ARG_PTR,
PROVEN_ARG_CHAR, PROVEN_ARG_BOOL, PROVEN_ARG_CUSTOM, /* ... */
} proven_arg_type_t;② 타입마다 하나씩 있는 생성 함수. proven_arg_i32(int v)처럼, 값을 받아 꼬리표를 붙여 돌려주는 작은 함수들이다. 전부 static inline이라 호출 비용이 없다.
③ _Generic으로 그 함수들을 고르는 매크로. 실제 헤더의 모습이다.
#define PROVEN_ARG(x) _Generic((x), \
_Bool: proven_arg_bool, \
char: proven_arg_char, \
signed char: proven_arg_i32, \
unsigned char: proven_arg_u32, \
short: proven_arg_i32, \
unsigned short: proven_arg_u32, \
int: proven_arg_i32, \
unsigned int: proven_arg_u32, \
long: proven_arg_i64, \
unsigned long: proven_arg_u64, \
long long: proven_arg_i64, \
unsigned long long: proven_arg_u64, \
double: proven_arg_f64, \
float: proven_arg_f64, \
const char*: proven_arg_cstr, \
char*: proven_arg_cstr, \
void*: proven_arg_ptr, \
proven_u8str_view_t: proven_arg_str_view,\
proven_arg_t: proven_arg_identity \
)(x)읽는 요령은 마지막 줄의 (x)다. _Generic이 함수 이름 하나로 값이 정해지고, 그 뒤의 (x)가 그 함수를 호출한다. 즉 PROVEN_ARG(n)은 n이 int면 proven_arg_i32(n)으로, double이면 proven_arg_f64(n) 으로 컴파일된다.
설계의 세부 몇 가지가 눈에 띈다.
- 좁은 정수를 굵은 쪽으로 모은다 —
short와signed char가 모두proven_arg_i32로 간다. 앞 절의 기본 인자 승격과 같은 방향이지만, 이쪽은 컴파일러가 아니라 라이브러리가 명시적으로 정한 것이다. float을double로 모은다 — 같은 이유다.char만 따로 둔다 —proven_arg_char가 따로 있어서char c = 'Z'가 숫자 90이 아니라 글자Z로 찍힌다. 다만PROVEN_ARG('Z')는'Z'가int인 C의 규칙 때문에 여전히 90으로 간다 — 헤더가 이 한계를 주석에 정직하게 적어 두었다.proven_arg_t자기 자신도 목록에 있다 — 이미 꾸러미로 만든 값을 다시 감쌀 때 그대로 통과시키기 위한 항등 가지다.- 목록에 없는 타입은 컴파일 오류 —
default가지가 없다. 구조체를 실수로 넘기면 빌드가 실패한다.printf가 무엇이든 받아 주고 실행 중에 무너지던 것과 정반대의 선택이다.
그다음은 단순하다. proven_println("...", PROVEN_ARG(a), PROVEN_ARG(b))는 꾸러미들을 배열 리터럴로 묶어 개수와 함께 실제 함수에 넘긴다. 가변 인자 자리를 통과하지 않으므로 승격도, 개수 추측도, 서식-인자 불일치도 없다.
문. 그러면 _Generic으로 C에 제네릭이 생긴 것인가?
답. 아니다. _Generic은 이미 존재하는 함수들 중에서 고르는 장치이지, 타입에 따라 코드를 새로 만들어 주지 않는다. 타입마다 함수를 하나씩 손으로 써 두어야 하고, 목록도 손으로 유지해야 한다. 그래서 표준 라이브러리의 <tgmath.h>(수학 함수의 타입별 선택)나 이 절의 PROVEN_ARG처럼 유한하고 잘 알려진 타입 집합에 쓰인다. 진짜 제네릭이 필요하면 C에서는 여전히 매크로로 코드를 찍어 내거나(92장의 컨테이너 매크로), void *와 크기를 넘기는 방식(85장의 qsort)을 쓴다.
58.6 역사 — varargs에서 stdarg로
이 장치의 역사는 C가 계약의 언어로 자란 과정의 축소판이다.
표준 이전(K&R 시절). 원형이 없던 시절(12장)의 C에서는 사실상 모든 함수가 인자 개수를 검사받지 않았다 — printf 같은 함수가 특별할 것도 없었다. 인자를 꺼내는 방법도 이식성이 없었다: 첫 인자의 주소에서 스택을 따라 훑는 식의 요령을 각자 썼는데, 인자를 스택이 아니라 레지스터로 넘기는 기계가 나오면서 이 요령이 깨졌다. 그래서 나온 것이 Unix 계열의 <varargs.h>였다 — 요령을 매크로로 감싸 기계 차이를 숨긴 첫 시도다.
C89 — <stdarg.h>. 표준화 위원회가 원형을 도입하면서(10·24장) 가변 인자도 정식 문법을 얻었다: 원형에 ...을 적고, 값 꺼내기는 va_* 매크로로 한다. 옛 varargs와의 결정적 차이는 이름 있는 인자가 최소 하나 필요하다는 것이다(va_start가 그 인자를 기준점으로 삼는다) — 그래서 printf(const char *fmt, ...)처럼 서식이 앞에 오는 서명이 표준이 됐다.
C23 — 다듬기. 기준점 인자가 필요하다는 제약이 완화되어(va_start의 둘째 인자가 선택적) 인자 없는 가변 함수도 적을 수 있게 됐고, 옛 varargs.h는 역사 속으로 완전히 사라졌다. 반세기에 걸쳐 “이식성 없는 요령 → 표준 매크로 → 군더더기 제거”로 다듬어 온 것이다.
흔한 오해. “가변 인자 함수는 편리하니 자주 쓰면 좋다”
편리해 보이지만, C에서 가변 인자는 타입 안전이 사라지는 자리다. 컴파일러는... 자리의 인자를 검사하지 않고, 받는 쪽은 타입을 추측할 방법이 없다. printf 계열이 그나마 안전한 것은 컴파일러가 특별히 서식 문자열을 읽어 검사해 주기 때문이지(17장의 경고), 언어의 일반 규칙 덕분이 아니다 — 직접 만든 가변 인자 함수에는 그 그물이 없다. 실무 권고는 분명하다: 꼭 필요하지 않으면 만들지 않는다. 대안이 대개 더 낫다 — 개수가 정해졌으면 그냥 인자를 나열하고, 여럿 이면 배열과 길이를 받고(38장의 관행), 종류가 다양하면 48장의 태그된 공용체로 명시적인 값 목록을 받는다. C 라이브러리 설계의 현대적 취향은 “가변 인자는 로깅·서식처럼 도구의 검사가 붙는 자리에만”이다.문. printf 같은 서식 함수를 감싸는 내 함수를 만들려면 어떻게 하는가 — 예컨대 로그 함수?
답. 그 자리를 위해 v 붙은 짝이 표준에 준비되어 있다 — vprintf, vfprintf, vsnprintf처럼 va_list를 그대로 받는 판들이다. 내 함수가 ...으로 받아 va_start로 읽개를 만든 뒤, 그 읽개를 통째로 넘겨주면 된다. 서식 검사 경고를 내 함수에서도 받고 싶다면 컴파일러 확장 표기(gcc·clang의 format 속성)를 붙이는 것이 관행이다 — 언어가 못 주는 안전을 도구가 메우는, 이 책에 여러 번 나온 무늬다.
가변 인자의 정체를 알았다 — 함수에 값을 여럿 넘기는 법이었다. 다음 장은 그 거울상이다. 함수 자체를 값으로 다루는 법, 즉 함수 포인터다.