POCU C언어 정주행 12회차 - 가변 인자 함수

2022. 12. 29. 16:46C언어 복습

솔직히 이것도 이전 글에서 다뤘던 비트 패턴과 공용체만큼이나 거의 쓰지 않는 것이다. 실제로 내가 대학교 입학하고 약 5~6년 정도 C/C++을 다루고 있는데 솔직히 비트 패턴, 공용체, 가변 인자 함수는 사용해본 기억이 거의 없다. 물론 이전 글에서도 그랬지만 이번에도 정리는 할 것이다.

 

가변 인자 함수가 뭘까? 말 그대로 인자가 변하는 것이 가능한 함수 즉, 함수로 들어오는 인자의 유형과 갯수가 변하는 것이 가능한 함수라는 뜻이다. 그럼 이제 가변 인자 함수가 어떻게 생겼고 어떻게 사용하는지 알아보자. 근데 사실 가변 인자 함수는 이미 본 적이 있다. C언어를 배운 적이 있다면 다음 아래의 두 함수를 분명 사용해봤을 것이다.

  • printf
  • scanf

이 두 함수가 사실은 가변 인자 함수다. 잘 생각해보면 저 두 함수는 수상할 정도로 유동적으로 인자를 전달할 수 있지 않았는가? 정수를 입출력할 때는 정수의 값이나 주소를 전달했고 실수를 입출력할 때는 실수의 값이나 주소를 전달했으니 말이다... 그럼 printf, scanf의 원형이 어떻게 선언되어 있는지 알아보자.

#include <stdarg.h>

/* 출력한 문자의 갯수를 반환하며 오류가 생기면 음수를 반환 */
int printf(const char* format, ...);

/* 입력받은 값의 갯수를 반환하며 윈도우 기준으로
Ctrl + 'Z'를 누르면 EOF를 반환하며 입력받기에 실패하면 0을 반환 */
int scanf(const char* format, ...);

2번째 매개 변수가 들어갈 자리에 존재하는 ...이 바로 가변 인자라는 것을 선언한 모습이다. 말 그대로 어떤 녀석이라도 인자로 넘어오는 것이 가능하며 그렇기 때문에 어떤 데이터가 넘어올지 모르니 저렇게 ...으로 표현을 한 것이다. 그럼 함수 내부에서 ...이라고 되어있는 저 녀석을 어떻게 사용할까? 그걸 알기 위해서 아래의 4가지 매크로 함수에 대해서 알아야 한다.

  • va_list: 가변 인자 목록을 의미하며 밑의 3개의 매크로 함수를 사용하는 데 필요한 정보를 가지고 있지만 구현을 하는 방법에 대한 것 자체는 표준에서 정의하고 있지는 않다.
  • va_start: (<va_list>, <가변 인자 시작하기 직전 매개 변수>)를 인자로 받는다. 가변 인자가 시작하는 지점을 찾아내는 역할을 하며 이것을 호출해야 가변 인자에 접근할 수 있다.
  • va_arg: (<va_list>, 받아올 가변 인자의 자료형)을 인자로 받는다. 단, 기본 자료형의 경우에는 정수는 int형으로, 실수는 double형으로 승격이 되어버린다. char같은 것을 넣었다가 제대로 값을 읽지 못할 수 있다는 얘기이다.
  • va_end: <va_list>를 인자로 받으며 가변 인자에 접근이 모두 끝났다는 것을 명시하며 사용했던 가변 인자 목록을 정리한다. 이후에는 가변 인자로 접근을 하지 못하게 한다.

 

이 글에서는 사용 방법에 대한 것은 굳이 적지 않을 생각이다. 굳이 기억하고 있지 않아도 사용 방법이야 검색해서 찾아보면 되는 것이고 굳이 가변 인자 함수가 아니더라도 적어도 어떤 것의 사용법에 대한 것은 검색으로 해결하는 경우를 많이 찾아볼 수 있기 때문이다. 이 글에서 다룰 것은 가변 인자 함수가 어떤 원리로 돌아가는지에 대한 것이다. 그럼 아래의 예시를 보자.

#include <stdio.h>
#include <stdarg.h>

int add_ints(const size_t count, ...);

int main(void)
{
    printf("%d\n", add_ints(1, 16) + add_ints(4, 1, 2, 3, 4));
    return 0;
}

int add_ints(const size_t count, ...)
{
    va_list vl;
    size_t i;
    int sum = 0;
    
    va_start(vl, count);
    {
        for (i = 0; i < count; i++)
        {
            sum += va_arg(vl, int);
        }
    }
    va_end(vl);
    
    return sum;
}

