[C++/도서] Effective C++ - 챕터2: 생성자, 소멸자 및 대입 연산자

Donghee·2024년 10월 2일
0

Effective C++

목록 보기
2/2

챕터2: 생성자, 소멸자 및 대입 연산자

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

C++의 어떤 멤버 함수는 클래스 안에 직접 선언하지 않으면, 컴파일러가 저절로 선언해 주도록 되어 있다. 생성자, 복사 생성자, 복사 대입 연산자, 소멸자가 그들이다. 다음의 빈 클래스를 보자.

class Empty{};

이는 사실,

class Empty{
public:
	Empty() {...} // 기본 생성자
	Empty(const Empty& rhs) {...} // 복사 생성자
	~Empty() {...} // 소멸자
	Empty& operator=(const Empty& rhs) {...} // 복사 대입 연산자
};

이렇게 쓴 것과 근본적으로 다르지 않다는 것이다. 물론 복사 대입 연산자(=) 같은 경우엔 제약 조건이 붙어 있다. 최종 결과가 적법(legal)하고, 이치에 맞아야만(reasonable)해야만 컴파일러가 이들을 자동 생성한다. 예를 들어 데이터 멤버가 상수나 참조자가 있다면, 이를 다른 값으로 대입하는 것은 불가능하니 이 상황에서 복사 대입 연산자를 사용하면, 컴파일 거부를 일으킨다.

컴파일러는 경우에 따라 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓는다.
복사 대입 연산자는 그 결과가 적법하고 이치에 맞아야만 자동 생성된다.

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

어떤 클래스 MyClass의 객체는 사본을 만드는 것이 불가하게 만들고 싶다고 해보자. 이런 경우 우리는, 객체를 복사하려 하는 코드가 컴파일되지 않았으면 좋겠다는 생각을 한다.

일반적인 경우엔, 어떤 클래스에서 특정한 종류의 기능을 지원하지 않았으면 하는 경우엔 그 함수를 선언하지 않으면 된다. 그런데 이 전략은 복사 생성자와 복사 대입 연산자에 대해서는 ‘해당사항 없음’이다. 우리가 선언하지 않는다 해도, 외부에서 호출하려고 하면 컴파일러가 우리 대신 이들을 선언해버리기 때문이다.

해결의 열쇠는 다음과 같다. 복사 생성자와 복사 대입 연산자를 private으로 선언하자. 그렇다면 일단 클래스 멤버 함수가 명시적으로 선언되기 때문에, 컴파일러는 자신의 기본 버전을 만들 수 없게 된다.

물론 private 멤버 함수는 그 클래스의 멤버 함수나, friend 함수가 호출할 수 있다는 점이 여전히 허점이다. 그 클래스의 friend 함수로 지정된 함수는, private 멤버로 들어가있는 모든 요소를 사용할 수 있기 때문이다. 이것까지 막으려면 정의(define)를 안 해 버리면 어떨까?

정의되지 않은 함수를 실수로 호출하려 한다면, 링크 시점에서 에러를 보게 될 것이다. 이 기법은 꽤 널리 퍼지며 하나의 ‘기법’으로 굳어지기까지 했다.

class MyClass {
public:
	...
private:
	...
	// 둘다 선언만 존재
	MyClass(const MyClass&);
	MyClass& operator=(const MyClass&);
};

사용자가 MyClass 객체의 복사를 시도하면 컴파일러가 에러를 내고, 깜빡하고 멤버 함수나 friend 함수를 통해 복사를 시도하면 링커가 에러를 낼 것이다.

이 링크 시점 에러를 컴파일 시점 에러로 옮길 수도 있다. 위처럼 private으로 복사 생성자와 복사 대입 연산자를 선언하되, 별도의 기본 클래스에 넣고 이를 상속하는 방식으로 가는 것이다.

class Uncopyable {
protected:
	// 생성과 소멸은 가능 
	Uncopyable() {}
	~Uncopyable() {}
private:
	// 복사 방지
	Uncopyable(const Uncopyable&);
	Uncopyable& operator=(const Uncopyable&);
};

