본문 바로가기
연구소👨‍💻/CS연구소

[C++] 포인터와 스마트 포인터

by 신그자체김상범 2024. 5. 11.

지난번 참조와 포인터에서 이어진다. 최근 백준 문제를 풀면서 메모리 문제가 있었다. 한 객체를 두 개의 포인터로 지정해놓았는데. 다른 한 포인터로 delete를 해버리니 다른 포인터에 접근을 하는 것 자체만으로도 에러가 발생했다.

나중에 알고보니 이게 C++에서 흔히 일어나는 댕글링 포인터 문제라는걸 알았다.  
Dangling pointer는 메모리가 해제된 뒤에 그 메모리를 가리키는 포인터를 의미한다고한다.
지난번에 말한 것 처럼 C++은 다른 언어들에 비해 메모리를 수동으로 등록하고 해제하는게 가능한데 그러다보니 해제된 메모리에 접근하는 일들이 생기고 그것때문에 접근 위반 오류(segmentation fault)가 나는 것이다.

수동 메모리 관리는 빠르지만 이런 문제들이 많다 이외에도 메모리 누수같은 문제들도 존재한다. 그러다보니 C++ 에서도 이런 메모리 문제를 해결하려고 여러가지 스마트 포인터들이 존재한다.

스마트 포인터

Unique pointer

유니크 포인터는 대표적인 C++의 스마트 포인터중 하나다.

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> p1(new int(5)); // Initialize with pointer to a new integer
    std::unique_ptr<int> p2 = std::make_unique<int>(10); // Preferred method (C++14 onwards)

    std::cout << *p1 << ", " << *p2 << std::endl;
    return 0;
}

 

unique_ptr로 쓰이는 이 스마트 포인터는 이름대로 생각하면

  1. 한 스코프 안에서 한번만 생성되는 포인터
  2. 한 객체에 대해서 단 한개의 포인터만 허용하는 스마트 포인터
  3. 마치 참조처럼 한 번 초기화되면 다시 초기화 될 수 없는 포인터

3가지 경우가 떠올랐다. 일단 이중에서는 두번째 해석이 맞다. 한 객체에 대해서 두개 이상의 포인터를 할당할 수 없게 해놓은 것이다. 이렇게 해서 unique_ptr은 한 객체의 소유권을 가지게 된다. 그렇기 때문에 unique_ptr이 객체의 소유권을 이전하지 않고 리셋되면 객체는 파괴된다. 

 

unique_ptr이 가능한 것

  1. 소유권 생성
  2. 소유권 복사, 공유 금지
  3. 소유권 이전
  4. 자동 메모리관리

그럼 과연 unique_ptr로 선언한 객체를 new로 다시 주소지정을 하면 에러가 뜰까가 궁금해졌다. 그래서 이걸 G쌤한테 물어보니 이렇게 답했다. 


unique_ptr 이 관리하는 객체에 대해
new 연산자로 다른 객체를 할당하려고 하면, 직접적인 에러는 발생하지 않습니다. 그러나, 이는 좋은 프로그래밍 관행이 아닙니다. 왜냐하면
unique_ptr 에 의해 이미 관리되고 있는 객체에 대해
new 를 사용하여 다시 할당하는 것은 그 포인터에 대한 직접적인 접근이 없기 때문에, 기존 메모리를 누수시킬 수 있습니다.

 

new의 경우에 사용은 가능하지만 그렇게하면 unique_ptr을 쓰는 것 자체가 의미가 없게 된다고한다 그러니 한 파일내에서 new와 unique_ptr을 같이 쓰는 것은 지양해야 할 것 같다.

 

dangling pointer을 unique_ptr로 방지할 수 있는가

결국 그럼 앞서 말했던 dangling pointer을 방지할 수 있냐고 하면 당연히 그럴 수 있을 것이다. 어차피 한 포인터만 객체를 관리할 수 있게되니 소멸된 메모리에 대해서 다른 포인터한테도 이를 지시해줘야할 필요가 없다.

 

 

shared_ptr

unique_ptr을 한 번 배우고 나면 당연히 이제 shared_ptr의 존재또한 예감할 수 밖에 없다.

 

// 트라이의 클래스
class Trie
{
public:

    int num;
    char val;
    Trie* next[26];

    Trie(char val) : val(val), num(0) {
        for (int idx = 0; idx < 26; idx++)
        {
            next[idx] = nullptr;
        }
    }; 
    ...

