소년코딩

new와 delete를 사용한 동적 메모리 할당 (Dynamic memory allocation with new and delete)

동적 메모리(dynamic memory) 할당이 필요하다.

C++은 세 가지 기본 타입의 메모리 할당을 지원한다. 이 중 두 가지는 이미 봤다.

  • 정적 메모리 할당(static memory allocation)은 정적 변수와 전역변수에 대해 발생한다. 이러한 타입의 변수에 대한 메모리는 프로그램이 실행될 때 한 번 할당되며, 프로그램 수명 내내 지속한다.
  • 자동 메모리 할당(auto memory allocation)은 함수 매개 변수와 지역 변수에 대해 발생한다. 이러한 타입의 변수에 대한 메모리는 관련 블록을 입력할 때 할당되고, 블록을 종료할 때 필요에 따라 여러 번 해제된다.
  • 동적 메모리 할당(dynamic memory allocation)은 이 포스트의 주제다.

정적 및 자동 메모리 할당에는 두 가지 공통점이 있다.

  • 변수/배열의 크기는 컴파일 타임에 알아야 한다.
  • 메모리 할당 및 해제가 자동으로 수행된다. (변수가 인스턴스화/제거되는 경우)

외부(사용자 또는 파일) 입력을 처리할 때 이러한 제약 조건으로 인해 문제가 발생하는 상황이 생길 수 있다.

예를 들어, 문자열을 사용하여 누군가의 이름을 저장할 수 있지만, 입력할 때까지 이름의 길이를 알 수 없다. 또는 디스크에서 여러 레코드를 읽으려고 할 수 있지만, 실제로 얼마나 많은 레코드가 있는지 알 수 없다. 또한, 게임에서 얼마나 많은 몬스터를 가지는 배열을 만들어야 하는지 알 수 없다.

컴파일 시간에 모든 것의 크기를 선언해야 한다면, 할 수 있는 최선의 방법은 필요로 하는 변수의 최대 크기를 추측하고 충분하다고 가정하는 것이다.

char name[25]; // let's hope their name is less than 25 chars!
Record record[500]; // let's hope there are less than 500 records!
Monster monster[40]; // 40 monsters maximum
Polygon rendering[30000]; // this 3d rendering better not have more than 30,000 polygons!

위 프로그램은 몇 가지 이유로 좋지 않다.

첫째, 변수가 실제로 사용되지 않으면 낭비되는 메모리가 많다.

둘째, 고정 배열을 포함한 대부분의 일반 변수는 스택(stack)이라는 메모리 영역에 할당된다. 일반적으로 Visual Studio는 스택 크기를 1MB로 기본 설정하며, 이 크기를 초과하면 스택 오버플로가 발생하고 운영 체제가 프로그램을 종료한다.

Visual Studio에서 아래 프로그램을 실행하면 프로그램이 닫힌다.

int main()
{
    int array[1000000]; // allocate 1 million integers (probably 4MB of memory)
}

많은 프로그램, 특히 그래픽을 다루는 프로그램에서 메모리가 1MB로 제한되면 문제가 발생한다.

셋째, 가장 중요한 것은 인위적인 한계 및 또는 배열 오버플로가 발생할 수 있다는 것이다. 사용자가 디스크에서 600개의 레코드를 읽으려고 하지만 최대 500개의 레코드에 대해서만 메모리를 할당하면 어떻게 될까? 오류가 나거나, 500개의 레코드만 읽거나, 레코드 배열을 오버플로 하는 현상이 일어난다.

다행히는 이러한 문제는 동적 메모리 할당(dynamic memory allocation)을 통해 쉽게 해결할 수 있다.

동적 메모리 할당은 프로그램 실행 중에 필요한 메모리를 운영체제에 요청하는 방법이다. 이 메모리는 프로그램의 제한된 스택(stack) 메모리에서 할당되는 것이 아니라 힙(heap)이라는 운영체제에서 관리하는 훨씬 더 큰 메모리 풀에서 할당된다. 최신 시스템에서는 힙 크기가 기가바이트 단위가 될 수 있다.


