C++ 동적 메모리

6 분 소요

동적 메모리

C++는 C와 마찬가지로 프로그래머가 모든 상황을 잘 알고 있다고 여기고 자유를 최대한 보장한다. 언어 자체가 굉장이 유연하다. 그리고 안정성이 떨어지는 것을 감수하고 성능을 추구하기 때문에 심각한 문제가 발생할 가능성이 있는 일도 얼마든지 할 수 있다. 특히 메모리 할당과 관리는 C++프로그램에서 문제가 많이 발생하는 부분중에 하나이다. 더 완벽한 C++프로그래머가 되려면 메모리 관리의 작동 방식을 정화히 이해하고 있어야 한다. C와는 다르게 C++에는 컨테이너가 있어서 메모리 관리를 알아서 해주는것을 선호한다. 또한 자동으로 메모리를 해제해주는 스마트 포인터를 사용하기도 한다. 즉 newdelete등을 사용하여 메모리를 직접 할당 및 해제하는 코드는 작성하지 않는것이 좋다.
NOTE_ low level 메모리 연산은 C++에서 피하는 것이 좋다. 컨테이너나 스마트 포인터와 같은 신문물을 사용하는것이 추세이다.

스마트 포인터

C++에서 메모리 관리는 에러와 버그가 끊임없이 샘솟는다. 샘솟을 때마다 본좌의 대뇌 전두엽까지 그 여파가 밀려온다. 메모리 관리와 관련된 버그 중 상당수는 동적 메모리 할당과 포인터에서 발생한다. 메모리를 동적으로 할당하는 일이 많아서 객체끼리 수많은 포인터를 주고받다 보면 각 포인터에 대해 정확한 시점에 delete를 단 한번만 호출하지 못하는 실수가 발생할 수 있다. 이떄 동적으로 할당한 메모리를 여러번 해제하면 메모리 상태가 손상되거나 치명적인 에러로 프로그램은 요단강을 건널 수 있다. 또한 할당한 메모리를 해제하지 않으면 나의 메모리는 가득차게 될 것이다.
스마트 포인터를 사용하면 동적으로 할당한 메모리를 보다 편하게 관리할 수 있게 해준다. 스맡 포인터는 스코프를 벗어나면 할당된 리소스가 자동으로 해제되기 때문에 아주 매력적인 놈이다. 함수 스코프 안에서 동적으로 할당된 리소를 관리하는 데 사용할 수도 있고, 클래스의 데이터 멤버로 사용할 수도 있다. 동적으로 할당된 리소스의 소유권을 함수의 인수로 넘겨줄 때도 스마트 포인터를 활용한다. C++는 스마트 포인터를 지원하는 기능을 언어 차원에서 다양하게 제공한다.
  • 템플릿을 이용하면 모든 포인터 타입에 대해 타입에 안전한(type safe) 스마트 포인터 클래스를 작성 할 수 있다.
  • 연산자 오버로딩을 이용하여 스마트 포인터 객체에 대해 인터페이스를 제공해서 스마트 포인터 객체를 일반 포인터처럼 활용할 수 있다. 특히 *-> 연산자를 오버로딩하면 스마트 포인터 객체를 일반 포인터처럼 역참조할 수 있다.
CAUTION_ 리소스를 할당한 결과를 절대로 일반 포인터로 표현하면 안된다. 몰매를 맞을수도 있다. 어떤 방식으로 리소스를 할당 했든 반드시 그 결과를 곧바로 unique_ptr이나 shared_ptr과 같은 스마트 포인터에 저장하거라 다른 RAII 클래스를 사용한다.
NOTE_ RAII는 Resource Acquistion Is Initialization(리소스 획득 = 초기화)의 줄임말이다. RAII 클래스는 어떤 리소스의 소유권을 받아서 이를 적잘한 시점에 해제하는 작업을 수행한다.

unique_ptr

동적으로 할당한 리소스는 항상 unique_ptr과 인스턴스에 저장하는 것이 바람직하다. 메모리 누수는 다음과 같은 코드에서 일어난다.
void func()
{
    Simple* mySimplePtr = new Simple();
    mySimplePtr -> go();
}
할당은 했지만 해제를 하지 않했다. 하지만 이 상태에서 해제를 한다고 해도 메모리 누수를 완전히 해결한다고 볼 수 없다. 아래 예를 봐보자
void func()
{
    Simple* mySimplePtr = new Simple();
    mySimplePtr -> go();
    delete mySimplePtr;
}
해제를 delete로 잘한 것 같지만 사실 mySimplePtr -> go(); 코드에서 exception이 발생하면 delete가 되지 않는다. 이 문제를 명확히 해줄해줄 놈이 unique_ptr이다. unique_ptr은 인스턴스가 exception이나 scope를 벗어나면 소멸자가 호출 될 때 객체를 자동으로 해제된다.
void func()
{
    auto simpleSmartPointer = make_unique<Simple>();
    simpleSmartPointer->go();
}
이렇게 코드를 작성하면 메모리 누수 문제와 exeption 문제를 해결 가능하게 한다. 만약 Simple 생성자에서 매개변수를 받는다면 make_unique() 호출문 소괄호에 지정하면 된다.
auto simpleSmartPointer = make_unique<Simple>(10, 20);
NOTE_ unique_ptr을 생성할 때는 항상 make_unique()를 사용한다.

