Smart Pointer
raw pointer의 단점
- 객체와 배열 둘다 가리킬 수 있음
- 사용자가 delete해야 할 책임이 있는지 확인 어려움
- 어떻게 해제 해야하는지에 대한 정보를 얻기 어려움 (delete or 다른 매커니즘)
- delete ? delete [] ?
- 파괴가 정확히 한 번 일어남을 보장하기 어려움
- 파괴가 일어나지 않으면 메모리 누수
- 파괴를 여러번 수행 = 미정의 행동
- 포인터가 객체를 가리키고 있는 상황에서 객체 파괴 시 포인터는 대상을 잃음
smart pointer
- 동적으로 할당된 객체의 수명 관리에 도움이 되도록 보장, 자원 누수가 생기지 않도록 설계됨
unique_ptr
- auto_ptr 보다 더 효율적
- 객체 복사의 의미론을 왜곡 x
4가지 스마트 포인터의 공통적인 기능은 기본 생성 뿐
항목18: 소유권 독접 자원의 관리에는 std::unique_ptr 을 사용하라
raw pointer와 같은 크기라고 가정할 수 있음
독점적 소유권 의미론
- nullptr 이 아닌 unique_ptr 은 항상 자신이 가리키는 객체를 소유함
- std::unique_ptr을 이동하면, 소유권이 대상 포인터로 옮겨짐
- 복사 금지
- 이동 전용 타입
- 소멸 시 자원 파괴
흔한 용도
- 상속 계층구조 안의 객체를 생성하는 팩터리 함수의 리턴 타입으로 쓰이는 것
- 팩터리 함수: 흔히 힙에 객체 생성, 그 객체를 가리키는 포인터 리턴 (객체 삭제는 호출자 몫)
- Pimpl 관용구의 구현 메커니즘 (항목22)
- 상속 계층구조 안의 객체를 생성하는 팩터리 함수의 리턴 타입으로 쓰이는 것
커스텀 삭제자 (custom deleter)
- 해당 삭제자는 해당 자원의 파괴 시점에서 호출되는 임의의 함수
- 로그 기록 가능
- 함수 포인터를 삭제자로 지정할 경우 unique_ptr의 크기가 1워드에서 2워드로 증가
- 삭제자가 함수 객체일 경우 그 객체 크기만큼 포인터의 크기가 증가
- 상태 없는 함수 객체 (람다)의 경우 크기 변화가 없으
스마트 포인터 팁
- 다중 인자 템플릿에서 파라미터를 받아 생성할 때, Ts … params 을 완벽하게 전달하기 위해 forward를 사용
- std::unique_ptr<T[]>: 배열용 포인터
- std::shared로 쉽게 변환 가능, 효율적
- 팩터리 함수의 리턴타입으로 적합한 이유
- 좀 더 유연한 동기(sibling)으로 변환할 수 있는 여지
기억해 둘 사항
std::unique_ptr은 독점 소유권 의미론을 가진 자원의 관리를 위한, 작고 빠른 이동 전용 스마트 포인터
- 기본적으로 자원 파괴는 delete를 통해 일어나, 커스텀 삭제자를 지정할 수 있음
- 커스텀 삭제자에 따라 unique_ptr의 크기가 변함
- std::shared_ptr 로 쉽게 변환 가능
항목19: 소유권 공유 자원의 관리에는 std::shared_ptr 사용하라
std::shared_ptr
- 공유된 소유권 의미론
- 객체가 더 이상 필요하지 않게 된 시점에서 객체가 파괴됨
자원의 참조 횟수
- std::shared_ptr의 생성자는 참조횟수를 증가, 소멸자는 감소
- 복사 대입 연산자는 증가와 감소를 모두 수행
성능
- std::shared_ptr의 크기는 raw_pointer 의 두배
- 생포인터 + 참조 횟수
- 참조횟수를 담는 메모리 = 동적할당
- shared_ptr가 가리키는 객체 자체는 참조 횟수를 알지 못함
- std::make_shared를 이용하면 동적할당의 비용 피하기 가능(항목 21)
참조 횟수의 증가와 감소가 반드시 원자적 연산이어야함
- 원자적 연산은 비원자적 연산보다 느림
- 참조 횟수가 증가하지 않는 경우
- 이동 생성
- 원본 std::shared_ptr은 nullptr이됨, 새 std::shared_ptr의 수명이 시작될 때 기존의 ptr은 더이상 자원을 가리키지 않는 상태
- std::shared_ptr를 이동하는 것이 복사하는 것보다 빠름 (참조 횟수 증가 여부)
- 이동 생성
커스텀 삭제자
- 삭제자의 타입이 포인터 타입의 일부가 아니라 설계가 더 유연
- 같은 객체에 서로 다른 삭제자
- 삭제자에 따라 크기가 변하지 않음
- 삭제자와 무관하게 항상 포인터 두 개 분량
- 임의의 크기의 삭제자를 추가적인 메모리 없이 지칭..
- 추가적인 메모리 사용 가능
- std::shared_ptr의 객체의 일부가 아님
- 추가 메모리는 힙에서 할당,
제어블록
- 참조횟수는 제어블록이라고 부르는 더 큰 자료구조의 일부(사실상 표준 구현방법)
- shared_ptr가 관리하는 객체당 하나의 제어 블록이 존재
- shared_ptr 생성 시 커스텀 삭제자를 지정하면
- 참조 횟수 + 커스텀 삭제자의 복사본 = 제어 블록에 담김
- 커스텀 할당자도 마찬가지
- 약한 횟수도 포함 (항목21)
최초의 std::shared_ptr가 생성될 때 설정
제어블록2
- std::make_shared는 항상 제어블록을 생성
- 공유 포인터가 가리킬 객체를 새로 생성하므로
- 그 객체에 대한 제어블록이 이미 존재할 가능성 0
- std::unique_ptr으로부터 shared_ptr을 생성하면, 제어블록이 생성
- std::unique_ptr은 제어블록을 사용하지 않으므로
- raw_pointer로 std::shared_ptr 생성자 호출하면 제어블록 생성
- std::shared_ptr, std::weak_ptr을 생성자 인수로 지정하면 새로운 제어 블록을 만들지 않음
- std::make_shared는 항상 제어블록을 생성
- 제어블록 크기
- 커스텀 삭제자 + 커스텀 할당자 => 크기가 더 커질 수 있음
- 구현이 복잡 (상속 활용, 파괴하기 위한 가상함수 존재)
주의점
- raw pointer로 shared_ptr를 생성하지 마라
- 하나의 raw pointer로 여러 개의 std::shared_ptr를 생성하면, 여러 개의 제어블록이 생성
- 해당 객체가 여러 번 파괴 가능
- 대안: std::make_shared (그러나 커스텀 삭제자 지정 불가)
raw pointer를 사용할 수 밖에 없다면 new 의 결과 직접 전달
- this pointer를 전달하면, 새 제어블록이 만들어짐 -> 미정의 행동
- std::enable_shared_from_this 라는 템플릿 사용하여 방지 가능 (CRTP)
- this 대신
shared_from_this()
사용 - 이는 이미 shared_ptr이 존재한다는 가정 (없으면 함수의 행동은 정의되지 x)
- 생성자를 private로 선언, 팩터리 함수를 제공해야함
- this 대신
- std::enable_shared_from_this 라는 템플릿 사용하여 방지 가능 (CRTP)
1
2
3
4
std::vector<std::shared_ptr<Widget>> pw;
void Widget::process() {
pw.emplace_back(this); // 잘못된 형식
}
std::make_shared
- std::make_shared로 생성
- 제어블록의 크기 = 워드 3개
- std::shared_ptr의 역참조 비용은 raw_pointer 비용보다 크지 않음 (단순히 명령어 하나차이)
불가능한 일
- std::shared_ptr을 std::unique_ptr로 불가
std::shared_ptr<T[]>
없음 즉, 배열관리 불가
기억해 둘 사항들
- std::unique_ptr 크기의 두배
- 제어 블록에 관련된 추가 비용이 발생
- 원자적 참조 횟수 조작을 요구
- 커스텀 삭제자 지원, 타입과 무관
- raw pointer로부터 std::shared_ptr를 생성하는 일은 피해야함
항목20: std::shared_ptr처럼 작동하되, 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr을 사용하라
- 소유권 공유에 참여 x (참조 횟수에 영향 x)
- 역참조 불가, 널체크 불가
- shared_ptr를 인자로 생성됨
expired()
로 객체 유효 체크 가능
std::weak_ptr의 사용
- 만료 여부를 체크하고, 가리키는 객체에 대한 접근을 돌려주는 연산을 하나의 원자적 연산으로 수행하는 것
- std::weak_ptr에서 std::shared_ptr을 생성하는 것
- 이미 만료됨: 널 리턴
- std::weak_ptr에서 std::shared_ptr을 생성하는 것
1
2
3
std::shared_ptr<Widget> spw1 = wpw.lock();
auto spw2 = wpw.lock(); // spw2는 만료될 수 있음 그러면 null
std::shared_ptr<Widget> spw3{wpw}; // wpw가 만료이면 bad_weak_ptr 발생
캐시 적용 팩터리 함수
팩터리 함수의 비용이 크다고 할 때 (조회를 위해 파일이나 데이터베이스 입출력 수행 등)
- ID들을 되풀이해서 쓰이는 경우가 많을 때
- 팩터리 함수와 같은일을 하되, 호출 결과들을캐싱하는 함수를 작성하는 것
- 요청된 Widget을 캐시에 담아둔다면, 성능상 문제
- 더이상 쓰지 않는 Widget을 캐시에서 제거해야함
두 조건
- 호출자가 캐싱된 객체를 가리키는 스마트 포인터를 받아야함
- 그객체들의 수명을 호출자가 결정할 수 있어야함
- 팩터리 함수가 돌려준 객체를 클라이언트가 다 사용하고 나면 그 객체는 파괴
- 캐시 항목은 대상을 잃게됨
weak_ptr
- 캐시에 저장할 포인터는 자신이 대상을 잃었음을 감지할 수 있어야함
- 팩터리 함수의 반환이 shared_ptr 이어야함
관찰자 패턴
Observer
- 관찰자 대상(subject; 상태가 변할 수 있는 객체)
- 관찰자(observer; 상태가 변할 때 알림을 받는 객체)
구현
- 각 관찰자 대상 객체에는 관찰자들을 가리키는 포인터들을 담은 멤버가 있음
- 관찰 대상은 관찰자들의 수명을 제어하는 데에는 관심이 없지만, 파괴된 관찰자에 접근해서는 안됨
- 관찰 대상에 합당한 설계
- 관찰자들을 가리키는 std::weak_ptr들의 컨테이너를 멤버로 두는 것
- 만료 여부를 보고 관찰자가 유효한지 점검한 후에 관찰자에 접근 가능
더블 링크드 리스트 or 소유권 공유
graph LR;
A-->B;
C-->B;
B-.->A;
위와 같은 상황일 때 B에서 A를 가리키는 포인터는 어떤 포인터?
- Raw Pointer
- A가 파괴되면 B는 dangling pointer를 가지게 됨 (대상 잃은 포인터 역참조)
- shared_ptr
- 순환고리가 생김, A와 B가 파괴 되지 않음
- 메모리 누수 발생
- weak_ptr
- 위의 두 문제 해결
트리와 같이 엄격한 계층 구조
- 자식 노드노드들을 오직 그 부모만 소유
- 부모노드가 파괴 -> 자식들도 파괴
- 부모에서 자식은 std::unique_ptr로 구현하는게 최선
- 역링크는 raw pointer로 충분 (수명을 부모가 관리하기 때문에 역참조 위험 x)
효율성
std::shared_ptr와 본질적으로 효율성은 동일
- 객체의 소유권 공유에 참여 x
- 피지칭 객체의 참조 횟수에 영향 x
- 제어 블록에는 ‘두 번째’ 참조 횟수가 있음
- std::weak_ptr이 조작하는 참조횟수
기억해 둘 사항
std::shared_ptr처럼 동작하되 대상을 잃을 수 있는 포인터가 필요하면 weak 사용
weak 의 잠재적인 용도: 캐싱, 옵저버 패턴에서 옵저버 목록, std::shared_ptr 순환고리 방지
항목21: new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호하라
std::make_unique 와 std::make_shared
- std::make_shared는 C++11
- std::make_unique는 C++14
- 구현: 매개변수들을 생성할 객체의 생성자로 완벽 전달 (배열 지원 x)
1
2
3
4
5
6
// C++11에서 직접 구현
template<typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts&&... params)
{
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}
- 두 make는 임의의 개수와 타입의 인수들을 받아서 그것들을 생성자로 완벽 전달해서 객체를 동적으로 생성하고, 그 객체를 가리키는 스마트포인터를 돌려줌
사용할 이유
1. new 생성과의 본절적인 차이점
1
2
auto upw1(std::make_unique<Widget>());
std::unique_ptr<Widget> upw2(new Widget());
- new 방식
- 생성할 객체의 타입이 되풀이해서 나옴
- 소프트웨어 공학의 핵심 교의: “코드 중복을 피하라”와 충돌
- 컴파일 시간이 늘어나고, 목적 코드의 덩치가 커지고, 코드 기반으로 다루기 어려워짐, 일관성 없는 코드로 진화, 비일관성은 버그로 이어짐, 타자량이 많아짐
- 생성할 객체의 타입이 되풀이해서 나옴
2. 예외 안정성
1
2
3
void processWidget(std::shared_ptr<Widget> spw, int priority);
int computePriority();
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
자원 누수 발생 가능성
- 컴파일러가 소스코드를 목적코드로 번역하는 방식과 관련
- 실행 시점에서 함수가 호출될 때, 함수의 코드가 실행되기 전에 함수의 인자들이 먼저 평가됨
- 위 코드에서 processWidget의 경우 다음과 같은 순서로 실행됨
new Widget
이 평가, Widget 객체 힙에 생성- new가 산출한 포인터를 관리하는
std::shared_ptr<Widget>
의 생성자가 실행 - computePriority가 실행
- 컴파일러가 위 순서대로 실행하는 코드를 생성해야하는 것은 아님
new Widget
이 평가, Widget 객체 힙에 생성- computePriority가 실행
std::shared_ptr<Widget>
의 생성자가 실행
- 이렇게 computePriority가 먼저 실행될 때, 이 함수가 예외를 던지면, 1에의해 메모리 누수 발생
- 컴파일러가 소스코드를 목적코드로 번역하는 방식과 관련
이러한 문제는 make_shared, make_unique 함수를 사용하면 해결 가능
3. 향상된 효율성
- 컴파일러가 좀 더 간결한 자료구조를 사용하는 더 작고 빠른 코드를 산츨할 수 있게 됨
1
std::shared_ptr<Widget> spw(new Widget());
- new 사용시
- 한 번의 메모리 할당 실행 x
- 두 번의 할당이 일어남
- 제어 블록을 위한 메모리 할당
1
auto spw = std::make_shared<Widget>();
make_shared 사용시
- 한 번의 메모리 할당
- 제어 블록과 Widget 객체 모두를 담을 수 있는 크기의 메모리 조각을 한 번에 할당
- 프로그램의 정적 크기가 줄어듦
- 실행 코드 속도도 빨리짐
- 제어 블록에 일정 정도의 내부 관리용 정보를 포함할 필요가 없어, 전체적인 메모리 사용량이 줄어듦
- 한 번의 메모리 할당
std::allocate_shared 또한 make_shared 장점이 그대로 적용됨
make 함수 사용할 수 없는 상황
커스텀 삭제자 지정 불가
- 구현들의 구문적 세부사항에서 비롯된 한계
- std::initializer_list를 받는 생성자와 받지않는 생성자를 모두 가진 형식의 객체를 생성할 때
- 중괄호나 괄호에 따라 버전이 달라짐
- make는 forward를 통해 객체의 생성자에 완벽하게 전달
- 이 때, 구현이 중괄호? 괄호? 에 따라 커다란 차이가 생길 수 있음
- make함수들은 괄호를 사용함
- 따라서 중괄호 초기치로 생성하려면 new를 사용해야함
- 아니면, initializer_list 객체를 생성하여 파라미터로 넘겨주어야함
중괄호 초기치는 forward 불가
std::shared_ptr만의 문제
- 클래스에 자신만의 new와 delete를 정의하는 경우 std::make_shared는 부적합
- 이 상황은 전역 메모리 할당 루틴과 해제 루틴이 그 타입의 객체에 적합하지 않음을 의미
- 클래스 고유 메모리 관리 루틴: 클래스의 객체와 정확히 같은 크기의 메모리 조각들만 할당, 해제하는 경우가 많음
- 이런 경우 커스텀 할당(std::allocate_shared)과 커스텀 해제에는 잘안맞음
- std::allocate_shared가 요구하는 메모리 조각의 크기는 동적으로 할당되는 객체의 크기가 아니라, 그 크기에 제어블록의 크기를 더한 것이기 때문.
- make_shared의 속도상 장점은 제어블록이 객체와 동일한 메모리 조각에 놓이기 때문
- 하지만, 객체가 차지하고 있던 메모리는, 제어블록이 파괴되기 전까지 해제 불가
make함수가 할당한 메모리 조각은 shared, weak 둘다 파괴된 후 해제됨
- weak_ptr이 참조횟수를 점검해야함
- weak_ptr이 존재하는한 제어블록도 존재해야함
객체의 크기가 크고, weak 파괴와 shared의 파괴 간격이 길다면, 메모리 해제되는 시점 사이에 시간 지연이 생김
new를 사용하는 경우 shared_ptr이 파괴되면 즉시 그 객체의 메모리가 해제
- 최선의 방법
- new의 결과를 하나의 문장에서 즉시 넘겨주는것
- 커스텀 삭제자와 같이 넘기고 예외가 발생하면, 커스텀 삭제자로 메모리 누수 방지
- 비효율적임: 함수 호출에서 왼값을 넘겨준다는것, 복사가 발생한다는것, std::move로 오른값변환을 해야함
- new의 결과를 하나의 문장에서 즉시 넘겨주는것
1
processWidget(std::move(spw), computePriority());
기억해 둘 사항들
new 직접 사용에 비해, make를 사용하면
- 코드 중복의 여지가 없음
- 예외 안전성 향상
- allocate_shared와 마찬가지로 더 작고 빠른 코드가 산출됨
make 함수 부적절한 경우
- 커스텀 삭제자 지정해야하는 경우
- 중괄호 초기치를 전달해야하는 경우
make_shared 함수 부적절한 경우
- 커스텀 메모리 관리 기능을 가진 클래스를 다루는 경우
- 메모리가 넉넉하지 않은 시스템에서 큰 객체를 자주 다루고, weak_ptr의 수명이 긴 경우
항목22: Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정의하라
- Pimpl 관용구
- 클래스의 멤버들을 포인터로 대체
- 클래스에서 쓰이는 자료 멤버들을 그 구현 클래스로 옮기고
- 포인터를 통해서 그 자료 멤버들에 간접적으로 접근하는 기법
1
2
3
4
5
class Widget {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
}
- 위 코드를 C++98에서는 다음과 같이 구현
1
2
3
4
5
6
class Widget {
// 생성자, 소멸자
// ...
struct Impl;
Impl* pImpl;
};
그 다음, 원래의 클래스에서 사용하던 자료 멤버들을 담는 객체를 동적으로 할당, 해제하는 코드를 추가하는 것
- 이러한 할당 및 해제 코드는 클래스를 구현하는 소스 코드 파일에 둠
이렇게하면, 의존성들이 헤더에서 cpp 파일로 옮길 수 있음
- 단, 반드시 포인터를 동적으로 할당 및 해제해야함
스마트 포인터를 사용한 PIMPL
1
2
3
4
class Widget {
struct Impl;
std::unique_ptr<Impl> pImpl;
};
1
2
3
4
5
6
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>) {}
소멸자가 없어도되지만 컴파일 오류 발생
컴파일러는 소멸자를 자동으로 생성, pImpl의 소멸자를 호출하는 코드를 삽입
- unique_ptr의 기본 삭제자는 불완전한 형식을 가리키지 않는지를 C++11의 static_assert를 이용하여 점검
- 컴파일러는 형식의 정의를 보게 되면 그 형식을 완전한 형식으로 간주
- Widget::Impl의 정의는 cpp 파일에 있음
- 따라서 Impl 정의 이후에 컴파일러가 그 소스 파일에만 있는 Widget의 소멸자의 본문 을 보게한다면, 문제없이 컴파일러됨
1
2
3
4
5
6
7
class Widget {
// 생성자
// ...
~Widget();
struct Impl;
std::unique_ptr<Impl> pImpl;
};
1
2
3
4
5
6
7
8
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>) {}
Widget::~Widget() = default;
이동 연산
- Pimpl 관용구를 사용하는 클래스는 이동 연산들을 지원하기에 자연스러운 후보
- 컴파일러가 자동으로작성하는 이동 연산들이 그런 클래스의 요구에 맞는 std::unique_ptr에 대한 이동을 수행하기 때문
1
2
3
4
5
6
7
8
9
10
11
class Widget {
// 생성자
// ...
~Widget();
Widget(Widget&& rhs) = default; // 오류
Widget& operator=(Widget&& rhs) = default; // 오류
struct Impl;
std::unique_ptr<Impl> pImpl;
};
- 하지만 소멸자를 선언하면, 이동 연산들을 직접 작성해야함
- 헤더파일안에 pImpl이 불완전하기에, 자동으로 생성하는것이 불가능 (컴파일러가 자동으로 작성한 이동 연산자는 pImpl을 재배정하기 전에 pImpl이 가리키는 객체를 파괴해야함)
- 이동 연산들의 정의를 구현파일로 옮기면됨
1
2
3
4
5
6
7
8
9
10
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>) {}
Widget::~Widget() = default;
Widget::Widget(Widget&& rhs) = default;
Widget::Widget& operator=(Widget&& rhs) = default;
깊은 복사 연산
std::unique_ptr같은 이동 전용 타입이 있는 클래스에 대해서는, 컴파일러가 복사 연산들을 작성해주지 않는다.
작성한다고 해도, 작성된함수들은 std::unique_ptr 자체만 복사하는 얕은 복사를 수행하기 때문
1
2
3
4
5
6
7
8
9
10
class Widget {
// 생성자, 이동, 소멸...
// ...
Widget(Widget& rhs);
Widget& operator=(Widget& rhs);
struct Impl;
std::unique_ptr<Impl> pImpl;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
// ... 소멸 등
Widget::Widget(Widget& rhs): pImpl(nullptr) {
if(rhs.pImpl) {
pImpl = std::make_unique<Impl>(*rhs.pImpl);
}
}
Widget::Widget& operator=(Widget& rhs) {
if(!rhs.pImpl) {
pImpl.reset()
}
else if(!pImpl) {
pImpl = std::make_unique<Impl>(*rhs.pImpl);
}
else {
*pImpl = *rhs.pImpl;
}
return *this;
}
- 위 코드에서 널체크한 것, Impl의 복사연산을 활용한 것을 확인 가능
std::shared_ptr을 활용한 Pimpl
이 경우, 소멸자를 선언할 피룡가 없으며, 컴파일러가 이동연산들을 작성함
이러한 차이는, 커스텀 삭제자를 지원하는 방식의 차이에서 비롯된 것
- unique_ptr의 삭제자 형식
- 포인터 형식의 일부
- 그렇기에, 컴파일러가 작성하는 멤버 함수가 쓰이는 시점에서 피지칭 형식들이 완전한 형식들이어야함
- shared_ptr의 삭제자 형식
- 포인터 형식의 일부가 아님
- 실행시점 자료구조가 더 크고, 코드도 좀 느려짐
- 그러나, 컴파일러가 작성하는 멤버 함수가 쓰이는 시점에서 피지칭 형식들이 완전한 형식이 아니어도 상관없음
- unique_ptr의 삭제자 형식
기억해 둘 사항들
Pimpl 관용구는 클래스 구현과 클래스 클라이언트 사이의 컴파일 의존성을 줄임
- 빌드시간 감소
unique_ptr의 경우
- 소멸자 등 함수들을 헤더에 선언하고, 구현파일에서 구현해야함