스마트포인터

은수·2022년 6월 27일

cpp study

목록 보기
16/21

자원(resource) 관리의 중요성

사용이 끝난 자원은 반드시 반환을 해서 다른 작업에서 사용할 수 있도록 해야 함. C++의 경우, 한 번 획득한 자원을 프로그래머가 직접 해제해줘야 함.

제대로 종료하지 않으면, 포인터는 존재하지 않는데
heap에는 해당 객체가 남아있는 메모리 누수 발생


Resource Acquisition Is Initialization - RAII

자원의 획득은 초기화다 (RAII)

  • 자원 관리를 스택에 할당한 객체를 통해 수행
  • 즉, 자원(메모리) 관리를 스택의 객체(포인터 객체)를 통해 수행하는 것

포인터 객체 == 스마트 포인터 (Smart Pointer)

  • unique_ptr
  • shared_ptr

unique_ptr

c++에서 메모리를 잘못된 방식으로 관리했을 때 크게 두 가지 종류의 문제점 발생 가능

  1. 메모리 누수
    메모리를 사용한 후에 해제하지 않은 경우.
    장시간 작동하는 프로그램의 경우 시간이 지남에 따라 점점 사용하는 메모리 양 증가 -> 결과적으로 시스템 메모리 부족해짐
    - RAII 패턴 사용해서 해결 가능

  2. double free 버그
    이미 해제된 메모리 다시 참조하는 경우.
    보통 이런 경우 메모리 error 발생하며 프로그램이 죽는데, 이런 문제가 발생하는 이유는 만들어진 객체의 소유권이 명확하지 않기때문
    - 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는 A 클래스의 객체를 가리키는 포인터
std::unique_ptr<A> pa(new A());

// pa가 포인터인 것 처럼 사용하면 됨
pa->some();

unique_ptr를 정의하기 위해서는 템플릿에 인자로 포인터가 가리킬 클래스를 전달하면 됨.

  • unique_ptr은 -> 연산자를 오버로드해서 마치 포인터를 다루는 것과 같이 사용할 수 있게 함
  • RAII 패턴 사용 가능 = pa는 스택에 정의된 객체이기 때문에, do_something() 함수가 종료될 때 자동으로 소멸자 호출

삭제된 함수

#include <iostream>


class A {
 public:
  A(int a){};
  /* 복사 생성자 명시적 삭제
  		-> error 발생*/
  A(const A& a) = delete;
};

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

= delete; 를 사용함으로써 복사 생성자를 명시적으로 삭제했기 때문에 컴파일 오류 발생.

unique_ptr는 어떠한 객체를 유일하게 소유해야함. 따라서, 마찬가지로 복사 생성자가 명시적으로 삭제됨.

만약 unique_ptr 를 복사 생성할 수 있게 된다면, 특정 객체를 여러 개의 unique_ptr 들이 소유하게 되는 문제가 발생.따라서, 각각의 unique_ptr 들이 소멸될 때 전부 객체를 delete 하려 해서 앞서 말한 double free 버그가 발생


unique_ptr 소유권 이전

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; }

  ~A() {
    std::cout << "자원을 해제함!" << std::endl;
    delete[] data;
  }
};

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

  // pb 에 소유권을 이전.
  /* pa를 pb에 강제로 이동시켜버림.
     따라서 pb가 new A로 생성된 객체의 소유권을 갖게 되고,
     pa는 아무것도 가리키고 있지 않음 */
  std::unique_ptr<A> pb = std::move(pa);
  std::cout << "pb : ";
  pb->some();
}

int main() { do_something(); }

pa는 아무것도 가리키고 있지 않음.
pa.get()을 통해 실제 주소값을 확인하면 0 (nullptr) 이 출력됨.
따라서, pa를 이동시킨 후에 pa->some()하면 error 발생.

따라서 소유권을 이동시킨 후, 기존의 unique_ptr을 접근하지 않도록 조심해야 함.

댕글링 포인터 (dangling pointer)
: 소유권이 이전된 unique_ptr를 의미.
: 댕글링 포인터를 재참조할 시 런타임 오류


unique_ptr 를 함수 인자로 전달하기

unique_ptr 의 레퍼런스를 사용하는 것은,
unique_ptr 를 소유권 이라는 중요한 의미를 망각한 채 단순히 포인터의 단순한 Wrapper 로 사용하는 것에 불과.

