CodeGym /행동 /C++ SELF /함수로 std::unique_ptr 전달하기

함수로 std::unique_ptr 전달하기

C++ SELF
레벨 43, 레슨 0
사용 가능

1. 도입

함수를 작성하기 시작하면 모든 것이 동일하게 보이길 바랍니다: «인자를 전달하면 — 함수가 작업하고 — 결과를 반환한다». 하지만 std::unique_ptr에는 미묘한 점이 있습니다: 단순한 "포인터"가 아니라 소유권 계약입니다. 계약은 엄격합니다: 두 주체가 동일한 자원을 "소유한다"고 착각하면 언젠가 메모리가 중복 해제되거나 다른 문제가 발생합니다.

여기서 한 가지를 마음에 새겨야 합니다: 매개변수의 타입은 이야기를 전달해야 합니다. 주석으로 "// 여기 소유권 있음"이라고 쓰는 것이 아니라 시그니처 자체에 명확히 드러나야 합니다. 좋은 C++ 코드에서는 매개변수 타입만 보고도 "호출 후 누가 객체를 소유하는지"를 알 수 있는 경우가 많습니다. 이는 미적 문제가 아니라 테스터의 컴퓨터에서 주 1회씩 발생하는 버그를 피하는 방법입니다.

참고로 표준 라이브러리에서는 unique_ptr의 동작(예: reset()의 정확성이나 이동 할당의 후조건 등)을 매우 엄밀하게 규정하며, 관련 설명과 수정 사항이 상세히 다뤄집니다.

2. 값으로 전달: 함수가 소유권을 가져간다

함수 매개변수로 값으로 선언된 std::unique_ptr<T>를 보면, 이는 거의 항상 다음을 의미합니다: 함수가 자원의 소유자가 된다. 즉 호출자 쪽에서는 소유권이 사라지고(그는 "아파트 열쇠를 넘겨줌"), 함수 내부에서 자원은 범위를 벗어나면 자동으로 파괴됩니다 — 단 함수를 통해 다른 곳으로 전달하지 않는 한.

이것은 강력하고 매우 정직한 계약입니다: 시그니처에서 바로 "내가 소유권을 가져간다"라고 명시되어 있습니다. 추가 보너스는 호출 지점에서 std::move(...)를 반드시 작성해야 하므로 소유권이 옮겨간다는 사실을 잊기 어렵다는 점입니다.

미니 예제: 객체를 '먹어치우기' (consume)

학습용 간단한 애플리케이션에서 Note 엔티티(메모)와 애플리케이션이 소유하는 "활성 메모"를 다룹니다.

#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Note {
    std::string title;
};

void consume_note(std::unique_ptr<Note> note) {
    if (note) {
        std::cout << "Consumed: " << note->title << '\n'; // Consumed: Buy milk
    }
} // note will be destroyed here automatically

int main() {
    auto n = std::make_unique<Note>(Note{"Buy milk"});
    consume_note(std::move(n));

    std::cout << std::boolalpha << (n == nullptr) << '\n'; // true
}

여기서 std::move(n)라는 '의식'을 주목하세요. 마법의 단어처럼 보일 수 있지만 의미는 인간적으로 단순합니다: "나는 이 소유권을 넘긴다". std::move가 없으면 컴파일러가 허용하지 않으며 — 이는 좋은 일입니다.

언제 적절한가

값으로 unique_ptr를 전달하는 것은 함수가 실제로 소유자가 되어야 할 때 유용합니다. 예: 객체를 어딘가에 저장하거나, 큐에 넣거나, 컨테이너에 추가하거나, "보관"하려고 할 때입니다.

예를 들어 메모 저장소가 std::vector<std::unique_ptr<Note>>라면 "메모를 데이터베이스에 추가"하는 함수가 소유권을 가져가는 것이 합리적입니다.

#include <memory>
#include <string>
#include <utility>
#include <vector>

struct Note {
    std::string title;
};

void add_note(std::vector<std::unique_ptr<Note>>& notes,
              std::unique_ptr<Note> note) {
    notes.push_back(std::move(note));
}

int main() {
    std::vector<std::unique_ptr<Note>> notes;

    auto n = std::make_unique<Note>(Note{"Read C++ book"});
    add_note(notes, std::move(n));
}

