[모던 C++] 스마트 포인터

Jin Hur·2021년 8월 12일
0

C++

목록 보기
13/18

스마트 포인터의 Point!
C++11부터 도입되었으며 메모리 누수(memory leak)로부터 프로그램의 안전성을 보장하기 위해 만들어졌다.
C++의 RAII 디자인패턴 기반.


스마트 포인터의 필요성

C++의 장점이자 단점은 포인터를 통해 직접적으로 메모리에 접근하는 것

문제1. 댕글링 포인터

  • 반환한 공간에 접근하여 쓰레기 값에 접근할 수 있다.
    => 의도치 않은 결과가 발생할 수 있다.
  • 반환한 공간 다른 곳에 할당된다면, 이는 허용되지 않은 공간에 접근한다는 뜻이다.
    => 세그멘테이션 폴트 런타임 에러가 발생해 프로그램에 크래시가 발생할 수 있다.

문제2. 메모리 릭

  • 메모리를 할당받았지만, 해당 메모리 공간을 가리키는 포인터를 더이상 사용할 수 없을 때, 예를 들어 지역변수로 선언된 포인터 변수를 블록 밖에서 사용할 수 없는 경우 메모리 릭이 발생한다.
    이는 객체의 생성과 소멸이라는 쌍을 맞추어 주지 않아서이다.

메모리 할당

스택 할당

  • 스택 메모리를 할당 받아 객체를 생성하는 방법
  • 정적 메모리 할당 방법 중 하나
  • 메모리의 크기가 하드 코딩되어 있기 때문에 프로그램이 실행될 떄 이미 해당 메모리의 크기가 결정 (컴파일 시간에 결정)
  • 스택에 생성된 객체는 스코프를 벗어날 때 해당 객체의 소멸자가 호출되어 자동으로 소멸(명시적으로도 가능)
  • 덕분에 해제에 대한 메모리 누수와 같은 문제를 신경쓰지 않아도 됨.
  • 하지만 메모리 크기가 이미 정해져 있기에 런타임 메모리 크기를 조절할 수 없음
{
	Foo foo;
} 	// scope

힙에 할당하기

  • 힙 메모리를 할당 받아 객체를 생성하는 방법
  • 스택에 할당하는 방법과 반대로, 동적 메모리 할당의 방법 중 하나
  • 프로그램의 실행 시간에 크기를 결정하게 됨 (런타임에 결정)
  • 가장 기본적인 원시 포인터를 이용하여 객체를 생성하면 힙에 객체를 할당할 수 있음. 그러나 힙에 할당된 객체는 스택에 할당된 객체처럼 자동으로 소멸되지 않음. 따라서 객체를 사용 후 메모리 해제를 해야 메모리 누수와 같은 문제를 피할 수 있음.
{
	Foo *foo = new Foo;	// 힙에 할당
}

RAII: 객체 수명 및 리소스 관리를 위한 패턴

  • RAII는 C++의 독특한 디자인 패턴으로 Resource Acquistion Is Initialization의 약자. 즉 "리소스 획득은 초기화다"라는 뜻이다.
  • 자원의 안전한 사용을 위해 객체가 쓰이는 스코프를 벗어나면 자원을 해제해주는 기법.
  • C++에서 heap에 할당된 자원은 명시적으로 해제하지 않으면 해제되지 않지만, stack에 할당된 자원은 자신의 scope가 끝나면 메모리가 해제되며 소멸자가 불린다는 원리를 이용.
  • 객체의 생명 관리, 락 자원에 대해 대표적으로 적용된다.
    • 객체의 생명 관리 <= 스마트 포인터 활용
    • 락 <= std::lock_guard<mutex> 활용

스마트 포인터 정의

  • 포인터를 알맞은 정책에 따라 관리하는 객체
  • 포인터를 래핑해서 사용
  • 자원관리 기법에 사용되며 메모리 자원 관리와 경계 검사 등을 제공
  • 기존 포인터 변수에서 기능이 추가된 추상 데이터 타입

스마트 포인터 특징

  • 자원을 획득한 후, 자원관리 객체(스마트 포인터)에게 넘긴다.
  • 자원관리 객체는 자신의 소멸자를 사용하여 자원이 확실하게 해제 되도록 한다.
  • 버그 보완(안전), 자동 청소, 자동 초기화를 해준다.
  • 스마트 포인터는 C++에서 안전하고 효율적인 코드를 작성하는데 유용.
  • delete를 이용해 직접 메모리를 해제하지 않고, 스마트 포인터의 소멸자에 존재하는 delete 키워드를 이용해서 메모리를 삭제.
  • 원본 삭제 및 참조 카운팅 등을 이용해 댕글링 포인터가 되는 것을 막음.