Dynamically allocating single variables

단일 변수를 동적으로 할당하려면, new 연산자를 사용하면 된다.

new int; // dynamically allocate an integer (and discard the result)

위의 예제는 운영 체제에서 정수값의 메모리를 요청한다. new 연산자는 해당 메모리를 사용하여 객체를 만든 다음 할당된 메모리의 주소가 포함된 포인터를 반환한다.

할당된 메모리에 나중에 접근할 수 있도록 반환 값을 자체 포인터 변수에 할당한다.

int *ptr = new int; // dynamically allocate an integer and assign the address to ptr so we can access it later

그럼 다음 포인터를 역참조(dereference)하여 메모리에 접근할 수 있다.

*ptr = 7; // assign value of 7 to allocated memory

방금 할당된 메모리의 주소를 유지하는 포인터가 없으면 할당된 메모리에 접근할 수 없다.


How does dynamic memory allocation work?

컴퓨터에는 응용 프로그램에서 사용할 수 있는 메모리가 있다. 응용 프로그램을 실행할 때 운영 체제는 해당 메모리에 응용 프로그램을 로드한다. 응용 프로그램에서 사용하는 이 메모리는 다른 영역으로 나뉘며 각 영역은 다른 용도로 사용된다. 한 영역에는 코드가 포함되어 있다. 또 다른 영역은 정상적인 작업(어떤 함수가 호출되었는지 추적하고, 전역 변수와 지역 변수를 생성하고 소멸하는 등)에 사용된다. 나중에 더 자세한 설명을 할 예정이다.

메모리를 동적으로 할당할 때는 운영 체제에 프로그램 사용을 위해 해당 메모리의 일부를 예약하도록 요청해야 한다. 이 요청을 수행할 수 있으면 해당 메모리의 주소가 응용 프로그램에 반환된다. 그 시점부터, 응용 프로그램은 원하는 대로 이 메모리를 사용할 수 있다. 응용 프로그램이 메모리 사용을 완료하면 이 메모리를 다시 운영 체제로 반환하여 다른 프로그램에 제공할 수 있다.

정적 또는 자동 메모리와 달리 프로그램 자체는 동적으로 할당 된 메모리를 요청하고 처리하는 역할을 한다.


Initializing a dynamically allocated variable

변수를 동적으로 할당할 때 직접 초기화 또는 유니온 초기화를 통해 변수를 초기화할 수 있다.

int *ptr1 = new int (5);   // use direct initialization
int *ptr2 = new int { 6 }; // use uniform initialization

Deleting single variables

동적으로 할당된 변수를 모두 사용하면 메모리를 해제하여 재사용할 수 있도록 C++에 명시적으로 알려야 한다. 단일 변수의 경우 delete 연산자를 통해 수행된다.

// assume ptr has previously been allocated with operator new
delete ptr; // return the memory pointed to by ptr to the operating system
ptr = 0; // set ptr to be a null pointer (use nullptr instead of 0 in C++11)

What does it mean to delete memory?

delete 연산자는 실제로 아무것도 삭제하지 않는다. 단순히 가리키는 메모리를 다신 운영 체제로 반환하다. 그러면 운영 체제에서 해당 메모리를 다른 응용 프로그램에 다시 할당할 수 있다.

변수를 삭제하는 것처럼 보이지만 사실이 아니다! 포인터 변수는 여전히 이전과 동일한 범위를 가지며 다른 변수와 마찬가지로 새 값을 할당받을 수 있다.

동적으로 할당된 메모리를 가리키지 않는 포인터를 삭제하면 나쁜일이 발생할 수 있다.


댕글링 포인터 (Dangling pointers)

C++은 할당되지 않은 메모리의 내용이나 삭제되는 포인터의 값에 대해서는 보장하지 않는다. 운영 체제에 반환되는 메모리에는 반환되기 전의 값과 같은 값이 포함되며, 포인터는 현재 할당 해제된 메모리를 가리킨다.