여기서 좋은 점은 add_note가 소유권을 가져가고 이후에는 컨테이너가 객체를 소유한다는 사실이 명확하다는 것입니다.

가독성에 관한 작은 하지만 중요한 생각

함수가 std::unique_ptr<T>를 값으로 받는다면, 코드 읽는 사람은 다음을 기대해야 합니다:

  • 함수 내부에서 객체가 파괴될 수 있다;
  • 객체가 다른 곳으로 전달될 수 있다(또 다른 std::move);
  • 호출자는 호출 후에 "이제 더 이상 이걸 갖고 있지 않다"고 간주해야 한다.

즉, 코트를 맡기고 옷 보관증을 받는 것과 같습니다: 번호표는 받지만 옷은 더 이상 당신의 것이 아닙니다.

3. 참조로 전달: 함수가 소유자를 조작한다

때때로 함수가 영구적인 소유자가 되어서는 안 되지만, 호출자 쪽 소유자의 상태를 변경해야 할 때가 있습니다. 예: 현재 활성 메모를 새 것으로 바꾸거나, 메모를 닫아 비우거나, "비어 있으면 초기화"하는 경우입니다.

이럴 때 매개변수를 unique_ptr에 대한 참조로 만듭니다: std::unique_ptr<T>&.

중요한 점은: 참조는 T에 대한 것이 아니라 소유자 객체(unique_ptr)에 대한 것입니다. 즉 함수가 메모를 "안아가는" 것이 아니라 호출자의 보관번호를 열어 그 안에 무엇이 들어있는지를 바꾸는 것입니다.

미니 예제: 활성 메모 설정

#include <memory>
#include <string>
#include <utility>

struct Note {
    std::string title;
};

void set_active(std::unique_ptr<Note>& active, std::unique_ptr<Note> next) {
    active = std::move(next); // ownership moves into active
}

int main() {
    std::unique_ptr<Note> active;

    auto n = std::make_unique<Note>(Note{"Plan weekend"});
    set_active(active, std::move(n));
}

여기서는 균형이 중요합니다: 값으로 받은 next는 "나는 메모를 넘긴다"를 의미하고, 참조인 active는 "내 소유자를 바꿔라"를 의미합니다.

언제 적절한가

std::unique_ptr<T>& 전달은 함수가 다음을 해야 할 때 적절합니다:

  • 호출자 쪽의 객체를 교체한다;
  • 비어 있으면 채운다;
  • 해당 소유자에서 소유권을 꺼내 다른 곳으로 전달한다(예: 활성 객체를 꺼내 저장소에 넣음 — 이 경우 매우 주의가 필요함).

나중(다른 강의)에 get()/reset()/release()/swap() 연산을 자세히 다룰 것입니다. 그때 소유권 경계에서 얼마나 쉽게 실수할 수 있는지 더 분명해질 것입니다.

4. 관찰만 하고 소유하지 않기: const T& 및 const T*

"메모를 출력한다", "제목 길이를 계산한다", "제목이 비어있지 않은지 확인한다"와 같은 함수는 객체를 소유해서는 안 됩니다. 객체를 단순히 사용하기만 하면 됩니다.

여기서 초보자들이 흔히 저지르는 실수가 시작됩니다: 모든 곳에서 std::unique_ptr<T>를 사용하느라, 단순히 읽기만 해도 함수가 std::move를 요구하고 메모를 가져가 버립니다. 이는 친구에게 "펜을 잠깐 빌려줘"라고 말했는데 정작 펜을 가져가 버리는 것과 같습니다.

단순히 읽을 때의 올바른 방식: const Note&

객체가 반드시 존재해야 한다면 const T&를 사용하세요.

#include <iostream>
#include <string>

struct Note {
    std::string title;
};

void print_note(const Note& note) {
    std::cout << note.title << '\n';
}

int main() {
    Note n{"Hello"};
    print_note(n); // Hello
}

참조는 "객체가 존재한다"라고 말하고, const는 "나는 그것을 변경하지 않는다"라고 말합니다.

객체가 없을 수 있을 때: const Note*

메모가 선택되지 않았을 수 있다면(즉 unique_ptr가 비어 있을 수 있다면) 포인터 const T*로 표현하는 것이 더 편리합니다:

#include <iostream>
#include <string>

struct Note {
    std::string title;
};

