C++ 고급 문법 (9) — 객체 복사(Object Copy)

복사 생성자를 따로 쓰지 않아도 Buffer b = a;는 컴파일된다. 컴파일러가 멤버를 하나씩 복사하는 기본 복사 생성자를 만들어주기 때문이다. 멤버가 값 타입이면 이것으로 충분하지만, 생 포인터로 자원을 소유하고 있으면 포인터 값만 복사되어 두 객체가 같은 메모리를 해제한다. 이 글에서는 g++ 13.3과 AddressSanitizer로 얕은 복사가 실제로 어떻게 깨지는지 확인하고, 깊은 복사, 자기 대입, copy-and-swap, Rule of 3/5/0, 이동 생성자와 noexcept까지 정리한다.

이 시리즈의 다른 글

기본 복사는 얕은 복사

#include <cstdio>
#include <cstring>

class Buffer {
    char*       data_;
    std::size_t size_;
public:
    explicit Buffer(const char* s) : data_(new char[std::strlen(s) + 1]), size_(std::strlen(s))
    {
        std::memcpy(data_, s, size_ + 1);
    }
    ~Buffer() { delete[] data_; }
    // 복사 생성자/대입 연산자를 쓰지 않음 -> 컴파일러가 멤버별 복사를 만든다
    const char* c_str() const { return data_; }
};

int main()
{
    Buffer a("hello");
    Buffer b = a;              // data_ 포인터 값만 복사
    std::fprintf(stderr, "a=%p b=%p\n", static_cast<const void*>(a.c_str()), static_cast<const void*>(b.c_str()));
}                              // b, a 순서로 같은 메모리를 두 번 delete[]
$ g++ -std=c++20 -Wall -g -fsanitize=address -o sh shallow.cpp
$ ./sh    # ASan 보고 중 에러 종류만 발췌
a=0x502000000010 b=0x502000000010
ERROR: AddressSanitizer: attempting double-free

두 객체의 data_ 주소가 같고, 소멸할 때 같은 메모리를 두 번 해제한다.

깊은 복사와 자기 대입

복사 생성자와 복사 대입 연산자에서 새 메모리를 할당해 내용을 복사한다. 대입 연산자는 기존 자원을 먼저 정리해야 하는데, 이 순서가 자기 대입에서 문제를 만든다.

#include <cstdio>
#include <cstring>

class Buffer {
    char*       data_;
    std::size_t size_;
public:
    explicit Buffer(const char* s) : data_(new char[std::strlen(s) + 1]), size_(std::strlen(s))
    {
        std::memcpy(data_, s, size_ + 1);
    }
    ~Buffer() { delete[] data_; }

    Buffer(const Buffer& o) : data_(new char[o.size_ + 1]), size_(o.size_)   // 깊은 복사
    {
        std::memcpy(data_, o.data_, size_ + 1);
    }
    Buffer& operator=(const Buffer& o)          // 흔한 실수: 자기 대입을 고려하지 않음
    {
        delete[] data_;                         // o가 *this면 원본을 먼저 지워버린다
        size_ = o.size_;
        data_ = new char[size_ + 1];
        std::memcpy(data_, o.data_, size_ + 1);
        return *this;
    }
    const char* c_str() const { return data_; }
};

int main()
{
    Buffer a("hello");
    Buffer b = a;                               // 복사 생성자
    b = Buffer("world");                        // 복사 대입
    std::fprintf(stderr, "a=%s b=%s distinct=%d\n", a.c_str(), b.c_str(), a.c_str() != b.c_str());

    Buffer& alias = a;
    a = alias;                                  // 자기 대입
    std::fprintf(stderr, "a=%s\n", a.c_str());
}
$ g++ -std=c++20 -Wall -g -fsanitize=address -o deep deep.cpp
$ ./deep    # ASan 보고 중 에러 종류와 위치만 발췌
a=hello b=world distinct=1
ERROR: AddressSanitizer: heap-buffer-overflow
    #3 main deep.cpp:38
    #1 Buffer::operator=(Buffer const&) deep.cpp:22