할당이 해제된 메모리를 가리키는 포인터를 댕글링 포인터(dangling pointer)라고 한다. 댕글링 포인터를 역참조하거나 삭제하면 정의되지 않은 동작이 발생한다.

#include <iostream>

int main()
{
    int* ptr = new int; // dynamically allocate an integer
    *ptr = 7; // put a value in that memory location

    delete ptr; // return the memory to the operating system.  ptr is now a dangling pointer.

    std::cout << *ptr; // Dereferencing a dangling pointer will cause undefined behavior
    delete ptr; // trying to deallocate the memory again will also lead to undefined behavior.

    return 0;
}

위 프로그램에서 이전에 할당된 메모리에 할당된 7의 값은 여전할 수 있지만, 해당 메모리의 주소의 값이 변경되었을 수 있다. 또한, 메모리가 다른 응용 프로그램에 할당될 수 있으며 해당 메모리에 접근하려고 하면 운영 체제가 프로그램을 종료시킨다.

메모리를 할당 해제하면 여러 개의 댕글링 포인터가 생성될 수 있다.

#include <iostream>

int main()
{
    int *ptr = new int; // dynamically allocate an integer
    int *otherPtr = ptr; // otherPtr is now pointed at that same memory location

    delete ptr; // return the memory to the operating system.  ptr and otherPtr are now dangling pointers.
    ptr = 0; // ptr is now a nullptr

    // however, otherPtr is still a dangling pointer!

    return 0;
}

여기에 도움이 되는 몇 가지 좋은 방법이 있다.

먼저, 여러 포인터가 같은 동적 메모리를 가리키는 것을 피하자.

둘째, 포인터를 삭제할 때 포인터를 0 또는 nullptr로 설정하자.


Operator new can fail

드문 경우지만 운영 체제에 메모리를 요청할 때 운영 체제에 메모리가 없을 수 있다.

기본적으로 new가 실패하면 bad_alloc 예외가 throw된다. 이 예외가 제대로 처리되지 않으면 예외 또는 예외 처리를 아직 다루지 않았으므로 프로그램이 오류로 종료된다.

많은 경우, 예외를 던지는 것은 바람직하지 않다. 대신 메모리를 할당할 수 없는 경우 null 포인터를 반환 하도록하는 새로운 형식의 new가 있다. 이것은 new 키워드와 할당 유형 사이에 상수 std::nothrow를 추가하여 사용한다.

int* value = new (std::nothrow) int; // value will be set to a null pointer if the integer allocation fails

위의 예제에서 new가 메모리를 할당하지 못하면 할당된 메모리의 주소 대신 널 포인터(null pointer)를 반환한다.

널 포인터를 역참조하려고 하면, 정의되지 않은 동작이 발생한다. 따라서 모든 메모리 요청이 실제로 성공했는지 확인한 후 할당된 메모리를 사용하는 것이 좋다.

int* value = new (std::nothrow) int; // ask for an integer's worth of memory
if (!value) // handle case where new returned null
{
    // Do error handling here
    std::cout << "Could not allocate memory";
}

하지만 메모리 할당 실패는 매우 드문 경우이므로 위와 같은 확인작업을 안 하는 게 일반적이다.


Null pointers and dynamic memory allocation

널 포인터는 동적 메모리 할당을 처리할 때 유용하다. 동적 메모리 할당과 관련하여 널 포인터는 기본적으로 "이 포인터에 메모리가 할당되지 않았다."를 의미한다.

이렇게 하면 조건부로 메모리를 할당하는 작업을 할 수 있다.
// If ptr isn't already allocated, allocate it
if (!ptr)
    ptr = new int;

널 포인터는 삭제되지 않는다. 따라서 다음과 같이 할 필요가 없다.

if (ptr)
    delete ptr;
대신, 다음과 같이 작업하면 된다.
delete ptr;

ptrnull이 아닌 경우, 동적으로 할당된 변수가 삭제된다. 만약 null이라면, 아무 일도 일어나지 않는다.


메모리 누수 (Memory leak)

