본문 바로가기

study/C++

[C++][Effective C++] 05~12. 생성자, 소멸자 및 대입 연산자

728x90

[Effective C++(3판)]에 대한 내용을 공부하면서 정리한 내용이다.

 

이번에 요약하는 내용은 05~12 항목으로 아래와 같다.

  • 05. C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자
  • 06. 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자
  • 07. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자
  • 08. 예외가 소멸자를 떠나지 못하도록 붙들어 놓자
  • 09. 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자
  • 10. 대입 연산자는 *this의 참조자를 반환하게 하자
  • 11. operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자
  • 12. 객체의 모든 부분을 빠짐없이 복사하자

요약

05. C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자

복사 생성자(copy constructor), 복사 대입 연산자(copy assignment operator), 소멸자(destructor)는 클래스의 멤버 함수로 직접 선언하지 않으면, 컴파일러가 저절로 선언해준다. 이때 만드는 함수들은 모두 기본형이다. 생성자조차 선언되어 있지 않다면 기본 생성자를 선언한다. 기본형은 모두 public 멤버이며 inline 함수다.

즉, 아래와 같이 클래스만 선언했다면, 근본적으로 아래의 기본형을 선언한 것과 동일하다.

// 기본 클래스
class CEmpty {};

// 기본 함수 추가한 클래스
class CEmpty {
public:
	CEmpty() {} // 기본 생성자
    CEmpty(const CEmpty &rhs) {} // 기본 복사 생성자
    ~CEmpty() {} // 기본 소멸자(비가상 소멸자)
    CEmpty &operator=(const CEmpty &rhs) {} // 기본 복사 대입 연산자
};

 

항상 만들어지는 것은 아니고, 컴파일러가 필요하다고 판단될 때만 만들게 된다. 각각의 조건은 아래와 같다.

CEmpty e1; 		//< 기본 생성자, 소멸자 생성
CEmpty e2(e1); 	//< 기본 복사 생성자 생성
e2 = e1; 		//< 기본 복사 대입 연산자 생성

 

이때 소멸자는 해당 클래스가 상속한 기본 클래스의 소멸자가 비가상 소멸자라면, 해당 파생 클래스도 비가상 소멸자로 생성된다.

 

컴파일러가 만들어주는 기본 복사 대입 연산자가 생성되기 위해서는 조건이 필요하다. 둘 중 하나라도 통과하지 못한다면, 컴파일러는 operator=의 자동 생성을 거부한다.

  1. 적법(legal)해야 한다.
  2. 이치에 맞아(reasonable)야 한다.

아래의 예제 코드는 기본 복사 대입 연산자 생성을 거부한다.

template<class T>
class CNamedObject {
public:
	CNamedObject(std::string &sName, const T &value);
    
private:
	std::string &m_name; 	// 참조 멤버
    const T m_object; 		// 상수 멤버
};

...

std::string newDog("Persephone");
std::string oldDog("Satch");

CNamedObject<int> p(newDog, 2);
CNamedObject<int> s(oldDog, 36);

p = s; 	// Error. 기본 복사 대입 연산자 생성 실패

 

참조자는 복사 대입 연산자가 동작하지 않는다. 참조자는 선언과 동시에 초기화가 이루어져야 하며, 다른 객체로 참조를 변경할 수 없다.

상수도 마찬가지다. 상수 멤버를 수정하는 것은 문법적으로 어긋난 동작이다.

 

따라서, 이런 경우에는 컴파일러가 자체적으로 암시적으로 기본 복사 대입 연산자를 만들기 애매하다. 따라서 기본 복사 대입 연산자를 만들지 못한다.

 

또 다른 기본 복사 대입 연산자를 생성하지 못하는 경우는, 복사 대입 연산자를 private으로 선언한 기본 클래스로부터 파생 클래스를 만든 경우다. 해당 파생 클래스는 암시적 복사 대입 연산자를 가질 수 없다. 파생 클래스에서 호출할 권한이 없는 멤버 함수로 인식하기 때문이다.

 

06. 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자

일반적으로, 어떤 기능을 막고 싶다면 그런 기능을 제공하는 함수를 선언하지 않는다. 하지만 이 전략은 복사 생성자와 복사 대입 연산자에게 있어서는 해당되지 않는다. 이런 함수는 필요에 따라 컴파일러가 자동으로 생성하기 기본형을 생성하기 때문이다.

그렇다면 이 함수들의 선언을 막기 위해서는 어떻게 해야 할까?

 