스마트 포인터 장점

  • 코드 간략화 (명시적 delete 없음)
  • 자동 해제
  • 자동 초기화
  • Dangling Pointer 방지
  • 메모리 누수 방지
  • Exception 안전
    정상적인 함수 종료에 의한 함수 종료든 Exception이 발생하였든 스마트포인터의 소멸자는 항상 호출됨.
  • 가비지 컬렉션 (메모리 누수 방지)
    • (1) 일부 언어들은 자동 가비지 컬렉션 기능을 제공(대표적으로 자바). C++은 그렇지 못함.
    • (2) 스마트 포인터는 가비지 컬렉션 용도로 사용.
  • 효율성
    • (1) 스마트 포인터는 가용한 메모리를 좀 더 효율적으로 사용할 수 있게 하며 할당, 해제 시간을 단축시킬 수 있음.
    • (2) COW(Copy on Write): 1개의 객체가 수정되지 않는 동안 여러 COW 포인터가 해당 객체를 가리킬 수 있도록 하되, 해당 객체가 수정되는 경우 COW 포인터가 객체를 복사한 후 복사본을 수정하는 방법.
    • (3) 객체가 할당되거나 운용되는 환경에 대해 일부 가정을 세울 수 있는 경우 최적화된 할당 계획이 가능. (운영체제나 응용 프로그램이 변경된다 하더라도 클래스의 코드를 최적화된 할당 계획을 만들 수 있음.)

스마트 포인터 단점

  • 스마트 포인터가 NULL 인지 체크 불가
  • 상속 기반의 변환 제한
  • 상수 객체에 대한 포인터 지원 제한
  • 구현하기 까다로움
  • 이해하기 쉽지 않아 유지보수도 어려움
  • 디버깅이 까다로움

원시 포인터 vs 스마트 포인터로 객체 생성하기

  • 스택과 힙의 장점을 가지고 있는 스마트 포인터로 객체 생성하는 방법
  • 우선 일반적인 스마트 포인터 객체를 선언하면 객체는 스택 메모리를 할당받아 생성되게 됨. 그러나 객체 내부적으로 포인터를 지니고 있어, 동적으로 메모리를 할당 받을 수 있음. 또한 생성자와 소멸자를 포함하고 있어서 메모리 할당과 해제가 객체의 선언과 소멸 시점에서 이루어짐. 결국 스코프를 벗어나면 스마트 포인터 객체가 소멸되고, 이에 따라 동적으로 할당받은 메모리 공간이 자동으로 반환되며 메모리 누수에 대한 걱정을 덜 수 있게 됨.
  • C++에서 제공하는 스탠다드 스마트 포인터 3가지
    - unique_ptr / shared_ptr / weak_ptr (auto_ptr은 C++17부터 제거)

스마트 포인터 종류

shared_ptr

  • 스마트 포인터의 대표
  • unique_ptr처럼 동적으로 할당된 객체의 포인터를 관리하지만, 소유권 이전 뿐 아니라 복사, 대입이 가능한 스마트 포인터.
  • 해당 포인터에 할당된 메모리를 더 이상 참조하는 곳이 없을 땐 자동으로 소멸.
  • 순환 참조 문제가 생길 수 있음.
#include <memory>

void foo(){
	std::shared_ptr<bool> p_bool = std::make_shared<bool>();
}

Pseudo 코드

/* 참조 카운트를 관리하는 블록 클래스 */
class RefCountBlock {
public:
	int refCount = 1;
}

/* Shared 스마트 포인터 슈도코드 */
template<typename T>
class SharedPtr {
public:
	SharedPtr() {}	// 사실상 널 포인터 생성
    SharedPtr(T* ptr) 
    : _ptr(ptr)
    {
    	_block = new RefCountBlock();
        // SharedPtr이라는 래퍼를 활용해,
        // 메모리라는 리소스 획득을 초기화 과정에서 이루어지게 함
    }
    
    ~SharedPtr() {
    	if(_ptr != nullptr) {
			_block->refCount--;
            
            if(_block->refCount == 0){
            	// 실질적으로 관리하고 있던 할당 공간 반환
            	delete _ptr;	
                delete _block;
            }
        }
    }
    
