서적 정리/Effective C++

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

민돌이2 2021. 12. 10. 23:50

자기대입(self assignment)

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

class Widget { ... };

Widget w;
w = w; //자기대입

 

솔직히 고의로 자기대입을 할 경우가 전혀 없다고 생각한다.

왜냐하면 자신에게 자신을 대입한다고 한들 변화가 없기 때문인데, 나도 모르게 자기대입을 하는 경우가 생기고 이게 눈에 잘 띄지도 않는다.

본인도 모르게 자기대입을 하는 경우를 생각해보자

int a[5];

for (int i = 0; i < 5; i++)
	for (int j = 0; j < 5; j++)
		a[i] = a[j]; //i == j 일 때 자기대입 발생

2중 for문에서 만약 i와 j가 같은 상황일 때 자기대입이 발생할 것이고

*px = *py; //만약 py와 px가 가르키는 포인터가 같다면?

px와 py가 가르키는 대상이 같으면 자기대입이 발생할 것이다.

 

언뜻 보기에 명확하지 않은 이러한 자기대입이 생기는 이유는 여러 곳에서 하나의 객체를 참조하는 상황이 있기 때문인데 이것을 중복참조(aliasing)라고 부른다.

같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고 동작하는 코드를 작성할 때는 같은 객체가 사용될 가능성을 고려하는 것이 바람직한 자세다. 같은 클래스 계통에서 만들어진 객체라 해도 굳이 똑같은 타입으로 선언할 필요가 없다. 파생 크래스 타입의 객체를 참조하거나 가리키는 용도로 기본 클래스의 참조자나 포인터를 사용하면 되니까.

class Base { ... };
class Derived : public Base { ... };

void doSomething(const Base& rb, Derived* pd);

위 코드에서 doSomething함수의 매개 변수 rb와 *pd는 원래 같은 객체였을 수도 있다.

 

우리는 코딩을 할 때 자원 관리 용도로 항상 객체를 만들어야 할 것이고(13장, 14장 참고), 이렇게 만든 자원 관리 객체들이 복사될 때 나름대로 고민할텐데, 이때 조심해야 하는것이 대입 연산자이다.

 

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

class Bitmap { ... };

class Widget
{
private:
	Bitmap* pb;
};

여기서 Widget에 대입 연산자를 구현한다면

Widget& operator=(const Widget& rhs)
{
	delete pb; //현재 Bitmap 사용을 중지(삭제)
	pb = new Bitmap(*rhs.pb); //rhs의 Bitmap을 사용

	return (*this);
}

위 코드에서 자기 참조 문제는 operator= 내부에서 *this와 rhs가 같은 객체일 가능성이 있다는 것이다.

 

*this와 rhs가 같은 객체이면 왜 안좋을까?

둘이 같은 객체라고 생각해보자. delete pb;는 *this 객체의 pb를 삭제하기 위한 코드다. 하지만 둘이 같은 객체인 경우 *this 객체의 pb와 rhs 객체의 pb까지 삭제가 적용된다. rhs 객체의 pb가 삭제되었는데 pb = new Bitmap(*rhs.pb); 에서 어떤 일이 발생할까?

결론적으로 operator=함수가 끝나는 시점이 되면 해당 Widget 객체는 자신의 포인터 멤버를 통해 물고 있던 객체가 삭제된 상태가 되는 상황이 발생한다. 또한 예외에 대해서도 안전하지 않다.

 

자기 참조 문제에 대해서 해결법은 operator=의 처음에 일치성 검사(identity test)를 통해 자기대입을 점검하는 것이다.

Widget& operator=(const Widget& rhs)
{
	if (this == &rhs) return (*this); //자기 대입인 경우 return

	delete pb;
	pb = new Bitmap(*rhs.pb);

	return (*this);
}

일단 자기 참조 문제에 대한 해결은 했다. 하지만 여전히 예외에 대해서는 안전하지 않다.

만약 Bitmap을 동적 할당한 pb = new Bitmap(*rhs.pb); 코드에서 예외가 터진다면?(동적 할당에 필요한 메모리가 부족하다던지, Bitmap클래스 복사 생성자에서 예외를 던진다던지)

Widget 객체는 결국 삭제된 Bitmap을 가르키는 포인터를 껴안는 결과가 발생한다. 이런 포인터는 delete연산자를 안전하게 적용할 수도 없고, 안전하게 읽는 것조차 불가능하다.

 

자기 참조에 대해서 자유로우며 예외에 대해서도 자유로운 방법은 pb를 무턱대고 삭제하지말고 사본을 만들어 두는 것이다.

Widget& operator=(const Widget& rhs)
{
	if (this == &rhs) return (*this); //자기 대입인 경우 return

	Bitmap* pOrig = pb; //pb의 사본을 만들어둠
	pb = new Bitmap(*rhs.pb); //예외가 터져도 pb는 그대로
	delete pOrig;

	return (*this);
}

위 방법이 자기대입을 처리하는 가장 효율적인 방법이라고 할 수 없지만, 동작자체에는 아무 문제가 없다.

 

잘하는 프로그래머라면 구현은 물론 효율까지 생각하는 프로그래머라고 생각한다.

실제로 자기대입을 하는 경우가 얼마나 될까? 작은 가능성을 위해서 대입 연산자를 호출할 때마다 일치성 테스트를 한다는건 효율이 떨어지지않을까? 일치성 검사 코드가 들어가면 그만큼 코드가 커지고, 처리 흐름에 분기를 만들게 되므로 실행 시간이 줄어들 것이다.

 

예외 안전성과 자기대입 안전성을 동시에 가진 operator=을 구현하는 방법으로, 위의 코드처럼 문장의 실행 순서를 조정하는 것 외에 복사 후 맞바꾸기(copy and swap)라고 알려진 기법이 있다(29장 참고).

void swap(Widget& rhs); //*this의 데이터와 rhs의 데이터를 스왑

Widget& operator=(const Widget& rhs)
{
	Widget temp(rhs); //rhs의 데이터에 대해 사본 생성

	swap(temp); //*this의 데이터와 rhs의 사본과 스왑

	return *this;
}

 

위의 방법을 C++의 특성을 사용하여 조금 다르게 구현할 수있다.

1.클래스의 복사 대입 연산자는 인자를 값으로 취하도록 선언하는 것이 가능하다.

2.값에 의한 전달을 수행하면 전달된 대상의 사본이 생긴다(20장 참고). (call-by-value는 매개변수의 사본이 생성)

void swap(Widget& rhs); //*this의 데이터와 rhs의 데이터를 스왑

Widget& operator=(const Widget rhs) //call-by-value이기에 rhs은 rhs의 사본
{
	swap(rhs); //*this의 데이터와 rhs의 사본과 스왑

	return *this;
}

컴파일러가 더 효율적인 코드를 생성할 수 있는 여지가 있지만, 명확성에서 좀 떨어진 방법이긴하다.

 

이것만은 잊지 말자

operator=을 구현할 때, 어떤 객체가 자신에 대입되는 경우를 제대로 처리하도록 구현하자.

원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 된다.

두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실같은 객체인 경우 정확하게 동작하는지 확인해 보자.

728x90