이번 포스팅에서는 메모리를 직접적으로 다루고 활용할 일이 많은 C/C++에서 프로그래머가 직접 데이터의 할당과 회수 작업을 관리해야하는 번거로움을 해소 시켜주는 Smart Pointer에 대해 정리한다.  

 

     


    목차

    • 스마트 포인터의 정의와 필요성
    • auto_ptr과 일반 포인터의 차이점 및 문제점
    • shared_ptr | unique_ptr
    • 스마트 포인터 선택

     


    스마트 포인터의 정의와 필요성

    Smart Pointer동적 메모리 대입을 관리하기 위한 클래스 및 템플릿 라이브러리이다.  포인터와 다르게 스마트 포인터는 Class라는 점에서 일반적인 포인터와 차이가 있다. 그리고 프로그래머가 포인터를 통해 동적 메모리를 다룰 경우 메모리의 회수라는 책임도 짊어지게 되는데, 스마트 포인터는 클래스의 기능으로 해당 데이터의 회수를 소멸자를 통해 자동으로 진행해주고 있다. 데이터 회수를 굳이 직접하면 되지 class까지 만들어가면서 포인터를 사용해야되나? 라고 의문을 가질 수 있지만, 사실 사람이라면 누구나 실수하기 마련이다. 특히 프로그램의 크기가 커지면 커질수록 실수할 위험성도 똑같이 증가하게 된다. 스마트 포인터의 사용은 프로그래머가 어떠한 구조를 디자인하는데 있어서 다양한 기능들에 대한 모듈화가 중요한 핵심요소로 작용한다는 철학을 따른다.

     

     

     


     

    auto_ptr과 일반 포인터의 차이점 및 문제점

    스마트 포인터일반 포인터의 기능상 차이점을 코드를 통해 확인해보자

     

    다음 코드는 프로그래머가 흔히 포인터를 사용하면서 실수하는 상황을 연출한 코드이다.

     일반적인 포인터의 사용 예
    void func(string& str)
    {
        string* ps = new string(str);
        ...
        str = ps;
        return;
    }

     

    이 함수는 지역적으로 ps 포인터를 소유하고 있다. 매개인자로 넘겨받은 str의 데이터를 기반으로 새로운 임시 객체를 ps에 동적할당한다. ps는 임시객체를 가리키고 어떠한 작업을 마친 후 다시 str에게 주소를 넘긴다. 그리고 이 함수는 종료된다. 여기서 이 함수의 문제점을 바로 알아차릴 수 있을 것이다. 해당 함수가 종료된 시점에서 임시 객체에 대한 메모리도 같이 해제가 되어야하지만 이 함수는 delete를 사용하지 않아서 데이터가 남아 결과적으로 메모리 누수가 발생하게 된다. 이 부분에서 스마트 포인터의 필요성을 알 수 있다.

     

     

    스마트 포인터의 사용

    스마트 포인터를 직접 클래스로 구현해 사용할 수도 있지만 이 포스팅에서는 다루지 않는다. 대신 C++이 제공하는 3개의 표준 스마트 템플릿(auto_ptr, unique_ptr, shared_ptr)에 대해 알아본다. 위에 제공된 3개의 스마트 포인터를 사용하기 위해선 추가적으로 memory 헤더파일을 소스 코드에 포함시켜야 한다.

     

     

    auto_ptr 사용 예
    // 일반적인 포인터 사용시 실수하는 상황
    void func1()
    {
        double* pd = new double;    
        *pd = 25.5;
        return;  
    }  // 포인터 pd는 회수되지만, new double로 생성된 데이터는 사라지지 않는다. #메모리누수
    
    
    // auto_ptr을 사용한 예시
    void func2()
    {
        auto_ptr<double> ap(new double);
        *pd = 25.5;
        return;
    }  // auto_ptr이 회수되면서 데이터도 자동으로 해제한다.

     

    위 코드처럼 스마트 포인터를 사용할 경우 함수의 종료와 동시에 delete를 적지 않아도 스마트 포인터가 알아서 메모리를 해제해준다. 하지만 auto_ptr도 문제점이 전혀 없는건 아니다. 다음의 상황을 보자

     

     

    auto_ptr의 문제점
    #include <iostream>
    #include <memory>
    
    int main(void)
    {
        std::auto_ptr<int> ptr1(new int);
        *ptr1 = 100;
    
        std::auto_ptr<int> ptr2;
        ptr2 = ptr1;
    
        std::cout << *ptr2 << std::endl;  // 정상 출력 
        std::cout << *ptr1 << std::endl;  // 프로그램 crash
    
        return 0;
    }

     

    • ptr2 = ptr1;
      ptr1의 데이터 주소를 ptr2가 가리키게 한다
    • std::cout << *ptr1 << std::endl;
      ptr1의 값 100을 출력하는 구문이다. 하지만 프로그램이 정상적으로 동작하지 않는다.

     

    위 코드에서 ptr1이 가리키고 있는 주소를 똑같이 ptr2에게도 가리키게 하고, ptr1을 출력하려고 하면 에러가 된다.

    일반적인 포인터였다면 서로 같은 주소를 공유하게 되어 둘 다 정상 출력이 되야하지만, 위에 상황에서는 에러가 난다 이유가 무엇일까? 

     

    auto_ptr 스마트 포인터의 주소 공유를 막은 이유

    auto_ptr은 일반적인 포인터가 아니다. 클래스이다. 해당 포인터의 동작 원리는 생성자로 자신의 임시 객체에 대한 주소를 저장함과 동시에 소멸자로 해당 데이터의 해제까지 역할을 맡게된 셈이다. 만약 두 스마트 포인터가 같은 주소를 가리키게 된 상황에서 하나의 스마트 포인터를 소멸하게 된다면 같은 데이터를 가리키던 스마트 포인터는 아무것도 없는 쓰레기값을 가리키게 되는 꼴이 되고 소멸자가 제대로된 기능을 하지 못하게 되므로 오버헤드가 발생하게 된다. 그래서 이러한 상황을 막고자 auto_ptr은 동시에 같은 주소를 공유하기보다는 소유권을 다른 포인터에 양도하는 방법을 사용한다. 그래서 위에 출력문에서 ptr1은 주소의 소유권을 넘긴 상태이므로 값이 존재하지 않아 에러가 나오게 된다.

     

     

     


     

    shared_ptr  |   unique_ptr

    위에서 살펴본 auto_ptr은 일반적인 포인터가 갖는 데이터 회수 누락에 대한 단점을 보완한 동시에 하나의 주소를 공유하지 못하는 치명적인 단점이 있다는걸 알 수 있었다. 하지만 이에 대한 대책도 여전히 남아있다. 바로 shared_ptr과 unique_ptr이다. 

     

    shared_ptr 

    shared_ptr은 여러 스마트 포인터가 하나의 객체를 공유할 경우 스마트 포인터들이 몇 개 생성되었는지 파악하는 포인터를 생성한다. 이것을 참조 카운팅(reference counting)이라고 한다. 하나의 객체에 대해 주소를 공유하는 포인터가 늘어날 때마다 1이 카운팅되며, 마지막 포인터의 수명이 다했을 때만 delete가 호출된다. 이러한 방식으로 auto_ptr이 데이터 주소를 공유하지 못하는 단점을 shared_ptr은 해결했다.

     

    #include <iostream>
    #include <memory>
    
    int main(void)
    {
        std::shared_ptr<int> ptr1(new int);
        *ptr1 = 100;
    
        std::shared_ptr<int> ptr2;
        ptr2 = ptr1;
    
        std::cout << *ptr2 << std::endl;  // 정상 출력 
        std::cout << *ptr1 << std::endl;  // 정상 출력
    
        return 0;
    }

     

    auto_ptr과 다르게 shared_ptr은 정상적으로 주소가 공유되어서 출력문이 작동하는걸 확인할 수 있다.

     

     

    unique_ptr

    auto_ptr과 마찬가지로 unique_ptr도 똑같이 주소에 대한 소유권 개념을 가지고 있다. 하지만 unique_ptr은 auto_ptr보다 더 안정적인 형태를 가지고 있다. 다음 코드를 살펴보자.

     

    // auto_ptr 
    auto_ptr<string> p1(new string("auto"));      // #1
    auto_ptr<string> p2;                          // #2
    p2 = p1;                                      // #3
    -> 출력시 program crash
    
    // unique_ptr
    unique_ptr<string> p1(new string("unique"));  // #1
    unique_ptr<string> p2;                        // #2
    p2 = p1;                                      // #3 컴파일 error

     

    위 코드에서 auto_ptr은 컴파일러가 해당 코드에 문제가 있는지 컴파일 단계에서는 알 수 없으므로 프로그램은 정상적으로 실행된다. 하지만,프로그램이 실행되고 크래쉬가 나면서 프로그램이 정상적으로 동작하지 않는다. 반면에 unique_ptr은 p2 = p1 구문에서 대입 자체를 허용하지 않음으로써 컴파일 단계에서 error를 나타내준다. 즉, 컴파일 단계에서 문제를 발생시켜 즉시 프로그래머가 알 수 있도록 한다. 결국 unique_ptr은 auto_ptr보다 더 안전하다고 할 수 있다. 

     

    unique_ptr의 대입을 사용하기

    만약 프로그래머가 unique_ptr을 대입을 사용해야 하는 상황이 온다면 다음 코드처럼 진행하면 컴파일러 에러를 피하고 문제없이 대입을 사용할 수도 있다. 아래 코드를 보자

    unique_ptr<string> func(const char* str)
    {
        unique_ptr<string> temp(new string(str));
        return temp;
    }
    
    void main()
    {
        unique_ptr<string> ps;
        ps = func("safety");
    }

     

    func()는 임시 unique_ptr를 return한다. 그리고 ps는 그 임시 unique_ptr이 return한 객체의 소유권을 얻게된다. 그리고 임시 unique_ptr은 삭제된다. ps가 문자열 객체의 소유권을 가짐과 동시에 또 하나의 unique_ptr이 해제되므로 이 코드는 문제가 발생하지 않는다. 즉, 컴파일러는 이 코드를 허용하고 있다.

     

    만약 unique_ptr에서 다른 unique_ptr에 대입할 때 lvalue가 아닌 rvalue일 경우 컴파일러는 마찬가지로 동작을 허용한다. 하지만, rvalue가 중복되어 있는 상태라면 허용하지 않으니 주의해야한다.

    // 임시 rvalue를 통해 대입 허용
    unique_ptr<string> p3;
    p3 = unique_ptr<string> (new string "hi!");
    • p3 = unique_ptr<string> (new string "hi!");
      unique_ptr의 임시 객체를 생성한 후 p3에 대입한다. 이후 소멸자가 동작해서 임시객체는 사라지므로 정상적으로 동작한다.

     

     

    unique_ptr 끼리 대입을 허용하는 std::move() 표준 라이브러리 함수

    C++은 unique_ptr이 다른 unique_ptr로 대입될 수 있는 std::move()라는 표준 라이브러리 함수를 가지고 있다. 다음은 func() 함수를 이용하여 unique_ptr<string> 객체를 리턴하는 예제이다.

    using namespace std;
    unique_ptr<string> p1, p2;
    p1 = func("uniquely special");
    p2 = move(p1);
    cout << *p1 << *p2 << endl;

     

    또한, unique_ptr은 auto_ptr와 다르게 배열로도 사용될 수 있다는 장점을 가지고 있다. 하지만 그렇기 때문에 delete와 new, 그리고 delete[]와 new[]가 쌍으로 사용되어야 한다는 것 또한 기억하고 있어야 한다.

     

    std::unique_ptr <double[]> p1 (new double(5)); // delete [] 사용

     


     

    스마트  포인터 선택

    그렇다면 3가지는 각각 어떠한 상황에서 사용해야 할까? 만일, 프로그램이 하나의 객체에 대해 하나 이상의 포인터를 사용해야한다면, 소유권 개념이 없는 shared_ptr을 사용하는 것이 좋다.

     

    만약 하나의 데이터에 대해 다중 포인터를 필요로 하지 않는다면 unique_ptr을 사용하는 것이 좋다.

     

     

     


     

    '과거 자료' 카테고리의 다른 글

    [#4] Left Child Right Sibling Tree  (0) 2022.07.28
    [#3] Queue  (0) 2022.07.28
    [C++ Basic] 예외(exception) 메커니즘  (0) 2022.07.07
    [C++ Basic] 프렌드 클래스 (friend class)  (0) 2022.07.04
    [#2] Stack  (0) 2022.07.04