본문 바로가기

컴퓨터과학

C++ (19-2) 소유권 공유 자원의 관리에는 std::shared_ptr를 사용할 것

C++ (19-1) 소유권 공유 자원의 관리에는 std::shared_ptr를 사용할 것 ~ (19-2)

 

C++ shared_ptr


사용자는 커스텀 삭제자의 형식이 서로 다른(이를테면 커스텀 삭제자들을 람다 표현식으로 지정했기 때문에) 두 std::shared_ptr<Widget>을 생각해 봅시다.

auto customDeleter1 = [](Widget *pw) { ... }; // 커스텀 삭제자들
auto customDeleter2 = [](Widget *pw) { ... }; // 형식은 서로 다름

std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

pw1과 pw2는 같은 형식이므로, 그 형식의 객체들을 담는 컨테이너 안에 집어넣을 수 있습니다.

std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };

또한, 하나를 다른 하나에 배정할 수도 있고, 둘 다 std::shared_ptr<Widget> 형식의 매개변수를 받는 함수에 넘겨줄 수 있습니다. 그러나 커스텀 삭제자 형식이 다른 두 std::unique_ptr에서는 이런 일이 불가능합니다. std::unique_ptr의 형식에는 커스텀 삭제자의 형식이 영향을 미치기 때문입니다.
std::unique_ptr와의 또 다른 차이점은, 커스텀 삭제자를 지정해도 std::shared_ptr 객체의 크기가 변하지 않는다는 점입니다. 삭제자와 무관하게, std::shared_ptr 객체의 크기는 항상 포인터 두 개 분량입니다. 이는 아주 좋은 소식이기도 하지만, 뭔가 꺼림칙한 점도 느꼈을 것입니다. 함수 객체를 커스텀 삭제자로 사용할 수 있다는 점과 함수 객체가 임의의 분량의 자료를 담을 수 있다는 점을 조합하면, 커스텀 삭제자가 얼마든지 커질 수 있다는 결론이 나옵니다. 그렇다면, std::shared_ptr가 임의의 크기의 삭제자를 추가적인 메모리 없이 지칭하는 비결은 무엇일까요??
사실 그런 비결은 없습니다. 즉, std::shared_ptr가 추가적인 메모리를 사용할 수도 있습니다. 단, 그 메모리는 std::shared_ptr 객체의 일부가 아닙니다. 기본적으로 그 추가 메모리는 힙에서 할당되며, std::shared_ptr 객체의 생성자가 std::shared_ptr의 커스텀 할당자 지원을 활용하는 경우에는 그 할당자가 관리하는 메모리가 쓰입니다. 앞에서 std::shared_ptr 객체가 자신이 가리키는 객체에 대한 참조 횟수를 가리키는 포인터도 담근다고 했습니다. 그 말이 틀린 것은 아니지만, 오해의 소지가 있습니다. 사실 참조 횟수는 제어 블록(control block)이라고 부르는 더 큰 자료구조의 일부입니다. std::shared_ptr가 관리하는 객체당 하나의 제어 블록이 존재합니다. std::shared_ptr 생성 시 커스텀 삭제자를 지정했다면, 참조 횟수와 함께 그 커스텀 삭제자의 복사본이 제어 블록에 담깁니다.

(* '제어 블록'은 구현(컴파일러)이 사용할 수 있는 구현 세부사항일 뿐, 표준에 명시된 것은 아닙니다. 사실 C++11, C++14 표준은 소유권 공유 포인터를 반드시 참조 계수(reference counting) 방식으로 구현해야 한다고 요구하지 않습니다. 즉, 참조 횟수라는 것이 아예 없는 소유권 공유 포인터의 구현도 존재할 수 있습니다. 그러나 현실적으로 소유권 공유 포인터를 참조 계수 방식으로 구현하는 것과 그에 필요한 참조 회수 등의 관리용 자료를 포인터 객체와는 다른 어떤 공간(여기서 말하는 제어 블록)에 저장하는 것이 합리적인(어쩌면 가장 합리적인) 선택이자 보편적으로 쓰이는 방식이라는 점은 사실입니다. 이 책에는 이처럼 실제 C++ 표준은 아니지만 '사실상(de facto)' 표준인 기법이나 구현 방식들이 종종 등장합니다.)

