스마트 포인터의 기초적 부분을 만들어보며 이해해보자😁

안녕하세요! 두두코딩 널두 🥸 입니다 ✋
오늘은 C++ 스마트 포인터 개념에 대해 알아보겠습니다.

🖇 소스코드에 마우스를 올리고 copy 버튼을 누를 경우 더 쉽게 복사할 수 있습니다!

궁금한 점, 보안점 남겨주시면 성실히 답변하겠습니다. 😁
+ 감상평 댓글로 남겨주시면 힘이됩니다. 🙇

Intro.

이번 포스팅에서는 스마트포인터 를 알아보겠습니다.

스마트포인터의 등장

스마트포인터는 C++에서 사용하는 개념으로 가비지컬랙터가 없는 C++의 메모리 회수 정책을 도와주는 아주 유용한 도구이다.

해당 포인터를 만들어보며 찍먹(?)정도 해보도록 하자. 너무 깊게 들어가면 어려운 부분이 많아 깊은 부분은 중급 / 고급 과정에서 다룬다.

#include <iostream>

class Car
{
public:
  void Go() { std::cout << "Go" << std::endl; }
  ~Car() { std::cout << "~Car" << std::endl; }
};

class Sptr
{
  Car* ptr;

public:
  Sptr(Car* p = 0) : ptr(p) {}

  Car* operator->() { return ptr; }
};

int main()
{
  Sptr p = new Car;

  p->Go();
}

위와 같은 코드가 있다고 하자. Sptr은 포인터와 같은 형식으로 사용되고 있다. p->GO();를 보면 포인터에서 우리가 내부 데이터에 접근할 때 사용하는 연산이다.

생각해보면 Sptr객체인데.. 어떻게 저게 가능하지? 라고 생각하면 가장 먼저 떠올려야할 것은 ->() 연산자 재정의아닐까? 이다.

바로 operator->()의 재정의인데, 풀어서 생각해보면 다음과 같다.

(p.operator->())->Go(); 여기서 operator->()의 특징이 나오는데, 해당 연산자 재정의를 할 경우 ->가 한번 더 있는 것 처럼 사용된다는 점이다.

즉, operator->()를 했을 때, 포인터를 반환 받는데, 그 경우 ₩->를 통해 내부 함수에 접근을 해야한다. 그럴경우 Go()를 호출하는게 되게 애매해지는데, 이건 내부적으로 반환받은 포인터가 ->를 사용하는 것처럼 변환해준다는 것이다.

즉, operator->()쓰고 반환받은 포인터는 ->로 내부 접근한다만 기억하면 된다.

위와 같이 Sptr을 사용하면 포인터 처럼 객체를 사용할 수 있다. 객체사용의 장점이 중요한데, 바로 객체의 생성 / 복사 / 대입 / 소멸 과정을 제어할 수 있다는 점이다.

특히 소멸의 과정을 한번 보도록 하자

class Sptr
{
  Car* ptr;

public:
  Sptr(Car* p = 0) : ptr(p) {}

  // 소멸자
  ~Sptr() { delete ptr; }
  Car* operator->() { return ptr; }
};

위와 같이 소멸자를 활용하여, 내부 ptr을 제거할 수 있다. 분명 위 main()에서는 delete 연산을 하고 있지 않는다. 하지만 위 코드를 적용할 경우 main()이 끝남과 동시, Sptr이 파괴되고, 소멸자가 불려 ptr 데이터도 제거될 것이다.

즉, 위 행위는 보통 가비지 컬랙터가 해주는 역할인데, 이와 유사한 모양을 객체화한다면 나타낼 수 있다.

보통 포인터라고 하면 ->연산자 말고 *역참조 연산도 많이 활용하는데, 그것또한 재정의가 가능하다.

class Sptr
{
  Car* ptr;

public:
  Sptr(Car* p = 0) : ptr(p) {}

  // 소멸자
  ~Sptr() { delete ptr; }
  Car* operator->() { return ptr; }

  // *operator 연산자 재정의
  Car& operator*() { return *ptr; }
};

위와 같이, 재정의를 할 경우 (*p).Go()와 같은 동작을 할 수 있다.

스마트포인터 template 화

위에서 우리가 Sptr를 만들면서 특정 타입 Car에 대해 포인팅해 값을 접근하도록 만들었다. Sptr객체를 활용해 다양한 타입을 포인팅해 자원관리를 할 수 있도록 하기 위해서는 어떻게 해야할까???

앞에서 많이 다뤄서 익숙하겠짐나, 바로 Template 화 하는 것이다.

#include <iostream>

