티스토리 뷰

스마트 포인터와 일반 포인터

동적 할당 객체는 delete 키워드를 통해 매번 할당 해제를 해야한다. 하지만 C++11부터는 스마트 포인터를 통해 메모리 해제를 자동으로 할 수 있게 된다. 하지만, 모든 포인터를 스마트 포인터로 사용하는 것이 아니기에 일반 포인터가 사용되는 시점과 포인터의 소유권의 위치를 명확히 알아야 할당 해제에 어려움이 없을 것이다.

 

 

메모리 소유권 (Memory Ownership)

"메모리 소유권"이라는 용어는 일반적으로 프로그래밍에서 메모리 리소스의 소유 및 관리에 관련된 개념을 나타냅니다. 이는 특히 메모리 할당과 해제에 관련된 것이다.

 

int *a, *b;
a = new int(5);
b = a;
cout << *b << endl;

//delete a; // a의 소유권 해제
//delete b; // b에게 소유권 이전

 

위 코드를 확인하면 a, b 포인터가 선언되고 a에는 동적 할당을, b에는 a가 가지고 있는 주소를 할당해주었다.

 

그렇다면, 위 할당 메모리를 해제하는 delete문은 a변수로 해야할까? b변수로 해야할까?

정답은 둘다 가능하다이다. 이유는 위 코드의 동적할당된 객체의 소유권을 명확히 가지고 있는 변수는 오로지 코드의 작성자만이 알기 때문이다.

 

즉, 작성자가 a가 소유권이 있는 메인 변수라면 a를 통해 할당 해제를 하는 것이고, b에게 소유권이 이전되고 b를 소유권이 있는 변수로 생각한다면 b를 통해 할당 해제를 하는 것이다.

 

딱봐도 여느 명확한 정답이 있는 프로그래밍이 아닌, 말장난 같고 애매모한 경계에 있기 때문에 이는 개념이라고 불린다.

하지만 이러한 소유권은 스마트 포인터라는 포인터를 사용하여 체계적인 멤리 관리를 할 수 있도록 해준다.

 

 


스마트 포인터 (Smart Pointer)

스마트 포인터(Smart Pointer)는 메모리 관리를 자동화하고 메모리 누수(Memory Leak)를 방지하기 위한 포인터 클래스의 일종이다.

 

스마트 포인터는 일반적으로 특정 범위 안에서 객체의 수명을 추적하고, 객체가 더 이상 필요하지 않을 때 자동으로 메모리를 해제한다. (스마트 포인터의 소멸자는 사용하고 있던 포인터가 가리키는 메모리를 해제하는 코드가 들어있다.) 이로써 개발자는 명시적으로 메모리를 할당하고 해제하는 번거로움을 줄인다.

 

 

가장 일반적으로 사용되는 스마트 포인터에는 다음과 같은 종류가 있습니다.

 

 

unique_ptr

메모리에 할당된 값을 참조하는 포인터는 다수가 선언될 수 있다. 이는 각 포인터가 동일한 메모리 주소를 참조하기 때문에 그중 하나의 포인터라도 할당 해제를 해버리면 나머지 모든 포인터가 참조하고 있던 메모리 주소가 할당해제되어 추후 다른 데이터에게 덮어 씌워질 수 있는 문제가 발생한다.

 

void function(){
    int *a, *b;
    
    a = new int(5);
    b = a;
    
    delete a;
    delete b;
}

 

 

위 코드는 동일한 값에 대해 두 번 메모리 할당 해제를 진행하는 코드이다. a와 b 모두 동일한 값을 참조하지만 서로 다른 포인터 객체로써 a가 값의 할당을 해제해버리면 b는 아무것도 모른체 작동이 되어버린다.

 

이러한 소유권 중복 문제를 해결하기 위해 생성된 것이 unique_ptr이다. 혹시 데이터베이스를 공부해본 사람이라면 기본키(=PK, Primary Key)를 들어보았을 것이다. 기본 키는 수천만개의 데이터가 존재하더라도 유일하게 만들며 복사나 다른 어떤 형태로든 하나 이상 중첩된 상태로 존재하지 않도록 만든다.

 

이와 동일하게  unique_ptr은 메모리에 할당된 값을 하나의 포인터만 가질 수 있게 된다. 하여 유일하다는 성질을 위해 복사 생성자나 복사 대입 연산자가 없기에 다른 포인터가 복사해갈 수 없고 다음과 같은 특성을 지닌다.

 

Unique_ptr 특성
  1. 생성자를 통해 값을 초기화한다.
  2. 다른 곳에 대입할 수 없다.
  3. 새로운 주솟값을 대입할 수 없다.
unique_ptr<int> a(new int(5)); // 생성자 할당