-> 따라서, 원래의 포인터 주소값을 전달 해 줌으로써 함수에 올바르게 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(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 가 소멸될 때, 가리키던 객체 역시 소멸됨
  • 만약에 다른 함수에서 unique_ptr 가 소유한 객체에 일시적으로 접근하고 싶다면, get 을 통해 해당 객체의 포인터를 전달하면 됨
    (즉, pa.get() 형태로 넘기며(A* ptr)형태로 인자를 받으면 됨)
  • 만약에 소유권을 이동하고자 한다면, unique_ptrmove 하면 됨

unique_ptr 을 쉽게 생성하기

#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() {

  // std::make_unique 함수
  auto ptr = std::make_unique<Foo>(3, 5);
  ptr->print();
}

std::make_unique 함수

  • unique_ptr를 간단히 만들 수 있는 함수
  • 아예 템플릿 인자로 전달된 클래스의 생성자의 인자들에 직접 완벽한 전달 수행.
  • 따라서 기존처럼 불필요하게 std::unique_ptr<Foo> ptr(new Foo(3, 5)); 할 필요 없이 간단하게 만들 수 있음

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

#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 << "값 : " << data[0] << std::endl; }

  ~A() {
    std::cout << "자원을 해제함!" << std::endl;
    delete[] data;
  }
};

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

  // vec.push_back(std::unique_ptr<A>(new A(1))); 과 동일
  vec.emplace_back(new A(1));

  vec.back()->some();
}

emplace_back 함수

  • vector 안에 unique_ptr 을 직접 생성 하면서 집어넣을 수 있어 불필요한 이동 과정 생략 가능
  • 전달된 인자를 완벽한 전달(perfect forwarding) 을 통해, 직접 unique_ptr<A> 의 생성자에 전달 해서, vector 맨 뒤에 unique_ptr<A> 객체를 생성해버림.
  • emplace_back 사용 시에 어떠한 생성자가 호출되는지 주의해야 함.

shared_ptr

여러 개의 스마트 포인터가 하나의 객체를 같이 소유 해야 하는 경우.

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

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

class A {
  int *data;

 public:
  A() {
    data = new int[100];
    std::cout << "자원을 획득함!" << std::endl;
  }

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

int main() {
  std::vector<std::shared_ptr<A>> vec;


  // 모두 같은 객체를 가르키는 shared_ptr
  vec.push_back(std::shared_ptr<A>(new A()));
  vec.push_back(std::shared_ptr<A>(vec[0]));
  vec.push_back(std::shared_ptr<A>(vec[1]));

  // vec의 첫번째 원소부터 차례로 지움
  // 벡터의 첫번째 원소를 소멸 시킨다.
  std::cout << "첫 번째 소멸!" << std::endl;
  vec.erase(vec.begin());

  // 그 다음 원소를 소멸 시킨다.
  std::cout << "다음 원소 소멸!" << std::endl;
  vec.erase(vec.begin());

  // 마지막 원소 소멸
  std::cout << "마지막 원소 소멸!" << std::endl;
  
  // 비로소 A 소멸자 호출
  vec.erase(vec.begin());

  std::cout << "프로그램 종료!" << std::endl;
}

shared_ptr를 원소로 가지는 벡터 vec 을 정의한 후, vec[0], vec[1], vec[2] 가 모두 같은 A 객체를 가리키는 shared_ptr 를 생성

unique_ptr 와는 다르게 shared_ptr 의 경우 객체를 가리키는 모든 스마트 포인터 들이 소멸되어야만 객체를 파괴하기 때문에, 처음 두 번의 erase 에서는 아무것도 하지 않다가 마지막의 erase 에서 비로소 A 의 소멸자를 호출

즉, 참조 개수가 처음에는 3 이 였다가, 2, 1, 0 순으로 줄어듦


참조 개수