기본적으로 컴파일러가 자동으로 생성하는 기본형 함수는 public 접근 지시자를 갖는다. 컴파일러가 저절로 함수를 생성하는 것을 막기 위해서는 개발자가 직접 선언해야 한다. 하지만 개발자가 선언할 때는 반드시 public으로 선언할 필요가 없다. 복사 생성자 및 복사 대입 연산자를 private 멤버로 선언하자. 그러면 컴파일러는 기본형 함수를 자동 생성할 수 없고, 이 함수들은 private이기 때문에 외부에서 사용도 막을 수 있다.

 

추가적으로, 한 가지 더 고려되어야 할 사항이 있다. private 멤버 함수는 그 클래스의 멤버 함수 및 friend 함수에서는 호출할 수 있다는 것이다. 이것까지 막으려면 어떻게 해야 할까?

 

바로, private으로 선언한 멤버 함수(복사 생성자 및 복사 대입 연산자)를 정의하지 않는 것이다. 실수로 호출을 하더라도 링크 시점에 에러가 발생(컴파일러에 의한 에러 검출이 아니다.)하게 된다. 이 꼼수는 꽤 널리 퍼지면서 하나의 '기법'처럼 굳어졌다. 실제로 C++ iostream 라이브러리 내 몇몇 클래스에서도 복사 방지책으로 사용하고 있다.

이때 링크 시점의 에러 검출을 컴파일 시점에 할 수도 있다. 복사 생성자와 복사 대입 연산자를 private으로 선언하긴 하는데, 부모 클래스의 private 부분에 선언을 하는 방법이다. 그리고 대상 클래스는 해당 부모 클래스를 private 상속을 하게 되면, 어느 위치에서든 대상 클래스의 객체를 복사하려고 하면 컴파일러가 알려줄 것이다.

이를 지원하는 Boost 라이브러리가 이미 존재한다. noncopyable이라는 클래스로 복사 생성자 및 복사 대입 연산자를 private으로 선언해둔 클래스다.

(C++11 이후에는 =delete로 명시적으로 삭제한 함수를 표시하는 것 같다.)

 

07. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자

하나의 기본 클래스와 이를 다양하게 파생시킨 클래스가 있다고 해보자. 그리고, 이때 어떤 객체에 대한 포인터를 손에 넣는 용도로 팩토리 함수(factory function, 새로 생성된 파생 클래스 객체에 대한 기본 클래스 동적 할당 후 포인터를 반환하는 함수로 여기서는 자세히 다루지 않는다.)를 만들어 두자.

/* 기본 클래스 */
class CTimeKeeper {
public:
	CTimeKeeper();
    ~CTimeKeeper();
};

/* 파생 클래스 */
class CAtomicClock : public CTimeKeeper {...};
class CWaterClock : public CTimeKeeper {...};
class CWristWatch : public CTimeKeeper {...};

/* 팩토리 함수 */
CTimeKeeper *getTimeKeeper();


CTimeKeeper *pTimeKeeper = getTimeKeeper();
...
delete pTimeKeeper;

 

팩토리 함수로 받아온 객체(포인터)는 메모리 및 기타 자원의 누출을 막기 위해 적절히 삭제(delete) 해야 한다. 

문제는 여기서 발생한다. getTimeKeeper 함수가 반환하는 포인터가 파생 클래스인 CAtomicClock의 포인터라고 하자. 이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스 포인터(CTimeKeeper *)를 통해 삭제가 된다. 그런데, 기본 클래스의 소멸자가 비가상 소멸자(non-virtual destructor)다.

기본 클래스의 소멸자가 가상 소멸자였다면, 파생 클래스의 삭제 시에는 각 파생 클래스의 소멸자(오버 로딩된)가 호출된다. 하지만, 기본 클래스의 소멸자가 비가상 소멸자인 이상, CAtomicClock 객체는 기본 클래스인 CTimeKeeper의 소멸자에 의해 자원이 정리된다. 이럴 경우, 파생 클래스의 소멸자도 호출이 되지 않고, 파생 클래스의 멤버들이 소멸되지 않는다. 오직 기본 클래스 부분만 소멸된다. 결국 반쪽자리 부분 소멸(partially destroyed) 객체가 된다.

 

이 문제를 해결하려면, 계속 말한 대로 기본 클래스의 소멸자는 가상 소멸자로 하면 된다.

/* 기본 클래스 */
class CTimeKeeper {
public:
	CTimeKeeper();
    virtual ~CTimeKeeper(); // 가상 소멸자
};

 

