Extremely Agile/TDD2007/12/28 15:31
손수 만드는 디버깅 툴

디버깅을 할 때 가장 많이 쓰게 되는 툴은 무엇일까요? 아이러니하게도, 디버거가 아닙니다. 사실 툴이라고 하기도 좀 뭐하죠. 사람들이 디버깅할 때 가장 많이 사용하는 도구는, printf입니다. (Java라면 System.out.println이나 System.err.println쯤 되겠군요. C++이라면 cout이나 cerr가 되겠습니다.) 프로그램을 디버깅할 때 '프로그램의 상태를 화면에 출력하는' 일을 가장 많이 하게 된다는 뜻이죠.

하지만 printf를 무작정 프로그램 코드 안에 삽입하다보면, 나중에 삽질을 하게 됩니다. 어떤 삽질일까요? 네, 맞습니다. 나중에 프로그램 개발을 완료하고 시스템을 패키지화 해서 릴리즈 할 때가 되면, 그 모든 'printf' 문들을 전부 코드에서 지워줘야 합니다. 그러니, 가급적이면 printf를 써서 디버깅을 하더라도 좀 지능적으로 하는 것이 좋겠죠.

사용자 삽입 이미지

마이크로소프트의 Visual C++이라는 툴을 쓰다 보면 (물론 다른 툴들에도 그런 기능이 있습니다만) 컴파일을 디버그 모드로 할 것이냐 릴리즈 모드로 할 것이냐 하는 옵션을 보게 됩니다. 이런 두 가지 옵션이 있다는 것은, 릴리즈 모드로 컴파일할 때에는 '디버그 할 때에만 실행되던 코드들은 더 이상 실행되지 않아야 한다'는 원칙이 지켜져야 함을 보여줍니다.

그렇다면 디버그 모드와 릴리즈 모드는 어떻게 구별하나요? 컴파일 옵션을 통해 구별합니다.

디버그 모드로 컴파일할 때에는, 현재 컴파일이 디버그 모드에서 진행됨을 표시하는 특별한 매크로 상수를 정의하는 것이 보통입니다. gcc라면, 다음과 같이 합니다.

gcc -D_DEBUG <이하 생략>
위의 -D 옵션은 컴파일러에게 _DEBUG라는 심볼을 정의한 다음 컴파일 할 것을 주문합니다. 따라서 소스 코드를 작성할 때 #ifdef 와 #ifndef 을 적절히 활용하면, 디버깅 모드에서 실행되어야 할 코드와 그렇지 않아야 하는 코드를 적절히 나누어 작성할 수 있습니다. 다음과 같이 하면 되겠죠.

#ifdef _DEBUG
...
#else
...
#endif
그런데 printf문 하나 넣자고 그 앞뒤로 #ifdef를 둘러치자니, 그것도 못할 짓인것 같군요. 그러니, 매크로 함수를 정의해서 그 매크로 함수가 컴파일 옵션에 따라 서로 다른 방식으로 동작하도록 구현하는게 더 좋겠어요. 우선, 사용자가 원하는 문자열을 화면에 찍을 수 있도록 해 주는 매크로 함수인 DUMP의 구현부터 살펴보죠.

#ifdef _DEBUG

#define DUMP(PRNSTR,...)  printf( \
 "dumping   [%010u,%s,%04d] " #PRNSTR "\n", \
 (unsigned int)pthread_self(), __FILE__,__LINE__,__VA_ARGS__)

#else

#define DUMP(PRNSTR,...)

#endif
위의 DUMP 함수는 두 개의 인자를 받습니다. 첫 번째 인자인 PRNSTR은 printf의 서식 문자열의 일부로 사용될 문자열이고, 두 번째 인자(...)는 그 서식 문자열(PRNSTR)에 의해 출력될 인자들이에요. (printf와 똑같은 방식으로 실행되는 매크로 함수라는 거죠.)