다음의 Uncopyable 클래스를 상속하면, 앞에 나왔던 모든 문제들은 컴파일러가 에러로 처리해준다. 이는 private으로 상속해도 되고, Uncopyable의 소멸자는 가상 소멸자일 필요가 없다. 다음 항목에 나오겠지만, 다형성을 갖춘 기본 클래스가 아니기 때문이다.

컴파일러에서 자동으로 제공하는 기능을 허용치 않으려면, 대응되는 멤버 함수를 private로 선언한 후에 구현은 하지 않은 채로 둬라.
Uncopyable과 비슷한 기본 클래스를 쓰는 것도 방법이다.

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

TimeKeeper라는 클래스를 기본 클래스로 만들고, 이를 파생시켜 다형성을 이용하는 경우가 있다고 해보자.

class TimeKeeper {
public:
	TimeKeeper();
	~TimeKeeper();
	...
};
class AtomicClock: public TimeKeeper { ... };
class WaterClock: public TimeKeeper { ... };
class WristClock: public TimeKeeper { ... };

TimeKeeper의 파생 클래스들은 TimeKeeper의 다형성을 이용하는 것이므로, 기본 클래스 포인터를 반환하는 팩토리 함수를 만들어 이용하면 좋을 것 같다.

TimeKeeper* getTimeKeeper(); // 파생된 클래스 객체를 TimeKeeper 포인터로 동적 할당해 반환

이대로 이용하게 되면, 이 함수에서 반환되는 객체는 동적할당된 객체이므로, 힙 영역에 저장이 된다. 따라서 이를 사용한 후에는 적절히 delete하는 과정이 필수적이다. 하지만 이를 그냥 delete하게 되면, 파생 클래스 객체의 소멸자는 제대로 실행되지 않는다.

C++ 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스의 소멸자가 비가상, 즉 virtual이 아니라면 프로그램 동작은 미정의 사항이라고 되어 있다. 한번 천천히 생각해보자. 다형성을 가지는 나머지 동작들은 모두 가상 함수로 선언이 되어 있을 것이다. 따라서 소멸자도 다형성을 가져야 하는데, 이것만 비가상 함수로 되어 있는 것이다. 이때 어떤 동작을 해야 하는지는 미정의(undefined behavior)되어 있다고 한다. 보통은 기본 클래스 객체만 올바르게 소멸되고, 파생 클래스 부분은 제대로 처리가 되지 않는다고 한다.

따라서 우리는 기본 클래스에게 가상 소멸자를 정의해줘야 한다. 가상 소멸자를 가진 기본 클래스는, 소멸될 때 올바르게 파생 클래스 부분도 소멸시킨다. 우리는 이제 가상 함수를 하나라도 가진 클래스라면 가상 소멸자를 쥐어줘야한다.

물론 그렇다고 모든 클래스에 가상 소멸자를 막 쥐어주는 것은 좋지 못하다. 다음의 클래스를 보자.

class Point {
public:
	Point(int xCoord, int yCoord);
	~Point();
	
private:
	int x, y;
};

다음은 int형 멤버가 두 개 있고, 가상 소멸자가 선언된 클래스 Point다. int 하나가 32비트를 차지한다고 가정하면, 이 Point의 객체는 64비트의 크기라고 생각할 수 있다. 하지만 아니다. 가상 함수의 포인터가 존재해야하기 때문이다.

가상 함수를 C++에서 구현하려면 클래스에 별도의 자료구조가 들어가야 한다. 이 자료구조는 주어진 객체에 대해 어떤 가상 함수를 호출해야 하는지 결정하는 데에 쓰인다. 보통 vptr(virtual table pointer)라는 이름으로 불리고, 이것은 가상 함수의 주소, vtbl(virtual table)을 가리킨다. 결국 Point 객체에서 가상 소멸자가 호출되려면, 그 객체의 vptr이 가리키는 vtbl에 따라 정확한 행동이 결정되는 것이다.

따라서 경우 없이 전부 가상 소멸자를 선언하는 것은 옳지 못하다. 클래스에 가상 함수가 하나라도 있는 경우에만 가상 소멸자를 선언하도록 하자.

