본문 바로가기

책 스터디

[Exceptional C++ Style] 14. 클래스 객체 생성(소멸) 순서의 중요성

이 챕터에서는 클래스가 상속 관계를 가질 때, 특히 생성자와 소멸자의 처리 순서에 대해 다루고 있다.

기본 클래스로부터 상속받은 파생 클래스가 생성될 때 어떠한 순서로 생성 처리가 되며,
이러한 순서에 대해 정확한 이해없이 코드를 만들면 어떠한 문제가 생기는 지 알아본다.


신참 질문

다음 코드에서 무엇이, 그리고 왜 잘못 되었는가?
#include <string>

using namespace std;

class Base
{
public:
    explicit Base(const string& a)
    {
        //...
    }
    string f() { return "Hello World"; }
};

class Derived : public Base
{
public:
    Derived() : Base(s = f()) {}

private:
    string s;
};

int _tmain(int argc, _TCHAR* argv[])
{
    Derived d;
    return 0;
}
 
 
위 예제의 문제는 객체의 수명, 좀 더 구첵적으로는 객체가 존재하기도 전에 객체를 사용하려고 하는 것과 관련되어 있다.

참고로, 위 예제는 컴파일 단계에서는 아무런 경고도 에러도 뱉지 않고,
런타임에 Derived::s에 대해 포인터 주소가 잘못 되었다고만 알려주기에 더더욱 신경써서 작성해야 하는 부분임을 알아두자.

해답을 이야기하기 전에 C++에서 클래스 객체를 생성할 때 어떠한 순서로 초기화되는지에 대한 규칙을 먼저 살펴보자.
아래 규칙들은 여러 단계의 상속에 재귀적으로 적용된다.

1) 가상 상속(virtual inherit) 관계에 있는 부모들(부모-자식)부터 생성된다.
가장 깊이 있는 부모(최상단 부모)부터 내려오며, 복수 부모를 상속시 왼쪽에서 오른쪽 순으로 호출된다.
ex. public A, B 와 같이 동시에 두 개의 부모 상속시 A가 B보다 먼저 생성자 호출됨

2) 가상 상속이 아닌 직접 상속 관계에 있는 부모들(부모-자식)이 생성된다.

3) 비정적 멤버 객체들이 클래스 정의에 선언된 순서대로 생성된다.
    : 이렇기에 생성자 초기화 리스트가 가능한 거임!

4) 생성자 본문(body)가 실행된다.
 
이제 이 규칙들을 가지로 위 예제를 다시 한 번 살펴보면, 다음과 같은 문제가 있음을 확인할 수 있다.
class Derived : public Base
{
public:
    // 초기화 리스트는 왼쪽에서 오른쪽으로 수행된다.
    // 아직 Base(...) 즉, 부모 클래스의 생성자가 완료되지도 않은 시점이기에,
    // 규칙 순서 2-3의 관계에 의해 Derived::s는 아직 생성되지도 않았다!!!
    Derived() : Base(s = f()) {}

private:
    string s;
};
생성되지도 않은 녀석에게 값을 쓰려고 하니, 잘못된 주소 운운하는 런타임 예외가 발생하는 것이다.

위 Derived::string s가 생성되지 않았는데 값을 쓰려고 하는 문제는 vs2010 sp1에서도 런타임 오류를 확인하였다.
하지만, 책에서 언급하고 있는 문제가 하나 더 있다.
그리고 이 문제는 내가 봐도 눈으로는 잘못된 코드라고 생각된다.

바로, Derived::s 문제가 발생했던 그 라인에서 s에 값을 대입시키는 Base::f() 함수의 호출이다.
Derived::s를 주석처리하고 f()만 호출해보자.
class Derived : public Base
{
public:
    // 역시 아직 Base(...) 즉, 부모 클래스의 생성자가 완료되지 않은 시점에서,
    // Base::f() 함수를 호출하였다. 객체 생성이전에 객체의 멤버 함수를 호출한 것이다.
    Derived() : Base(/-s = *- f()) {}

private:
    string s;
};
헌데, vs2010 sp1에서 이 부분에 대해선 런타임에 아무런 문제가 없었다. 
디버깅을 해 보니, 역시나 Base::Base() 생성자가 호출도 되기 전에 Base::f()가 불렸지만,
아무런 문제없이 동작함을 확인할 수 있었다.

이 부분에 대해선 나중에 조금 더 찾아봐야 할 부분이 있는 듯 하다.


고수 질문

다음 예제에 나온 X 클래스 객체가 생성될 때 어떠한 순서로 초기화되는가?
(사실 아래 예제에서 class D1 : virtual public V1에서 D1은 V1을 가상 상속 받을 필요가 없다.
설명을 하기 위한 예제일 뿐이니, 이 부분에 대해 너무 심한 거북함을 느끼지 말자)
class B1 {};
class V1 : public B1 {};
class D1 : virtual public V1 {};

class B2 {};
class B3 {};
class V2 : public B1, public B2 {};
class D2 : public B3, virtual public V2 {};

class M1 {};
class M2 {};

class X : public D1, public D2
{
    M1 _m1;
    M2 _m2;
};
이러한 문제를 풀 때는 클래스 상속도를 도식화하여 보는 것이 훨씬 편하다.
이제 위에서 보았던 클래스 객체 생성 순서 규칙을 하나씩 꼼꼼하게 대입시켜 보면 답은 쉽게 나온다.

1. 우선, 가상 상속 관계에 있는 부모들(부모-자식)이 생성된다.

V1 생성 : B1::B1 -> V1::V1
V2 생성 : B1::B1 -> B2::B2 -> V2::V2

2. 직접 상속 관계에 있는 부모들(부모-자식)들이 생성된다. 

D1 생성 : D1::D1
D2 생성 : B3::B3 -> D2::D2

3. 자신의 비정적 멤버 객체들이 정의 순서대로 생성된다.

M1::M1 -> M2::M2

4. 자신의 생성자 본문이 실행된다 

X::X 

물론, 소멸은 언제나 그렇듯 생성의 반대 순서로 이루어진다. 
따라서, 생성 순서에 대한 이해가 확실하면 소멸 순서 역시 자연스레 알게 되는 것이고,

가장 중요한 것은 이를 외우려 드는 것이 아니라, C++ 상속에 대한 이해가 깊으면 자연스레 이해가 된다는 점이다.

그리고, 위 예제는 객체 생성의 순서를 알아보기 위한 예제일 뿐이다.
예제를 타이핑하면서도 속에서 거부반응이 강하게 일어나는 것을 느낄 수 있었다.
상속을 남용하지 말자!