int *b = a;            // 에러! unique_ptr은 다른 곳에 대입할 수 없다.
unique_ptr<int> c = a; // 에러! 좌변이 unique_ptr이라 할지라도 대입할 수 없다.
a = new int(7);        // 에러! unique-ptr에 새로운 주솟값을 대입할 수는 없다.

 

 

unique_ptr 선언

 

앞서 unique_ptr의 특성에서 템플릿을 통해 포인터의 자료형을 선언하는 것을 알 수 있다. 주의할 점은 unique_ptr 생성자는 explicit 키워드가 설정되어 있기에 변환 생성자로 선언이 불가능하다.

unique_ptr<int> ptr1(new int(5));  // 가능
unique_ptr<int> ptr2 = new int(5); // 불가능

 

 

unique_ptr 출력

 

unique_ptr은 초기화 이후 *연산자가 오버로딩되어 있기에 일반 포인터와 같이 사용이 가능하다. 구조체나 클래스 타입을 자료형으로 사용할 경우 애로우(->) 연산자도 사용이 가능하다.

unique_ptr<int> a(new int(5)); // 생성자 할당
cout << *a << endl;

 

 

unique_ptr 소유권 이전

unique_ptr의 소유권은 불변하는 것은 아니다. 다시 한번 unique_ptr의 특징을 말하면 "유일한 값"이 되는 것이지 "전달할 수 없다."는 전제가 아니다. 즉, 현재 가지고 있는 값이 유일한 값이지만 이 값을 다른 unique_ptr에게 "유일한 값"이라는 조건만 충족한다면 전달할 수 있는 것이다.

 

이를 위해 C++는 unique_ptr 클래스 내부에 release함수를 만들어 두 개의 unique_ptr이 서로 소유권을 이전할 수 있도록 해준다.

 

unique_ptr<int> a(new int(5));
unique_ptr<int> b(a.release()); // a가 가지고 있던 소유권을b에게 이전

 

위 과정을 통해 하나 알 수 있는 사실이 있다. unique_ptr은 복사 생성/대입은 불가능 하지만 이동 생성/대입은 가능하다.

이동 생성/대입 연산이 가능한 이유는 복사와 달리 이동은 우변의 객체가 임시 객체이기에 반드시 사라질 것이 약속되어있기 때문이다.

 

신규 객체 할당

unique_ptr은 새로운 값을 할당할 수도 있다. 이는 내부 함수 중 reset함수를 통해 가능하며, reset 함수로 새로운 값을 할당하면 기존 값은 자동 메모리 할당 해제가 일어난다.

 

unique_ptr<int> a(new int(5));
a.reset(new int(7)); // a가 가지고 있던 소유권을b에게 이전

 

또한, reset을 통해 NULL이나 아무 객체를 할당하지 않는다면 NULL 포인터 상태가 된다.

 

a.reset(NULL);
a.resetO;

 

 


shared_ptr

shared_ptr은 여러 포인터가 한 객체를 가리킬 수 있게 한다. 이는 "소유권이 한 포인터에게 한정되지 않는다"는 shared_ptr의 핵심을 만들어 준다.

 

shared_ptr은 일반 포인터와 다르게 정확히 몇 개의 포인터가 객체를 가리키는지 내부적으로 기록하고, 객체를 사용하던 포인터 중 하나가 소멸하여도 다른 포인터가 해당 객체를 사용하고 있다면 소멸시키지 않고, 객체를 가리키는 모든 포인터가 소멸되면 객체를 소멸시킨다. 하여 일반 포인터와 다르게 메모리 해제를 담당하는 포인터를 선정할 필요가 없다.

 

#include <iostream>
#include <memory>

using namespace std;

class MyClass {
public:
    MyClass(int x) : x(x) {
        cout << "MyClass(int)" << endl;
    }

    ~MyClass() {
        cout << "~MyClass()" << endl;
    }

    int GetX() const {
        return x;
    }

private:
    int x;
};

int main() {
    shared_ptr<MyClass> a(new MyClass(5));
    {
        shared_ptr<MyClass> b = a;
        cout << a->GetX() << endl;
        cout << b->GetX() << endl;
        cout << a.use_count() << endl;
        cout << b.use_count() << endl;
    }

    cout << a->GetX() << endl;
    cout << a.use_count() << endl;

    return 0;
}
MyClass(int)
5
5
2
2
5
1
~MyClass()

 

위 코드에서 MyClass 클래스형 포인터가 선언되고 정수 5를 입력 받는 새로운 동적할당 객체를 만들어 입력한다. 그리고 생성자가 호출될 때, 추가적으로 실행할 실행문들을 선언한 것이다.

 