자기 대입에서는 o.data_도 22번 줄에서 새로 할당한 버퍼를 가리키므로, 초기화되지 않은 메모리를 자기 자신에게 복사하게 된다. 원래 내용과 널 종료 문자가 사라져 38번 줄 출력이 버퍼 밖을 읽는다.

copy-and-swap

#include <cstdio>
#include <cstring>
#include <utility>

class Buffer {
    char*       data_;
    std::size_t size_;
public:
    explicit Buffer(const char* s) : data_(new char[std::strlen(s) + 1]), size_(std::strlen(s))
    {
        std::memcpy(data_, s, size_ + 1);
    }
    ~Buffer() { delete[] data_; }
    Buffer(const Buffer& o) : data_(new char[o.size_ + 1]), size_(o.size_)
    {
        std::memcpy(data_, o.data_, size_ + 1);
    }
    Buffer(Buffer&& o) noexcept : data_(std::exchange(o.data_, nullptr)), size_(std::exchange(o.size_, 0)) {}

    // copy-and-swap: 값으로 받아(복사 또는 이동) 교환한다. 자기 대입·예외에 안전
    Buffer& operator=(Buffer o) noexcept
    {
        std::swap(data_, o.data_);
        std::swap(size_, o.size_);
        return *this;                           // 이전 데이터는 o와 함께 소멸
    }
    const char* c_str() const { return data_ ? data_ : "(empty)"; }
};

int main()
{
    Buffer a("hello");
    Buffer& alias = a;
    a = alias;                                  // 자기 대입
    Buffer b("x");
    b = a;                                      // 복사 대입
    Buffer c("y");
    c = std::move(b);                           // 이동 대입
    std::printf("a=%s b=%s c=%s\n", a.c_str(), b.c_str(), c.c_str());
}
$ g++ -std=c++20 -Wall -g -fsanitize=address -o sw swap.cpp
$ ./sw
a=hello b=(empty) c=hello

매개변수를 값으로 받으면 복사(또는 이동)가 호출 전에 끝나므로, 본문은 교환만 하면 된다. 복사 중 예외가 나도 *this는 그대로이고 자기 대입도 자연스럽게 처리된다.

Rule of 3/5/0

규칙내용
Rule of Three소멸자·복사 생성자·복사 대입 중 하나를 직접 정의하면 셋 다 정의한다
Rule of FiveC++11 이후 이동 생성자·이동 대입까지 다섯 개를 함께 고려한다
Rule of Zero자원은 std::string, std::vector, 스마트 포인터 같은 멤버에 맡기고 다섯 개 모두 직접 쓰지 않는다
#include <cstdio>
#include <memory>
#include <string>
#include <vector>

struct Document {                 // 특수 멤버 함수를 하나도 쓰지 않는다 (Rule of Zero)
    std::string        title;
    std::vector<int>   pages;
};

struct Session {
    std::unique_ptr<int> token = std::make_unique<int>(42);   // 복사 불가 멤버
};

int main()
{
    Document a{"spec", {1, 2, 3}};
    Document b = a;               // string, vector가 각자 깊은 복사
    b.pages.push_back(4);
    std::printf("a.pages=%zu b.pages=%zu\n", a.pages.size(), b.pages.size());

    Session s1;
    Session s2 = std::move(s1);   // 이동은 된다
    std::printf("s1.token=%p s2.token=%d\n", static_cast<void*>(s1.token.get()), *s2.token);
    // Session s3 = s2;           // 복사는 막힌다 (주석 해제 시 아래 에러)
}
$ g++ -std=c++20 -Wall -o r0 rule0.cpp
$ ./r0
a.pages=3 b.pages=4
s1.token=(nil) s2.token=42
$ g++ -std=c++20 -Wall -c rule0_err.cpp 2>&1 | grep error | head -1
rule0_err.cpp:25:18: error: use of deleted function ‘Session::Session(const Session&)’

