[전문가를 위한 C++] 7장. 메모리 관리

Part 3. 전문가답게 C++ 코딩하기

Chapter 7. 메모리 관리

  • 이 챕터의 포인트는 7.4 스마트 포인터, 7.5 흔히 발생하는 메모리 문제이다. 시간이 없다면 이 부분 부터 보자.

7.1 동적 메모리 다루기

메모리 작동 과정 살펴보기

  • 항상 포인터 변수는 선언하자마자 nullptr 혹은 적절한 포인터로 초기화 해야 한다.

메모리 할당과 해제

  • new의 리턴값을 무시하거나 그 포인터를 담았던 변수가 스코프를 벗어나면 할당했던 메모리에 접근할 수 없고, 이를 메모리 누수(메모리 릭)라 부른다.

    int leaky() {
      new int;  // 버그! 메모리 누수가 발생했다.
    }
    
  • new로 메모리를 할당할 때 스마트 포인터가 아닌 일반 포인터로 저장했다면 반드시 그 메모리를 해제하는 delete문을 new와 짝을 이루도록 작성해야 한다.
  • 메모리를 해제한 포인터는 nullptr로 다시 초기화 한다.

     int *ptr = new int;
     delete ptr;
     ptr = nullptr;
    
  • malloc() vs new
    • malloc() 함수는 메모리에서 일정한 영역만 따로 빼놓을 뿐 객체에 대해 알지 못하지만,
    • new 연산자는 적절한 크기의 메모리 공간이 할당 될 뿐 아니라 생성자를 호출해 객체를 생성한다.

배열

  • 네 개의 Simple 객체로 구성된 배열을 할당하면 생성자가 4번 호출된다. 따라서 반드시 delete[]를 호출해서 메모리를 해제해야 한다.

    Simple* mySimpleArray = new Simple[4];
    delete[] mySimpleArray;
    mySimpleArray = nullptr;
    
  • 각 원소가 가리키는 객체가 있다면 일일이 해제해 줘야 한다.

    const size_t size = 4;
    Simple** mySimplePtrArray = new Simple*[size];
    for (size_t i = 0; i < size; i++) { 
      mySimplePtrArray[i] = new Simple();
    }
    
    // 할당된 객체 해제
    for (size_t i = 0; i < size; i++) {
      delete mySimplePtrArray[i];
    }
    
    // 배열 삭제
    delete[] mySimplePtrArray;
    mySimplePtrArray = nullptr;
    

포인터에 대한 타입 캐스팅

  • 포인터는 단지 메모리 주소(화살표)에 불과해서 타입을 엄격하게 따지지 않는다.
    • xml 문서를 가리키는 포인터와 정수를 가리키는 포인터의 크기는 같다.
  • 포인터는 C Style 캐스팅을 이용해 바꿀 수 있다.

    Document* doc = getDocument();
    char *chardoc = (char *)doc;
    
  • 정적 캐스팅을 사용하면 더 안전하게 캐스팅이 가능하다.

    Document* doc = getDocument();
    char *chardoc = static_cast<char *>(doc);
    

7.2 배열과 포인터의 두 얼굴

배열 == 포인터

포인터가 모두 배열은 아니다!

  • 모든 배열은 포인터로 참조할 수 있지만, 모든 포인터가 배열은 아니다.

7.3 로우레벨 메모리 연산

포인터 연산

커스텀 메모리 연산

가비지 컬렉션

객체 풀

7.4 스마트 포인터

  • C++는 스마트 포인터를 지원하는 기능을 언어 차원에서 다양하게 제공한다.
    • 템플릿 : 모든 포인터 타입에 대해 안전한(타입 세이프한) 스마트 포인터 클래스를 작성할 수 있다.
    • 연산자 오버로딩 : 스마트 포인터 객체에 대한 인터페이스를 제공해서 스마트 포인터를 일반 포인터처럼 사용할 수 있다. (*, ->를 오버로딩하면 스마트 포인터를 일반 포인터처럼 역참조할 수 있다.)
  • 헤더 파일에 정의되어 있다.

