5. C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자
[ Effective C++ ]
C++의 어떤 멤버 함수는 프로그래머가 클래스 안에 직접적으로 선언해 넣지 않아도, 컴파일러가 저절로 선언해 주도록 되어았다.
[1] 기본 생성자(default constructor)
[2] 복사 생성자(copy constructor)
[3] 복사 대입 연산자(copy assignment operator)
[4] 소멸자(destructor)
이 때, 컴파일러가 만드는 함수의 형태는 모두 기본형이다.
이들은 모두 public 멤버이며 inline 함수이다.
class Empty{};
위와 같이 클래스를 최소한으로 썼다면, 아래와 근본적으로 대동소이하다는 의미이다.
class Empty
{
public:
Empty() { ... } // 기본 생성자
Empty(const Empty& rhs) { ... } // 복사 생성자
~Empty() { ... } // 소멸자 : 가상 함수 여부에 대해서는 아래에서 더 자세히 설명 예정.
Empty& operator=(const Empty& rhs) { ... } // 복사 대입 연산자
};
이들은 꼭 필요할 때, 즉 실제로 호출되어야 할 때 컴파일러가 판단하여 만드는데, 아래와 같다.
Empty e1; // 기본 생성자, 소멸자
Empty e2(e1); // 복사 생성자
e2 = e1; // 복사 대입 연산자
기본 생성자와 소멸자가 하는 일은, 기본 클래스 및 비정적 데이터 멤버의 생성자와 소멸자를 호출하는 코드를 호출하는 것.
참고로, 여기서 상속한 기본 클래스의 소멸자가 가상 소멸자로 되어 있지 않다면 여기서 또한 비가상 소멸자로 만들어진다.(항목 7 참조)
컴파일러가 기본적으로 만들어준 복사 생성자와 복사 대입 연산자가 하는 일은, 원본 객체의 비정적 데이터를 사본 객체로 단순히 복사하는 것이 전부이다.
아래 예제를 보자.
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;
};
우선, NamedObject 클래스안에는 생성자가 선언되어 있기 때문에 컴파일러는 기본 생성자를 만들어내지 않는다. 생성자가 단 하나라도 선언되지 있다면 기본 생성자를 만들지 않는 것이다.
반면에, 복사 생성자나 복사 대입 연산자는 NamedObject에 선언되어 있지 않기 때문에, 이 두 함수의 기본형은 컴파일러에 의해 필요하다고 판단되면 만들어지게 된다.
NamedObject<int> no1("Smallest Prime Number", 2);
NamedObject<int> no2(no1); // 여기서 복사 생성자를 호출
위 예제에서, no1의 데이터 멤버인 no1.nameValue와 no1.objectValue를 사용해서 no2.nameValue 및 no2.objectValue를 각각 초기화 해야 한다.
여기서 std::string nameValue는 표준 string 타입이므로 자체적으로 복사 생성자를 갖고 있다.
그래서 string의 복사 생성자에 no1.nameValue를 인자로 넘겨 호출함으로써 복사가 이루어진다.
두번째로, T objectValue는 T가 int형이므로 각 비트를 그대로 복사해옴으로써 복사가 끝나게 된다.
컴파일러가 만들어 주는 복사 대입 연산자 또한 근본적으로 동작 원리가 동일한데, 주의해야할 사항은 최종 결과 코드가 적법하고(legal), 이치에 맞아야(reasonable) 한다.
그렇지 않다면 컴파일러는 자동 생성을 거부하게 된다.
아래의 예제를 보자.
template<class T>
class NamedObject
{
public:
NamedObject(std::string& name, const T& value);
...
private:
std:string& nameValue; // 이제 이 멤버는 참조자.
const T objectValue; // 이제 이 멤버는 상수.
};
이제, 아래와 같이 복사해보자.
std::string newDog("Persephone");
std::string oldDog("Satch");
NamedObject<int> p(newDog, 2);
NamedObject<int> s(oldDog, 36);
p = s; // **
이 때, 컴파일러가 기본적으로 만들어 주는 복사 대입 연산자가 호출된다. 이 때, nameValue는 어떻게 되어야 할까? p.nameValue는, s.nameValue가 참조하는 string을 가리켜야할까? C++ 문법상 불가능하다.
반대로, p.nameValue가 참조하는 string 객체의 내부 값이 변경되어야 할까? 이렇게 된다면 해당 string 객체에 대한 포인터나 참조자를 품고있는 다른 객체들, 실제 대입 연산제 직접적으로 연관없지만, 간접적으로 해당 원본을 가리키는 객체까지 영향을 받게 된다.
이 경우에는, 컴파일러 측에서 컴파일 에러를 뱉게 만드는 것이 이치에 맞을 것이다.
대신에, 프로그래머가 직접 해당 케이스를 핸들링하기 위해 복사 대입 연산자를 정의해주어야 한다.
데이터 멤버가 상수 객체인 경우에도, C++ 컴파일러는 비슷하게 동작한다. (참조처럼, 이후에 수정이 불가능 하므로)
추가로, 복사 대입 연산자를 private으로 선언한 기본 클래스로 부터 파생된 클래스의 경우, 이 클래스는 암시적 복사 대입연산자를 가질 수 없다. 컴파일러가 거부하게 된다.
파생 클래스에 대해 컴파일러가 만들어 주는 복사 대입 연산자는 기본 클래스 부분을 맡도록 되어 있긴 하지만(항목 12 참조), 이렇게 하더라도 파생 클래스 측에서 호출할 권한이 없는 멤버 함수는 암시적 복사 대입 연산자가 어떻게든 호출할 수는 없다.
[ 정리 ]
컴파일러는 경우에 따라 클래스에 대해 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓을 수 있다.
복사 대입 연산자의 경우, 기본적인 대입 연산이 이치에 맞지 않는다면 컴파일 단계에서 에러를 뱉어 암시적 버전의 생성을 컴파일러가 거부한다.