C++ 이야기 열세번째: 기본기 다지기, explicit 키워드

Posted at 2007. 6. 3. 02:04 // in S/W개발/C++ 이야기 // by 김윤수


[이글의 최신 Update 문서는 항상 여기에서 확인할 수 있습니다]

C++ 이야기 열세번째입니다. 어떤 분이 방명록에 글을 남기시길 제가 쓴 글들을 다 인쇄해 봤더니 70페이지 가량이 나오더라더군요. 그러면서 책쓰면 후배들에게 좋은 정보가 될 것 같다고. 그 글을 보는 순간 잠시 우쭐해졌습니다. ㅋㅋㅋ 잠시 이 자리를 빌어서 감사하단 말씀 전해드리고 싶구요. 솔직히 저는 아직 내공이 안돼서 더 공부해야할 것 같습니다.

이번에는 원래 함수 객체 두 번째 글을 쓰려고 했는데, 생각지 않게 explicit 키워드가 머리속에 떠 올라서 순서를 바꿔서 explicit 키워드에 대해 얘기해 보려고 합니다.

최근에 C++ 를 배우신 분들은 모르시겠지만 explicit 키워드는 C++의 첫번째 표준안인 98년 9월 1일 버전에 처음으로 도입된 키워드입니다. Bjarne Stroustrup 이 처음에 C++ 를 창시할 때는 없었다는 거죠. 그럼 왜 98년 C++ 표준안을 출판할 때는 explicit 라는 키워드를 넣었을까요 ? 이런 의문을 제기하지 않고 왜 그런지를 모른다면 C++의 참맛을 안다고 할 수 없겠죠.

결론부터 말씀드린다면 explicit 키워드는 C 나 C++ 의 공통 특징 중의 하나인 암시적 형변환의 특성때문에 프로그래머가 예상치 못하는 형변환이 일어남으로 인해 정말 발견하기 어려운 버그가 생기는 걸 방지하기 위해서 도입된 키워드입니다.

결론은 그렇다 치는데, 도대체 어떤 버그이길래 그렇게 발견하기 어려운 버그일까요 ? 역시 백마디 말보다 하나의 예가 이해를 돕는데는 제격이므로 예를 들어 한 단계씩 차분히 설명해 보도록 하겠습니다.

일단, 여러분이 Stack 이라는 클래스를 설계하려고 한다고 해 보죠. 제일 먼저 있어야 할 것은 Stack 객체를 생성하는 생성자가 있어야 할 것입니다. 우선 생각할 수 있는 생성자로는 n 개의 요소까지 가질 수 있는 Stack 을 생성하는 걸 생각해 볼 수 있을 것입니다. 다른 생성자로는 다른 Stack 을 복사해서 생성하는 것, 다른 배열을 복사해서 생성하는 것, 다른 vector 를 복사해서 생성하는 것 등을 생각할 수 있을 것입니다. 이런 생성자를 갖는 Stack 은 다음과 같이 작성할 수 있겠네요.

// 저번에 썼던 코드입니다.
template <typename T>
struct Print {
  void operator() (T v) {
    cout << v << " ";
  }
};

class Stack {
private:
  vector<int> _data;
  int _pos;

public:
  const static int MIN_ELEM = 10;

  // 최대 n 개 요소를 가질 수 있는 Stack 생성자
  // 기본값을 지정해 봤습니다.
  Stack(int n = MIN_ELEM): _data(n), _pos(0) {
    cout << "Stack(int) called" << endl;
  }

  // 다른 Stack 을 복사해서 생성하는 복사 생성자
  Stack(const Stack& other): _data(other._data), _pos(other._pos) {
    cout << "Stack(const Stack&) called" << endl;
  }

  // 다른 배열을 복사해서 생성하는 생성자
  Stack(int arr[], int n): _data(arr, arr+n), _pos(n) {
    cout << "Stack(int[], int) called" << endl;
  }

  // 다른 vector 를 복사해서 생성하는 생성자
  Stack(vector<int> v): _data(v), _pos(v.size()) {
    cout << "Stack(vector<int>) called" << endl;
  }

  // 그냥 어떻게 작동하는지 확인하기 위한 디버깅용 멤버 함수
  void printAll() {
    for_each (_data.begin(), _data.end(), Print<int>());
    cout << endl;
  }
};

다음 코드를 수행하면 어떤 결과가 벌어질까요 ? 여러분의 예상과 맞아 떨어지는지 한 번 보시기 바랍니다.

