본문 바로가기
Object Oriented Programming(C++)/전문가를 위한 C++

전문가를 위한 C++ | Chapter 07 메모리 관리(상)

by continue96 2022. 1. 20.

7.1 동적 메모리 다루기

 메모리는 컴퓨터의 로우 레벨 구성 요소에 속하지만, 실력 있는 C++ 프로그래머가 되기 위해서는 동적 메모리를 처리하는 과정을 확실하게 이해하고 넘어가야 한다.

 

7.1.1 메모리 작동 과정

 이 책에서는 메모리 한 칸을 레이블이 달린 상자로 표현한다. 여기서 레이블은 그 메모리를 사용하는 변수의 이름에 해당한다. 그리고 상자에 담긴 데이터는 그 변수에 현재 저장된 값이다.

int i = 10;
int* ptr = nullptr;
ptr = new int;

 예를 들어 그림 7-1 왼쪽은 다음 코드를 실행한 후의 메모리 상태를 표현한 것이다. 지역 변수 i를 자동 변수(automatic variable)라고 부르며 스택에 저장된다. 프로그램 실행 흐름이 이 변수가 선언된 유효 범위(scope)를 벗어나면 할당된 메모리가 자동으로 해제된다. new 키워드를 사용하면 힙 메모리가 할당된다. ptr 변수를 스택에 생성하고 동적으로 생성된 힙 메모리를 가리키도록 설정한다. 여기서 ptr 변수는 여전히 스택에 있지만, 이 변수가 가리키는 값은 힙에 있다.

그림 7-1 메모리 작동 과정

 

 다음 코드는 포인터가 스택과 힙에 모두 있는 예를 보여준다. 먼저 정수 포인터에 대한 포인터를 ptr이란 변수로 선언한다. 그런 다음 정수 포인터를 담을 힙 메모리를 할당한 후, 그 메모리에 대한 포인터를 ptr에 저장한다. 이어서 이 메모리에 정수를 담을 힙 메모리를 동적으로 할당한다. 그림 7-1 오른쪽은 두 포인터 중 ptr은 스택에, *ptr은 힙에 있도록 구성한 상태를 보여준다.

int** ptr = new int*;
*ptr = new int;

 

7.1.2 메모리 할당과 해제

7.1.2.1 new와 delete

 변수에 필요한 메모리 블록을 할당하려면 new 키워드에 그 변수의 타입을 지정해서 호출한다. 그러면 할당된 메모리에 대한 포인터가 리턴된다. 힙 메모리를 해제하려면 delete 키워드에 해제할 메모리를 가리키는 포인터를 지정한다.

int* ptr = new int;
delete ptr;
ptr = nullptr;

 new의 리턴값을 무시하거나 그 포인터를 담았던 변수가 유효 범위를 벗어나면 할당했던 메모리에 접근할 수 없다. 이를 메모리 누수(memory leak)라 부른다.

 

CAUTION new로 메모리를 할당할 때 일반 포인터로 저장했다면 반드시 그 메모리를 해제하는 delete문을 짝을 이루도록 작성해야 한다.
NOTE 메모리를 해제한 포인터는 nullptr로 다시 초기화한다. 그러면 해제된 메모리를 가리키는 포인터를 다시 사용하는 실수를 막을 수 있다.

 

 7.1.2.2 malloc과 free

 C++는 여전히 malloc을 지원하지만 new를 사용하는 것이 바람직하다. new는 단순히 메모리를 할당하는데 그치지 않고 생성자를 호출해서 객체를 생성하기 때문이다. free와 delete의 관계도 이와 비슷한데, free는 객체의 소멸자를 호출하지 않는 반면 delete는 소멸자를 호출해서 객체를 정상적으로 제거한다.