멤버가 알아서 깊은 복사를 하거나 복사를 금지하므로, 클래스의 복사 가능 여부가 멤버로부터 자동으로 정해진다.

이동이 조용히 복사로 바뀌는 경우

소멸자나 복사 연산을 직접 선언하면 컴파일러는 이동 생성자·이동 대입을 만들지 않는다. 이때 std::move는 에러 없이 복사로 대체된다.

#include <cstdio>
#include <utility>

struct Tracked {
    Tracked() = default;
    Tracked(const Tracked&) { std::puts("  copy"); }
    Tracked(Tracked&&) noexcept { std::puts("  move"); }
};

struct Plain {
    Tracked t;
};

struct WithDtor {
    Tracked t;
    ~WithDtor() {}            // 소멸자만 추가했다 -> 암시적 이동 생성자가 사라진다
};

int main()
{
    std::puts("Plain:");    { Plain a;    [[maybe_unused]] Plain b = std::move(a); }
    std::puts("WithDtor:"); { WithDtor a; [[maybe_unused]] WithDtor b = std::move(a); }
}
$ g++ -std=c++20 -Wall -o nm nomove.cpp
$ ./nm
Plain:
  move
WithDtor:
  copy
직접 선언한 것암시적 복사암시적 이동
없음생성생성
소멸자생성 (deprecated)없음 → 복사로 대체
복사 생성자 또는 복사 대입나머지 하나는 생성 (deprecated)없음 → 복사로 대체
이동 생성자 또는 이동 대입삭제됨선언한 것만

이동 생성자의 noexcept

#include <cstdio>
#include <vector>

template <bool NoExcept>
struct Item {
    static inline int copies = 0, moves = 0;
    Item() = default;
    Item(const Item&) { ++copies; }
    Item(Item&&) noexcept(NoExcept) { ++moves; }
};

template <bool NoExcept>
void grow(const char* label)
{
    std::vector<Item<NoExcept>> v;
    for (int i = 0; i < 1000; ++i) v.emplace_back();   // 재할당이 여러 번 일어난다
    std::printf("%-20s copies=%d moves=%d\n", label, Item<NoExcept>::copies, Item<NoExcept>::moves);
}

int main()
{
    grow<true>("move noexcept");
    grow<false>("move may throw");
}
$ g++ -std=c++20 -Wall -o vg vecgrow.cpp
$ ./vg
move noexcept        copies=0 moves=1023
move may throw       copies=1023 moves=0

std::vector는 재할당 중 예외가 나도 원래 상태를 보장해야 해서, 이동 생성자가 noexcept가 아니면 이동 대신 복사를 쓴다. 원소 1000개를 넣는 동안 1023번의 이동이 전부 복사로 바뀌었다.

주의사항

상황문제대응
생 포인터로 자원 소유 + 기본 복사이중 해제, danglingRule of Zero로 멤버에 위임하거나 다섯 개 모두 정의
대입 연산자에서 자원 해제 후 복사자기 대입 시 원본 소실, 할당 실패 시 객체 파손copy-and-swap
디버깅용으로 소멸자만 추가이동이 복사로 바뀌어 성능 저하= default로 이동 연산도 함께 선언
이동 생성자에 noexcept 누락컨테이너 재할당 시 복사이동 연산은 noexcept로 선언
다형 클래스를 값으로 복사파생 부분이 잘림(slicing)업캐스팅 편 참고, 복사 대신 clone()

마무리

  • 기본 복사는 멤버별 복사이며, 생 포인터 멤버는 주소만 복사되어 이중 해제로 이어진다.
  • 직접 자원을 관리한다면 copy-and-swap으로 대입을 구현하고, 가능하면 Rule of Zero로 특수 멤버 함수를 쓰지 않는다.
  • 소멸자 하나만 추가해도 암시적 이동이 사라지고, noexcept가 없는 이동은 컨테이너에서 쓰이지 않는다.

참고

답글 남기기