unique_ptr 고급 활용: 커스텀 삭제자, 배열, Pimpl, C API 핸들 감싸기
들어가며
make_unique, std::move, get()까지 익혔다면 unique_ptr의 기본은 갖춘 셈입니다. 실제 프로젝트에서는 여기에 몇 가지가 더 필요합니다. malloc으로 할당된 버퍼, FILE*, Windows HANDLE처럼 delete가 아닌 다른 함수로 해제해야 하는 C API 리소스를 감싸야 하고, 동적 배열을 다뤄야 하며, Pimpl 패턴으로 헤더에서 구현 세부사항을 숨기거나 팩토리 함수에서 소유권을 넘겨야 합니다.
이런 상황의 공통점은 “누가 언제 해제하는가”입니다. C 라이브러리가 malloc으로 할당한 버퍼를 돌려줄 때, 예외나 early return이 여러 군데 있으면 모든 경로에서 free를 부르기 어렵습니다. FILE*를 여러 함수에 넘기다 보면 누가 fclose해야 하는지 흐려집니다. 헤더의 private 멤버를 하나 바꿨을 뿐인데 그 헤더를 포함하는 모든 파일이 다시 컴파일되기도 합니다. 이 글은 커스텀 삭제자, unique_ptr<T[]>, Pimpl, 이동 의미론으로 이 문제들을 하나씩 다루고, 자주 하는 실수를 정리합니다.
커스텀 삭제자로 malloc, FILE*, HANDLE 관리
unique_ptr<T>의 기본 삭제자 std::default_delete<T>는 delete를 호출합니다. free, fclose, CloseHandle처럼 다른 해제 함수가 필요한 리소스는 두 번째 템플릿 인자로 삭제자 타입을 지정합니다. unique_ptr은 소멸할 때 포인터가 null이 아니면 deleter(ptr)를 호출합니다.
flowchart LR
subgraph Default[기본 삭제자]
D1["unique_ptr#lt;T#gt;"]
D2["delete ptr"]
end
subgraph Custom[커스텀 삭제자]
C1["unique_ptr#lt;T, Deleter#gt;"]
C2["Deleter{}(ptr)"]
end
D1 --> D2
C1 --> C2
C API: malloc/free
#include <cstdlib>
#include <iostream>
#include <memory>
#include <new>
struct FreeDeleter {
void operator()(void* p) const noexcept { std::free(p); }
};
using UniqueBuffer = std::unique_ptr<char, FreeDeleter>;
UniqueBuffer allocateBuffer(std::size_t size) {
void* p = std::malloc(size);
if (!p) throw std::bad_alloc();
return UniqueBuffer(static_cast<char*>(p));
}
int main() {
auto buf = allocateBuffer(1024);
buf.get()[0] = 'A'; // unique_ptr<char>에는 operator[]가 없으므로 get()으로 접근
buf.get()[1] = '\0';
std::cout << buf.get() << '\n';
} // 소멸 시 free 호출
FreeDeleter는 멤버가 없는 함수 객체라 unique_ptr의 크기를 늘리지 않습니다. 인터넷에서는 std::unique_ptr<char, decltype(&std::free)>(p, &std::free) 형태도 자주 보이는데, 이 방식은 함수 포인터를 저장하느라 포인터 하나만큼 커지고, 표준 라이브러리 함수의 주소를 취하는 것은 C++20부터 명시적으로 보장되지 않는 사용법입니다. 대부분의 컴파일러에서 동작하지만, 함수 객체 삭제자가 더 작고 이식성도 좋습니다.
unique_ptr<char>는 단일 객체용 특수화라서 buf[0] 같은 첨자 연산자가 없습니다. 바이트 버퍼처럼 인덱스로 다룰 거라면 std::unique_ptr<char[], FreeDeleter>로 선언해 operator[]를 쓸 수도 있습니다.
람다 삭제자
#include <iostream>
#include <memory>
int main() {
auto deleter = [](int* p) {
std::cout << "Custom delete: " << *p << '\n';
delete p;
};
std::unique_ptr<int, decltype(deleter)> ptr(new int(42), deleter);
static_assert(sizeof(ptr) == sizeof(int*)); // 캡처 없는 람다: 추가 공간 없음 (주요 구현 기준)
} // "Custom delete: 42" 출력 후 delete
캡처가 없는 람다는 멤버가 없는 클래스이므로 함수 객체 삭제자와 마찬가지로 공간을 차지하지 않습니다. 캡처가 있는 람다는 캡처한 값만큼 unique_ptr이 커집니다. 람다 타입은 decltype으로만 이름을 얻을 수 있어서, 타입 별칭이나 클래스 멤버로 쓰기에는 이름 있는 struct 삭제자가 더 다루기 쉽습니다.
FILE* 래퍼
#include <cstdio>
#include <memory>
#include <stdexcept>
#include <string>
struct FileDeleter {
void operator()(FILE* fp) const noexcept { std::fclose(fp); }
};
using FileHandle = std::unique_ptr<FILE, FileDeleter>;
FileHandle openFile(const char* path, const char* mode) {
FILE* fp = std::fopen(path, mode);
if (!fp) {
throw std::runtime_error(std::string("Cannot open: ") + path);
}
return FileHandle(fp);
}
int main() {
auto file = openFile("data.txt", "r");
char buf[256];
while (std::fgets(buf, sizeof(buf), file.get())) {
// 처리...
}
} // fclose 자동 호출
unique_ptr은 포인터가 null일 때 삭제자를 호출하지 않으므로, 삭제자 안에서 fp가 null인지 다시 검사할 필요는 없습니다. openFile은 열기에 실패하면 예외를 던지므로, FileHandle을 받았다면 파일이 열려 있다는 뜻입니다. fclose의 실패(버퍼를 비우다 쓰기 오류가 나는 경우)는 소멸자에서는 알릴 방법이 없으므로, 쓰기 파일의 오류를 확인해야 한다면 명시적으로 std::fclose(file.release())를 호출하고 반환값을 검사합니다.
Windows HANDLE
#ifdef _WIN32
#include <memory>
#include <stdexcept>
#include <windows.h>
struct HandleDeleter {
void operator()(HANDLE h) const noexcept {
if (h != INVALID_HANDLE_VALUE) CloseHandle(h);
}
};
using UniqueHandle = std::unique_ptr<void, HandleDeleter>;
UniqueHandle openMutex(const wchar_t* name) {
HANDLE h = OpenMutexW(MUTEX_ALL_ACCESS, FALSE, name);
if (!h) { // OpenMutexW는 실패 시 NULL 반환
throw std::runtime_error("OpenMutex failed");
}
return UniqueHandle(h);
}
#endif
HANDLE은 void*이므로 unique_ptr<void, HandleDeleter>로 감쌀 수 있습니다. 주의할 점은 Win32 API마다 실패 값이 다르다는 것입니다. OpenMutexW나 CreateMutexW는 실패 시 NULL을 반환하지만, CreateFileW는 INVALID_HANDLE_VALUE(-1)를 반환합니다. unique_ptr은 null만 “비어 있음”으로 보므로, INVALID_HANDLE_VALUE가 들어올 수 있는 API라면 생성 전에 걸러내거나 삭제자에서 검사해야 합니다.
삭제자 종류와 unique_ptr 크기
| 삭제자 종류 | unique_ptr 크기 (64비트, 주요 구현) | 이유 |
|---|---|---|
기본 (default_delete) | 8바이트 | raw 포인터와 동일 |
| 멤버 없는 struct, 캡처 없는 람다 | 8바이트 | 빈 클래스 최적화(EBO) |
함수 포인터 (decltype(&f)) | 16바이트 | 포인터 + 함수 포인터 |
| 캡처 있는 람다, 상태 있는 struct | 8바이트 + 상태 크기(정렬 포함) | 삭제자 상태를 함께 저장 |
표준은 크기를 규정하지 않지만, libstdc++, libc++, MSVC STL 모두 삭제자를 빈 클래스 최적화가 가능한 방식으로 저장하므로 위 표대로 동작합니다.
unique_ptr<T[]>와 vector 중 고르기
unique_ptr<T[]> 특수화
unique_ptr에는 배열 타입 T[]에 대한 부분 특수화가 있고, 이 특수화의 기본 삭제자는 delete[]를 호출합니다. operator[]를 제공하는 대신 operator*와 operator->는 없습니다.
#include <iostream>
#include <memory>
int main() {
// C++14: make_unique<T[]>(n)은 원소를 값 초기화(int는 0)
std::unique_ptr<int[]> arr = std::make_unique<int[]>(100);
for (int i = 0; i < 100; ++i) {
arr[i] = i;
}
std::cout << arr[0] << " " << arr[99] << '\n'; // 0 99
} // delete[] 자동 호출
make_unique<T[]>(n)은 C++14 도입 당시부터 new T[n]()과 같이 원소를 값 초기화하므로, int 배열은 0으로 채워집니다. 곧바로 다른 값으로 덮어쓸 큰 버퍼라면 이 초기화가 낭비일 수 있는데, C++20의 std::make_unique_for_overwrite<T[]>(n)은 기본 초기화만 해서(int라면 값이 정해지지 않음) 이 비용을 없앱니다.
C API에 버퍼 넘기기
#include <cstddef>
#include <memory>
void processWithBuffer(std::size_t size) {
auto buf = std::make_unique<char[]>(size); // 0으로 초기화됨
some_c_function(buf.get(), size); // C API에는 raw 포인터 전달
} // delete[] 자동 호출
unique_ptr<T[]>는 자신이 가리키는 배열의 길이를 저장하지 않으므로, 크기는 별도 변수로 함께 들고 다녀야 합니다.
배열 vs std::vector 선택 기준
| 상황 | 권장 | 이유 |
|---|---|---|
| 크기가 바뀜 | std::vector | resize, push_back, 크기 정보 내장 |
| C API에 raw 포인터 전달 | std::vector 또는 unique_ptr<T[]> | vec.data() 또는 arr.get() |
| 크기가 컴파일 타임 상수 | std::array | 힙 할당 없음 |
| 런타임에 정해지고 이후 고정, 초기화 비용을 피하고 싶음 | unique_ptr<T[]> + make_unique_for_overwrite | vector는 원소를 항상 초기화 |
| 다형적 객체 모음 | std::vector<std::unique_ptr<Base>> | unique_ptr<Base[]>는 파생 클래스 배열을 다룰 수 없음 |
Pimpl 패턴과 특수 멤버 정의 위치
Pimpl(Pointer to Implementation)은 클래스의 구현 세부사항을 별도 Impl 클래스로 옮기고, 공개 클래스에는 그 포인터만 남기는 패턴입니다. 헤더에는 Impl의 전방 선언만 있으므로, private 멤버나 구현에만 필요한 헤더를 바꿔도 공개 헤더는 변하지 않고, 그 헤더를 포함하는 파일들을 다시 컴파일할 필요가 없습니다.
flowchart TB
subgraph Header[widget.h]
W["class Widget"]
P["unique_ptr#lt;Impl#gt; pImpl_"]
end
subgraph Impl[widget.cpp]
I["class Widget::Impl"]
D["구현 세부사항"]
end
W --> P
P -.->|포인터| I
I --> D
기본 Pimpl 구현
// widget.h
#pragma once
#include <memory>
class Widget {
public:
Widget();
~Widget();
Widget(Widget&&) noexcept; // 선언만: 정의는 .cpp에서
Widget& operator=(Widget&&) noexcept;
Widget(const Widget&); // 복사는 Impl을 복제하도록 직접 구현
Widget& operator=(const Widget&);
void doSomething();
int getValue() const;
private:
class Impl;
std::unique_ptr<Impl> pImpl_;
};
// widget.cpp
#include "widget.h"
#include <string>
#include <vector>
class Widget::Impl {
public:
std::vector<int> data;
std::string name;
void doSomethingInternal() { /* 복잡한 구현... */ }
int getValueInternal() const { return 42; }
};
Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}
// 여기서는 Impl이 완전한 타입이므로 unique_ptr<Impl>의 소멸 코드를 만들 수 있음
Widget::~Widget() = default;
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
Widget::Widget(const Widget& other)
: pImpl_(std::make_unique<Impl>(*other.pImpl_)) {}
Widget& Widget::operator=(const Widget& other) {
if (this != &other) {
pImpl_ = std::make_unique<Impl>(*other.pImpl_);
}
return *this;
}
void Widget::doSomething() { pImpl_->doSomethingInternal(); }
int Widget::getValue() const { return pImpl_->getValueInternal(); }
왜 .cpp에서 정의해야 하는가
unique_ptr<Impl>을 파괴하는 코드는 delete를 호출하므로 그 지점에서 Impl이 완전한 타입이어야 합니다. std::default_delete는 불완전한 타입이면 static_assert로 컴파일을 멈춥니다. 헤더에 ~Widget() = default;를 두거나 소멸자를 아예 선언하지 않으면, 소멸자가 헤더에서(정확히는 Widget을 파괴하는 각 사용자 코드에서) 정의되고, 그 시점에는 Impl이 전방 선언뿐이라 다음과 같은 에러가 납니다.
error: invalid application of 'sizeof' to incomplete type 'Widget::Impl'
이동 대입도 같은 이유로 .cpp에서 정의해야 합니다. pImpl_ = std::move(other.pImpl_)는 기존에 가리키던 Impl을 먼저 삭제하기 때문입니다. 이동 생성자는 삭제를 하지 않을 것 같지만, 컴파일러에 따라 예외 처리 경로를 위해 소멸 코드를 생성하기도 하므로(Effective Modern C++ 항목 22) 함께 .cpp로 옮기는 것이 안전합니다. 헤더에서는 선언만 하고 .cpp에서 = default로 정의하면, 구현은 컴파일러가 만들어 준 그대로이면서 정의 위치만 옮겨집니다.
이동된 Widget은 pImpl_이 null이 되므로, 이동 후 원본의 멤버 함수를 호출하면 null 역참조가 됩니다. 이동된 객체에 대입이나 소멸 외의 연산을 허용하려면 각 멤버 함수에서 pImpl_을 검사해야 합니다.
Pimpl은 ABI 안정성에도 쓰입니다. 공개 클래스의 크기와 레이아웃이 포인터 하나로 고정되므로, 공유 라이브러리의 구현(Impl의 멤버)을 바꿔도 공개 클래스의 레이아웃과 내보낸 함수 시그니처를 유지하는 한 사용자 코드를 다시 컴파일하지 않고 .so/.dll만 교체할 수 있습니다. 헤더에 인라인 함수나 템플릿이 남아 있으면 그 부분은 사용자 바이너리에 복사되므로 이 보장에서 빠집니다. 대가는 모든 호출이 포인터를 한 번 더 따라간다는 것과 객체마다 힙 할당이 하나 추가된다는 점입니다.
이동 전용 타입으로서의 unique_ptr
unique_ptr은 복사 생성자와 복사 대입이 삭제되어 있고 이동만 가능합니다. 이것이 “이 객체의 소유자는 하나”라는 규칙을 타입 시스템으로 강제하는 방식입니다.
sequenceDiagram
participant A as ptr1
participant B as ptr2
participant H as Heap
A->>H: 소유
Note over A,B: ptr2 = std::move(ptr1)
A->>B: 소유권 이전
B->>H: 소유
Note over A: nullptr
이동 기본
#include <cassert>
#include <memory>
int main() {
auto ptr1 = std::make_unique<int>(42);
// auto ptr2 = ptr1; // ❌ 컴파일 에러: 복사 생성자 삭제됨
auto ptr2 = std::move(ptr1); // ✅ 이동
assert(!ptr1); // 이동된 unique_ptr은 null이 보장됨
assert(ptr2 && *ptr2 == 42);
auto ptr3 = std::make_unique<int>(100);
ptr2 = std::move(ptr3); // ptr2가 가리키던 42는 delete, 100의 소유권 이전
assert(!ptr3);
assert(ptr2 && *ptr2 == 100);
}
일반적인 표준 라이브러리 타입은 이동 후 “유효하지만 지정되지 않은 상태”가 되지만, unique_ptr은 이동 후 null이 된다는 것을 표준이 보장합니다.
함수 인자와 반환
#include <iostream>
#include <memory>
void takeOwnership(std::unique_ptr<int> ptr) { // 값으로 받으면 "소유권을 가져간다"는 뜻
std::cout << *ptr << '\n';
} // ptr 소멸 → delete
void inspect(const int& value) { // 소유권과 무관하면 참조나 raw 포인터
std::cout << value << '\n';
}
std::unique_ptr<int> createValue(bool flag) {
if (flag) {
return std::make_unique<int>(1); // prvalue 반환: C++17부터 복사 생략 보장
}
auto p = std::make_unique<int>(2);
return p; // 지역 변수: 자동으로 이동(또는 NRVO)
}
int main() {
auto ptr = std::make_unique<int>(42);
inspect(*ptr);
takeOwnership(std::move(ptr)); // 이후 ptr은 null
auto p = createValue(true);
}
함수 시그니처가 소유권 의도를 드러내도록 쓰는 것이 핵심입니다. unique_ptr<T>를 값으로 받는 함수는 소유권을 가져가고, 객체를 쓰기만 하는 함수는 T&나 T*를 받습니다. 쓰기만 하는 함수가 const std::unique_ptr<T>&를 받으면 호출자가 반드시 unique_ptr로 객체를 들고 있어야 하는 불필요한 제약이 생깁니다(C++ Core Guidelines R.30). 반환할 때는 std::move를 붙이지 않습니다. 지역 변수를 반환하면 컴파일러가 알아서 이동하고, return std::move(p);는 NRVO를 막을 뿐입니다.
컨테이너에 저장하기
#include <iostream>
#include <memory>
#include <vector>
struct Shape {
virtual void draw() const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
void draw() const override { std::cout << "Circle\n"; }
};
struct Rectangle : Shape {
void draw() const override { std::cout << "Rectangle\n"; }
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>()); // 임시 객체이므로 자동으로 이동
auto rect = std::make_unique<Rectangle>();
shapes.push_back(std::move(rect)); // 이름 있는 변수는 std::move 필요
for (const auto& s : shapes) {
s->draw();
}
}
vector가 재할당할 때도 unique_ptr의 이동 생성자는 noexcept이므로 원소는 복사 없이 이동됩니다. 기반 클래스 Shape의 소멸자가 virtual이어야 unique_ptr<Shape>가 파생 객체를 올바르게 삭제한다는 점도 잊지 말아야 합니다.
예제: Pimpl과 팩토리, 상태 있는 삭제자
Pimpl + 팩토리
생성자를 private으로 두고 정적 팩토리 함수만 공개하면, 객체 생성이 실패할 수 있는 초기화(파일 읽기 등)를 팩토리 안에서 처리할 수 있습니다.
// document.h
#pragma once
#include <memory>
#include <string>
class Document {
public:
static std::unique_ptr<Document> create(const std::string& path);
~Document();
void save();
void load();
private:
class Impl;
std::unique_ptr<Impl> pImpl_;
explicit Document(std::unique_ptr<Impl> impl);
};
// document.cpp
#include "document.h"
#include <fstream>
#include <iterator>
class Document::Impl {
public:
std::string path;
std::string content;
void saveInternal() {
std::ofstream f(path);
f << content;
}
void loadInternal() {
std::ifstream f(path);
content.assign(std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>());
}
};
std::unique_ptr<Document> Document::create(const std::string& path) {
auto impl = std::make_unique<Impl>();
impl->path = path;
impl->loadInternal();
// 생성자가 private이라 make_unique는 접근할 수 없으므로 new를 직접 사용
return std::unique_ptr<Document>(new Document(std::move(impl)));
}
Document::Document(std::unique_ptr<Impl> impl) : pImpl_(std::move(impl)) {}
Document::~Document() = default;
void Document::save() { pImpl_->saveInternal(); }
void Document::load() { pImpl_->loadInternal(); }
std::make_unique는 라이브러리 내부에서 new Document(...)를 호출하므로 private 생성자에 접근할 수 없습니다. 그래서 팩토리 안에서 new를 직접 쓰고 즉시 unique_ptr로 감쌌습니다. 이 한 줄은 누수 위험이 없습니다.
상태 있는 삭제자: mmap
삭제에 포인터 외의 정보가 필요하면 삭제자가 상태를 가져야 합니다. munmap은 매핑 길이를 요구하므로, 길이를 삭제자에 저장합니다.
#include <cstddef>
#include <memory>
#include <sys/mman.h>
struct MunmapDeleter {
std::size_t length;
void operator()(void* p) const noexcept { munmap(p, length); }
};
using MappedRegion = std::unique_ptr<void, MunmapDeleter>;
MappedRegion mapFile(int fd, std::size_t length) {
void* p = mmap(nullptr, length, PROT_READ, MAP_PRIVATE, fd, 0);
if (p == MAP_FAILED) return MappedRegion(nullptr, MunmapDeleter{0});
return MappedRegion(p, MunmapDeleter{length});
}
이 경우 unique_ptr의 크기는 포인터와 size_t를 합친 16바이트가 됩니다. mmap의 실패 값은 null이 아니라 MAP_FAILED이므로, 그대로 unique_ptr에 넣으면 소멸자가 잘못된 주소로 munmap을 호출합니다. 위처럼 실패를 null로 바꿔 넣거나 예외를 던져야 합니다.
옵셔널 소유: 필요할 때만 만드는 멤버
class Parser {
std::unique_ptr<Cache> cache_; // 기본은 비어 있음
public:
void enableCache() {
cache_ = std::make_unique<Cache>();
}
void parse() {
if (cache_) {
cache_->lookup(/* ... */);
}
}
};
unique_ptr 멤버는 “있을 수도 없을 수도 있는, 이 객체가 소유하는 부품”을 표현하기에 좋습니다. 객체가 작고 다형성이 필요 없다면 std::optional<Cache>가 힙 할당 없이 같은 의미를 표현합니다.
자주 하는 실수
get()으로 얻은 포인터를 delete하기
// ❌ 이중 해제
auto ptr = std::make_unique<int>(42);
delete ptr.get(); // ptr이 소멸할 때 또 delete
get()은 소유권 없이 포인터만 빌려줍니다. C API에 넘길 때처럼 잠시 쓰는 용도로만 사용하고, 소유권 자체를 넘겨야 한다면 release()를 호출해 unique_ptr이 더 이상 해제하지 않게 만든 뒤 넘깁니다.
Pimpl 소멸자를 헤더에서 정의하기
앞에서 본 것처럼 헤더에 ~Widget() = default;를 두거나 소멸자를 선언하지 않으면 불완전한 타입 에러가 납니다. 소멸자와 이동 대입은 헤더에서 선언만 하고 .cpp에서 정의합니다.
unique_ptr<T[]>에 operator* 사용
auto arr = std::make_unique<int[]>(10);
// int x = *arr; // ❌ 컴파일 에러: 배열 특수화에는 operator*가 없음
int x = arr[0]; // ✅
이동 후 원본 사용
auto ptr1 = std::make_unique<int>(42);
auto ptr2 = std::move(ptr1);
std::cout << *ptr1 << '\n'; // ❌ ptr1은 null → null 역참조(미정의 동작)
이동 후 원본은 다시 대입하기 전까지 사용하지 않습니다. 두 곳에서 같은 객체를 계속 써야 한다면 한쪽은 소유하지 않는 참조를 쓰거나, 정말 공동 소유가 필요할 때만 shared_ptr을 고려합니다.
malloc 버퍼를 기본 삭제자로 감싸기
// ❌ 소멸 시 delete 호출 → malloc 메모리에 delete는 미정의 동작
std::unique_ptr<char> buf(static_cast<char*>(std::malloc(100)));
// ✅ free를 호출하는 삭제자 지정
std::unique_ptr<char, FreeDeleter> buf2(static_cast<char*>(std::malloc(100)));
unique_ptr을 값으로 넘기면서 move를 빠뜨리기
void take(std::unique_ptr<int> p) {}
auto ptr = std::make_unique<int>(42);
// take(ptr); // ❌ 컴파일 에러: 복사 불가
take(std::move(ptr)); // ✅ 소유권 이전, 이후 ptr은 null
new[] 배열을 unique_ptr에 넣기
// ❌ 소멸 시 delete (delete[]가 아님) → 미정의 동작
std::unique_ptr<int> arr(new int[10]);
// ✅ 배열 특수화 사용
std::unique_ptr<int[]> arr2(new int[10]);
auto arr3 = std::make_unique<int[]>(10);
이 실수는 컴파일 에러가 나지 않아서 특히 위험합니다. int처럼 소멸자가 없는 타입에서는 겉으로 문제가 없어 보이다가, 소멸자가 있는 타입이나 다른 할당자 구현에서 힙 손상으로 드러납니다.
make_unique 대신 new를 쓸 때의 주의점
auto p = std::make_unique<Widget>(a, b); // 권장
std::unique_ptr<Widget> q(new Widget(a, b)); // 이 한 줄 자체는 누수 위험 없음
// C++17 이전에는 이런 호출에서 누수가 가능했음
process(std::unique_ptr<Widget>(new Widget), mayThrow());
C++14까지는 함수 인자의 평가 순서가 섞일 수 있어서, new Widget이 실행된 뒤 unique_ptr이 만들어지기 전에 mayThrow()가 예외를 던지면 Widget이 샐 수 있었습니다. C++17은 각 인자의 평가가 다른 인자와 끼어들지 않도록 바꿔 이 경우를 막았지만, make_unique는 타입 이름을 한 번만 쓰고 new를 코드에서 없앤다는 점에서 여전히 기본으로 권장됩니다.
참고 자료
- cppreference - std::unique_ptr
- C++ Core Guidelines - Smart Pointers
- Scott Meyers, “Effective Modern C++”, 항목 18~22