Example

표준 스마트 포인터의 대표적인 장점은 문법을 새로 익히지 않고도 향상된 기능을 누릴 수 있다는 것이다. 스마트 포인터는 일반 포인터와 똑깥이 *>로 역참조한다. 예를 들자면 다음과 같다.
auto simpleSmartPointer = make_unique<Simple>(10, 20);
simpleSmartPointer->go();
(*simpleSmartPointer).go(); // 일반 포인터처럼 사용할 때
만약 일반 포인터만 전달할 수 있는 함수에 스마트 포인터를 전달할 때 get() 메서드를 사용하면 된다.
void func(Simple* simple) {}

int main(int argc, char *argv[])
{
    auto simpleSmartPointer = make_unique<Simple>(10, 20);
    func(simpleSmartPointer.get());
}
그리고 다른 포인터로 변경하고자 하거나 내부 포인터를 해제할때는 reset() 메서드를 사용하면 된다.
simpleSmartPointer.reset();             // 리소스 해제 후 nullptr로 초기화
simpleSmartPointer.reset(new Simple()); // 리소스 해제 후 새로운 Simple 인스턴스로 설정
release() 라는 메서드도 있는데 이를 이요하면 내부 포인터의 관계를 끊을 수 있다. realse() 메서드는 리소스에 대한 내부 포인터를 리턴한 뒤 스마트 포인터를 nullptr로 설정한다. 그러면 스마트 포인터는 그 리소스에 대한 소유권을 잃는다. 그리고 리소스를 다 쓴 뒤 반드시 직접 해제해야 한다.
Simple* simple = simpleSmartPointer.release(); // 소유권을 해제한다.
delete simple; // 반드시 해제해야 한다.
simple = nullptr;
unique_ptr은 단독 소유권을 표현하기 때문에 복사할 수 없다. std::move()를 사용하면 하나의 unique_ptr을 다른 곳으로 이동할 수 있는데, 복사가 아닌 이동의 개념이다. 다음과 같이 클래스로 소유권을 명시적으로 이전하는 용도로 많이 사용한다.
class Container
{
    public:
        Container(unique_ptr&lt;int&gt; data) : _data(std::move(data)) {}
    private:
        unique_ptr&lt;int&gt; _mdata;
};

int main(int argc, char *argc[])
{
    auto simpleSmartPointer = std::make_unique&lt;int&gt;(42);
    Container container(std::move(simpleSmartPointer));
}
C 스타일 배열을 사용할 때는 다음과 같이 사용 가능하다.
auto arr = std::make_unique<int[]>(10);
이렇게 C 스타일 동적 할당 배열을 저장할 수는 있지만, 이보다는 std::array나 std::vector와 같은 표준 라이브러리 컨테이너를 사용하는 것이 바람직하다.

shared_ptr

shared_ptr의 사용법은 uniquee_ptr과 비슷하다. shared_ptr은 make_shared()로 생성한다. 이렇게 하는것이 shared_ptr을 직접 생성하는 것보다 훨씬 효율적이다. 그리고 shared_ptr을 생성할 때는 만드시 make_shared()를 사용하는 것을 유의하자.
auto mySimpleSmartPtr = make_shared<Simple>();
C++17부터 shared_ptr도 unique_ptr과 마찬가지로 기존 C 스타일 동적 할당 배열에 대한 포인터를 저장할 수 있다. C++17 이전에는 이렇게 할 수 없었다. 하지만 C++17에서 지원한다고 하더라도 여전히 C스타일 배열보다는 표준 라이브러리 컨테이너를 사용하는 것이 바람직하다. std::vector 나 std::array과 같은 컨테이너를 사용하도록 하자!
shared_ptr도 unique_ptr처럼 get()과 reset() 메서드를 제공한다. 다른 점은 reset()을 호출하면 레퍼런스 카운팅 메커니즘에 따라 마지막 shared_ptr이 제거되거나 리셋될 때 리소스가 해제된다. 참고로 shared_ptr은 release()를 지원하지 않는다. 현재 동일한 리소스를 공유하는 shared_ptr의 개수는 use_count()로 알아낼 수 있다.