    // (shared_ptr 특징)
    // 소유권 대입->복사, 복사 생성자 활용
    SharedPtr(const SharedPtr& sptr) 
    : _ptr(sptr._ptr), _block(sptr.block)
    {
    	if(_ptr != nullptr){
        	// 나도 관리하는 공간에 대한 소유권을 주장하겠다!
            _block->refCount++;
        }
    }
    // "" => 복사 대입 연산자 활용
    void operator=(const SharedPtr& sptr) {
    	_ptr = sptr._ptr;
        _block = sptr._block;
        if(_ptr != nullptr){
        	// 나도 관리하는 공간에 대한 소유권을 주장하겠다!
            _block->refCount++;
        }
    }
    
private:
	// 원본 포인터
    T* _ptr = nullptr;
    // 참조카운트 객체
    RefCountBlock* _block = nullptr;
}

사이클 문제(순환 참조 문제)

shared_ptr<AAA> a1 = make_shared<AAA>();

{
	shared_ptr<AAA> a2 = make_shared<AAA>();
    a1->ptr_target = a2;
    a2->ptr_target = a1;
}

// 블록을 빠져나왔을 때 a1에 대한 refCnt는 2, a2에 대한 refCnt는 1
// 만약 a1에 대한 포인터도 더이상 접근될 수 없을 때, 각 refCnt가 1로 남아있어 메모리 반환이 되지 않는 문제가 발생할 수 있다.
// 이를 순환 참조에 의해 일으켜진 문제라하여 순환 참조 문제라 한다.
  • 사이클 문제가 발생할 수 있는지 유심히 살펴보고 이를 예방해야 한다.
  • nullptr을 직접적으로 명시하여 refCnt를 줄이는 방법을 써야 한다.

weak_ptr

  • shared_ptr을 간접적으로 참조. 동적으로 할당된 객체의 포인터를 직접적으로 소유할 순 없다.
  • shared_ptr의 순환 참조 문제 해결.

Pseudo 코드

class RefCountBlock {
public:
	int refCount = 1;
    /* 추가 */
    int weakCount = 1;	// weak 포인터가 참조하는 수
}

/* Weak 스마트 포인터 슈도코드 */
template<typename T>
class WeakPtr {
public:
	WeakPtr() {}	// 사실상 널 포인터 생성
    WeakPtr(T* ptr) 
    : _ptr(ptr)
    {
    	_block = new RefCountBlock();
        
    }
    
    ~WeakPtr() {
    	if(_ptr != nullptr) {
			_block->refCount--;
            
            if(_block->refCount == 0){
            	// 실질적으로 관리하고 있던 할당 공간 반환
            	delete _ptr;
                
                //delete _block;
                // weak ptr에서는 refCnt가 0일 때,
                // 곧 바로 refBlock을 삭제하지 않는다.
                // 해당 메모리가 날라갔는지 날라가지 않았는지 확인하는 용도로 사용할 수 있다.
                // shared_ptr처럼 생명주기에 직접적으로 관여하지는 않음
                
            }
        }
    }
    ...
}

장점

  • 생명 주기에 직접적으로 관여하지 않기에 순환 참조 문제에서 자유로울 수 있다.

단점

  • 관리하는 메모리 공간이 반환되었는지 확인하는 expired() 함수를 통해 체크하고, 다시 shared_ptr로 관리해야 하는 번거러움이 생긴다.

unique_ptr

  • shared_ptr과는 다르게 복사, 대입이 불가능하고 소유권 이전(move)만 가능하다.
  • 동적으로 할당된 객체의 포인터를 관리. 또한 참조하는 객체를 관리하는데, 해당 포인터에 할당된 메모리를 더 이상 참조하는 곳이 없을 때 자동으로 소멸.
  • 소유권 이전(move)할 수 있지만, 복사(copy)나 대입(assign)할 수 없음.
  • 할당받은 공간을 가리키는 포인터가 유일해야 하는 경우에 사용한다.
  • 내부적으로 레퍼런스 카운터와 같은 개념이 없고, 다만 일반적인 포인터에서 복사 생성자 및 복사 대입 연산자를 막아주는 셈이다.
#include <memory>

void foo(){
	std::unique_ptr<bool> p_bool = std::make_unique<bool>();
    
    //std::unique_ptr<bool> p_bool2 = p_bool; 	// 대입 및 복사 불가
    
    // 이동 가능
    std::unique_ptr<bool> p_bool2 = std::move(p_bool);
}

참조

0개의 댓글