Foo* mallocFoo = (Foo*)malloc(sizeof(Foo));
Foo* newFoo = new Foo();

 이 문장을 실행하면 malloc 함수는 메모리에서 일정한 영역만 따로 빼놓을 뿐 객체에 대해 알지도 못하고 관심도 없다. 반면 new를 호출한 문장은 적절한 크기의 메모리 공간이 할당될 뿐만 아니라 Foo의 생성자를 호출해서 객체를 생성한다.

 

CAUTION C++에서는 malloc과 free를 절대 사용하지 말고 new와 delete를 사용한다.

 

 7.1.2.3 메모리 할당 실패

 기본적으로 메모리 상황이 상당히 좋지 않을 때 new가 실패하면 프로그램이 종료된다. new로 요청한 만큼의 메모리가 없어서 익셉션이 발생하면 프로그램이 종료된다.

 익셉션이 발생하지 않는 버전의 new는 대신 nullptr를 리턴한다. 물론 익셉션을 던지는 버전보다 nothrow 버전을 사용할 때 이 상황을 처리해주지 않아 버그가 발생할 가능성이 높다. 이러한 이유로 표준 버전의 new를 사용하는 것이 바람직하다.

int* ptr = new(nothrow) int;

 

7.1.3 배열

 배열은 서로 타입이 같은 원소들을 변수 하나에 담아서 각 원소를 인덱스로 접근하게 해준다.

 

 7.1.3.1 기본 타입 배열

 프로그램에서 배열에 대한 메모리를 할당하면 실제 메모리에서도 연속된 공간을 할당한다. 이때 메모리 한 칸은 배열의 한 원소를 담을 수 있는 크기로 할당된다. 예를 들어 다섯 개의 int 값으로 구성된 배열을 지역 변수로 선언하면 스택에 메모리가 할당된다. 배열을 힙에 선언할 때도 비슷하다.

int array[5];
int* arrayPtr = new int[5];

 그림 7-2에서 볼 수 있듯이 힙에 저장한 배열은 원소가 저장된 위치만 다를 뿐 스택에 저장한 배열과 거의 같다.

그림 7-2 기본 타입 배열

 배열을 힙에 할당하면 실행 시간에 크기를 정할 수 있다는 장점이 있다. 여기서 명심할 점은 new[]를 호출한 만큼 delete[]를 호출해야 한다는 것이다. 반드시 배열을 다 쓰고 나면 delete[]를 호출해서 리턴 받은 메모리를 해제해야 한다.

delete[] arrayPtr;
arrayPtr = nullptr;

 

 7.1.3.2 객체 배열

 N개의 객체로 구성된 배열을 new[N]으로 할당하면 객체를 담기에 충분한 크기의 N개 블록이 연속된 공간에 할당된다. new[]로 객체 배열을 할당하면 디폴트 생성자로 형식에 맞게 초기화된 객체 배열을 가리키는 포인터가 리턴된다. 예를 들어 다섯 개의 Foo 객체로 구성된 배열을 할당하면 Simple 생성자가 다섯 번 호출된다.

class Foo {
public:
	Foo() { cout << "이것은 Foo 생성자입니다." << endl; }
	~Foo() { cout << "이것은 Foo 소멸자입니다." << endl; }
};

Foo* fooArray = new Foo[5];

그림 7-3 객체 배열

 앞서 설명했듯이 배열에 대한 메모리를 new[]로 할당하면 반드시 delete[]를 호출해서 메모리를 해제해야 한다. delete[]를 호출하면 할당된 메모리를 해제할 뿐만 아니라 각 원소의 객체마다 소멸자를 호출한다.

delete[] fooArray;
fooArray = nullptr;

 배열 버전의 delete[]를 사용하지 않고 delete를 사용하면 객체를 가리키는 포인터만 삭제한다고 여겨 첫 번째 원소에 대한 소멸자만 호출하는 등 프로그램이 이상하게 동작한다.

 

CAUTION new/new[]로 할당한 변수를 해제할 때는 반드시 delete/delete[]를 사용한다.

 

 7.1.3.3 다차원 배열

 다차원 배열이란 여러 개의 인덱스 값을 사용하도록 1차원 배열을 확장한 것이다.

 