01: int
02: main(void)
03: {
04:   Stack s1;
05:   s1.printAll();
06:   Stack s2(20);
07:   s2.printAll();
08:   Stack s3(s2);
09:
10:   vector<int> v(10, 3);
11:   for_each(v.begin(), v.end(), Print<int>());
12:   cout << endl;
13:
14:   Stack s4(v);
15:   Stack s5 = v;
16:
  s1 = v;                 // 이게 어떤 효과를 일으킬까요 ?
17:   s2 = 10;                // 버그 아닌가요 ? 좀 이상하지 않나요 ?
18:
19:   s1.printAll();          // 뭐가 찍힐까요 ?
20:   s2.printAll();          // 뭐가 찍힐까요 ?
21: }



위 코드가 컴파일될까요 ? 특히 17 line은 컴파일이 안될 것 같지 않으세요 ? stack 에 정수값을 할당한다는 게 전혀 말이 안되니 당연히 컴파일 안되어야 정상이겠죠. 자 한 번 컴파일해 볼까요 ? 어! 이게 어떻게 된 거죠 ? 아무런 에러 없이 컴파일이 되네요. 17 line 도 에러 없이 컴파일이 됩니다. 이게 어떻게 된 조화일까요 ? 컴파일러가 왜 이걸 정상적인 코드로 인식하고 컴파일을 했는지를 알려면 우리의 입장이 아니라 컴파일러의 입장에서 생각해 봐야 할 겁니다. 이른바 역지사지 해 보는 것이죠.

17 line 을 만나면 컴파일러는 어떻게 할까요 ? 컴파일러가 하는 일은 자신이 알고 있는 모든 지식을 동원해서 코드를 컴파일 해내는 것입니다. 먼저 s2 에 정수값을 할당할 수 있는 할당 연산자가 있는지 볼 것입니다. 어! 그런데 Stack 클래스 정의를 봤더니 아무리 눈씻고 찾아 봐도 없네요. 프로그래머가 정의한 할당 연산자가 없다면, 컴파일러가 생성한 복사 할당 연산자가 있을 것입니다. 자기가 생성한 복사 할당 연산자를 쓰려고 했더니, 이번에는 10 이라는 정수값이 복사 할당 연산자의 인자와 호환이 되질 않네요. 그렇다고 우리의 C++ 컴파일러는 그냥 컴파일을 포기하고 항복할 녀석이 아니죠. 이번에는 혹시 정수를 복사 할당 연산자의 인자(const Stack& 일 겁니다)로 변환할 수 있는지를 볼 겁니다. 여기에서 C++ 컴파일러는 인자가 하나인 생성자를 암시적 형변환하는데 사용한다는 것을 기억해 보세요. 죽 뒤져 보았더니 Stack(int) 가 눈에 띄네요. 그래서 결국 Stack(10) 을 호출해서 10개의 요소를 갖는 Stack 을 생성하고 그걸 복사 할당 연산자를 이용해서 s2 에 할당하게 됩니다. 이제 그 짧은 코드 안에서 어떤 일이 일어나는지 이해가 되시나요 ?

제가 쓰고 있는 컴파일러(Microsoft Visual C++ 2005)에서 컴파일 후 실행해 봤더니 다음과 같은 결과가 나옵니다.

01: Stack(int) called                         // 04 line 출력
02: 0 0 0 0 0 0 0 0 0 0                       // 05 line 출력
03: Stack(int) called                         // 06 line 출력
04: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0   // 07 line 출력
05: Stack(const Stack&) called                // 08 line 출력
06: 3 3 3 3 3 3 3 3 3 3                       // 10 line 출력
07: Stack(vector<int>) called                 // 14 line 출력
08: Stack(vector<int>) called                 // 15 line 출력
09: Stack(vector<int>) called                 // 16 line 출력
10: Stack(int) called                         // 17 line 출력
11: 3 3 3 3 3 3 3 3 3 3                       // 19 line 출력
12: 0 0 0 0 0 0 0 0 0 0                       // 20 line 출력

09 ~ 12번째 라인 결과가 좀 의아하지 않나요 ? 왜 이런 결과가 나올까요 ? 눈치 빠른 분들 벌써 회심의 미소를 짓고 계시는 군요. 예, 그렇습니다. 아까 컴파일러 입장에서 살펴 본대로 C++ 의 암시적 형변환 규칙에 따르면 클래스의 생성자 중에 인자가 하나인 생성자들은 프로그래머의 의도와는 전혀 상관없이 컴파일러에 의해 암시적 형변환에 동원되도록 되어 있어서 s1 = v, s2 = 10 이라는 코드를 만나면 각각 Stack(vector<int>), Stack(int) 를 호출하여 임시 객체를 생성하고, 그 임시 객체를 가지고 복사 할당 연산자를 호출하게 됩니다(위 코드 예에서는 컴파일러에 의해 자동 생성된 할당 연산자가 호출되겠죠. 클래스 정의에서 할당 연산자를 여러분 나름대로 정의한 후, cout 으로 뭔가를 출력하게 되면 할당 연산자가 호출되는 것을 확인하실 수 있을 겁니다). 그러고 나면 s1v vector의 내용으로 Stack 을 채우게 될 것이고, s2는 10개의 int 요소로 채우게 될 것입니다. 그래서 결과적으로 09 ~ 12번째같은 결과가 나오는 것이지요. s1 = v 에 대해 Stack(vector<int>) 를 호출하는 건 그래도 어느 정도 말이 된다고 하지만 s2 = 10 에 대해 Stack(int) 를 호출하는 건 좀 오버아닌가요 ? 도대체 Stack 객체에 10을 할당한다는 게 말이 되느냔 말입니다.

