8 minute read

‘전문가를 위한 C++ - Marc Gregoire 지음, 남기혁 옮김’ 책을 참고하여 작성한 포스트입니다.


모던 C++ 코드에서는 로우레벨 메모리 연산은 가급적 피하고 컨테이너나 스마트 포인터와 같은 최신 기능을 활용하는 추세다.

동적 메모리 다루기

메모리의 작동 과정

int i { 7 };

int* ptr { nullptr };
ptr = new int;
// 혹은 두 줄을 합쳐
int* ptr { new int };

int** handle { nullptr };
handle = new int*;
*handle = new int;
  • 로컬 변수 i를 자동 변수(automatic variable)라고 하며, 스택에 저장된다.
    • 프로그램의 실행 흐름이 이 변수가 선언된 스코프(scope, 유효범위)벗어나면 할당된 메모리가 자동으로 해제 된다.
  • new키워드를 사용하면 프리 스토어(free store, 자유 공간)에 메모리가 할당된다.
  • 위에서는 ptr 변수를 스택에 생성하고 nullptr로 초기화한 뒤,
  • 프리스토어에 할당된 메모리를 가리키게 했다.
  • 포인터는 스택, 프리스토어 둘 다 존재 가능하지만, 동적 메모리는 항상 프리스토어에 할당된다.
  • 포인터를 동적 할당하면 프리스토어에 존재한다.

포인터 변수는 항상 선언 후 nullptr이나 적절한 포인터로 초기화 해주어야 한다!

메모리 할당과 해제

  • new 키워드와 delete 키워드를 통해 할당 및 해제를 한다.

    new와 delete 사용법

  • new 키워드로 할당 후 리턴값을 무시하거나, 해당 포인터를 담았던 변수가 스코프를 벗어나게 되면 할당된 메모리에 접근할 수 없게 된다.
  • 이를 미아(orphan)가 됐다 혹은 메모리 누수(메모리 릭, memory leak)가 발생했다 고 한다.
  • 이를 방지하기 위해 스마트 포인터가 아닌 일반 포인터로 저장하는 경우 반드시 delete 문을 이어서 작성해 주어야한다.
  • 그리고 메모리를 해제한 포인터는 꼭 nullptr로 초기화 해주어야, 나중에 이 포인터를 호출해도 문제가 발생하지 않는다.

malloc()

  • malloc은 객체를 만들지 않는다.
  • C++에서는 절대 사용하지 않아야 한다.

메모리 할당에 실패한 경우

  • new는 메모리 부족 등으로 메모리 할당에 실패한 경우 예외를 던진다.
  • nothrow를 new의 인수로 넣으면 예외를 던지지 않고 nullptr을 반환한다.
int* ptr { new(nothrow) int };
  • 예외를 안던지니 버그가 발생할 확률이 더 높다.

배열(array)

기본 타입 배열

  • 배열에 할당한 메모리는 실제로도 연달아 붙어 있다.
  • 메모리 한칸의 크기는 원소 하나를 담을 수 있는 크기다.
int MyArray[3] { 1, 2, 3 }; // [1, 2, 3]
int MyArray[3] { 1, 2 }; // [1, 2, 0]
int MyArray[3] { 0 }; // [0, 0, 0]
int MyArray[3] {}; // [0, 0, 0]
int MyArray[] {1, 2} // [1, 2]

// 배열을 프리스토어에 선언
int* MyArrayPtr { new int[5] }; 
int* MyArrayPtr { new int[] {1, 2, 3} };
int* MyArrayPtr { new int[ArraySize] }
delete[] MyArrayPtr; // 원래는 각각 해주어야 함!
MyArrayPtr = nullptr;
  • 배열을 정적할당하면 컴파일 시간에 배열의 크기가 정해지지만,
  • 동적으로 할당하면 실행 시간에 정할 수 있다.
  • 위의 배열은 동적으로 할당된 배열(dynamically allocated array) 이다.
  • 이는 동적 배열(dynamic array)과는 다르다! 배열을 할당하고 나면 원소 개수가 변하지 않기 때문!
  • C에 realloc()이 있지만(배열의 크기를 동적으로 조절함) 위험하니 사용하지 않는 것이 좋다.

객체 배열

  • 객체 배열도 기본 타입 배열과 비슷하다.
  • new[] 호출하면 배열을 구성하는 각 객체마다 영 인수(zero-argument, 디폴트) 생성자가 호출된다.
  • 기본 타입 배열의 경우 디폴트로 원소를 초기화하지 않는다?

배열 삭제

  • delete[]로 삭제해야 한다.
  • 포인터 배열인 경우는 각 원소가 가리키는 객체를 일일이 해제해야 한다!

