2장. 생성자, 소멸자 및 대입연산자 🎺

안녕하세요! 두두코딩 입니다 ✋
오늘은 항목 11 자기대입 문제점에 대해 알아보고, 해결하는 방법을 다뤄보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

자기대입 (self assignment)

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

class Widget { ... };

Widget w;

...

// 자기에 대한 대입
w = w;

위의 코드를 보면, 과연 이렇게 작성하는 사람이 있을까? 혹은 이 코드가 정확하게 돌아갈까? 생각할 수 있다. 하지만, 해당 코드는 문제 없이 돌아가며, 이렇게 작성하는 사람도 물론 정상이다😳 문제는 해당 코드처럼 자기대입이 눈에 잘 띄문 문제가 되지 않지만, 잘 띄지 않는 상태에서 처리를 제대로 하지 않으면 문제가 된다. 잘띄지 않는 경우가 무엇일까?

// i == j일 경우
a[i] = a[j];

// 같은 객체를 가리킬 경우
*px = *py;

위 코드와 같이, ij가 같을 경우 문제가 된다. 우리가 loop를 순회 할 때, 값 설정을 어떻게 하느냐에 따라 ij는 동일한 값을 가질 수 있다. 해당 경우 자기대입이 발생하게 되며, 아래의 코드 같은 경우도 같은 객체를 가리킬 경우 자기대입이 발생한다.

자기대입이 되는 이유를 생각해보면, 여러 곳에서 하나의 객체를 참조하는 상태 즉, “중복참조” 현상이 발생하기 때문이다. 우리가 같은 타입으로 만들어진 객체를 여러 개의 참조자 혹은 포인터를 활용할 때는, 같은 객체가 사용 즉, 자기대입이 될 경우를 항상 고려해주는 것이 좋다.

주의!! 대입 연산자

우리가 자기대입에 대해 가장 주의해야할 부분은 대입 연산자 이다. 해당 연산자는 개발자가 신경쓰지 않아도, 자기대입에는 안전하게 동작해야한다. 만약 우리가 자기대입을 소홀하게 작성한 대입 연산자 를 작성했다고 가정해보자.

class Bitmap { ... };

class Widget {
	...

private:
	// 힙에 할당한 객체를 가리키는 포인터
	Bitmap *pb;
};

// 안전하지 않은 operator= 구현
Widget&
Widget::operator=(const Widget& rhs)
{
	delete pb;
	pb = new Bitmap(*rhs.pb);

	return *this;
}

위 코드는 동적 할당된 비트맵을 가리키는 원시 포인터를 데이터 멤버로 갖는 Widget 클래스를 형상화 한 것이다. Widget Class내에서는 대입 연산자를 갖고 있을 것이며, 위와 같이 구성되어있다고 해보자.

겉으로 보기에는 operator=() 구현이 아주 멀쩡해보일 수 있다. 의미적으로는 문제가 없지만, 해당 코드는 두가지 문제를 가지고 있다.

🌱 자기대입의 문제

자기 대입의 문제는, operator=() 내부에서 *this 와 rhs 객체가 같을 수 있다는 점이다. 즉 둘이 같은 객체일 경우, delete연산을 했을 때, delete 연산이 *this 뿐 아니라 rhs에도 적용이 된다. 즉, 이 함수는 끝나는 시점에는 pb에는 아무 것도 없는 객체로 반환게 된다.

🌱 예외처리의 문제

해당 코드에서는 자기대입에 더불이 예외 처리 문제도 존재한다.

우선, 자기대입의 문제를 해결할 수 있는 방법 중 간단하게 처리하는 방법을 확인해보자.

Widget& Widget::operator=(const Widget& rhs)
{
	// 객체가 같은지, 즉 자기 대입일 경우
	// 바로 반환함.
	if (this == &rhs) return *this;

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

	return *this;
}

위의 코드와 같이, 해당 함수 operator=()가 수행되기 전 this == &rhs를 비교해 현재 객체가 동일한 객체 인지 파악한다. 만약 같을 경우 바로 반환을 하고, 아닐 경우 기존 동작을 수행하도록 구현한다. 이런 방법을 우리는 일치성 검사 라고 칭한다. 해당 방법 즉, 일치성 검사만 적용할 경우 자기대입 에 대한 문제만 처리할 수 있고, 예외처리 문제는 아직 해결되지 않았다. 그렇다면, 어느 부분에서 예외가 발생할까?

자기대입과 예외처리에 안전한 코드 작성

해당 코드에서 예외처리가 발생할 수 있는 부분은 어디일까? new Bitmap 부분이다. 즉, Bitmap 객체를 생성하는 부분에서 만약 Heap 영역에 메모리가 없을 경우 예외가 발생할 수 있다.

