[Effective C++] 2. 생성자, 소멸자 및 대입 연산자 [4/4]
이 글은 제 개인적인 공부를 위해 작성한 글입니다.
틀린 내용이 있을 수 있고, 피드백은 환영합니다.
항목 11 : operator=에서는 자가대입에 대한 처리가 빠지지 않도록 하자
자가대입(self-assignment)이란, 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 말한다.
1
2
3
4
5
class Widget { ... };
Widget w;
...
w = w;
문제가 있어 보이는 코드지만, 이 코드는 적법한 코드이다. 게다가 이 대입이란 연산이 그렇게 눈에 잘 띄는가 하면 그것도 아니라는 것이 큰 문제이다. 예를 들어,
1
2
3
a[i] = a[j];
*px = *py;
이런 코드에서 i와 j가 같거나, px와 py가 같은 객체를 가리키는 경우엔 자가대입이 되고 만다. 언뜻 보기에 명확하지 않은 이러한 자가대입이 생기는 이유는 여러 곳에서 하나의 객체를 참조하는 상태, 다시 말해 중복참조(aliasing)라고 불리는 것 때문이다. 같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고 동작하는 코드를 작성할 때는 같은 객체가 사용될 가능성을 고려하는 것이 일반적으로 바람직한 자세이다. 사실, 같은 클래스 계통에서 만들어진 객체라 해도 굳이 똑같은 타입으로 선언할 필요까지는 없다. 파생 클래스 타입의 객체를 참조하거나 가리키는 용도로 기본 클래스의 참조자나 포인터를 사용하면 되니까이다.
1
2
3
class Base { ... };
class Derived : public Base { ... };
void doSomething(const Base& rb, Derived* pd); // rb 및 *pd는 같은 객체였을 수도 있다.
항목 13과 14에 나온 조언을 따른다면 우리는 자원 관리 용도로 항상 객체를 만들어야 할 것이고, 이렇게 만든 자원 관리 객체들이 복사될 때 나름대로 잘 동작하도록 코딩할 게 분명하다. 바로 이때 조심해야 하는 것이 대입 연산자이다. 이 연산자는 우리가 신경쓰지 않아도 자가대입에 대해 안전하게 동작해야 한다. 하지만 자원 관리를 우리의 손으로 완벽하게 하기란 참으로 어려운 일이다. 어쩌다 보면 자원을 사용하기 전에 덜컥 해제해 버릴 수도 있을지 모른다. 예시를 하나 보자. 동적 할당된 비트맵을 가리키는 원시 포인터를 데이터 멤버로 갖는 클래스를 하나 만들었다고 가정해 보자.
1
2
3
4
5
6
7
class Bitmap { ... };
class Widget
{
...
private:
Bitmap* pb; // 힙에 할당된 객체를 가리키는 포인터
};
이제 겉보기에 멀쩡해 보이는 operator=의 구현 코드를 하나 보자. 의미적으로는 문제가 없을 것 같지만 예외에도 안전하지 않고, 자기 참조의 가능성이 있는 위험한 코드이다.
1
2
3
4
5
6
Widget& Widget::operator=(const Widget& rhs)
{
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
여기서 찾을 수 있는 자기 참조의 문제는 operator= 내부에서 *this(대입되는 대상)와 rhs가 같은 객체일 가능성이 있다는 것이다. 왜 이것이 좋지 않을까? 이 둘이 같은 객체이면, delete 연산자가 *this 객체의 비트맵에만 적용되는 것이 아니라 rhs의 객체까지 적용되어 버린다. 그러니까, 이 함수가 끝나는 시점이 되면 해당 Widget(자기 참조에 의해 변경되면 큰일나는 그 Widget) 객체는 자신의 포인터 멤버를 통해 물고 있던 객체가 어처구니없게도 삭제되는 불상사를 당하게 된다.
이런 에러에 대한 대책은 예쩐부터 있어 왔다. 전통적인 방법은 operator=의 첫 머리에서 일치성 검사를 통해 자가대입을 점검하는 것이다.
1
2
3
4
5
6
7
Widget& Widget::operator=(const Widget& rhs)
{
if (this == &rhs) return *this;
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
어쨌든 이렇게 하면 되기는 하지만, 이전 버전의 operator=이 자가대입에 안전하지 못할 뿐만 아니라 예외에도 안전하지 않다고 했다. 예외 안전성에 대해서는 이번 것도 여전히 문제를 가지고 있다. 특히 신경 쓰이는 부분이 ‘new Bitmap’ 표현식이다. 동적 할당에 필요한 메모리가 부족하다든지 Bitmap 클래스 복사 생성자에서 예외를 던진다든지 해서 이 부분에서 예외가 터지게 되면, Widget 객체는 결국 삭제된 Bitmap을 가리키는 포인터를 껴안고 홀로 남게 된다. 이런 포인터가 멋대로 떠다니게 놔두면 프로그램이 망가질 수 있다. 이런 포인터는 delete 연산자를 안전하게 적용할 수도 없고, 안전하게 읽는 것조차 불가능하다.
다행스럽게도 operator=을 예외에 안전하게 구현하면 대개 자가대입에도 안전한 코드가 나오게 되어 있다. 그러니까, 예외 안전성에만 집중하면 자가대입 문제는 무시하더라도 무사히 넘어갈 확률이 높아진다는 것이다. 이번 항목에서는 “많은 경우에 문장 순서를 세심하게 바꾸는 것만으로 예외에 안전하면서 자가대입에도 안전한 코드가 만들어진다”라는 법칙을 한 가지 써보자. 지금의 코드는, pb를 무턱대고 삭제하지 말고 이 포인터가 가리키는 객체를 복사한 직후에 삭제하면 깔끔히 해결될 것 같다.
1
2
3
4
5
6
7
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap* pOrig = pb;
pb = new Bitmap(*rhs.pb);
delete pOrig;
return *this;
}
이 코드는 이제 예외에서 안전하다. ‘new Bitmap’ 부분에서 예외가 발생하더라도 pb는 변경되지 않은 상태가 유지되기 때문이다. 게다가 일치성 검사가 없음에도 불구하고 이 코드는 자가대입 현상을 완벽하게 처리하고 있다. 원본 비트맵을 복사해 놓고, 복사해 놓은 사본을 포인터가 가리키게 만든 후, 원본을 삭제하는 순서로 실행되기 때문이다. 물론 이 방법이 자가대입을 처리하는 가장 효율적인 방법이라고는 할 수 없겠지만, 동작에는 아무 문제가 없다.
효율이 너무나 신경 쓰인 나머지, 일치성 테스트를 함수 앞단에 도로 붙여 놓고 싶은 사람도 있을 것이다. 하지만 곰곰히 생각해 보자. 우리의 프로그램에서 자가대입이 얼마나 자주 일어날까? 이런 이야기를 하는 이유는 일치성 테스트 역시 공짜가 아니기 때문이다. 일치성 검사 코드가 들어가면 그만큼 코드가 커지는데다가, 처리 흐름에 분기를 만들게 되므로 실행 시간 속력이 줄어들 수 있다. CPU 명령어 선행인출, 캐시, 파이프라이닝 등의 효과도 떨어질 수 있고 말이다.
예외 안전성과 자가대입 안정성을 동시에 가진 opeartor=을 구현하는 방법으로, 방금 본 예처럼 문장의 실행 순서를 수작업으로 조정하는 것 외에 다른 방법이 하나 더 있다. ‘복사 후 맞바꾸기(copy-and-swap)’라고 알려진 기법인데, 이 기법은 사실 예외 안전성과 아주 밀접한 관계에 있기 때문에 항목 29에 자세히 설명되어 있다. 하지만 이 기법은 operator= 작성에 아주 자주 쓰이기 때문에 어떤 식으로 구현하는지만 여기서 봐도 굉장히 도움될 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget
{
...
void swap(Widget& rhs);
...
};
Widget& Widget::operator=(const Widget& rhs)
{
Widget temp(rhs);
swap(temp);
return *this;
}
이 방법은 C++가 가진 두 가지 특징을 활용해서 조금 다르게 구현할 수도 있다. 첫 번째, 클래스의 복사 대입 연산자는 인자를 값으로 취하도록 선언하는 것이 가능하다는 점과 두 번째, 값에 의한 전달을 수행하면 전달된 대상의 사본이 생긴다는 점을 이용하는 것이다.
1
2
3
4
5
Widget& Widget::operator=(Widget rhs)
{
swap(rhs);
return *this;
}
이 코드는 기술을 조금 쓰는 대신에 명확성을 조금 잃어버린 코드인 듯하다. 하지만 객체를 복사하는 코드가 함수 본문으로부터 매개변수의 생성자로 옮겨졌기 때문에, 컴파일러가 더 효율적인 코드를 생성할 수 있는 여지가 만들어지는 것은 사실이다.
operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만들자. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 사용할 수도 있다.
두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보자.
항목 12 : 객체의 모든 부분을 빠짐없이 복사하자.
객체의 안쪽 부분을 캡슐화한 객체 지향 시스템 중 설계가 잘 된 것들을 보면, 객체를 복사하는 함수가 딱 둘만 있는 것을 알 수 있다. 복사 생성자와 복사 대입 연산자라고, 성격에 따라 이름도 적절히 지어져 있다. 이 둘을 통틀어 객체 복사 함수라고 부른다. 객체 복사 함수는 컴파일러가 필요에 따라 만들어내기도 한다. 그리고 컴파일러가 생성한 복사 함수는 비록 저절로 만들어졌지만 동작은 기본적인 요구에 아주 충실하다. 복사되는 객체가 갖고 있는 데이터를 빠짐없이 복사하는 것에 말이다.
객체 복사 함수를 우리가 선언한다는 것은, 컴파일러가 만든 녀석의 기본 동작에 뭔가 마음에 안 드는 것이 있다는 이야기이다. 이에 대해 컴파일러도 썩 반기는 분위기는 아니라는 듯, 꽤나 까칠한 자세로 우리를 골탕 먹이려고 한다. 어떻게 하는고 하니, 우리가 구현한 복사 함수가 거의 확실히 틀렸을 경우에도 입을 다물어 버리낟.
고객을 나타내는 클래스가 하나 있다고 가정하자. 이 클래스의 복사 함수는 개발자가 직접 구현했고, 복사 함수를 호출할 때마다 로그를 남기도록 작성됐다.
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
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;
}
문제가 하나도 없어 보인다. 실제로 그렇기도 하다. 그런데 데이터 멤버 하나를 Customer에 추가하면서 행복에 금이 가기 시작한다.
1
2
3
4
5
6
7
8
9
10
class Data { ... };
class Customer
{
public:
...
private:
std::string name;
Data lastTransaction;
};
이렇게 되고 나면, 복사 함수의 동작은 완전 복사가 아니라 부분 복사가 된다. 고객의 이름은 복사하지만 lastTransaction은 복사하지 않는 것이다. 여기서 주의해야 할 점이, 이런 상황에 대해 컴파일러는 아무런 에러를 발생시키지 않는다는 것이다. 컴파일러 경고 수준을 최대로 높여도 마찬가지이다. (항목 53 참조) 우리가 멋대로 복사 함수를 작성하는 데 대한 컴파일러의 한풀이 느낌이다. 컴파일러가 복사 함수를 정성스레 마련해 주겠다는데 우리가 거절했으니, 우리 구현이 잘못되어도 컴파일러는 입을 다물고 있는 것이다.
결국 우리가 해야 할 일은 한 가지이다. 클래스에 데이터 멤버를 추가했으면, 추가한 데이터 멤버를 처리하도록 복사 함수를 다시 작성할 수밖에 없는 것이다. (생성자도 마찬가지고, 비표준형 operator=도 마찬가지이다.)
이 문제가 가장 프로그래머를 괴롭히는 경우가 하나 있는데, 바로 클래스 상속이다. 아래 코드를 봐보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class PriorityCustomer : public Customer
{
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 클래스의 복사 함수는 언뜻 보기엔 PriorityCustomer의 모든 것을 복사하고 있는 것처럼 보이지만, 다시 보면 PriorityCustomer에 선언된 데이터 멤버를 모두 복사하고 있는 것은 사실이지만, Customer로부터 상속한 데이터 멤버들의 사본도 엄연히 PriorityCustomer 클래스에 들어 있는데, 이들은 복사가 안 되고 있다. PriorityCustomer의 복사 생성자에는 기본 클래스 생성자에 넘길 인자들도 명시되어 있지 않아서, PriorityCustomer 객체의 Customer 부분은 인자 없이 실행되는 Customer 기본 생성자에 의해 초기화된다. 이 생성자는 당연히 name 및 lastTransaction에 대해 ‘기본적인’ 초기화를 해 줄 거란 말이다.
PriorityCustomer의 복사 대입 연산자의 경우에는 사정이 다소 다르다. 복사 대입 연산자는 기본 클래스의 데이터 멤버를 건드릴 시도도 하지 않기 떄문에, 기본 클래스의 데이터 멤버는 변경되지 않고 그대로 있게 된다.
사정이 어쨌든, 파생 클래스에 대한 복사 함수를 우리 스스로 만든다고 결심했다면 기본 클래스 부분을 복사에서 빠뜨리지 않도록 각별히 주의해야 한다. 물론 기본 클래스 부분은 priority 멤버일 가능성이 아주 높기 때문에, 이들을 직접적으로 건드리긴 어렵다. 그 대신, 파생 클래스의 복사 함수 안에서 기본 클래스의 복사 함수를 호출하도록 만들면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}
이번 항목의 제목으로 나온 “모든 부분을 복사하자”라는 말이 이런 뜻이다. 객체의 복사 함수를 작성할 때는 다음의 두 가지를 꼭 확인하라는 것이다. 첫 번째, 해당 클래스의 데이터 멤버를 모두 복사하고, 두 번째, 이 클래스가 상속한 기본 클래스의 복사 함수도 호출해 주도록 하자.
사실, 클래스의 양대 복사 함수는 본문이 비슷하게 나오는 경우가 많아서, 한쪽에서 다른 쪽을 호출하게 만들어서 코드 판박이를 피하면 좋겠다는 사람이 있을 것이다.
복사 대입 연산자에서 복사 생성자를 호출하는 것부터 말이 안되는 발상이다. 이미 만들어져 버젓이 존재하는 객체를 ‘생성’하고 있으니 말이다. 태어난 아이에게 엄마 뱃속으로 들어가라고 말하는 꼴이다. 게다가 이렇게 할 수 있는 문법도 없다. 이런 방법이 가능해 보이는 문법을 보았다면, 특정한 조건에서 객체의 데이터가 훼손되어 버릴 수 있어서 매우 위험하다.
반대의 경우, 그러니까 복사 생성자에서 복사 대입 연산자를 호출하는 것도 마찬가지로 말이 안된다. 생성자의 역할을 새로 만들어진 객체를 초기화하는 것이지만, 대입 연산자의 역할은 ‘이미’ 초기화가 끝난 객체에게 값을 주는 것이다. 초기화된 객체에만 적용된다는 이야기이다. 그런데 생성 중인 객체에다가 대입이라니, 초기화된 객체에 대해서만 의미를 갖는 동작을 ‘아직 초기화도 안 된’ 객체에 대해 한다는 것이다. 하지 마세요!
대신에 이런 방법을 생각해 볼 수 있다. 어쩌다 보니 복사 생성자와 복사 대입 연산자의 코드 본문이 비슷하게 나온다는 느낌이 들면, 양쪽에서 겹치는 부분을 별도의 멤버 함수에 분리해 놓은 후에 이 함수를 호출하게 만드는 것이다. 대개 이런 용도의 함수는 private 멤버로 두는 경우가 많고, 이름이 init 어쩌고 하는 이름을 가진다. 안전할뿐만 아니라 검증된 방법이므로, 복사 생성자와 복사 대입 연산자에 나타나는 코드 중복을 제거하는 방법으로 사용해보길 바란다.
객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 한다.
클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 말자. 그 대신, 공통된 동작을 제3의 함수에다가 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결하자.
참고