[Effective C++]객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자

생성자, 소멸자 및 대입 연산자, 다섯 번째 이야기

Posted by SungBeom on November 27, 2019 · 9 mins read

생성자나 소멸자에서 가상 함수를 호출하면 안 되는 이유

주식 거래를 본떠 만든 클래스 계통 구조가 있다고 가정합시다. 이를테면 매도 주문, 매수 주문 등이 있겠죠. 이러한 거래를 모델링하는 데 있어서 중요한 포인트라면 감사(audit) 기능이 있어야 한다는 점입니다. 그렇기 때문에 주식 거래 객체가 생성될 때마다 감사 로그(audit log)에 적절한 거래 내역이 만들어지도록 해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Transaction {                           // 모든 거래에 대한 기본 클래스
public:
    Transaction();
 
    virtual void logTransaction() const = 0;  // 타입에 따라 달라지는
                                              // 로그 기록을 만듭니다.
    ...
};
 
 
Transaction::Transaction()                    // 기본 클래스 생성자의 구현
{
    ...
    logTransaction();                         // 마지막 동작으로, 이 거래를
}                                             // 로깅(하기 시작)합니다.
 
class BuyTransaction: public Transaction {    // Transaction의 파생 클래스
public:
    virtual void logTransaction() const;      // 이 타입에 따른 거래내역
                                              // 로깅을 구현합니다.
    ...
};
 
class SellTransaction: public Transaction {   // 역시 파생 클래스
public:
    virtual void logTransaction() const;      // 이 타입에 따른 거래내역
                                              // 로깅을 구현합니다.
    ...
};
cs

BuyTransaction b;
위 코드가 실행되면 BuyTransaction 생성자가 호출되기 전에 Transaction 생성자가 호출됩니다. Transaction 생성자의 마지막 줄을 힐끗 보면 가상 함수인 logTransaction을 호출하는 문장이 보이는데, 무척이나 당혹스러운 사건이 벌어지는 부분이 바로 이 문장입니다. 여기서 호출되는 logTransaction 함수는 BuyTransaction의 것이 아니라 Transaction의 것입니다! 현재 생성되는 객체의 타입이 BuyTransaction인데도 말이죠. 기본 클래스의 생성자가 호출될 동안에는, 가상 함수는 절대로 파생 클래스 쪽으로 내려가지 않습니다. 그 대신, 객체 자신이 기본 클래스 타입인 것처럼 동작합니다.

이 같은 동작에는 다 이유가 있습니다. 기본 클래스 생성자는 파생 클래스 생성자보다 앞서서 실행되기 때문에, 기본 클래스 생성자가 돌아가고 있을 시점에 파생 클래스 데이터 멤버는 아직 초기화된 상태가 아니라는 것이 핵심입니다. 이때 기본 클래스 생성자에서 어쩌다 호출된 가상 함수가 파생 클래스 쪽으로 내려간다면, 파생 클래스 버전의 가상 함수가 초기화되지 않은 데이터 멤버를 건드릴 것입니다. 이렇듯 어떤 객체의 초기화되지 않은 영역을 건드린다는 것은 치명적인 위험을 내포하기 때문에, C++은 여러분이 이런 실수조차 하지 못하도록 막은 것입니다.

핵심적인 이야기는, 파생 클래스 객체의 기본 클래스 부분이 생성되는 동안은, 그 객체의 타입은 바로 기본 클래스입니다. 호출되는 가상 함수는 모두 기본 클래스의 것으로 결정(resolve)될 뿐만 아니라, 런타임 타입 정보를 사용하는 언어 요소(이를테면 dynamic_cast라든지 typeid 같은 것)를 사용한다고 해도 이 순간엔 모두 기본 클래스 타입의 객체로 취급합니다. 위의 예제의 경우, BuyTransaction 객체의 기본 클래스 부분을 초기화하기 위해 Transaction 생성자가 실행되고 있는 동안에는, 그 객체의 타입이 Tranaction이라는 이야기입니다. 이런 식의 처리는 C++ 언어의 다른 모든 기능에서 이루어지고 있습니다. BuyTransacion 클래스만의 데이터는 아직 초기화된 상태가 아니기 때문에, 아예 없었던 것처럼 취급하는 편이 최고로 안전하다는 거죠. 그러니까, 파생 클래스의 생성자의 실행이 시작되어야만 그 객체가 비로소 파생 클래스 객체의 면모를 갖게 됩니다.