해당 예외가 발생할 경우 pb가 가리키는 포인터는 아무것도 없는 삭제된 포인터를 않고 반환하게 되는 문제가 생길 수 있는 것이다.

예외처리로 인해 삭제된 포인터를 가리키고 있는 객체가 있을 경우 delete를 안전하게 적용할 수 없고, 안전하게 읽는 것 조차 불가능해진다. 따라서, 프로그래머는 또 밤을 세서 문제를 해결해야되는 불상사가 생기게 되는 것이다..😖

보통 operator=() 예외에 안전성 있는 코드를 작성할 경우 대개 자기대입문제도 해결되는 경우가 많다. 뒷장에 가면 예외처리 관련된 부분을 많이 배우게 된다. 그 때 다양한 예외를 처리하는 방법에 대해 알아보고, 이번 항목에서는 “많은 경우에 문장 순서를 세심하게 바꾸는 것만으로 예외에 안전한 코드가 만들어진다”는 부분에 대해 다뤄보자.

Widget& Widget::operator=(const Widget& rhs)
{
	// pb를 어딘가에 기억해두자.
	Bitmap *pOrig = pb;
	// pb가 pb의 사본을 가리키게 하자.
	pb = new Bitmap(*rhs.pb);
	// 기존 pb를 삭제하게 만들자.
	delete pOrig;

	return *this;
}

위의 코드는 예외에 안전한 코드이다. new Bitmap부분에서 예외가 발생하더라도, pb는 변경되지 않은 상태이기 때문이다.

해당 코드와 같이 작성하게 될 경우, “자기대입을 위한 일치성 검사”가 없어도 자기대입현상을 완벽하게 해결하고 있다. 구체적으로, 원본 비트맵을 복사해두고, 복사해 놓은 사본을 포인터가 가리키게 한 후, 원본을 삭제하는 순서로 되어져 있다.

물론 해당 방법이 자기대입을 처리하는 가장 효율적인 방법이라고는 할 수 없다. 효율적으로 따지면, 검사를 하고, 복사하는 동작을 안하는 일치성 검사 방법이 훨씬 좋다고 할 수 있다. 하지만 시스템 적으로 생각해보면, 우리 프로그램에서 자기대입이 얼마나 발생할 것인가? 별로 일어나지 않는다. 따라서, 매번 분기문을 검사해 시스템 적으로 “CPU 명령어 선행인출 캐시”, “파이프라이닝”의 퍼포먼스를 낮추는 것보다 위의 방법이 더 좋을수도 있다라고 생각해 볼 수 있다.

CAS 방법

위의 코드와 같이, 순서를 변경함으로 “자기대입 안전성” 과 “예외처리”를 둘 다 해결할 수 있지만, 다른 방법도 한번 알아보자. 우리가 지금부터 알아볼 방법은 Copy And Swap (CAS) 방법이다. 이 항목도 위와 마찬가지로, 예외처리와 밀접하게 연관된 기법이기 때문에 뒷장에서 자세하게 다루도록 한다. 우선 맛만 보자🙃

class Widget {
	...
	// *this 의 데이터 및 rhs 데이터를 맞바꿀 수 있는 코드
	void swap(Widget& rhs);
	...
};

Widget& Widget::operator=(const Widget& rhs)
{
	Widget temp(rhs);

	swap(temp);

	return *this;
}

위의 코드와 같이, swap이라는 함수를 하나 만든다. (Widget에서 사용)

operator=() 함수가 불리게 될 경우, rhs의 사본을 하나 만들고, swap()를 활용해, 두 객체를 교환한다. Heap영역이 아니라 Stack 영역에 객체를 생성한다는 차이점이 있다. 위의 코드와 큰 차이는 없지만, 해당 방법이 operator=()를 구현할 때 더 많이 사용된다는 점을 알아두자.

위의 코드에서 Widget temp(rhs)를 하지말고, 그냥 값에 의한 전달을 하게 되면 더 쉽게 구현이 가능하지 않을까? 값에 의한 전달을 하게 되면 어짜피 사본이 넘어 오게 되니까, 굳이 사본을 만들 필요가 없다.

class Widget {
	...
	// *this 의 데이터 및 rhs 데이터를 맞바꿀 수 있는 코드
	void swap(Widget& rhs);
	...
};

// 참조가 아닌 값을 전달함.
Widget& Widget::operator=(Widget rhs)
{
	swap(rhs);

	return *this;
}

위의 코드는 깔끔하긴 한데, 만약 객체의 데이터가 크게 된다면 비효율을 갖게 될 것이다. 해당 문제도, 뒷 부분에서 다루니 쭈욱 따라오길 바란다! 😚

To Sum Up

👉 operator=를 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만들어야한다.

👉 같은 타입의 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인지 확인할 필요가 있다.