비슷하게, 기본 클래스에서는 껍데기만 둔 채로 각 파생 클래스마다 구현되어야 한다면 애초에 가상 함수로 지정할 수도 있다. 그리고 만약 가상 함수가 하나라도 있다면 가상 소멸자를 갖는 게 대부분 맞다. (많은 개발자가 기본/파생 클래스 여부가 아닌, 가상 함수 유무로 가상 소멸자를 결정한다.)

 

그렇다고 모든 클래스의 소멸자를 가상 소멸자로 두는 것은 옳지 못하다. 기본 클래스로 의도한 클래스는 소멸자를 가상으로 선언해야 하며, 기본 클래스로 의도하지 않았다면 비가상으로 두는 것이 맞다.

다른 이유도 있지만, 가상 선언 자체만으로도 해당 객체의 크기가 커진다. 자세한 이유는 생략하지만, 간략히 말하자면 가상 선언 시 클래스에 포인터가 포함되는 구조체가 추가가 된다.

 

비가상 소멸자로 인해 문제가 발생하는 대표적인 경우는 string 클래스를 상속받는 파생 클래스를 만드는 경우다. C++의 string 클래스는 대표적으로 비가상 소멸자를 갖는 클래스다. 따라서 해당 파생 클래스는 소멸하는 과정은 언제든 실수하는 순간 문제가 발생할 수 있다.

사실, string 클래스뿐만 아니라, STL 컨테이너 타입 전부가 여기에 해당한다.

 

기본 클래스에 가상 소멸자를 쥐어 주자는 규칙은 다형성(polymorphic)을 가진 기본 클래스에만 적용된다는 사실을 기억하자. 즉, 기본 클래스 인터페이스로 파생 클래스 타입의 조작을 허용한 기본 클래스에만 적용되는 규칙이다. 위의 예시에서 CTimeKeeper 포인터로도 CAtomicClock/CWaterClock/CWristWatch 객체를 조작할 수 있는 것과 마찬가지다.

 

08. 예외가 소멸자를 떠나지 못하도록 붙들어 놓자

소멸자에서 예외가 발생하는 것을 C++에서 막는 것은 아니지만, 실제 환경에서는 개발자가 막을 수밖에 없다.

C++에서 발생하는 예외는 완전하지 못한 프로그램의 종료 또는 정의되지 않은 동작을 발생시킨다.

그런, 예외를 던지고 실패할 수도 있는 코드를 소멸자에 넣어야 하는 경우도 있을 것이다. 아래의 예제 코드는 데이터베이스 연결을 나타내는 클래스다.

class CDBConnection {
public:
	static CDBConnection create(); // (팩터리 메서드) CDBConnection 객체를 반환

	void close();	// DB 연결 종료. 이때 실패 시 예외를 던짐.
};

 

위 코드는 사용자가 CDBConnection 객체에 대해 직접 close()를 호출해야 하는 상황으로 보인다. 사용자의 망각을 사전에 차단하기 위한 방법은 CDBConnection의 자원관리 클래스를 별도로 만들어, 해당 클래스 소멸자에서 CDBConnectionclose를 직접 호출하는 것이다.

class CDBConn {
public:
	CDBConn(CDBConnection db) : m_db(db) {
	}

	~CDBConn() {
		m_db.close();
	}

private:
	CDBConnection m_db;
};

// 아래와 같이 객체 자원의 관리를 넘길 수 있음
	{
		CDBConn dbc(CDBConnection::create());

		// CDBConn 객체(dbc) 자동 소멸.
		// 따라서, CDBConn의 멤버변수인 CDBConnection 객체(m_db)에 대해 close()가 호출됨
	}

 

그러나, 위 코드에서 close()에서 예외가 발생했다고 하자. CDBConn의 소멸자가 예외를 전파할 것이다. 이렇게 되면 처음 말한 문제들이 발생할 수 있다.

소멸자에서 발생한 예외를 처리하는 방법은 두 가지가 있다.

  1. close에서 예외가 발생하면 바로 프로그램을 끝냅니다. (abort())
  2. close를 호출한 곳에서 예외를 삼킵니다. (try~catch 후 로그만 남기고 프로그램 실행에 영향은 주지 않음)

 