첫 번째 인자 PRNSTR은 #PRNSTR을 통해 큰 따옴표로 둘러쳐진 문자열로 변환됩니다. (# 기호가 어떤 역할을 하는 지에 대해서는 C 전처리기 관련 문서를 찾아보시는 것이 좋겠습니다.) 따라서 "dumping   [%010u,%s,%04d] " #PRNSTR "\n"는 하나의 문자열입니다. C/C++에서는 "a" "b" "c"가 "abc"와 같으니까요. 따라서 최종적으로 만들어질 서식 문자열(printf의 첫 번째 인자로 넘겨지는)에는 쓰레드 아이디와 파일명, 그리고 라인수를 출력할 자리가 기본적으로 포함됩니다.

두 번째 인자(...)는 첫 번째 인자로 준 서식 문자열에 의해 출력될 인자들인데요, __VA_ARGS__를 사용해서 printf의 인자로 그대로 넘겨버렸습니다.

이렇게 매크로 함수를 정의하고 나면, 프로그램 코드 안에서 다음과 같은 짓을 할 수 있습니다.

int variable = 0;
...
DUMP("%d", variable);
...
이 코드를 -D_DEBUG를 사용해서 디버그 모드로 컴파일하고 실행시키면, DUMP가 실행될 때 마다 화면에 쓰레드 아이디와 파일명, 그리고 DUMP가 놓인 라인수를 포함하는 정보가 출력됩니다. 물론 변수 variable의 값도 함께 말이죠.

