가. 가변 인수 함수
가변 인수 함수에 대해서 알아 본다. 가변 인수의 함수를 만드는 방법에 대해서는 물론이고 가변 인수 함수가 동작하는 원리에 대해서도 자세하게 분석해 볼 것이다. 조금 어렵기는 하지만 포인터를 적절하게 활용하는 예를 볼 수 있으며 포인터로 어떤 일이 가능한지를 경험할 수 있는 좋은 기회가 될 것이다. 가변 인수 함수가 어떻게 동작하는지를 설명할 수 있다면 포인터를 정복했다고 생각해도 좋다.
가변 인수란 말 뜻 그대로 인수의 개수와 타입이 미리 정해져 있지 않다는 뜻이며 그런 인수를 사용하는 함수를 가변 인수 함수라고 한다. 가변 인수 함수의 가장 좋은 예는 C언어의 가장 기초 함수인 printf이다. C언어를 배우는 사람이 가장 먼저 배우는 친근한 함수이므로 이 함수를 통해 가변 인수 함수를 어떻게 사용하는지 연구해 보자. 이 함수는 서식 문자열과 서식에 대응되는 임의 타입의 인수들을 개수에 상관없이 전달받을 수 있다. 다음이 printf 함수의 호출 예이다.
printf("정수는 %d이고 실수는 %f이다.", i, d);
printf("이름=%s, 나이=%d, 키=%f", "김상형", 25, 178.8);
printf("%d + %f = %f", 123, 3.14, 123 + 3.14);
각 printf 함수로 전달되는 인수의 개수와 타입이 모두 다르지만 정상적으로 컴파일되고 실행된다. 반면 gotoxy(10,15,"quickly")나 strcpy(src,dest,3) 따위의 호출은 당장 컴파일 에러로 처리된다. 이런 함수들은 가변 인수를 받아들이지 않기 때문에 헤더 파일에 적힌 원형대로 정확하게 인수의 개수와 타입을 맞춰서 호출해야 한다. 인수가 남아서도 안되며 모자라도 안되고 타입이 틀려도 에러로 처리된다. 그렇다면 printf 함수의 원형은 어떻게 선언되어 있길래 가변 인수를 처리할 수 있을까? 다음이 printf 함수의 원형이다.
int printf(const char *format, ...);
이 함수의 첫 번째 인수는 format이라는 이름의 문자열 상수인데 흔히 서식 문자열이라고 부른다. 두 번째 이후의 인수에는 타입과 인수 이름이 명시되어 있지 않으며 대신생략 기호(ellipsis)인 ...이 적혀 있다. 생략 기호는컴파일러에게 이후의 인수에 대해서는 개수와 타입을 점검하지 않도록 하는데 이 기호에 의해 가변 인수가 가능해진다.
컴파일러는 ...이후의 인수에 대해서는 개수가 몇 개든지 어떤 타입이든지 상관하지 않고, 있는 그대로 함수에게 넘겨주므로 얼마든지 많은 임의 타입의 인수들을 전달할 수 있다. 대신 전달된 인수의 정확한 타입을 판별하여 꺼내쓰는 것은 함수가 알아서 해야 한다. 컴파일러는 인수를 마음대로 취할 수 있도록 허락은 해 주지만(사실은 허락이 아니라 무관심이다) 뒷일에 대해서는 절대로 책임지지 않는다.
생략 기호 이전에 전달되는 인수를 고정 인수라고 하는데 printf 함수의 경우 format 인수가 바로 고정 인수이다. 고정 인수는 원형에 타입과 개수가 분명히 명시되어 있으므로 원형대로 정확하게 전달해야 한다. printf가 아무리 가변 인수를 지원한다고 하더라도 printf(1, 2)나 printf(3.14) 따위의 호출은 안된다. printf의 첫 번째 인수는 반드시 const char * 타입의 서식 문자열이어야 하며 두 번째 인수부터 가변 인수이다.
가변 인수 함수를 사용하는 것은 별로 어렵지 않다. printf 함수의 경우 고정 인수인 서식 문자열을 먼저 전달하고 서식의 개수와 타입에 맞는 인수들을 순서대로 전달하기만 하면 된다. 그렇다면 이런 가변 인수를 취할 수 있는 함수는 어떻게 만드는지 알아보자. 관건은 자신에게 전달된 임의 타입의 인수들을 순서대로 꺼내서 정확한 값을 읽는 것이다. 가변 인수 함수의 개략적인 구조는 다음과 같다.
void VarFunc(int Fix, ...)
{
va_list ap;
va_start(ap, Fix);
while (모든 인수를 다 읽을 때까지)
{
va_arg(ap, 인수타입);
}
va_end(ap);
}
물론 함수의 이름이나 원형, 고정 인수의 개수 등은 필요에 따라 마음대로 작성할 수 있다. 마지막 인수 자리에 ...만 있으면 가변 인수 함수가 된다. 가변 인수 함수 내부에서는 인수를 읽기 위해 이상한 모양의 매크로 함수들을 많이 사용하는데 이 문장들을 각각 분석해 보자.
va_list ap
함수로 전달되는 인수들은 스택(Stack)이라는 기억 장소에 저장되며 함수는 스택에서 인수를 꺼내 쓴다. 스택에 있는 인수를 읽을 때 포인터 연산을 해야 하는데 현재 읽고 있는 번지를 기억하기 위해 va_list형의 포인터 변수 하나가 필요하다. 변수 이름은 ap로 되어 있는데 ap는 어디까지나 지역 변수일 뿐이므로 이름은 마음대로 정할 수 있되 관습적으로 가변 인수를 다루는 매크로에서는 ap라는 이름을 사용한다. va_list 타입은 char *형으로 정의되어 있다. 가변 인수를 읽기 위한 포인터 변수를 선언했다고 생각하면 된다.
va_start(ap, 마지막고정인수)
이 명령은 가변 인수를 읽기 위한 준비를 하는데 ap 포인터 변수가 첫 번째 가변 인수를 가리키도록 초기화한다. 첫 번째 가변 인수의 번지를 조사하기 위해서 마지막 고정 인수를 전달해 주어야 한다. va_start 내부에서는 마지막 고정 인수 다음 번지로 ap를 맞추어 주므로 이후부터 ap 번지를 읽으면 순서대로 가변 인수를 읽을 수 있다.
va_arg(ap, 인수타입)
가변 인수를 실제로 읽는 명령이다. va_start가 ap를 첫 번째 가변 인수 번지로 맞추어 주므로 ap 위치에 있는 값을 읽기만 하면 된다. 단, ap 번지에 있는 값이 어떤 타입인지를 알아야 하므로 두 번째 인수로 읽고자 하는 값의 타입을 지정해 주어야 한다. 예를 들어 ap 위치에 있는 정수값을 읽고자 한다면 va_arg(ap, int)를 호출하고 실수값을 읽고자 한다면 va_arg(ap, double)이라고 호출하면 된다. 이 명령은ap위치에서 타입에 맞는 값을 읽어 리턴해 주며 또한 ap를 다음 가변 인수 위치로 옮겨준다.그래서 va_arg를 반복적으로 호출하면 전달된 가변 인수를 순서대로 읽을 수 있다.
그런데 이 명령에서 조금 이상한 점을 발견할 수 있는데 int나 double같은 타입 이름이 어떻게 함수의 인수로 전달될 수 있는가 하는 점이다. 함수의 인수로는 값이 전달되는 것이 정상인데 타입명이 어떻게 함수의 인수가 될 수 있는가 말이다. 타입명은 분명히 함수의 인수가 될 수 없다. 그럼에도 불구하고 va_arg가 타입명을 인수로 받아들일 수 있는 이유는 va_arg가 진짜 함수가 아니라 매크로 함수이기 때문이다. va_arg의 두 번째 인수는 내부적으로 sizeof 연산자와 캐스트 연산자로 전달되기 때문에 타입명이 될 수 있다.
va_end(ap)
이 명령은 가변 인수를 다 읽은 후 뒷정리를 하는데 별다른 동작은 하지 않으며 실제로 없어도 전혀 지장이 없다. 이 명령이 필요한 이유는 호환성 때문인데 플랫폼에 따라서는 가변 인수를 읽은 후에 뒷처리를 해 주어야 할 필요가 있기 때문이다. 적어도 인텔 계열의 CPU에서는 va_end가 아무 일도 하지 않는다. 그러나 다른 플랫폼이나 미래의 환경에서는 va_end가 중요한 역할을 할 수도 있으므로 호환성을 위해서는 관례적으로 넣어 주는 것이 좋다.
여기까지 설명을 읽고 "음, 그렇군, 가변 인수 함수 만들기 무지 쉽군"이라고 한번에 이해할 수 있는 사람은 많지 않을 것이다. 이 매크로들을 사용하는 방법과 정확한 동작 원리는 좀 더 연구해 봐야 할 과제이다. 일단 실제로 동작하는 가변 인수 함수를 하나 만들어 보자. 다음 예제의 GetSum 함수는 첫 번째 인수로 전달된 num 개수만큼의 정수 인수들의 합계를 구해 리턴해 준다.
예 제 : GetSum
#include <Turboc.h>
int GetSum(int num, ...)
{
int sum = 0;
int i;
va_list ap;
int arg;
va_start(ap, num);
for (i = 0; i < num; i++)
{
arg = va_arg(ap, int);
sum += arg;
}
va_end(ap);
return sum;
}
void main()
{
printf("1+2=%d\n", GetSum(2, 1, 2));
printf("3+4+5+6=%d\n", GetSum(4, 3, 4, 5, 6));
printf("10~15=%d\n", GetSum(6, 10, 11, 12, 13, 14, 15));
}
GetSum 함수의 첫 번째 인수 num은 전달될 정수 인수의 개수를 가지는 고정 인수이며 이 인수 다음에 num 개의 정수 값을 나열해 주면 된다. 인수의 개수가 몇개이든간에 전달된 모든 값의 합계를 더해 리턴해 줄 것이다. 실행 결과는 다음과 같다.
1+2=3
3+4+5+6=18
10~15=75
num 다음의 가변 인수가 1개든, 10개든, 100개든 GetSum 함수는 전달된 모든 인수의 합계를 구해 줄 것이다. GetSum 함수에서 가변 인수들을 어떻게 읽는지 분석해 보자. va_list형의 포인터 ap를 선언하고 va_start(ap, num) 호출로 ap가 마지막 고정 인수 num 다음의 위치, 그러니까 첫 번째 가변 인수를 가리키도록 초기화했다. 그리고 num만큼 루프를 돌면서 va_arg(ap,int) 호출로 ap 위치에 있는 int값을 계속 읽어 sum에 누적시킨다.
모든 가변 인수를 다 읽었으면 va_end(ap)로 뒷정리를 하고 계산된 sum값을 리턴하였다. 앞에서 보인 기본 형식대로 va_ 매크로를 사용하여 가변 인수를 읽어 처리하기만 하면 되므로 사용만을 목적으로 한다면 그리 어렵지 않다.
나.가변 인수 함수의 조건
가변 인수 함수는 인수의 개수와 타입에 대한 제약이 없지만 그렇다고 해서 아무 인수나 마음대로 전달할 수 있는 것은 아니다. 가변 인수 함수에도 지켜야 할 규칙들이 있는데 이 규칙에 대해 알아 보자.
1.가변 인수 함수는 반드시 하나 이상의 고정 인수를 가져야 한다.첫 번째 인수부터 가변 인수일 수는 없는데 왜냐하면 가변 인수를 읽기 위한 포인터 ap를 초기화하기 위해서 마지막 고정 인수의 번지를 알아야 하기 때문이다. va_start 매크로는 마지막 고정 인수의 번지에 길이를 더해 가변 인수가 시작되는 번지를 계산하는데 고정 인수가 없으면 이 매크로가 동작하지 않는다. GetSum 함수는 인수의 개수를 전달하는 num 고정 인수를 가지며 printf 함수도 서식 문자열을 첫 번째 인수로 가진다.
만약 고정 인수를 가지지 않는 가변 인수 함수를 꼭 만들고 싶다면 va_ 매크로를 쓰는 대신 스택을 직접 뒤지는 방법을 사용할 수는 있다. 하지만 컴파일러마다 함수를 호출할 때 스택을 조작하는 방법이 다르고 어셈블리를 직접 사용해야 하기 때문에 일반적으로 불가능하다고 보는 편이 옳다. 또한 바로 다음의 2, 3번 규칙을 만족하기 위해서도 고정 인수가 필요하다. 가변 인수들을 일관된 방법으로 읽기 위해서는 반드시 하나 이상의 고정 인수가 있어야 한다.
2. 함수 내부에서 자신에게 전달된 가변 인수의 개수를 알 수 있도록 해야 한다.전달될 수 있는 인수의 개수에는 제한이 없으며 컴파일러는 함수가 호출될 때 인수의 개수를 점검하지도 않는다. 그래서 호출측에서 가변 인수가 몇개나 전달되었는지를 알려 주지 않으면 함수 내부에서 인수의 개수를 알 수 있는 방법이 전혀 없다. 함수 스스로 인수의 개수를 파악할 수 있도록 호출측이 정보를 제공해야 한다.
GetSum 함수는 첫 번째 고정 인수 num을 통해 뒤쪽의 가변 인수가 몇개나 전달되었는지를 알려 주도록 되어 있으며 함수 내부에서는 num만큼 루프를 돌면서 va_arg로 인수들을 읽었다. 만약 num 인수가 없다면 GetSum 함수는 루프를 얼마만큼 돌아야 할 지 결정할 수 없을 것이다.
GetSum 함수의 예처럼 가변 인수의 개수를 고정 인수로 알려 주는 것은 가장 쉽기는 하지만 개수를 바꿀 때마다 고정 인수를 수정해야 하므로 불편할 수도 있다.고정 인수로 개수를 전달하는 것이 귀찮다면 가변 인수의 목록 끝에 특이값을 전달하는 방법을 쓸 수도 있는데 예를 들어 인수값 중 0을 만나면 이 값을 가변 인수의 끝으로 인식하도록 약속을 하는 것이다.이런 방법으로 GetSum 함수를 수정해 보았다.
예 제 : PrintSum
#include <Turboc.h>
void PrintSum(const char *msg, ...)
{
int sum = 0;
va_list ap;
int arg;
va_start(ap, msg);
for (;;)
{
arg = va_arg(ap, int);
if (arg == 0)
{
break;
}
sum += arg;
}
va_end(ap);
printf(msg,sum);
}
void main()
{
PrintSum("1+2=%d\n",1,2,0);
PrintSum("3+4+5+6=%d\n",3,4,5,6,0);
PrintSum("10~15=%d\n",10,11,12,13,14,15,0);
}
GetSum 함수를 수정한 PrintSum 함수는 서식 문자열과 여러 개의 정수 값을 인수로 전달받되 가변 인수의 끝에는 0을 두어 0을 만날 때까지 모든 인수의 값을 합해 메시지를 직접 출력한다. 함수 내부의 루프는 무한 루프로 수정되었으며 읽은 인수 값이 0일 때까지 루프를 돌도록 했다. 실행 결과는 앞에서 만든 예제와 동일하다.
GetSum 함수는 가변 인수의 개수를 고정 인수를 통해 직접적으로 알려 주도록 했으며 PrintSum 함수는 개수는 알려 주지 않되 가변 인수의 끝을 알려 주는 특별한 표지 값을 전달함으로써 이 값이 나올 때까지 가변 인수를 취할 수 있도록 했다. 어떤 방법을 쓰든지 어쨋든 함수 내부에서 가변 인수의 개수를 알 수 있도록만 해 주면 된다.
그렇다면 표준 함수인 printf는 인수의 개수를 어떻게 파악할까? 개수를 전달해 주는 고정 인수도 없고 끝을 나타내는 특이값도 없어서 함수 내부에서 가변 인수의 개수를 알 수 없는 것 같다. 그러나 자세히 관찰해 보면 서식 문자열에 포함된 서식의 개수가 바로 가변 인수의 개수와 일치한다는 것을 알 수 있다.printf는 첫 번째 고정 인수로 전달되는 서식 문자열에서 %d, %f, %s 같은 서식의 개수만큼 가변 인수를 읽음으로써 사실상 가변 인수의 개수를 전달받는다.
3.개수와 마찬가지로 함수 내부에서 각각의 가변 인수 타입을 알 수 있어야 한다.GetSum이나 PrintSum 함수처럼 모든 인수를 정수형으로 고정하든가 아니면 첫 번째, 두 번째는 실수, 세 번째 이후는 모두 정수라는 식으로 미리 약속이 되어 있어야 한다. printf는 대응되는 서식으로부터 가변 인수의 타입을 판별하는데 %d가 제일 처음 나왔으면 첫 번째 가변 인수는 정수, 다음으로 %f가 나왔으면 두 번째 가변 인수는 실수라는 것을 알게 된다.
가변 인수들의 타입을 알아야 하는 이유는 va_arg 매크로가 ap번지에서 가변 인수를 읽을 때 얼마만큼 읽어서 어떤 타입으로 해석해야 할 지를 알아야 하기 때문이다. 가변 인수의 타입을 전달하는 방식도 여러 가지를 생각할 수 있는데 printf와 같이 하나의 고정 인수를 통해 모든 가변 인수의 타입을 판단할 수 있는 힌트를 제공하는 방식이 가장 좋다.
다음 예제의 GetSum2 함수는 types 고정 인수에 이후 전달되는 가변 인수들의 타입을 문자열로 전달한다. 정수형에 대해서는 i, 실수형에 대해서는 d라는 문자를 할당해서 이 문자들을 순서대로 죽 적어주는 것이다. 예를 들어 types가 "iidd"라면 앞 쪽 두 인수는 정수형이고 뒤쪽 두 인수는 실수형이며 총 가변 인수는 4개라는 정보가 전달된다.
예 제 : GetSum2
#include <Turboc.h>
double GetSum2(const char *types, ...)
{
double sum = 0;
va_list ap;
const char *p;
va_start(ap, types);
for (p = types; *p; p++)
{
switch (*p)
{
case 'i':
sum += va_arg(ap, int);
break;
case 'd':
sum += va_arg(ap, double);
break;
}
}
va_end(ap);
return sum;
}
void main()
{
printf("1+2=%f\n", GetSum2("ii", 1, 2));
printf("2.5+3.8+4=%f\n", GetSum2("ddi", 2.5, 3.8, 4));
printf("1+2.345+6+7.8901=%f\n", GetSum2("idid", 1, 2.345, 6, 7.8901));
}
types 고정 인수를 통해 인수의 개수와 타입까지도 한꺼번에 전달할 수 있기 때문에 정수형, 실수형을 마구 섞어서 전달해도 함수 내부에서 다양한 타입의 인수들을 제대로 읽을 수 있을 것이다. 실행 결과는 다음과 같다.
1+2=3.000000
2.5+3.8+4=10.300000
1+2.345+6+7.8901=17.235100
GetSum2 함수에서는 types의 길이만큼 루프를 돌되 이 문자열의 처음부터 순서대로 문자를 읽으면서 i이면 va_arg(ap, int)로 인수를 읽고 d이면 va_arg(ap, double)로 인수를 읽었다. 정수, 실수 외에도 더 다양한 타입을 전달하고 싶다면 types의 의미를 확장하고 switch문의 case만 늘려 주면 된다.
규칙들이 다소 복잡하다고 느껴질 지 모르겠지만 잘 생각해 보면 지극히 당연한 규칙들 뿐이다. 함수 내부에서 전달된 인수의 개수나 타입을 전혀 알 수 없다면 값을 정확하게 읽지 못하므로 이런 규칙이 필요하다. 규칙만 지킨다면 인수에 대한 정보를 알려 주는 방법에 대해서는 자유를 누릴 수 있다.
가변 인수 함수는 인수의 개수나 타입에 대해 호출측에서 자유롭게 결정할 수 있는 편리한 함수이다. 그러나 자유에는 언제나 책임이 따르는 법이라 규칙을 제대로 지키지 않았을 때의 결과에 대해 컴파일러는 어떠한 책임도 지지 않는다. 가변 인수 함수를 잘못 호출했을 때 어떤 결과가 발생하는지 다음 예제를 실행해 보자.
예 제 : printfCall
#include <Turboc.h>
void main()
{
delay(1000); printf("%d%d\n", 1,2);
delay(1000); printf("%d%d%d\n", 1,2,3,4,5);
delay(1000); printf("%d%d\n", 1,3.14);
delay(1000); printf("%f%f\n", 1,2);
delay(1000); printf("%s\n", 1);
}
다섯 개의 printf 함수 호출문이 있고 결과를 천천히 감상할 수 있도록 1초씩 시간을 지연시키도록 했다. 순서대로 이 호출문들을 분석해 보자.
첫 번째 호출문은 두 개의 정수를 출력하되 서식과 인수의 개수, 타입이 정확하게 일치하며 따라서 이 호출문은 아주 정상적으로 처리될 것이다.
두 번째 호출문에는 서식이 세 개 밖에 없지만 인수는 다섯 개나 전달되었다. 이 경우도 정상 실행되는데 모자라는 것은 문제가 되지만 남는 것은 무시해 버리면 되므로 문제가 되지 않는다.
세 번째 호출문은 %d 서식 두 개를 가지고 있지만 실제 전달된 인수는 정수 상수 하나와 실수 상수 하나여서 개수는 맞지만 타입이 일치하지 않는다. 이 경우 printf는 두 번째 가변 인수를 정수형으로 읽기 때문에 3.14를 억지로 정수형으로 바꾸어 출력하게 된다. 이상 동작을 하지는 않지만 어쨋든 원하던 결과는 아닐 것이다.
네 번째 호출문은 두 개의 %f 서식을 가지고 있지만 가변 인수는 둘 다 정수형이다. 이 경우 정수형 값을 8바이트의 실수형으로 읽으려고 시도하게 되는데 원하는 결과도 나오지 않을 뿐더러 잘못하면 다운될 수도 있다. 이 코드가 다운될 것인가 아닌가는 순전히 운의 문제이되 요행스럽게도 스택에는 자유 공간이 많이 남아 있기 때문에 8바이틀 더 읽는다고 해서 쉽게 다운되지는 않는다.
마지막 호출문은 심각한데 서식은 %s로 되어 있어 가변 인수가 문자열인 것으로 전달되지만 실제 전달된 인수는 정수형이다. 따라서 불쌍한 printf는 정수 1을 포인터로 해석하여 이 위치의 문자열을 읽으려고 시도하는데 절대 번지 1은 시스템 영역이기 때문에 그 자리에서 즉사해버린다. 32비트의 보호된 환경에서는 허가되지 않은 영역을 읽으려고 할 때 운영체제가 강제로 프로세스를 종료해 버리기 때문이다.
보다시피 가변 인수 함수를 잘못 쓰면 이렇게 위험해질 수 있다. 그러나 이런 위험한 코드를 작성했음에도 불구하고 위 예제를 컴파일하면 컴파일러는 뻔뻔스럽게도 0 error 0 warning이라는 결과를 보여 준다. 컴파일러는 위 코드가 위험한지 아닌지를 판별할 능력도 없고 권한도 없다. printf 함수의 원형에는 ...이라고 되어 있어 인수에 대해서는 개수든 타입이든 간섭하지 말라고 했기 때문이다. 그러니 가변 인수 함수는 사용하는 사람이 주의하는 수밖에 없다.
다.매크로 분석
가변 인수 함수는 만드는 방법과 주의 사항 등에 대해 알아 봤는데 사용만을 목적으로 한다면 여기까지만 이해해도 충분하다. 그러나 가변 인수를 읽어 내는 매크로들이 어떤 식으로 동작하는지, 포인터를 어떻게 조작하길레 임의 타입의 인수를 자유 자재로 읽을 수 있는지 호기심이 발동한다면 va_ 매크로를 분석해 보도록 하자. 이 매크로들은 길이가 짧지만 포인터와 sizeof 연산자, 그리고 비트 연산자들이 어떻게 절묘하게 동작하는지 감상해 볼 수 있는 좋은 연구 과제이다.
가변 인수에 대한 언어의 문법적 지원은 인수 목록에 대한 점검을 무시하도록 하는 ... 밖에 없다. 그렇다고 해서 라이브러리 차원의 함수 지원이 있는 것도 아니며 컴파일러가 가변 인수를 특별하게 처리해 주는 것도 아니다. 가변 인수에 대한 모든 지원은 오로지 표준 헤더 파일 stdarg.h에 정의되어 있는 매크로에 의해 구현된다.
이 헤더 파일을 직접 열어 보면 플랫폼별로 va_ 매크로들이 각각 작성되어 있는데 대부분의 경우 인텔 계열의 CPU를 사용하고 있으므로 매킨토시나 알파, MIPS 같은 경우는 무시하고 X86 계열의 경우만 분석해 보도록 하자. 실제 매크로 구문은 컴파일러마다 조금씩 다른데 아래 코드는 가장 간략하게 잘 정리되어 있다고 생각되는 비주얼 C++ 6.0의 stdarg.h 헤더 파일에 기록된 내용이다. 나머지 플랫폼의 매크로도 큰 틀은 비슷하다.
typedef char * va_list;
#define _INTSIZEOF(n) ( (sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1) )
#define va_start(ap,v) ( ap = (va_list)&v + _INTSIZEOF(v) )
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap) ( ap = (va_list)0 )
먼저 va_list에 대한 타입 정의를 볼 수 있는데 va_list는 단순한 char *형으로 정의되어 있다. 여기서char에 대한 포인터라는 것은 별다른 의미는 없고 증감할 때 1바이트씩 증감하도록 하기 위해 char형 포인터로 선언된 것이다.실제로 어떤 컴파일러는 va_list를 void *로 정의하는 것도 있다. 중요한 것은 va_list 타입이 포인터 타입이라는 것이다.
_INTSIZEOF(n) 매크로는 인수로 전달된 타입 n의 길이를 계산하는데 n의 값에 따라 이 매크로의 계산 결과가 어떻게 되는지 조사해 보자. 매크로의 연산식을 엄밀하게 분석해 보면 각 타입의 크기가 얼마로 계산될 지 예측할 수 있지만 이럴 때는 그냥 프로그램을 하나 만들어 확인해 보는 것이 더 간편하고 확실하다.
printf("char = %d\n", _INTSIZEOF(char));
printf("short = %d\n", _INTSIZEOF(short));
printf("int = %d\n", _INTSIZEOF(int));
printf("float = %d\n", _INTSIZEOF(float));
printf("double = %d\n", _INTSIZEOF(double));
크기별로 각 타입에 대해 _INTSIZEOF 매크로가 어떤 값을 계산해 내는지 출력해 보았다. 결과는 다음과 같다.
char = 4
short = 4
int = 4
float = 4
double = 8
char형의 크기는 1이지만 이 매크로에 의해 4로 계산되며 short, float도 4가 되며 double은 8이 된다. 이 매크로가 하는 일은 타입의 크기를 4의 배수로 올림해 준다고 할 수 있는데 좀 더 정확하게 표현하자면 정수형의 크기에 대한 배수로 올림한다. 알다시피 정수형의 크기는 시스템마다 다른데 16비트 환경에서는 2바이트이고 32비트 환경에서는 4바이트이며 이 크기는 또한 스택 하나의 크기이기도 하다.
결국 이 매크로는 각 타입의 변수가 스택을 통해 함수로 전달될 때 몇 바이트를 차지하는가를 계산해낸다. char형이 1바이트라도 함수의 인수로 전달될 때는 int형으로 확장되므로 스택에는 4바이트로 들어가며 _INTSIZEOF는 인수가 스택에 들어가 있을 때의 크기를 계산해 내는 것이다. 아주 간단한 동작을 하는 매크로이지만 플랫폼에 따른 스택의 크기까지 고려하여 이식성을 보장할 수 있도록 잘 작성되어 있다. 4바이트의 배수 타입에 대해서 _INTSIZEOF는 sizeof 연산자와 실질적으로 동일하다.
va_start 매크로는 가변 인수의 위치를 가리키는 포인터 ap를 초기화하는데 이 초기화를 위해 마지막 고정 인수 v를 전달해 주어야 한다. ap는 마지막 고정 인수 v의 번지에 v의 크기를 더한 번지로 초기화된다. 스택에 인수가 들어갈 때는 전달된 역순으로 들어가므로 가변 인수들이 먼저 전달(높은 번지)되고 고정 인수가 제일 끝에 전달(낮은 번지)된다. 따라서 가변 인수 함수가 호출된 직후의 스택 모양은 다음과 같다.
이 상태에서 &v는 고정 인수의 번지를 가리키며 이 번지를 char *로 캐스팅한 후 고정 인수의 길이만큼 더하면 바로 아래에 있는 첫 번째 가변 인수의 번지를 구할 수 있다. va_start 매크로는 이 연산을 통해 ap에 가변 인수의 시작 번지를 초기화한다. 이후 ap에 있는 값을 읽으면 가변 인수의 값을 구할 수 있는데 이 동작을 하는 매크로가 바로 va_arg 매크로이다.
va_arg 함수는 ap를 일단 가변 인수의 길이만큼 더해 다음 가변 인수 번지로 이동시킨다. 그리고 다시 길이를 빼서 원래 자리로 돌아온 후 이 번지를 t타입의 포인터로 캐스팅하여 * 연산자로 그 값을 읽는다. 이 매크로는 ap의 값을 읽기만 하는 것이 아니라 다음번 va_arg 호출을 위해 ap를 방금 읽은 가변 인수 다음의 번지로 옮겨 주는 동작까지 해야 하기 때문에 길이를 더했다가 다시 뺀 후 그 위치를 읽도록 되어 있다. 이 매크로의 동작을 그림으로 그려 보면 다음과 같다.
중간 변수를 사용하지 않고 매크로 한 줄로 값을 읽기도 하고 ap를 다음 위치로 옮겨 놓기도 해야 하므로 조금 복잡하게 되어 있는데 매크로 구문의 연산 순서에 따라 어떤 동작들이 일어나는지를 보도록 하자.
va_arg(ap,t) 호출문은 ap 번지에 있는 가변 인수를 t 타입으로 읽고 그 길이만큼 ap를 증가시켜 다음 가변 인수를 읽을 수 있도록 한다. 그래서 va_arg를 계속 호출하면 가변 인수들을 연속적으로 액세스할 수 있다. 단, va_arg가 정확하게 읽고 길이만큼 다음 위치로 이동하기 위해서는 가변 인수의 타입을 반드시 알려 주어야 한다. va_arg 매크로의 동작을 좀 더 잘게 분할해 본다면 다음과 같다.
ret=*(t *)ap;
ap += _INTSIZEOF(t);
return ret;
ap 포인터를 t *로 캐스팅한 후 이 자리에 있는 값을 읽어 ret에 대입해 놓고 ap는 t의 크기만큼 증가시켜 다음 위치로 이동한다. 그리고 전체 결과로 ret를 리턴하는 것이다. 위 코드는 어디까지나 va_arg 매크로의 동작 설명을 위해 코드를 풀어 놓은 것이지 실제로 이런 코드를 작성할 수는 없다. 왜냐하면 매크로는 지역 변수를 가질 수 없고 설사 블록 범위 변수를 쓴다 하더라도 가변적인 ret의 타입을 결정할 수 없기 때문이다. 그래서 이 세 동작을 한 매크로 구문으로 절묘하게 구겨 넣은 것이 바로 va_arg이다. 이 매크로는 가히 예술이라고 평가해도 될 정도로 잘 만들어져 있다.
마지막으로 va_end 매크로는 가변 인수를 가리키던 ap 포인터를 NULL로 만들어 무효화시키는데 사실 이 동작은 굳이 필요치 않다. 어차피 ap는 지역 변수로 선언되었고 함수가 종료되면 사라지므로 어떤 값을 가지더라도 아무 문제가 없으며 실제로 va_end 호출을 빼도 별 문제없이 잘 동작한다. va_end 매크로는 미래의 플랫폼에서 가변 인수를 읽는 방법이 달라질 경우 뒷정리를 할 수 있는 위치를 확보하는 역할 이외에는 아무 의미가 없다.
가변 인수 함수의 예제로 최초 작성했던 GetSum 함수를 매크로를 쓰지 않고 전개해서 간략하게 다시 작성해 보면 다음과 같다. 동작은 완벽하게 동일하다.
int GetSum(int num, ...)
{
int sum=0;
int i;
//va_list ap;
char *ap;
int arg;
//va_start(ap,num);
ap = (char *)&num + sizeof(num);
for (i = 0; i < num; i++)
{
//arg = va_arg(ap,int);
arg =* (int *)ap;
ap += sizeof(int);
sum += arg;
}
//va_end(ap);
return sum;
}
보다시피 가변 인수 함수는 포인터 연산, sizeof 연산자, 캐스트 연산자들의 절묘한 조합에 의해 동작한다는 것을 알 수 있다. 이 동작을 좀 더 쓰기 쉽고 호환성과 이식성에 유리하도록 정리해 놓은 것이 바로 va_ 매크로이다.
라.가변 인수 함수의 활용
가변 인수 함수는 한 줄에 여러 개의 정보를 다양한 방법으로 다룰 수 있다는 면에서 편리하다. printf 함수는 다양한 타입의 변수를 한꺼번에 출력할 수 있어 변수 값을 확인해 볼 때 아주 유용하다. 이런 함수를 직접 만들려면 독자적으로 서식을 정의하고 서식 문자열과 대응되는 가변 인수를 직접 읽는 복잡한 루틴을 만들어야 하는데 다행히 이런 일을 대신해 주는 함수가 준비되어 있다. 대표적으로 다음 두 함수만 소개 한다.
int vprintf(const char *format, va_list argptr);
int vsprintf(char *buffer, const char *format, va_list argptr);
이외에 vscanf, vsscanf 등의 함수도 있는데 알파벳 v로 시작한다고 해서 이런 함수를 v계열의 함수라고 한다. 위 두 함수는 printf, sprintf와 동일한 기능을 수행하는데 가변 인수를 직접 나열하는 대신 가변 인수가 시작되는 번지만을 인수로 취한다는 점이 다르다. 즉 실제로 가변 인수를 취하지는 않으며 가변 인수를 취하는 다른 함수의 내부에서 printf의 서식을 해석하고 적용하는 일을 대신해 준다.
이 두 함수를 사용하면 printf처럼 동작하는 비슷한 함수를 직접 만들어 쓸 수 있다. 다음 함수는 C/C++언어의 가변 인수 기능을 활용하여 실행 중에 변수 값을 디버거로 실시간 확인해 보는 기능을 제공한다.
void CustomTrace(char *format, ...)
{
char buf[1024];
va_list marker;
va_start(marker, format);
vsprintf(buf, format, marker);
OutputDebugString(buf);
}
OutputDebugString이라는 API 함수가 사용되었는데 이 함수는 주어진 문자열을 디버깅 창으로 출력해 준다. 비주얼 C++의 경우 Output 윈도우에 이 함수의 출력 내용이 나타나므로 실행 중에 변수 값의 변화를 확인하거나 특정 함수의 호출 시점, 회수 등을 알고 싶을 때 중간 중간에 이 함수를 삽입해 주면 된다. 사용 예를 들자면 다음과 같다.
CustomTrace("변수 a=%d, 변수 f=%f\n", a, f);
CustomTrace("함수 func가 %d번째 호출되었음", count++);
CustomTrace 함수의 내부는 무척 간단하다. va_start로 첫 번째 가변 인수의 번지를 구한 후 그 번지를 서식 문자열과 함께 vsprintf 함수로 넘겨 주기만 하면 된다. OutputDebugString 함수를 직접 사용할 수 있지만 이 함수는 단순한 문자열만 출력할 수 있는데 비해 CustomTrace는 서식화된 문자열을 출력할 수 있어 훨씬 더 편리하다.
다음은 똑같은 목적의 좀 더 복잡한 함수를 소개한다. 이 함수는 Win32 파일 입출력 함수까지 사용하고 있기 때문에 현재 단계에서 분석해 보기는 어려우므로 차후에 API를 배운 후에 직접 분석해 보기 바란다.
#define DEBUGLOGFILE "c:\\DebugLog.txt"
void WriteLogFile(char *strLog,...)
{
HANDLE hLog;
static int count = 0;
DWORD dwWritten;
char szLog[1024];
char strLog2[1024];
va_list marker;
SYSTEMTIME st;
//가변 인수를 조립한다.
va_start(marker, strLog);
vsprintf(szLog, strLog, marker);
//처음 호출될 때 파일을 만들고 이후부터는 파일을 열기만 한다.
if (count == 0)
{
hLog = CreateFile(DEBUGLOGFILE, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, \
FILE_ATTRIBUTE_NORMAL, NULL);
}
else
{
hLog = CreateFile(DEBUGLOGFILE, GENERIC_WRITE, 0, NULL, OPEN_ALWAYS, \
FILE_ATTRIBUTE_NORMAL, NULL);
}
//로그에 현재 시간과 카운터를 기록한다.
GetLocalTime(&st);
wsprintf(strLog2, "카운터=%06d(%d:%d:%d:%d) %s\r\n", count++, \
st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, szLog);
SetFilePointer(hLog, 0, NULL, FILE_END);
WriteFile(hLog, strLog2, strlen(strLog2), &dwWritten, NULL);
CloseHandle(hLog);
}
사용하는 방법은 printf나 CustromTrace와 동일하다. 단, 차이점이라면 조립된 서식 문자열이 화면이나 디버깅 창으로 출력되는 것이 아니라 지정한 파일에 기록된다는 것과 카운트, 호출 시점의 시간 등을 같이 기록해 준다는 점이다. 멀티 스레드 환경이나 실시간으로 동작하는 프로그램을 디버깅할 때는 디버거를 쓰기 쉽지 않기 때문에 모든 디버깅 정보를 파일에 일단 기록한 후 파일에 남겨진 로그 정보를 분석하는 것이 더 효율적이다.
이럴 때 이 함수가 아주 유용하게 사용되며 실전에서 여러 번 활용했었는데 효과가 아주 좋았다. 릴리즈 모드에서만 증상이 나타날 때라든가 디버거를 쓸 수 없는 서비스류의 프로그램을 디버깅할 때 특히 많은 활약을 한다. 물론 이 함수는 더 개선해 볼 여지가 많은데 다음에 실력이 늘면 직접 개선해 보기 바란다.
'C C++' 카테고리의 다른 글
가변인수를 가지는 함수 및 매크로 만들기 (0) | 2006.04.17 |
---|---|
[펌] Unix (0) | 2005.11.09 |
[펌] [GCC] 컴파일 옵션 (0) | 2005.11.09 |