추상 클래스를 만들고 싶은데, 딱히 쥐어줄만한 순수 가상 함수가 없을 경우에는 순수 가상 소멸자가 답이 된다. 억지로 다른 함수를 순수 가상 함수로 만들지 말고, 소멸자를 순수 가상 소멸자로 만들자. 물론 이 순수 가상 소멸자는 반드시 정의가 필요하다는 걸 기억하자.

class AWOV {
public:
	virtual ~AWOV() = 0;
};
AWOV::~AWOV() {} // 정의

소멸자는 파생 클래스 > 기본 클래스 순으로 하나씩 호출이 되기 때문에, 본문이 꼭 필요하다는 걸 기억하자.

모든 기본 클래스가 다형성을 갖도록 설계된 것은 아니다. 표준 string 타입, STL 컨테이너 타입 등은 다형성의 흔적도 없고, 소멸자가 가상인 것도 아니다. 이들을 통해 파생 클래스를 만들어 제대로 소멸되지 않는 상황을 유의하자.

다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 한다.
기본 클래스로 설계되지 않거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 한다.

항목 8: 예외가 소멸자를 떠나지 못하도록 붙들어 놓자

소멸자로부터 예외가 터져 나가는 경우를 C++ 언어가 막는 것은 아니다. 하지만 분명히 우리가 막을 필요는 있다. 다음 예제를 보자.

class Widget {
public:
	...
	~Widget() { ... }
};

void doSomething()
{
	std::vector<Widget> v;
	...
}

v가 소멸될 때, v의 원소들로 들어있는 Widget 객체들을 소멸시킬 책임은 v에게 있다. 이때 v의 Widget 원소들이 10개 있다고 가정하자. v가 소멸되어, Widget 객체들을 소멸시키다가 첫 번째 객체 소멸에서 예외가 발생되었다고 해보자. 나머지 아홉 개의 객체이라도 모두 소멸되어야 하기 때문에, v는 나머지 소멸자들을 호출할 것이다. 그런데 이 과정에서 또 예외가 발생했다고 해보자. 이런 상황에서는 활성화된 예외가 동시에 두 개나 되고, C++의 입장에서는 감당하기에 버겁다.

다른 표준 라이브러리 컨테이너나 TR1의 컨테이너, 배열을 쓰더라도 결과는 마찬가지일 것이다. 중요한 것은 C++가 예외를 내보내는 소멸자를 좋아하지 않는다는 것이다.

예외가 나올 수 있는 코드를 소멸자에 넣어야 할 사람이 우리라면 어떻게 해야할까? 다음 예제를 보자.

class DBConnection {
public:
	...
	static DBConnection create();
	
	void close();
};

class DBConn {
public:
	...
	~DBConn() { db.close(); }
private:
	DBConnection db;
};

DBConnection 클래스는 데이터베이스 연결을 나타내는 클래스이다. 이는 소멸되기 전에 꼭 close 함수를 통해 연결을 종료해야한다. DBConn 클래스는 DBConnection 객체를 담는 클래스로, 소멸자에서 DBConnection 객체의 close를 호출시켜 연결의 종료를 보장해준다.

이 상황의 close 함수에서 예외가 발생했다고 가정하자. 이 얘기는 즉 소멸자에서 예외가 발생할 수 있다는 얘기인데, 우리는 위에서 얘기했던 것처럼 이 상황을 피해야한다. 이를 해결할 방법 두 가지를 알아보자.

  • close에서 예외가 발생하면 프로그램을 바로 끝낸다.
    	DBConn::~DBConn() 
    	{
    		try { db.close(); }
    		catch (...) {
    			// 로그 작성
    			std::abort(); // 강제 종료
    		}
    	}
    괜히 소멸자에서 생긴 예외를 흘려 내보냈다가 정의되지 않은 동작까지 이를 수 있다면, 바로 끝내는 것은 나름의 장점이 있다.
  • close를 호출한 곳에서 예외를 삼켜 버린다.
    	DBConn::~DBConn() 
    	{
    		try { db.close(); }
    		catch (...) {
    			// 로그 작성
    		}
    	}
    대부분의 경우에서 예외 삼키기는 좋은 방법이 아니다. 예외에서 가장 중요한 정보인 무엇이 잘못됐는지에 대한 부분이 묻혀 버리기 때문이다.