이렇게 코드를 짜면 add_ints라는 함수가 두 번 호출이 된다. 그리고 호출이 될 때마다 아래의 그림과 같이 스택 메모리를 차지하게 된다. 여기서 중요한 것은 add_ints라는 함수 본인은 인자로 어떤 데이터가 몇 개가 넘어올지 알 수 없지만 호출자는 직접 인자를 넘기는 입장이기 때문에 어떤 데이터가 얼마나 넘어갈지 알 수 있다는 것이다. 그렇기 때문에 스택 메모리를 할당시키는 것 자체는 문제가 없다.

이미지 출처: POCU 아카데미 C 언매니지드

따라서 va_start라는 매크로 함수가 아래와 같이 구현되어 있을 것이라고 생각할 수 있고 이는 합리적인 추론이다. data라는 변수가 있는지 실제로 이런 역할을 하는 녀석이 있는지 까보지는 않았지만 일단 있다고 가정하고 진행한다. 이러면 data라는 변수는 count라는 변수의 다음 번지에 존재하는 매개 변수를 가리키게 된다. 여기부터 데이터를 읽어오기만 하면 되는 것이다.

#define va_start(va, arg) va.data = (char*)&arg + sizeof(arg)

va_start(vl, count);

/* 위의 코드를 아래로 변환! */

-> vl.data = (char*)&count + sizeof(count);

여기까지 이해를 했다면 va_arg가 어떻게 구현되어 있을지 추측할 수 있다. 그냥 현재 위에서 봤던 va_list의 data와 같은 변수가 va_arg라는 매크로 함수가 사용될 때마다 다음 녀석을 가리키도록 만들면 될 뿐이다. 아래와 같이 말이다.

#define va_arg(va, type) *(type*)va.data; ((type*)va.data)++

val = va_arg(vl, int);

/* 위의 코드를 아래로 변환! */

-> val = *(int*)vl.data; ((int*)vl.data)++;

 

여기까지 이해가 되었다면 다음의 경우에서 왜 컴파일 오류가 생기는지도 이해할 수 있을 것이다. va_list가 가변 인자를 읽어나가는 과정에서 당연히 문제가 생길 수 밖에 없다. 가변 인자의 데이터를 읽기 위해서 메모리의 위치를 정확하게 파악해야 하는데 아래와 같이 코드를 짜면 무슨 수로 가변 인자의 시작과 끝을 판단할 것이란 말인가?

void something_func(int, int, ...); /* 컴파일 가능 */
void something_func(int, ..., int); /* 컴파일 에러 */
void something_func(..., int, int); /* 컴파일 에러 */
void something_func(...);           /* 가변 인자 접근 불가 */

 

지금까지 printf, scanf같은 가변 인자 함수가 어떤 방식으로 작동하는지 알아봤다. 여기서 알 수 있는 것은 컴파일러가 우리를 위해서 많은 일을 해주고 있다는 것이다. printf, scanf같은 함수에서 인자의 타입이나 갯수에서 실수를 했을 때, 에러가 뜨는 것을 본 적이 있는가? 사실 표준으로 따지면 컴파일 오류를 내주지 않아도 된다! 하지만 워낙 많이 쓰이고 정의가 잘 되어있는 것이다 보니 컴파일러가 업그레이드가 되면서 이런 기능들이 탑재가 된 것이다.

 

그럼 아래에서 아주 간단한 서식 지정자 기능만 들어있는 printf함수를 직접 구현한 코드로 가변 인자 함수에 대한 내용을 마무리하겠다. %d, %c, %s의 기능만 들어있는 코드이다.

format_print.h

#ifndef __FORMAT_PRINT_H__
#define __FORMAT_PRINT_H__

int print_format(const char* format, ...);

#endif

format_print.c

#include "format_print.h"

#include <stdio.h>
#include <stdarg.h>

static void print_recursive_integer(unsigned int val)
{
    if (val == 0)
    {
        return;
    }
    
    print_recursive_integer(val / 10u);
    putchar(val % 10 + '0');
}

static void print_format_integer(unsigned int val)
{
    if (val == 0)
    {
        putchar('0');
        return;
    }
    
    print_recursive_integer(val);
}

int print_format(const char* format, ...)
{
    va_list vl;
    const char* ptr = format;
    int is_formatting_character = 0;
    int count = 0;
    
    va_start(vl, format);
    
    while (*ptr != '\0')
    {
        if (is_formatting_character != 0)
        {
            is_formatting_character = 0;
            
            switch ((int)*ptr)
            {
            case '%':
                putchar('%');
                break;
                
            case 'd':
                print_format_integer(va_arg(vl, unsigned int));
                break;
                
            case 'c':
                putchar(va_arg(vl, unsigned int));
                break;
                
            case 's':
                fputs(va_arg(vl, const char*), stdout);
                break;
            
            default:
                fputs("wrong format specifier\n", stdout);
                fputs("your string: ", stdout);
                fputs(format, stdout);
                return 0;
            }
        }
        else if (*ptr == '%')
        {
            is_formatting_character = 1;
        }
        else
        {
            putchar(*ptr);
        }
        
        ptr++;
    }
}