첫 번째 방법은 꽤 괜찮은 방법일 수 있다. 그러나, 두 번째 방법은 대부분 좋은 방법은 아니다. 예외가 발생하는 중요한 정보가 묻혀버리기 때문이다. 일부 불완전한 종료 또는 미정의 동작으로 발생할 위험을 감수하는 것보다는 나을 수도 있다. 신뢰성 있는 프로세스의 지속이 가능하다면, 괜찮은 방법일 수는 있는 것이다.

그렇다 하더라도 둘 다 특별히 좋은 방법은 아니다. 소멸자에서 발생한 예외에 대해 무책임하기 때문이다. 아래의 방법은 보다 나은 방법을 제공한다.

class CDBConn {
public:
	CDBConn(CDBConnection db) : m_db(db), m_closed(false) {
	}

	~CDBConn() {
		if ( false == m_closed ) { // 사용자가 close하지 않았으면, 소멸 단계에서 자동으로 닫아줌
			try {
				close();
			} catch ( ... ) {
				// log
			}
		}
	}

	void close() { // 사용자가 직접 close할 수 있게 한다.
		m_db.close();
		m_closed = true;
	}

private:
	CDBConnection m_db;
	bool m_closed;
};

 

위와 같이 CDBConn에서 직접 close 함수를 제공하고 사용자가 close를 사용할 수 있게 한다. 만약, 사용자가 close를 호출하지 못했다면, CDBConn의 소멸자에서 CDBConnection이 닫혔는지에 대한 확인을 통해 직접 닫아준다. 이럴 경우, 아래와 같은 장점이 있다.

  1. 사용자가 close의 예외처리를 직접 처리할 수 있다.
  2. 사용자가 깜빡하고 close를 호출하지 못했다면, 자동으로 close를 해준다.

다만, 사용자가 깜빡하고 close를 하지 못한 경우에는 소멸자에서 close를 호출해줄 때를 보자. 만약 소멸자가 호출하는 close에서 예외가 발생한다면 그때는 어떨까? 위에서 언급한 대로 끝내거나 삼켜버려야 한다.

소멸자엔 확실히 종료하는 코드를 두고, 사용자로 떠넘기는 아이디어는 무책임하게 보일지 모른다. 하지만, 여기서의 포인트는 "예외는 소멸자가 아닌 다른 함수에서 비롯되어야 한다."라는 점이다.

 

09. 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자

주식 거래를 본떠 만든 클래스 계통 구조가 있다고 가정해보자. 이런 거래를 모델링하는 데 있어서 중요한 포인트는 감사(audit) 기능이 있어야 한다는 점일 것이다. 그렇기 때문에 주식 거래 객체가 생성될 때마다 감사 로그에 적절한 거래 내역을 만들어야 한다. 다음과 같은 구조가 될 것이다.

class CTransaction { // 기본 클래스
public:
	CTransaction() {
		...

		logTransaction(); // 마지막 동작으로 거래 내역을 로깅
	}
	
	virtual void logTransaction() const = 0; // 타입에 따라 달라지는 감사 기능
};

class CBuyTransaction : public CTransaction { // 파생 클래스
public:
	virtual void logTransaction() const; // 타입에 따른 거래내역 감사 기능
};

class CSellTransaction : public CTransaction { // 파생 클래스
public:
	virtual void logTransaction() const; // 타입에 따른 거래내역 감사 기능
};

 

여기서 CBuyTransaction 객체를 하나 만들면 어떻게 될까? 먼저 CTransaction 생성자가 호출된다. 이때 마지막에 가상 함수인 logTransaction()을 호출하는 것을 볼 수 있다. 여기서 문제가 발생한다.

CTransaction 생성자에서 호출되는 logTransaction()는 CBuyTransaction 클래스의 것이 아닌, CTransaction 클래스의 것이다. 즉, 기본 클래스의 생성자가 호출되는 동안에 가상 함수는 절대로 파생 클래스로 내려가지 않아 원하는 동작을 하지 않는다.

 

만약에 기본 클래스의 가상 함수를 구현한 파생 클래스에서의 함수를 호출한다고 생각하면 어떨까? 이때도 문제가 생긴다. 객체의 생성 시점에 기본 클래스의 생성자는 파생 클래스의 생성자보다 먼저 실행된다. 따라서, 파생 클래스의 멤버는 아직 초기화되지 않았다. 파생 클래스에서 구현한 가상 함수는 파생 클래스의 멤버를 다룰 것이기 때문에, 여기서 문제가 발생할 것이다.

 