void print_note_ptr(const Note* note) {
    if (note) {
        std::cout << note->title << '\n';
    } else {
        std::cout << "(no active note)\n"; // (no active note)
    }
}

여기서 계약은 더 정직합니다: nullptr는 "객체가 없다"를 의미합니다.

unique_ptr가 있을 때 호출 방법

만약 std::unique_ptr<Note> active;가 있다면, 소유권을 건드리지 않고 관찰자를 전달할 수 있습니다.

최소한으로는 다음과 같이 보입니다(나중에 get()를 자세히 다루겠지만, 지금은 주소를 "보여준다"로 이해하세요):

#include <memory>

void print_note_ptr(const Note* note);

int main() {
    std::unique_ptr<Note> active;
    print_note_ptr(active.get()); // either address or nullptr
}

핵심 아이디어는 관찰과 소유를 분리한다는 것입니다. unique_ptr는 소유에 관한 것이고, T*/const T* 및 T&/const T&는 사용에 관한 것입니다.

드물지만 유용한 경우: const std::unique_ptr<T>&

가끔 함수가 객체 자체뿐 아니라 소유자의 상태도 보아야 할 때가 있습니다: 비어 있는지, deleter가 무엇인지(나중에), 또는 내부의 T*나 T&을 노출하고 싶지 않을 때 등입니다.

이럴 때는 const std::unique_ptr<T>&를 받을 수 있습니다. 이는 "나는 소유권을 가져가지 않고, 소유자를 변경하지 않으며 단지 상태를 본다"를 의미합니다.

#include <iostream>
#include <memory>

struct Note {
    int id{};
};

void debug_owner(const std::unique_ptr<Note>& p) {
    std::cout << std::boolalpha << static_cast<bool>(p) << '\n';
}

int main() {
    std::unique_ptr<Note> p;
    debug_owner(p); // false
}

그러나 실제 규칙으로는: 객체 자체를 다루려면 보통 const T&나 const T*가 더 읽기 좋고 간단합니다. const unique_ptr<T>&는 "소유권 자체에 대해 말하고 싶다"는 의도를 나타냅니다.

5. 선택 기준: 매개변수로 무엇을 전달할까

시그니처 선택의 순간에는 다음 질문을 자신에게 던져보는 것이 유용합니다: 호출 전후에 소유권은 어떻게 될 것인가? 이를 인간어로 설명할 수 있으면 적절한 시그니처는 거의 자동으로 결정됩니다.

아래는 체크리스트형 표입니다. 습관이 들 때까지 기억용으로 가지고 있으면 좋습니다.

함수의 작업(의미) 매개변수 시그니처 사람 말로 풀면
함수가 소유권을 가져가고 자원의 운명을 결정함
std::unique_ptr<T>
"객체를 내게 넘겨, 너는 더 이상 갖고 있지 않아"
함수가 호출자의 객체를 교체할 수 있음 (소유자 조작)
std::unique_ptr<T>&
"네 소유자에 접근해서, 그가 소유하는 것을 바꿀 수 있어"
함수가 단순히 객체를 읽음, 객체는 반드시 존재해야 함
const T&
"볼게, 하지만 변경하지 않을게; 객체는 존재해야 함"
함수가 객체를 읽음, 그러나 객체가 없을 수도 있음
const T*
"있으면 볼게; 없으면 괜찮아"
함수가 정확히 소유자만 관찰 (비어 있는지 등 확인)
const std::unique_ptr<T>&
"소유자의 상태만 읽고, 소유권은 건드리지 않음"

이 표에서 주목할 점은 "값으로 또는 참조로"가 결정의 일부에 불과하다는 것입니다. 종종 정답은 "아무것도: 여기서는 unique_ptr가 필요 없다, const T&를 써라"처럼 들립니다.

6. 실용 미니 코드: 활성 메모와 아카이브

지금부터 세 가지 역할을 모두 보여주는 작은 코드 조각을 모아봅니다: 메모 생성, 활성 메모 설정, 활성 메모 출력, 아카이브에 추가. 완전한 애플리케이션을 목표로 하진 않지만 확장하기 쉬운 좋은 뼈대입니다.

모델과 함수들

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

struct Note {
    std::string title;
};

std::unique_ptr<Note> make_note(std::string title) {
    return std::make_unique<Note>(Note{std::move(title)});
}

