C++에서 자원은 매우 중요합니다.
컴퓨터에서 자원( Resource )라 하면 여러 가지를 꼽을 수 있지만, 예를 들어보자면
할당한 메모리도 자원이고 Open 한 파일 역시 하나의 자원이라 할 수 있습니다.
당연하게도, 자원의 양은 프로그램마다 한정적이기 때문에
관리를 잘 해주어야 합니다. 이 말은 즉, 사용이 끝난 자원은 반드시 반환을 해서
다른 작업 때 사용할 수 있도록 해야 합니다.
C++ 이후에 나온 많은 언어들은 대부분
가비지 컬렉터( Garbage Collector - GC )라 불리는
자원 청소기가 내장되어 있습니다. 이름에서 직관적으로 알 수 있듯 프로그램 상에서
더 이상 쓰이지 않는 자원을 자동으로 해제해 주는 역할을 합니다.
하지만 C++의 경우는 다릅니다.
한 번 획득한 자원은 직접 해제하지 않는 이상 프로그램이 종료되기 전까지 영원히 남아있게 됩니다. ( 프로그램 종료 시 운영체제가 해제 )
간단한 예시로,
#include <iostream>
class A
{
int* data;
public:
A()
{
data = new int[100];
std::cout << "자원을 획득함!" <<std::endl;
}
~A()
{
std::cout << "소멸자 호출!" << std::endl;
delete[] data;
}
};
void do_something() { A* pa = new A(); }
int main()
{
do_something();
}
이를 성공적으로 컴파일 한다면
자원을 획득함!
으로 생성자만 호출 되고, 소멸자는 호출되지 않은 점을 확인할 수 있습니다.
그 이유는 까먹고
delete pa;
를 하지 않았기 때문이죠.
( delete는 메모리를 해제하기 직전 가리키는 객체의 소멸자를 호출합니다. )
만약 delete를 do_something() 안에서 호출 하지 않는다면,
생성된 객체를 가리키던 pa는 메모리에서 사라지게 됩니다.
따라서, Heap 어딘가에 클래스 A의 객체가 남아있지만,
그 주소값을 가지고 있는 포인터는 메모리 상에 존재하지 않게 됩니다.
프로그램의 크기가 커질 수록,
자원을 해제하는 위치가 애매한 경우가 많아져서 숙련된 프로그래머라도
자원 할당 해제를 놓치기 쉽습니다. 다음과 같은 상황을 생각해 봅시다.
#include <iostream>
class A
{
int* data;
public:
A()
{
data = new int[100];
std::cout << "자원을 획득함!" <<std::endl;
}
~A()
{
std::cout << "소멸자 호출!" << std::endl;
delete[] data;
}
};
void thrower()
{
throw 1; // 예외를 발생 시킴
}
void do_something() { A* pa = new A(); }
int main()
{
try
{
do_something();
}
catch(int i
{
std::cout << "예외 발생!" << std::endl;
}
}
성공적으로 컴파일 했다면,
자원을 획득함!
예외 발생!
과 같이 나옵니다.
thrower()로 발생된 예외로 인해, 밑에 있는 delete pa가 실행되지 않고 넘어 갔습니다.
물론 예외 처리는 정상이지만, 이로 인한 메모리 누수는 피할 수가 없게 됩니다.
그렇다면 이 상황을 어떻게 해결해야 할까요??
RAII는 자원의 획득은 초기화 라는 C++ 의 디자인 패턴입니다.
이는 자원 관리를 스택에 할당한 객체를 통해 수행하는 것입니다.
예외가 발생해서 함수를 빠져나가더라도, 함수의 스택에 정의되어 있는
모든 객체들은 빠짐없이 소멸자가 호출됩니다. 이를 Stack Unwinding이라 합니다.
물론 예외가 발생하지 않더라도 함수가 종료되면 소멸자들이 호출 됩니다.
그렇다면 생각을 바꿔 이 소멸자들 안에 다 사용한 자원을 해제하는 루틴을 넣는다면 어떨까요?
위 코드의 pa
같은 경우 객체가 아니기 때문에 소멸자가 호출되지 않습니다.
그렇다면 그 대신, pa
를 일반적인 포인터가 아닌, 포인터 객체
로 만들어서 소멸 시,
자신이 가리키고 있는 데이터를 같이 delete
하게 하면 됩니다.
즉, 자원(이 경우 메모리)관리를 스택의 객체를 통해 수행하게 되는 것입니다.
이렇게 똑독하게 작동하는 포인터 객체를
스마트 포인터(Smart Pointer)라고 합니다.
C++11 이상부터는 기존에 문제가 있던, auto_ptr
대신
unique_ptr
과 shared_ptr
을 제공합니다.
사용한 메모리를 해제하지 않아서 생기는 메모리 누수 외에도 다른 문제가 있습니다.
Data* data = new Data();
Data* data2 = data;
// data : 사용이 끝났으니 소멸 해야지
delete data;
// data2 : 나도 사용 다 했으니 소멸해야지
delete data2;
이 경우 data
와 data2
가 동시에 한 객체를 가리키고 있고 delete data
를 통해
그 객체를 소멸 시켰습니다.
하지만, data2
가 이미 소멸된 객체를 다시 소멸시키려 합니다.
보통 이런 경우, 메모리 오류가 나며 프로그램이 죽게 됩니다.
이렇게 이미 소멸된 객체를 다시 소멸시키며 발생하는 버그를 double free 버그라고 부릅니다.
위와 같은 문제는, 만들어진 객체의 소유권이 명확하지 않아서 입니다.
만약, 우리가 어떤 포인터에 객체의 유일한 소유권을 부여해서, 이 포인터 외에는
객체를 소멸 시킬 수 없다! 라고 한다면 double free 버그는 없을 것입니다.
C++에서는 이렇게, 특정 객체에 유일한 소유권을 부여하는 포인터 객체를 unique_ptr
이라고 합니다.
#include <iostream>
#include <memory>
class A
{
int *data;
public:
A()
{
std::cout << "자원을 획득함!" << std::endl;
data = new int[100];
}
void some()
{
std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl;
}
~A()
{
std::cout << "자원을 해제함!" << std::endl;
delete[] data;
}
};
void do_something()
{
std::unique_ptr<A> pa(new A());
pa->some();
}
int main()
{
do_something();
}
위 코드에서 pa
는 스택에 정의된 객체이기 때문에,
do_something()
함수가 종료될 때,
자동으로 소멸자가 호출됩니다.
그리고 이 unique_ptr
은 소멸자 안에서 자신이 가리키고 있는 자원을
해제해 주기 때문에, 자원이 잘 해제될 수 있었습니다.
만약에 unique_ptr
을 복사하려고 한다면 어떨까요?
void do_something()
{
std::unique_ptr<A> pa(new A());
// pb 도 객체를 가리키게 할 수 있을까?
std::unique_ptr<A> pb = pa;
}
만약 위 코드를 컴파일하려 했다면,
삭제된 함수를 사용하려 했다는 오류가 나오게 됩니다.
사용을 원치 않는 함수를 삭제시키는 방법은 C++11
에 추가된 기능입니다.
#include <iostream>
class A
{
public:
A(int a){};
A(const A& a) = delete;
};
int main()
{
A a(3); // 가능
A b(a); // 불가능 (복사 생성자는 삭제됨)
}
이를 컴파일 한다면, 복사 생성자를 호출하는 부분에서 오류가 발생합니다. 왜냐하면,
A(const A& a) = delete;
와 같이 복사 생성자를 명시적으로 삭제하였기 때문이죠. unique_ptr
도 마찬가지로 unique_ptr
의 복사 생성자가 명시적으로 삭제되었습니다.
그 이유는 unique_ptr
는 어떠한 객체를 유일하게 소유해야 하기 때문이지요.
만일 unique_ptr
를 복사 생성할 수 있게 된다면, 특정 객체를 여러 개의 unique_ptr
들이 소유하게 되는 문제가 발생합니다.
따라서, 각각의 unique_ptr
들이 소멸될 때 전부 객체를 delete 하려 해서 앞서 말한 double free
버그가 발생하게 됩니다.
unique_ptr
의 소유권 이전은 가능합니다.
std::unique_ptr<A> pb = std::move(pa);
위와 같이 pa
를 pb
에 강제로 이동시켜버립니다.
이제 pb
가 new A
로 생성된 객체의 소유권을 갖고,
pa
는 아무것도 가리키고 있지 않게 됩니다.
따라서 소유권을 이동 시킨 후의 기존 unique_ptr
에 접근하지 않도록 조심해야 합니다.
소유권이 이전된
unique_ptr
을
댕글링 포인터( Dangling Pointer ) 라고 하며,
이를 재참조 시, 런타임 오류가 발생하도록 합니다.
따라서 소유권 이전은, 댕글링 포인터를
절대 다시 참조하지 않겠다는 확신 하에 이동해야 합니다.
어떤 unique_ptr
을 함수 인자로 전달하고 싶다면 어떨까요?
unique_ptr
은 복사 생성자가 없기 때문에 함수에 레퍼런스 전달을 하면 될까요?
#include <iostream>
#include <memory>
class A {
int* data;
public:
A() {
std::cout << "자원을 획득함!" << std::endl;
data = new int[100];
}
void some() { std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; }
void do_sth(int a) {
std::cout << "무언가를 한다!" << std::endl;
data[0] = a;
}
~A() {
std::cout << "자원을 해제함!" << std::endl;
delete[] data;
}
};
// 올바르지 않은 전달 방식
void do_something(std::unique_ptr<A>& ptr) { ptr->do_sth(3); }
int main() {
std::unique_ptr<A> pa(new A());
do_something(pa);
}
일단, 함수 내부로 unique_ptr
이 잘 전달 되었음을 알 수 있습니다.
하지만 위와 같이 unique_ptr
을 전달하는 것이 문맥 상 맞는 코드 일까요?
만일 위와 같이 레퍼런스로 unique_ptr
을 전달했다면,
do_something
함수 내부에서는
ptr
이 유일한 소유권을 의미하지 않습니다.
물론, ptr
은 레퍼런스이기 때문에, do_something
함수가 종료되며 pa
가 가리키고 있는 객체를 파괴하지는 않습니다.
하지만 pa
가 유일하게 소유하고 있던 객체는 이제 적어도 do_something
내부에서는 ptr
을 통해서도 소유할 수가 있게 되는 것입니다.
따라서 unique_ptr
을 레퍼런스로 사용하는 것은 unique_ptr
을 소유권이라는 중요한 의미를 망각한 채 단순히 포인터의
단순한 Wrapper
로 사용하는 것에 불과합니다.
고로, 함수에 올바르게 unique_ptr
을 전달하기 위해선,
원래의 포인터 주소값을 전달해 주면 됩니다.
void do_something(A* ptr) { ptr->do_sth(3); }
int main() {
std::unique_ptr<A> pa(new A());
do_something(pa.get());
}
unique_ptr
의 get 함수를 호출하면, 실제 객체의 주소값을 리턴합니다.
위 경우 do_something
함수가 일반적인 포인터를 받으며, 소유권이라는 의미는 버린 채, do_something
함수 내부에서 객체에 접근할 수 있는 권한을 주는 것입니다.
정리하자면,
unique_ptr
은 어떤 객체의 유일한 소유권을 나타내는 포인터이며, 소멸될 때, 가리키던 객체도 소멸된다.unique_ptr
이 소유한 객체에 접근하고 싶다면, get을 통해 객체 포인터를 전달한다.unique_ptr
을 move
하면 된다.C++14부터는 unique_ptr
을 간단히 만들 수 있는,
std::make_unique
함수를 제공합니다.
#include <iostream>
#include <memory>
class Foo {
int a, b;
public:
Foo(int a, int b) : a(a), b(b) { std::cout << "생성자 호출!" << std::endl; }
void print() { std::cout << "a : " << a << ", b : " << b << std::endl; }
~Foo() { std::cout << "소멸자 호출!" << std::endl; }
};
int main() {
auto ptr = std::make_unique<Foo>(3, 5);
ptr->print();
}
이를 컴파일 하면
생성자 호출!
a : 3, b : 5
소멸자 호출!
와 같이 잘 작동합니다.
#include <iostream>
#include <memory>
#include <vector>
class A {
int *data;
public:
A(int i) {
std::cout << "자원을 획득함!" << std::endl;
data = new int[100];
data[0] = i;
}
void some() { std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; }
~A() {
std::cout << "자원을 해제함!" << std::endl;
delete[] data;
}
};
int main() {
std::vector<std::unique_ptr<A>> vec;
std::unique_ptr<A> pa(new A(1));
vec.push_back(pa); // ??
}
위 코드를 컴파일 하면 무시무시한 컴파일 오류가 발생합니다.
오류의 이유는 삭제된 unique_ptr
의 복사 생성자에 접근하였기 때문입니다.
기본적으로 vector
의 push_back 함수는 전달된 인자를 복사해서 집어 넣기 때문에
위와 같은 문제가 발생하게 되는 것입니다.
이의 방지를 위해 명시적으로 pa
를 vector
안으로 이동 시켜주어야 합니다.
즉, push_back의 우측값 레퍼런스를 받는 버전이 오버로딩 될 수 있도록 말이죠.
int main() {
std::vector<std::unique_ptr<A>> vec;
std::unique_ptr<A> pa(new A(1));
vec.push_back(std::move(pa)); // 잘 실행됨
}
하지만 재미있게도, emplace_back
함수를 이용하면, vector 안에
unique_ptr
을 직접 생성하며 집어넣을 수 있습니다.
즉, 불필요한 이동 과정을 생략할 수 있다는 것입니다.
emplace_back
함수는 전달된 인자를 완벽한 전달(Perfect Forwarding)을 통해,
직접 unique_ptr<A>
의 생성자에 전달해서, vector
맨 뒤에 unique_ptr<A>
객체를 생성해버리게 됩니다.
따라서, 위에서처럼 불필요한 이동 연산이 필요 없게 됩니다.
참조 : 모두의코드