와와

[ 씹어먹는 C++ ] 스마트 포인터 (unique_ptr/ shared_ptr/ weak_ptr) 본문

개발/C++

[ 씹어먹는 C++ ] 스마트 포인터 (unique_ptr/ shared_ptr/ weak_ptr)

정으주 2024. 9. 22. 21:44

https://modoocode.com/229

 

씹어먹는 C ++ - <13 - 1. 객체의 유일한 소유권 - unique_ptr>

모두의 코드 씹어먹는 C ++ - <13 - 1. 객체의 유일한 소유권 - unique_ptr> 작성일 : 2018-09-18 이 글은 57754 번 읽혔습니다. 이번 강좌에서는 C++ 의 RAII 패턴unique_ptr안녕하세요 여러분! 지난번 강좌에서 다

modoocode.com

https://modoocode.com/252

 

씹어먹는 C ++ - <13 - 2. 자원을 공유할 때 - shared_ptr 와 weak_ptr>

모두의 코드 씹어먹는 C ++ - <13 - 2. 자원을 공유할 때 - shared_ptr 와 weak_ptr> 작성일 : 2018-12-21 이 글은 48329 번 읽혔습니다. 이번 강좌에서는 shared_ptrenable_shared_from_thisweak_ptr에 대해 다룹니다.안녕하

modoocode.com

 

 

 

자원 관리의 중요성

 

C++에서 한 번 획득한 자원은, 직접 해제해주지 않는 이상 프로그램이 종료되기 전까지 영원히 남아있게 된다.

 

예를 들어, 함수 내에서 생성된 포인터는 실행이 끝나면 사라진다. Heap 어딘가에 클래스 A의 객체가 남아있지만, 그 주소값을 가지고 있는 포인터는 메모리 상에 존재하지 않게 되는 것이다. 그 객체는 영원히 해제되지 못한 채 입에서 자리만 차지하고 있게 된다.

메모리 처리 코드를 써놓더라도 도중에 예외가 발생하여 호출되지 못할 위험도 있다!

 

Resource Acquisition Is Initialization - RAII

C++ 창시자인 비야네 스트로스트룹 이 제안한 "자원의 획득은 초기화다"라는 의미를 가진, 객체의 수명과 자원의 관리를 연결시키는 C++ 자원 관리 패턴이다.

 

  • 자원(메모리, 파일 핸들, 네트워크 연결 등)을 객체의 생성자에서 획득
  • 자원의 해제는 객체의 소멸자에서 자동으로 처리
  • 스마트 포인터 사용: unique_ptr, shared_ptr 등을 통해 동적 할당된 메모리를 자동으로 관리
  • 장점:
    • 자원 누수 방지: 예외 발생 시에도 스택 풀기(stack unwinding)를 통해 자원이 안전하게 해제됨
    • 코드 간소화: 명시적인 자원 해제 코드가 필요 없어짐
    • 안전성 향상

 

객체의 유일한 소유권 - unique_ptr


메모리 관련 문제들은 보통 다음과 같은 경우들이다.

1. 메모리를 사용한 후에 해제하지 않은 경우

2. 이미 해제된 메모리를 다시 참조하는 경우

 

여기서 두번째 경우를 대비하여 특정 객체에 유일한 소유권을 부여하는 포인터 객체를 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(); }

 

 

1. 정의

std::unique_ptr<A> pa(new A());

 

 

위는 아래와 동일한 문장이다. 

A* pa = new A();

 

 

2. 복사 불가능

void do_something() {
  std::unique_ptr<A> pa(new A());

  // pb 도 객체를 가리키게 할 수 있을까?
  std::unique_ptr<A> pb = pa;
}

 

unique_ptr 의 복사 생성자는 명시적으로 삭제되었다. 위와 같은 코드는 오류가 발생함.

 

3. 소유권 이전

void do_something() {
  std::unique_ptr<A> pa(new A());
  std::cout << "pa : ";
  pa->some();

  // pb 에 소유권을 이전.
  std::unique_ptr<A> pb = std::move(pa);
  std::cout << "pb : ";
  pb->some();
}

 

복사 생성자는 삭제되었지만, 이동 생성자는 사용 가능하다. 따라서 위의 코드처럼 소유권을 강제로 이전시킬 수 있음.

단, 소유권을 이동 시킨 후에 기존의 unique_ptr에 접근하지 않도록 조심해야 한다. 

 

4. 함수 인자로 전달

// 올바르지 않은 전달 방식
void do_something(std::unique_ptr<A>& ptr) { ptr->do_sth(3); }

 

위와 같은 경우는 실행은 되지만 unique_ptr의 원칙에 위배된다.

unique_ptr은 유일한 소유권을 의미하는데 ptr로도 접근할 수 있게 되버리기 때문이다.

 

void do_something(A* ptr) { ptr->do_sth(3); }

int main() {
  std::unique_ptr<A> pa(new A());
  do_something(pa.get());
}

 

함수 인자로 전달할 때는 get 함수를 이용하여 그냥 원래의 포인터 주소값을 전달하면 된다.

 

 

5. 쉽게 생성하기

 