객체가 소멸될(소멸자가 호출될) 때도 똑같습니다. 파생 클래스의 소멸자가 일단 호출되고 나면 파생 클래스만의 데이터 멤버는 정의되지 않은 값으로 가정하기 때문에, 이제부터 C++은 이들이 없는 것처럼 취급하고 진행합니다. 기본 클래스 소멸자에 진입할 당시의 객체는 더도 덜도 아닌 기본 클래스 객체가 되며, 모든 C++ 기능들(가상 함수, dynamic_cast, 기타 등) 역시 기본 클래스 객체의 자격으로 처리합니다.

에러조차 발생하지 않는 경우

Transaction의 생성자가 여러 개 된다고 가정해 보세요. 각 생성자에서 하는 일이 조금씩은 다르겠지만 몇 가지 작업은 똑같은 텐데, 똑같은 작업을 모아 공동의 초기화 코드로 만들어 두면 코드 판박이 현상을 막을 수 있겠지요? 대개 이런 설계로 private 멤버인 비가상 초기화 함수가 만들어지는데, 이 함수 안에서 logTransaction을 호출한다고 가정해 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Transaction {
public:
    Transaction() { init(); } // 비가상 멤버 함수를 호출합니다.
 
    virtual void logTransaction() const = 0;
    ...
 
private:
    void init()
    {
        ...
        logTransaction();     // 비가상 함수에서 가상 함수를 호출합니다.
    }
};
cs

이 코드는 앞의 코드와 비교해서 볼 때 개념적으로는 똑같은 코드이지만, 앞의 코드와 달리 컬파일도 잘 되고 링크도 말끔하게 되기 때문입니다. logTransaction은 Transaction 클래스 안에서 순수 가상 함수이기 때문에, 대부분의 시스템은 순수 가상 함수가 호출될 때 프로그램을 바로 끝내(abort) 버립니다. 하지만 logTransaction 함수가 '보통' 가상(즉, 순수 가상이 아닌) 함수이며 Transaction의 멤버 버전이 구현되어 있을 경우엔 앞에서 말씀드린 대로 Transaction의 버전이 호출되겠지요. 이때는 사용자가 원하는 방향이 아님에도 프로그램은 문제없이 돌아갈 것입니다. 이런 문제를 피하기 위해서는 생성 중이거나 소멸 중인 객체에 대해 생성자나 소멸자에서 가상 함수를 호출하는 코드를 철저히 솎아내고, 생성자와 소멸자가 호출하는 모든 함수들이 똑같은 제약을 따르도록 만드는 일 밖에 없습니다.

파생 클래스에서 기본 클래스로 초기화 넘기기

이 문제의 대체방법에 대해 한 가지 소개하자면, logTransaction을 Transaction 클래스의 비가상 멤버 함수로 바꾸는 것입니다. 그러고 나서 파생 클래스의 생성자들로 하여금 필요한 로그 정보를 Transaction의 생성자로 넘겨야 한다는 규칙을 만듭니다. logTransaction이 비가상 함수이기 때문에 Transaction의 생성자는 이 함수를 안전하게 호출할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Transaction {
public:
    explicit Transaction(const std::string& logInfo);
 
    void logTransaction(const std::string& logInfo) const;  // 이제는 비가상
                                                            // 함수입니다.
    ...
};
 
Transaction::Transaction(const std::string& logInfo)
{
    ...
    logTransaction(logInfo);
}
 
class BuyTransaction: public Transaction {
public:
    BuyTransaction { parameters }
        : Transaction(createLogString( parameters ))        // 로그 정보를
    { ... }                                                 // 기본 클래스
    ...                                                     // 생성자로 넘깁니다.
 
private:
    static std::string createLogString( parameters );
};
cs

기본 클래스 부분이 생성될 때는 가상 함수를 호출한다 해도 기본 클래스의 울타리를 넘어 내려갈 수 없기 때문에, 필요한 초기화 정보를 파생 클래스 쪽에서 기본 클래스 생성자로 '올려'주도록 만듦으로써 부족한 부분을 역으로 채울 수 있다는 것입니다.

방금 본 예제 코드에서 BuyTransaction 클래스에서 선언된 createLogString이라는 (private 멤버) 정적 함수가 기본 클래스 생성자 쪽으로 넘길 값을 생성하는 용도로 쓰이는 도우미 함수입니다. 이는 기본 클래스에 멤버 초기화 리스트가 많이 달려 있는 경우에 특히 훨씬 편리합니다(그리고 읽기에도 더 편합니다). 정적 멤버이기 때문에, 생성이 채 끝나지 않은 BuyTransaction 객체의 미초기화된 데이터 멤버를 자칫 실수로 건드릴 위험도 없습니다.


정리

생성자 혹은 소멸자 안에서 가상 함수를 호출하지 마세요. 가상 함수라고 해도, 지금 실행중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않으니까요.