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 에서 직접 잠금은 86.5 밀리초, std::lock_guard 는 86.0 으로 둘은 같은 기계어가 된다. -O0 에서는 직접 88.2, lock_guard 96.8. 인라인되지 않으면 가드의 생성자와 소멸자가 진짜 함수 호출이 되기 때문이다.
경합 없는 std::mutex 에 lock·증가·unlock 1,000 만 번. 직접 lock()/unlock() 하는 경우와 std::lock_guard 비교. 7 회 중앙값, Apple Silicon Mac(M1 Pro).

-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 의 닫는 중괄호)에서 해제가 두 번입니다.

그림. Buffer a 는 new char[16] 으로 잡은 16 바이트 블록을 가진다. Buffer b = a 는 포인터만 복사하므로 b 도 같은 블록을 가리킨다. main 이 끝날 때 b 의 소멸자가 먼저 블록을 해제하고, 이어 a 의 소멸자가 같은 블록을 또 해제해 이중 해제가 된다. 고치는 법은 복사 생성자에서 깊은 복사를 하거나, 멤버를 std::vector 로 바꿔 소멸자 자체를 쓰지 않는 것이다.
생 포인터를 가진 클래스를 컴파일러가 만든 복사로 복사하면 죽는 이유. 객체 둘, 블록 하나, delete[] 두 번. ASan 은 "attempting double-free" 라고 보고했고, ASan 없이는 종료 코드 133 으로 죽었다.

실행하기 전에 잡는 방법도 있습니다. 다음 두 도구가 찾아냈습니다.

$ 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
판단 그림. 클래스가 new, fopen, 소켓 같은 생 핸들로 자원을 직접 소유하는가. 아니면 0 의 규칙: 소멸자도 복사·이동 함수도 쓰지 말고 std::string, std::vector, std::unique_ptr 같은 멤버에게 맡긴다. 그렇다면 그 핸들을 작은 RAII 클래스나 삭제자를 단 unique_ptr 로 감쌀 수 있는지 묻고, 감쌀 수 있으면 그렇게 해서 0 의 규칙으로 돌아간다. 감쌀 수 없으면 다섯 가지(소멸자, 복사 생성자, 복사 대입, 이동 생성자, 이동 대입)를 모두 쓰거나, 복사가 말이 안 되면 복사 두 개를 delete 한다.
0·3·5 규칙 가운데 지금 쓰는 클래스에 맞는 것 고르기. 대부분의 클래스는 첫 번째 답(0 의 규칙)에서 끝나야 한다.

어중간한 함정: 소멸자만 쓰면 이동이 사라진다

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++ 클래스를 복사하면 왜 이중 해제가 나나요?

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).

  • #C++
  • #중급
  • #RAII
  • #5의 규칙
  • #AddressSanitizer
  • #생성자

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