① 다차원 스택 배열

 다음 코드는 2차원 배열을 스택에 생성하고 0으로 초기화한 후 코드로 접근하는 예를 보여준다.

char board[3][3] = { };
board[0][0] = 'O'; /* (0, 0)에 O를 둡니다. */
board[1][2] = 'X'; /* (1, 2)에 X를 둡니다. */

 스택에 생성한 2차원 배열의 메모리 상태는 그림 7-4와 같다. 실제 메모리는 두 개의 축을 사용하지 않고 주소가 지정된 1차원 배열처럼 나열된다. 다차원 배열의 한 원소에 접근할 때 각 인덱스는 다차원 배열에 속한 하위 배열에 접근할 요소로 사용한다.

그림 7-4 다차원 스택 배열

 

② 다차원 힙 배열

 동적으로 할당하는 다차원 배열은 포인터로 접근한다. 힙 배열에 대한 메모리 할당 방식은 스택 배열과 다르다. 힙에서는 메모리 공간이 연속적으로 할당되지 않기 때문에 스택 방식의 다차원 배열처럼 메모리를 할당하면 안 된다. 그림 7-5는 3x3 보드의 배열을 동적으로 할당하는 예를 보여준다.

char** board = new char[i][j]; /* 컴파일 에러 */

그림 7-5 다차원 힙 배열

 

 힙 배열의 첫 번째 인덱스에 해당하는 차원의 배열을 연속적인 공간에 먼저 할당한다. 그런 다음 이 배열의 각 원소에 두 번째 인덱스에 해당하는 타원의 배열을 가리키는 포인터를 저장한다. 여기서 첫 번째 차원의 배열이 가리키는 각 원소에 대한 메모리는 마치 1차원 힙 배열을 할당하듯이 일일이 할당해야 한다. 메모리를 해제할 때도 마찬가지로 delete[]로 하위 배열을 일일이 해제해야 한다.

char** allocateCharacterBoard(size_t x, size_t y) {
	char** board = new char*[x]; /* 첫 번째 차원의 배열을 할당합니다. */
	for (size_t i = 0; i < x; ++i) {
		board[i] = new char[y]; /* i번째 하위 배열을 할당합니다. */
	}
	return board;
}

void releaseCharacterBoard(char** board, size_t x) {
	for (size_t i = 0; i < x; ++i) {
		delete[] board[i]; /* i번째 하위 배열을 해제합니다. */
	}
	delete[] board; /* 첫 번째 차원의 배열을 해제합니다. */
}

 

 기존 C 스타일 배열은 메모리 안전성이 떨어지므로 가급적 사용하지 않는 것이 좋다. 코드를 새로 작성할 때는 std::array나 std::vector와 같은 C++ 표준 라이브러리에서 제공하는 컨테이너를 사용하기 바란다.

 

7.1.4 포인터

 포인터는 남용하기 쉬운 기능으로 악명이 높다. new를 호출하거나 스택에 생성된 것처럼 별도로 할당된 영역이 아닌 메모리 공간을 사용하면 객체를 저장하거나 힙으로 관리하는 메모리가 손상돼 프로그램이 제대로 작동하지 않게 된다.

 

 7.1.4.1 포인터 작동 방식

 포인터는 두 가지 관점으로 이해할 수 있다. 수학적 사고에 익숙한 사람은 포인터를 주소로 본다. 공간적 사고에 익숙한 사람은 포인터를 화살표로 생각하면 이해하기 쉽다. 그림 7-6은 메모리에 있는 포인터를 개념적으로 나타낸 것이다. *연산자로 포인터를 역참조하면 메모리에서 한 단계 더 들어가 볼 수 있다. &연산자를 사용하면 특정 지점의 주소를 얻을 수 있다. 이렇게 하면 메모리에 대한 참조 단계가 하나 더 늘어난다.