	// 트라이의 메소드
     Trie* fillUpTable(char c, int index, vector<int> &table)
        {   
            if (c == '#')
            {
                table[index] = num;
                return nullptr;
            }
            if (c && c != ' ')
            {   
                if (index != -1)
                { 
                    table[index] = num;
                }
                if (next[c - 'a'] != nullptr)
                {
                    return next[c - 'a'];
                }
    ...

지난번에 푼 27652번 문제에서 내가 만들었던 트라이를 보면 트라이의 특성중 하나에 트라이 포인터 배열들이 존재하고 메소드중 하나인 fillUpTable에서는 리턴값으로 트라이의 포인터가 존재한다.

트리처럼 뻗어나가는 트라이의 특성을 이용해서 계속 리턴받은 트라이를 활용한다는 것인데 이럴려면 결국 어딘가에서는 저 트라이의 값으로 초기화되는 다른 변수가 있어야하고 그럼 한개의 객체를 두 개 이상의 포인터가 지목해야한다. 이런 구조를 만드는데는 unique_ptr로는 충분하지 않으니 앞에서 말했던 소유권을 공유하고 복사할 수 있는 포인터가 필요할 것이다.

 

말그대로 shared_ptr은 객체의 주소에대해 포인터들이 복수의 소유권을 가지게하면서도 자동으로 메모리를 관리해주는 기능이다. 그렇기때문에 객체는 자신을 가리키는 포인터가 하나도 없게 될 때까지 메모리에 남아있게 된다. roadmap.sh 에 나와있는 말에 따르면

 When using a shared_ptr, the reference counter is automatically incremented every time a new pointer is created, and decremented when each pointer goes out of scope. Once the reference counter reaches zero, the system will clean up the memory.

shared_ptr을 쓰면 객체의 참조 카운터가 올라가고 내려가는 식으로 만들어져있다고 한다. 그리고 참조 카운터가 0이되면 메모리를 삭제한다고한다.

weak_ptr

weak_ptr은 shared_ptr의 참조 카운팅의 기능때문에 생기는 오류들을 해결하기 위해 만들어졌다.

 

참조 카운팅이라는건 즉 어떤 포인터가 한 객체를 가리키고 -> 그 객체의 참조 카운팅이 늘어나고, 가리키던 포인터가 사라지고 -> 그 객체의 포인터가 줄어들고 하는 방식으로 일어난다. 여기까지만 보면 어떻게 문제가 생기나 싶지만 다음과 같은 예시가 있다.

 

#include <memory>
#include <iostream>

class B; // 선언 필요

class A {
public:
    std::shared_ptr<B> b;
    ~A() { std::cout << "A destroyed\n"; }
};

class B {
public:
    std::shared_ptr<A> a;
    ~B() { std::cout << "B destroyed\n"; }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();

    a->b = b; // A는 B를 가리킴
    b->a = a; // B는 A를 가리킴

    return 0;
}

 

다음과같은 코드를 보면 객체를 가리키는 포인터가 있는데 그 원본객체의 속성에는 다른객체를 가리키는 속성이 또하나 존재한다. 그리고 두 객체 A, B는 서로를 가리키고 있다. 이럴때 프로그램이 종료될 경우 당연히 외부 포인터 a와 b가 사라지게 되는데 그 객체 A, B는 자신들의 속성에서 서로를 가리키고 있기 때문에 참조는 0이되지 않고 두 객체는 사라지지 않는다. 이런 순환참조 문제가 발생하면 기존의 shared_ptr의 목적을 잃어버리는 것이기 때문에 대책이 필요했다

weak_ptr은 이런점에서 기존 shared_ptr을 가리키면서도 그 참조 카운트를 증가시키거나 감소시키지 않아 이렇게

객체안에서 다른 객체를 참조하고 껍데기 객체를 가리키는 포인터가 사라져서 외부 객체가 사라지더라도 

객체 내부에서 서로를 참조하는 것은 참조로 치지않아 문제없이 객체를 삭제할 수 있게 해준다.

 

그럼 왜 수동 메모리 관리가 남아있을까?

이렇게 편리한 새 메모리 관리 툴들을 보면 이제 new와 delete가 필요한가 싶어졌다. 
그래서 굳이 C++이 수동 메모리관리를 남겨둔 이유를 알고 싶어서 G쌤에게 물어봤다



네, 일반적으로 new와 delete를 직접 사용하는 것이 스마트 포인터를 사용하는 것보다 메모리 할당과 해제에 있어서 오버헤드가 적을 수 있습니다. 스마트 포인터는 추가적인 기능성을 제공하기 위해 내부적으로 더 많은 로직을 수행하기 때문입니다. 여기에는 소유권 관리, 참조 카운팅(특히 shared_ptr의 경우), 그리고 다른 안전성과 편의성을 위한 메커니즘이 포함됩니다.
new와 delete의 성능 이점
직접적인 접근: new와 delete는 메모리를 직접적으로 할당하고 해제합니다. 이는 중간 관리 계층이 없다는 의미로, 실행 속도가 빠를 수 있습니다.간단한 사용: 복잡한 로직이나 추가적인 상태 추적이 없기 때문에, 실행 시간이 빠르고 예측 가능합니다.

스마트 포인터의 오버헤드
unique_ptr: unique_ptr은 new와 delete에 비해 상대적으로 오버헤드가 적습니다. unique_ptr은 객체의 소유권만을 단일화하고, 참조 카운팅 같은 추가적인 상태 관리를 하지 않습니다. 그러나 포인터의 복사나 이동이 발생할 때 소유권을 확인하고 조정하는 추가적인 작업이 필요합니다.shared_ptr: shared_ptr은 참조 카운팅을 관리하기 위해 내부적으로 더 많은 메모리와 처리 시간을 필요로 합니다. 이는 shared_ptr 간의 복사나 할당이 발생할 때마다 참조 카운트를 업데이트하는 추가적인 계산을 수반합니다.

결국 그 원인은 메모리 관리에 추가로 드는 비용 때문이라고 정리할 수 있다. 오버헤드란 말 자체는 행동을 처리하는데 느는 간접적인 시간, 메모리를 의미하고 자동 메모리관리는 수동 메모리 관리에 비해서 더 많은 데이터를 들일 수 밖에 없으니. 어쨌든 나는 앞으로 이런 메모리, 시간의 차이를 직접적으로 이해하고 활용할 수 있는 방향으로 단순 new, delete에만 의지하지 않는 방향으로 나아가야겠다.