reference counting

레퍼런스 카운팅은 어떤 클래스의 인스턴스 수나 현재 사용 중인 특정한 객체를 추적하는 메커니즘이다. 레퍼런스 카운팅을 지원하는 스마트 포인터는 실제 포인터를 참조하는 스마트 포인터 수를 추적한다. 그래서 스마트 포인터가 중복 삭제되는 것을 방지한다. 아래 예를 확인해보자!
#include <iostream>
#include <memory>

class Simple
{
    public:
    Simple()
    {
        std::cout << "Constructor called" << std::endl;
    }
    ~Simple()
    {
        std::cout << "Destructor called" << std::endl;
    }
};

void doubleDelete()
{
    Simple *mySimple = new Simple();
    std::shared_ptr<Simple> smartPtr1(mySimple);
    std::shared_ptr<Simple> smartPtr2(mySimple);
}

int main(int argc, char *argv[])
{
    doubleDelete();

    return 0;
}
본좌의 위대한 컴퓨터에서는 다음과 같은 결과가 출력되었다.
Constructor called
Destructor called
Destructor called
shared_ptr_ex1(28774,0x104b00580) malloc: *** error for object 0x600001e74040: pointer being freed was not allocated
shared_ptr_ex1(28774,0x104b00580) malloc: *** set a breakpoint in malloc_error_break to debug
[1] 28774 abort ./shared_ptr_ex1
생성자는 한 번 호출되고 소멸자는 두번 호출되는 현상이 발생하였다. unique_ptr로 작성할 때도 똑같은 문제가 발생한다. 이렇게 사용하는 것이 아니라 복사본을 만들어 사용해야 한다.
#include <iostream>
#include <memory>

class Simple
{
    public:
    Simple()
    {
        std::cout << "Constructor called" << std::endl;
    }
    ~Simple()
    {
        std::cout << "Destructor called" << std::endl;
    }
};

void doubleDelete()
{
    Simple *mySimple = new Simple();
    std::shared_ptr<Simple> smartPtr1(mySimple);
    std::shared_ptr<Simple> smartPtr2(smartPtr1);
}

int main(int argc, char *argv[])
{
    doubleDelete();

    return 0;
}
그러면 이렇게 출력된다.
Constructor called
Destructor called
shared_ptr 두 개가 한 Simple 객체를 동시에 가리키더라도 Simple 객체는 딱 한 번만 삭제된다. 참고로 unique_ptr은 레퍼런스 카운팅을 지원하지 않는다. 왜냐면, unique_tr은 복제 생성자(copy constructor)를 지원하지 않기 때문이다.
NOTE_ 중복 삭제를 방지하는 스마트 포인터를 직접 구현하기 보다는 표준 shared_ptr 템플릿으로 리소스를 공유하는 것이 바람직하다. 가능하면 복제 생성자를 사용한다.

Aliasing

shared_ptr 은 앨리어싱 (aliasing)을 지원한다. 한 포인터(소유한 포인터)를 다른 shared_ptr과 공유하면서 다른 객체(저장된 포인터)를 가리킬 수 있다. 예를 들어서 shared_ptr이 객체를 가리키는 동시에 그 객체의 멤버도 가리키게 할 수 있다. 코드로 표현하면 아래와 같아.
#include <iostream>
#include <memory>

class Simple
{
    public:
    Simple(int data) : _data(data)
    {
        std::cout << "Constructor called" << std::endl;
    }
    ~Simple()
    {
        std::cout << "Destructor called" << std::endl;
    }
    int _data;
};

void doubleDelete()
{
    Simple *mySimple = new Simple(20);
    std::shared_ptr<Simple> smartPtr1(mySimple);
    std::shared_ptr<Simple> smartPtr2(smartPtr1);
    auto aliasing = std::shared_ptr<int>(smartPtr2, &smartPtr2->_data);

    std::cout << "data: " << *aliasing << std::endl;
}

int main(int argc, char *argv[])
{
    doubleDelete();

    return 0;
}
결과는 아래와 같다.
Constructor called
data: 20
Destructor called
여기서 shared_ptr 3개가 모두 삭제될 때 Simple 객체가 삭제된다. 소유한 포인터는 레퍼런스 카운팅에 사용하는 반면, 저장된 포인터는 포인터를 역참조하거나 그 포인터에 대해 get()을 호출할 때 리턴된다. 저장된 포인터는 비교 연산을 비롯한 대부분의 연산에 적용할 수 있다.

참고