[전문가를 위한 C++] 메모리 관리
‘전문가를 위한 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로 변환해야 한다.
- lock() 메서드를 이용해 shared_ptr을 리턴 받기. 이때 shared_ptr에 연결된 weak_ptr이 해제되면 shared_ptr의 값은 nullptr이 된다.
- 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