template<typename T> class Sptr
{
  // 타입의 포인터
  T* ptr;

public:
  Sptr(T* p = 0) : ptr(p) {}
  ~Sptr() { delete ptr; }

  T* operator->() { return ptr; }
  T& operator*()  { return &ptr; }
}

int main()
{
  Stpr<int> p1 = new int;

  *p1 = 10;
  std::cout << *p << std::endl;
}

위의 코드와 같이 만들 경우 다양한 타입에 대해서 스마트 포인팅 할 수 있다. 즉, int*를 만들어 사용하지만, delete 연산 즉, 자원누수현상에 대해 생각할 필요가 없다.

사용방법은 아주 간단하다. 추가로, 주의사항을 알아보자.

스마트포인터를 만들 때 주의할 점이 하나가 있는데 아래의 코드를 보자.

int main()
{
  Sptr<int> p2 = p1;
}

위와 같이 할 경우 에러가 발생한다. 컴파일 에러이면 코드가 잘못된 부분을 찾으면 되는데 아쉽게도 런타임 에러가 발생한다. 왜 발생하는 것일까? 바로 대입연산자의 특징 모든 값을 복사함 (단, shallow copy)의 문제이다.

대입연산자 같은 경우 shallow copy 즉, bitwise 카피로 포인터 주소값도 그냥 복사해버린다. 따라서, 포인팅 대상을 가리키고 있는 주소값도 복사되고, 대상을 가리키는 포인터는 2개가 된다. 이 경우 어떤 하나의 포인팅에서 delete연산을 할 경우, 대상객체가 파괴되기 때문에, 포인터 하나가 부유하는 문제가 발생한다.

이를 막기 위해 우리는 2가지를 앞서 배웠다.

1. deep copy 구현

가장 먼저 생각할 수 있는 방법은 Sptr의 대입연산자를 deep copy를 통해 구현하는 것이다. 하지만 포인터라는 특성상 대상 객체를 가리키는 역할을 해야되는데 대상 객체를 새롭게 만드는건 아주 어폐가 있는 행위이다.

따라서 우리는 2번째 방법을 활용해야한다.

2. reference counting 구현

즉, 현재 대상 객체를 가리키고 있는 포인터의 갯수를 관리하는 기법을 사용해야한다. 이 방법은 살짝 어려운 부분이 있어 찍먹 하는 포스팅에서 다루기보다 중급과정에서 다루도록 한다.

정리해보자면, 스마트 포인터의 대입연산자에서는 referencing counter 방법을 활용해 대상객체가 임의로 파괴되는 행위를 막아야한다는 점을 기억하자.

아.. 대입연산자는 재정의가 가능할까? => 바로 다음 포스팅에서 다루도록 한다.

표준 스마트포인터

우리는 스마트포인터를 구현해보며, 자원관리를 해주는 스마트함에 장점을 깨닳았다. 하지만, 어떤 프로그램을 개발할 때마다 우리가 만들어써야할까? 그건아니다. 바로 C++ 표준에서 스마트 포인터를 제공해주기 때문에, 해당 객체를 가져다 쓰면된다.

#include <iostream>
// 스마트포인터를 사용하기 위한 헤더
#include <memory>

class Car{
public:
  void Go() { std::cout << "Go" << std::endl; }
  ~Car() { std::cout << "~Car" << std::endl; }
};

int main() {
  std::shared_ptr<Car> p (new Car); // ok..!!

  p->Go();
}

위와 같이 C++ 표준에서는 <memory> 헤더파일에 스마트포인터를 만들어 제공한다. 스마트 포인터의 이름은 shared_ptr이다. 해당 객체는 내부적으로 참조계수 기반 즉, referencing count 방법을 통해 구현되어져 있다.

자원관리를 따로 하고 싶지 않다면 포인터를 사용할 때 무조건 스마트 포인터를 적극 활용하자.

추가로, 스마트 포인터를 사용함에 있어 주의사항이 한가지 있다

int main()
{ 
  std::shared<Car> p (new Car); // 1

  std::shared<Car> p = new Car; // 2 error
}

위와 같이, [1] 초기화 같은 경우 정상적으로 동작한다. 하지만 [2] 초기화 같은 경우 에러가 발생하는데 이는 explicit개념과 변환생성자 개념을 알아야하기 때문에 찍먹 과정이 아닌 중급과정에서 자세하게 다루도록 한다!

Outtro.

해당 포스팅은 Ecourse의 C++ Basic 강의를 참고해 작성되었습니다.

강의를 참고하실 분은 여기를 클릭해 확인해주세요!