unique_ptr

  • 리소스에 대한 고유 소유권을 받는 스마트 포인터.
  • 스코프를 벗어나거나 리셋되면 참조하던 리소스를 해제한다.
  • unique_ptr 생성 방법

    void notLeaky() {
      auto mySimpleSmartPtr = make_unique<Simple>();
      mySimpleSmartPtr->go();
      // delete를 호출하지 않아도 된다. 
      // unique_ptr 인스턴스가 스코프를 벗어나면 (함수가 끝나거나 익셉션이 발생하면) 소멸자가 호출될 때 객체가 자동으로 해제되기 때문.
    }
    
    • make_unique()를 지원하지 않는 컴파일이라면 다음과 같이 unique_ptr을 생성한다.

      unique_ptr<Simple> mySimpleSmartPtr(new Simple());
      
    • C++ 17 이전에는 반드시 make_ptr을 사용해야 했다. (C++ 17부터는 둘 다 안전하다.)

      // Simple이나 Bar의 생성자 또는 data()함수에서 익셉션이 발생하면 메모리 누수가 발생할 수 있다.
      foo(unique_ptr<Simple>(new Simple()), unique_ptr<Bar>(new Bar(data())));
      
      // make_unique를 사용하면 메모리 누수가 발생하지 않는다.
      foo(make_unique<Simple>(), make_unique<Bar>());
      
  • unique_ptr 사용 방법
    • get() 메서드를 이용하면 내부 포인터에 직접 접근할 수 있다.

      void processData(Simple* simple) {...}
      
      auto mySimpleSmartPtr = make_unique<Simple>();
      processData(mySimpleSmartPtr.get());
      
    • reset()을 사용하면 unique_ptr의 내부 포인터를 해제하고, 다른 포인터로 변경할 수 있다.

      mySimpleSmartPtr.reset();  // 리소스 해제 후, nullptr로 초기화
      mySimpleSmartPtr.reset(new Simple());  // 리소스 해제 후, 새로운 Simple 인스턴스로 설정
      
    • release()를 이용하면 unique_ptr와 내부 포인터의 관계를 끊을 수 있다. 리소스에 대한 내부 포인터를 리턴한 뒤 스마트 포인터를 nullptr로 설정한다. 스마트 포인터는 리소스에 대한 소유권을 잃기 때문에 리소스를 다 쓴 뒤 반드시 직접 해제해야 한다.
      Simple* simple = mySimpleSmartPtr.release();  // 소유권을 해제한다.
      delete simple;
      simple = nullptr;
      
    • unique_ptr은 단독 소유권을 표현하기 때문에 복사할 수 없다. 하지만 std::move() 유틸리티를 사용해 이동할 수는 있다.

      class Foo {
        public:
          Foo (unique_ptr<int> data) : mData(move(data)) {}
        private:
          unique_ptr<int> mData;
      };
      
      auto myIntSmartPtr = make_unique<int>(42);
      Foo f(move(myIntSmartPtr));
      
  • C stype 배열
  • 커스텀 제거자

    int* malloc_int(int value) {
      int* p = (int*)malloc(sizeof(int));
      *p = value;
      return p;
    }
    
    int main() {
      unique_ptr<int, decltype(free)*> myIntSmartPtr(malloc_int(42), free);
      return 0;
    }
    