커스텀 할당자를 지정했다면 그 할당자의 복사본도 제어 블록에 담깁니다. 그 외에도 제어 블록에는 약한 횟수(Chapter(21) 참고)라고 부르는 이차적인 참조 횟수가 포함되며 그밖의 추가 자료가 포함될 수 있으나, 지금 논의에서는 그런 추가 자료의 존재를 무시하기로 합니다. 다음은 하나의 std::shared_ptr<T> 객체에 연관된 메모리에 관한 내용입니다.
객체의 제어 블록은 그 객체를 가리키는 최초의 std::shared_ptr가 생성될 때 설정됩니다. 일반적으로 어떤 객체에 대한 std::shared_ptr를 생성하는 코드에서 그 객체를 가리키는 다른 std::shared_ptr가 이미 존재하는지(따라서 제어 블록이 이미 존재하는지)를 알아내는 것은 불가능하지만, 제어 블록의 생성 여부에 관해 다음과 같은 규칙들을 유추할 수는 있습니다.

- std::make_shared(Chapter(21) 참고)는 항상 제어 블록을 생성합니다. 이 함수는 공유 포인터가 가리킬 객체를 새로 생성하므로, std::make_shared가 호출되는 시점에서 그 객체에 대한 제어 블록이 이미 존재할 가능성은 전혀 없습니다.

- 고유 소유권 포인터(즉, std::unique_ptr나 std::auto_ptr)로부터 std::shared_ptr 객체를 생성하면 제어 블록이 생성됩니다. 고유 소유권(unique-ownership) 포인터는 제어 블록을 사용하지 않으므로, 피지칭 객체에 대한 제어 블록이 이미 존재할 가능성은 전혀 없습니다. (생성 과정에서 std::shared_ptr 객체는 피지칭 객체의 소유권을 획득하므로, 고유 소유권 포인터는 널로 설정됩니다.)

- 생 포인터로 std::shared_ptr 생성자를 호출하면 제어 블록이 생성됩니다. 이미 제어 블록이 있는 객체로부터 std::shared_ptr를 생성하고 싶다면, 생 포인터가 아니라 std::shared_ptr나 std::weak_ptr(Chapter (20) 참고)를 생성자의 인수로 지정하면 됩니다. std::shared_ptr나 std::weak_ptr를 받는 std::shared_ptr 생성자들은 새 제어 블록을 만들지 않습니다. 전달된 똑똑한 포인터들이 이미 필요한 제어 블록을 가리키고 있을 것이기 때문입니다.

이 규칙들에서 비롯되는 한 가지 결과는, 하나의 생 포인터로 여러 개의 std::shared_ptr를 생성하면 피지칭 객체에 여러 개의 블록이 만들어지므로, 의문의 여지 없이 미정의 행동이 된다는 점입니다. 제어 블록이 더러 개라는 것은 참조 횟수가 여러 개라는 뜻이며, 참조 횟수가 여러 개라는 것은 해당 객체가 여러 번 파괴된다는 뜻입니다(참조 횟수마다 한 번씩). 간단히 말해서, 다음과 같은 코드는 엄청나게 나쁩니다.

auto pw = new Widget; // pw는 생 포인터
...
std::shared_ptr<Widget> spw1(pw, loggingDel); // *pw에 대한 제어 블록이 생성됨
...
std::shared_ptr<Widget> spw2(pw, loggingDel); // *pw에 대한 두 번째 제어 블록이 생성됨

동적으로 할당된 객체를 가리키는 생 포인터 pw를 만드는 것 자체도 나쁜 일입니다. 이번 장 전체에 깔린 조언, 즉 똑똑한 포인터를 생 포인터보다 선호하라는 조언에 반하는 일이기 때문입니다. (혹시 그 조언의 동기를 까먹었다면, 이번 장 도입부를 다시 읽어보기 바랍니다.) 그러나 그 점은 일단 제쳐놓기로 합시다. pw를 생성하는 문장은 코딩 스타일 면에서 역겹긴 하지만, 프로그램의 미정의 행동을 유발하지는 않습니다.
그다음 문장에서는 생 포인터로 sp1의 생성자를 호출하며, 이에 의해 피지칭 객체에 대한 제어 블록이(따라서 참조 횟수가) 생성됩니다.