어느 쪽을 택하든 close가 최초로 예외를 던지게 된 요인에 대해 프로그램이 할 수 있는 조치가 없기 때문에, 썩 좋은 방법은 아닌 듯 보인다.

조금 더 나은 방법을 생각해보자. DBConn에서도 close 함수를 제공해, 사용자가 직접 close에 대한 예외처리가 가능하게 만들자. 물론 close가 되지 않은 상태로 소멸한다면, 소멸자에서 close 함수를 호출시켜 최소한의 보장은 해주자.

class DBConn {
public:
	...
	void close()
	{
		db.close();
		closed = true;
	}
	
	~DBConn() 
	{
		// 사용자가 close를 호출시키지 않았다면 소멸자에서 호출
		if (!closed)
		try {
			db.close();
		}
		catch (...) {
			// 로그 작성
			...
		}
	}
	
private:
	DBConnection db;
	bool closed;
};

위의 코드는 사용자에게 직접 close를 호출할 권한을 주고, 덕분에 소멸자가 아닌 다른 곳에서 예외처리를 진행할 수 있다. 이것마저 없었다면 사용자는 예외를 대처할 기회조차 없는 것이다.

소멸자에서는 예외가 빠져나가면 안 된다. 만약 그럴 가능성이 있다면, 예외 삼키기 혹은 강제 종료를 해야한다.
어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 한다면, 해당 연산의 함수는 반드시 소멸자가 아닌 보통의 함수여야 한다.

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

항목명을 다시 보자. 객체 생성 및 소멸 과정 중에는 가상 함수를 호출하면 절대로 안된다. 생각해보자. 기본 클래스 생성자는 파생 클래스 생성자보다 앞서서 실행된다. 따라서 기본 클래스 생성자가 돌아가고 있을 시점에는, 파생 클래스 데이터 멤버는 아직 초기화 상태가 아니라는 것이다.

결국 기본 클래스의 생성자가 호출될 동안에는, 가상 함수는 절대로 파생 클래스 쪽으로 내려가지 않는다. 그 대신, 객체 자신이 기본 클래스 타입인 것처럼 동작한다.

객체가 소멸될 때는 어떨까? 아까와 똑같이 생각해보자. 파생 클래스의 소멸자가 일단 호출되고 나면, 파생 클래스만의 데이터 멤버는 정의되지 않은 값으로 가정하기 때문에 C++는 이들을 없는 것처럼 취급한다. 또한 기본 클래스 소멸자에 진입할 당시의 객체는 그저 기본 클래스 객체가 되고, 모든 C++ 기능들 역시 기본 클래스 객체의 자격으로 처리한다.

생성자나 소멸자에서 가상 함수를 호출하는 경우에는, 컴파일러에서 직접 경고 메세지를 띄워주는 경우도 있다. 하지만 생성자나 소멸자에서 호출한 비가상 함수가 다시 가상 함수를 호출하는 골치아픈 경우가 나온다면, 컴파일은 잘 되지만 실행 과정에서 결함이 발생할 것이다. 이 문제를 해결하는 방법은 별 다른 게 없다. 그저 생성자나 소멸자에서 가상 함수를 호출하는 코드를 잘 솎아내고, 이들에서 호출되는 함수가 똑같은 제약을 따르게 만드는 방법밖에는 없다.

그렇다면 우리가 정말 기본 클래스 생성자에서 다형성을 가질 수는 없을까? 여러 방법이 있지만 다음의 예제로 한 방법을 알아보자.

class Transaction {
public:
	// 객체의 묵시적 형변환을 막는 explicit 키워드
	explicit Transaction(const std::string& logInfo);
	void logTransaction(const std::string& logInfo);
	...
};

Transaction::Transaction(const std::string& logInfo)
{
	...
	logTransaction(logInfo);
}
class BuyTransaction: public Transaction {
public:
	BuyTransaction(...) : Transaction(createLogString(,,,)) {...}
private:
	static std::string createLogString(...);
};

