대입 연산의 두 단계
C++에서 객체의 대입(assignment)은 크게 두 단계로 나눌 수 있습니다. 하나는 객체가 지니고 있던 기존 상태를 해제하는 것이고, 다른 하나는 그 자리에 새로운 상태를 구축하는 것입니다.
다행히 이 두 단계에는 이미 검증된 도구가 존재합니다. 기존 상태의 해제는 소멸자(destructor)가 담당하고, 새로운 상태의 구축은 복사 생성자(copy constructor)가 담당합니다.
대입 연산자 오버로딩은 왜 어려운가?
소멸자와 복사 생성자를 따로 구현하는 것은 비교적 간단하지만, 대입 연산자(operator=)를 직접 오버로딩하는 일은 꽤 까다롭습니다. 자기 자신에 대한 대입(self-assignment) 처리, 기존 리소스의 해제 시점, 메모리 할당 중 예외가 발생했을 때 객체 상태의 일관성 유지 등 고려해야 할 함정이 많기 때문입니다.
복사-스왑(copy and swap) 관용구는 바로 이 문제를 우아하게 해결해 주는 기법입니다.
복사-스왑 관용구의 동작 원리
이 관용구는 다음 순서로 동작합니다.
- 복사 — 복사 생성자를 사용해 원본 데이터의 지역 복사본을 만듭니다.
- 스왑 — swap 함수로 기존 데이터와 새 데이터를 맞바꿉니다.
- 소멸 — 임시 복사본은 스코프를 벗어나면서 소멸자에 의해 자동으로 정리됩니다.
과정이 끝나면 객체에는 새 데이터의 복사본만 남게 됩니다.
관용구에 필요한 세 가지 요소
- 복사 생성자(copy constructor)
- 소멸자(destructor)
- swap 함수
예제 코드
다음은 동적 배열을 감싸는 클래스에 복사-스왑 관용구를 적용한 예입니다.
#include <algorithm> // std::copy
#include <cstddef> // std::size_t
class Buffer {
private:
int* data_;
std::size_t size_;
public:
// 생성자
explicit Buffer(std::size_t size = 0)
: data_(size ? new int[size] : nullptr), size_(size) {}
// 소멸자
~Buffer() { delete[] data_; }
// 복사 생성자
Buffer(const Buffer& other)
: data_(other.size_ ? new int[other.size_] : nullptr),
size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
// 예외를 던지지 않는 swap 함수
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
// 복사-스왑 관용구를 적용한 대입 연산자
// 매개변수를 '값으로' 받으면 복사 생성자가 복사본을 만들어 줍니다.
Buffer& operator=(Buffer other) noexcept {
swap(*this, other);
return *this; // other는 함수 종료 시 소멸자로 정리됩니다.
}
};
매개변수를 값으로 전달하기 때문에 컴파일러가 복사 생성자를 통해 복사본을 만들어 주며, 메모리 할당이 실패해 예외가 던져지더라도 원본 객체는 전혀 손상되지 않습니다(강한 예외 보장). 또한 self-assignment도 별도의 검사 없이 자연스럽게 처리됩니다.
주의: std::swap을 그대로 사용하지 마세요
여기서 필요한 swap 함수는 같은 클래스의 두 객체(또는 멤버)를 맞바꾸는 예외를 던지지 않는(no-throw) 함수여야 합니다.
주의: 표준 라이브러리의 std::swap을 그대로 사용해서는 안 됩니다. std::swap은 내부적으로 복사 생성자와 대입 연산자를 사용해 값을 교환하기 때문에, 대입 연산자 구현 내부에서 이를 호출하면 불필요한 복사로 인한 성능 저하, 심한 경우 무한 재귀 호출 문제까지 일으킬 수 있습니다. 포인터나 멤버 변수만 맞바꾸는 전용 swap 함수를 직접 제공하는 것이 올바른 방법입니다.