스마트 포인터

·2022년 6월 27일
0

cpp_study

목록 보기
20/25

사용이 끝난 자원은 반드시 반환을 해서 다른 작업 때 사용할 수 있도록 해야 합니다. 메모리를 할당만 하고 해제를 하지 않는다면, 결국 메모리 부족으로 프로그램이 crash 될 수도 있음.

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();
  // 할당된 객체가 소멸되지 않음!
  // 즉, 400 바이트(4 * 100) 만큼의메모리누수발생 
}

/* [출력]
자원을 획득함!
*/

자원을 획득만 하고, 소멸자는 호출되지 않음.
-> delete *pa를 안해서 생성된 객체의 주소값을 가지는 포인터는 메모리 상에 존재하지 않게 됨.

또한, 아래와 같이 thrower()로 발생된 예외로 delete pa가 실행되지 않고 넘어가면 메모리 누수가 생기게 됨.

void thrower() { 
  // 예외를 발생시킴! 
  throw 1;
}
void do_something() { 
  A *pa = new A(); thrower();
  // 발생된 예외로 인해 delete pa 가 호출되지 않는다!
  delete pa; 
}

Resource Acquisition Is Initialization - RAII

RAII 패턴: 자원의 획득은 초기화다 - Resource Acquisition Is Initialization

자원의 관리를 스택에 할당한 객체를 통해 수행하는 것

stack unwinding : 예외가 발생해서 함수를 빠져나가도, 그 함수의 스택에 정의되어 있는 모든 객체들은 빠짐없이 소멸자가 호출된다. -> 예외가 발생하지 않으면 함수가 종료될 때 당연히 소멸자들이 호출됨.

이 개념을 이용해서, 포인터 객체라는 걸 도입하면, 함수가 종료되며 쌓인 객체 스택에서 소멸자들이 차례로 불리면서 포인터 자체와 포인터가 가리키는 객체 자체도 소멸될 수 있다(포인터 객체 내 소멸자에 가리키는 객체를 소멸하는 명령어 존재).

객체의 유일한 소유권 - unique_ptr

C++에서 메모리를 잘못된 방식으로 관리하는 경우 다음 두가지 종류의 문제점이 발생함

  • 메모리 사용 후 해제하지 않은 경우: 메모리 누수(memory leak)
    -> 시스템 메로리가 부족해져서 서버가 죽어버리는 상황이 발생 가능
    => RAII 패턴을 사용하면 해결 가능
  • 이미 해제된 메모리를 다시 참조하는 경우
    -> double free bug: 이미 소멸된 객체를 다시 소멸시켜서 발생하는 버그

2번째 문제점인 double free bug 는 어떤 포인터에 객체의 소유권을 유일하게 부여해서 해결 가능.
-> unique_ptr: 특정 객체에 유일한 소유권을 부여하는 포인터 객체

++ unique_ptr로 pa를 스택에 정의된 객체로 만들기 때문에, RAII 패턴 역시 자동 사용 가능.

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

// 아래와 동일
A* pa = new A();

unique_ptr은 -> 을 오버로드해서 마치 포인터를 다루는 것과 같이 사용할 수 있게 함.

삭제된 함수

#include <iostream>
class A { 
public:
  A(int a){};
  A(const A& a) = delete; 
  // 복사 생성자를 호출하는 부분에서 오류가 발생함
};

int main() {
  A a(3); // 가능
  A b(a); // 불가능 (복사 생성자는 삭제됨)
}

컴파일하게 되면 복사 생성자를 호출하는 부분에서 오류가 발생함.
-> 복사 생성자를 명시적으로 삭제했기 때문

이와 같이, unique_ptr도 복사 생성자가 명시적으로 삭제됨.
-> unique_ptr는 어떠한 객체를 유일하게 소유해야 하기 때문!

