C++의 어떤 멤버 함수는 여러분이 클래스 안에 직접 선언해 넣지 않으면 컴파일러가 저절로 선언해 주도록 되어 있습니다. 바로 복사 생성자(copy constructor), 복사 대입 연산자(copy assignment operator), 그리고 소멸자(destructor)인데, 좀더 자세히 말하면 이때 컴파일러가 만드는 함수의 형태는 모두 기본형입니다. 게다가, 생성자조차도 선언되어 있지 않으면 역시 컴파일러가 여러분 대신에 기본 생성자를 선언해 놓습니다. 이들은 모두 public 멤버이며 inline함수입니다. 


그러니까, 여러분이 다음과 같이 썼다면


1
2
class Empty{ };
 


다음과 같이 쓴 것과 근본적으로 대동소이하다는 이야기입니다.


1
2
3
4
5
6
7
8
9
10
class Empty{
public:
    Empty() { ... }                        // 기본생성자
    Empty(const Empty& rhs) { ... }        // 복사 생성자
 
    ~Empty() { ... }                    // 소멸자 : 가상 함수 여부에 대해서는
                                        // 아래에서 더 자세히 설명하겠습니다.
 
    Empty& operator= (const Empty& rhs) { ... }        // 복사 대입 연산자
 };
cs


이들은 꼭 필요하다고 컴파일러가 판단할 때만 만들어지도록 되어 있지만, 필요한 조건이 그리 대단한 것도 아닙니다. 이들이 만들어지는 조건을 만족하는 코드는 다음과 같습니다.


1
2
3
4
5
Empty e1;            // 기본 생성자, 소멸자
 
Empty e2(e1);        // 복사 생성자
 
e2 = e1;            // 복사 
cs


이렇게 여러분 대신 컴파일러가 함수를 만들어 주기는 하나 봅니다.


이때 소멸자는 이 클래스가 상속한 기본 클래스의 소멸자가 가상 소멸자로 되어 있지 않으면 역시 비가상 소멸자로 만들어진다는 점을 꼭 짚고 가야겠습니다.




복사 생성자와 복사 대입 연산자의 경우에는 어떻까요? 아주 단순합니다.  원본 객체의 비정적 데이터를 사본 객체 쪽으로 그냥 복사하는 것이 전부이지요.


1
2
3
4
5
6
7
8
9
10
11
12
template<typename T>
class NamedObject {
public:
    NamedObject(const char *name, const T& value);
    NamedObject(const std::string& name, const T& value);
 
    ...
 
private:
    std::string nameValue;
    T objectValue;
};
cs


이 NamedObject 템플릿 안에는 생성자가 선언되어 있으므로, 컴파일러는 기본 생성자를 만들어 내지 않을 것입니다.


반면, 복상 생성자나 복사 대입 연산자는 NamedObject에 선언되어 있지 않기 때문에, 이 두 함수의 기본형이 컴파일러에 의해 만들어 집니다.


1
2
3
NamedObject<int> no1("Smallest Prime Number"2);
 
NamedObject<int> no2(no1);    //여기서 복사 생성자를 호출합니다.
cs


표준 string 타입은 자체적으로 복사 생성자를 갖고 있으므로 복사가 되고, int는 기본제공 타입이므로 비트를 그대로 복사해 오는것으로 끝납니다.


하지만,


일반적인 것만 놓고 보면, 이 복사 대입 연산자의 동작이 필자가 설명한 대로 되려면 최종 결과 코드가 '적법해야(legal)' 하고 '이치에 닿아야만(resonable)' 합니다. 둘 중 어느 검사도 통과하지 못하면 컴파일러는 operator=의 자동생성을 거부해 버립니다.



예를 들어 NamedObject가 다음과 같이 정의되어 있다고 가정해 보죠


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T>
class NamedObject {
public:
    
    // 이 생성자는 이제 상수 타입의 name을 취하지 않습니다. nameValue가
    // 비상수 string의 참조자가 되었기 때문입니다. 참조할 string을 가져야 하기
    // 때문에 char* 는 없애 버렸습니다.
    NamedObject(const std::string& name, const T& value);
 
    ...                            // operator=는 선언된 게 없다고 가정합니다.
 
private:
    std::string& nameValue;        // 이제 이 멤버는 참조자 입니다.
    const T objectValue;        // 이제 이 멤버는 상수입니다.
};
cs


자, 그럼 여기서 어떤 일이 일어날지 생각해 봅시다.


1
2
3
4
5
6
7
8
9
std::string newDog("persephone");
std::string oldDog("Satch");
 
NamedObject<int> p(newDog, 2);
 
NamedObject<int> s(oldDog, 36);
 
= s;        // p에 들어 있는 데이터 멤버에서 어떤일이
            // 일어나야 할까요?
cs


대입 연산이 일어나기 전, p.nameValue 및 s.nameValue는 string 객체를 참조하고 있습니다. 물론 같은 string 객체는 아닙니다. 이때 대입 연산이 일어나면 p.nameValue가 어떻게 되어야 할까요? s.nameValue가 참조하는 string을 가리켜야할까요?


다시말해, 참조자 자체가 바뀌어야 하는 걸까요? 이렇게 된다면 C++의 새로운 세계를 개척하는 용사의 시대가 열려야 할 것입니다. 왜냐하면 C++의 참조자는 원래 자신이 참조하고 있는 것과 다른 객체를 참조할 수 없기 때문이죠. 이렇게 할 수 있는 방법이 아예 없습니다.


그렇다면 p.nameValue가 참조하는 string 객체 자체가 바뀌는 게 맞을까요? 이렇게 되면 그 string에 대한 포인터나 참조자를 품고 있는 다른 객체들, 즉 실제 대입연산에 직접적으로 관여하지 않는 객체까지 영향을 받게 됩니다.


정녕 이것이 컴파일러가 저절로 만들어낸 복사 대입 연산자가 해야 마땅할 일일까요?


어느 쪽을 정하더라도 껄적지근한 이 문제에 대해, C++는 시원하게 '컴파일 거부' 카드를 냅니다.


그렇게 때문에, 참조자를 데이터 멤버로 갖고 있는 클래스에 대입 연산을 지원하려면 여러분이 직접 복사 대입 연산자를 정의해 주어야 합니다.


데이터 멤버가 상수 객체인 경우에도  C++ 컴파일러가 비슷하게 동작하니 꼭 주의하세요. 상수 멤버를 수정하는 것은 문법에 어긋나기 때문에, 자동으로 만들어진 암시적 복사 대입 연산자 내부에서는 상수 멤버를 어떻게 처리해야 할지가 애매해 집니다.




이것만은 잊지 말자!

* 컴파일러는 경우에 따라 클래스에 대해 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓을 수 있습니다.

+ Recent posts