7. Memory Management
이 챕터의 내용
▶ 메모리를 사용하고 관리하는 다양한 방법
▶ 종종 배열과 포인터 사이의 복잡한 관계
▶ 메모리 작업에 대한 낮은 수준의 검토
▶ 일반적인 메모리 함정
▶ 스마트 포인터와 사용법
이 챕터에 대한 WILEY.COM 다운로드
이 챕터의 모든 예제 코드는 이 책의 웹사이트 www.wiley.com/go/proc++5e의 코드 다운로드 탭에서 이 챕터의 코드 다운로드의 일부로 사용할 수 있다.
여러 면에서 C++ 프로그래밍은 도로가 없는 곳에서 운전을 하는 것과 같다. 물론 당신이 원하는 곳은 어디든 갈 수 있지만, 당신을 다치지 않게 막아줄 라인이나 신호등은 없다. C언어와 마찬가지로 C++는 프로그래머에게 손을 대지 않는 접근 방식을 가지고 있다. 언어는 당신이 무엇을 하고 있는지 알고 있다고 가정한다. C++는 매우 유연하고 성능을 위해 안전성을 희생하기 때문에 문제를 일으킬 가능성이 있는 작업을 수행할 수 있다.
메모리 할당과 관리는 C++ 프로그래밍에서 특히 발생하기 쉬운 오류 영역이다. 고품질의 C++ 프로그램을 작성하려면, 전문 C++ 프로그래머는 배후에서 메모리가 작동하는 방식을 이해해야 한다. Part III의 첫 번째 장은 메모리 관리의 모든 것을 탐구한다. 동적 메모리의 함정과 이를 피하고 제거하는 몇 가지 기술에 대해 배울 것이다.
전문 C++ 프로그래머들이 이러한 코드를 접하게 되기 때문에, 이 장에서는 로우 레벨 메모리 처리에 대해 설명한다. 그러나 모던 C++ 코드에서는 로우 레벨 메모리 작업을 최대한 피해야 한다. 예를 들어, 동적으로 할당된 C 스타일 배열 대신, 당신은 모든 메모리 관리를 자동으로 처리하는 vector와 같은 Standard Library 컨테이너를 사용해야 한다. 로우 포인터 대신, 이 장의 뒷 부분에서 설명하는 unique_ptr과 shared_ptr 같은 스마트 포인터를 사용해야 한다. 이 포인터들은 더 이상 필요하지 않을 때, 메모리와 같은 기본 리소스를 자동으로 해제한다. 기본적으로 당신의 코드에서 new/new[]와 delete/delete[] 같은 메모리 할당 루틴에 대한 호출을 피해야 한다. 물론 항상 가능한 것은 아니며, 기존 코드에서는 그렇지 않을 가능성이 높으므로 전문 C++ 프로그래머는 메모리가 배후에서 어떻게 작동하는지 알아야 한다.
주의
모던 C++ 코드에서는 컨테이너와 스마트 포인터 같은 최신 구성을 위해 가능한 한 로우 레벨 메모리 작업을 피해야 한다.
WORKING WITH DYNAMIC MEMORY (동적 메모리 작업)
메모리는 컴퓨터의 로우 레벨 컴포넌트로, 때때로 C++와 같은 고급 프로그래밍 언어에서 불행하게도 머리를 숙이게 만든다. 전문 C++ 프로그래머가 되기 위해서는 C++에서 동적 메모리가 실제로 어떻게 작동하는지에 대한 확실한 이해가 필수적이다.
How to Picture Memory (메모리를 그리는 방법)
개체가 메모리에서 어떻게 보이는지에 대한 정신 모델이 있으면, 동적 메모리를 이해하는 것이 훨씬 쉽다. 이 책에서 메모리 단위는 옆에 레이블이 있는 박스로 표시된다. 레이블은 메모리에 해당하는 변수 이름을 나타낸다. 박스 안의 데이터는 메모리의 현재 값을 표시한다.
예를 들어, Figure 7-1은 아래 코드 라인이 실행된 후의 메모리 상태를 보여준다. 라인은 i가 지역 변수가 되도록 함수에 있어야 한다:
int i { 7 };
i는 스택에 할당된 소위 자동 변수(automatic variable)이다. 프로그램 흐름이 변수가 선언된 범위를 벗어나면, 자동으로 할당이 해제된다.
new 키워드를 사용하면, 메모리는 힙(heap 또는 free store)에서 할당된다. 아래 코드는 nullptr로 초기화된 스택에 변수 ptr을 생성한 다음, ptr 포인트에 힙 메모리를 할당한다:
int* ptr { nullptr };
ptr = new int;
또한, 이것은 아래와 같이 한 라인으로 작성할 수 있다:
int* ptr { new int };
Figure 7-2는 이 코드가 실행된 후의 메모리 상태를 보여준다. 변수 ptr은 힙의 메모리를 가르키지만, 여전히 스택에 있다. 포인트는 변수일 뿐이며, 스택이나 힙에 상주할 수 있지만 이 사실을 쉽게 잊어버린다. 그러나 동적 메모리는 항상 힙에 할당된다.
주의
일반적으로 포인터 변수를 선언할 때마다, 적절한 포인터나 nullptr로 즉시 초기화해야 한다. 초기화하지 않은 상태로 두면 안된다.
다음 예제에서는 포인터가 스택과 힙 모두에 존재할 수 있음을 보여준다:
int** handle { nullptr };
handle = new int*;
*handle = new int;
이 코드는 먼저 정수에 대한 포인터에 대한 포인터로 변수 handle로 선언한다. 그런 다음 정수에 대한 포인터를 보유하기에 충분한 메모리를 동적으로 할당하고, handle에 새 메모리에 대한 포인터를 저장한다. 다음으로 해당 메모리(*handle)에는 정수를 보유할 수 있을 만큼 충분히 큰 동적 메모리의 다른 섹션에 대한 포인터가 할당된다.
Figure 7-3은 한 포인터는 스택(handle)에 있고 다른 포인터는 힙(*handle)에 있는 두 가지 레벨의 포인터를 보여준다.
Allocation and Deallocation (할당과 할당 해제)
변수를 위한 공간을 생성하기 위해, 당신은 new 키워드를 사용한다. 프로그램의 다른 부분에서 사용할 수 있도록 해당 공간을 해제하기 위해, 당신은 delete 키워드를 사용한다. new와 delete 같은 단순한 개념에 여러 변형과 복잡성이 없다면, 물론 C++가 아닐 것이다.
Using new and delete (new와 delete 사용하기)
메모리 블록을 할당하려면, 당신은 공간이 필요한 변수 유형으로 new를 호출한다. new는 해당 메모리에 대한 포인터를 반환하지만, 해당 포인터를 변수에 저장하는 것은 당신에게 달려 있다. new의 반환 값을 무시하거나 포인터 변수가 범위(scope)를 벗어나게 되면, 더 이상 액세스할 방법이 없기 때문에 메모리가 orphaned(외톨이)가 된다. 이를 memory leak(메모리 누수)이라고 한다.
예를 들어, 아래 코드는 int를 보유하기에 충분한 메모리를 외톨이로 만든다. Figure 7-4는 코드가 실행된 후 메모리 상태를 보여준다. 스택에서 직접 또는 간접적으로 액세스할 수 힙 영역(free store)에 데이터 블록이 있는 경우, 메모리는 외톨이 또는 누수 된다.
void leaky()
{
new int; // BUG! Orphans/leaks memory!
cout << "I just leaked an int!" << endl;
}
빠른 메모리를 무한대로 제공하는 컴퓨터를 만드는 방법을 찾을 때까지, 당신은 개체와 연결된 메모리를 해제하고 다른 용도로 재사용할 수 있는 시점을 컴파일러에 알려야 한다. 힙 영역(free store)에서 메모리를 해제하려면, 당신은 아래와 같이 메모리에 대한 포인터와 함께 delete 키워드를 사용한다:
int* ptr { new int };
delete ptr;
ptr = nullptr;
주의
일반적으로 new로 메모리를 할당하고, 포인터를 스마트 포인터에 저장하는 대신 로우 포인터를 사용하는 모든 코드 라인은 delete로 동일한 메모리를 해제하는 다른 코드 라인과 일치해야 한다.
참고
메모리를 해제한 후 nullptr로 포인터를 설정하는 것이 좋다. 이러게 하면, 이미 할당 해제된 메모리에 대한 포인터를 실수로 사용하지 않게 된다. nullptr 포인터에서 delete를 호출할 수 있다는 점도 주목할 가치가 있다. 이는 단순히 아무것도 하지 않을 것이다.
What About My Good Friend malloc(나의 좋은 친구 malloc는 어떻습니까)?
당신이 C 프로그래머라면, malloc() 함수의 문제점이 무엇인지 궁금할 것이다. C에서 malloc()은 주어진 메모리 바이트 수를 할당하는 데 사용된다. 대부분의 경우, malloc()을 사용하는 것은 간단하고 직관적이다. malloc() 함수는 여전히 C++에도 존재하지만, 그것을 피해야 한다. malloc()에 비해 new의 주요 이점은 new가 메모리를 할당할 뿐만 아니라 개체를 생성한다는 것이다.
예를 들어, Foo라는 가상의 클래스를 사용하는 다음 두 줄의 코드를 고려해 보자:
Foo* myFoo { (Foo*)malloc(sizeof(Foo)) };
Foo* myOtherFoo { new Foo{} };
이 라인을 실행한 후, myFoo와 myOtherFoo는 모두 Foo 개체에 대해 충분히 큰 힙 영역(free store)의 메모리 영역을 가리킨다. Foo의 데이터 멤버와 메서드는 두 포인터를 모두 사용하여 액세스할 수 있다. 차이점은 myFoo가 가리키는 Foo 개체는 해당 생성자가 호출되지 않았기 때문에 적절한 개체가 아니라는 것이다. malloc() 함수는 특정 크기의 메모리 조각만 따로 설정한다. 개체에 대해 알지도 관심도 없다. 반대로 new에 대한 호출은 적절한 크기의 메모리를 할당하고 개체를 생성하기 위해 적절한 생성자를 호출한다.
free() 함수와 delete 연산자 사이에도 비슷한 차이점이 있다. free()를 사용하면, 개체의 소멸자가 호출되지 않는다. delete를 사용하면, 소멸자가 호출되고 개체가 적절하게 정리된다.
주의
C++에서 malloc()과 free()를 사용하면 안 된다. new와 delete만 사용해야 한다.
When Memory Allocation Fails(메모리 할당이 실패한 경우)
대부분이 아니더라도, 많은 프로그래머가 new가 항상 성공할 것이라는 가정하에 코드를 작성한다. 그 근거는 new가 실패하면, 메모리는 매우 적고 수명이 매우 나쁘다는 것을 의미한다는 것이다. 이 상황에서는 프로그램이 무엇을 할 수 있는지 명확하지 않기 때문에 종종 헤아릴 수 없는 상태가 된다.
기본적으로 new가 실패하면(예: 요청에 사용할 수 있는 메모리가 충분하지 않은 경우) 예외가 발생한다. 이 예외가 포착되지 않으면, 프로그램은 종료된다. 많은 프로그램에서 이 동작은 허용될 수 있다. Chapter 14, "Handling Errors"에서는 메모리 부족 상황에서 정상적으로 복구할 수 있는 가능한 접근 방식을 설명한다.
예외를 발생시키지 않는 대체 버전의 new도 있다. 대신 C의 malloc() 동작과 유사하게 할당이 실패하면 nullptr을 반환한다. 이 버전을 사용하기 위한 구문은 다음과 같다:
int* ptr { new(nothrow) int };
구문이 조금 이상한데, "nothrow"를 new에 대한 인수인 것처럼 작성을 한다.
물론, 예외를 발생하는 버전과 동일한 문제가 여전히 있다. 결과가 nullptr이면 어떻게 해야 할까요? 컴파일러는 결과를 확인할 것을 요구하지 않으므로, nothrow 버전의 new는 예외를 발생하는 버전보다 다른 버그로 이어질 가능성이 더 크게 존재한다. 이러한 이유로 표준 버전의 new를 사용하는 것이 제안되고 있다. 프로그램에서 메모리 부족 복구가 중요한 경우, Chapter 14에서 다루는 기술을 통해 필요한 모든 도구를 얻을 수 있다.
Arrays(배열)
배열은 동일한 유형의 여러 변수를 인덱스가 있는 단일 변수로 패키징한다. 번호가 매겨진 슬롯안의 값은 기억하기 쉽기 때문에, 배열로 작업하는 것은 초보 프로그래머도 빠르게 익숙해질 수 있다. 배열의 메모리 내 표현은 이 지적인 모델과 크게 다르지 않다.
Arrays of Primitive Types(기본 유형의 배열)
당신의 프로그램에서 배열을 위한 메모리를 할당할 때, 각 조각이 배열의 단일 요소를 보유할 수 있을 만큼 충분히 큰 메모리의 연속적인 조각을 할당한다. 예를 들어, 아래와 같이 스택에서 5개의 int로 구성된 로컬 배열을 선언할 수 있다:
int myArray[5];
이러한 기본 유형 배열의 개별 요소는 초기화되지 않는다. 즉, 메모리의 해당 위치에 있는 무슨 값이든 포함된다. Figure 7-5는 배열이 생성된 후의 메모리 상태를 보여준다. 스택에 배열을 생성할 때, 크기는 컴파일 타임에 알려진 상수 값이어야 한다.
참고
일부 컴파일러는 스택에서 가변 크기의 배열을 허용한다. 이것은 C++의 표준 기능이 아니므로, 이 기능이 보이면 조심스럽게 뒤로 물러나는 것이 좋다.
스택에 배열을 생성할 때, 초기 요소(initial elements)를 제공하기 위해 초기화 목록(initializer list)을 사용할 수 있다:
int myArray[5] { 1, 2, 3, 4, 5 };
초기화 목록(initializer list)이 배열 크기보다 적은 요소를 포함한 경우, 배열의 나머지 요소는 0으로 초기화된다. 예를 들면, 아래와 같다:
int myArray[5] { 1, 2 }; // 1, 2, 0, 0, 0
아래는 배열의 모든 요소를 0으로 빠르게 초기화하는 데 사용할 수 있다:
int myArray[5] { 0 }; // 0, 0, 0, 0, 0
이 경우, 0은 생략할 수 있다. 아래는 모든 요소를 0으로 초기화(Chapter 1, "A Crash Course in C++ and the Standard Library")한다:
int myArray[5] { }; // 0, 0, 0, 0, 0
초기화 목록(initializer list)을 사용할 때, 컴파일러는 요소 수를 자동으로 추론할 수 있으므로 배열 크기를 명시적으로 지정할 필요가 없다. 예를 들면, 아래와 같다:
int myArray[] { 1, 2, 3, 4, 5 };
배열의 위치를 참조하기 위해 포인터를 사용한다는 점을 제외하면, 힙 영역(free store)에서 배열을 선언하는 것은 다르지 않다. 아래 코드는 5개의 초기화되지 않은 int 배열을 메모리를 할당하고 myArrayPtr이라는 변수에 메모리에 대한 포인터를 저장한다:
int* myArrayPtr { new int[5] };
Figure 7-6에서 볼 수 있듯이, 힙 영역(free store) 기반 배열은 스택 기반 배열과 유사하지만, 위치는 다르다. myArrayPtr 변수는 배열의 0 번째 요소를 가리킨다.
new 연산자와 마찬가지로, new[]는 할당에 실패할 경우 예외를 발생(throw)하는 대신 nullptr을 반환하는 nothrow 인수를 허용한다. 예를 들면, 아래와 같다:
int* myArrayPtr { new(nothrow) int[5] };
힙 영역(free store)에서 동적으로 생성된 배열은 초기화 목록(initializer list)을 사용하여 초기화할 수도 있다. 예를 들면, 아래와 같다:
int* myArrayPtr { new int[] { 1, 2, 3, 4, 5 } };
new[]에 대한 각 호출은 메모리를 정리하기 위해 delete[]에 대한 호출과 쌍을 이루어야 한다. 예를 들면, 아래와 같다:
delete [] myArrayPtr;
myArrayPtr = nullptr;
힙 영역(free store)에 배열을 배치하는 이점은 런타임에 배열의 크기를 정의할 수 있다는 것이다. 예를 들어, 아래의 코드 조각은 askUserForNumberOfDocuments()라는 가상 함수에서 원하는 문서의 수를 수신하고, Document 개체의 배열을 생성하기 위해 그 결과를 사용한다.
Document* createDocArray()
{
size_t numDocs { askUserForNumberOfDocuments() };
Document* docArray { new Document[numDocs] };
return docArray;
}
new[]에 대한 각 호출과 delete[]에 대한 호출은 쌍을 이루어야 하므로, 이 예제에서는 createDocArray() 호출자(caller)가 delete[]를 사용하여 반환된 메모리를 정리하는 것이 중요하다. 또 다른 문제는 C 스타일 배열은 그 크기를 모른다는 것이다. 따라서 createDocArray() 호출자는 반환된 배열에 얼마나 많은 요소가 있는지 모른다.
이전 함수에서, docArray는 동적으로 할당된 배열이다. 이것을 동적 배열과 혼동하면 안된다. 배열 자체는 일단 할당되면 크기가 변경되지 않기 때문에 동적이지 않는다. 동적 메모리를 사용하면 런타임에 할당할 블럭의 크기를 지정할 수 있지만, 데이터를 수용하기 위해 그 크기를 자동으로 조정하지는 않는다.
참고
크기를 동적으로 조정하고 실제 크기를 알고 있는, 예를 들어 Standard Library 컨테이너와 같은 데이터 구조체가 있다. 사용하기에 훨씬 더 안전하기 때문에 C 스타일 배열 대신 이러한 컨테이너를 사용해야 한다.
C++에는 C 언어에서 계승된 realloc()이라는 함수가 있다. 이것은 사용하면 안된다. C 언어에서, realloc()은 새 크기의 새메모리 블럭을 할당하고, 이전 데이터를 모두 새 위치에 복사하고, 원래 블럭을 삭제하여 배열의 크기를 효과적으로 변경하는 데 사용된다. 이 접근 방식은 C++에서 사용자 정의 개체가 비트 단위 복사에 잘 응답하지 않기 때문에 매우 위험하다.
주의
C++에서 realloc()을 사용하지 않는다. 그것은 당신의 친구가 아니다.
Arrays of Objects (개체의 배열)
요소가 초기화되는 방식을 제외하면, 개체의 배열은 기본 유형의 배열과 다르지 않다. 당신이 N개의 개체 배열을 할당하기위해 new[N]을 사용하면, 각 블럭이 단일 개체에 대해 충분히 큰 N개의 연속된 블럭에 대한 충분한 공간이 할당된다. 개체 배열의 경우, new[]는 각 개체의 인수가 없는(=기본값) 생성자를 자동으로 호출하는 반면, 기본 유형 배열에는 기본적으로 초기화되지 않은 요소가 있다. 이런 식으로, new[]를 사용하여 개체 배열을 할당하면 완전히 생성되고 초기화된 개체 배열에 대한 포인터가 반환된다.
예를 들어, 아래 클래스를 고려해 본다:
class Simple
{
public:
Simple() { cout << "Simple constructor called!" << endl; }
~Simple() { cout << "Simple destructor called!" << endl; }
};
4개의 Simple 개체로 구성된 배열을 할당하면, Simple 생성자가 4번 호출된다.
Simple* mySimpleArray { new Simple[4] };
Figure 7-7은 이 배열의 메모리 다이어그램을 보여준다. 보시다시피 기본 유형의 배열과 다르지 않다.
Deleting Arrays (배열 삭제)
앞에서 언급했듯이, 배열 버전의 new(new[])로 메모리를 할당할 때, 배열 버전의 delete(delete[])로 메모리를 해제해야 한다. 이 버전은 배열에 연결된 메모리를 해제할 뿐만 아니라, 배열내의 개체를 자동으로 소멸(destruct)한다.
Simple* mySimpleArray { new Simple[4] };
// Use mySimpleArray...
delete [] mySimpleArray;
mySimpleArray = nullptr;
delete 배열 버전을 사용하지 않으면, 당신의 프로그램은 이상한 방식으로 작동할 수 있다. 컴파일러는 당신이 개체에 대한 포인터를 삭제한다는 사실만 알고 있기 때문에, 일부 컴파일러에서는 배열의 첫 번째 요소에 대한 소멸자(destructor)만 호출한다. 그리고 배열의 다른 모든 요소는 orphaned(외톨이) 개체가 된다. 다른 컴파일러에서는 new와 new[]가 완전히 다른 메모리 할당 체계를 사용할 수 있기 때문에 메모리 손상이 발생할 수 있다.
주의
new로 할당된 모든 항목에는 항상 delete를 사용하고, new[]로 할당된 모든 항목에는 항상 delete[]를 사용한다.
물론, 배열의 요소가 개체인 경우에만 소멸자가 호출된다. 포인터 배열이 있는 경우, 아래 코드와 같이 각 개체를 개별적으로 할당한 것처럼, 개별적으로 가리키는 각 개체를 삭제해야 한다:
const size_t size { 4 };
Simple** mySimplePtrArray { new Simple*[size] };
// Allocate an object for each pointer.
for (size_t i { 0 }; i < size; i++ ) { mySimplePtrArray[i] = new Simple{}; }
// Use mySimplePtrArray...
// Delete each allocated object.
for (size_t i { 0 }; i < size; i++ ) {
delete mySimplePtrArray[i];
mySimplePtrArray[i] = nullptr;
}
// Delete the array itself.
delete [] mySimplePtrArray;
mySimplePtrArray = nullptr;
주의
최신 C++에서는 로우 C 스타일 포인터를 사용하지 않아야 한다. C 스타일 배열에 로우 포인터를 저장하는 대신, std::vector와 같은 최신 Standard Library 컨테이너에 스마트 포인터를 저장해야 한다. 이장의 뒷 부분에서 설명하는 스마트 포인터는 연관된 메모리를 적시에 자동으로 할당 해제한다.
Multidimensional Arrays (다차원 배열)
다차원 배열은 여러 인덱스로 인덱스 값의 개념을 확장한다. 예를 들어, tic-tac-toc 게임은 3 x 3 그리드를 나타내기 위해 2차원 배열을 사용할 수 있다. 아래 예제는 스택에 선언되고, 0으로 초기화되고 일부 테스트 코드로 액세스되는 배열을 보여준다:
char board[3][3] {};
// Test code
board[0][0] = 'X'; // X puts marker in position (0,0).
board[2][1] = 'O'; // O puts marker in position (2,1).
2차원 배열의 첫 번째 첨자가 x 좌표인지 y 좌표인지 궁금할 수 있다. 진실은 당신이 일관성을 유지하는 한 별로 중요하지 않다는 것이다. 4 x 7 그리드는 char board[4][7]이나 char board[7][4]로 선언할 수 있다. 대부분의 애플리케이션에서 첫 번째 첨자를 x 축으로, 두 번째 첨자를 y 축으로 생각하는 것이 가장 쉽다.
Multidimensional Stack Arrays (다차원 스택 배열)
메모리에서, 3 x 3 스택 기반의 2차원 board 배열은 Figure 7-8과 같다. 메모리에는 두 개의 축이 없기 때문에(주소는 단순히 순차적임), 컴퓨터는 1차원 배열과 마찬가지로 2차원 배열을 나타낸다. (1차원 배열과 2차원 배열의) 차이점은 배열의 크기와 배열에 액세스하는 데 사용되는 방법이 있다.
다차원 배열의 크기는 모든 차원을 곱한 다음, 배열의 단일 요소 크기를 곱한 것이다. 문자가 1 바이트라고 가정할 때, Figure 7-8에서 3 x 3 보드는 3 x 3 x 1 = 9바이트이다. 4 x 7 문자 보드의 경우, 배열은 4 x 7 x 1 = 28바이트이다.
다차원 배열의 값에 액세스하기 위해, 컴퓨터는 각 첨자를 다차원 배열 내의 다른 하위 배열에 액세스하는 것처럼 처리한다. 예를 들어 3 x 3 그리드에서, 표현식 board[0]은 실제로 Figure 7-9에서 강조 표시된 하위 배열을 참조한다. board[0][2]와 같이 두 번째 첨자를 추가하면, Figure 7-10에서 보여주는 것처럼 컴퓨터가 하위 배열 내에서 두 번째 첨자를 찾아 올바른 요소에 액세스할 수 있다.
이러한 테크닉은 N차원 배열로 확장되지만, 3차원보다 높은 차원은 개념과하기 어렵고 거의 사용되지 않는다.
Multidimensional Free Store Arrays (다차원의 힙 영역 배열)
런타임에 다차원 배열의 차원을 결정해야 하는 경우, 힙 영역(free store) 기반의 배열을 사용할 수 있다. 동적으로 할당된 1차원 배열이 포인터를 사용하여 액세스되는 것처럼, 동적으로 할당된 다차원 배열도 포인터를 사용하여 액세스된다. 2차원 배열에서 유일한 차이점은 포인터 대 포인터로 시작해야 한다는 것이다. N 차원 배열에서 N 레벨의 포인터가 필요하다. 처음에는 동적으로 할당되는 다차원 배열을 선언하고 할당하기 위한 올바른 방법이 아래와 같이 보일 수 있다:
char** board { new char[i][j] }; // BUG! Doesn`t compile
다차원의 힙 영역(free store) 기반 배열은 스택 기반 배열처럼 작동하지 않기 때문에, 이 코드는 컴파일되지 않는다. 메로리 레이아웃이 연속적이지 않다. 대신, 힙 영역(free store) 기반 배열의 첫 번째 첨자 차원에 대해 단일 연속 배열을 할당하는 것으로 시작한다. 해당 배열의 각 요소는 실제로 두 번째 첨자 차원에 대한 요소를 저장하는 다른 뱌열에 대한 포인터이다. Figure 7-11은 동적으로 할당된 2 x 2 보드에 대한 해당 레이아웃을 보여준다.
안타깝게도, 컴파일러는 사용자를 대신하여 하위 배열에 대한 메모리를 할당하지 않는다. 힙 영역(free store) 기반의 일차원 배열은 할당할 수 있지만, 개별 하위 배열은 명시적으로 할당해야 한다. 다음 함수는 이차원 배열에 대한 메모리를 적절하게 할당한다:
char** allocateCharacterBoard(size_t xDimension, size_t yDimension)
{
char** myArray { new char*[xDimension] }; // 첫 번째 차원 할당
for (size_t i { 0 }; i < xDimension; i++) {
myArray[i] = new char[yDimension]; // i번째 하위 배열 할당
}
return myArray;
}
마찬가지로 힙 영역(free store) 기반의 다차원 배열과 관련된 메모리를 해제하려는 경우, 배열 delete[] 구문은 사용자를 대신하여 하위 배열을 정리하지 않는다. 배열을 해제하는 코드는 다음 함수에서와 같이 배열을 할당하는 코드를 반영해야 한다:
void releaseCharacterBoard(char**& myArray, size_t xDimension)
{
for (size_t i { 0 }; i < xDimensionl; i++) {
delete [] myArray[i]; // i번째 하위 배열 삭제
myArray[i] = nullptr;
}
delete [] myArray; // 첫번째 차원 삭제
myArray = nullptr;
}
참고
다차원 배열을 할당하는 이 예는 가장 효율적인 솔루션이 아니다. 먼저 첫 번째 차원에 대한 메모리를 할당한 후, 각 하위 배열에 대한 메모리를 할당한다. 이로 인해 메모리 블럭이 메모리 주변에 흩어져, 이러한 데이터 구조로 작동하는 알고리즘의 성능에 영향을 미친다. 연속된 메모리에서 작업할 수 있는 경우, 알고리즘은 훨씬 빠르게 실행된다. 해결 방법은 크기가 xDimension * yDimension * elementSize인 큰 메모리 블록 하나를 할당하고, x * yDimension + y와 같은 공식으로 요소에 액세스하는 것이다.
이제 배열 작업에 대한 모든 세부 사항을 알았으므로, 메모리 안전을 제공하지 않는 오래된 C 스타일 배열은 가능한 피하는것이 좋다. 레거시 코드에서 접하게 되므로 여기에서 설명을 한다. 새로운 코드에서는 std::array와 vector와 같은 C++ Standard Library의 컨테이너를 사용해야 한다. 예를 들어, 일차원 동적 배열의 경우 vector<T>를 사용한다. 이차원 동적 배열의 경우, vector<vector<T>>를 사용할 수 있고 더 높은 차원에서도 비슷하다. 물론, vector<vector<T>>와 같은 데이터 구조로 직접 작업하는 것은 특히 그것을 생성하는 데 있어서 여전히 지루하고 이전 참고에서 논의된 것과 동일한 메모리 단편화 문제를 경험하게 된다. 따라서 애플리케이션에서 N차원 동적 배열이 필요한 경우, 사용하기 쉬운 인터페이스를 제공하는 도우미 클래스를 작성하는 것이 좋다. 예를 들어, 동일하게 긴 행을 가진 이차원 데이터로 작업하려면, Matrix<T>나 Table<T> 클래스 템플릿을 작성(또는 물론 재사용)하는 것이 좋다. 그 클래스 템플릿은 메로리 할당과 해제 그리고 요소 액세스 알고리즘을 사용자에게 숨긴다. 클래스 템플릿 작성에 대한 자세한 내용은 Chapter 12, "Writing Generic Code with Templates"를 참조한다.
주의
C 스타일 배열 대신, std::array, vector 등과 같은 C++ Standard Library 컨테이너를 사용한다.
Working with Pointers (포인터 작업)
포인터는 상대적으로 쉽게 남용할 수 있기 때문에 나쁜 평판을 받는다. 포인터는 단지 메모리의 주소이기 때문에, 이론적으로 해당 주소를 수동으로 변경할 수 있으며, 다음 코드 라인과 같은 무서운 작업을 수행할 수도 있다:
char* scaryPointer { (char*)7 };
이 라인은 메모리 주소 7에 대한 포인터를 만든다. 이는 임의의 가비지이거나 애플리케이션의 다른 곳에서 사용되는 메모리일 가능성이 있다. 예를 들어 새 메모리나 스택과 같이, 사용자를 대신하여 따로 보관되지 않은 메모리 영역을 사용하기 시작하면, 결국 개체와 관련된 메모리나 힙 영역(free store) 관리와 관련된 메모리가 손상되고 프로그래밍 오동작할 것이다. 이러한 오동작은 여러 가지 방식으로 나타날 수 있다. 예를 들어, 데이터가 손상되었기 때문에 잘못된 결과가 나타나거나, 존재하지 않는 메모리에 액세스하거나 보호된 메모리에 쓰기를 시도하여 하드웨어 예외가 트리거되는 것으로 나타날 수 있다. 운이 좋다면, 일반적으로 운영 체제나 C++ 런타임 라이브러리에 의해 프로그램이 종료되는 심각한 오류 중 하나를 얻게 될 것이다. 운이 없다면, 잘못된 결과만 얻게 될 것이다.
A Mental Model for Pointers (포인터의 정신 모델)
포인터에 대해 생각할 수 있는 두 가지 방법이 있다. 좀 더 수학적인 생각을 가진 독자는 포인터를 주소로 볼 수 있다. 이 관점은 이 장의 뒷부분에서 다루는 포인터의 산술 연산을 좀 더 이해하기 쉽게 만든다. 포인터는 메모리를 향하는 신비한 경로가 아니다. 메모리 위치의 해당하는 숫자이다. Figure 7-12는 주소 기반 세계의 관점에서 2 x 2 그리드를 보여준다.
참고
Figure 7-12에서 주소는 단지 설명을 위한 것이다. 실제 시스템의 주소는 하드웨어와 운영 체제에 크게 의존한다.
공간 표현에 더 익숙한 독자는 포인터의 화살표 보기에서 더 많은 이점을 얻을 수 있다. 포인터는 프로그램에 "이봐! 저기를 봐."라고 말하는 간접적인 레벨이다. 이 보기에서, 포인터의 여러 레벨은 데이터 경로의 개별 단계가 된다. Figure 7-11은 메모리에 있는 포인터의 그래픽 보기를 보여준다.
* 연산자를 사용하여 포인터를 역참조(dereference)하면, 메모리에서 한 레벨 더 깊게 보도록 프로그렘에 지시하는 것이다. 주소 기반 관점에서, 포인터가 가리키는 주소의 메모리로 점프하는 것을 역참조라고 생각한다. 그래픽 관점에서 모든 역참조는 베이스에서 헤드까지 화살표를 따라가는 것에 해당한다.
& 연산자를 사용하여 해당 위치의 주소를 가져오면, 메모리에 간접적인 레벨이 추가된다. 주소 기반 관점에서, 프로그램은 포인터로 저장할 수 있는 해당 위치의 숫자 주소를 기록한다. 그래픽 관점에서, & 연산자는 헤드가 표현식으로 지정한 위치에서 끝나는 새 화살표를 만든다. 화살표의 베이스는 포인터로 저장할 수 있다.
Casting with Pointers (포인터로 캐스팅)
포인터는 단지 메모리 주소(또는 어딘가로 향하는 화살표)이기 때문에, 다소 약하게 입력된다. XML 문서에 대한 포인터는 정수에 대한 포인터와 크기가 같다. 컴파일러는 C 스타일 캐스트를 사용하여 포인터 유형을 다른 포인터 유형으로 쉽게 캐스팅할 수 있다:
Document* documentPtr { getDocument() };
char* myCharPtr { (char*)documentPtr };
정적 캐스트(static cast)는 조금 더 안전하다. 컴파일러는 관련 없는 데이터 유형의 포인터에 대한 정적 캐스트 수행을 거부한다:
Document* documentPtr { getDocument() };
char* myCharPtr { static_cast<char*>(documentPtr) }; // BUG! Won`t compile
캐스팅하려는 두 포인터가 실제로 상속으로 관련된 개체를 가리키는 경우, 컴파일러는 정적 캐스트를 허용한다. 그러나 동적 캐스트(dynamic cast)는 상속 계층에서 캐스트를 수행하는 더 안전한 방법이다. Chapter 10, "Discovering Inheritance Techniques"에서는 사용할 수 있는 다양한 C++ 스타일 캐스트를 포함하여 상속에 대해 자세히 설명한다.
ARRAY-POINTER DUALITY (배열 포인터의 이중성)
당신은 포인터와 배열의 일부 오버랩(overlap)을 이미 확인했다. 힙 영역(free store)에 할당된 배열은 첫 번째 요소에 대한 포인터로 참조된다. 스택 기반 배열은 일반적인 변수 선언과 함께 배열 구문([])을 사용하여 참조된다. 그러나 당신이 배우려고 할 때, 오버랩 부분은 여기서 끝나지 않는다. 포인터와 배열은 복잡한 관계를 가지고 있다.
Arrays Are Pointers! (배열은 포인터이다!)
힙 영역(free store) 기반 배열은 포인터를 사용하여 배열을 참조할 수 있는 유일한 위치가 아니다. 당신은 스택 기반의 배열 요소에 액세스하기 위해 포인터 구문을 사용할 수도 있다. 배열의 주소는 실제로 첫 번째 요소(인덱스 0)의 주소이다. 컴파일러는 배열의 변수 이름으로 전체 배열을 참조할 때, 실제로는 첫 번째 요소의 주소를 참조하고 있다는 것을 알고 있다. 이러한 방식으로 포인터는 힙 영역(free store) 기반 배열과 같이 작동한다. 다음 코드는 스택에 0으로 초기화된 배열을 생성하고, 그것을 액세스하기 위해 포인터를 사용한다:
int myIntArray[10] {};
int* myIntPtr { myIntArray };
// 포인터를 사용하여 배열 액세스
myIntPtr[4] = 5;
포인터를 사용하여 스택 기반 배열을 참조하는 기능은 함수에 배열을 전달할 때 유용하다. 다음 함수는 포인터로 정수 배열을 받아들인다. 포인터가 크기에 대해 어떤 것도 암시하지 않기 때문에, 호출자는 배열의 크기를 명시적으로 전달해야 한다. 사실, 포인터이든 아니든 모든 형식의 C++ 배열에는 크기의 기본 제공 개념이 없다. 이것은 당신이 Standard Library에서 제공되는 것과 같은 최신 컨테이너를 사용해야 하는 또 다른 이유이다.
void doubleInts(int* theArray, size_t size)
{
for (size_t i { 0 }; i < size; i++) { theArray[i] *= 2; }
}
이 함수의 호출자는 스택 기반이나 힙 영역(free store) 기반의 배열을 전달할 수 있다. 힙 영역(free store) 기반 배열의 경우에는 포인터가 이미 존재하며 함수에 값(value)을 전달한다. 스택 기반 배열의 경우에는 호출자는 배열 변수를 전달할 수 있으며, 컴파일러는 자동으로 배열 변수를 배열에 대한 포인터로 처리하거나 첫 번째 요소의 주소를 명시적으로 전달할 수 있다. 세 가지 형식이 모두 여기에 보여준다:
size_t arrSize { 4 };
int* freeStoreArray { new int[arrSize]{ 1, 5, 3, 4 } };
doubleInts(freeStoreArray, arrSize);
delete [] freeStoreArray;
freeStoreArray = nullptr;
int stackArray[] { 5, 7, 9, 11 };
arrSize = std::size(stackArray); // C++17 이후, <array> 요구됨
// arrSize = sizeof(stackArray) / sizeof(stackArray[0]);
// C++17 이전, 1장 참조
doubleInts(stackArray, arrSize);
doubleInts(&stackArray[0], arrSize);
배열의 파라미터 전달 의미론는 포인터의 의미론과 매우 비슷하다. 컴파일러는 배열이 함수에 전달될 때 배열을 포인터로 취급하기 때문이다. 배열을 인수로 받고 배열 내부의 값을 변경하는 함수는 실제로 배열의 복사본이 아니라 원본 배열을 변경하는 것이다. 포인터와 마찬가지로, 배열을 전달하는 것은 실제로 함수에 전달하는 것이 배열의 복사본이 아니라 원래 배열의 주소이기 때문에 참조에 의한 전달(pass-by-reference) 기능을 효과적으로 모방한다. 다음 doubleInts()의 구현은 파라미터가 포인터가 아니라 배열인데도 원래 배열을 변경한다:
void doubleInts(int theArray[], size_t size)
{
for (size_t i { 0 }; i < size; i++) { theArray[i] *= 2; }
}
함수 프로토타입에서 theArray 뒤에 있는 대괄호 사이의 숫자는 간단히 무시된다. 다음 세 가지 버전은 동일하다:
void doubleInts(int* theArray, size_t size);
void doubleInts(int theArray[], size_t size);
void doubleInts(int theArray[2], size_t size);
당신은 왜 이런 식으로 작동하는지 궁금할 것이다. 함수 정의에 배열 구문이 사용될 때, 컴파일러가 배열을 복사하지 않는 이유는 무엇일까? 이는 효울성을 위해 수행된다. 배열의 요소를 복사하는 데 시간이 걸리고, 잠재적으로 많은 메모리를 차지한다. 항상 포인터를 전달함으로써, 컴파일러는 배열을 복사하기 위한 코드를 포함할 필요가 없다.
구문이 명확하지는 않지만, 스택 기반의 알려진 길이 배열을 "참조로(by reference)" 함수에 전달한는 방법이 있다. 힙 영역(free store) 기반 배열에서는 작동하지 않는다. 예를 들어, 다음 doubleIntsStack()은 크기가 4인 스택 기반 배열만 허용한다:
void doubleIntsStack(int (&theArray)[4]);
Chapter 12에서 자세히 설명하는 함수 템플릿은 컴파일러가 스택 기반 배열의 크기를 자동으로 추론할 수 있도록 사용될 수 있다:
template<size_t N>
void doubleIntsStack(int (&theArray)[N])
{
for (size_t i { 0 }; i < N; i++) { theArray[i] *= 2; }
}
요약하면, 배열 구문을 사용하여 선언된 배열은 포인터를 사용하여 액세스할 수 있다. 배열이 함수에 함수에 전달되면, 항상 포인터로 전달된다.
참고
C++20부터, C 스타일 배열을 함수에 전달해야 하는 경우, C 스타일 배열로 직접 전달하는 대신 함수에 std::span 유형의 파라미터를 갖는 것이 좋다. Chapter 18에서 논의한다. span은 배열과 그 크기에 대한 포인터를 래핑한다.
Not All Pointers Are Arrays! (모든 포인터가 배열은 아니다!)
이전 섹션의 doubleInts() 함수에서와 같이, 컴파일러에서 포인터가 예상되는 배열을 전달할 수 있으므로, 당신은 포인터와 배열이 동일하다고 믿게 될 수 있다. 사실, 미묘하지만 중요한 차이점이 있다. 포인터와 배열은 많은 속성을 공유하고 때때로 상호 교환적으로 사용할 수 있지만(앞에서 살펴본 것처럼), 동일하지는 않다.
포인터 자체는 의미가 없다. 임의의 메모리, 단일 개체 또는 배열을 가리킬 수 있다. 항상 포인터와 함께 배열 구문을 사용할 수 있지만, 포인터가 항상 배열이 아니기 때문에 이렇게 하는 것이 항상 적절한 것은 아니다. 예를 들어, 다음 코드 라인을 고려해 본다:
int* ptr { new int };
포인터 ptr은 유효한 포인터이지만, 배열은 아니다. 배열 구문(ptr[0])을 사용하여 가리키는 값에 액세스할 수 있지만, 이렇게 하면 스타일이 의심스럽고 실질적인 이점이 없어진다. 실제로 배열이 아닌 포인터와 함께 배열 구문을 사용하면 버그가 발생할 수 있다. ptr[1]의 메모리는 무엇이든 될 수 있다!
주의
배열은 자동으로 포인터로 참조되지만, 모든 포인터가 배열은 아니다.
LOW-LEVEL MEMORY OPERATIONS (저수준의 메모리 작업)
C에 비해 C++의 가장 큰 장점 중 하나는 메모리에 대해 크게 걱정할 필요가 없다는 것이다. 당신이 개체를 사용하여 코딩하는 경우, 각 개별 클래스가 자체 메모리를 적절하게 관리하는지 확인하기만 하면 된다. 생성(construction)과 소멸(destruction)을 사용하여, 컴파일러는 메모리 관리를 수행할 시기를 알려줌으로써 메모리 관리를 돕는다. Standard Library 클래스에서 알 수 있듯이, 클래스 내에서 메모리 관리를 숨기면 유용성이 크게 달라진다. 그러나 일부 애플리케이션이나 레거시 코드를 사용하면, 당신은 더 낮은 수준의 메모리로 작업해야 할 수도 있다. 레거시, 효율성, 디버깅 또는 호기심을 위해 로우 바이트 작업을 위한 몇 가지 기술을 알고 있으면 도움이 될 수 있다.
Pointer Arithmetic (포인터 산술)
C++ 컴파일러는 포인터 산술(pointer arithmetic)을 수행할 수 있도록 포인터의 선언된 유형을 사용한다. int에 대한 포인터를 선언하고 선언한 포인터를 1 증가시키면, 포인터는 1 바이트가 아닌 int의 크기만큼 메모리에서 앞으로 이동한다. 배열은 메모리안에서 동종의 데이터를 순차적으로 포함하기 때문에, 이러한 유형의 작업은 배열에 가장 유용하다. 예를 들어, 힙 영역(free store)에서 int 배열을 선언한다고 가정한다:
int* myArray { new int[8] };
당신은 인덱스 2에 있는 값을 설정하기 위한 다음 구문에 이미 익숙할 것이다:
myArray[2] = 33;
포인터 산술을 사용하면, 다음 구문을 동등하게 사용할 수 있다. 이 구문은 myArray의 "2개의 int에 앞선" 메모리에 대한 포인터를 얻은 다음 값을 설정하려고 이를 역참조한다:
*(myArray + 2) = 33;
개별 요소에 액세스하기 위한 대체 구문으로서 포인터 산술은 그리 매력적이지 않은 것 같다. 포인터 산술의 진정한 힘은 myArray + 2와 같은 표현식이 여전히 int에 대한 포인터이고 따라서 더 작은 int 배열을 나타낼 수 있다는 사실에 있다.
와이드 문자열을 사용하는 예를 살펴본다. 와이드 문자열은 Chapter 21, "String Localization and Regular Expressions"에서 논의되지만, 세부 사항은 이 시점에서 중요하지 않다. 지금은 와이드 문자열이 예를 들어 일본어 문자열을 나타내는 소위 유니코드 문자를 지원한다는 것을 아는 것으로 충분하다. wchar_t 유형은 이러한 유니코드 문자를 수용할 수 있는 문자 유형이며, 일반적으로 char보다 크다. 즉 1바이트 이상이다. 문자열 리터럴이 와이드 문자열 리터럴임을 컴파일러에 알리려면, 문자열 리터럴 앞에 L을 붙인다. 예를 들어, 다음과 같은 와이드 문자열이 있다고 가정한다:
const wchar_t* myString { L"Hello, World" };
더 나아가 와이드 문자열을 받을 수 있는 함수가 있고 입력(1)의 대문자 버전을 포함하는 새로운 문자열을 반환한다고 가정한다:
(1) Chapter 2, "Working with Strings and String and String Views"를 기억해보면, C 스타일 문자열은 0으로 종료된다. 즉 마지막 요소에 '\0'을 포함한다. 따라서 입력 문자열의 길이를 지정하기 위해 크기 파라미터를 함수에 추가할 필요가 없다. 이 함수는 '\0' 문자에 도달할 때까지 문자열의 개별 문자를 계속 반복한다.
wchar_t* toCaps(const wchar_t* text);
myString을 이 함수에 전달하여 문자열을 대문자로 만들 수 있다. 그러나 myString의 일부만 대문자로 만들려는 경우, 문자열의 뒷부분만 참조하는 포인터 산술을 사용할 수 있다. wchar_t는 일반적으로 1바이트보다 크지만, 다음 코드는 포인터에 7을 추가하여 와이드 문자열의 World 부분으로 toCaps()를 호출한다:
toCaps(myString + 7);
포인터 산술의 또 다른 유용한 애플리케이션에는 빼기를 포함된다. 동일한 유형의 다른 포인터에서 한 포인터를 빼면 두 포인터 사이의 절대 바이트 수가 아닌 두 포인터 사이에서 가리키고 있는 유형의 요소 수가 제공된다.
Custom Memory Management (사용자 메모리 관리)
99%의 경우에 대해(일부는 100%라고 말할 수도 있음) C++의 내장된 메모리 할당 기능은 사용하기에 적합하다. 이면에서 사용 가능한 메모리 목록을 유지하면서, new와 delete는 적절한 크기의 청크로 메모리를 할당하고 삭제 시 메모리 청크를 해당 목록으로 다시 해제하는 모든 작업을 수행한다.
리소스 제약 조건이 매우 엄격하거나 공유 메모리 관리와 같은 매우 특별한 조건에서 사용자 지정 메모리 관리를 구현하는 것이 실행 가능한 옵션일 수 있다. 걱정할 필요는 없다. 들리는 것처럼 무섭지 않다. 기본적으로 메모리를 직접 관리한다는 것은 클래스에서 많은 청크로 메모리를 할당하고 필요할 때 해당 메모리를 조각으로 나눠주는 것을 의미한다.
이 접근 방식이 더 나은 점은 무엇인가요? 자신의 메모리를 관리하면 잠재적으로 오버헤드를 줄일 수 있다. new를 사용하여 메모리를 할당할 때, 프로그램은 할당된 메모리 양을 기록하기 위해 소량의 공간도 별도로 설정해야 한다. 이렇게 하면 delete를 호출할 때, 적절한 양의 메모리를 해제할 수 있다. 대부분의 개체의 경우, 오버헤드는 할당되는 메모리보다 훨씬 작아 거의 차이가 없다. 그러나 작은 개체나 개체 수가 엄청나게 많은 프로그램의 경우 오버헤드가 영향을 미칠 수 있다.
메모리를 직접 관리할 때, 각 개체의 크기를 선험적으로 알 수 있으므로 각 개체에 대한 오버헤드를 피할 수 있다. 많은 수의 작은 개체의 경우 그 차이가 엄청날 수 있다. 사용자 지정 메모리 관리를 수행하려면 Chapter 15, "Overloading C++ Operators"의 항목인 new와 delete 연산자를 오버로드해야 한다.
Garbage Collection (가비지 컬렉션)
가비지 컬렉션(garbage collection)을 지원하는 환경에서는 프로그래머가 개체와 관련된 메모리를 명시적으로 해제하는 경우가 거의 없다. 대신, 더 이상 참조가 없는 개체는 런타임 라이브러리에 의해 어느 시점에서 자동으로 정리된다.
C#과 Java에서와 같이, 가비지 컬렉션은 C++ 언어에 내장되어 있지 않다. 최신 C++에서는 스마트 포인터를 사용하여 메모리를 관리하는 반면, 레거시 코드에서는 new와 delete를 사용하여 개체 레벨에서의 메모리 관리를 볼 수 있다. shared_ptr(이 장의 뒷부분에서 설명함)과 같은 스마트 포인터는 메모리를 가비지 컬렉션하는 매우 유사한 기능을 제공한다. C++에서 진정한 가비지 컬렉션을 구현하는 것은 가능하지만 쉽지는 않고, 메모리를 해제하는 작업에서 벗어나도 아마도 새로운 골칫거리가 생길 것이다.
가비지 컬렉션에 대한 한 가지 접근 방식은 마크와 스윕(mark and sweep)이라고 한다. 이 접근 방식을 사용하면, 가비지 컬렉션이 주기적으로 프로그램의 모든 단일 포인터를 검사하고 참조된 메모리가 여전히 사용 중이라는 사실에 마크를 추가한다. 검사 주기가 끝나면, 마크되지 않은 모든 메모리는 사용 중이 아닌 것으로 간주되어 해제된다. C++에서 이러한 알고리즘을 구현하는 것은 쉬운 일이 아니며, 잘못 수행하면 delete를 사용하는 것보다 훨씬 더 오류가 발생하기 쉬워진다.
가비지 컬렉션을 위한 안전하고 쉬운 메커니즘에 대한 시도가 C++에서 이루어지고 있지만, C++에서 가비지 컬렉션을 완벽하게 구현하더라도 모든 애플리케이션에 사용하기에 반드시 적합한 것은 아니다. 가비지 컬렉션의 단점은 다음과 같다:
- 가비지 컬렉션이 활발하게 실행 중이면, 프로그램이 응답하지 않을 수 있다.
- 가비지 컬렉션에는 소위 비결정적 소멸자가 있다. 개체는 가비지 컬렉션될 때까지 소멸되지 않으므로, 소멸자는 개체가 해당 범위를 벗어날 때 즉시 실행되지 않는다. 이는 소멸자가 수행하는 리소스 정리(예: 파일 닫기, 잠금 해제 등)가 미래의 불확실한 시간까지 수행되지 않음을 의미한다.
가비지 컬렉션 메커니즘을 작성하는 것은 매우 어렵다. 의심할 여지 없이 잘못 할 것이고, 오류가 발생하기 쉬울 것이며, 느려질 가능성이 높다. 따라서 애플리케이션에서 가비지 컬렉션 메모리를 사용하려면, 재사용할 수 있는 기존의 특수 가비지 컬렉션 라이브러리를 조사하는 것이 좋다.
Object Pools (개체 풀)
가비지 컬렉션은 소풍을 위해 접시를 사서 사용한 접시를 마당에 놔두면, 언젠가는 누군가 주워서 버리는 것과 같다. 분명히 메모리 관리에 대한 보다 생태학적 접근이 있어야 한다.
개체 풀은 재활용과 동일하다. 적당한 수의 접시를 구입하고 접시를 사용한 후에는 나중에 재사용할 수 있도록 세척한다. 개체 풀은 시간이 지남에 따라 동일한 유형의 개체를 많이 사용해야 하고, 각 개체를 생성하는 데 오버헤드가 발생하는 상황에 이상적이다.
Chapter 29, "Writing Efficient C++"에는 성능 효율성을 위한 개체 풀 사용에 대한 자세한 내용이 포함되어 있다.
COMMON MEMORY PITFALLS (일반적인 메모리 함정)
new/delete/new[]/delete[]를 사용한 동적 메모리 처리와 로우 레벨 메모리 작업은 오류가 발생하기 쉽다. 메모리 관련 버그로 이어질 수 있는 상황을 정확히 지적하기는 어렵다. 모든 메모리 누수나 잘못된 포인터에는 고유한 뉘앙스가 있다. 메모리 문제를 해결하는 마법의 탄환은 없다. 이 섹션에서는 몇 가지 일반적인 문제의 범주와 문제를 감지하고 해결하는 데 사용할 수 있는 몇 가지 도구에 대해 설명한다.
Underallocating Data Buffers and Out-of-Bounds Memory Access (데이터 버퍼의 할당 부족과 범위를 벗어난 메모리 액세스)
할당 부족은 C 스타일 문자열의 일반적인 문제로, 프로그래머가 뒤에 오는 '\0' 센티널에 추가 문자를 할당하지 못할 때 발생한다. 문자열의 할당 부족은 프로그래머가 확실하게 고정된 최대 크기를 가정할 때도 발생한다. 기본으로 내장된 C 스타일 문자열 함수는 고정된 크기를 따르지 않으며, 문자열의 끝을 미지의 메모리에 운좋게 기록한다.
다음 코드는 할당 부족을 보여준다. 네트워크 연결에서 데이터를 읽어서 C 스타일 문자열에 넣는다. 이것은 네트워크 연결이 한 번에 적은 양의 데이터만 수신하기 때문에 루프에서 수행된다. 각 루프에서는 getMoreData()가 호출되며, 이는 동적으로 할당된 메모리에 대한 포인터를 반환한다. getMoreData()에서 nullptr이 반환되면, 모든 데이터가 수신된 것이다. strcat()은 첫 번째 인수로 주어진 C 스타일 문자열의 끝에 두 번째 인수로 주어진 C 스타일 문자열을 연결하는 C 함수이다. strcat()는 대상 버퍼가 충분히 클 것으로 예상한다.
char buffer[1024] { 0 }; // Allocate a whole bunch of memory
while (thre) {
char* nextChunk { getMoreData() };
if (nextChunk == nullptr) {
break;
} else {
strcat(buffer, nextChunk); // BUG! No guarantees against buffer overrun!
delete [] nextChunk;
}
}
이 예제에서 세 가지 방법으로 잠재적인 할당 부족 문제를 해결할 수 있다. 선호도 내림차순으로 나열하면 다음과 같다:
- C++ 스타일 문자열을 사용하여, 사용자를 대신하여 연결과 관련된 메모리를 처리한다.
- 버퍼를 전역 변수나 스택에 할당하는 대신 힙 영역(free store)에 할당한다. 남은 공간이 충분하지 않으면, 최소한 현재 내용과 새 청크를 담을 수 있는 만큼 충분히 큰 새 버퍼를 할당하고 원래 버퍼를 새 버퍼에 복사하고 새 내용을 추가한 다음 원래 버퍼를 삭제한다.
- 최대 개수('\0' 문자 포함)를 사용하고 이보다 더 많은 문자를 반환하지 않는 getMoreData() 버전을 만든다. 그런 다음 버퍼의 남은 공간과 현재 위치를 추적한다.
데이터 버퍼의 할당 부족은 일반적으로 범위를 벗어난 메모리 액세스로 이어진다. 예를 들어, 메모리 버퍼를 데이터로 채우는 경우 버퍼가 실제보다 크다고 가정하면 할당된 데이터 버퍼 외부에 쓰기를 시작할 수 있다. 메모리의 필수 부분이 덮어 쓰여지면, 프로그램이 충돌하는 것은 시간 문제일 뿐이다. 프로그램의 개체와 관련된 메모리를 갑자기 덮어쓰면, 어떤 일이 발생할 수 있는지 고려해 보라. 좋지 않다.
범위를 벗어난 메모리 액세스는 '\0' 종료 문자를 잃어버린 C 스타일 문자열을 처리할 때도 발생한다. 예를 들어, 부적절하게 종료된 문자열이 다음 함수에 전달되면 문자열을 'm' 문자로 채우고 문자열 뒤의 메모리 내용을 'm'자로 계속 채우면서 문자열 범위 밖의 메모리를 덮어쓴다.
void fillWithM(char* text)
{
int i { 0 };
while (text[i] != '\0') {
text[i] = 'm';
i++;
}
}
배열의 끝을 지나서 메모리에 쓰는 버그는 종종 버퍼 오버플로 오류(buffer overflow errors)라고 한다. 이러한 버그는 바이러스와 웜 같은 여러 유명한 맬웨어 프로그램에 의해 약용된다. 교활한 해커는 실행 중인 프로그램에 코드를 삽입하기 위해 메모리 일부를 덮어쓰는 기능을 이용할 수 있다.
주의
보호를 전혀 제공하지 않는 오래된 C 스타일 문자열과 배열을 사용하지 않는다. 대신 모든 메모리를 관리하는 C++ 문자열과 벡터 같은 현대적이고 안전한 구조를 사용한다.
Memory Leasks (메모리 누수)
메모리 누수를 찾아 수정하는 것은 C 또는 C++ 프로그래밍에서 가장 짜증나는 부분 중 하나일 수 있다. 당신의 프로그램이 마침내 작동하고 올바른 결과를 제공하는 것 같다. 그때, 당신의 프로그램이 실행되면서 점점 더 많은 메모리를 소모한다는 것을 알아차리기 시작한다. 당신의 프로그램에 메모리 누수가 있다.
메모리 누수는 메모리를 할당하고 해제하지 않을 때 발생한다. 처음에는 쉽게 피할 수 있는 부주의한 프로그래밍의 결과처럼 들린다. 결국, 당신이 작성하는 모든 클래스에서 모든 new 항목에 해당하는 delete가 있는 경우 메모리 누수가 없어야 한다. 맞나요? 사실 항상 그런 것은 아니다. 예를 들어, 다음 코드에서 Simple 클래스는 할당된 메모리를 해제하도록 적절하게 작성되어 있다. 그러나 doSomething()이 호출되면, outSimplePtr 포인터는 메모리 누수를 증명하기 위해 이전 Simple 개체를 삭제하지 않고 다른 Simple 개체로 변경된다. 개체에 대한 포인터를 잃어버리면 삭제하는 것이 거의 불가능하다.
class Simple
{
public:
Simple() { m_intPtr = new int {}; }
~Simple() { delete m_intPtr; }
void setValue(int value) { *m_intPtr = value; }
private:
int* m_intPtr;
};
void doSomething(Simple*& outSimplePtr)
{
outSimplePtr = new Simple {}; // BUG! Doesn`t delete the original.
}
int main()
{
Simple* simplePtr { new Simple{} }; // Allocate a Simple object
doSomething(simplePtr);
delete simplePtr; // Only cleans up the second object.
}
주의
이 코드는 데모용일 뿐이다. 제품용 품질 코드에서 m_intPtr과 simplePtr은 모두 로우 포인터가 아니라 이 장의 뒷 부분에서 논의되는 소위 스마트 포인터여야 한다.
이전 예제와 같은 경우, 메모리 누수는 아마도 프로그래머 사이의 소통 부족이나 부족한 코드 문서화로 인해 발생했을 것이다. doSomething() 호출자는 변수가 참조로 전달되었다는 사실을 깨닫지 못했을 수 있으므로 포인터가 재할당 될 것이라고 예상할 이유가 없다. 파라미터가 const가 아닌 포인터에 대한 참조라는 것을 알아 차렸다면, 이상한 일이 발생했다고 의심했을 수 있지만 doSomething() 주위에 이 동작을 설명하는 주석이 없다.
Finding and Fixing Memory Leaks in Windows with Visual C++ (Visual C++를 사용하여 Windows에서 메모리 누수를 찾아서 수정하기)
메모리 누수는 메모리를 쉽게 볼 수 없고, 어떤 개체가 사용되지 않고 원래 어디서 할당되었는지 확인할 수 없기 때문에 추적하기 어렵다. 그러나 당신을 위해 이를 수행할 수 있는 프로그램이 있다. 메모리 누수 감지 도구는 값비싼 전문 소프트웨어 패키지부터 무료로 다운로드 가능한 도구에 이르기까지 다양하게 있다. Microsoft Visual C++(2)로 작업하는 경우, 해당 디버그 라이브러리에 메모리 누수 감지 기능이 내장되어 있다. MFC 프로젝트를 만들지 않는 한, 이 메모리 누수 감지는 기본적으로 활성화되지 않는다. 다른 프로젝트에서 메모리 누수 감지를 활성화하려면 코드 시작 부분에 다음 세 라인을 포함하여 시작해야 한다:
(2) Community Edition이라는 무료 버전의 Microsoft Visual C++를 사용할 수 있다.
#define _CRTDBG_MAP_ALLOC
#include <cstdlib>
#include <crtdbg.h>
이 라인들은 보여지는 대로 정확한 순서여야 한다. 다음으로 new 연산자를 다음과 같이 재정의해야 한다:
#ifdef _DEBUG
#ifndef DBG_NEW
#define DBG_NEW new ( _NORMAL_BLOCK, __FILE__, __LINE__ )
#define DBG_NEW
#endif
#endif // _DEBUG
new 연산자의 재정의는 #ifdef _DEBUG 문 내에 있으므로, new의 재정의는 당신의 애플리케이션을 디버그 버전으로 컴파일할 때만 수행된다. 이것은 당신이 일반적으로 원하는 것이다. 릴리스 빌드는 일반적으로 성능 저하로 인해 메모리 누수 감지를 수행하지 않는다.
마지막으로 해야 할 일은 다음 라인을 당신의 main() 함수 첫 번째 줄에 추가하는 것이다:
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
이는 애플리케이션이 종료될 때 감지된 모든 메모리 누수를 디버그 출력 콘솔에 기록하도록 Visual C++ CRT(C RunTime) 라이브러리에 지시한다. 이전 leaky 프로그램의 경우, 디버그 콘솔에 다음과 유사한 라인이 포함된다:
Detected memory leasks!
Dumping objects ->
c:\leaky\leaky.cpp(15) : {147} normal block at 0x014FABF8, 4 bytes long.
Data: < > 00 00 00 00
c:\leaky\leaky.cpp(33) : {146} normal block at 0x014F5048, 4 bytes long.
Data: <Pa > 50 61 21 01
Object dump complete.
출력은 메모리가 할당되었지만 할당 해제되지 않은 파일과 라인을 명확하게 보여준다. 라인 번호는 파일 이름 바로 뒤에 있는 괄호 사이에 있다. 중괄호 사이의 숫자는 메모리 할당에 대한 카운터이다. 예를 들어, {147}은 프로그램이 시작된 이후 147번째 할당을 의미한다. 당신은 특정 할당이 수행될 때 디버거를 중단하도록 VC++ 디버그 런타임에 지시하기 위해 VC++ _CrtSetBreakAlloc() 함수를 사용할 수 있다. 예를 들어, 147번째 할당에서 중단하도록 디버거에 지시하기 위해 main() 함수의 시작 부분에 다음 라인을 추가할 수 있다:
_CrtSetBreakAlloc(147);
이 leaky 프로그램에는 두 개의 메모리 누수가 있다. 절대 삭제되지 않는 첫 번째 Simple 개체(33라인)와 이것이 생성하는 힙 영역(free store)의 정수(15라인)이다. Visual C++ 디버거 출력 창에서 메모리 누수 중 하나를 더블 클릭하면, 자동으로 코드의 해당 라인으로 이동한다.
물론 Microsoft Visual C++(이 섹션에서 설명됨)와 Valgrind(다음 섹션에서 설명) 같은 프로그램은 실제로 메모리 누수를 수정할 수 없다. 그게 무슨 재미가 있을까요? 이러한 도구는 실제 문제를 찾는 데 사용할 수 있는 정보를 제공한다. 일반적으로 여기에는 원래 개체를 해제하지 않고 개체에 대한 포인터를 덮어쓴 위치를 찾기 위해 코드를 단계별로 실행하는 작업이 포함된다. 대부분의 디버거는 이러한 상황이 발생할 때 프로그램 실행을 중단할 수 있는 "watch point(감시 지점)" 기능을 제공한다.
Finding and Fixing Memory Leaks in Linux with Valgrind (Valgrind로 Linux에서 메모리 누수를 찾아서 수정하기)
Valgrind는 무엇보다도 당신의 코드에서 누수된 개체가 할당된 정확한 라인을 찾아내는 Linux용 무료 오픈 소스 도구의 예이다.
이전 leaky 프로그램에서 Valgrind를 실행하여 생성된 다음 출력은 메모리가 할당되었지만 해제되지 않은 정확한 위치를 보여준다. Valgrind는 동일한 2개의 메모리 누수를 발견한다. 첫 번째 Simple 개체는 삭제되지 않았고 이 개체가 생성하는 힙 영역(free store)의 정수는 다음과 같다:
==15606== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
==15606== malloc/free: in use at exit: 8 byte in 2 blocks.
==15606== malloc/free: 4 allocs, 2 frees, 16 bytes allocated.
==15606== For countsof detected errors, rerun with: -v
==15606== searching for pointers to 2 not-freed blocks.
==15606== checked 4455600 bytes.
==15606==
==15606== 4 bytes in 1 blocks are still reachable in loss record 1 of 2
==15606== at 0x4002978F: __builtin_new (vg_replace_malloc.c:172)
==15606== by 0x400297E6: operator new(unsigned) (vg_replace_malloc.c:185)
==15606== by 0x804875B: Simple::Simple() (leaky.cpp:4)
==15606== by 0x8048648: main (leaky.cpp:24)
==15606==
==15606==
==15606== 4 bytes in 1 blocks are definitely lost in loss record 2 of 2
==15606== at 0x4002978F: __builtin_new (vg_replace_malloc.c:172)
==15606== by 0x400297E6: operator new(unsigned) (vg_replace_malloc.c:185)
==15606== by 0x8048633: main (leaky.cpp:20)
==15606== by 0x4031FA46: __libc_start_main (in /lib/libc-2.3.2.so)
==15606==
==15606== LEAK SUMMARY:
==15606== definitely lost: 4 bytes in 1 blocks.
==15606== possibly lost: 0 bytes in 0 blocks.
==15606== still reachable: 4 bytes in 1 blocks.
==15606== suppressed: 0 bytes in 0 blocks.
주의
메모리 누수를 피하기 위해 std::vector, array, string, 스마트 포인터(이 장의 뒷부분에서 설명) 그리고 기타 최신 C++ 구조를 사용하는 것이 좋다.
Double-Deletion and Invalid Pointers (이중 삭제와 유효하지 않은 포인터)
delete를 사용하여 포인터와 연결된 메모리를 해제하더라도 이 메모리는 프로그램의 다른 부분에서 사용할 수 있다. 그러나 현재 댕글링 포인터(dangling pointer)인 포인터를 계속 사용하려고 시도하는 것을 막을 수 있는 방법은 없다. 이중 삭제도 문제가 있다. 포인터에 대해 두 번째로 delete를 사용하는 경우, 프로그램이 다른 개체에 할당된 메모리를 해제할 수 있다.
이중 삭제와 이미 해제된 메모리 사용은 모두 증상이 즉시 나타나지 않을 수 있으므로 추적하기 어려운 문제이다. 비교적 짧은 시간 내에 두 번의 delete가 발생하는 경우, 연결된 메모리가 그렇게 빨리 재사용되지 않을 수 있기 때문에 프로그램이 잠재적으로 무한정 작동할 수 있다. 마찬가지로, 삭제된 개체가 삭제된 직후에 사용되는 경우 대부분 그대로 유지된다.
물론, 그러한 행동이 효과가 있거나 계속 작동한다는 보장은 없다. 메모리 할당자는 일단 개체가 삭제되면 그것을 보존할 의무가 없다. 작동하더라도 삭제된 개체를 사용하는 것은 매우 좋지 않은 프로그래밍 스타일이다.
이중 삭제와 이미 해제된 메모리 사용을 방지하려면 메모리 할당을 해제한 후 포인터를 nullptr로 설정해야 한다.
많은 메모리 누수 감지 프로그램은 해제된 개체의 이중 삭제와 사용을 감지할 수도 있다.
SMART POINTERS
이전 섹션에서 설명한 것처럼, C++에서 메모리 관리는 오류와 버그의 지속적인 원인이다. 이러한 버그 중 다수는 동적 메모리 할당과 포인터 사용으로 인해 발생한다. 당신의 프로그램에서 동적 메모리 할당을 광범위하게 사용하고 개체 간에 많은 포인터를 전달할 때, 각 포인터에 대해 정확하게 한 번 그리고 적절한 시기에 delete를 호출하는 것을 기억하기는 어렵다. 잘못된 결과는 심각하다. 동적으로 할당된 메모리를 두 번 이상 해제하거나 이미 해제된 메모리에 대한 포인터를 사용하면, 메모리 손상이나 치명적인 런타임 오류가 발생할 수 있다. 동적으로 할당된 메모리를 해제하는 것을 잊어 버리면 메모리 누수가 발생한다.
스마트 포인터는 동적으로 할당된 메모리를 관리하는 데 도움이 되며, 메모리 누수를 방지하기 위해 권장되는 기술이다. 개념적으로 스마트 포인터는 메모리와 같이 동적으로 할당된 리소스를 보유할 수 있다. 스마트 포인터가 범위를 벗어나거나 재설정되면, 보유한 리소스를 자동으로 해제할 수 있다. 스마트 포인터는 함수 범위에서 또는 클래스에서 데이터 멤버로 동적으로 할당된 리소스를 관리하는 데 사용할 수 있다. 또한 함수 인수를 통해 동적으로 할당된 리소스의 소유권을 전달하는 데 사용할 수도 있다.
C++는 스마트 포인터를 매력적으로 만드는 몇 가지 언어 기능을 제공한다. 첫째, 템플릿을 사용하여 모든 포인터 유형에 대한, 유형에 안전한(type-safe) 스마트 포인터 클래스를 작성할 수 있다(Chapter 12 참조). 둘째, 연산자 오버로딩(Chapter 15 참조)를 사용하여, 코드에서 스마트 포인터 개체를 마치 덤(dumb) 포인터인 것처럼 사용할 수 있도록 스마트 포인터 개체에 인터페이스를 제공할 수 있다. 특히, 클라이언트 코드에서 일반 포인터를 역참조하는 것과 같은 방식으로 스마트 포인터 개체를 역참조할 수 있도록 *, -> 그리고 [] 연산자를 오버로드할 수 있다.
스마트 포인터에는 여러 유형이 있다. 가장 간단한 유형은 리소스의 단독/고유 소유권을 갖는다. 리소스의 단일 소유자이기 때문에, 스마트 포인터는 참조된 리소스가 범위를 벗어나거나 재설정될 때 자동으로 해제할 수 있다. Standard Library는 고유 소유권(unique ownership) 의미론을 가진 스마트 포인터인 std::unique_ptr을 제공한다.
조금 더 발전된 유형의 스마트 포인터는 공유 소유권(shared ownership)를 허용한다. 즉 이러한 스마트 포인터 중 몇 개는 동일한 리소스를 참조할 수 있다. 이러한 스마트 포인터가 범위를 벗어나거나 재설정되면, 해당 리소스를 참조하는 마지막 스마트 포인터인 경우에만 참조된 리소스를 해제해야 한다. Standard Library는 공유 소유권을 지원하는 std::shared_ptr을 제공한다.
표준 스마트 포인터인 unique_ptr과 shared_ptr은 <memory>에 정의되어 있고 다음 섹션에서 자세히 설명한다.
참고
당신의 기본 스마트 포인터는 unique_ptr이어야 한다. 리소스를 실제로 공유해야 하는 경우에만 shared_ptr을 사용한다.
주의
리소스 할당 결과를 로우 포인터에 할당하지 않는다. 어떤 리소스 할당 방법을 사용하든, 항상 리소스 포인터를 unique_ptr이나 shared_ptr 중 하나의 스마트 포인터에 즉시 저장하거나 다른 RAII 클래스를 사용한다. RAII는 Resource Acquisition Is Initialization의 약자이다. RAII 클래스는 특정 리소스의 소유권을 가져오고 적절한 시기에 할당 해제를 처리한다. Chapter 32, "Incorporating Design Techniques and Frameworks."에서 논의되는 디자인 기법이다.
unique_ptr
unique_ptr은 리소스에 대한 단독 소유권을 갖는다. unique_ptr이 파괴(destroy)되거나 재설정(reset)되면, 리소스가 자동으로 해제된다. 한 가지 장점은 return 문이 실행되거나 예외가 발생하는 경우에도 메모리와 리소스가 항상 해제된다는 것이다. 예를 들어, 이것은 함수에 여러 개의 return 문이 있을 때, 각 return 문 이전에 리소스를 해제해야 한다는 것을 기억할 필요가 없기 때문에 코딩을 단순화한다.
일반적으로, unique_ptr 인스턴스에 단일 소유자가 있는 동적으로 할당된 리소스를 항상 저장한다.
Creating unique_ptrs
힙 영역(free store)에 Simple 개체를 할당하고 릴리스를 무시하여 노골적으로 메모리를 누수하는 다음 함수를 고려해본다:
void leaky()
{
Simple* mySimplePtr { new Simple{} }; // BUG! Memory is never released!
mySimplePtr->go();
}
때로는 당신의 코드에서 동적으로 할당된 메모리를 적절하게 할당 해제하고 있다고 생각할 수 있다. 불행하게도, 모든 상황에서 올바르지 않을 가능성이 크다. 다음 함수를 수행한다:
void couldBeLeaky()
{
Simple* mySimplePtr { new Simple{} };
mySimplePtr->go();
delete mySimplePtr;
}
이 함수는 Simple 개체를 동적으로 할당하고 개체를 사용한 다음 적절하게 delete를 호출한다. 그러나 이 예시에서는 여전히 메모리 누수가 발생할 수 있다. go() 메서드에서 예외가 발생하는 경우, delete 호출이 실행되지 않아 메모리 누수가 발생한다.
대신 std::make_unique() 도우미 함수를 사용하여 만든, unique_ptr을 사용해야 한다. unique_ptr은 모든 종류의 메모리를 가리킬 수 있는 일반 스마트 포인터이다. 이것이 클래스 템플릿이며, 함수 템플릿으로 make_unique()를 사용하는 이유이다. 둘 다 unique_ptr이 가리키는 메모리 유형을 지정하는 꺽쇠 괄호 < > 사이에 템플릿 파라미터가 필요하다. 템플릿은 Chapter 12에서 자세히 설명하지만, 스마트 포인터 사용 방법을 이해하는 데 이러한 세부 사항은 중요하지 않다.
다음 함수는 로우 포인터 대신 unique_ptr을 사용한다. Simple 개체는 명시적으로 삭제되지 않지만, unique_ptr 인스턴스가 범위를 벗어나면(함수 마지막에서 또는 예외가 발생하여) 자동으로 소멸자에서 Simple 개체의 할당을 해제한다.
void notLeaky()
{
auto mySimpleSmartPtr { make_unique<Simple>() };
mySimpleSmartPtr->go();
}
이 코드는 auto 키워드와 함께 make_unique()를 사용하므로 포인터 유형(이 경우 Simple)을 한 번만 지정하면 된다. 이것이 unique_ptr을 생성하기 위해 권장되는 방법이다. Simple 생성자에 파라미터가 필료한 경우, 파라미터를 make_unique()에 인수로 전달한다.
make_unique()는 값 초기화를 사용한다. 예를 들어, 기본 유형은 0으로 초기화되고 개체는 기본으로 생성된다. 예를 들어, 성능이 중요한 코드에서 이 값 초기화가 필요하지 않은 경우, C++20에서 도입된 기본 초기화를 사용하는 새로운 make_unique_for_overwrite() 함수를 사용할 수 있다. 기본 유형의 경우, 이는 개체가 여전히 기본으로 구성되는 동안 개체가 전혀 초기화되지 않고 해당 위치의 메모리에 있는 모든 항목을 포함한다는 것을 의미한다.
다음과 같이 해당 생성자를 직접 호출하여 unique_ptr을 만들 수도 있다. 이제 Simple은 두 번 언급되어야 한다.
unique_ptr<Simple> mySimpleSmartPtr { new Simple{} };
이 책의 앞부분에서 설명한 것처럼, 클래스 템플릿 인수 추론(CTAD)은 종종 컴파일러가 클래스 템플릿 생성자에 전달된 인수를 기반으로 클래스 템플릿에 대한 템플릿 유형을 추론하도록 하는 데 사용될 수 있다. 예를 들어, vector<int> v{1,2} 대신에 vector v{1,2}를 작성할 수 있다. CTAD는 unique_ptr에서 작동하지 않으므로, 템플릿 유형을 생략할 수 없다.
C++17 이전에는, 유형을 한 번만 지정해야 할 뿐만 아니라 안전상의 이유로 make_unique()를 사용해야 했다. foo()라는 함수에 대한 다음 호출을 고려해 본다:
foo(unique_ptr<Simple> { new Simple{} }, unique_ptr<Bar> { new Bar { data() } });
Simple이나 Bar의 생성자 또는 data() 함수가 예외를 발생하는 경우, 컴파일러 최적화에 따라 Simple이나 Bar 개체가 누수될 수 있다. make_unique()를 사용하면 어떤 것도 누수되지 않는다:
foo(make_unique<Simple>(), make_unique<Bar>(data()));
C++17부터, foo()에 대한 두 호출 모두 안전하지만, 더 읽기 쉬운 코드를 만들기 위해 여전히 make_unique() 사용하는 것이 좋다.
참고
unique_ptr을 생성하기 위해, 항상 make_unique()를 사용한다.
Using unique_ptrs
표준 스마트 포인터의 가장 큰 특징 중 하나는 사용자가 새로운 구문을 많이 배울 필요없이 엄청난 이점을 제공한다는 것이다. 스마트 포인터는 표준 포인터와 마찬가지로 여전히 역참조(* 또는 ->를 사용)될 수 있다. 예를 들어, 앞의 예제에서 -> 연산자는 go() 메서드를 호출하는 데 사용된다:
mySimpleSmartPtr->go();
표준 포인터와 마찬가지로, 다음과 같이 작성할 수도 있다:
(*mySimpleSmartPtr).go();
get() 메서드는 기본(underlying) 포인터에 직접 액세스하는 데 사용할 수 있다. 이는 로우 포인터가 필요한 함수에 포인터를 전달하는 데 유용할 수 있다. 예를 들어, 다음과 같은 함수가 있다고 가정한다:
void processData(Simple* simple) { /* Use the simple pointer... */ }
그러면 다음과 같이 호출할 수 있다:
processData(mySimpleSmartPtr.get());
unique_ptr의 기본(underlying) 포인터를 해제하고 선택적으로 reset()을 사용하여 다른 포인터(새로운 개체)로 변경할 수 있다. 다음은 예제이다:
mySimpleSmartPtr.reset(); // Free resource and set to nullptr
mySimpleSmartPtr.reset(new Simple{}); // Free resource and set to a new
// Simple instance
리소스의 기본(underlying) 포인터를 반환한 다음 스마트 포인터를 nullptr로 설정하는, unique_ptr의 release()를 사용하여 기본(underlying) 포인터와 연결을 끊을 수 있다. 효과적으로, 스마트 포인터는 리소스의 소유권을 잃게 되고, 따라서 사용자는 작업이 끝나면 리소스를 해제할 책임이 있다. 다음은 예제이다:
Simple* simple { mySimpleSmartPtr.release() }; // Release ownership()
// Use the simple pointer...
delete simple;
simple = nullptr;
unique_ptr은 고유한 소유권을 나타내므로, 복사할 수 없다! 그러나 (스포일러 경고), Chapter 9, "Mastering Classes and Objects"에서 자세히 설명된 것처럼 이동 의미론(move semantics)을 사용하여 unique_ptr을 다른 것으로 이동할 수 있다. 미리 보면, std::move() 유틸리티 함수는 다음 코드 스니펫에서와 같이 unique_ptr의 소유권을 명시적으로 이동하는 데 사용할 수 있다. 지금은 새로운 구문에 대해 걱정하지 않아도 된다. Chapter 9에서 모든 것이 명확해진다.
class Foo
{
public:
Foo(unique_ptr<int> data) : m_data { move(data) } {}
private:
unique_ptr<int> m_data;
};
auto myIntSmartPtr { make_unique<int>(42) };
Foo f { move(myIntSmartPtr) };
unique_ptr and C-Style Arrays
unique_ptr은 동적으로 할당된 이전 C 스타일의 배열을 저장하는 데 적합하다. 다음 예제에서는 동적으로 할당된 정수 10개의 C 스타일 배열을 보유하는 unique_ptr을 생성한다:
auto myVariableSizedArray { make_unique<int[]>(10) };
myVariableSizedArray의 유형은 unique_ptr<int[]>이고 배열 표기법을 사용하여 해당 요소에 액세스할 수 있다. 다음은 예제이다:
myVariableSizedArray[1] = 123;
배열이 아닌 경우와 마찬가지로, make_unique()는 std::vector와 유사하게 배열의 모든 요소에 대해 값 초기화를 사용한다. 기본 유형의 경우, 이는 0으로 초기화하는 것을 의미한다. C++20부터, make_unique_for_overwrite() 함수를 대신 사용하여 초기화된 기본값으로 배열을 생성할 수 있다. 이는 기본 유형에 대해 초기화되지 않음을 의미한다. 그러나 초기화되지 않은 데이터는 가능한 한 피해야 하므로 신중하게 사용해야 한다.
동적으로 할당된 C 스타일 배열을 저장하기 위해 unique_ptr을 사용할 수 있지만, 대신 std::array 또는 vector와 같은 Standard Library 컨테이너를 사용하는 것이 좋다.
Custom Deleters
기본적으로, unique_ptr은 표준 new와 delete 연산자를 사용하여 메모리를 할당하고 할당 해제한다. 사용자는 이 동작을 변경하여 고유한 할당과 할당 해제 함수를 사용할 수 있다. 다음은 예제이다:
int* my_alloc(int value) { return new int { value }; }
void my_free(int* p) { delete p; }
int main()
{
unique_ptr<int, decltype(&my_free)> myIntSmartPtr { my_alloc(42), my_free };
}
이 코드는 my_alloc()로 정수에 대한 메모리를 할당하고, unique_ptr은 my_free() 함수를 호출하여 메모리를 할당 해제한다. unique_ptr의 이 기능은 메모리뿐 아니라 다른 리소스 관리에도 유용하다. 예를 들어, unique_ptr이 범위를 벗어날 때 파일이나 네트워크 소켓 또는 무엇이든 자동으로 닫는(close) 데 사용할 수 있다.
불행하게도, unique_ptr을 사용하는 사용자 정의 삭제기(deleter)의 구문은 약간 어색할 수 있다. 사용자 정의 삭제기(deleter)의 유형을 템플릿 유형 파라미터로 지정해야 한다. 이 파라미터는 함수에 대한 포인터 유형으로 단일 포인터를 수락하고 void를 반환해야 한다. 이 예제에서는 decltype(&my_free)이 사용되었고 my_free() 함수에 대한 포인터 유형을 반환한다. shared_ptr과 함께 사용자 정의 삭제기(deleter)를 사용하는 것이 훨씬 쉽다. shared_ptr에 대한 다음 섹션에서는 shared_ptr을 사용하여 파일이 범위를 벗어날 때 파일을 자동으로 닫는 방법을 보여준다.
shared_ptr
경우에 따라, 여러 개체나 코드 조각은 동일한 포인터의 복사본이 필요하다. unique_ptr은 복사할 수 없으므로, 이러한 경우에 사용할 수 없다. 대신, std::shared_ptr은 복사할 수 있는 공유 소유권을 지원하는 스마트 포인터이다. 그러나 동일한 리소스를 참조하는 shared_ptr의 인스턴스가 여러 개 있는 경우, 실제로 리소스를 해제해야 하는 시기를 어떻게 알 수 있을까요? 이는 앞으로 다룰 "The Need for Reference Counting" 섹션의 주제인 소위 참조 카운팅을 통해 해결할 수 있다. 그러나 먼저 shared_ptr을 구성하고 사용하는 방법을 살펴본다.
Creating and Using shared_ptrs
당신은 unique_ptr과 비슷한 형식으로 shared_ptr을 사용할 수 있다. 하나를 생성하려면, shared_ptr을 직접 생성하는 것보다 효율적인 make_shared()를 사용한다. 예를 들면 다음과 같다:
auto mySimpleSmartPtr { make_shared<Simple>() };
주의
shared_ptr을 생성하려면, 항상 make_shared()를 사용한다.
unique_ptr과 마찬가지로, 클래스 템플릿 인수 추론은 shared_ptr에 대해 작동하지 않으므로 템플릿 유형을 지정해야 한다. make_shared()는 make_unique()와 유사한 값 초기화(value initialization)를 사용한다. 이것이 바람직하지 않은 경우, 기본 초기화로 make_unique_for_overwrite()와 유사한 C++20의 make_shared_for_overwrite()를 사용할 수 있다.
C++17부터는 unique_ptr로 할 수 있는 것처럼, shared_ptr을 사용하여 동적으로 할당된 C 스타일 배열에 대한 포인터를 저장할 수 있다. 또한, C++20부터는 make_unique()를 사용할 수 있는 것처럼 make_shared()를 사용할 수 있다. 그러나, 이제 shared_ptr에 C 스타일 배열을 저장할 수 있더라도 C 스타일 배열 대신 Standard Library 컨테이너를 사용하는 것이 좋다.
unique_ptr과 마찬가지로, shared_ptr은 get()과 reset() 메서드도 지원한다. 유일한 차이점은 reset()을 호출할 때, 마지막 shared_ptr이 파괴되거나 재설정될 때만 기본(underlying) 리소스가 해제된다는 것이다. 참고로 shared_ptr은 release()를 지원하지 않는다. use_count() 메서드를 사용하여 동일한 리소스를 공유하고 있는 shared_ptr의 인스턴스 수를 검색할 수 있다.
unique_ptr과 마찬가지로, 기본적으로 shared_ptr은 표준 new와 delete 연산자를 사용하여 메모리를 할당과 할당 해제하거나 C 스타일 배열을 저장하는 경우 new[]와 delete[]를 사용한다. 이 동작은 다음과 같이 변경할 수 있다:
// Implementations of my_alloc() and my_free() as before.
shared_ptr<int> myIntSmartPtr { my_alloc(42), my_free };
보이는 것처럼, 템플릿 유형 파라미터로 사용자 정의 삭제기(deleter)의 유형을 지정할 필요가 없으므로 unique_ptr을 사용하는 사용자 정의 삭제기(deleter)보다 훨씬 쉽다.
다음은 파일 포인터를 저장하기 위해 shared_ptr을 사용하는 예제이다. shared_ptr이 소멸(이 경우 범위를 벗어날 때)되면, 파일 포인터를 close()를 호출하여 자동으로 닫는다. C++에는 파일 작업(Chapter 13, "Demystifying C++ I/O")을 위한 적절한 객체 지향 클래스가 있다. 이러한 클래스는 자동으로 그들의 파일을 닫는다. 오래된 C 함수 fopen()과 fclose()를 사용하는 이 예제는 순수 메모리 외에도 shared_ptr를 사용할 수 있는 어떤 대상에 대한 데모를 제공하기 위한 것이다. 예를 들어 C++에 대안이 없고 리소스를 open/close하는 기능이 유사한, C 스타일 라이브러리를 사용해야 하는 경우 사용할 수 있다. 다음 예제와 같이 shared_ptr로 래퍼할 수 있다:
void close(FILE* filePtr)
{
if (filePtr == nullptr) { return; }
fclose(filePtr);
cout << "File closed." << endl;
}
int main()
{
FILE* f { fopen("data.txt", "w") };
shared_ptr<FILE> filePtr { f, close };
if (filePtr == nullptr) {
cerr << "Error opening file." << endl;
} else {
cout << "File opened." << endl;
// Use filePtr
}
}
The Need for Reference Counting
앞에서 간략하게 언급했듯이 shared_ptr과 같은 공유된 소유권을 가진 스마트 포인터가 범위를 벗어나거나 재설정될 때, 그것이 참조하는 마지막 스마트 포인터인 경우에만 참조된 리소스를 해제해야 한다. 이것은 어떻게 수행될까요? 한 가지 솔루션은 Standard Library의 스마트 포인터가 사용하는 참조 카운팅(reference counting)이다.
일반적인 개념으로, 참조 카운팅은 사용 중인 클래스나 특정 개체의 인스턴스 수를 추적하는 기술이다. 참조 카운팅 스마트 포인터는 실제 단일 포인터나 단일 개체를 참조하기 위해 생성된 스마트 포인터의 수를 추적하는 것이다. 이러한 참조 카운트 스마트 포인터가 복사될 때마다, 동일한 리소스를 가리키는 새로운 인스턴스가 생성되고 참조 카운터가 증가된다. 이러한 스마트 포인터의 인스턴스가 범위를 벗어나거나 재설정되면 참조 카운터가 감소한다. 참조 카운터가 0으로 떨어지면, 더 이상 리소스의 다른 소유자가 없으므로 스마트 포인터가 리소스를 해제한다.
참조 카운트 스마트 포인터는 이중 삭제와 같은 많은 메모리 관리 문제를 해결한다. 예를 들어 동일한 메모리를 가리키는 다음 두 개의 로우 포인터가 있다고 가정한다. Simple 클래스는 이 장의 앞부분에서 소개되었으며 인스턴스가 생성되고 소멸될 때 단순히 메세지를 출력한다.
Simple* mySimple1 { new Simple{} };
Simple* mySimple2 { mySimple1 }; // Make a copy of the pointer
두 로우 포인터를 모두 삭제하면 이중(중복) 삭제가 발생한다:
delete mySimple2;
delete mySimple1;
물론 (이상적으로는) 이와 같은 코드를 찾을 수는 없지만, 한 함수에서 이미 메모리를 삭제하는 동안 다른 함수에서 메모리를 삭제하는 등의 여러 계층의 함수 호출이 관련된 경우 발생할 수 있다.
shared_ptr 참조 카운트 스마트 포인터를 사용하면 이러한 이중 삭제를 피할 수 있다:
auto smartPtr1 { make_shared<Simple>() };
auto smartPtr2 { smartPtr1 }; // Make a copy of the pointer.
이 경우 두 스마트 포인터가 범위를 벗어나거나 재설정되면, Simple 인스턴스가 정확히 한 번만 해제된다.
이 모든 것은 관련된 로우 포인터가 없을 때만 올바르게 작동한다. 예를 들어, new를 사용하여 일부 메모리를 할당한 다음 동일한 로우 포인터를 참조하는 두 개의 shared_ptr 인스턴스를 생성한다고 가정한다:
Simple* mySimple { new Simple{} };
shared_ptr<Simple> smartPtr1 { mySimple };
shared_ptr<Simple> smartPtr2 { mySimple };
이 두 스마트 포인터는 소멸될 때 동일한 개체를 삭제하려고 시도한다. 컴파일러에 따라 이 코드 조각은 충돌할 수 있다. 다음과 같은 출력을 얻을 수 있다:
Simple constructor called!
Simple destructor called!
Simple destructor called!
이런! 생성자에 대한 한 번의 호출과 소멸자에 대한 두 번의 호출? unique_ptr에서도 같은 문제가 발생한다. 참조 카운트된 shared_ptr 클래스도 이런 식으로 작동한다는 사실에 놀랄 수도 있다. 그러나 이것은 올바른 동작이다. 여러 개의 shared_ptr 인스턴스가 동일한 메모리를 가리키도록 하는 유일한 안전한 방법은 단순히 이러한 shared_ptr을 복사하는 것이다.
Casting a shared_ptr
Aliasing
weak_ptr
'Programming Language > Professional C++' 카테고리의 다른 글
Professional C++ - 6. Designing for Reuse (0) | 2022.11.03 |
---|---|
Professional C++ - 5. Designing with Objects (0) | 2022.10.10 |
Professional C++ (0) | 2022.09.08 |
Professional C++ - 4. Designing Professional C++ Programs (0) | 2022.09.08 |
Professional C++ - 3. Coding with Style (0) | 2022.07.21 |