복사 생성자를 따로 쓰지 않아도 Buffer b = a;는 컴파일된다. 컴파일러가 멤버를 하나씩 복사하는 기본 복사 생성자를 만들어주기 때문이다. 멤버가 값 타입이면 이것으로 충분하지만, 생 포인터로 자원을 소유하고 있으면 포인터 값만 복사되어 두 객체가 같은 메모리를 해제한다. 이 글에서는 g++ 13.3과 AddressSanitizer로 얕은 복사가 실제로 어떻게 깨지는지 확인하고, 깊은 복사, 자기 대입, copy-and-swap, Rule of 3/5/0, 이동 생성자와 noexcept까지 정리한다.
이 시리즈의 다른 글
- C++ 고급 문법 (7) — 생성자(Constructor)
- C++ 고급 문법 (8) — const 멤버 함수(const Member Function)
- C++ 고급 문법 (10) — 함수 포인터(Function Pointer)
- C++ 고급 문법 (11) — 연산자 오버로딩(Operator Overloading)
기본 복사는 얕은 복사
#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 Five | C++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번의 이동이 전부 복사로 바뀌었다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 생 포인터로 자원 소유 + 기본 복사 | 이중 해제, dangling | Rule of Zero로 멤버에 위임하거나 다섯 개 모두 정의 |
| 대입 연산자에서 자원 해제 후 복사 | 자기 대입 시 원본 소실, 할당 실패 시 객체 파손 | copy-and-swap |
| 디버깅용으로 소멸자만 추가 | 이동이 복사로 바뀌어 성능 저하 | = default로 이동 연산도 함께 선언 |
이동 생성자에 noexcept 누락 | 컨테이너 재할당 시 복사 | 이동 연산은 noexcept로 선언 |
| 다형 클래스를 값으로 복사 | 파생 부분이 잘림(slicing) | 업캐스팅 편 참고, 복사 대신 clone() |
마무리
- 기본 복사는 멤버별 복사이며, 생 포인터 멤버는 주소만 복사되어 이중 해제로 이어진다.
- 직접 자원을 관리한다면 copy-and-swap으로 대입을 구현하고, 가능하면 Rule of Zero로 특수 멤버 함수를 쓰지 않는다.
- 소멸자 하나만 추가해도 암시적 이동이 사라지고,
noexcept가 없는 이동은 컨테이너에서 쓰이지 않는다.