결국, 핵심적인 것은 "파생 클래스 객체의 기본 클래스 부분이 생성되는 동안(기본 클래스의 생성자가 호출되는 동안)에는 그 객체의 타입이 바로 기본 클래스"라는 것이다. 따라서, 호출되는 가상 함수 모두 기본 클래스의 것으로 결정(resolve)되는 것이다. 그로 인해 기본 클래스의 가상 함수가 그대로 호출이 되는 것이다.

 

소멸자는 반대 순서로 동일한 내용이다. 소멸자가 호출될 경우 파생 클래스의 소멸자가 먼저 호출이 되어 파생 클래스의 멤버를 정리한다. 이후 기본 클래스의 가상 함수는 기본 클래스의 것이 호출되게 된다.

 

생성자와 소멸자에서 가상 함수를 호출하는 경우에 대한 대응 방법은 여러 가지가 있다. 그중 하나에 대해 소개한다. CTransaction 클래스(기본 클래스)의 logTransaction을 비가상 멤버 함수로 바꾸는 것이다. 만약, logTransaction이 비가상 함수라면 CTransaction의 생성자는 이 함수를 안전하게 호출할 수 있다.

 

10. 대입 연산자는 *this의 참조자를 반환하게 하자

C++ 대입 연산은 여러 개가 사슬처럼 엮일 수 있는 재밌는 성질을 갖고 있다.

int x, y, z;
x = y = z = 15;

 

이러한 특성은 우측 연관(right-associative) 연산이라 한다. 즉, 다음과 같이 위의 코드를 재분석할 수 있다.

x = (y = (z = 15));

 

이렇게 대입 연산을 사슬처럼 엮으려면, 대입 연산자가 좌변의 인자의 참조자를 반환하도록 구현되어야 한다. 일종의 관례(convention)인데, 개발 시 만드는 클래스에도 대입 연산자가 필요하다면, 이 관례를 지키는 것이 좋다.

참고로, 좌변 객체의 참조자를 반환하게 만다는 관례는 단순 대입 연산자 말고 모든 형태의 대입 연산제에서 지켜야 한다.

class CWidget {
public:
	// 단순 대입 연산자
	CWidget & operator= (const CWidget &rhs) {
		//...
		return *this;
	}

	// +=, -=, *= 등에도 동일한 규약을 적용
	CWidget & operator+= (const CWidget &rhs) {
		// ...
		return *this;
	}
    
	// 일반적이지 않은 매개변수 타입일지라도 동일한 규약을 적용
	CWidget &operator= (int rhs) {
		//...
		return *this;
	}
};

 

11. operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자

자기대입(self assignment)이란, 어떤 객체에 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다.

// 자기대입 예제
CWidget widget;
widget = widget;

 

위의 코드는 적법한(legal) 코드다. 바보 같은 코드라고 생각할 수 있지만, 이런 경우에 문제가 발생한다면 오히려 찾기도 어렵다. 예를 들어, 아래의 경우에는 예상치도 못하게 자기대입이 발생할 수도 있다.

// 자기대입 예제
a[i] = a[j]; // i == j 일 수 있다.
*px = *py; // px와 py가 가리키는 것이 같을 수 있다.

 

자기대입이 발생하는 근본적인 원인은 같은 객체에 여러 참조가 존재하는 중복 참조(aliasing)이라 불리는 것 때문이다. 같은 타입의 객체 여러 개를 참조자 또는 포인터로 물어 놓고 동작하는 코드에서 많이 발생할 수 있다.

어쨌든 대입 연산자는 사용자가 신경 쓰지 않아도 자기대입에 대해 안전하게 동작해야 한다.

 

동적 할당된 비트맵을 가리키는 원시 포인터를 멤버로 갖는 클래스를 만들었다고 가정해보자.

class CBitmap {
	// ...
};

class CWidget {
public:
	// 자기대입에 대해 불안전한 대입 연산자
	CWidget & operator= (const CWidget &rhs) {
		delete pBitmap;
		pBitmap = new CBitmap(*rhs.pBitmap); // rhs의 비트맵을 사용하도록 함
		return *this;
	}

private:
	CBitmap *pBitmap;
};

 

위의 코드는 의미적으로 문제가 없을 것으로 보이지만, 자기 참조의 가능성이 여전히 남아있다. 자기 참조의 문제는 *thisrhs가 같을 수 있다는 것이다. 이 둘이 같은 객체라면, delete pBitmap 동작 시에 *this.pBitmap 뿐만 아니라 rhs.pBitmap까지 자원 해제된다.

 

