C++ 클래스, RAII, 0·3·5 의 규칙: 이중 해제를 ASan 으로 추적
C++ 강좌 3. Apple clang 21 로 실행: 생성자와 소멸자, 파일과 잠금의 RAII, 얕은 복사 이중 해제를 ASan 으로 확인, 0/3/5 의 규칙, explicit 과 {} 함정.
시리즈: C++ 강좌 3/6 (중급). 함수를 쓸 줄 알고 std::vector 와 std::string 을 다루는 분(2 강)을 위한 글입니다. 다 읽으면 생성자와 소멸자로 자원을 관리하는 클래스를 쓰고, 파일과 잠금으로 RAII 를 설명하고, 얕은 복사로 생긴 이중 해제를 ASan 보고서에서 알아보고, 0·3·5 의 규칙 중 무엇을 쓸지 정할 수 있습니다.
C++ 가 "문자열이 편한 C" 에서 벗어나는 건 이 강의부터입니다. 클래스는 메모리, 파일, 잠금, 소켓 같은 자원을 소유합니다. 중급 C++ 코드의 크래시 대부분은 두 객체가 같은 것을 서로 자기 것이라 믿거나, 에러 경로에서 자원이 해제되지 않는 데서 나옵니다. 둘 다 쓰기는 쉽고 리뷰에서는 잘 안 보입니다. 아래 예제는 모두 Apple Silicon Mac(M1 Pro), macOS 26, Apple clang 21.0.0 + libc++ 에서, 따로 적지 않으면 clang++ -std=c++23 -Wall -Wextra -pedantic 으로 직접 실행했습니다.
학교 자료구조 과제에서 연결 리스트 클래스를 만들고 "복사 생성자를 직접 구현하라"는 조건이 왜 붙는지 의아했다면, 이 글의 세 번째 절이 그 답입니다. C 에서 왔다면 C 강좌 3 과 비교해 보세요. 같은 이중 해제인데, 여기서는 버그 있는 코드를 컴파일러가 대신 써 줍니다.
생성자, 소멸자, 그리고 언제 실행되는가
생성자는 객체가 만들어질 때 실행되어 준비를 하고, 소멸자(~이름)는 수명이 끝날 때 실행됩니다. 지역 변수라면 스코프의 닫는 중괄호에서, 만들어진 순서의 반대로 사라집니다.
#include <iostream>
#include <string>
class Tracer {
public:
explicit Tracer(std::string name) : name_(std::move(name)) {
std::cout << " ctor " << name_ << '\n';
}
~Tracer() { std::cout << " dtor " << name_ << '\n'; }
private:
std::string name_;
};
Tracer global("global");
int main() {
std::cout << "main start\n";
Tracer a("a");
{
Tracer b("b");
Tracer c("c");
std::cout << "leaving inner block\n";
}
Tracer* h = new Tracer("heap");
std::cout << "main end\n";
(void)h; // never deleted
}
ctor global
main start
ctor a
ctor b
ctor c
leaving inner block
dtor c
dtor b
ctor heap
main end
dtor a
dtor global
: name_(std::move(name)) 은 멤버 초기화 리스트로, 생성자 본문보다 먼저 멤버를 만듭니다. global 은 main 전에 만들어지고 뒤에 사라집니다. heap 의 dtor 는 찍히지 않습니다. new 로 만든 객체는 누군가 delete 할 때까지 삽니다. macOS 에 들어 있는 도구로 찾을 수 있습니다(-g 로 빌드).
$ leaks --atExit -- ./lifetime_g
Process 43333: 1 leak for 32 total leaked bytes.
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in main>':
3 lifetime_g 0x100620740 main + 220 lifetime.cpp:24
sizeof(Tracer) 는 24(std::string 하나)이고 할당기가 32 로 올렸습니다. 이 Mac 에서는 AddressSanitizer 의 누수 검사를 쓸 수 없어서, ASAN_OPTIONS=detect_leaks=1 은 detect_leaks is not supported on this platform 을 찍었습니다.
RAII: 소멸자를 정리 코드로 쓰기
RAII(Resource Acquisition Is Initialization) 는 자원을 생성자에서 얻고 소멸자에서 놓는다는 뜻입니다. 소멸자는 return 이든 예외든 스코프를 나갈 때 항상 실행되므로 정리를 잊을 수가 없습니다.
왜 중요한지 보여 주는 실험입니다. 두 함수가 파일을 열어 1 바이트를 읽고, 헤더가 틀리면 예외를 던집니다. 수동 버전은 마지막에 fclose 를 부르고, RAII 버전은 File 클래스에 맡깁니다.
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <string>
int first_char_manual(const char* path) {
std::FILE* f = std::fopen(path, "r");
if (!f) throw std::runtime_error(std::string("fopen: ") + std::strerror(errno));
int c = std::fgetc(f);
if (c != 'X') throw std::runtime_error("bad header"); // leaks f
std::fclose(f);
return c;
}
class File {
public:
File(const char* path, const char* mode) : f_(std::fopen(path, mode)) {
if (!f_) throw std::runtime_error(std::string("fopen: ") + std::strerror(errno));
}
~File() { std::fclose(f_); }
File(const File&) = delete; // one owner only
File& operator=(const File&) = delete;
std::FILE* get() const { return f_; }
private:
std::FILE* f_;
};
int first_char_raii(const char* path) {
File f(path, "r");
int c = std::fgetc(f.get());
if (c != 'X') throw std::runtime_error("bad header"); // f closed here
return c;
}
int main(int argc, char** argv) {
bool raii = argc > 1 && std::string(argv[1]) == "raii";
for (int i = 1; i <= 1000; ++i) {
try {
raii ? first_char_raii("data.txt") : first_char_manual("data.txt");
} catch (const std::exception& e) {
if (std::string(e.what()) != "bad header") {
std::printf("call %d: %s\n", i, e.what());
return 1;
}
}
}
std::printf("%s: 1000 calls, no error\n", raii ? "raii" : "manual");
}
서브셸에서 열 수 있는 파일 수 한도를 256 으로 두고 실행했습니다.
$ (ulimit -n 256; ./raii_file manual; ./raii_file raii)
call 254: fopen: Too many open files
raii: 1000 calls, no error
수동 버전은 에러가 날 때마다 파일 핸들을 하나씩 흘리다가 254 번째 호출에서 죽었습니다(256 에서 stdin, stdout, stderr 를 빼고 하나 더한 수). 서버에서 이 에러 경로가 한 시간에 한 번 지난다면, 크래시는 배포 며칠 뒤에 찾아옵니다. RAII 버전은 1,000 번 모두 통과했습니다.
= delete 는 복사를 막습니다. 두 File 이 같은 FILE* 를 닫으면 다음 절 버그의 "이중 닫기" 판이 되기 때문입니다. 이제 컴파일러가 막아 줍니다.
filecopy.cpp:13:10: error: call to deleted constructor of 'File'
13 | File b = a;
filecopy.cpp:6:5: note: 'File' has been explicitly marked deleted here
잠금도 자원이다: RAII 는 공짜인가
std::lock_guard 는 생성자에서 mutex 를 잠그고 소멸자에서 풉니다. 잠금과 해제 사이에 예외가 나도 mutex 가 영원히 잠긴 채로 남지 않습니다. 직접 lock()/unlock() 하는 것과 비교했습니다. 경합 없이 1,000 만 번, 7 회 중앙값입니다.
[[gnu::noinline]] void inc_manual() { m.lock(); ++counter; m.unlock(); }
[[gnu::noinline]] void inc_guard() { std::lock_guard<std::mutex> lk(m); ++counter; }
| 빌드 | 직접 | lock_guard |
|---|---|---|
-O2 |
86.5 ms | 86.0 ms |
-O0 |
88.2 ms | 96.8 ms |
-O2 에서는 두 함수가 같은 명령어로 컴파일됐습니다(objdump -d 로 비교). 차이는 잡음입니다. -O0 에서는 아무것도 인라인되지 않아 가드의 생성자와 소멸자가 진짜 호출이 되고, 여기서는 약 10% 였습니다. "추상화는 최적화하면 공짜"라는 C++ 의 약속 그대로입니다.
재는 방법도 적어 둡니다. 첫 측정은 틀렸습니다. 어떤 버전을 돌릴지를 반복문 안에서 strcmp 로 고르게 했더니 std::scoped_lock 이 lock_guard 보다 약 30% 느려 보였습니다. 디스어셈블해 보니 함수는 똑같았고, 늘어난 시간은 세 번째 분기까지 가는 strcmp 두 번이었습니다. 위 숫자는 선택을 반복문 밖(함수 포인터를 한 번만 고름)으로 뺀 뒤에 잰 것입니다.
이중 해제로 끝나는 얕은 복사
이번에는 생 포인터로 힙 메모리를 가지고, 소멸자만 쓴 클래스입니다.
#include <cstddef>
#include <cstring>
#include <iostream>
class Buffer {
public:
explicit Buffer(std::size_t n) : size_(n), data_(new char[n]) {
std::memset(data_, 0, n);
}
~Buffer() { delete[] data_; }
char* data() { return data_; }
std::size_t size() const { return size_; }
private:
std::size_t size_;
char* data_;
};
int main() {
Buffer a(16);
std::strcpy(a.data(), "hello");
Buffer b = a; // compiler-generated copy: copies the pointer
b.data()[0] = 'J';
std::cout << "a: " << a.data() << " b: " << b.data() << '\n';
} // b's destructor frees, then a's destructor frees the same block
-Wall -Wextra -pedantic 에서도 경고 없이 컴파일됩니다. 터미널에서 실행하면:
a: Jello b: Jello
Trace/BPT trap: 5 (exit code 133)
b 를 바꿨더니 a 도 바뀌었습니다. 둘 다 같은 블록을 가리키니까요. 그리고 두 번째 delete[] 에서 메시지 하나 없이 트랩 시그널로 죽었습니다(출력을 파일로 파이프하면 첫 줄마저 사라집니다. 크래시가 버퍼에 남은 출력을 버리기 때문입니다). AddressSanitizer 는 모든 걸 설명합니다.
==43345==ERROR: AddressSanitizer: attempting double-free on 0x6020000000f0 in thread T0:
#1 0x000104bb9460 in Buffer::~Buffer() buffer_bad.cpp:10
#3 0x000104bb8c7c in main buffer_bad.cpp:24
0x6020000000f0 is located 0 bytes inside of 16-byte region [0x6020000000f0,0x602000000100)
freed by thread T0 here:
#1 0x000104bb9460 in Buffer::~Buffer() buffer_bad.cpp:10
#3 0x000104bb8c60 in main buffer_bad.cpp:24
previously allocated by thread T0 here:
#1 0x000104bb9304 in Buffer::Buffer(unsigned long) buffer_bad.cpp:7
SUMMARY: AddressSanitizer: double-free buffer_bad.cpp:10 in Buffer::~Buffer()
같은 소멸자, 같은 24 번째 줄(main 의 닫는 중괄호)에서 해제가 두 번입니다.
실행하기 전에 잡는 방법도 있습니다. 다음 두 도구가 찾아냈습니다.
$ clang++ -std=c++23 -Wdeprecated -c buffer_bad.cpp
buffer_bad.cpp:10:5: warning: definition of implicit copy constructor for 'Buffer' is deprecated because it has a user-provided destructor [-Wdeprecated-copy-with-user-provided-dtor]
$ clang++ --analyze -std=c++23 buffer_bad.cpp
buffer_bad.cpp:10:17: warning: Attempt to free released memory [cplusplus.NewDelete]
-Wdeprecated 는 -Wall -Wextra 에 들어 있지 않습니다. 추가를 고려할 만합니다.
3 의 규칙과 5 의 규칙: 올바른 Buffer
3 의 규칙은 소멸자, 복사 생성자, 복사 대입 중 하나라도 직접 써야 하는 클래스라면 거의 확실히 셋 다 필요하다는 규칙입니다. C++11 에서 이동 연산이 더해져 5 의 규칙이 됐습니다. 대입 연산자 하나로 복사와 이동을 모두 처리하는 copy-and-swap 관용구로 올바른 Buffer 를 씁니다.
#include <cstddef>
#include <cstring>
#include <iostream>
#include <utility>
class Buffer {
public:
explicit Buffer(std::size_t n) : size_(n), data_(new char[n]()) {}
~Buffer() { delete[] data_; }
Buffer(const Buffer& o) : size_(o.size_), data_(new char[o.size_]) { // deep copy
std::memcpy(data_, o.data_, size_);
std::cout << " copy ctor\n";
}
Buffer(Buffer&& o) noexcept // steal
: size_(std::exchange(o.size_, 0)), data_(std::exchange(o.data_, nullptr)) {
std::cout << " move ctor\n";
}
Buffer& operator=(Buffer other) noexcept { // other was built by copy or move ctor
swap(*this, other);
std::cout << " assign (swap)\n";
return *this;
} // other's destructor frees our old block
friend void swap(Buffer& a, Buffer& b) noexcept {
std::swap(a.size_, b.size_);
std::swap(a.data_, b.data_);
}
char* data() { return data_; }
std::size_t size() const { return size_; }
private:
std::size_t size_;
char* data_;
};
-fsanitize=address,undefined 로 빌드한 테스트용 main 의 출력입니다. 종료 코드는 0 이었습니다.
Buffer b = a;
copy ctor
a: hello b: Jello
a = a;
copy ctor
assign (swap)
a: hello
Buffer c = std::move(a);
move ctor
a.size() after move: 0 c: hello
b = Buffer(8);
assign (swap)
이제 a 와 b 는 독립입니다. 눈여겨볼 점이 둘 있습니다. a = a(clang 은 -Wself-assign-overloaded 로 경고)가 잘 되는 건 copy-and-swap 이 먼저 복사하고 마지막에 해제하기 때문입니다. 그리고 b = Buffer(8) 에서는 이동조차 찍히지 않았습니다. C++17 부터는 값으로 받는 매개변수에 임시 객체를 넘기면 매개변수 자리에 바로 만들어집니다.
자기 대입은 왜 신경 써야 할까요. delete[] data_; 한 다음 o.data_ 를 복사하는 순진한 operator= 는 &o == this 일 때 자기 데이터를 날립니다. i == j 일 때의 v[i] = v[j] 같은 코드에서 일어납니다. 해 보니 일반 빌드는 a: 뒤에 아무것도 찍지 않았고, ASan 은 출력하는 줄의 strlen 안에서 heap-buffer-overflow 를 보고했습니다. 진짜 버그보다 두 줄 뒤입니다. 새니타이저는 메모리를 잘못 쓴 곳을 알려 줄 뿐, 논리가 틀린 곳을 알려 주지는 않습니다.
0 의 규칙: 실제로 써야 할 버전
포인터 하나를 위해 특수 함수 다섯 개를 올바르게 쓰는 건 큰일입니다. 0 의 규칙은 평범한 클래스에서는 생 자원을 소유하지 말라는 겁니다. 스스로 정리하는 멤버를 쓰고, 다섯 개 중 아무것도 쓰지 않습니다.
class Buffer { // rule of zero
public:
explicit Buffer(std::size_t n) : bytes_(n) {}
char* data() { return bytes_.data(); }
std::size_t size() const { return bytes_.size(); }
private:
std::vector<char> bytes_; // copies, moves and frees itself
};
class Packet { // move-only, still rule of zero
std::unique_ptr<char[]> payload_;
public:
explicit Packet(std::size_t n) : payload_(std::make_unique<char[]>(n)) {}
};
Buffer b = a; 는 ASan 에서도 에러 없이 a: hello b: Jello 를 찍습니다. Packet 은 복사할 수 없고, 에러가 이유를 알려 줍니다.
rule0e.cpp:35:12: error: call to implicitly-deleted copy constructor of 'Packet'
note: copy constructor of 'Packet' is implicitly deleted because field 'payload_' has a deleted copy constructor
어중간한 함정: 소멸자만 쓰면 이동이 사라진다
0 의 규칙 클래스에 (로그 등을 위해) "소멸자만" 추가하면 조용히 성질이 바뀝니다.
struct Payload {
Payload() = default;
Payload(const Payload&) { std::cout << " Payload copied\n"; }
Payload(Payload&&) noexcept { std::cout << " Payload moved\n"; }
};
struct Plain { Payload p; }; // rule of zero
struct Logged { Payload p; ~Logged() { /* log something */ } }; // only a destructor added
Plain b = std::move(a):
Payload moved
Logged d = std::move(c):
Payload copied
사용자가 선언한 소멸자는 암시적 이동 연산의 생성을 막아서, std::move 가 복사로 돌아갑니다. 경고는 없습니다. 안에 큰 vector 가 있다면 조용한 성능 버그입니다(이동과 복사의 차이는 4 강 에서 잽니다). 소멸자를 아예 쓰지 않거나, 다섯 개를 모두 선언하고 기본값으로 충분한 것은 = default 로 둡니다.
멤버 비용을 이 플랫폼의 sizeof 로 쟀습니다.
| 클래스 | sizeof |
|---|---|
빈 struct {} |
1 |
virtual 소멸자 하나 |
8 (vtable 포인터) |
생 size_t + char* |
16 |
std::vector<char> 멤버 |
24 |
std::unique_ptr<char[]> 멤버 |
8, 생 포인터와 같음 |
explicit 과 초기화 리스트 함정
인자 하나짜리 생성자는 암시적 변환이기도 합니다. explicit 이 없으면 다음 코드가 컴파일됩니다.
class Buffer {
public:
Buffer(std::size_t n) : bytes_(n) {} // NOT explicit
std::size_t size() const { return bytes_.size(); }
private:
std::vector<char> bytes_;
};
void send(const Buffer& b) { std::cout << "sending " << b.size() << " bytes\n"; }
send(1000000); // "sending 1000000 bytes": a 1 MB buffer built from an int
Buffer b = 'A'; // b.size() = 65
explicit 을 붙이면 둘 다 에러가 됩니다. no matching function for call to 'send'(메모는 no known conversion from 'int' to 'const Buffer')와 no viable conversion from 'char' to 'Buffer' 입니다. 변환 자체가 목적(const char* 에서 std::string 처럼)이 아니라면, 인자 하나짜리 생성자는 explicit 으로 둡니다.
클래스에 std::initializer_list 생성자가 있으면 중괄호와 소괄호가 서로 다른 생성자를 부릅니다.
a(3, 5): size 3 b{3, 5}: size 2 // std::vector<int>
s1 = "xxx" size 3 s2 size 2 first byte 3 // std::string s1(3, 'x'), s2{3, 'x'}
Range r1(1, 10):
Range(int,int)
Range r2{1, 10}:
Range(initializer_list) size 2
sizeof x = 4 y.size() = 1 // auto x{42}; auto y = {42};
std::vector<int> b{3, 5} 는 "5 가 세 개"가 아니라 원소 두 개입니다. std::string s2{3, 'x'} 는 첫 바이트가 제어 문자 3 인 문자열입니다. 내 클래스에 initializer_list 생성자를 추가하면 호출하는 쪽의 중괄호가 모두 그쪽으로 넘어갑니다.
같은 영역에서 두 가지 더.
vexing.cpp:4:13: warning: empty parentheses interpreted as a function declaration [-Wvexing-parse]
4 | Widget w(); // meant: default-construct a Widget
vexing.cpp:5:12: error: base of member reference is a function; perhaps you meant to call it with no arguments?
reorder.cpp:5:35: warning: initializer order does not match the declaration order [-Wreorder-ctor]
reorder.cpp:5:79: warning: field 'width_' is uninitialized when used here [-Wuninitialized]
Widget w(); 는 함수 선언입니다("most vexing parse"). Widget w; 나 Widget w{}; 로 씁니다. 멤버는 초기화 리스트 순서가 아니라 선언 순서로 초기화됩니다. Label 은 height_ 를 먼저 선언하고 아직 만들어지지 않은 width_ 로 계산했습니다. 일반 빌드는 width 2 height -8, ASan 빌드는 width 2 height 1 을 찍었습니다. 실행할 때마다 쓰레기 값이 달라지는 것이 초기화 안 된 값을 읽었다는 신호입니다.
흔한 실수 → 실제로 일어난 일 → 해결
| 실수 | 실제로 본 것 | 해결 |
|---|---|---|
클래스 안 생 new, 소멸자만 |
종료 코드 133, ASan attempting double-free |
0 의 규칙(std::vector, unique_ptr) |
순진한 operator= |
자기 대입에서 데이터 소실, ASan 은 strlen 을 지목 |
copy-and-swap |
throw 전에 직접 fclose |
254 번째에 Too many open files |
RAII 래퍼 |
| 소멸자만 추가 | std::move 가 조용히 복사 |
다섯 개 모두 안 쓰거나 모두 쓰기 |
| 인자 하나짜리 생성자 | send(1000000) 이 컴파일됨 |
explicit |
std::vector<int> v{3, 5} |
원소 2 개 | "5 가 세 개"는 v(3, 5) |
| 초기화 리스트 순서 어긋남 | height -8 |
의존 순서대로 멤버 선언, -Wreorder-ctor 따르기 |
솔직한 한계와 버전 메모
- 실행한 것은 arm64 macOS 26 의 Apple clang 21.0.0 과 libc++ 뿐입니다. GCC, MSVC, Linux, Valgrind 는 돌려 보지 않았습니다. Linux 라면 이중 해제에서 glibc 가
free(): double free detected같은 메시지를 낼 것으로 보지만(실행 안 함), macOS 는 아무 말 없이 트랩했습니다. - 이동 생성자,
= delete,= default,noexcept,std::initializer_list는 C++11.std::exchange와std::make_unique는 C++14.std::scoped_lock과 임시 객체의 복사 생략 보장은 C++17. -Wdeprecated-copy-with-user-provided-dtor: 이 경우의 암시적 복사는 C++11 부터 deprecated 이고, 경고 이름은 clang 의 것입니다.- 잠금 시간은 경합 없음, 단일 스레드, 기계 한 대에서 잰 값입니다. 경합하는 잠금은 다른 이야기이고 6 강 에서 잽니다.
연습 문제
1. 다음 코드는 경고 없이 컴파일됩니다. 실행하면 무슨 일이 일어나고, 0 의 규칙으로 어떻게 고치나요?
class Holder {
public:
explicit Holder(int v) : p_(new int(v)) {}
~Holder() { delete p_; }
int value() const { return *p_; }
private:
int* p_;
};
int main() {
std::vector<Holder> v;
v.push_back(Holder(1));
std::cout << v[0].value() << '\n';
}
2. 각 vector 의 원소 수는? std::vector<int> a(5); std::vector<int> b{5}; std::vector<int> c(5, 1); std::vector<int> d{5, 1};
정답
1. Holder(1) 은 임시 객체입니다. push_back 이 그것을 복사하고(만들어진 복사는 포인터만 복사), 이어서 임시 객체의 소멸자가 int 를 delete 합니다. v[0] 은 허공을 가리키는 포인터를 갖게 됩니다. ASan 은 Holder::value() const ex.cpp:7 에서 heap-use-after-free, 해제는 13 번째 줄에서 불린 Holder::~Holder() ex.cpp:6 이라고 보고했습니다. 끝에는 vector 의 소멸자가 한 번 더 delete 하려 합니다. std::unique_ptr<int> p_; 를 std::make_unique<int>(v) 로 초기화하고 소멸자를 지우면 됩니다. Holder 는 이동 전용이 되고 push_back(Holder(1)) 은 이동합니다. 원소 두 개로 시험하니 1 2 를 찍었고 ASan 도 조용했습니다.
2. a: 0 이 5 개. b: 원소 1 개(5). c: 1 이 5 개. d: 원소 2 개(5 와 1).
다음 강의
4 강에서 생 new 와 작별합니다. unique_ptr, shared_ptr, weak_ptr, 소멸자 출력으로 눈에 보이는 순환 참조 누수, 그리고 std::move 와 noexcept 가 커지는 vector 에 무슨 일을 하는지 다룹니다.
- 이전: C++ 강좌 2/6: 함수·오버로딩·vector·string
- 다음: C++ 강좌 4/6: 스마트 포인터와 이동 의미론
- 시리즈 처음: C++ 강좌 1/6, 이어서: 템플릿과 STL 알고리즘, 성능과 동시성
- 관련 글: 소멸자 없이 소유권을 다루는 다른 언어로 Swift 메모리·ARC·성능, 수동 메모리 관리 연결 리스트는 자료구조: 연결 리스트
- C++ 의 위치: 프로그래밍 학습 로드맵
자주 묻는 질문
C++ 클래스를 복사하면 왜 이중 해제가 나나요?
new 로 받은 생 포인터를 가지고 소멸자에서 해제하는 클래스라면, 컴파일러가 만든 복사 생성자는 포인터만 복사합니다. 두 객체가 블록 하나를 소유하고 둘 다 delete 합니다. 실제로 Buffer 클래스는 종료 코드 133(Trace/BPT trap)으로 죽었고, AddressSanitizer 는 Buffer::~Buffer() 에서 "attempting double-free" 를 보고했습니다. 깊은 복사를 쓰거나(3/5 의 규칙), 더 낫게는 멤버를 std::vector 로 바꿉니다(0 의 규칙).
C++ 의 0 의 규칙(rule of zero)이란?
클래스에 소멸자도 복사·이동 연산도 쓰지 않고, 스스로 정리하는 멤버(std::string, std::vector, std::unique_ptr)에게 맡기는 것입니다. 그러면 컴파일러가 만든 연산이 올바르게 동작합니다. 소멸자만 쓰는 건 아무것도 안 쓰는 것보다 나쁩니다. 실험에서 std::move 가 조용히 복사로 바뀌었는데, 사용자가 선언한 소멸자가 암시적 이동 생성자를 막기 때문입니다.
RAII 나 std::lock_guard 는 성능을 깎나요?
최적화를 켜면 그렇지 않습니다. -O2 에서 lock/unlock 1,000 만 번이 직접 호출 86.5 ms, std::lock_guard 86.0 ms 였고, 두 함수는 같은 기계어로 컴파일됐습니다. -O0 에서는 생성자와 소멸자가 진짜 함수 호출이 되어 가드가 약 10% 느렸습니다(96.8 ms 대 88.2 ms).