다차원 배열

  • 프로스토어에서는 메모리 공간이 연속적으로 할당되지 않기 때문에 스택 방식의 다차원 배열처럼 메모리 할당을 할 수 없다.
char** AllocateCharBoard(size_t XDimension, size_t YDimension)
{
    // 첫 번째 차원의 배열 할당
    char** MyArray { new char*[XDimension] };
    for (size_t i = 0; i < XDimension; i++)
    {
        // i번째 하위 배열 할당
        MyArray[i] = new char[YDimension];
    }
    return MyArray;
}
  • 위 코드처럼 할당 해주어야 한다.

복잡하니까 std::array 나 std::vector 사용하자!

포인터 다루기

포인터에 대한 타입 캐스팅

  • 포인터는 메모리 주소에 불과하므로 타입을 엄격히 따지지 않는다.
  • c 스타일 캐스팅((type))으로 바꿀 수 있지만,
  • 정적 캐스팅(static cast)을 사용해야 좀 더 안전하다.
  • 관련 없는 데이터 타입으로 포인터를 캐스팅하면 컴파일 에러가 발생하기 때문.
  • 상속 관계가 있을 때 컴파일 에러를 던지지 않지만, 이 경우 동적 캐스팅(dynamic cast)을 사용해 주는 것이 더 안전하다.(자세한 것은 10장)



배열과 포인터의 두 얼굴

배열 == 포인터

  • 프리스토어 뿐 아니라 스택 배열에 접근할 때도 포인터 사용 가능하다.
  • 배열의 주소는 첫 번째 원소에 대한 주소다.
  • 스택 배열을 함수에 넘길 때 포인터가 유용하다. 이 때 배열의 크기도 같이 넘겨주어야 한다.
  • 함수에 스택 배열 변수가 들어갈 때 컴파일러가 배열에 대한 포인터로 변환해 준다. 혹은 직접 첫 번째 배열의 주소를 넘겨도 된다.
  • 프리스토어 배열의 경우는 이미 포인터이므로 그대로 들어간다.
  • 함수에서 배열을 인수로 받는 경우 레퍼런스 전달 방식으로 전달 되므로, 함수에 전달되는 값은 원본을 가리키는 주소이다.
void DoubleInts(int* IntArray, size_t Size)
{
    for (size_t i = 0; i < size; i++)
    {
        IntArray[i] *= 2;
    }
}

size_t ArrSize = 4;
int* FreeStoreArray = new int[ArrSize] {1, 2, 3, 4};
DoubleInts(FreeStoreArray, ArrSize);
delete[] FreeStoreArray;
FreeStoreArray = nullptr;

int StackArray[] = {5, 6, 7, 8};
ArrSize = std::size(StackArray); // <array>
DoubleInts(StackArray, ArrSize);
DoubleInts(&StackArray[0], ArrSize); // 대괄호 안에 숫자가 안들어가도, 2같은 다른 숫자가 들어가도 상관 없다.



로우레벨 메모리 연산

포인터 연산(pointer arithmetic)

  • 포인터를 int로 선언하고 그 값을 1만큼 증가시키면 포인터는 메모리에서 1바이트가 아닌 int 크기만큼 이동한다.
  • 타입의 크기만큼 이동하므로 배열을 다루는 데 유용하다.
  • 개별 원소에 접근하는 방법으로 좋은 것은 아니지만, 부분 배열을 표현하는 데 유용하다.
  • *(MyArray + 2) 면 원래 배열의 3번째 원소부터 시작하는 배열을 가리키는 포인터가 될 것이다.
  • 그리고 배열 내의 두 포인터를 서로 빼고 타입의 크기 만큼 나누면 두 포인터 사이의 원소 개수를 알 수 있겠죠

가비지 컬렉션(garbage collection)

  • 스마트 포인터는 가비지 컬렉션과 비슷한 방식으로 메모리를 관리한다.
  • 가비지 컬렉션을 구현하는 기법 중 표시 후 쓸기(mark and sweep)가 있는데, 프로그램 내 모든 포인터를 주기적으로 검사해 참조하는 메모리를 계속 사용하는 지 여부를 표시하는 것이다. 아무런 표시가 없는 메모리는 더 이상 사용하지 않는 것으로 간주하고 해제하는 것이다.



흔히 발생하는 메모리 관련 문제

데이터 버퍼 과소 할당과 경계를 벗어난 메모리 접근

  • 스트링의 끝을 나타내는 널 문자를 할당하지 않는 경우 과소 할당(underallocation) 문제가 생긴다.
  • 배열의 경계를 벗어난 메모리 영역에 쓰는 버그를 버퍼 오버플로 에러(buffer overflow error)라고 한다.

메모리 누수(memory leak)