Transaction이 기본 클래스, BuyTransaction이 파생 클래스이다. Transaction의 생성자에서 logInfo 인자를 받아 logTransaction 함수를 호출시켜준다. 여기서 파생되는 BuyTransaction의 생성자는 createLogString 함수를 통해 사용할 정보를 미리 넘겨주는 형태이다.

가상 함수를 호출하는 대신, 필요한 초기화 정보를 파생 클래스 쪽에서 기본 클래스 생성자로 올려주도록 만드는 것이다. createLogString 함수는 정적 멤버이기 때문에, 파생 클래스의 미초기화 이슈를 고려할 필요도 없다.

생성자 혹은 소멸자 안에서 가상 함수를 호출하지 말자.
가상 함수라고 해도 지금 실행 중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않는다.

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

C++의 대입 연산은 여러 개가 사슬처럼 엮일 수 있는 재미있는 성질을 갖고 있다. 또한 이 연산의 재미있는 점은 우측 연관 연산이라는 점이다. 즉,

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

이 대입 연산은,

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

이런 연산처럼 돌아가고 있다는 것이다.

이렇게 대입 연산이 사슬처럼 엮이려면 대입 연산자가 좌변 인자에 대한 참조자를 반환하도록 구현되어 있을 것이다. 이런 구현은 일종의 관례이므로, 지키는 것이 좋다.

class Widget {
public:
	...
	Widget& operator=(const Widget& rhs)
	{
		...
		return *this; // 좌변 객체(참조자) 반환
	}
	...
};

이 규약은 단순 대입형 연산자 말고도 +=, -=, *= 등의 모든 형태의 대입 연산자에서 지켜져야 한다. 또한 대입 연산자의 매개변수 타입이 일반적이지 않은 경우에도 동일한 규약을 적용해야 한다.

이 관례를 따르지 않는다고 해도 컴파일이 안 된다거나 하지는 않는다. 하지만 이 관례는 모든 기본제공 타입들, 표준 라이브러리에 속한 타입들에서도 따르고 있다는 점을 기억하자.

대입 연산자는 *this의 참조자를 반환하도록 만들자.

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

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

class Widget { ... };
Widget w;
...
w = w; // 자기대입

예를 들어,

a[i] = a[j]; // i와 j가 같으면 자기대입
*px = *py; // px와 py가 가리키는 대상이 같으면 자기대입

등의 코드도 특정 조건이 갖추어 지면 자기대입이 된다. 명확하지 않은 이러한 자기대입이 생기는 이유는 여러 곳에서 하나의 객체를 참조하는 상태, 즉 중복참조 때문이다.

같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고 동작하는 코드를 작성할 때는, 같은 객체가 사용될 가능성을 고려하는 것이 바람직하다.

이럴 때 가장 조심해야 하는 것이 대입 연산자다. 이 연산자는 우리가 신경쓰지 않아도 자기대입에 대해 안전하게 동작해야 한다. 자기 참조의 가능성이 있는 코드를 보자.

Widget& Widget::operator=(const Widget& rhs)
{
	delete pb;
	pb = new Bitmap(*rhs.pb);
	
	return *this;
}

여기서 생기는 문제는 operator= 내부에서 this와 rhs가 같은 객체일 가능성이 있다는 것이다. 이 둘이 같은 객체면, delete 연산자가 this 객체의 비트맵과 rhs의 객체에 동시에 적용된다.

전통적인 방법은 operator=의 첫머리에서 일치성 검사를 통해 자기대입을 점검하는 것이다.

Widget& Widget::operator=(const Widget& rhs)
{
	if (this == &rhs) return *this;

	delete pb;
	pb = new Bitmap(*rhs.pb);
	
	return *this;
}

예외 안정성에 대해서는 이번 것도 문젯거리를 안고 있다. ‘new Bitmap’ 부분에서 예외가 터지게 되면, Widget 객체는 결국 삭제된 Bitmap을 가리키는 포인터를 껴안고 홀로 남고 만다. 이런 포인터는 delete 연산자를 안전하게 적용할 수도 없고, 안전하게 읽는 것조차 불가능하다.

pb가 가리키는 객체를 복사 후 삭제하는 방식으로 해결해보자.

