ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 1. std::unique_ptr
    Modern C++/Smart Pointer 2020. 7. 2. 18:39

    C++ 메모리 관리

    C++에서 생 포인터(raw pointer)를 관리하기는 쉽지 않다. 자바는 가비지 콜렉터가 이를 수행하지만 C++같은 경우 사용자가 직접 메모리를 관리해야 한다. 잘못된 메모리 관리는 메모리 릭이 발생하거나 더블 프리 문제로 프로그램이 예외를 던지고 프로그램이 종료하기도 한다. 

    여기서 생 포인터를 사용하기 어려운 이유를 살펴 보기로 하자.

    struct Data {
        uint8_t *data_;
        size_t length_;
    };
    

    여기서 data_는 하나의 객체를 가리키는 포인터인지 배열을 가리키는 포인터인지 코드 자체로는 구별할 수 없다. 

    생성자에는 uint8_t 형 배열 포인터와 해당 배열의 크기를 받는 생성자와 소멸자를 추가해보자. 

    struct Data {
        Data(const uint8_t *data, size_t length);
        ~Data();
        //복사 이동은 지원하지 않는다고 가정 
        Data(const Data& rhs) = delete;
        Data& operator = (const Data& rhs) = delete;
        Data(Data&& rhs) = delete;
        Data& operator = (Data&& rhs) = delete;
        
        uint8_t *data_;
        size_t length_;
    };
    Data::Data(const uint8_t* data, size_t length) :data_(nullptr), length_(0) {
        data_ = new uint8_t [length];
        length_ = (data_ != nullptr)? length: 0;
    }
    
    Data::~Data() {
        if(data_ != nullptr) {
            delete data_; // 잘못 !!!!
            data_ = nullptr;
        }
    }

    실수로 이와 같이 소멸자에서 배열을 삭제하지 않고 하나의 객체만 삭제하여도 정상적으로 컴파일이 이루어지고 프로세스가 실행되지만 메모리 누수가 발생하게 된다. 코드의 양이 많을 경우 메모리 누수를 파악하는데 어려움이 발생한다.

     

    포인터가 객체를 가리키고 있을 때 가리키는 객체가 정확히 한 번 삭제할 수 있다고 확신할 방법이 없다.

    //특정 조건이 유효하지 않다면 해당 메모리를 삭제 
    void delete_if_invalid(char* data, bool valid) {
        if(valid == false) {
            delete [] data;
            data = nullptr;
        }
    }
    
    int main() {
        char *data = new char [20];
        std::memcpy(data, "hello world", 11);
        delete_if_invalid(data, false);
        //delete_if_invalid 에서 삭제가 일어 나지 않았다면 해당 배열 메모리 삭제
        if(data != nullptr)
            delete [] data;
        return 0;
    }
    

    위의 코드는 메모리 관리에서 가장 많이 하는 실수이다. 실제 delete_if_invalid 함수에서 해당 포인트가 가리키는 객체를 지우고 해당 포인터를 널로 설정했기 때문에 해당 함수가 호출된 이후 data 객체가 널이므로 delete[] data;를 호출하지 않는 걸 기대하지만 실제는 delete_if_invalid 에 인수로 전달된 data는 복사되어 전달되기 때문에 두 다른 변수가 동일한 메모리를 가리키고 있지만 두 값은 서로 다르다. 따라서 함수 내 정의는 임시 변수 data 에 널을 할당한 것이지, 실제 main 내 data에 널을 할당한 게 아니므로 delete가 다시 한번 호출되어 예외가 발생하고 프로세스가 종료된다. 이처럼 포인터가 가리키고 있는 메모리가 객체를 잃었는지 알 수 있는 방법이 없다. 

     

    이런 문제점들을 해결하기 위해 C++11 부터 스마트 포인터(Smart Pointer)를 지원하게 되었다. 스마트 포인터에서 핵심은 동적 할당 객체에 소유권 개념을 도입한 것이다. C++11 에서 지원하는 스마트 포인트는 std::auto_ptr, std::unique_ptr, std::shared_ptr, std::weak_ptr 총 네가지 이다. 이들은 적절한 시점에 동적할당된 객체를 컴파일러가 파괴함으로써 자원 누수를 방지하도록 설계되었다. 

    std::auto_ptr은 C++98 에서부터 존재하던 표준 함수로, 완벽하게 std::unique_ptr이 이를 대체하고 있고 사용에 제약이 있으므로  해당 함수는 사용하지 않는게 좋다. 

    std::unique_ptr

    std::unique_ptr은 해당 포인터가 가리키는 자원을 혼자 사용할 때 사용된다. 이를 '소유권을 독점한다.' 라고 말한다. std:unique_ptr은  생 포인터와 동일한 크기를 갖는다고 생각할 수 있고, 생 포인터와 거의 동일한 성능과 연산을 지원한다. std::unique_ptr은 가리키는 자원을 독점적으로 소유하므로 해당 자원의 복사는 불가하므로 오직 소유권 이전을 위한 이동 연산만이 가능하다 . std::unique_ptr은 아래와 같이 두가지로 정의되어 있다. 

    template<class T, class Deleter = std::default_delete<T>>
    class unique_ptr; // 단일 객체용
    
    template <class T, class Deleter>
    class unique_ptr<T[], Deleter>; // 배열용

    std::unique_ptr 사용

    std::unique_ptr은 단일 객체와 배열을 위한 것 두가지 형태가 존재하고 사용 대상에 대해 적합하게 API가 설계되었다. 

    std::unique_ptr<int> p{new int(0)};
    std::cout << *p << std::endl; //단일 객체에 대한 *, -> 지원 
    std::cout << p[0] << std::endl; //단일 객체에 대한 operator []는 지원하지 않아 에러
    
    std::unique_ptr<int[]> p_array {new int[10]};
    p_array[0] = 1; //배열 객체에 대한 [] 지원
    std::cout << p_array[0] << std::endl; //배열 객체에 대한 [] 지원
    std::cout << *p_array << std::endl; //배열 객체에 대한 *는 지원하지 않아 에러

     

    std::unique_ptr은 소유권을 독점하는 목적으로 사용되기도 하지만 std::shared_ptr로의 변환이 쉬워 팩토리 함수의 반환 값으로. 많이 사용한다. 팩토리 함수는 반환된 객체가 소유권을 독점이나 공유 여부를 판단할 수 없으므로 이 함수를 사용하는 쪽에서 소유권을 공유할 때 std::shared_ptr로 쉽게 변환해 사용할 수 있다. 

     

    std::unique_ptr에 삭제자 지정 

    std::unique_ptr은 템플릿을 통해 사용자가 삭제자를 지정할 수 있다.

    #include <iostream>
    #include <cstdio>
    
    class File {
    public:
        File(const char* filename, const char* mode);
        File(const File& rhs) = delete;
        File& operator = (const File& rhs) = delete;
        bool write(const char* data);
    private:
        //사용자 Deleter 타입 - std::fclose(FILE* stream) 
        std::unique_ptr<std::FILE, decltype(&std::fclose)> fp;
    };
    
    // 파일 객체 생성 시 unique_ptr 에 FILE* 할당 및 Deleter 할당 
    File::File(const char *filename, const char *mode)
    :fp(std::fopen(filename, mode), &std::fclose) {
    }
    
    
    bool File::write(const char *data) {
        if(fp == nullptr)
            return false;
        size_t len = fwrite(data, 1, strlen(data), fp.get());
        return len == strlen(data);
    }
    
    int main() {
        File test("text.txt", "w+");
        test.write("hello world");
        return 0;
    }

    File 클래스는 생성 시 파일을 열고 소멸시 Deleter 에 등록된 std::fclose()를 호출해 파일을 닫는다.

     

    std::unique_ptr 을 반환하는 팩터리 함수

    아래는 팩토리 함수에서 std::unique_ptr을 반환하는 예이다 . 이를 통해 생 포인터와 동일하게 접근하지만 메모리 관리는 std::unique_ptr 에 맞길 수 있다. 실제 기본 삭제자를 사용하는 경우에는 std::make_unique<T>()를 이용해 쉽게 객체를 생성할 수 있다. 

    #include <iostream>
    #include <memory>
    class Coffee {
    public:
        virtual ~Coffee(){};
        virtual std::string get_receipe() const = 0;
        friend std::ostream& operator << (std::ostream& oss, const Coffee& coffee) {
            return oss << coffee.get_receipe();
        }
    };
    
    class Americano : public Coffee {
    public:
        std::string get_receipe() const override {
            return "esspresso and water";
        }
    };
    
    class Latte : public Coffee {
    public:
        std::string get_receipe() const override {
            return "esspresso and water and steam milk";
        }
    };
    
    
    std::unique_ptr<Coffee> make_coffee(int type) {
        if(type == 0) {
           //return std::unique_ptr<Americano>(new Americano);
           return std::make_unique<Americano>();
        } else {
           //return std::unique_ptr<Latte>(new Latte);
           return std::make_unique<Latte>();
        }
    }
    
    int main() {
        auto coffee = make_coffee(0);
        std::cout << *coffee << std::endl;
    }

    생포인터를 std::unique_ptr 생성자에 넘기는 경우 문제가 발생할 수 있다. 

    int main() {
        auto americano = new Americano();
        //생 포인터를 사용해 std::unique_ptr을 생성
        auto p_americano0 = std::unique_ptr<Americano>(americano);
        auto p_americano1 = std::unique_ptr<Americano>(americano);
        //두 unique_ptr 이 동일한 객체를 가리키고 있으므로 해당 객체 소멸이 americano를 두 번 메모리 해제로 에러 발생
        //std::unique_ptr 생성자에 생 포인터를 넘기는 일은 피해야함.
        return 0;
    }

    위와 같이 동일한 객체를 가리키는 포인터를 두 std::unique_ptr<Americano> 객체에 넘기는 경우 두 객체가 소멸 시 메모리 해제가 두 번 발생해 예외를 발생한다. 

    실행 결과

    void free_coffee(Coffee* cof) {
        std::cout << "free coffee" << std::endl;
        delete cof;
    }
    
    int main() {
        //std::unique_ptr<T> 생성 시 객체 생성 
        auto p_latte0 = std::unique_ptr<Latte>(new Latte());
        
        //편리 함수 std::make_shared를 사용을 권장함 
        auto p_latte1 = std::make_unique<Latte>();
        
        //커스텀 삭제자를 사용하는 경우 std::make_shared<T>()는 사용할 수 없음
        auto p_latte2 = std::unique_ptr<Latte, decltype(&free_coffee)>(new Latte(), &free_coffee);
        return 0;
    }

    std:unique_ptr<T> 객체 생성 시 커스텀 삭제자를 사용하지 않는다면 std::make_unique<T> 사용을 권장한다. 

     

    다음에는 자원을 공유 할 때 사용 가능한 std::shared_ptr을 살펴보고 팩토리 함수에서 반환된 std::unique_ptr을 어떻게 std::shared_ptr로 변환하고 자원의 소유권을 공유할 수 있는 지에 대해서 다룬다. 

     

     

    댓글

Designed by Tistory.