비주얼 C++를 이용한 윈도우 애플리케이션의 메모리 누수 탐지 및 수정

  • 나중에 추가

    밸그린드를 이용한 리눅스 애플리케이션의 메모리 누수 탐지 및 해결 방법

  • 나중에 추가

중복 삭제와 잘못된 포인터

  • 포인터에 할당된 메모리를 delete로 해제하고 나서도 포인터는 해당 메모리를 계속 가리키고 있다.(댕글링 포인터(dangling pointer))
  • 나중에 해제된 메모리를 다른 데이터로 쓰게 됐을 때 이전에 할당한 포인터를 delete 호출하면 의도치 않은 데이터 삭제가 이루어 질 수 있다.
  • 그래서 꼭 해제 후에는 nullptr로 초기화 해주는 것이 좋다.



스마트 포인터(smart pointer)

  • 동적으로 할당된 메로리를 쉽게 관리할 수 있게 해준다.
  • 기본적으로 메모리 뿐 아니라 동적으로 할당한 모든 리소스를 가리킨다.
  • 스마트 포인터가 스코프를 벗어나거나 리셋되면 할당된 리소스가 자동으로 해제된다.
  • 함수 스코프 안에서 동적으로 할당된 리소스를 관리하는 데 사용할 수도 있고, 클래스의 데이터 멤버로 사용할 수도 있다.
  • 동적으로 할당된 리소스의 소유권을 함수의 인수로 넘겨줄 때도 활용 가능하다.
  • 그리고 템플릿을 이용해 타입에 안전한(type-safe) 스마트 포인터 클래스 이용도 가능하다.
  • 연산자 오버로딩을 통해 스마트 포인터 객체를 일반 포인터처럼 활용도 가능하다.

unique_ptr

  • 리소스에 대해 단독 소유권(unique ownership)을 가진다.
  • 스코프를 벗어나거나 리셋되면 참조하던 리소스를 자동으로 해제한다.
  • return 이나 예외 발생 시에도 해제된다.

    생성 방법

  • unique_ptr은 클래스 템플릿으로 구현되어 모든 종류의 메모리를 가리킬 수 있는 범용 스마트 포인터다.
  • std::make_unique() 헬퍼 함수를 통해 생성 가능하다.
  • 소괄호 안에는 생성자 매개변수를 넣으면 된다.
  • 값 초기화(value initailization)를 사용하므로, 기본 타입은 0으로 초기화 되고, 객체는 디폴트로 생성된다.
  • CTAD(class template argument deduction, 클래스 템플릿 인수 추론)를 사용할 수 없으므로 템플릿 타입을 생략할 수 없다.
void NotLeaky()
{
    auto SmartPointer { make_unique<Simple>() };
    SmartPointer->Go();
}
// 아래와 같이 선언해도 된다.
void NotLeaky2()
{
    unique_ptr<Simple> SmartPoint { new Simple{} };
}
  • 가독성 측면에서 std::make_unique() 가 좋아보인다.

사용 방법

  • 일반 포인터와 똑같이 사용하면 된다.
  • 내부 포인터를 해제 혹은 변경하고 싶다면, reset()을 이용한다.
  • 소유권을 해제하고 싶다면 release() 메서드를 이용한다.
  • 단독 소유권을 표현하므로 복사할 수 없다. 단, std::move() 유틸리티 함수를 사용해 이동 의미론을 적용하면 다른 곳으로 이동은 가능하다.(9장)
SmartPointer.reset(); // 리소스 해제 후 nullptr로 초기화
SmartPointer.reset(new Simple{}); // 리소스 해제 후 새로운 Simple 인스턴스로 설정
Simple* SimpleInstance { SmartPointer.release() }; // 소유권 해제
delete SimpleInstance;
SimpleInstance = nullptr;
  • C 스타일의 동적 할당 배열을 저장하는 데 효과적이다.
// 정수 10개를 가진 C 스타일의 동적 할당 배열
auto VariableArray { make_unique<int[]>(10) };

shared_ptr

  • 포인터 하나를 여러 객체나 코드에서 복제해 사용할 때 사용한다.
  • 레퍼런스 카운팅(reference counting) 기법을 사용해 리소스 해제 시점을 알아 낸다.

    사용 방법

  • unique_ptr과 비슷하게 make_shared()로 생성한다.
  • release()는 지원하지 않으며 대신 use_count()를 통해 현재 동일한 리소스를 공유하는 shared_ptr 개수를 알 수 있다.
  • 동일한 일반 포인터를 참조하는 shared_ptr 인스턴스 두 개 이상을 생성하면 생성자는 한번 호출되는데 소멸자는 여러 번 호출되는 문제가 생길 수 있다.
  • 그래서 여러 shared_ptr 인스턴스가 동일한 메모리를 가리키게 할 때는 shared_ptr 인스턴스를 그냥 복제하는 것이 좋다.