C++ 14 부터 unique_ptr 을 간단히 만들 수 있는 std::make_unique 함수를 제공한다.

make_unique 함수는 아예 템플릿 인자로 전달된 클래스의 생성자에 인자들에 직접 완벽한 전달을 수행

 

std::unique_ptr<Foo> ptr(new Foo(3, 5));	// 기존
auto ptr = std::make_unique<Foo>(3, 5);		// 간단

 

 

 

unique_ptr 를 원소로 가지는 컨테이너

 

  std::vector<std::unique_ptr<A>> vec;
  std::unique_ptr<A> pa(new A(1));

  vec.push_back(pa);  // ??

 

unique_ptr 를 vector의 원소로 추가하려 할 때, 삭제된 unique_ptr 의 복사 생성자에 접근하기 때문에 에러가 난다.

 

push_back 의 우측값 레퍼런스를 받는 버전이 오버로딩 될 수 있도록  명시적으로 pa를 vector 안으로 이동 시켜주어야만 한다.

 

int main() {
  std::vector<std::unique_ptr<A>> vec;
  std::unique_ptr<A> pa(new A(1));

  vec.push_back(std::move(pa));  // 잘 실행됨
}

 

 

생각해보기

더보기

문제 1

unique_ptr 을 어떤식으로 구현할 수 있을지 생각해보세요 (난이도 : 중상)

 

- 복사 delete

- get 함수 구현

- 포인터 변수 소유

- 소멸자에서 메모리 해제

 

template <typename T>
class UniquePtr {
private:
    T* ptr;

public:
    // 생성자
    explicit UniquePtr(T* p = nullptr) : ptr(p) {}

    // 소멸자
    ~UniquePtr() {
        delete ptr;
    }

    // 복사 생성자와 복사 대입 연산자를 삭제
    UniquePtr(const UniquePtr&) = delete;
    UniquePtr& operator=(const UniquePtr&) = delete;

    // 이동 생성자
    UniquePtr(UniquePtr&& other) noexcept : ptr(other.ptr) {
        other.ptr = nullptr;
    }

    // 이동 대입 연산자
    UniquePtr& operator=(UniquePtr&& other) noexcept {
        if (this != &other) {
            delete ptr;
            ptr = other.ptr;
            other.ptr = nullptr;
        }
        return *this;
    }

    // 포인터 연산자
    T& operator*() const { return *ptr; }
    T* operator->() const { return ptr; }

    // get 함수
    T* get() const { return ptr; }
};

 

 

 


 

 

 

Shared_ptr

여러 포인터가 동일한 객체를 안전하게 공유할 수 있도록 하는 포인터

 

1. 특징

 

참조 카운트를 사용하여 객체의 수명을 관리한다.

  • 새로운 shared_ptr이 객체를 가리킬 때마다 카운트가 증가
  • shared_ptr이 소멸될 때마다 카운트가 감소
  • 카운트가 0이 되면 객체를 자동으로 삭제
std::shared_ptr<A> p1(new A());
std::shared_ptr<A> p2(p1);  // p2 역시 생성된 객체 A 를 가리킨다.

// 반면에 unique_ptr 의 경우
std::unique_ptr<A> p1(new A());
std::unique_ptr<A> p2(p1);  // 컴파일 오류!

 

 

2. use_count

 

현재 shared_ptr 의 참조 개수가 몇 개 인지는 use_count 함수를 통해 알 수 있다.

std::shared_ptr<A> p1(new A());
std::shared_ptr<A> p2(p1);  // p2 역시 생성된 객체 A 를 가리킨다.

std::cout << p1.use_count();  // 2
std::cout << p2.use_count();  // 2

 

 

3. 제어 블록 공유

 

처음으로 실제 객체를 가리키는 shared_ptr  제어 블록(control block) 을 동적으로 할당한 후, shared_ptr 들이 이 제어 블록에 필요한 정보를 공유하는 방식으로 구현된다.

 

shared_ptr 는 복사 생성할 때 마다 해당 제어 블록의 위치만 공유하면 되고, shared_ptr 가 소멸할 때 마다 제어 블록의 참조 개수를 하나 줄이고, 생성할 때 마다 하나 늘리는 방식으로 작동함

 

 

p1, p2, p3 가 공통된 제어 블록을 공유한다

 

 

4. make_shared 로 생성

 

std::shared_ptr<A> p1 = std::make_shared<A>();	// A 생성 + 제어블록 동적 할당 한번에

std::shared_ptr<A> p1(new A()); // A 생성 + 제어블록 동적 할당 각각 (느림)

 

 

5. 생 포인터로 직접 생성 피하기

 

shared_ptr 은 인자로 주소값이 전달된다면, 마치 자기가 해당 객체를 첫번째로 소유하는 shared_ptr 인 것 마냥 행동한다. 즉, 제어블록을 생성한다.

A* a = new A();
std::shared_ptr<A> pa1(a);
std::shared_ptr<A> pa2(a);	// 위험함!!

std::shared_ptr<A> pa1 = std::make_shared<A>();
std::shared_ptr<A> pa2 = pa1;	// 안전하게 복사

 