unique_ptr를 복사 생성할 수 있다면, 특정 객체를 여러 unique_ptr들이 소유하게 되는 문제가 발생함.
각각의 unique_ptr들이 소멸될 때 전부 객체를 delete하려고 하기 때문에 double free bug가 발생함.

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());
std::cout << "pa : "; 
pa->some();

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

}

int main() { do_something(); }

unique_ptr은 복사 생성자는 정의되어 있지 않지만, 이동 생성자는 가능하다.
마치 소유권을 이동시킨다라는 개념으로 생각하면 됨.

소유권 이동 이후 기존 unique_ptr을 접근하지 않도록 조심해야 함.

unique_ptr를 함수 인자로 전달하기

unique_ptr를 함수 인자로 전달하고 싶다면: unique_ptr는 복사 생성자가 없음.
함수에 레퍼런스로 전달하면?

-> 소유권이라는 의미가 사라지게 됨...

get 함수

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

int main() {
  std::unique_ptr<A> pa(new A()); 
  do_something(pa.get());
  // 실제 객체의 주소값을 리턴해 줌: 일반적인 포인터를 받을 수 있음.
}

소유권이라는 의미는 버린 채, do_something 함수 내부에서 객체로 접근 권한을 주는 것.

unique_ptr을 쉽게 생성하기

std::unique_ptr<Foo> ptr(new Foo(3, 5));

unique_ptr로 완벽한 전달을 수행함.

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

기본적으로 vector의 push_back 함수는 전달된 인자를 복사해서 집어넣기 때문에 컴파일 때 에러가 발생하게 됨.

#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); // ?? 
}

따라서 아래와 같이 push_back을 사용할 때 move로 명시적으로 이동 생성자 이용!

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를 직접 생성해서 넣을 수 있음.

여러 객체가 소유할 수 있는 포인터

특정 자원을 몇 개의 객체에서 가리키는지를 추적한 다음에, 그 수가 0 이 되야만 비로소 해제를 시켜주는 방식의 포인터

shared_ptr

객체를 소유하는 unique_ptr와는 다르게, shared_ptr로 객체로 가리킬 경우, 다른 shared_ptr 역시 그 객체를 가리킬 수 있음.

참조 개수가 0이 되어야 가리키고 있는 객체를 해제할 수 있음.

현재 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

개개의 shared_ptr들은 참조 개수가 몇개인 지 알고 있어야 함.

그런데 아래와 같은 경우 p2의 참조 개수는 증가시켜도 p1에 저장된 참조 개수는 건드릴 수 없음.

 std::shared_ptr<A> p3(p2);

-> 이런 문제점 때문에

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

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

생성

std::shared_ptr<A> p1(new A());

위 생성 방법으로는 동적 할당이 2번 발생

  • A를 생성하기 위해 동적 할당이 1번 일어나야 함
  • 제어 블록을 동적으로 할당함

동적 할당은 아예 두개 합친 크기로 한번 할당하는 게 훨씬 빠름 -> 아래 참고

std::shared_ptr<A> p1 = std::make_shared<A>();

shared_ptr 생성 시 주의할 점

shared_ptr은 인자로 주소값이 전달되면,
마치 본인이 해당 객체를 첫번째로 소유하는 shared_ptr인 것처럼 행동함.

A* a = new A(); 
std::shared_ptr<A> pa1(a); 
std::shared_ptr<A> pa2(a);

따라서 shared_ptr를 주소값을 통해 생성하는 걸 지양해야 함.
(이미 해제한 메모리를 또 해제한다는 오류가 날 수 있음)

또한, 순환 참조 문제가 발생할 수 있음(락).

weak_ptr

위에서 말한 순환 참조 문제를 해결하기 위해 weak_ptr를 사용

  • 제어블록에는 참조 개수, 약한 참조 개수(weak count)를 기록함
  • weak_ptr 활용할 때 lock() 함수를 통해 수행(shared_ptr로 변환해야 함)
profile
이것저것 개발하는 것 좋아하지만 서버 개발이 제일 좋더라구요..

0개의 댓글