POCU C언어 정주행 15회차 - 어서트 재정의, 전처리 명령어와 장단점

2023. 1. 5. 14:27C언어 복습

1. 어서트 재정의

assert라는 함수는 디버깅을 하는 데 있어서 자주 활용되는 함수이다. 그런데 매크로 함수를 적절하게 활용하면 이런 assert함수를 좀 더 보기좋게 바꿀 수 있다. 아래의 코드를 보자.

#define ASSERT(condition, msg)
    if (!(condition))
    {
       	fprintf(stderr, "%s(%s: %d)\n", msg, __FILE__, __LINE__);
        __asm { int 3 }
    }
        
void Func(void)
{
    int month = 20;
    ASSERT(month < 12, "invalid month number");
}

우리가 기존에 활용했던 assert함수는 assert(month < 12); 정도였을 것이다. 그런데 이번에 보게 된 ASSERT매크로 함수는 에러 문구에 해당하는 문자열이 하나 더 들어갔다. 이렇게 했을 때의 장점은 다음과 같다.

  1. assert를 실행시켰을 때, 실패를 하면 호출 스택이 assert함수 내부로 찍히게 된다. 하지만 만약 assert가 아니라 Func라는 함수의 이름이 찍힌다면 디버깅이 더 쉬울 것이다. 어떤 함수에서 오류가 났는지 좀 더 편하게 볼 수 있기 때문이다.
  2. 참고로 __asm { int 3 } 은 어셈블리어로 프로그램 실행을 그곳에서 중지시키는 코드이다. 따라서 ASSERT매크로에 매개 변수로 집어넣은 month < 12 가 거짓이면 __asm { int 3 }을 만나고 프로그램이 중지된다. 단, 이는 x86에서의 이야기고 다른 플랫폼은 다를 수 있으니 검색을 통해 충분히 알아본 후에 조건부 컴파일로 이 문제를 해결하면 된다.
  3. 매크로 함수에 있는 __asm { int 3 }에서 프로그램이 멈추면 5번째 줄에 있는 __asm { int 3 }에서 멈추는 것이 아니라 11번째 줄인 ASSERT매크로 함수를 사용하는 부분에서 중단점이 걸리게 된다. 그럼 invalid month number라는 문자열을 바로 볼 수 있고 사람의 입장에서 바로 어디가 잘못 되었는지 알 수 있게 되는 것이다.

 

 

2. 전처리 명령어와 장단점

C언어를 배웠던 입장에서 #define, #include, #if와 같은 유명한 전처리 명령어는 꽤 봐왔기 때문에 굳이 적지 않으려고 한다. 다만 기존에 몰랐던 전처리 명령어가 있어서 그것만 간단하게 정리하고 빠르게 넘어갈 것이다.

 

# 기능

아래 코드의 실행 결과를 보면 눈치챌 수 있겠지만 #define 뒤에 나오는 #명령은 매크로 함수에서 받은 매개 변수를 문자열 자체로 인식하게 만드는 기능이다. 여기서 중요한 것은 서식문자에 나올법한 %나 쌍따옴표같은 것들도 전부 문자열의 일부로 치환해버린다는 것이다. 잘 활용하면 매우 유용해보여서 적어보았다.

#define str(s) #s

printf("%s\n", str(\n));
printf("%s\n", str("\n"));
printf("%s\n", str(int main));

/*
실행 결과

"\n"
int main
*/

 

##기능

이번에 볼 코드는 ##이라는 기능에 대한 예제이다. 이거는 좀 특이해서 아래의 예제 코드를 먼저 보고 글을 보는 것이 이해가 편할 것이라고 생각한다.

#define PRINT(n) printf("%d\n", g_id_##n);

int g_id_none = 0;
int g_id_teacher = 1;
int g_id_student = 2;

PRINT(number)  /* 컴파일 오류 */
PRINT(none)    /* g_id_none의 값 출력 */
PRINT(student) /* g_id_student의 값 출력 */

##의 기능은 매개 변수로 들어온 키워드 자체를 해당 위치에 치환시킨다. 즉, 정말 말 그대로 복사, 붙여넣기를 시키는 것이다. 그래서 저렇게 변수의 이름의 일부를 매개 변수로 전달했을 때, 변수의 값이 출력되며 이를 이용해서 변수의 이름을 이용한 접근이 가능하다.

 

장점

  • 함수 호출이 아닌 곧바로 코드를 복붙하는 개념으로 함수 호출로인한 과부하가 없다.
  • C에서 불편한 것들 중 일부를 매크로를 이용한 꼼수로 편하게 해결할 수 있다.

단점

  • 디버깅이 굉장히 불편하고 어렵다.
  • \를 이용해서 읽기 좋게 바꾸더라도 중단점을 매크로 내부에 찍을 수는 없다.

 

활용 예제 1 - 튜플

#include <stdio.h>

#define MONSTER_DATA \
    MONSTER_ENTRY(0, "pope", 100) \
    MONSTER_ENTRY(1, "big rat", 30) \
    MONSTER_ENTRY(2, "mama", 255) \
    MONSTER_ENTRY(3, "dragon", 300000) \
    
int main(void)
{
    size_t i;
    
    int ids[] = {
#define MONSTER_ENTRY(id, name, hp) id,
        MONSTER_DATA
#undef MONSTER_ENTRY
    };
    
    const char* names[] = {
#define MONSTER_ENTRY(id, name, hp) name,
        MONSTER_DATA
#undef MONSTER_ENTRY
    };
    
    int healths[] = {
#define MONSTER_ENTRY(id, name, hp) hp,
        MONSTER_DATA
#undef MONSTER_ENTRY
    };
    
    for (i = 0; i < sizeof(ids) / sizeof(int); i++)
    {
        printf("%3d %6d %s\n", ids[i], healths[i], names[i]);
    }
    
    return 0;
}

 

활용 예제 2 - getter (OOP의 그 getter 맞음)

#include <stdio.h>

#define MONSTER_STRUCT \
    MONSTER_MEMBER(int, id) \
    MONSTER_MEMBER(const char*, name) \
    MONSTER_MEMBER(int, hp) \
    
typedef struct {
#define MONSTER_MEMBER(type, name) type name;
    MONSTER_STRUCT
#undef MONSTER_MEMBER
} monster_t;

#define MONSTER_MEMBER(type, name) \
type get_mob_##name(const monster_t* mob) \
{ \
    return mob->name; \
} \

MONSTER_STRUCT

#undef MONSTER_MEMBER

int main(void)
{
    monster_t mob;
    mob.id = 0;
    mob.name = "Pope Mob";
    mob.hp = 10001;
    
    printf("%3d %6d %s\n", get_mob_id(&mob), get_mob_hp(&mob), get_mob_name(&mob));
    
    return 0;
}

솔직히 보면서 좀 신기했다... 이런 식으로 코드를 짠 적이 한 번도 없었기 때문이다... 확실히 유용해보이는데 그 와중에 매크로 함수의 단점을 확실하게 느낄 수 있었다. 천천히 차근차근 읽어보면 읽히기는 하는데 실수가 날 일이 정말 많아보이는 코드라고 생각한다. 사실 getter뿐만 아니라 C++의 함수 오버로딩같은 것도 위와 같이 구현되어 있는 것은 아닐까라고 생각하게 되었다. 또한 그동안 매크로 함수가 실수를 유발하기 쉽고 디버깅이 불편해져서 사용하는 것을 무작정 꺼려했는데 매크로 함수의 사용을 다시 생각해보게 되었다.