C++11은 안전하지 않은 std::auto_ptr를 대체하기 위해 std::unique_ptr와 std::shared_ptr를 도입했습니다. 두 가지 모두 파일 핸들이나 데이터베이스 연결과 같은 비메모리 리소스를 관리하기 위한 사용자 정의 삭제자를 지원하지만, 소유권 모델과 성능 요구사항에 따라 구조적 접근 방식이 근본적으로 다릅니다.
std::unique_ptr는 독점 소유권을 구현하고 삭제자를 유형의 일부로 저장합니다 (두 번째 템플릿 매개변수). 삭제자가 상태를 가지면, 이는 관리하는 포인터와 함께 unique_ptr 객체 내에 공간을 차지하게 됩니다. 반면에, std::shared_ptr는 힙에 할당된 제어 블록을 통해 공유 소유권을 구현하며, 여기서 삭제자는 유형이 지워진 상태로 shared_ptr 객체와는 별도로 저장됩니다.
이러한 구조적 차이는 서로 다른 크기 특성으로 이어집니다. 상태가 없는 삭제자가 있는 std::unique_ptr는 Empty Base Optimization 덕분에 원시 포인터와 정확히 동일한 공간을 차지합니다. 반대로, std::shared_ptr는 삭제자의 크기나 복잡성에 관계없이 항상 두 개의 포인터 크기(일반적으로 16 바이트)로 일정한 크기를 유지합니다. 이는 삭제자가 별도로 할당된 제어 블록에 존재하기 때문에 발생합니다.
#include <memory> #include <cstdio> #include <iostream> struct FileDeleter { void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; struct StatefulDeleter { int flags = 0xDEAD; void operator()(FILE* fp) const { if (fp) std::fclose(fp); } }; int main() { // 상태가 없는 삭제자가 있는 unique_ptr: 크기 == 포인터 크기 (64비트에서는 8 바이트) std::unique_ptr<FILE, FileDeleter> up(nullptr); // shared_ptr: 삭제자에 관계없이 일정한 크기 (16 바이트) std::shared_ptr<FILE> sp(nullptr, FileDeleter{}); std::cout << "고유 (상태 없음): " << sizeof(up) << " 바이트 "; std::cout << "공유 (어떤 삭제자): " << sizeof(sp) << " 바이트 "; // 상태가 있는 삭제자가 있는 unique_ptr: 더 큰 크기 (16 바이트: 포인터 + 정수 + 패딩) std::unique_ptr<FILE, StatefulDeleter> up2(nullptr, StatefulDeleter{}); std::shared_ptr<FILE> sp2(nullptr, StatefulDeleter{}); std::cout << "고유 (상태 있음): " << sizeof(up2) << " 바이트 "; std::cout << "공유 (상태 있음): " << sizeof(sp2) << " 바이트 "; }
개발 팀은 C API에서 반환된 레거시 데이터베이스 연결 핸들 (void*)를 관리해야 했습니다. 이 핸들은 delete가 아닌 db_disconnect()를 통해 특정 정리가 필요했습니다. 이 애플리케이션은 초당 수천 개의 핸들을 생성하는 빠른 루프에서 진행되고 있어, 메모리 사용과 할당 성능이 매우 중요했습니다.
첫 번째 접근 방식은 핸들을 저장하고 소멸자에서 db_disconnect()를 호출하는 사용자 정의 RAII 래퍼 클래스 ConnectionGuard를 고려하는 것이었습니다. 장점으로는 인터페이스에 대한 완전한 제어와 연결 특정 메소드를 추가할 수 있는 기능이 있었습니다. 단점으로는 모든 리소스 유형에 대해 상당한 상용구 코드가 필요하고, 포인터 의미를 재발명해야 하며, 스마트 포인터를 위해 설계된 표준 라이브러리 알고리즘과 호환되지 않는 점이 있었습니다.
두 번째 해결책은 삭제 기능을 캡처하는 람다 삭제자로 **std::shared_ptr<void>**를 사용하는 것이었습니다. 장점으로는 표준 구성 요소를 즉시 사용할 수 있고, 필요할 경우 소유권을 공유할 수 있는 미래 지향적인 기능이 있었습니다. 단점으로는 제어 블록을 위해 강제적인 힙 할당이 필요하고, 자주 사용되는 독점 소유권에 부적합한 원자 참조 카운팅 오버헤드가 있으며, 경량 핸들의 특성에 관계없이 객체 크기가 16 바이트로 고정된 것이었습니다.
세 번째 접근 방식은 함수 포인터 삭제자 또는 바람직하게는 상태가 없는 펑터와 함께 std::unique_ptr<void, decltype(&db_disconnect)> 를 사용하는 것이었습니다. 장점으로는 상태가 없는 펑터를 사용할 때 제로 오버헤드 (원시 포인터 크기인 8 바이트와 일치), 힙 할당 없음, 독점 소유권 의미를 완벽하게 표현할 수 있는 것이었습니다. 단점으로는 유형 서명이 장황하고, 런타임에 삭제자를 변경할 수 없는 것이었습니다.
팀은 상태가 없는 펑터 삭제자를 가진 세 번째 솔루션을 선택했습니다. 이 선택은 힙 할당을 전혀 없앴고, 래퍼 크기를 8 바이트로 줄였으며, 자동 정리를 유지하면서 원자 연산 오버헤드를 제거했습니다.
결과적으로 메모리 사용량이 40% 감소하고 연결 풀링 시스템의 지연 시간이 크게 개선되어 예외 안전성이 확보되었으나 성능을 저하시킨 부분은 없었습니다.
왜 std::unique_ptr는 기본 삭제자를 사용할 때 파괴 시점에서 완전한 유형이 필요하고, std::shared_ptr는 그렇지 않습니까?
답변: 기본 삭제자를 가진 std::unique_ptr는 관리되는 포인터에서 delete를 호출합니다. C++ 표준은 T에 대한 포인터에서 delete를 호출하기 위해서는 T가 완전한 유형으로 정의되어 있어야 하며, 소멸자는 호출되고 메모리 해제를 위한 크기를 계산할 수 있습니다. unique_ptr의 소멸자가 T가 단순히 전방 선언된 곳에서 인스턴스화되면, 컴파일이 실패합니다. std::shared_ptr는 삭제자가 T를 파괴하는 방법을 아는 상태로 제어 블록에 생성 시점에 캡처합니다. 삭제자가 유형이 지워지고 별도로 저장되기 때문에, shared_ptr는 나중에 T가 불완전할 때 파괴될 수 있습니다. 이 구별은 Pimpl (구현 포인터) 관용구에 매우 중요합니다: shared_ptr는 소스 파일에서 구현 세부 사항을 숨길 수 있지만, unique_ptr는 완전한 유형이나 구현이 보이는 곳에서 정의된 사용자 정의 삭제자가 필요합니다.
왜 std::make_unique는 사용자 정의 삭제자를 지원하지 않으며, 권장되는 대안은 무엇입니까?
답변: std::make_unique ( C++14에서 도입)는 예외 안전성 할당을 제공하지만, 오직 std::unique_ptr<T> 또는 std::unique_ptr<T[]>를 반환하며, 이는 std::default_delete를 사용합니다. 이 함수는 인수로부터 삭제자 유형을 유추할 수 없습니다. 왜냐하면 삭제자 유형은 unique_ptr 템플릿 서명에 포함되어야 하고, 팩토리 함수는 명시적인 템플릿 매개변수 없이 사용자 정의 삭제자 유형을 암시적으로 유추할 수 없습니다. 권장되는 대안은 직접 생성입니다: std::unique_ptr<T, CustomDeleter>(new T(args), CustomDeleter{...}). 이 접근 방식은 템플릿에서 삭제자 유형을 명시적으로 지정하면서 사용자 정의 리소스 정리 논리를 허용하지만, 예외 안전성 보장을 유지하기 위해서는 수동 예외 처리 또는 신중한 생성 순서가 필요합니다.
Empty Base Optimization은 상태가 없는 삭제자를 사용할 때 std::unique_ptr의 메모리 레이아웃에 어떤 영향을 미치며, 왜 std::shared_ptr에서는 이 기능을 사용할 수 없습니까?
답변: std::unique_ptr는 삭제자가 클래스 유형일 때 삭제자 클래스에서 상속합니다. 삭제자가 데이터 멤버가 없으면(상태가 없음), **C++**는 **Empty Base Optimization (EBO)**를 적용하여 빈 기본 하위 객체가 0 바이트를 차지할 수 있도록 합니다. 결과적으로 sizeof(std::unique_ptr<T, StatelessDeleter>)는 sizeof(T*)와 같아져서 제로 오버헤드 추상화를 달성합니다. std::shared_ptr는 유형 지우기를 지원해야 하므로 EBO를 활용할 수 없습니다: 동일한 T의 모든 shared_ptr는 삭제자에 관계없이 동일한 크기를 가져야 합니다. 따라서 shared_ptr는 삭제자를 shared_ptr 객체 내에 저장하는 대신 힙에 할당된 제어 블록에 저장합니다. 이 설계는 삭제자의 런타임 다형성을 가능하게 하지만, 힙 할당을 강제하고 unique_ptr가 누리는 스택 공간 최적화를 방지합니다.