본문 바로가기

책 스터디

[Exceptional C++ Style] 19. 파생된 클래스들에 대한 규칙 강제

이번 챕터에서는 C++ 클래스에 대해 컴파일러가 암묵적으로 생성하는 함수들에 대해 이야기하면서,
이러한 바탕 하에서 파생 클레스들에 대해 어떠한 규칙들을 강제할 수 있는지 설명하고 있다.

역시나 기본적인 내용이지만, 중요한 기본이기에 짚고 넘어가도록 하자.


신참 질문

하나의 클래스에 대해 암묵적으로 선언, 정의되는 함수들은 무엇이며, 선언과 정의되는 때는 언제인가? 
그리고 그런 선언과 정의들은 어떠한 의미론을 가지는가?

C++ 컴파일러는 클래스를 컴파일할 때 다음 네 가지 함수들이 존재하는지 체크해 존재하지 않으면,
컴파일러가 알아서 암묵적으로 이들을 선언한다.

이러한 암묵적은 생성은 1) 편의를 위한 것이자, 2) C와의 하위 호환성을 위한 것이다.

C++에서 C 스타일의 struct는 오직 public 자료 멤버들로만 이루어진 일종의 클래스이다.
구체적으로 말하면, 그런 구조체는 (명시적으로 정의된) 멤버 함수들이 없다.

그래도 그런 구조체를 생성하고, 복사하고, 파괴할 수 있어야 하기 때문에, C++ 언어는 그런 구조체에 대해
생성, 복사, 파괴를 적절하게 수행하는 적절한 함수들을 자동적으로 만들어 낸다.
(단, 프로그래머가 직접 명시적으로 정의하지 않았다면...)

그리고, 이들이 실제 사용되는 코드가 존재할 때 이들을 정의한다.

그럼 암묵적으로 생성되는 네 가지 함수들에 대해 하나씩 자세히 정리해 보자.
코드 예시를 위해 다음의 클래스를 예시로 지정한다.

class Empty
{
    // 어떠한 함수들이 어떤 접근 영역으로 생성될까?

private:
    int x;
};
 
1. 기본 생성자

프로그래머가 기본 생성자를 명시적으로 선언하지 않으면, 하나의 기본 생성자가 암묵적으로 생성된다.
암묵적으로 생성된 기본 생성자는 기본적으로 public, inline이다.
class Empty
{
public:
    Empty() {}    // 암묵적으로 생성된 기본 생성자
    //...
};
 
2. 복사 생성자

프로그래머가 복사 생성자를 명시적으로 선언하지 않으면, 하나의 복사 생성자가 암묵적으로 생성된다.
암묵적으로 생성된 복사 생성자는 기본적으로 public, inline이며, 가능한 경우에는 const 참조로 매개변수를 받는다.
암묵적으로 생성된 복사 생성자는 기반, 멤버 부분 객체들을 멤버별로 복사(shallow copy)한다.

기본적으로는 아래와 같은 시그너쳐를 가진다.
class Empty
{
public:
    //...
    Empty(const Empty& other)    // 암묵적으로 생성된 복사 생성자
    {
         x = other.x;
    }
    //...
};
 
위에서 가능한 경우에 const 참조로 매개변수를 받는다고 했다.
그렇다면 const 참조를 매겨변수로 받을 수 없는 경우가 무엇이 있을까?
평소 사용도 하지 않는(스마트 포인터를 써야 한다면, shared_ptr) 녀석이지만, 이럴 땐 적절한 예가 되어준다. 
class Empty
{
public:
    //...

    // 아래와 같이 복사 동작이 원본을 변경하는 경우엔 const를 쓸 수가 없기에...
    // 매개 변수는 비상수 참조로 받아야 한다.
    Empty(Empty& other)  
    {
         i = other.i;
    }

private:
    auto_ptr<int> i;    // auto_ptr은 소유권 이전 개념이 있다.
};
 
 
그리고 행여나 포인터 객체로 생성해놓구선 복사 생성자나 대입 연산자를 쓰진 않겠지? ㅡ,.ㅡ? 

