[Effective C++] 2. 생성자, 소멸자 및 대입 연산자 [2/2]
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
항목 7 : 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자
시간 기록을 유지하는 방법은 활용에 따라 무궁무진하다。 그래서인지 TimeKeeper정도의 이름을 가진 클래스를 기본 클래스로 만들어 놓은 후에 적절한 용도에 따라 이것을 파생시키도록 설계하면 딱 알맞을 것 같다.
1
2
3
4
5
6
7
8
9
10
11
class TimeKeeper
{
public:
TimeKeeper();
~TimeKeeper();
...
};
class AtomicClock : public TimeKeeper { ... };
class WaterClock : public TimeKeeper { ... };
class WristClock : public TimeKeeper { ... };
이 클래스의 혜택을 받는 사용자들은 시간 정보에 접근하고 싶어 한다. 시간 계산이 어떻게 되는지에 대해서 신경 쓰지 않으면서 말이다. 그렇기에 어떤 시간기록 객체에 대한 포인터를 손에 넣는 용도로 팩토리 함수(factory function, 새로 생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수) 를 만들어 두면 딱 좋을 것 같다.
1
TimeKeeper* getTimerKeeper();
팩토리 함수의 기본 규약을 그대로 따라간다면 getTimerKeeper 함수에서 반환되는 객체는 힙에 있게 되므로, 결국 메모리 및 기타 자원의 누출을 막기 위해 해당 객체를 적절히 삭제해야 한다.
1
2
3
TimeKeeper* ptk = getTimerKeeper();
...
delete ptk;
항목 13을 보면 객체 삭제를 사용자에게 전가하는 것은 에러 발생에 노출될 소지가 있다는 이야기가 있고, 항목 18에서는 팩토리 함수의 인터페이스를 수정하면 흔히 발생할 수 있는 사용자 에러 방지할 수 있다는 이야기도 나오지만, 어쨌든 그런 이야기는 여기선 뒤로 젖혀두자., 이번 항목에선 위의 코드에 숨어 있는 더 근본적인 약점을 다루고 또 해결할거다.
문제는 getTimerKeeper 함수가 반환하는 포인터가 파생 클래스 객체에 대한 포인터라는 점과 이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스 포인터를 통해 삭제된다는 점, 그리고 결정적으로 기본 클래스에 들어 있는 소멸자가 비가상 소멸자라는 점이다.
C++의 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 들어 있으면 프로그램 동작은 미정의 사항이라고 되어 있다. 대개 그 객체의 파생 클래스 부분이 소멸되지 않게 된다. 정리하면, getTimerKeeper 함수에서 포인털르 통해 날아오는 AtomicClock 객체는 기본 클래스 포인터를 통해 삭제 될 때 AtomicClock 부분이 정리되지 않을 뿐 아니라, AtomicClock의 소멸자도 실행되지 않는다. 그러나 기본 클래스 부분은 소멸 과정이 제대로 끝나므로 결국 반쪽자리 부분 소멸 객체의 신세로 전락하게 된다.
이 문제를 없애는 방법은 지각히 간단하다. 기본 클래스에게 가상 소멸자를 주면 된다. 이젠 파생 클래스 객체를 기본 클래스 포인터로 삭제할 때 우리가 원하는 쪽으로 동작하게 될 것이다. 기본 클래스의 소멸자 앞에 virtual 하나 붙여 줬을 뿐인데 객체 전부가 소멸된다.
1
2
3
4
5
6
7
8
9
10
11
class TimeKeeper
{
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
TimeKeeper* ptk = getTimerKeeper();
...
delete ptk; // 이제 제대로 동작한다.
TimerKeeper 비슷한 기본 클래스에는 대개 소멸자 외에도 가상 멤버 함수들이 들어 있게 마련이다. 파생 클래스를 구현할 때 해당 함수를 역할에 따라 맞추는 작업을 허용한다는 의미이다. 예를 들어 TimeKeeper 클래스는 현재 시각을 알려주는 getCurrentTime 함수를 가상 함수로 가질 수 있다. 이 함수는 여러 파생 클래스에서 다른 의미로 구현될 것이다. 어쨌든, 가상 함수를 하나라도 가진 클래스는 가상 소멸자를 가져야 하는 게 대부분 맞다.
가상 소멸자를 갖고 있지 않은 클래스를 만나면 ‘이 클래스는 기본 클래스로 쓰이지 않을 것이다’라고 생각하면 된다. 기본 클래스로 의도하지 않은 클래스에 대해 소멸자를 가상으로 선언하는 것은 좋지 않은 자세이다.
예시를 보자.
1
2
3
4
5
6
7
8
class Point
{
public:
Point(int xCoord, int yCoord);
~Point();
private:
int x, y;
};
int가 32비트를 차지한다고 가정하면, 이 Point 객체는 64비트 레지스터에 딱 맞게 들어갈 수 있을 것이다. 그러나 C나 포트란 등의 다른 언어로 작성된 함수에 넘길 일이 생길 때도 64비트 크기의 자료로 넘어갈 것이다. 그런데 Point 클래스의 소멸자가 가상 소멸자로 만들어지는 순간, 사정이 변한다.
가상 함수를 C++에서 구현하려면 클래스에 별도의 자료구조가 하나 들어가야 한다. 이 자료구조는 프로그램 실행 중에 주어진 객체에 대해 어떤 가상 함수를 호출해야 하는지를 결정하는 데 쓰이는 정보인데, 실제로는 포인터의 형태를 취하는 것이 대부분이고, 대개 vptr(가상 함수 테이블 포인터(virtual table pointer))이라는 이름으로 불린다. vptr은 가상 함수의 주소, 즉 포인터들의 배열을 가리키고 있으며 가상 함수 테이블 포인터의 배열은 vtbl(가상 함수 테이블(virtual table))이라고 불린다. 가상 함수를 하나라도 갖고 있는 클래스는 반드시 그와 관련된 vtbl을 갖고 있다. 어떤 객체에 대해 어떤 가상 함수가 호출되려고 하면, 호출되는 실제 함수는 그 객체의 vptr이 가리키는 vtbl에 따라 결정된다. vtbl에 있는 함수 포인터들 중 적절한 것이 연결되는 것이다.
중요한 것은 Point 클래스에 가상 함수가 들어가게 되면 Point 타입 객체의 크기가 커진다는 것이다. 프로그램 실행환경이 32비트 아키텍처라면, 크기가 64비트(int 두 개)에서 96비트(int 두 개에 vptr 하나)로 커진다. 64비트 아키텍처에서는 64비트에서 128비트로 커질 수 있다. 64비트 시스템에서 포인터 크기는 64비트이기 때문이다. Point 객체는 가상 함수 테이블 포인터가 하나 추가됐을 뿐인데 크기가 무려 50%에서 100%까지 커져 버린다. 이젠 64비트 레지스터에 들어가긴 글렀다. 게다가 C 등의 다른 언어로 선언된 동일한 자료구조와의 호환성이 없어진다. 왜냐하면 다른 언어로 Point와 겉보기가 똑같은 데이터 배치를 써서 선언했다고 해도 vptr만은 어떻게 만들 수 없기 때문이다. 결국, 다른 언어로 작성된 함수에 Point 객체를 전달하고 또 그 함수로부터 전달받을 수 있게 하려면 vptr 부분을 어떻게든 보충해 주어야 하는데, 이 부분부터는 구현환경에 따라 세부사항이 달라지는 문제이기 떄문에 이식성에 대한 기대는 접는게 좋다.
정리하면, 어느 경우를 막론하고 소멸자를 전부 virtual로 선언하는 일은 virtual로 절대 선언하지 않는 것만큼이나 편찮은 마인드이다. 가상 소멸자를 선언하는 것은 그 클래스에 가상 함수가 하나라도 들어 있을 경우에만 한정하자.
가상 함수가 전혀 없는데도 비가상 소멸자 때문에 뒤통수를 맞는 경우도 있다. 한 예가 표준 string 타입이다. 이 타입은 가상 함수를 갖고 있지 않지만, 전후 사정 무시하고 이 타입을 기본 클래스로 잡아버리는 일부 프로그래머들이 있따. 아래 예시처럼 말이다.
1
2
3
4
5
class SpecialString : public std::string
{
// std::string은 가상 소멸자가 없기에 말도 안되는 아이디어이다.
...
};
이것을 사용한 응용프로그램 어딘가에서 SpecialString의 포인터를 string의 포인터로 어떻게든 변환한 후에 그 string 포인터에 delete를 적용하면 그 순간부터 미정의 동작이 일어날 수 있다. 실질적으로는 SpecialString 부분에 있는 자원이 누출된다.
이 현상은 가상 소멸자가 없는 클래스이면 어떤 것에든 전부 적용된다. 그런데 가상 소멸자가 없는 클래스는 우리 주변에 아주 가까이 있다. STL 컨테이너 타입 전부가 여기에 속한다. 행여나 비가상 소멸자를 가진 표준 컨테이너 등의 클래스를 써서 쓸모 있는 나만의 클래스를 만들고 싶었던 분들은 자제하자.
경우에 따라서는 순수(pure) 가상 소멸자를 두면 편리하게 쓸 수도 있다. 알겠지만, 순수 가상 함수는 해당 클래스를 추상 클래스(그 자체로는 인스턴스를 못만드는)로 만들어 버린다. 하지만 어떤 클래스가 추상 클래스였으면 좋곘는데 마땅히 넣을 만한 순수 가상 함수가 없을 떄도 종종 생기게 마련이다. 이럴 때 대체 어떻게 해야 할까? 추상 클래스는 본래 기본 클래스로 쓰일 목적으로 만들어진 것이고, 기본 클래스로 쓰이려는 클래스는 가상 소멸자를 가져야 한다. 한편 순수 가상 함수가 있으면 바로 추상 클래스가 된다. 종합하면 답이 간단하게 나온다. 추상 클래스로 만들고 싶은 클래스에 순수 가상 소멸자를 선언하는 것이다.
1
2
3
4
5
class AWOV // AWOV = Abstract w/o Virtuals
{
public:
virtual ~AWOV() = 0; // 순수 가상 소멸자
};
AWOV 클래스는 순수 가상 함수를 갖고 있으므로, 우선 추상 클래스이다. 동시에 이 순수 가상 함수가 가상 소멸자이므로, 앞에서 말한 소멸자 호출 문제로 고민할 필요가 없다. 그런데 예상외의 복병이 하나 있으니, 이 순수 가상 소멸자의 정의를 두지 않으면 안 된다는 것이다.
1
AWOV::~AWOV() {} // 순수 가상 소멸자 정의
소멸자가 동작하는 순서는 이렇다. 상속 계통 구조에서 가장 말단에 있는 파생 클래스의 소멸자가 먼저 호출되는 것을 시작으로, 기본 클래스 쪽으로 거쳐 올라가면서 각 기본 클래스의 소멸자가 하나씩 호출된다. 컴파일러는 ~AWOV의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것이므로, 잊지 말고 이 함수의 본문을 준비해 두어야 하는 것이다. 이 부분을 잊으면 링커 에러가 나게 된다.
그리고 기본 클래스의 손에 가상 소멸자를 쥐어 주자는 규칙은 다형성을 가진 기본 클래스, 그러니까 기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된 기본 클래스에만 적용된다는 사실을 알아가자. 이번 항목에서 본 TimeKeeper 클래스가 이것에 속한다. AtomicClock, WaterClock 객체를 보면 TimeKeeper 포인터만 가지고도 이것들을 조작할 수 있을 거라는 생각을 가지게 하기 때문이다.
모든 기본 클래스가 다형성을 갖도록 설계된 것은 아니다. 앞에서 살짝 언급했듯이 표준 string 타입도 그렇고, STL 컨테이너 타입은 기본 클래스는 커녕 다형성의 흔적조차 볼 수 없다. 한편, 기본 클래스로는 쓰일 수 있지만 다형성은 갖지 않도록 설계된 클래스도 있는데, 이런 클래스는 기본 클래스의 인터페이스를 통한 파생 클래스 객체의 조작이 허용되지 않는다. 이들에게서 가상 소멸자를 볼 수 없는 것은 바로 이런 이유 때문이다.
다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 한다. 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 한다.
기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 한다.
항목 8 : 예외가 소멸자를 떠나지 못하도록 붙들어 놓자
소멸자로부터 예외가 터져 나가는 경우를 C++ 언어에서 막는 것은 아니지만, 실제 상황을 들춰보면 확실히 우리가 막을 수밖에 없는 것 같다. 납득할 만한 명분도 있고 말이다. 예시를 보자.
1
2
3
4
5
6
7
8
9
10
11
class Widget
{
public:
~Widget() { ... } // 이 함수로부터 예외가 발생된다고 가정하자.
};
void doSomething()
{
std::vector<Widget> v;
...
} // v는 여기서 자동으로 소멸
벡터 v가 소멸될 때, 자신이 거느리고 있는 Widget들 전부를 소멸시킬 책임은 바로 이 벡터에게 있을 것이다. v에 들어 있는 Widget이 열 개인데, 첫 번째 것을 소멸시키는 도중에 예외가 발생되었다고 가정하자. 나머지 아홉 개는 여전히 소멸되어야 하므로(그렇지 않으면 이들이 가지고 있을지 모르는 자원이 누출된다), v는 이들에 대해 소멸자를 호출해야 할 것이다. 그런데 이 과정에서 문제가 또 터졌다고 가정하자. 두 번째 Widget에 대해 호출된 소멸자에서 예외가 던져지면 어떻게 될까? 현재 활성화된 예외가 동시에 두 개나 만들어진 상태이고, C++의 입장에서는 감당하기 버겁다. 이 두 예외가 동시에 발생한 조건이 어떤 미묘한 조건이냐에 따라 프로그램 실행이 종료되든지 아니면 정의되지 않은 동작을 보이게 될 텐데, 이 경우에는 프로그램이 정의되지 않은 동작을 보인다. 다른 표준 라이브러리 컨테이너라든지 TR1(항목 54 참조)의 컨테이너를 쓰더라도 결과는 마찬가지이며, 심지어 배열을 써도 마찬가지이다. 그런데 컨테이너나 배열을 썼기 때문에 이런 말썽이 생긴 것이 아니다. 완전하지 못한 프로그램 종료나 미정의 동작의 원인은 바로, 예외가 터져 나오는 것을 내버려 두는 소멸자에게 있다. 심지어 컨테이너나 배열을 쓰지 않아도 말이다. C++는 예외를 내보내는 소멸자를 좋아하지 않다.
예외를 던지고 실패할 수 있는 코드를 소멸자에 넣어야 할 사람이 우리라면 어떻게 하겠는가? 우리가 데이터베이스 연결을 나타내는 클래스를 쓰고 있다고 가정해보자.
1
2
3
4
5
6
7
8
class DBConnection
{
public:
// DBConnection 객체를 반환하는 함수. 매개변수는 편의상 생략
static DBConnection create();
// 연결을 닫고, 실패하면 예외를 던진다.
void close();
};
보다시피 사용자가 DBConnection 객체에 대해 close를 직접 호출해야 하는 설계이다. 사용자의 실수를 방지하는 좋은 방법이라면, DBConnection에 대한 자원 관리 클래스를 만들어서 그 클래스의 소멸자에서 close를 호출하게 만드는 것일 것이다. 여기서는 자원 관리 클래스의 소멸자가 어떤 형태인지만 살펴보자.
1
2
3
4
5
6
7
8
9
10
11
12
class DBConn
{
public:
...
~DBConn()
{
db.close();
}
private:
DBConnection db;
};
이제 다음과 같은 프로그래밍이 가능하다.
1
2
3
4
// DBConnection 객체를 생성하고, 이걸 DBConn 객체로 넘겨서 관리를 맡긴다.
DBConn dbc(DBConnection::create());
... // DBConn 객체를 사용한다.
// DBConn 객체가 소멸될 때, DBConnection 객체도 닫히게 된다.
close 호출만 성공하면 아무 문제가 없는 코드이다. 그러나 close를 호출했는데 여기서 예외가 발생했다고 가정하면 어떻게 될까? DBConn의 소멸자는 분명히 이 예외를 전파할 것이다. 쉽게 말해 그 소멸자에서 예외가 나가도록 내버려 둔다는 말이다. 이게 문제이다. 예외를 던지는 소멸자는 곧 걱정거리를 의미하기 때문이다.
걱정거리를 피하는 방법은 두 가지이다.
close에서 예외가 발생하면 프로그램을 바로 끝낸다. 보통 abort를 호출한다.
1 2 3 4 5 6 7 8 9
DBConn::~DBConn() { try { db.close(); } catch (...) { close 호출이 실패했다는 로그 작성; std::abort(); } }
객체 소멸이 진행되다가 에러가 발생한 후에 프로그램 실행을 계속할 수 없는 상황이라면 꽤 괜찮은 선택이다. 소멸자에서 생긴 예외를 그대로 흘려 내보냈다가 정의되지 않은 동작에 이를 수 있다면, 그런 불상사를 막는다는 의미에서 어느 정도 장점도 있다.
close를 호출한 곳에서 일어난 예외를 삼켜 버린다.
1 2 3 4 5 6 7 8
DBConn::~DBConn() { try { db.close(); } catch (...) { close 호출이 실패했다는 로그 작성; } }
대부분의 경우에서 예외 삼키기는 그리 좋은 발상이 아니다. 중요한 정보가 뭍혀 버리기 때문이다. 무엇이 잘못됐는지를 알려 주는 정보 말이다. 하지만 때에 따라서는 불완전한 프로그램 종료 혹은 미정의 동작으로 인해 입는 위험을 감수하는 것보다 그냥 예외를 먹어버리는 게 나을 수도 있다. 단, 예외 삼키기를 선택한 것이 제대로 빛을 보려면, 발생한 예외를 그냥 무시한 뒤라도 프로그램이 신뢰성 있게 실행을 지속할 수 있어야 한다.
써놓긴 했지만 어느 쪽을 택하든 특별히 좋을 건 없어 보인다. 둘 다 문제점이 있기 때문이다. 중요한 것은 close가 최초로 예외를 던지게 된 요인에 대해 프로그램이 어떤 조치를 취할 수 있는가인데, 이런 부분에 대한 대책이 전무한 상태이기 때문이다.
더 좋은 전략을 고민해 보도록 하자. DBConn 인터페이스를 잘 설계해서, 발생할 소지가 있는 문제에 대처할 기회를 사용자가 가질 수 있도록 하면 어떨까? 이를테면, DBConn에서 close 함수를 직접 제공하게 하면 이 함수의 실행 중에 발생하는 예외를 사용자가 직접 처리할 수 있을 것이다. DBConnection이 닫혔는지의 여부를 유지했다가, 닫히지 않았으면 DBConn의 소멸자에서 닫을 수도 있을 것이다. 이렇게 하면 데이터베이스 연결이 누출되지 않는다. 하지만 소멸자에서 호출하는 close마저 실패한다면, 끝내거나 삼켜버리거나 둘 중 하나를 해야 할 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class DBConn
{
public:
...
void close()
{
db.close();
closed = true;
}
~DBConn()
{
if (!closed)
{
try { db.close(); }
catch (...)
{
close 호출이 실패했다는 로그 작성;
... // 프로그램을 끝내거나 예외를 삼켜 버린다.
}
}
}
private:
DBConnection db;
bool closed;
};
close 호출의 책임을 DBConn의 소멸자에서 사용자에게 넘기는 건 무책임한 책임 전가로 보일 수도 있다. 하지만 어떤 동작이 예외를 일으키면서 실패할 가능성이 있고 또 그 예외를 처리해야 할 필요가 있다면, 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야 한다는 것이 포인트다. 이유는 이번 항목에서 말했듯이, 예외를 일으키는 소멸자는 시한폭탄이나 마찬가지라서 프로그램의 불완전 종료 혹은 미정의 동작의 위험을 내포하고 있기 때문이다.
위의 예제 코드를 다시 보면, 사용자가 호출할 수 있는 close 함수를 두긴 했지만 부담을 떠넘기는 모양새가 아니다. 사용자에게 에러를 처리할 수 있는 기회를 주는 것이다. 이것마저 없다면 사용자는 예외에 대처할 기회를 못 잡게 된다.
소멸자에서는 예외가 빠져나가면 안 된다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 한다.
어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 소멸자가 아닌 다른 함수여야 한다.
C++ 11~ : 소멸자는 기본적으로 noexcept
C++ 11에서 도입된 noexcept 키워드는 해당 함수가 예외를 던지지 않겠다 라고 선언하는 키워드이다. 그리고 C++은 특별한 이유가 없다면 모든 소멸자는 암시적으로 noexcept(true)라는 규칙을 세웠다.
cpppreference에서 소멸자 항목을 보면 예외에 대해 이렇게 설명되어 있다.
다른 함수와 마찬가지로, 소멸자도 예외를 발생시켜 종료될 수 있습니다(이 경우 일반적으로 noexcept(false)를 명시적으로 선언해야 함)(C++11부터)
참고