-D_DEBUG를 컴파일 옵션에서 빼 버리고 다시 컴파일하면 매크로 함수 DUMP는 코드 안에서 사라집니다. (#else와 #endif 사이 부분을 참고하세요) 그러므로 화면에는 어떤 메시지도 출력되지 않습니다.

자. 이런 구현법을 활용하면 다양한 일들을 할 수 있습니다. 가령, 어떤 실행문을 돌릴 때, 디버그 모드에서 돌리면 '해당 실행문이 실행되려 한다는 사실을 알리는' 텍스트 메시지를 함께 출력하고, 릴리즈 모드에서 돌리면 그런 메시지 없이 그냥 그 실행문이 실행되도록 만들려면, 다음과 같은 매크로 함수를 정의해서 사용하면 됩니다.

#ifdef _DEBUG

#define EXECUTE(X)  \
 ( printf("executing [%010u,%s,%04d] " #X "\n", \
   (unsigned int)pthread_self(), __FILE__, __LINE__),(X) )

#else

#define EXECUTE(X) x

#endif
자. 먼저 EXECUTE의 인자 X가 #X를 통해서 큰 따옴표 문자열로 변환된 다음 서식 문자열과 결합됩니다. 그런 다음 쓰레드 아이디와 파일명, 행번호 등의 정보와 함께 화면에 출력됩니다. 그런 다음 실행문 X가 실행됩니다. 세미콜론을 사용하지 않고도 이 두 실행문을 연달아 실행시킬 수 있었던 것은, ',' 연산자 때문입니다. 이 연산자는 좌 우의 피연산자들을 순서대로 실행시키는 역할을 합니다.

int variable = 0;
...
EXECUTE(variable = 1);
따라서 위의 코드는 디버그 모드에서 다음과 같은 출력을 내놓게 됩니다.

executing [0000000012, test.cpp, 36] variable = 1
그리고 그 결과로 variable 에는 1이 대입되죠. 디버그 모드가 아닌 릴리즈 모드(즉, -D_DEBUG를 사용하지 않은 상태)로 컴파일하고 실행해 보면 화면에 아무런 메시지도 출력되지 않지만 variable에 1을 대입하는 동작은 여전히 수행됩니다.

자. 그러면 이런 매크로 함수들을 많이 만들어 두면, 굉장히 쓸만한 디버깅 도구를 스스로 만들어 사용할 수 있겠군요. (물론 그러려면 C/C++의 매크로 전처리기에 대한 지식은 가지고 있어야만 합니다.)

좀 더 나아가면, 프로그램 내 특정 코드 세그먼트의 성능을 측정하는 매크로 함수도 만들어 사용할 수 있습니다. 아래의 예제를 보시죠. #ifdef ... #endif는 생략하고, 매크로 함수의 코드만 보였습니다.

#define PROFILE_BEGIN(pfid)        \
   unsigned int __prf_l1_##pfid = __LINE__; \
   struct timeval __prf_1_##pfid;    \
   struct timeval __prf_2_##pfid;    \
   do {          \
    gettimeofday(&__prf_1_##pfid, 0);  \
   } while ( false )

#define PROFILE_END(pfid)        \
   unsigned int __prf_l2_##pfid = __LINE__; \
   do {          \
    gettimeofday(&__prf_2_##pfid, 0);  \
    long __ds = __prf_2_##pfid.tv_sec - __prf_1_##pfid.tv_sec; \
    long __dm = __prf_2_##pfid.tv_usec - __prf_1_##pfid.tv_usec; \
    if ( __dm < 0 ) { __ds--; __dm = 1000000 + __dm; } \
    printf("profiling [%010u,%s] " #pfid  \
      " (%u ~ %u) total %u.%06u seconds\n", \
      (unsigned int)pthread_self(),   \
      __FILE__,      \
      __prf_l1_##pfid,    \
      __prf_l2_##pfid,    \
      (unsigned int)(__ds),    \
      (unsigned int)(__dm));    \
   } while ( false )
이 코드는 다음과 같이 사용합니다.
PROFILE_BEGIN(test1)
    /* do some programming jobs */
PROFILE_END(test1)

그런 다음 디버그 모드에서 컴파일하고 실행해 보면, 화면에 PROFILE_BEGIN(test1)과 PROFILE_END(test1) 사이의 코드가 실행된 시간이 다음과 같이 찍히게 되죠. (test1이라는 이름은 해당 코드 안에서 유일해야 합니다.)

[test_api.cpp] test1 (90 ~ 98) total 1.34122342 seconds

test_api.cpp 파일의 코드 90번째 줄 부터 98번째 줄까지의 코드 실행 시간이 저만큼 걸렸다는 뜻입니다.

프로파일링까지 통합 환경 안에서 한방에 제공하는 IDE를 쓰신다면 문제가 다르겠습니다만 (뭐 가령 Eclipse나 NetBeans같은 것 말이죠) 그런 툴을 사용할 수 없는 환경에서 vi만 가지고 개발을 해야 한다면 이런 디버그 매크로들을 여러 개 만들어 두고 사용하는 것이 여러가지 버그를 잡는 데 도움을 줍니다.

[참고할만한 링크]


[3부에 계속...]



Posted by 이병준

TRACKBACK http://www.buggymind.com/trackback/95 관련글 쓰기

댓글을 달아 주세요

  1. 지나가다

    VC++ 6.0은 매크로에서 ...을 안 지원하는 것으로 알고 있습니다.
    컴파일이 되지 않겠네요.

    2009/10/27 10:01 [ ADDR : EDIT/ DEL : REPLY ]
    • 네 그렇습니다. gcc에서는 잘 됩니다만, 아무래도 VC++ 6.0은 제약이 많죠.

      2009/10/27 10:23 [ ADDR : EDIT/ DEL ]
  2. 최모씨

    좋은글 잘 읽었어요~ 이거 퍼가고 출처 남겼는데 괜찮은지요?

    2010/01/23 23:58 [ ADDR : EDIT/ DEL : REPLY ]

Languages/C++2007/12/12 11:56
오늘 같이 일하는 분들 중 가운데 한분으로부터 "왜 제 프로그램을 실행시키면 destructor가 2번 불리는 거죠?"라는 질문을 들었습니다.

소멸자는 명시적으로 불러주지 않는 한 (뭐 가령 obj.TheClass::~TheClass()와 같이 해 주지 않는 한) 절대로 두 번 불리지 않습니다. 따라서 '소멸자가 두 번 호출된다'는 증상은, 실제로는 '어디선가 객체가 두 개 생성되고 있기' 때문에 발생합니다.

따라서 그분과 저는 어디서 객체가 두 번 생성되는지를 찾아보기 시작했습니다. 그 분이"객체를 두 번 생성한 적이 없는데요?"라고 고개를 갸웃거리기 시작하셨기 때문에, 저는 문제는 딱 한가지 뿐일 거라고 생각했습니다. 여기서 '두번 생성'되는 오류를 겪고 있는 객체의 클래스를 B라고 하고, 그 클래스를 사용하는 다른 클래스를 A라고 해 보겠습니다. 저는 그 분께 "혹시 다음과 같이 작성된 코드가 있나요"라고 물었습니다.

class A {
    B b_obj;

public :

   ...

   const B get_b_obj() const {
      return b_obj;
   }
};

여기까지 이야기가 되고 나니까, 그분 혼자서도 버그를 찾아낼 수 있었습니다. 이 분이 작성한 코드의 문제는,  함수의 반환값을 선언하는 부분에 실수로 &를 빼먹는 바람에 get_b_obj()가 호출되는 순간 b_obj의 복사본 객체가 만들어졌다는 점이었습니다. 객체가 두개가 되었으니, 소멸자가 두 번 불리는 것처럼 보였을 밖에요.

const B& get_b_obj() const {
    return b_obj;
}

그럼 이런 문제를 방지하려면 어떻게 해야 하나요? 다음과 같은 대비책들을 생각해 볼 만 합니다.

  1. B의 복사 생성자를 private으로 선언한다.
  2. A의 get_b_obj() 메소드의 반환값을 const B* 로 만든다

만일 질문하신 분이 B의 복사 생성자를 private로 만들어 두었더라면 위의 오류를 컴파일 시간에 잡을 수 있었을 겁니다. 복사 생성자가 private이니, 반환값의 type으로 const B&를 해야 할 것을 const B와 같이 했으면 오류 메시지가 떴겠죠. 그러니 '여러 개의 객체를 만들 일이 없는' 클래스에 대해서는 복사 생성자를 private로 해두는 것도 좋겠어요.

또다른 해결책으로는 객체 레퍼런스 대신 포인터를 반환하는 것을 생각해 볼 수 있습니다. 포인터를 반환하도록 하면 저런 오류는 상당부분 줄일 수 있습니다. const B*에서 '*'를 빼먹는 실수를 저지르면 똑같은 문제가 발생하지 않느냐고 생각하실 수도 있는데, 그러려면 return &b_obj; 에서 &를 빼먹는 실수도 저질러야 하기 때문에 사실상 확률은 굉장히 낮아지죠. (*와 &를 빼먹는 실수를 한꺼번에 저질러야 저런 문제가 발생하거든요). 물론 포인터를 별로 좋아하지 않는 분들께는 이런 방법이 별로 마음에 들지 않으시겠지만 말이에요.




'Languages > C++' 카테고리의 다른 글

VI와 ctags  (0) 2007/12/18
empty container의 반환  (0) 2007/12/14
메모리 할당과 초기화는 다르다  (2) 2007/12/14
Destructor가 왜 2번 불리는 거죠?  (0) 2007/12/12
pure virtual method called  (2) 2007/12/06
stringstream  (2) 2007/12/04


Posted by 이병준

TRACKBACK http://www.buggymind.com/trackback/85 관련글 쓰기

댓글을 달아 주세요

Languages/C++2007/12/06 15:48

C++ 프로그래밍을 하다 보면 간혹 이와 같은 희귀한 오류 메시지를 만나게 되는 경우가 있습니다. 보통 다음과 같은 양상으로 나타나게 되는데요.

pure virtual method called
terminate called without an active exception

이런 오류 메시지가 나타나는 원인은 이렇습니다. 다음 코드를 보시죠.

class A {

public :
    A() {}

    void init() {
        ....
        virtual_method();
        ....
    }

    virtual void virtual_method() = 0;
};

class B : public A {

public :
    B() {}

    void virtual_method() {
        using namespace std;
        cout << "this is the implementation of pure virtual function A::virtual_method()\n";
    }
};

어디서 많이 보던 패턴이죠? ^^ 상위 클래스에는 알고리즘의 골격만 들어가고, 하위 클래스에는 해당 알고리즘을 구성하는 부분 알고리즘(위의 경우에는 virtual_method())의 실제 구현이 들어가는 그런 구조입니다. 알고리즘의 전체 골격은 바뀌지 않으면서, 그 세부사항만 하위 클래스에 따라 달라질 때 유용한 구조입니다.

그런데 이런 식으로 하는 것 자체에는 그다지 문제가 없는데, 문제는 init()을 호출하는 위치를 A 클래스의 생성자 안으로 이동시키게 되는 순간 발생합니다.

class A {

    void init() {
        ....
        virtual_method();
        ....
    }

public :
    A() {
        init();
    }

    virtual void virtual_method() = 0;
};

겉보기에는 꽤나 그럴싸하죠. B bb; 하는 순간 B 객체에 포함된 A 부분이 생성될 테고, A 부분은 자신의 멤버 변수 초기화를 마친 다음에 init() 함수를 불러서 알고리즘을 구동시키고, 그러면 그 알고리즘의 일부로 B 클래스에 정의된 멤버 함수가 자동적으로 호출되어 객체 전반적인 초기화를 완료하는...

그런데 이런 식의 초기화는 잘 될 턱이 없습니다. A가 private 함수 init()을 호출하는 순간 B::virtual_method가 호출되는데, 아직 객체 bb의 B 부분은 초기화가 되지도 않았거든요. -_- 그러니 B::virtual_method가 온전히 동작할 리가 없습니다. 사실 호출되는 것 자체가 불가능하죠. 객체 bb의 B 부분이 초기화되지 않았으니, 가상함수 테이블도 만들어지지 않았을 테니 말이에요.

그러니 위의 코드는 컴파일은 잘 됩니다만 (그러니 프로그래머는 '모든 순수 가상함수들을 구현했다니깐!'하는 식으로 착각하게 됩니다) 실제로 돌려보면 런타임에는 해당 가상함수의 구현이 발견되지 않습니다. 그래서 컴파일 시간이 아닌 실행 시간에

pure virtual method called
terminate called without an active exception

이런 괴상망칙한 오류가 발생하게 되는거죠. 나는 분명 구현했는데 그 함수가 아직도 '순수 가상 함수라니...' 정말 짜증나는 상황이지 않나요? ㅋㅋ

Effective Java라는 책에서도 지적되었던 것 같습니다만, 이런 이유로 해서 상위 클래스의 '생성자'안에서 하위 클래스의 메소드를 호출하는 것은 불가능하거나, 가능하더라도 피해야만 합니다. 설사 저런 오류가 나지 않더라도, 초기화되지 않은 메모리에다가 뭔 짓을 하려고 하면 그게 제대로 될 리가 없거든요.

- - -

이 글을 쓰고 웹 서핑을 하다보니 Meyer 선생도 자신의 저서(Effective C++ 3rd Edition, Item9)에서 같은 문제를 지적했더군요.

Never call virtual functions during construction or destruction.

이 문제에 대한 좀 더 상세한 지식을 얻고 싶으시다면, 여기에 가보시는 것도 좋겠습니다. :-)

'Languages > C++' 카테고리의 다른 글

VI와 ctags  (0) 2007/12/18
empty container의 반환  (0) 2007/12/14
메모리 할당과 초기화는 다르다  (2) 2007/12/14
Destructor가 왜 2번 불리는 거죠?  (0) 2007/12/12
pure virtual method called  (2) 2007/12/06
stringstream  (2) 2007/12/04


Posted by 이병준

TRACKBACK http://www.buggymind.com/trackback/84 관련글 쓰기

댓글을 달아 주세요

  1. yakkle

    클래스 A 에서 void virtual_method() = 0; 가 virtual void virtual_method() = 0; 로 바뀌어야 하지 않나요?
    문제 상황을 재현해 보려다가 안되길래 글 남깁니다.

    2009/06/12 10:24 [ ADDR : EDIT/ DEL : REPLY ]