이때 중요한 것은 스마트 포인터 변수 b가 a와 동일한 MyClass 객체를 참조하게 된다. 이를 통해 a 포인터에 생성된 MyClass 객체를 a와 b 포인터가 모두 참조하고 있다. 하지만, b는 생성자 스코프를 벗어나면 소멸하는 지역변수이기 때문에 a 생성자 구문을 벗어나서 a의 use_count함수를 호출하면 a 포인터 개수만 나온다.

 

📌 use_count함수

현재 shared_ptr에 저장된 객체를 참조하고 있는 포인터의 개수를 반환한다.

 

또한, b 변수가 스코프를 벗어나서 소멸하더라도 a가 계속 MyClass 객체를 참조하고 있기 때문에 shared_ptr 객체는 소멸되지 않는다.

 

reset

shared_ptr도 reset 함수를 사용할 수 있다. reset을 사용하면 기존의 스마트 포인터가 가지고 있던 객체를 대체하게 된다. 

 

#include <iostream>
#include <memory>

using namespace std;

class MyClass {
public:
    MyClass(int x) : x(x) {
        cout << "MyClass(int)" << endl;
    }

    ~MyClass() {
        cout << "~MyClass()" << endl;
    }

    int GetX() const {
        return x;
    }

private:
    int x;
};

int main() {
    shared_ptr<MyClass> a(new MyClass(5));
    cout << "===== 1 =====" << endl;
    {
        shared_ptr<MyClass> b = a;
        cout << b.use_count() << endl;
        a.reset();
        cout << b.use_count() << endl;
        cout << "===== 2 ==" << endl;
    }
    cout << a.use_count() << endl;
    cout << "===== 3 =====" << endl;

    return 0;
}
MyClass(int)
===== 1 =====
2
1
===== 2 ==
~MyClass()
0
===== 3 =====

 

위 main 함수에서 a 포인터가 새로운 객체를 할당하며 b에게 값을 전달한다. 즉, a와 b 모두 a의 shared_ptr의 객체를 참조하기 때문에 참조 개수가 2개가 된다.

 

하지만 생성자 스코프에서 a.reset 함수를 통해 a의 shared_ptr 객체에 대한 연결을 끊었다. 즉, a의 shared_ptr 객체는 b 포인터와만 연결된 상태가 되었다. 하여, 생성자 스코프를 나오고 b 변수가 소멸되었을 때, a의 use_count가 0으로 출력된다.

 

 


weak_ptr

weak_ptr은 shared_ptr이 가리키는 객체는 똑같이 사용하지만 해당 객체를 소유하지 않는 특징이 있다. 이게 무슨 말이냐 하면, shared_ptr이 소유한 객체의 소멸에 관여하지 않고 참조만 할 뿐 참조 개수도 변하지 않는다.

 

weak_ptr에게 값을 전달한 shared_ptr 객체의 참조 크기는 동일하게 유지된다. 그렇기에 객체를 가리키고 싶지만 소유권은 필요 없는 상황에 사용된다. 또한 weak_ptr은 shared_ptr을 받아야만 사용할 수 있는 객체이다. 하여 shared_ptr가 반드시 선언되고 shared_ptr 객체를 할당 받아야만 사용이 가능하다.

 

#include <iostream>
#include <memory>

using namespace std;

int main() {
    shared_ptr<int> a(new int(5));
    weak_ptr<int> b = a;
    cout << a.use_count() << endl; // 1 출력
    cout << b.use_count() << endl; // 1 출력

    return 0;
}
1
1

 

 

 

대상 소멸 여부 확인

weak_ptr은 shared_ptr이 가진 객체가 소멸되었을 경우가 있을 수 있다. 그렇기에 weak_ptr은 현재 가리키는 객체의 소멸 여부를 확인해야하고 이 작업은 expired라는 함수로 진행된다.

 

#include <iostream>
#include <memory>

using namespace std;

int main() {
    shared_ptr<int> a(new int(5));
    weak_ptr<int> b = a;
    cout << b.use_count() << endl; // 1 출력
    a.reset(); // 객체가소멸됨
    cout << b.use_count() << endl; // 0 출력
    cout << b.expired() << endl; // 1(true) 출력

    return 0;
}
1
0
1

 

 

Lock

weak_ptr은 현재 가리키는 객체의 소멸 여부를 모르기에 바로 역참조를 할 수 없다. 하지만 lock 함수를 사용한다면 객체에 대한 역참조를 진행할 수 있으며, 객체가 존재할 때는 shared_ptr을, 존재하지 않을 때는 NULL을 가리키는 shared_ptr을 리턴한다.

 

#include <iostream>
#include <memory>

using namespace std;

int main() {
    shared_ptr<int> a(new int(5));
    weak_ptr<int> b = a;
    if (!b.expired()) {
    	cout << *b.lock() << endl;
    }
    else {
    	cout << "객체가 이미 소멸되었다!" << endl;
    }
    
    return 0;
}
5
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함