Smart Pointers
C++ 11
#include
자바와 C++의 차이점 중 한가지는 Garbage collection의 유무입니다. 자바의 JVM(Java Virtual Memory)가 Garbage Collection이라는 프로세스를 통해 메모리를 관리하며, 이는 프로그램에서 사용되지 않는 메모리를 지속적으로 찾아내서 제거합니다. 즉, 실행 중인 JVM 내부에서 일어납니다. 현재는 메탈에 가까워졌다곤 하지만 인간이 제어할 수 있는 내에선 여전히 프로그래머가 직접 메모리를 지정하는 것이 좋다고 할 수 있습니다.
”메탈에 가깝다“란 개발자가 운영체제의 메모리를 프로그램적으로, 즉 코드를 작성함으로써 관리할 수 있다는 것을 의미합니다.
따라서, C++에서는 메모리의 지정 및 해제를 new, delete를 통해 프로그래머가 직접 설정해야 하는데 인간은 여태까지 그래왔듯 실수를 하기 마련입니다. 그럴 경우 메모리 누수(memory leak)이 발생하게 되고 오류로 이어질 수 있습니다. 이러한 실수를 방지할 수 있는 방법 중 한가지가 바로 스마트포인터입니다.
Resource Acquisition Is Initialization - RAII
C++ 창시자인 비야네 스트로스트룹은 C++에서 자원을 관리하는 방법으로 RAII의 패턴을 제안하였습니다. 직역하면 **”자원의 획득은 초기화다”**인데 이는 자원 관리를 스택에 할당한 객체를 통해 수행하게 되는 것입니다.
예외가 발생하여 함수 혹은 해당 스코프를 벗어날 경우 해당 스택에 정의되어 있는 모든 객체들은 소멸자가 호출되지만(stack unwinding) 객체가 아닌 포인터들의 경우 소멸자가 호출되지 않아 메모리 누수(memory leak)가 발생하게 됩니다. 즉, 자원관리를 스택의 객체(포인터 객체)를 통해 수행하게 되는 것입니다.
스마트 포인터는 포인터처럼 사용하는 클래스 템플릿으로 객체가 소멸될 때 자동으로 호출되는 소멸자 함수를 통해 메모리를 자동으로 해제해주는 역할을 수행합니다.
스마트 포인터를 지정하는 방식은 shared_ptr
, unique_ptr
이 있으며 지금부터 살펴보도록 하겠습니다.
shared_ptr
Reference Counting을 이용하는 스마트 포인터로 객체를 참조할 때 내부적으로 레퍼런스 카운트를 유지하다가 이 값이 0이될 경우 더 이상 해당 객체의 메모리를 사용하지 않는다고 판단하여 자동으로 해당 객체의 메모리를 해제합니다.
shared_ptr 객체가 선언된 스코프를 벗어나게 되면 소멸자가 호출됩니다.
1 | delete Pointer; |
간단한 예제를 통해 확인해보겠습니다.
1 |
|
위의 코드를 실행할 시 아래와 같이 출력됩니다.
이 때, 중간에 주석처리한 문장은 에러를 출력합니다. 그 이유는 shared_ptr은 explicit한 형지정만 가능하기 때문입니다.
1 | //output |
앞서 Reference Counting이 된다고 했으므로 이번에는 실제 count된 숫자를 출력해보도록 하겠습니다.
Reference counting
shared_ptr의 기본적인 Reference counting 동작은 다음과 같습니다.
- shared_ptr 변수의 Reference counting은 0입니다.
- make_shared를 사용하여 shared_ptr 객체를 생성하면 Reference counting이 1이 됩니다.
- 다른 shared_ptr 변수에 대입하면 Reference counting이 1씩 증가하게 됩니다. 대입한 변수 또한 Reference counting은 2가 됩니다.
- nullptr을 대입한 변수는 Reference counting은 0이 됩니다. 같은 포인터를 참조하고 있던 다른 변수는 1씩 감소합니다.
- reset()을 사용해도 nullptr처럼 참조 카운터가 0이 됩니다.
예시를 통해 위를 확인해보도록 하겠습니다.
1 |
|
또한, shared_ptr객체가 해당 스코프를 벗어날 경우 참조 카운터를 1 감소시킵니다.
- 초기 Reference counting는 1입니다.
- 다른 변수에 대입할 경우 Reference counting는 1 증가합니다.
- 스코프를 벗어날 경우 해당 스코프내의 shared_ptr은 소멸되며 참조하고있던 shared_ptr의 Reference counting은 1 감소합니다.
그 예시는 아래와 같습니다.
1 |
|
Call by reference
또한, Call by reference를 사용할 경우에는 Reference count가 증가하지 않습니다.
- Call by Reference를 사용할 경우
1 |
|
1 | //output |
- Call by Reference를 사용하지 않을 경우
1 |
|
1 | //output |
move()
move를 사용하여 shared_ptr 변수가 가리키는 포인터를 다른 shared_ptr로 이동할 수 있습니다. 기존 shared_ptr은 참조하는 것이 사라지므로 Reference counting은 0이 됩니다.
1 |
|
1 | //output |
첫번째로 make_shared를 통해 객체 생성시 reference count는 증가하지만 make함수를 사용하지 않고 선언만 할 시 참조하고 있는 것이 없으므로 reference count는 0입니다.
두번째로 s2의 객체를 생성하여 s의 소유권을 move()함수를 통해 옮길 시 s의 reference count는 0이되고 s2의 reference count는 1이됩니다.
reset()
shared_ptr 객체가 가리키는 포인터를 새롭게 연결하거나 기존의 것을 연결 해제하는 메서드입니다. 파라미터가 없을경우는 연결해제하고 있을 경우에는 재연결을 하게 됩니다.
예시를 통해 살펴보겠습니다.
- 파마리터가 없이 사용할 경우
1 |
|
- 파라미터가 있을 경우
1 |
|
단, nullptr
을 대입할 경우에는 파라미터가 없는 reset() 메서드를 사용한 것과 동일한 동작을 수행합니다.
shared_ptr의 주의할 점
둘 이상의 shared_ptr이 같은 포인터를 가리키면 안됩니다.
그 이유는 하나의 shared_ptr의 객체를 소멸시키려 했지만 다른 shared_ptr 객체는 여전히 포인터를 가리키기 때문에 소멸이 되지 않기 때문입니다.
1 |
|
힙(heap)영역이 아닌 스택(stack)영역을 가리키는 포인터를 사용하여 shared_ptr 객체를 생성해선 안됩니다.
힙메모리를 사용하면 스코프를 벗어나도 포인터가 가리키는 메모리가 유지되지만 스택 메모리를 사용할 경우 스코프를 벗어나면 사라지기 때문입니다.
1 |
|
unique_ptr
하나의 스마트 포인터만이 특정 객체를 소유할 수 있도록, 객체에 소유권의 개념을 도입한 스마트 포인터입니다.
이 스마트 포인터는 해당 객체의 소유권을 가지고 있을 때만, 소멸자가 해당객체를 삭제할 수 있습니다.
또한 move() 멤버함수를 통해 소유권을 이전할 수는 있지만 복사할 수는 없습니다.
소유권 이전이 발생하면 이전의 인스턴스는 해당 객체를 소유하지 않게 재설정됩니다.
이 때, C++ 14 이후부터 제공되는 make_unique()함수를 이용하면 더 안전하게 생성할 수 있습니다. 또한 C++ 14 이후 버전을 사용한다면 이는 권장되는 함수인데 그 이유는 아래와 같습니다.
1 | autoupw1(std::make_unique<Widget>());// make 함수 사용 |
위와 같이 make 함수를 사용했을 때와 안했을 때의 차이를 볼 수 있습니다.
- Widget이라는 타입이 중복 사용
make함수를 사용하지 않는다면 widget이라는 type이 두 번 등장하고 있습니다. 이는 중복되는 코드를 지양해야하는 SW 엔지니어링 관점에서 좋지 않은 구문입니다.
중복 코드가 입력됐을 때 생기는 컴파일 타임의 증가, 코드량의 증가, 일치하지 않는 코드의 증가 등의 이유로 생길 수 있는 버그의 문제 때문에 make함수가 권장됩니다.
- exception safety
다음은 예시를 통해 살펴보겠습니다.
우선순위를 통해 Widget 객체를 처리하는 함수가 다음과 같을 때
void processWidget(std::shared_ptr<Widget> spw, int priority);
processWidget이 항상 매개변수로 전달되는 shared_ptr의 복사본을 만들면 타당하다할 수 있습니다.
여기서 우선순위를 계산하는 함수를 다음과 같이 지정하고
int computePriority();
다음과 같이 make함수를 사용하지 않고 processWidget을 실행할 경우
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
위 함수는 세가지 동작이 완료된 후에 동작합니다.
새로운 Widget 객체 생성 ( new를 통해 )
Widget 객체를 인자로 전달해서 std::shared_ptr 생성
compute_priority 함수 실행
위 세가지 동작은 컴파일러마다 다른 순서로 진행될 수 있습니다.
1-2-3의 경우에 3에서 예외가 발생할 경우 std::shared_ptr가 자동으로 메모리 해제를 하겠지만
1-3-2의 경우에 3에서 예외가 발생할 경우 2에서 std::shared_ptr에 의해서 메모리가 관리될 수 없으므로 memory leak이 발생합니다.
하지만, make함수를 사용할 경우 1과 2가 동시에 진행이 되어 컴파일러에 따라 다른 결과를 반환하지 않게됩니다.
다만, make함수는 다음과 같은 경우에 사용하지 말아야합니다.
- 삭제자를 지정하려고할 때
- std::initializer_list를 매개변수로 갖는 생성자가 오버로드 되어있는 클래스