shared_ptr

  • 리소스의 소유자를 추적하도록 레퍼런스 카운팅을 구현한 스마트 포인터.
  • 레퍼런스 카운터 (공유 소유권)가 0이 되면 그 리소스를 사용하는 곳이 없기 때문에 스마트 포인터에 의해 자동으로 해제된다.
  • 생성 및 사용 방법

    auto mySimpleSmartPtr = make_shared<Simple>();
    
    • unique_ptr과 같이 get(), reset() 메서드를 지원한다.
      • reset()는 마지막 shared_ptr가 제거되거나 리셋될 때 리소스가 해제된다.
    • release()는 지원하지 않는다.
    • 현재 동일한 리소스를 공유하는 shared_ptr의 개수는 use_count()로 알 수 있다.
    • 메모리 할당 및 해제는 new와 delete 연산자를 사용할 수도 있다. (커스텀 제거자)

      shared_ptr<int> myIntSmartPtr(malloc_int(42), free);
      
  • shared_ptr 캐스팅하기
    • const_pointer_cast()
    • dynamic_pointer_cast()
    • static_pointer_cast()
    • reinterpret_pointer_cast()
  • 레퍼런스 카운팅이 필요한 이유
    • 중복 삭제 문제
      • shared_ptr 복사본을 만들어서 사용해야 한다.
      // 중복 삭제
      void DoubleDelete() {
        Simple* mysimple = new Simple();
        shared_ptr<Simple> smartPtr1(mysimple);
        shared_ptr<Simple> smartPtr2(mysimple);
      }
      
      // 복사본으로 중복삭제 문제 해소
      void nonDoubleDelete() {
        auto smartPtr1 = make_shared<Simple>();
        shared_ptr<Simple> smartPtr2(smartPtr1); 
      }
      
      • 참고로 unique_ptr은 복사 생성자를 지원하지 않음을 기억하자.
  • 앨리어싱
    • shared_ptr은 하나의 포인터를(소유한 포인터를) 다른 shared_ptr과 공유하면서 다른 객체(저장된 포인터)를 가리킬 수 있다.
      • ex) shared_ptr가 객체를 가리키는 동시에 객체의 멤버도 가리킬 수 있다.
        class Foo {
          public:
            Foo (int value) : mData(value) {}
            int mData;
        };
        
        auto foo = make_shared<Foo>(42);
        auto aliasing = shared_ptr<Foo>(foo, &foo->mData);
        // 두 shared_ptr(foo, aliasing)가 모두 삭제될 때만 Foo 객체가 삭제된다.
        

weak_ptr

  • 언제 사용할까?
    • weak_ptr은 shared_ptr 관리용. 레퍼런스 카운트에 포함되지 않는다.
      • 실은 weak reference count를 증가시키지만 이는 객체 수명과 관련이 없다.
  • 어떻게 사용할까?
    • weak_ptr 생성자shared_ptr 혹은 다른 weak_ptr를 인수로 받는다.
    • weak_ptr에 저장된 포인터에 접근하려면 shared_ptr로 변환해야 한다.
      • weak_ptr 인스턴스의 lock() 메서드를 사용해 shared_ptr을 리턴받는다.
      • shared_ptr 생성자에 weak_ptr을 인수로 전달해 shared_ptr을 새로 생성한다.
  • 사용 예제

이동 의미론

  • 표준 스마트 포인터인 unique_ptr, shared_ptr, weak_ptr은 모두 성능 향상을 위해 이동 의미론을 지원 한다.

enable_shared_from_this

  • 믹스인 클래스 enable_shared_from_this에서는 객체의 메서드에서 shared_ptr, weak_ptr을 안전하게 리턴할 수 있다.
  • 대표적인 메서드는 다음과 같다.
    • shared_from_this() : 객체의 소유권을 공유하는 weak_ptr을 리턴.
    • weak_from_this() : 객체의 소유권을 추적하는 weak_ptr을 리턴.
  • 사용 방법
    • 객체의 포인터가 shared_ptr에 이미 저장된 상태에서만 shared_from_this()를 호출할 수 있음에 주의.
    • 다음과 같이 getPointer() 메서드를 구현하면 중복 삭제 문제가 발생한다. image

7.5 흔히 발생하는 메모리 문제

스트링 과소 할당 문제

메모리 경계 침범

메모리 누수

  • 스마트 포인터를 사용하자.

    // 포인터 변수가 레퍼런스로 전달되어 포인터에 다른 값을 할당.
    void doSomething(Simple*& ptr) {
      ptr = new Simple(); // Leak!
    }
    
    int main() {
      Simple* simplePtr = new Simple();
      doSomething(simplePtr);
      delete simplePtr;
      return 0;
    }
    

중복 삭제와 잘못된 포인터