3. 대입 연산자

프로그래머가 대입 연산자를 명시적으로 선언하지 않으면, 하나의 대입 연산자가 암묵적으로 생성된다.
암묵적으로 생성된 대입 연산자는 public, inline이며, 대입될 객체를 가리키는 비상수 참조를 돌려준다.
그리고, 매개 변수는 가능하다면 const 참조로 받는다 (이 룰은 복사 생성자의 그것과 동일하다)
class Empty
{
public:
    //...
    Empty& operator = (const Empty& other)    // 암묵적으로 생성된 대입 연산자
    {
         x = other.x;
         return *this;
    }
    //...
};

살짝 다른 얘기로 빠져서...아래의 예를 보자.
class Sample
{
public:
    Sample() : _value(0) {}
    Sample(int v) : _value(v) {}
    Sample(const Sample& other) { _value = other._value; }
    Sample& operator= (const Sample& other) { _value = other._value; return *this; }
    ~Sample() {}

private:
    int _value;  
};

int main()
{
    Sample s1(10);
    Sample s2 = s1;    // 1
    Sample s3;
    Sample s3 = s2;    // 2
}
 
위 예제에서 1번과 2번 지점에서 각각 어떤 함수가 호출되나?
= 연산자가 사용되었다고 해서 1번과 2번 모두에서 대입 연산자가 사용될 것이라 착각하는 사람들이 의외로 적지 않다.
아니...어쩌면 생각보다 많았다고 표현하는 것이 맞을 것이다.

이건 헤깔리면 안 된다.
객체가 생성되는 과정에서의 = 연산은 복사 생성자를 호출한다.그리고 이미 생성된 객체들간에 = 연산이 사용될 때는 대입 연산자가 호출되는 것이다.
 
 
4. 소멸자

프로그래머가 소멸자 함수를 명시적으로 선언하지 않으면, 하나의 소멸자가 암묵적으로 생성된다. 
암묵적으로 생성된 소멸자는 public, inline이며, 빈(empty) 소멸자를 직접 작성한 형태가 된다.
class Empty
{
public:
    //...
    ~Empty() {}
    //...
};
 
다른 이야기지만, 소멸자는 예외를 던지지 않도록 작성하는 것이 좋다.
즉, 소멸자 안에서 예외가 발생하면 그 안에서 처리가 되어야지 다른 곳으로 전파가 되면 안 된다는 뜻이다.
이를 내버려 두면, 시한폭탄이나 마찬가지라서 프로그램의 종료 후 이상 증세를 일으킬 수 있다.

이에 대해선 Effective C++ 3판, 챕터9를 한 번 읽어보는 것이 좋다.


고수 질문

모든 파생클래스들이 하나 이상의 암묵적으로 선언, 정의된 함수들을 사용하지 않도록 강제하고 싶은 기반 클래스가 있다.
class Count
{
public:
    // 파생 클래스가 지켜야 할 점

    // 1. 파생 클래스는 반드시 Count의 특수 형태 생성자만 호출해야 한다.
    // 2. Count 이하 클래스들은 어떤 형태로든 복사를 금지한다.

    Count(-- 고유 매개변수들 --);    // Count만을 위한 특수 생성자
    Count& operator= (const Count&);    // 평범한 대입 연산자
    virtual ~Count();    // 평범한 소멸자
};
 
Count 기반 클래스 작성자가 친절하게 주석까지 달아 놓았지만, 파생 클래스 작성자들이 지키리라는 보장은 어디에도 없다. 
예를 들어, 다음과 같이 약속을 어긴 클래스를 작성할 수도 있다.
class DerivedCount : public Count
{
    // 생성자, 대입연산자, 소멸자 모두 명시적으로 선언하지 않아,
    // 암묵적으로 생성되게 작성함.

private:
    int i;
};
 