첫번째의 경우, 두 개의 제어블록이 따로 생성됨

shared_ptr 를 주소값을 통해서 생성하는 것을 지양해야 한다.

 

 

6. enable_shared_from_this

 

객체가 자신의 shared_ptr를 안전하게 생성해야 할 때 사용

class MyClass : public std::enable_shared_from_this<MyClass> {	// 상속 후 사용
public:
    std::shared_ptr<MyClass> getShared() {
        return shared_from_this();
    }
};

 

한 가지 중요한 점은 shared_from_this 가 잘 작동하기 위해서는 해당 객체의 shared_ptr 가 반드시 먼저 정의되어 있어야만 한다. 즉 shared_from_this 는 있는 제어 블록을 확인만 할 뿐, 없는 제어 블록을 만들지는 않는다.

 

A* a = new A();
std::shared_ptr<A> pa1 = a->get_shared_ptr();	//오류임!!

 

 

7. 순환 참조 문제

 

int main() {
  std::shared_ptr<A> pa = std::make_shared<A>();
  std::shared_ptr<A> pb = std::make_shared<A>();

  pa->set_other(pb);
  pb->set_other(pa);	// 서로 영원히 참조함, 해제 불가
}

 

weak_ptr 은 이러한 순환 참조 문제를 해결하기 위해 도입되었다.

 

 

 

weak_ptr

 

객체의 존재 여부를 확인하면서도 객체의 수명에는 영향을 주지 않는다.

 

특징

  • 참조 카운트를 증가시키지 않음
  • 직접적으로 객체에 접근할 수 없음
  • shared_ptr이나 다른 weak_ptr로부터 생성

 

활용 예시

#include <iostream>
#include <memory>
#include <string>
#include <vector>

class A {
  std::string s;
  std::weak_ptr<A> other;

 public:
  A(const std::string& s) : s(s) { std::cout << "자원을 획득함!" << std::endl; }

  ~A() { std::cout << "소멸자 호출!" << std::endl; }

  void set_other(std::weak_ptr<A> o) { other = o; }
  void access_other() {
    std::shared_ptr<A> o = other.lock();
    if (o) {
      std::cout << "접근 : " << o->name() << std::endl;
    } else {
      std::cout << "이미 소멸됨 ㅠ" << std::endl;
    }
  }
  std::string name() { return s; }
};

int main() {
  std::vector<std::shared_ptr<A>> vec;
  vec.push_back(std::make_shared<A>("자원 1"));
  vec.push_back(std::make_shared<A>("자원 2"));

  vec[0]->set_other(vec[1]);
  vec[1]->set_other(vec[0]);

  // pa 와 pb 의 ref count 는 그대로다.
  std::cout << "vec[0] ref count : " << vec[0].use_count() << std::endl;
  std::cout << "vec[1] ref count : " << vec[1].use_count() << std::endl;

  // weak_ptr 로 해당 객체 접근하기
  vec[0]->access_other();

  // 벡터 마지막 원소 제거 (vec[1] 소멸)
  vec.pop_back();
  vec[0]->access_other();  // 접근 실패!
}

 

 

정의

void set_other(std::weak_ptr<A> o) { other = o; }

// 호출
vec[0]->set_other(vec[1]);
vec[1]->set_other(vec[0]);

 

weak_ptr 는 생성자로 shared_ptr 나 다른 weak_ptr 를 받는다.

 

 

shared_ptr로 변환

void access_other() {
  std::shared_ptr<A> o = other.lock();
  if (o) {
    std::cout << "접근 : " << o->name() << std::endl;
  } else {
    std::cout << "이미 소멸됨 ㅠ" << std::endl;
  }
}

// weak_ptr 로 해당 객체 접근하기
  vec[0]->access_other();

 

weak_ptr 그 자체로는 원소를 참조할 수 없고, shared_ptr 로 변환해야 한다. 이 작업은 lock 함수를 통해 수행할 수 있다.

 

weak_ptr 에 정의된 lock 함수는 만일 weak_ptr 가 가리키는 객체가 아직 메모리에서 살아 있다면 (즉 참조 개수가 0 이 아니라면) 해당 객체를 가리키는 shared_ptr 을 반환하고, 이미 해제가 되었다면 아무것도 가리키지 않는 shared_ptr 을 반환함

 

 

해제 시점

 

 shared_ptr 은 0 개 지만 아직 weak_ptr 가 남아있다고 가정하면

물론 이 상태에서는 이미 객체는 해제 되어 있을 것이다. 하지만 제어 블록 마저 해제해 버린다면, 제어 블록에서 참조 카운트가 0 이라는 사실을 알 수 없게 된다.

(메모리가 해제된 이후에, 같은 자리가 다른 용도로 할당 될 수 있는데, 이 때문에 참조 카운트 위치에 있는 메모리가 다른 값으로 덮어 씌어질 수 도 있다)

 

즉, 제어 블록을 메모리에서 해제해야 하기 위해서는 이를 가리키는 weak_ptr 역시 0 개여야 한다.

따라서 제어 블록에는 참조 개수와 더불어 약한 참조 개수를 (weak count) 기록함.