동적으로 할당된 메모리는 범위가 없다. 즉, 명시적으로 할당이 해제되거나 프로그램이 종료될 때까지 할당된 상태를 유지한다. 그러나 동적으로 할당된 메모리 주소를 유지하는 데 사용되는 포인터는 일반 변수의 범위 지정 규칙을 따른다. 이 불일치는 흥미로운 문제를 일으킬 수 있다.

void doSomething()
{
    int* ptr = new int;
}

이 함수는 정수를 동적으로 할당하지만, delete를 사용해서 해제하지는 않는다. 포인터 ptr은 일반 변수와 같은 규칙을 따르므로, 함수가 끝나면 ptr은 범위를 벗어난다. ptr은 동적으로 할당된 정수의 주소를 보유하는 유일한 변수이기 때문에, ptr이 삭제되면 동적으로 할당 된 메모리에 대한 참조가 더 이상 존재하지 않는다. 이것은 프로그램이 동적으로 할 된 메모리의 주소를 '잃어버리는(lost)'는 것을 의미한다. 결과적으로 이 동적으로 할당된 정수는 해제할 수 없다.

이것을 '메모리 누수(memory leak)'라고 한다. 메모리 누수는 프로그램이 운영 체제로 되돌리기 전에 동적으로 할당된 메모리의 일부 비트 주소를 잃어버리면 발생한다. 이런 일이 발생하면 프로그램은 동적으로 할당된 메모리를 다시는 알 수 없으므로 동적으로 할당 된 메모리를 해제할 수 없다. 운영 체제는 이 메모리를 프로그램에서 여전히 사용 중이므로 이 메모리를 사용할 수 없다.

메모리 누수는 프로그램이 실행되는 동안 사용 가능한 메모리를 소모하므로 이 프로그램뿐만 아니라 다른 프로그램에서도 사용할 수 있는 메모리가 줄어든다. 심각한 메모리 누수 문제가 있는 프로그램은 사용 가능한 모든 메모리를 먹어 전체 시스템이 느리게 실행되거나 충돌할 수 있다. 프로그램이 종료된 후에만 운영 체제가 모든 누출 메모리를 정리하고 '회수(reclaim)' 할 수 있다.

메모리 누수 발생은 주소를 잃어버림으로써 생기기도 하지만 다른 원인도 있다. 예를 들어, 동적으로 할당된 메모리의 주소를 보유한 포인터에 다른 값이 할당되면 메모리 누수가 발생할 수 있다.

int value = 5;
int* ptr = new int; // allocate memory
ptr = &value;       // old address lost, memory leak results

이 문제는 포인터를 재할당하기 전에 해제하여 해결할 수 있다.

int value = 5;
int* ptr = new int; // allocate memory
delete ptr;         // return memory back to operating system
ptr = &value;       // reassign pointer to address of value

또한, 이중 할당을 통해 메모리 누수가 발생할 수 있다.

int* ptr = new int;
ptr = new int; // old address lost, memory leak results

두 번째 동적 할당에서 반환된 주소는 첫 번째 동적 할당된 주소를 덮어쓴다. 따라서 첫 번째 동적 할당은 메모리 누수가 된다!

마찬가지로 재할당하기 전에 포인터를 해제하면 이러한 문제를 방지할 수 있다.


요약 (Summary)

new 연산자와 delete 연산자를 사용하면 프로그램에 단일 변수를 동적으로 할당∙해제할 수 있다.

동적으로 할당된 메모리는 범위가 없으며, 할당을 해제하거나 프로그램이 종료될 때까지 할당된 상태를 유지한다.

댕글링 포인터나 널 포인터를 역참조하지 않도록 주의해야 한다.


cpp 번역: 이 포스트의 원문은 http://www.learncpp.com/cpp-tutorial/69-dynamic-memory-allocation-with-new-and-delete/ 입니다. 입니다.

댓글 로드 중…

블로그 정보

소년코딩 - 소년코딩

소년코딩, 자바스크립트, C++, 물리, 게임 코딩 이야기

최근에 게시된 이야기