void set_active(std::unique_ptr<Note>& active, std::unique_ptr<Note> next) {
    active = std::move(next);
}

void print_note_ptr(const Note* note) {
    if (note) {
        std::cout << "Active: " << note->title << '\n';
    } else {
        std::cout << "Active: (none)\n";
    }
}

void archive(std::vector<std::unique_ptr<Note>>& storage, std::unique_ptr<Note> note) {
    if (note) {
        storage.push_back(std::move(note));
    }
}

int main() {
    std::vector<std::unique_ptr<Note>> archive_storage;
    std::unique_ptr<Note> active;

    print_note_ptr(active.get());                 // Active: (none)

    auto n = make_note("Buy milk");
    set_active(active, std::move(n));

    print_note_ptr(active.get());                 // Active: Buy milk

    archive(archive_storage, std::move(active));  // 활성 메모가 아카이브로 이동함
    print_note_ptr(active.get());                 // Active: (none)
}

여기서 모든 "소유권 드라마"는 소유권 관련 주석 한 줄 없이도 드러납니다 — 타입과 std::move가 그것을 명확히 해주기 때문입니다. 좋은 스타일은 은밀한 마법을 줄이고 명확한 합의를 더하는 것입니다.

7. 흔한 실수

실수 #1: 단순히 읽기만 할 때 std::unique_ptr<T>를 값으로 받는 것.
고전적인 실수입니다: void print(std::unique_ptr<Note> n)처럼 쓰고 왜 std::move가 필요한지, 그리고 출력 후 메모가 사라진 이유에 놀랍니다. 값으로 받은 unique_ptr는 소유권을 넘긴다는 계약입니다. 출력에는 대개 const Note& (객체가 반드시 존재할 때) 또는 const Note* (존재하지 않을 수도 있을 때)가 더 적합합니다.

실수 #2: 소유권을 가져가는 함수 호출에서 std::move를 쓰지 않음.
초보자는 consume(p)라 쓰고 단순히 변수를 전달했다고 생각하지만, unique_ptr는 복사되지 않으며 값으로 전달하려면 이동이 필요합니다. 함수가 std::unique_ptr<T>를 받는다면 호출은 consume(std::move(p))처럼 보여야 합니다. 만약 std::move가 싫다면 당신은 그 소유권을 넘기고 싶지 않은 것이므로 시그니처를 바꿔야 합니다.

실수 #3: std::move 후에도 마치 아무일 없었던 것처럼 객체를 사용함.
set_active(active, std::move(n)) 이후에 변수 n은 보통 비어있게 됩니다. 여전히 유효하므로 다시 채우거나 nullptr와 비교할 수 있지만, 확인 없이 역참조하면 안 됩니다. 이는 "이전까지 동작했는데 갑자기 충돌한다"는 유형의 문제를 일으킵니다.

실수 #4: 날것 포인터를 이용해 unique_ptr를 수동으로 복제하려고 함.
어떤 학생은 T* raw = p.get();를 가져와 같은 주소로 또 다른 std::unique_ptr<T>를 만들기도 합니다. 이것은 단일 소유자라는 개념을 깨뜨립니다: 두 소유자가 하나의 자원을 해제하려 해 문제가 발생합니다. 공유 소유가 필요하면 다른 도구(shared_ptr 등)를 사용해야 합니다.

실수 #5: 소유권을 가져가려 했지만 std::unique_ptr<T>&로 선언한 경우.
반대 상황도 문제입니다: 함수가 객체를 완전히 인수받아야 하는데 foo(std::unique_ptr<T>&)처럼 선언하면 호출자는 std::move를 강제할 필요가 없고 호출만으로는 소유권이 이동하는지 알기 어렵습니다. 함수가 소유권을 가져가야 한다면 값으로 std::unique_ptr<T>를 받아 호출자가 명시적으로 소유권 이전을 확인하게 하세요.

1
과제
C++ SELF, 레벨 43, 레슨 0
잠금
택배원에게 소포
택배원에게 소포
1
과제
C++ SELF, 레벨 43, 레슨 0
잠금
활성 세션
활성 세션
1
과제
C++ SELF, 레벨 43, 레슨 0
잠금
소유권을 넘기지 않는 메모
소유권을 넘기지 않는 메모
1
과제
C++ SELF, 레벨 43, 레슨 0
잠금
작업 큐
작업 큐
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION