Devin.KR

C++ 복사와 이동 - 규칙 0/3/5, 얕은 복사 이중 해제, noexcept 이동 (C++ 객체와 자원 관리 4장)

개발자 조회 1

이 장에서 배우는 것

3장의 File은 복사를 금지해서 문제를 피했다. 하지만 이미지 버퍼, 오디오 샘플, 네트워크 패킷처럼 복사가 필요한 자원도 있다. 그리고 복사가 비쌀 때 "복사하지 말고 내용물만 옮기는" 방법도 필요하다. 이 장에서는 C++ 객체가 복사되고 옮겨질 때 불리는 네 함수(복사 생성자, 복사 대입, 이동 생성자, 이동 대입)를 직접 구현하고, 언제 무엇을 써야 하는지 규칙 0/3/5로 정리한다.

  • 원시 포인터를 가진 클래스를 기본 복사에 맡기면 두 객체가 같은 메모리를 가리키다 이중 해제로 끝나는 과정을 본다.
  • 복사 생성자, 이동 생성자, 복사 후 교환(copy-and-swap) 대입을 구현한다.
  • std::vector가 원소를 옮길 때 이동 생성자에 noexcept가 없으면 복사로 바꿔 버리는 것을 숫자로 확인한다.

문제 상황

센서 데이터를 담는 고정 크기 버퍼 클래스를 만들었다. 생성자에서 new[]로 메모리를 얻고 소멸자에서 delete[]로 돌려주니 3장의 RAII 원칙은 지켰다. 그런데 복사에 대해서는 아무것도 쓰지 않았다.

#include <cstddef>
#include <iostream>

class Buffer {
public:
    explicit Buffer(std::size_t size) : data_(new unsigned char[size]{}) {}
    ~Buffer() { delete[] data_; }

    unsigned char& operator[](std::size_t i) { return data_[i]; }

private:
    unsigned char* data_;
};

int main() {
    Buffer a(64);
    a[0] = 42;
    {
        Buffer b = a;
        b[0] = 7;
        std::cout << "a[0] = " << int(a[0]) << std::endl;
    }
    std::cout << "블록을 나왔다" << std::endl;
}
$ clang++ -std=c++20 -Wall -Wextra buffer_bug.cpp -o buffer_bug && ./buffer_bug
a[0] = 7
블록을 나왔다
/bin/bash: line 1: 44184 Trace/BPT trap: 5       ./buffer_bug

이상한 일이 두 가지 일어났다. 첫째, b[0] = 7이라고 썼는데 a[0]이 7이 되었다. 둘째, 블록을 나온 뒤 프로그램이 Trace/BPT trap으로 비정상 종료했다(종료 코드 133).

원인은 컴파일러가 만들어 준 기본 복사 생성자다. 기본 복사는 멤버를 하나씩 그대로 복사한다. data_는 포인터이므로 주소 값만 복사된다. 이것을 얕은 복사라고 한다. ab가 같은 메모리를 가리키게 되고, 블록 끝에서 b의 소멸자가 그 메모리를 해제한다. main이 끝날 때 a의 소멸자가 이미 해제된 메모리를 다시 해제하고, macOS의 메모리 할당기가 이것을 감지해 프로세스를 멈췄다. 이중 해제는 할당기의 내부 구조를 망가뜨리기 때문에, 환경에 따라서는 멈추지 않고 한참 뒤 엉뚱한 곳에서 터지기도 한다.

기본 복사는 포인터 값만 복사해 a 와 b 가 같은 힙 메모리를 가리키고, 둘 다 해제하려다 이중 해제가 된다. 깊은 복사는 새 메모리를 얻어 내용을 옮기므로 각자 자기 것만 해제한다.

이런 버그는 실행하기 전에 clang 정적 분석기로도 찾을 수 있다.

$ clang++ -std=c++20 --analyze buffer_bug.cpp
buffer_bug.cpp:7:17: warning: Attempt to free released memory [cplusplus.NewDelete]
    7 |     ~Buffer() { delete[] data_; }
      |                 ^~~~~~~~~~~~~~
1 warning generated.

완성 코드

복사 생성자, 이동 생성자, 대입 연산자를 제대로 갖춘 buffer.cpp다. 각 함수가 불릴 때 이름을 출력한다.

#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t size) : size_(size), data_(new unsigned char[size]{}) {
        log("생성");
    }
    ~Buffer() { delete[] data_; }

    Buffer(const Buffer& other) : size_(other.size_), data_(new unsigned char[other.size_]) {
        std::copy(other.data_, other.data_ + size_, data_);
        log("복사 생성");
    }

    Buffer(Buffer&& other) noexcept
        : size_(std::exchange(other.size_, 0)), data_(std::exchange(other.data_, nullptr)) {
        log("이동 생성");
    }

    Buffer& operator=(Buffer other) noexcept {
        swap(other);
        log("대입");
        return *this;
    }

    void swap(Buffer& other) noexcept {
        std::swap(size_, other.size_);
        std::swap(data_, other.data_);
    }

    std::size_t size() const { return size_; }
    unsigned char& operator[](std::size_t i) { return data_[i]; }

private:
    void log(const char* what) const { std::cout << "  " << what << " (" << size_ << "바이트)\n"; }

    std::size_t size_;
    unsigned char* data_;
};

int main() {
    std::cout << "Buffer a(16)\n";
    Buffer a(16);
    a[0] = 42;

    std::cout << "Buffer b = a\n";
    Buffer b = a;
    b[0] = 7;
    std::cout << "  a[0]=" << int(a[0]) << ", b[0]=" << int(b[0]) << '\n';

    std::cout << "Buffer c = std::move(a)\n";
    Buffer c = std::move(a);
    std::cout << "  a.size()=" << a.size() << ", c[0]=" << int(c[0]) << '\n';

    std::cout << "b = c\n";
    b = c;

    std::cout << "b = Buffer(8)\n";
    b = Buffer(8);
    std::cout << "  b.size()=" << b.size() << '\n';
    std::cout << "main 끝\n";
}

줄별 해설

복사 생성자 Buffer(const Buffer& other) — 깊은 복사

새 메모리를 따로 얻고(new unsigned char[other.size_]), 내용을 std::copy로 옮겨 적는다. 이제 두 객체는 각자 자기 메모리를 소유한다. 이것을 깊은 복사라고 한다. 한쪽을 바꿔도 다른 쪽은 그대로고, 각자 소멸자에서 자기 것만 해제한다.

이동 생성자 Buffer(Buffer&& other) noexcept

&&는 우측값 참조다. "곧 사라질 객체" 또는 "내용물을 가져가도 된다고 허락받은 객체"에만 묶인다. 이동 생성자는 새 메모리를 얻지 않는다. 상대의 포인터를 그대로 가져오고, 상대의 포인터는 nullptr로 바꾼다. std::exchange(x, v)xv를 넣고 원래 값을 돌려주는 함수라서 "가져오고 비우기"를 한 줄로 쓸 수 있다. 상대를 비워 두는 이유는 상대의 소멸자도 결국 불리기 때문이다. delete[] nullptr은 아무 일도 하지 않으므로 안전하다. 이동은 포인터 두 개를 옮기는 일이라 크기와 상관없이 빠르고, 메모리를 얻지 않으니 실패할 일도 없다. 그래서 noexcept를 붙인다(이유는 뒤에서 숫자로 본다).

Buffer c = std::move(a) 는 새 메모리를 얻지 않고 a 의 포인터를 c 로 옮긴 뒤 a 를 nullptr 로 비운다. 할당이 없으므로 실패할 일이 없어 noexcept 를 붙일 수 있다.

Buffer& operator=(Buffer other) noexcept — 복사 후 교환

대입 연산자는 매개변수를 값으로 받는다. 복사 대입(b = c)이면 other가 복사 생성자로 만들어지고, 이동 대입(b = Buffer(8))이면 이동하거나 아예 그 자리에 바로 만들어진다. 함수 안에서는 swap으로 내 내용물과 other의 내용물을 바꾼다. 함수가 끝나면 other가 소멸하면서 내 옛 메모리를 해제한다. 이 방식의 장점은 세 가지다. 자기 자신에게 대입(b = b)해도 안전하고, 복사 도중 메모리가 부족해 예외가 나도 원래 객체는 멀쩡하며(9장의 강한 보장), 복사 대입과 이동 대입을 함수 하나로 처리한다.

std::move(a)

std::move는 아무것도 옮기지 않는다. a를 "내용물을 가져가도 되는 객체"로 표시(우측값으로 형변환)할 뿐이다. 실제로 옮기는 일은 그 표시를 받은 이동 생성자가 한다. 이동된 뒤의 a는 "유효하지만 내용은 정해지지 않은" 상태다. 이 클래스에서는 크기 0인 빈 버퍼다. 다시 값을 대입해서 쓰는 것은 괜찮지만, 이동된 객체의 내용을 읽고 의미를 기대하면 안 된다.

실제 실행 결과

$ clang++ -std=c++20 -Wall -Wextra buffer.cpp -o buffer && ./buffer
Buffer a(16)
  생성 (16바이트)
Buffer b = a
  복사 생성 (16바이트)
  a[0]=42, b[0]=7
Buffer c = std::move(a)
  이동 생성 (16바이트)
  a.size()=0, c[0]=42
b = c
  복사 생성 (16바이트)
  대입 (16바이트)
b = Buffer(8)
  생성 (8바이트)
  대입 (8바이트)
  b.size()=8
main 끝

표 4-1. 문장마다 불린 특수 멤버 함수(buffer.cpp 실행 결과)

문장불린 함수
Buffer b = a;복사 생성
Buffer c = std::move(a);이동 생성
b = c;복사 생성(매개변수) + 대입
b = Buffer(8);생성(매개변수 자리에 바로) + 대입
  • Buffer b = a — 복사 생성. b[0]을 7로 바꿔도 a[0]은 42 그대로다. 문제 상황의 공유 버그가 사라졌다.
  • Buffer c = std::move(a) — 이동 생성. a.size()가 0이 되고 42는 c로 옮겨 갔다.
  • b = c — 매개변수 other를 만드느라 복사 생성이 먼저 찍히고, 그다음 대입이 찍힌다.
  • b = Buffer(8) — 임시 객체가 매개변수 자리에 바로 만들어져서 복사도 이동도 없이 생성 한 번, 대입 한 번이다.
  • 마지막에 비정상 종료가 없다. 이중 해제가 사라졌다.

noexcept가 성능을 바꾸는 이유

std::vector는 용량이 차면 더 큰 메모리를 얻고 기존 원소를 옮긴다(6장). 옮기는 도중 예외가 나면 반쯤 옮겨진 상태가 되는데, vector는 "실패하면 원래대로"라는 약속을 지키고 싶어 한다. 그래서 이동 생성자가 예외를 던지지 않는다고 선언되어 있을 때만 이동을 쓰고, 그렇지 않으면 원본을 남겨 두는 복사를 쓴다.

#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

struct Safe {
    static inline int copies = 0;
    static inline int moves = 0;
    std::string data = "texture";

    Safe() = default;
    Safe(const Safe& o) : data(o.data) { ++copies; }
    Safe(Safe&& o) noexcept : data(std::move(o.data)) { ++moves; }
};

struct Risky {
    static inline int copies = 0;
    static inline int moves = 0;
    std::string data = "texture";

    Risky() = default;
    Risky(const Risky& o) : data(o.data) { ++copies; }
    Risky(Risky&& o) : data(std::move(o.data)) { ++moves; }
};

struct Zero {
    std::string name = "texture";
    std::vector<unsigned char> pixels;
};

int main() {
    std::vector<Safe> safe;
    std::vector<Risky> risky;
    for (int i = 0; i < 5; ++i) {
        safe.emplace_back();
        risky.emplace_back();
    }
    std::cout << "noexcept 이동:   복사 " << Safe::copies << "회, 이동 " << Safe::moves << "회\n";
    std::cout << "noexcept 없음:   복사 " << Risky::copies << "회, 이동 " << Risky::moves << "회\n";
    std::cout << "capacity: " << safe.capacity() << '\n';

    std::cout << std::boolalpha;
    std::cout << "Zero 이동이 noexcept? " << std::is_nothrow_move_constructible_v<Zero> << '\n';
    std::cout << "Risky 이동이 noexcept? " << std::is_nothrow_move_constructible_v<Risky> << '\n';
}
$ clang++ -std=c++20 -Wall -Wextra noexcept_move.cpp -o noexcept_move && ./noexcept_move
noexcept 이동:   복사 0회, 이동 7회
noexcept 없음:   복사 7회, 이동 0회
capacity: 8
Zero 이동이 noexcept? true
Risky 이동이 noexcept? false

두 타입은 이동 생성자에 noexcept가 있느냐만 다르다. 원소 5개를 넣는 동안 재할당이 세 번(1→2, 2→4, 4→8) 일어나 원소가 총 7번 옮겨졌는데, noexcept가 없는 쪽은 7번 모두 복사했다. 원소가 큰 텍스처나 메시였다면 이 차이가 그대로 프레임 시간이 된다. Zero는 이동 생성자를 직접 쓰지 않았는데도 noexcept로 나왔다. 멤버들(std::string, std::vector)의 이동이 모두 noexcept이기 때문에 컴파일러가 만든 이동 생성자도 noexcept가 된다.

규칙 0 / 3 / 5