C++ 컴파일러는 참 잘난체 하는 데는 이골이 났나 봅니다. 프로그래머가 특별히 정의하지 않더라도 기본 복사 생성자, 복사 할당자를 생성해 버리고, 그것도 모자라 인자가 하나인 생성자를 암시적 형변환하는데 지 맘대로 써 버린다니... 그런데 나름 C++ 컴파일러도 말 못할 고민이 있다고 하네요. C++ 본래 태생이 그래서 그렇답니다. 맘에 안든다고 버릴 수도 없고, 그냥 우리가 적응해서 살아야 되지 않겠습니까 ?

아무리 적응하려고 한다지만 s2 = 10 을 만났을 때 Stack(int) 를 호출하는 건 프로그래머 입장에서는 참 예상하기 힘든 일입니다. 그래서 이런 예상치 못한 수행이 일어나면 프로그래머는 그런 버그를 정말 찾기 힘들게 됩니다. 실제로 이런 일들이 프로젝트 수행 도중 종종 일어나고, 이 버그는 찾기도 정말 힘듭니다. 그러니 아예 이런 일은 피해가는 게 상책이지요. 피해가는 방법이야 s2 = 10 과 같은 코드를 작성하지 않도록 Coding Guideline 에 넣어 두고, Code Review 도 하고 그런 방법도 있겠지만 좀 더 좋은 방법은 아예 s2 = 10 과 같은 코드를 만나면 컴파일시에 에러가 발생해 버린다면 더 좋을 것입니다(항상 기억하세요! 에러는 빨리 발견할 수록 수정하는데 드는 비용이 작다).

그래서, explicit 키워드가 C++의 첫번째 표준안에 새로 도입됐던 것입니다. explicit 키워드는 인자가 하나인 생성자가 암시적 형변환에 쓰이지 않도록 해줍니다. 암시적 형변환에 쓰이면 컴파일 에러가 발생하게 됩니다. 위에 정의한 Stack 정의에서 다음 부분을 수정하신 후에 main()함수를 다시 컴파일해 보시기 바랍니다.

  // 최대 n 개 요소를 가질 수 있는 Stack 생성자
  // 기본값을 지정해 봤습니다.
  explicit Stack(int n = MIN_ELEM): _data(n), _pos(0) {
    cout << "Stack(int) called" << endl;
  }


제가 쓰는 컴파일러에서는 다음과 같은 에러가 발생하네요.

error C2679: binary '=' : no operator found which takes a right-hand operand of type 'int' (or there is no acceptable conversion)

정확히 의도한 바와 같이 컴파일 에러가 발생하네요.

이상에서 밝힌 바와 같이 explicit 키워드는 C++ 의 특징 중에 하나인 암시적 형변환 때문에 발생할 수 있는 문제의 소지를 미리 예방하는데 유용한 키워드입니다. 그러니, 클래스의 생성자를 설계할 때, 인자가 하나만 있는 생성자에 대해서는 암시적 형변환이 발생할 때 어떤일이 벌어질지를 생각해 보고, 암시적 형변환이 말이 안되는 의미를 갖게 될 경우 explicit 라는 키워드를 꼭 붙이시기 바랍니다. 아니면 거꾸로 생성자에 대해 일단은 무조건 explicit 키워드를 붙였다가 컴파일할 때 암시적 형변환이 허용되지 않아서 너무 불편하다 싶으면 그때가서 빼는 것도 방법입니다. explicit 키워드를 쓴 경우에는 다음과 같이 코드를 작성하시면 암시적 형변환과 같은 의미를 갖는 코드를 작성하실 수 있습니다.

  s1 = Stack(v);
  s2 = Stack(10);


마지막으로 다음을 기억하신다면 C++의 암시적 형변환 때문에 버그 찾느라 헤메는 일은 없을 것입니다.
생성자에 explicit 키워드를 써서 불필요한 암시적 형변환을 막아두자.
혹시 틀린 부분이나 보완하고 싶으시 부분이 있으면 언제라도 알려주시고, 소프트웨어 관련된 저의 다른 글들도 참고로 읽어 보세요.