그림 7-6 포인터 작동 방식

 

 7.1.4.2 포인터 타입 캐스팅

 포인터는 단지 메모리 주소에 불과해서 타입을 엄격히 따지지 않는다. 포인터의 타입은 C 스타일 캐스팅을 이용해 얼마든지 바꿀 수 있다. 정적 캐스팅(static cast)을 사용하면 좀 더 안전한데, 관련 없는 데이터 타입으로 포인터를 캐스팅하면 컴파일 에러가 발생한다.

Spreadsheet* spreadsheetPtr = getSpreadsheet();
char* charPtr = (char*)spreadsheetPtr;
char* charPtr = static_cast<char*>(spreadsheetPtr); /* 컴파일 에러 */

 한편, 상속 관계에 있는 대상끼리 캐스팅할 때는 동적 캐스팅(dynamic cast)을 사용하는 것이 더 안전하다.

 

7.2 배열과 포인터의 두 얼굴

 배열과 포인터는 비슷하다. 좀 더 자세히 살펴보면 포인터와 배열은 공통점이 있다.

 

7.2.1 모든 배열은 포인터다

 힙 배열과 마찬가지로 스택 배열에 접근할 때도 포인터를 사용할 수 있다. 배열의 주소는 사실 첫 번째 원소에 대한 주소다. 컴파일러는 배열의 변수 이름을 보고 첫 번째 원소에 대한 주소를 가리킨다. 배열 문법으로 선언한 배열은 포인터로도 접근할 수 있다. 그리고 컴파일러는 함수로 전달하는 배열을 항상 포인터 취급한다.

 

 7.2.1.1 함수에 배열을 포인터로 전달하기

 스택 배열을 포인터로 접근하는 기능은 배열을 함수에 넘길 때 특히 유용하다. 다음 함수는 정수 배열을 포인터로 받는다.

void doubleInts(int* intArray, size_t size) { /* 배열을 포인터로 전달합니다. */
	for (size_t i = 0; i < size; ++i) {
		intArray[i] *= 2;
	}
}

int* heapArray = new int[5]{0, 1, 2, 3, 4};
doubleInts(heapArray, 5); /* 힙 배열을 전달합니다. */
delete[] heapArray;
heapArray = nullptr;

int stackArray[5] = {0, 1, 2, 3, 4};
doubleInts(stackArray, 5); /* 스택 배열을 전달합니다. */
doubleInts(&stackArray[0], 5);

 이 함수를 호출할 때 힙 배열을 전달하면 이미 포인터가 담겨 있어서 함수에 값으로 전달된다. 스택 배열을 전달하면 컴파일러가 배열 변수를 포인터로 변환한다.

 

 7.2.1.2 함수에 배열을 배열로 전달하기

 함수에 배열을 매개 변수로 전달하는 과정은 포인터를 매개 변수로 전달할 때와 비슷하다. 컴파일러는 배열을 함수로 전달하는 부분을 포인터로 취급한다. 포인터와 마찬가지로 함수에 배열을 전달하면 참조 전달 방식(call by reference) 효과가 나타나는데, 배열에 담긴 값을 변경하는 함수는 원본을 직접 수정하게 된다.

void doubleInts(int intArray[], size_t size) { /* 배열을 배열로 전달합니다. */
	for (size_t i = 0; i < size; ++i) {
		intArray[i] *= 2;
	}
}

 

7.2.2 모든 포인터가 배열은 아니다

 포인터는 임의의 메모리를 가리킬 수도 있고 객체나 배열을 가리킬 수도 있다. 포인터에 배열 문법을 적용해도 되지만, 포인터 자체가 배열은 아니기 때문에 부적절한 경우도 있다. 예를 들면 다음과 같다. ptr이라는 포인터는 정상적인 포인터지만 배열은 분명히 아니다.

int* ptr = new int; /* 이 포인터는 배열이 아닙니다. */
CAUTION 모든 배열은 포인터로 참조할 수 있지만, 모든 포인터가 배열은 아니다.

댓글