표 4-2. 규칙 0 / 3 / 5

규칙내용언제
규칙 0소멸자·복사·이동을 하나도 직접 쓰지 않는다멤버가 전부 스스로 관리하는 타입(std::vector, std::string, std::unique_ptr)일 때. 기본값
규칙 3소멸자, 복사 생성자, 복사 대입 중 하나를 쓰면 셋 다 쓴다원시 자원을 직접 관리하는데 이동이 필요 없을 때(C++11 이전 방식)
규칙 5규칙 3에 이동 생성자, 이동 대입을 더한다원시 자원을 직접 관리하는 클래스를 새로 만들 때

실무에서 목표는 규칙 0이다. 이 장의 Buffer도 사실 멤버를 std::vector<unsigned char> 하나로 바꾸면 다섯 함수를 모두 지울 수 있고, 동작은 같다. 규칙 5는 vectorunique_ptr 같은 "자원 관리 부품"을 직접 만들 때만 쓰고, 나머지 클래스는 그 부품을 조립해 규칙 0으로 만든다.

실무에서 자주 틀리는 것

1. 소멸자만 쓰고 끝낸다

소멸자를 직접 쓴 클래스는 대부분 자원을 관리한다는 신호인데, 복사는 여전히 컴파일러가 만든 얕은 복사다. 문제 상황의 버그가 정확히 이것이다. 게다가 소멸자를 직접 쓰면 컴파일러가 이동 생성자를 자동으로 만들지 않아서, std::move를 해도 조용히 복사가 된다.

2. const 객체에 std::move

const Buffer c(16); Buffer d = std::move(c);는 컴파일된다. 하지만 const 객체의 내용물은 바꿀 수 없으니 이동 생성자(Buffer&&)에 묶이지 못하고 복사 생성자가 불린다. 옮겼다고 생각했지만 복사한 것이다.

3. 이동한 뒤 원래 변수를 계속 쓴다

names.push_back(std::move(name)); 다음 줄에서 name을 로그에 찍으면 빈 문자열이 나올 가능성이 높다. 이동 뒤에는 새 값을 대입하기 전까지 쓰지 않는다.

4. 이동 생성자에 noexcept를 빠뜨린다

위에서 본 대로 vector 성능이 조용히 떨어진다. 직접 쓴 이동 생성자는 정말 예외를 던지지 않는다면 반드시 noexcept를 붙인다.

연습 문제

  1. struct WithDtor { std::string name; ~WithDtor() {} };는 이동 생성자가 있는가? WithDtor b = std::move(a);는 무엇을 부르는가?
  2. 멤버로 std::unique_ptr<int> p; 하나만 가진 구조체 Owner는 복사할 수 있는가? 이동할 수 있는가? 아무 함수도 직접 쓰지 않았다고 하자.
  3. 이동 생성자와 복사 생성자가 모두 있는 타입에서 const Counter c; Counter d = std::move(c);를 실행하면 어느 쪽이 불리는가?

정답

  1. 없다. 소멸자를 직접 선언하면 이동 생성자와 이동 대입이 암묵적으로 만들어지지 않는다. 복사 생성자는 여전히 만들어지므로 std::move(a)는 복사 생성자에 묶여 name을 복사한다. 검증 프로그램에서 std::is_nothrow_move_constructible_v<WithDtor>false인 것(복사 생성자의 문자열 복사는 예외를 던질 수 있음)으로 확인했다. 빈 소멸자는 지우거나, 꼭 필요하면 WithDtor(WithDtor&&) = default; 등을 함께 쓴다.
  2. 복사는 안 되고 이동은 된다. 컴파일러가 만드는 복사 생성자는 멤버를 복사하려 하는데 unique_ptr의 복사가 삭제되어 있으므로 Owner의 복사도 삭제된다. 이동은 멤버별 이동으로 자동 생성된다. 규칙 0의 좋은 예다. 멤버 타입이 "복사 불가, 이동 가능"이라는 성질을 바깥 타입에 그대로 물려준다.
  3. 복사 생성자다. std::move(c)의 타입은 const Counter&&인데, 이동 생성자의 매개변수 Counter&&const를 받을 수 없다. const Counter&를 받는 복사 생성자가 대신 선택된다. 검증 프로그램에서 복사 1회, 이동 0회를 확인했다.

다음 장에서는 이 장에서 손으로 만든 "힙 메모리의 주인" 역할을 표준 라이브러리의 스마트 포인터에 맡긴다. unique_ptr은 이 장의 이동 전용 설계를 그대로 구현한 타입이다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.