이 예제에서, Count의 저자가 파생 클래스가 올바르게 작성되도록 하려면, Count 클래스가 어떻게 변경되어야 할까?
즉, 파생 클래스가 제대로 작성되지 않으면, 컴파일 시점에 오류를 내게 하거나, 적어도 런타임 오류를 발생시킬 수 있을까? 

좀 더 자세하게 얘기해서, 컴파일러가 암묵적으로 생성시키는 네 가지 함수를 어떻게든 올바르게 작성하게 강제할 수 있을까? 

Count 클래스는 명시한 특수 형태의 생성자만 사용해야 하고, 어떤 식으로든 복사도 허용하지 않는 것이 주요 원칙이다.(이런 식의 요구 조건들은 보통 개체 수를 한정짓는 Singleton 패턴에서 흔히 볼 수 있다) 

이를 달성하기 위해서는 다음과 같은 장치들이 마련되어 있어야 한다.

1. 기본 생성자 허용 금지
2. 복사 생성자 및 대입 연산자 허용 금지

기본 생성자 허용 금지는 의외로 간단하다.

기반 클래스에 기본 생성자가 존재하지 않기 때문에, 
파생 클래스를 생성하려 할 때 기반 클래스의 기본 생성자가 없어서 컴파일 에러가 발생하게 된다.
파생 클래스의 객체 생성시 생성자 함수 호출 순서를 생각해 보면 쉽게 이해할 수 있다. (부모 생성자 -> 자식 생성자)
class Count
{
public:
    Count(-- 고유 매개변수들 --);    // Count만을 위한 특수 생성자
    Count& operator= (const Count&);    // 평범한 대입 연산자
    virtual ~Count();    // 평범한 소멸자
};

class DerivedCount : public Count
{
    // 생성자, 대입연산자, 소멸자 모두 명시적으로 선언하지 않아, 암묵적으로 생성됨.

private:
    int i;
};

int main()
{
    Derived d;    // 부모의 기본 생성자가 없기에, 컴파일 에러가 발생!
}
 
복사 금지 역시 어렵지 않다.

기반 클래스에서 복사 생성자와 대입 연산자를 private 영역에 두고 선언만 해 두면,
파생 클래스에서 접근도 안 될 뿐더러, 선언만 되어 있고 정의가 없으므로 호출이 불가능하다.
class Count
{
    // ...
private:
    Count(const Count&);    // 선언만
    Count& operator= (const Count&);    // 선언만
};

class DerivedCount : public Count
{
    // 생성자, 대입연산자, 소멸자 모두 명시적으로 선언하지 않아, 암묵적으로 생성됨.

private:
    int i;
};

int main()
{
    Derived d;
    Derived d2 = d;    // Count의 private 멤버에 엑세스 할 수 없다는 컴파일 에러가 발생함
}

이제 Count 클래스를 원래 목적에 맞게 최종 수정하면 다음과 같은 형태가 된다.
class Count
{
public:
    // Count::Count() {} 기본 생성자가 없기에 파생 클래스에서 호출할 수 없다 : 컴파일 에러
    Count(-- 고유 매개변수들 --);    // Count만을 위한 특수 생성자  
    virtual ~Count();    // 평범한 소멸자

private:
    Count(const Count&);    // private 영역 : 컴파일 에러
    Count& operator= (const Count&);    // private 영역 : 컴파일 에러
};
 
 
원하는 기능을 모두 파생 클래스에서 할 수 없도록 하였으며,
그 결과 역시 런타임 오류가 아닌 컴파일 타임 에러를 발생토록 하여 파생 클래스 작성자가 사전에 알아차릴 수 있다.

파생된 클래스들이 암묵적으로 생성되는 함수들을 사용하지 못하게 하는 가장 간단하고도 최선의 선택은
그런 함수들을 기반 클래스에서 존재하지 않도록 하거나, public이 아니게 만드는 것이다.