복사 가능 및 이동 가능 타입 (Copyable and Movable Types)¶
클래스의 공개 API는 그 클래스가 복사 가능한지, 이동 전용인지, 복사도 이동도 불가능한지를 분명히 드러내야 합니다. 여러분의 타입에 복사나 이동이 명확하고 의미가 있다면 그 연산을 지원하세요.
정의:
이동 가능한 타입은 임시 객체로부터 초기화하고 대입할 수 있는 타입입니다.
복사 가능한 타입은 같은 타입의 다른 객체로부터 초기화하거나 대입할 수 있으면서, 원본의 값이 바뀌지 않는 타입입니다(따라서 정의상 이동도 가능합니다). std::unique_ptr<int>는 이동은 가능하지만 복사는 불가능한 타입의 예입니다(대상에 대입하는 과정에서 원본 std::unique_ptr<int>의 값이 반드시 바뀌어야 하기 때문입니다). int와 std::string은 이동 가능하면서 복사도 가능한 타입의 예입니다. (int는 이동과 복사 연산이 같습니다. std::string은 복사보다 비용이 적은 이동 연산이 있습니다.)
사용자 정의 타입에서 복사 동작은 복사 생성자와 복사 대입 연산자가 정의합니다. 이동 동작은 이동 생성자와 이동 대입 연산자가 있으면 그것들이, 없으면 복사 생성자와 복사 대입 연산자가 정의합니다.
복사·이동 생성자는 객체를 값으로 전달할 때처럼 어떤 상황에서는 컴파일러가 암시적으로 호출할 수 있습니다.
장점:
복사 가능하고 이동 가능한 타입의 객체는 값으로 전달하고 반환할 수 있어서 API가 더 단순하고 안전하며 범용적이 됩니다. 객체를 포인터나 참조로 전달할 때와 달리 소유권, 수명, 가변성 같은 문제로 혼동할 위험이 없고 계약에 그런 내용을 명시할 필요도 없습니다. 또한 클라이언트와 구현 사이의 비지역적 상호작용을 막아 주므로, 이해하고 유지보수하기 쉽고 컴파일러가 최적화하기도 좋습니다. 게다가 이런 객체는 대부분의 컨테이너처럼 값 전달을 요구하는 제네릭 API와 함께 쓸 수 있고, 타입 합성 같은 데서 추가적인 유연성을 얻을 수 있습니다.
복사·이동 생성자와 대입 연산자는 Clone(), CopyFrom(), Swap() 같은 대안보다 대개 올바르게 정의하기 쉽습니다. 암시적으로든 = default로든 컴파일러가 생성해 줄 수 있기 때문입니다. 이들은 간결하며 모든 데이터 멤버가 복사되도록 보장합니다. 또한 복사·이동 생성자는 힙 할당이나 초기화와 대입을 나누는 단계가 필요 없고 복사 생략(copy elision) 같은 최적화의 대상이 되므로 대체로 더 효율적입니다.
이동 연산은 rvalue 객체에서 리소스를 암시적이고 효율적으로 옮겨 올 수 있게 해 줍니다. 덕분에 경우에 따라 더 단순한 코딩 스타일이 가능해집니다.
단점:
어떤 타입은 복사할 필요가 없으며, 그런 타입에 복사 연산을 제공하면 혼란스럽거나 무의미하거나 아예 잘못될 수 있습니다. 싱글턴 객체(Registerer), 특정 범위에 묶인 객체(Cleanup), 객체의 정체성과 밀접하게 결합된 객체(Mutex)를 나타내는 타입은 의미 있게 복사할 수 없습니다. 다형적으로 사용될 기반 클래스 타입의 복사 연산은 위험합니다. 그것을 사용하면 객체 잘림(object slicing)이 일어날 수 있기 때문입니다. 기본값으로 두거나 부주의하게 구현한 복사 연산은 틀릴 수 있고, 그로 인한 버그는 혼란스럽고 진단하기 어렵습니다.
복사 생성자는 암시적으로 호출되므로 호출 지점을 놓치기 쉽습니다. 참조 전달이 관례이거나 필수인 언어에 익숙한 프로그래머에게는 혼란스러울 수 있습니다. 또한 과도한 복사를 부추겨 성능 문제를 일으킬 수도 있습니다.
결정:
모든 클래스의 공개 인터페이스는 그 클래스가 어떤 복사·이동 연산을 지원하는지 분명히 드러내야 합니다. 보통은 선언의 public 섹션에서 해당 연산을 명시적으로 선언하거나 삭제하는 형태가 됩니다.
구체적으로, 복사 가능한 클래스는 복사 연산을 명시적으로 선언해야 하고, 이동 전용 클래스는 이동 연산을 명시적으로 선언해야 하며, 복사·이동이 불가능한 클래스는 복사 연산을 명시적으로 삭제해야 합니다. 복사 가능한 클래스가 효율적인 이동을 지원하기 위해 이동 연산을 함께 선언해도 됩니다. 네 가지 복사·이동 연산을 모두 명시적으로 선언하거나 삭제하는 것도 허용되지만 필수는 아닙니다. 복사 또는 이동 대입 연산자를 제공한다면 그에 대응하는 생성자도 제공해야 합니다.
class Copyable {
public:
Copyable(const Copyable& other) = default;
Copyable& operator=(const Copyable& other) = default;
// 위 선언 때문에 암시적 이동 연산은 억제됩니다.
// 효율적인 이동을 지원하려면 이동 연산을 명시적으로 선언해도 됩니다.
};
class MoveOnly {
public:
MoveOnly(MoveOnly&& other) = default;
MoveOnly& operator=(MoveOnly&& other) = default;
// 복사 연산은 암시적으로 삭제되지만, 원한다면
// 아래처럼 명시적으로 적어도 됩니다.
MoveOnly(const MoveOnly&) = delete;
MoveOnly& operator=(const MoveOnly&) = delete;
};
class NotCopyableOrMovable {
public:
// 복사도 이동도 불가능합니다.
NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
NotCopyableOrMovable& operator=(const NotCopyableOrMovable&)
= delete;
// 이동 연산은 암시적으로 비활성화되지만, 원한다면
// 아래처럼 명시적으로 적어도 됩니다.
NotCopyableOrMovable(NotCopyableOrMovable&&) = delete;
NotCopyableOrMovable& operator=(NotCopyableOrMovable&&)
= delete;
};
이런 선언·삭제는 명백한 경우에만 생략할 수 있습니다.
- 클래스에 private 섹션이 없다면(구조체나 인터페이스 전용 기반 클래스처럼), 복사·이동 가능 여부는 public 데이터 멤버의 복사·이동 가능 여부로 결정될 수 있습니다.
- 기반 클래스가 복사도 이동도 불가능한 것이 분명하다면, 파생 클래스도 자연히 그렇습니다. 다만 이 연산들을 암시적으로 남겨 둔 인터페이스 전용 기반 클래스만으로는 구체적인 하위 클래스가 명백해지지 않습니다.
- 복사의 생성자나 대입 연산 중 하나를 명시적으로 선언하거나 삭제했다면, 나머지 복사 연산은 더 이상 명백하지 않으므로 함께 선언하거나 삭제해야 합니다. 이동 연산도 마찬가지입니다.
복사·이동의 의미가 보통의 사용자에게 명확하지 않거나 예상치 못한 비용이 든다면 그 타입은 복사·이동 가능해서는 안 됩니다. 복사 가능한 타입의 이동 연산은 순전히 성능 최적화이며 버그와 복잡성의 잠재적 원천이므로, 대응하는 복사 연산보다 확연히 효율적인 경우가 아니라면 정의하지 마세요. 타입이 복사 연산을 제공한다면, 그 연산의 기본 구현이 올바르도록 클래스를 설계하기를 권장합니다. 다른 코드와 마찬가지로 기본 생성된 연산의 정확성도 잊지 말고 검토하세요.
객체 잘림의 위험을 없애려면 기반 클래스를 추상 클래스로 만드는 편이 좋습니다. 생성자를 protected로 두거나, 소멸자를 protected로 선언하거나, 순수 가상 멤버 함수를 하나 이상 두면 됩니다. 구체 클래스로부터 파생하는 것은 피하세요.
옮긴이 풀이¶
핵심: 공개 API가 복사/이동 가능 여부를 "명시"해야 한다¶
클래스를 보는 사람이 그 클래스를 값으로 복사할 수 있는지, 이동만 되는지, 둘 다 안 되는지 선언만 보고 알 수 있어야 합니다. 그래서 해당 연산을 public 섹션에서 명시적으로 선언하거나 = delete합니다.
// 복사 가능: 복사 연산을 명시적으로 선언
class Copyable {
public:
Copyable(const Copyable&) = default;
Copyable& operator=(const Copyable&) = default;
};
// 이동 전용: 이동을 선언하면 복사는 암시적으로 삭제됨
class MoveOnly {
public:
MoveOnly(MoveOnly&&) = default;
MoveOnly& operator=(MoveOnly&&) = default;
};
// 복사·이동 불가: 복사를 명시적으로 삭제
class NotCopyableOrMovable {
public:
NotCopyableOrMovable(const NotCopyableOrMovable&) = delete;
NotCopyableOrMovable& operator=(const NotCopyableOrMovable&) = delete;
};
언제 복사를 막아야 하나?¶
복사의 의미가 불분명하거나 예상치 못한 비용이 드는 타입은 복사 가능하게 만들지 마세요. 싱글턴(Registerer), 특정 스코프에 묶인 객체(Cleanup), 정체성이 중요한 객체(Mutex)가 그렇습니다. 특히 다형적으로 쓰이는 기반 클래스는 복사 시 객체 잘림(object slicing)이 발생하므로 위험합니다.
객체 잘림 방지¶
기반 클래스는 추상으로 만드세요 — 생성자나 소멸자를 protected로 두거나, 순수 가상 함수를 하나 이상 두는 방법이 있습니다. 구체 클래스에서 파생하는 것은 피하세요.
복사 가능 타입의 이동 연산¶
복사 가능한 타입에 이동 연산을 추가하는 것은 순전히 성능 최적화이며 버그·복잡성의 원천입니다. 복사보다 확실히 훨씬 빠를 때만 정의하세요.