Simple* MySimple { new Simple{} };
shared_ptr<Simple> SmartPtr1 { MySimple };
shared_ptr<Simple> SmartPtr2 { MySimple };
// 생성자 한번, 소멸자 두번 호출돼버림

캐스팅(나중에 추가)

  • 제약이 있어 모든 타입을 지원하지는 않지만, unique_ptr과는 달리 캐스팅 함수 사용이 가능하다.

앨리어싱(aliasing)

  • 한 포인터(소유한 포인터, owned pointer)를 다른 shared_ptr과 공유하면서 다른 객체(저장된 포인터, stored pointer)를 가리킬 수 있다.
  • 예를 들어 shared_ptr이 어떤 객체를 소유하는 동시에 그 객체의 멤버도 가리키게 할 수 있다.
class Foo
{
public:
    Foo(int Value) : Data { Value } {}
    int Data;
};

auto FooPtr { make_shared<Foo>(42) };
auto aliasing { shared_ptr<int> { FooPtr, &FooPtr->Data} };
  • 두 shared_ptr(FooPtr 와 aliasing)이 모두 삭제되어야 Foo 객체가 삭제된다.
  • 소유한 포인터는 레퍼런스 카운팅에 사용되지만, 저장된 포인터는 역참조하거나 get()을 호출하면 리턴된다.
  • 저장된 포인터는 대부분의 연산에 적용 가능하다.

weak_ptr

  • shared_ptr이 관리하는 리소스에 대한 레퍼런스를 가질 수 있다.
  • 리소스를 직접 소유하지 않기 때문에 shared_ptr이 해당 리소스를 해제하는 데 아무런 영향을 미치지 않는다.
  • weak_ptr의 생성자는 shared_ptr나 다른 weak_ptr을 인수로 받는다.
  • 그리고 weak_ptr에 저장된 포인터에 접근하려면 shared_ptr로 변환해야 한다.
    1. lock() 메서드를 이용해 shared_ptr을 리턴 받기. 이때 shared_ptr에 연결된 weak_ptr이 해제되면 shared_ptr의 값은 nullptr이 된다.
    2. shared_ptr의 생성자에 weak_ptr을 인수로 전달해 생성하기. 이때 shared_ptr에 연결된 weak_ptr이 해제되면 std::bad_weak_ptr 예외를 던진다.
void UseResource(weak_ptr<Simple>& WeakSimple)
{
    auto Resource { WeakSimple.lock() };
    if (Resource)
    {
        cout << "Resource still alive" << endl;
    }
    else
    {
        cout << "Resource has been freed" << endl;
    }
}

int main()
{
    auto SharedSimple { make_shared<Simple>() };
    weak_ptr<Simple> WeakSimple { SharedSimple };

    // weak_ptr 사용
    UseResource(WeakSimple);

    // reset shared_ptr
    // Simple 리소스에 대한 shared_ptr은 하나뿐이므로
    // weak_ptr이 살아 있더라도 리소스가 해제된다
    Sharedsimple.reset();

    // use weak_ptr one more
    UseResource(WeakSimple);
}

/*
Simple constructor called!
Resourcee still alive.
Simple destructor called!
Resource has been freed.
*/

함수에 전달하기

  • 매개변수에서 포인터를 받는 함수는 소유권을 전달하거나 공유할 경우에만 스마트 포인터를 사용해야 한다.
  • shared_ptr 소유권을 공유 받으려면 shared_ptr을 값으로 전달 받으면 되고,
  • unique_ptr의 소유권을 전달하려면 unique_ptr을 값으로 받으면 된다. 이 경우는 이동 의미론이 필요하다.
  • 소유권 전달과 공유가 전혀 없다면 비 const 대상에 대한 레퍼런스나 const에 대한 레퍼런스로 매개변수를 정의해야 한다.

함수에서 리턴하기

  • 표준 스마트 포인터들은 함수에서 값으로 리턴하는 것을 쉽고 효율적으로 처리한다.

enable_shared_from_this

  • std::enable_shared_from_this 를 상속해서 클래스를 만들면 객체애 대한 호출한 메서드가 자신에게 shared_ptr이나 weak_ptr을 안전하게 리턴할 수 있다.
  • 이 클래스 없이 shared_ptr이나 weak_ptr을 리턴하려면 weak_ptr을 멤버로 추가한 뒤 이를 복제해서 리턴하거나, 이를 이용하여 생성한 shared_ptr을 리턴해야 한다.
  • 상속하게 되면 두 메서드가 추가된다,
    • shared_from_this(): 객체의 소유권을 공유하는 shared_ptr을 리턴
    • weak_from_this(): 객체의 소유권을 추적하는 weak_ptr을 리턴
  • 자세한건 나중에 추가.

Leave a comment