이러한 문제를 해결하기 위해 여러 방법이 존재한다. 전통적인 방법은 일치성 검사(identity test)를 통해 자기대입을 점검하는 것이다.

// 자기대입에 대해 안전한 대입 연산자
CWidget & operator= (const CWidget &rhs) {
    // 자기대입 점검
    if ( &rhs == this ) {
        return *this;
    }

    delete pBitmap;
    pBitmap = new CBitmap(*rhs.pBitmap);
    return *this;
}

 

여기에 이제 추가적으로 보완을 하도록 하자. 위의 코드는 예외가 터지게 되면 어떠한 조치도 취하지 않고 미정의된 동작을 할 가능성이 많다. 특히 new CBitmap에서 예외가 발생할 경우, 기존 객체의 pBitmap은 삭제된 채로 유지되고 만다. 이제 대입 연산자를 예외에 안전하게 구현해보도록 하자.

사실, 예외에 안전하기만 하면 자기대입 문제는 무시하더라도 무사히 코드가 넘어갈 확률이 높다. 아래의 코드는 자기대입 문제에 대한 점검을 수행하지 않더라도 떠도는 객체 없이 안전하게 동작한다. 어느 부분에서 예외가 발생하더라도 서비스에 대한 지속성을 제공한다는 것이다.

// 예외에 안전한 대입 연산자
CWidget & operator= (const CWidget &rhs) {
    CBitmap *pOrig = pBitmap; // 현재 비트맵을 기록
    pBitmap = new CBitmap(*rhs.pBitmap); // 현재 비트맵을 rhs의 것으로 갱신
    delete pOrig; // 기존 비트맵을 삭제
    return *this;
}

 

사실, 위의 코드에서 일치성 테스트를 제거한 데는 이유가 있다. 실제로 자기대입은 그렇게 자주 발생하지 않는다. 그에 반해 일치성 테스트를 추가함으로써 코드와 분기 처리, 실행 시간 전체에 영향을 줄 수 있기 때문이다. 그래서 예외에 안전한 코드를 작성하고 일치성 테스트는 생략한 것이다.

 

12. 객체의 모든 부분을 빠짐없이 복사하자

객체를 캡슐화할 때 설계가 잘 된 것을 보면 객체 복사 함수는 딱 둘만 있다.

  1. 복사 생성자
  2. 복사 대입 연산자

이 둘은 복사 함수(copying function)라 부른다.

 

객체 복사 함수를 직접 선언하는 것은 컴파일러의 기본형이 마음에 들지 않기 때문이다. 다만, 이때 제대로 구현하지 못하면 컴파일러는 정확히 집어주지 못하는 경우도 있다.

아래의 코드를 보자. 고객의 복사 함수를 호출할 때마다 로그를 남기도록 작성되었다고 가정하자.

void logCall(const std::string &sFuncName);

class CDate {
	// something
};

class CCustomer {
public:
	// 복사 생성자
	CCustomer(const CCustomer &rhs) : m_name(rhs.m_name) {
		logCall("Customer copy constructor");
	}

	// 복사 대입 연산자
	CCustomer &operator= (const CCustomer &rhs) {
		logCall("Customer copy assignment operator");

		m_name = rhs.m_name;
		return *this;
	}

private:
	std::string m_name;
	CDate m_lastTransaction; // 문제 발생 가능
};

 

위의 코드에서 복사 함수의 동작은 완전 복사가 아닌, 부분 북사(partial copy)가 된다. 고객의 m_name은 복사가 되지만, m_lastTransaction은 복사되지 않는다. 하지만, 이런 상황이라도 컴파일러는 알려주지 않는다.

클래스의 멤버로 어떤 객체가 추가되었으면, 그 추가된 객체의 타입에 대해서도 복사할 수 있도록 복사 함수를 다시 작성할 수밖에 없다.

 

가장 문제가 되어 개발자를 괴롭히는 경우는, 클래스 상속이 주어진 경우다. 파생 클래스의 복사 함수에서 파생 클래스의 멤버를 모두 복사한다 하더라도 기본 클래스의 멤버가 완전 복사되지 않을 수 있다. 파생 클래스에서 기본 클래스의 private 멤버를 초기화 하기에는 어려울 수 있기 때문에, 파생 클래스의 복사 함수에서 직접 기본 클래스의 복사 함수를 호출하도록 하는 방법을 많이 사용한다.

단, 기본 클래스의 복사 함수가 모두 완전 복사되도록 작성되어있어야 한다.

728x90