Widget& Widget::operator=(const Widget& rhs)
{
	Bitmap *pOrig = pb; // 원본 저장
	pb = new Bitmap(*rhs.pb); // rhs의 pb 사본으로 pb에 저장
	delete pOrig; // 원본은 delete
	
	return *this;
}

이 코드는 이제 원본 비트맵을 복사해 놓고, 복사해 놓은 사본을 포인터가 가리키게 만든 후, 원본을 삭제하는 순서로 실행되기 때문이다. 이제 ‘new Bitmap’ 부분에서 예외가 발생하더라도 pb는 변경되지 않은 상태가 유지된다.

효율이 너무 신경 쓰인 나머지, 일치성 테스트를 함수 앞단에 다시 붙여 놓고 싶은 사람도 있을 것이다. 하지만, 프로그램에서 자기대입이 얼마나 자주 일어날까? 일치성 테스트는 공짜가 아니다. 일치성 검사 코드가 들어가면 그만큼 코드가 코지고, 처리 흐름에 분기를 만들게 되므로 실행 시간 속력이 줄어들 수 있다. 추가로 CPU 명령어 선행인출, 캐시, 파이프라이닝 등의 효과도 떨어질 수 있다.

  • CPU 명령어 선행인출: CPU가 프로그램에서 필요한 명령어들을 미리 읽어 들이는 방식. 분기 예측이 실패하면, 미리 읽어들인 명령어들이 잘못된 경우가 되어 효율이 떨어진다.
  • 캐시: 자주 사용하는 데이터를 메모리에서 빨리 가져오기 위한 고속 메모리. 사용할 데이터가 캐시에 저장되어 있지 않는 상황(캐시 미스)이 발생하면, 캐시의 효과가 줄어든다.
  • 파이프라이닝: CPU가 명령어를 여러 단계(인출→해석→실행→저장)로 나누어 처리할 때, 각 단계를 병렬로 처리해 성능을 높이는 기법. 분기 예측 오류로 인해 파이프라인을 다시 시작해야 한다면 효과가 줄어든다.

다른 방법이 있다. ‘복사 후 맞바꾸기’(Copy And Swap, CAS)라고 알려진 기법이다. 이 기법은 예외 안전성과 아주 밀접한 관계에 있다.

class Widget {
	...
	void swap(Widget& rhs);
	...
};
Widget& Widget::operator=(const Widget& rhs)
{
	Widget temp(rhs);
	swap(temp);
	return *this;
}

이 방법은 C++가 가진 특징을 이용해 다르게 구현할 수도 있다.

  1. 클래스의 복사 대입 연산자는 인자를 값으로 취하도록 선언하는 것이 가능하다.
  2. 값에 의한 전달을 수행하면 전달된 대상의 사본이 생긴다.
Widget& Widget::operator=(Widget& rhs)
{
	swap(rhs);
	
	return *this;
}

이 코드는, 명확성이 다소 떨어질 수 있다는 걱정이 존재한다. 하지만 객체를 복사하는 코드가 매개변수의 생성자로 옮겨졌기 때문에, 컴파일러가 더 효율적인 코드를 생성할 수 있는 여지가 생겼다.

swap을 활용하는 경우에는, private이나 protected 데이터 멤버에 접근해야하고 전역 함수 같이 사용하기 위해 friend 함수로 선언을 많이 한다고 한다.

https://www.geeksforgeeks.org/c-program-to-swap-two-members-using-friend-function/

operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 하자. 원본과 복사대상의 주소를 비교하거나, 문장의 순서를 조정하거나, CAS 기법을 써도 된다.
두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 같은 객체인 경우에 정확하게 동작하는지 확인해보자.

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

설계가 잘 된 객체 지향의 클래스를 보면, 객체를 복사하는 함수가 딱 둘만 있다. 복사 생성자, 복사 대입 연산자이다. 이 둘을 통틀어 객체 복사 함수라고 부른다.

컴파일러가 생성한 복사 함수는 비록 저절로 만들어졌지만 동작은 기본적인 요구에 아주 충실하다. 객체 복사 함수를 직접 선언한다는 것은, 컴파일러가 만든 기본 동작에 뭔가 마음에 안 드는 것이 있다는 이야기이다. 컴파일러는 이제 직접 구현한 복사 함수가 거의 틀렸을 경우에도 별 경고를 주지 않는다. 다음의 예제를 보자.

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

