Effective C++ : 2
Updated:
Chapter2. 생성자, 소멸자 및 대입 연산자
우리들이 만드는 거의 모든 C++ 클래스에 한 개 이상 꼭 들어 있는 것들이 생성자/소멸자/대입 연산자이다. 이들은 C++ 프로그램에 있어서 일용할 양식과 같이 중요한 함수이다. 첫 번째 것은 새로운 객체를 메모리에 만드는 데 필요한 과정을 제어하고 객체의 초기화를 맡는 함수이고, 두 번째 것은 객체를 없앰과 동시에 그 객체가 메모리에서 적절히 사라질 수 있도록 하는 과정을 제어하는 함수이며, 마지막 것은 기존의 객체에 다른 객체의 값을 줄 때 사용하는 함수이다. 따라서 클래스를 제대로 쓰려면 이들이 우선 우뚝 서 있어야 함은 분명하고도 중요한 요구사항이다.
항목 5: C++이 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자
클래스가 비어 있지만 비어 있는게 아닌 때가 있다. 일단은 C++(컴파일러)가 빈 클래스를 훑고 지나갈 때라고 말씀드릴 수 있겠다. C++의 어떤 멤버 함수는 여러분이 클래스 안에 직접 선언해 넣지 않으면 컴파일러가 저절로 선언해 주도록 되어 있다. 바로 복사 생성자(copy constructor), 복사 대입 연산자(copy assignment operator), 그리고 소멸자(destructor)이다. 좀 더 자세히 말하면 이 때 컴파일러가 만드는 함수의 형태는 모두 기본형이다. 게다가, 생성자조차도 선언되어 있지 않으면 역시 컴파일러가 기본 생성자를 선언해 놓는다. 이들은 모두 public 멤버이며 inline 함수이다.
그러므로 만약 다음과 같이 함수를 선언했다면,
class Empty();
다음과 같이 쓴 것과 근본적으로 같다는 말이다.
class Empty {
public:
Empty() { . . . } // 기본 생성자
Empty(const Empty& res) { . . . } // 복사 생성자
~Empty() { . . . } // 소멸자: 가상 함수 여부에 대해서는 아래에서 더 자세히 설명하겠다.
Empty& operator=(const Empty& res) { . . .} // 복사 대입 연산자
};
이들은 꼭 필요하다고 컴파일러가 판단할 때만 만들어지도록 되어 있다. 이들이 만들어지는 조건을 만족하는 코드는 다음과 같다.
Empty e1; // 기본 생성자, 그리고
// 소멸자
Empty e2(e1); // 복사 생성자
e2 = e1; // 복사 대입자
- 이것만은 잊지 말자! 1) 컴파일러는 경우에 따라 클래스에 대해 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓을 수 있다.
항목 6: 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자
해결의 열쇠는 다음과 같다. 선언을 한 후에 public 멤버로 두지 말고, 컴파일러가 만들어낼 함수(복사 생성자, 복사 대입 연산자 등)를 private 멤버로 선언하는 것이다. 일단 클래스 멤버가 명시적으로 선언되기 때문에, 컴파일러는 자신의 기본 버전을 만들 수 없게 된다. 게다가 이 함수들이 비공개(private)의 접근성을 가지므로, 외부로부터의 호출을 차단할 수 있다.
여기까지 90점이다. 10점이 모자란다. private 멤버 함수는 그 클래스의 멤버 함수 및 프렌드(friend) 함수가 호출할 수 있다는 점이 여전히 허점이다. 이것까지 막으려면, 그러니까 ‘정의(define)’를 안 해 버리는 기지를 발휘해 보면 어떨까? 클래스에 선언만 한 후에 정의를 하지 않으면 된다.
class HomeForSale {
public:
. . .
private:
. . .
HomeForSale(const HomeForSales&); // 선언만 달랑있다.
HomeForSale& operator=(const HomeForSale&);
};
이렇게 HomeForSale 클래스를 정의하면 HomeForSale 객체를 복사하려고 시도하면 컴파일러가 강한 백태클을 걸 것이고, 깜박하고 멤버 함수 혹은 프렌드 함수 안에서 그렇게 하면 링커가 싫어할 것이다.
이를 링크 시점 에러에서 컴파일 시점 에러로 옮길 수도 있다. 복사 생성자와 복사 대입 연산자를 private로 선언하되, 이것을 HomeForSale 자체에 넣지 말고 별도의 기본 클래스에 넣고 이것으로부터 HomeForSale을 파생시키는 것이다. 그리고 그 별도의 기본 클래스는 복사 방지만 맡는다는 특별한 의미를 부여한다. 소개가 거창했지만 이 기본 클래스는 사실 단순 그 자체이다.
class Uncopyable {
protected: // 파생된 객체에 대해서
Uncopyable() { } // 생성과 소멸을
~Uncopyable() { } // 허용한다
private:
Uncopyable(const Uncopyable&); // 하지만 복사는 방지한다
Uncopyable& operator=(const Uncopyable&);
};
- 이것만은 잊지말자!
- 컴파일러에서 자동으로 제공하는 기능을 허용치 않으려면, 대응되는 멤버 함수를 private로 선언한 후에 구현은 하지 않은 채로 두어라. Uncopyable과 비슷한 기본 클래스를 쓰는 것도 한 방법이다.
항목 7: 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자
다양한 문제가 있다(책 참조). 이를 해결하기 위해서는 기본 클래스의 소멸자 앞에 virtual 하나를 붙여주면 객체 전부가 소멸된다. 파생 클래스 부분까지 몽땅 말이다.
class TimeKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
. . .
};
TimeKeeper *ptk = GetTimeKeeper();
. . .
delete ptk; // 제대로 작동한다
- 이것만은 잊지 말자!
- 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 한다. 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 한다.
- 기본 클래스로 설계되지 않았거나, 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 한다.
항목 8: 예외가 소멸자를 떠나지 못하도록 붙들어 놓자
소멸자에서 예외가 발생했을 때 걱정거리를 피하는 방법은 두 가지이다.
- 소멸자에서 예외가 발생하면 프로그램을 바로 끝낸다. 대개 absort를 호출한다.
DBConn::~DBConn()
{
try { db.close(); }
catch ( . . . ) {
close 호출이 실패했다는 로그를 작성한다
std::absort();
}
}
객체 소멸이 진행되다가 에러가 발생한 후에 프로그램 실행을 계속할 수 없는 상황이라면 꽤 괜찮은 선택이다. 소멸자에서 생긴 예외를 그대로 흘려 내보냈다가 정의되지 않은 동작에까지 이를 수 있다면, 그런 불상사를 막는다는 의미에서 어느 정도 장점도 있다.
- 소멸자를 호출한 곳에서 일어난 예외를 삼켜 버린다.
DBConn::~DBConn()
{
try { db.close(); }
catch ( . . . ) {
close 호출이 실패했다는 로그를 작성한다
}
}
대부분의 경우에서는 그리 좋은 발상이 아니다. 중요한 정보가 묻혀 버리기 때문이다(무엇이 잘못되었는지를 알려 주는 정보). 단, ‘예외 삼키기’를 선택한 것이 제대로 빛을 보려면, 발생한 예외를 그냥 무시한 뒤라도 프로그램이 신뢰성 있게 실행을 지속할 수 있어야 한다. 써놓긴 했지만 어느 쪽을 택하든 특별히 좋을 건 없어 보인다(둘 다 문제점이 있기 때문).
- 이것만은 잊지 말자!
- 소멸자에서는 예외가 빠져나가면 안 된다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 한다.
- 어떤 클래스 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 함수)이어야 한다.
항목 9: 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자
생성자 혹은 소멸자 안에서 가상 함수를 호출하면 안 된다. 가상 함수라고 해도, 지금 실행 중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않기 때문이다.
항목 10: 대입 연산자는 *this의 참조자를 반환하게 하자
C++의 대입 연산은 여러 개가 사슬처럼 엮일 수 있는 재미있는 성질을 가지고 있다.
int x, y, z;
x = y = z = 15; // 대입이 사슬처럼 이어진다.
대입 연산이 가진 또 하나의 재미있는 특성은 바로 우측 연관(right-associative) 연산이라는 점이다. 즉, 위의 대입 연산 사슬은 다음과 같이 분석된다.
x = (y = (z = 15));
이렇게 대입 연산이 사슬처럼 엮이려면 대입 연산자가 좌변인자에 대한 참조자를 반환하도록 구현되어 있을 것이다. 이런 구현은 일종의 관례(convention)인데, 여러분이 만드는 클래스에 대입 연산자가 혹시 들어간다면 이 관례를 지키는 것이 좋다.
class Widget {
public:
. . .
Widget& operator=(const Widget& rhs)
{
. . .
return *this;
}
. . .
};
- 이것만은 잊지 말자!
- 대입 연산자는 *this의 참조자를 반환하도록 만들어라
항목 11: operator=에서는 자기대입에 대한 처리가 빠지지 않도록 하자
자기대입(self assignment)이란, 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다.
a[i] = a[j]; // 자기대입의 가능성을 품은 문장
*px = *py; // 자기대입의 가능성을 품은 문장
이 문장들의 경우, i = j, px 및 py가 가리키는 대상이 같으면 자기대입이 된다. 언뜻 보기에 명확하지 않은 이러한 자기대입이 생기는 이유는 여러 곳에서 하나의 객체를 참조하는 상태, 즉 중복참조(aliasing)라고 불리는 것 때문이다.
같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고 동작하는 코드를 작성할 때는 같은 객체가 사용 될 가능성을 고려하는 것이 일반적으로 바람직한 자세가 되겠다.
예시를 하나 들어보자. 동적 할당된 비트맵을 가리키는 원시 포인터를 데이터 멤버로 갖는 클래스를 하나 만들었다고 가정해 보자.
class Bitmap { . . . };
class Widget {
. . .
priavte:
Bitmap *pb; // 힙에 할당한 객체를 가리키는 포인터
};
이를 토대로 겉보기엔 멀쩡해 보이지만 자기 참조의 가능성이 있는 위험천만한 코드를 작성해보겠다.
Widget& Widget::operator= (const Widget& rhs)
{
delete pb; // 현재의 비트맵 사용을 중지한다
pb = new Bitmap(*rhs.pb); // 이제 rhs의 비트맵을 사용한다
return *this;
}
여기서 찾을 수 있는 자기 참조 문제는 operator= 내부에서 *this(대입되는 대상)와 rhs가 같은 객체일 가능성이 있다는 것이다. 이 둘이 같은 객체일 경우 delete 연산자가 *this가 비트맵에만 적용되는 것이 아니라 rhs의 객체까지 적용되어 버린다. 그러므로 이 함수가 끝나는 시점이 되면 해당 Widget 객체는 자신의 포인터 멤버를 통해 물고 있던 객체가 어처구니없게도 삭제된 상태가 되는 불상사를 당하게 된다.
이러한 에러에 대한 대책은 예전부터 있었다. 전통적인 방법은 operator=의 첫머리에서 일치성 검사(identity test)를 통해 자기대입을 점검하는 것이다.
Widget& Widget::operator=(const Widget& rhs)
{
if (this == &rhs) return *this; // 객체가 같은지, 즉 자기대입인지 검사한다
// 자기대입이면 아무것도 안 한다
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
하지만 이러한 방식에도 문제가 있다. ‘new Bitmap’ 표현식에서 예외가 터지게 되면(동적 할당에 필요한 메모리가 부족하다든지, Bitmap 클래스 복사 생성자에서 예외를 던진다든지 해서), Widget 객체는 결국 삭제된 Bitmap을 가리키는 포인터를 껴안고 홀로 남고 만다. 이런 포인터가 멋대로 떠다니게 놔두면 제대로 쥐약이다. 이런 포인터는 delete 연산자를 안전하게 적용할 수도 없고, 안전하게 읽는 것조차 불가능하다.
이번 항목에서는 “많은 경우에 문장 순서를 세심하게 바꾸는 것만으로 예외에 안전한(동시에 자기대입에 안전한) 코드가 만들어진다”라는 법칙 한 가지를 여기서 써먹어 보도록 하겠다. 지금의 코드는 pb를 무턱대고 삭제하지 말고 이 포인터가 가리키는 객체를 복사한 직후에 삭제하면 깔끔히 해결될 것 같다.
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap *pOrig = pb; // 원래의 pb를 어딘가에 기억해 둔다
pb = new Bitmap(*rhs.pb); // 다음, pb가 *pb의 사본을 가리키게 만든다
delete pOrig; // 원래의 pb를 삭제한다
return *this;
}
이 코드는 이제 예외에 안전하다. ‘new Bitmap’ 부분에서 예외가 발생하더라도 pb는 변경되지 않은 상태가 유지되기 때문이다. 게다가 일치성 검사 같은 것이 없음에도 불구하고 이 코드는 자기대입 현상을 완벽히 처리하고 있다.
다음 방법은 ‘복사 후 맞바꾸기(copy and swap)’이라고 알려진 기법이다.
class Widget {
. . .
void swap(Widget& rhs); // *this의 데이터 및 rhs의 데이터를 맞바꾼다
. . .
};
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs); // rhs의 데이터에 대해 사본을 하나 만든다
swap(temp); // *this의 데이터를 그 사본의 것과 바꾼다
return *this;
}
해당 코드는 C++의 특징을 활용해서 조금 다르게 구현할 수 있다.
- 클래스의 복사 대입 연산자는 인자를 값으로 취하도록 선언하는 것이 가능하다는 점
- 값에 의한 전달을 수행하면 전달된 대상의 ‘사본’이 생긴다는 점을 이용하는 것이다.
Widget& Widget::operator=(Widget rhs) // rhs는 넘어온 원래 객체의 사본이다 (값에 의한 전달)
{
swap(rhs); // *this의 데이터를 이 사본의 데이터와 맞바꾼다
return *this;
}
- 이것만은 잊지 말자!
- operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만들자. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 된다.
- 두 개 이상의 객체에 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해보자.
항목 12: 객체의 모든 부분을 빠짐없이 복사하자
객체의 안쪽 부분을 캡슐화한 객체 지향 시스템 중 설계가 잘 된 것들을 보면, 객체를 복사하는 함수가 딱 둘만 있는 것을 알 수 있다. 복사 생성자와 복사 대입 연산자라고, 성격에 따라 이름도 적절히 지어져 있다. 이 둘을 통틀어 객체 ‘복사 함수(copying function)’라고 부른다.
객체 복사 함수를 선언한다는 것은, 컴파일러가 만든 녀석의 기본 동작에 뭔가 마음에 안 드는 것이 있다는 말이다. 이때 컴파일러는 여러분이 구현한 복사 함수가 거의 확실히 틀렸을 경우에 입을 다물어 버린다.
- 이것만은 잊지 말자!
- 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 한다.
- 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 말아라. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결하자.
Leave a comment