  • use_count 함수
    use_count함수를 통해 shared_ptr의 참조 개수 알 수 있음.
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
  • 제어 블록(control block)
    실제 객체를 가리키는 shared_ptr 가 제어 블록(control block) 을 동적으로 할당한 후, shared_ptr 들이 이 제어 블록에 필요한 정보를 공유하는 방식으로 구현

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

make_shared 로 생성하자

앞서 사용했던 std::shared_ptr<A> p1(new A());와 같은 형태로 shared_ptr를 생성하는 것은 바람직한 방법이 아님.

A생성 위한 동적할당, shared_ptr 제어블록 동적할당 -> 총 두번의 동적 할당 발생하기 때문.

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

make_shared함수는 A 의 생성자의 인자들을 받아서, 이를 통해 객체 A 와 shared_ptr 의 제어 블록 까지 한 번에 동적 할당 한 후에 만들어진 shared_ptr 을 리턴

= 아예 두 개 합친 크기로 한 번 할당 하는 것


shared_ptr 생성 시 주의 할 점

shared_ptr 은 인자로 주소값이 전달된다면, 마치 자기가 해당 객체를 첫번째로 소유하는 shared_ptr 인 것 마냥 행동함.

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

p1이 ref count 0으로 해당 A 객체를 삭제 하면,
p2는 이미 해제된 객체를 가리키며, 이 객체를 소멸 시키려 하여 오류가 발생.

-> 따라서, shared_ptr 를 주소값을 통해서 생성하는 것을 지양해야함


enable_shared_from_this

  • this 를 사용해서 shared_ptr 을 만들고 싶은 클래스가 있다면,
  • 객체 내부에서 자기 자신을 가리키는 shared_ptr 원하면,
  • 어쩔수 없이 shared_ptr 를 주소값을 통해서 생성해야 한다면)

enable_shared_from_this 를 상속 받으면 됨.

#include <iostream>
#include <memory>

class A : public std::enable_shared_from_this<A> {
  int *data;

 public:
  A() {
    data = new int[100];
    std::cout << "자원을 획득함!" << std::endl;
  }

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

  std::shared_ptr<A> get_shared_ptr() { return shared_from_this(); }
};

int main() {
  std::shared_ptr<A> pa1 = std::make_shared<A>();
  std::shared_ptr<A> pa2 = pa1->get_shared_ptr();

  std::cout << pa1.use_count() << std::endl;
  std::cout << pa2.use_count() << std::endl;
}

enable_shared_from_this 클래스

  • shared_from_this 라는 멤버 함수를 정의하고 있음
  • shared_from_this 함수는 이미 정의되어 있는 제어블록을 사용해서 shared_ptr 생성
  • 따라서 이전처럼 같은 객체에 두 개의 다른 제어 블록이 생성되는 일 예방 가능

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


서로 참조하는 shared_ptr

순환 참조 문제

  • 객체들을 더이상 사용하지 않는되도 불구하고 참조 개수가 절대로 0 이 될 수 없는 상황
  • 각 객체는 shared_ptr 를 하나 씩 가지고 있는데, 이 shared_ptr 가 다른 객체를 가리키고 있음.
  • 따라서 객체1 파괴를 위해서는 객체2가 먼저 파괴되어야 하고, 객체2가 파괴되기 위해서 객체1이 파괴되어야 하는 순환 참조 문제 발생
  • shared_ptr 자체에 내재되어 있는 문제
  • 해결하기 위해 weak_ptr 사용

weak_ptr

weak_ptr ?

  • 일반 포인터와 shared_ptr 사이에 위치한 스마트 포인터
  • 스마트 포인터 처럼 객체를 안전하게 참조할 수 있게 해주지만, shared_ptr 와는 다르게 참조 개수를 늘리지는 않음.
  • 따라서 설사 어떤 객체를 weak_ptr 가 가리키고 있다고 하더라도, 다른 shared_ptr 들이 가리키고 있지 않다면 이미 메모리에서 소멸됨
  • 때문에 weak_ptr 자체로는 원래 객체를 참조할 수 없고, 반드시 shared_ptr 로 변환해서 사용해야 함.
  • 가리키고 있는 객체가 이미 소멸되었다면 빈 shared_ptr 로 변환되고, 아닐경우 해당 객체를 가리키는 shared_ptr 로 변환 -> lock 함수 통해 수행

lock 함수?
만일 weak_ptr 가 가리키는 객체가 아직 메모리에서 살아 있다면 (즉 참조 개수가 0 이 아니라면) 해당 객체를 가리키는 shared_ptr 을 반환하고, 이미 해제가 되었다면 아무것도 가리키지 않는 shared_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();  // 접근 실패!
}

0개의 댓글