class Customer {
public:
	...
	Customer(const Customer& rhs);
	Customer& operator=(const Customer& rhs);
	...
private:
	std::string name;
};

Customer::Customer(const Customer& rhs) : name(rhs.name)
{
	logCall("Customer copy constructor");
}

Customer& Customer::operator=(const Customer& rhs)
{
	logCall("Customer copy assignment operator");
	
	name = rhs.name;
	
	return *this;
}

이 코드는 별 문제가 없어보인다. 여기서 데이터 멤버를 추가해보자.

class Date { ... };

class Customer {
public:
	...
private:
	std::string name;
	Date lastTransaction;
};

Date 객체를 데이터 멤버로 추가했다. 이제 복사 함수 동작은 완전 복사가 아니라 부분 복사가 된다. 이런 상황이 되더라도, 대부분의 컴파일러는 경고하지 않는다.

결국 우리가 할 일은, 데이터 멤버가 추가되었다면 추가한 데이터 멤버를 처리하도록 복사 함수를 다시 작성하는 것밖에는 없다.

이 문제가 더 심해지는 경우는, 클래스 상속의 경우다.

class PriorityCustomer: public Custome {
public:
	...
	PriorityCustomer(const PriorityCustomer& rhs);
	PriorityCustomer& operator=(const PriorityCustomer& rhs);
	...
private:
	int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : priority(rhs.priority)
{
	logCall("PriorityCustomer copy constructor");
}

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
	logCall("PriorityCustomer copy assignment operator");
	
	priority = rhs.priority;
	
	return *this;
}

PriorityCustomer 클래스의 복사 함수는 언뜻 보기엔 모든 것을 복사하고 있는 것처럼 보인다. 하지만 Customer로부터 상속한 데이터 멤버들은 복사가 되지 않고 있다. PriorityCustomer 객체의 Customer 부분은 기본 생성자에 의해 초기화된다. 이 생성자는 당연히 name, lastTransaction에 대해 기본적인 초기화만 수행한다. 결국 복사 대입 연산자는 기본 클래스의 데이터 멤버를 건드릴 시도조차 하지 않는다.

물론 기본 클래스의 데이터 멤버는 private 멤버일 가능성이 아주 높다. 따라서 이들을 직접 건드리기 보다는, 파생 클래스의 복사 함수에서 기본 클래스의 복사 함수를 호출하도록 만들면 된다.

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs) : Customer(rhs), priority(rhs.priority)
{
	logCall("PriorityCustomer copy constructor");
}

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
	logCall("PriorityCustomer copy assignment operator");
	
	Customer::operator=(rhs);
	priority = rhs.priority;
	
	return *this;
}

객체의 복사 함수를 작성할 때는 다음 두 가지를 꼭 확인하자.

  1. 해당 클래스의 데이터 멤버를 모두 복사
  2. 이 클래스가 상속한 기본 클래스의 복사 함수도 호출

혹시나 복사 생성자와 복사 대입 연산자의 본문이 비슷한 경우가 있어 한 쪽에서 다른 쪽을 호출하는 식은 안될까 생각할 수 있다. 하지만 복사 대입 연산자에서 복사 생성자를 호출하는 것부터 말이 안되는 발상이다. 이미 만들어져 버젓이 존재하는 객체를 ‘생성’하고 있기 떄문이다.

반대는 어떨까? 복사 생성자는 새로 만들어진 객체를 초기화하는 것이지만, 대입 연산자는 이미 초기화된 객체에 값만 넘겨주는 것이다. 생성 중인 객체에 대입하라니, 이미 말이 안된다.

결국 이렇게 복사 생성자와 복사 대입 연산자의 코드 본문이 비슷하게 나온다는 느낌이 들면, 겹치는 부분을 별도의 멤버 함수로 분리해 이 함수를 호출하게 만들자.

객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 한다.
클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대 하지 말자. 대신 공통된 동작을 제3의 함수에 분리해 이것을 호출하도록 하자.

profile
마포고개발짱

0개의 댓글