<CPP>/BASIC

14. 할당, 해제

CodeGrimie 2021. 1. 15. 12:34

C와 CPP를 사용하는 또 다른 이유인 동적 할당을 정리한다.

동적 할당은 양날의 검으로 숙련자가 사용하면 극한의 최적화를 구현할 수 있는 반면 반대의 경우는 극한의 발적화를 이룰 수 있다.

어찌 되었던 극한이란 타이틀을 얻을 수 있는 점에서 가치는 있다.(?)

 

동적 할당을 이해하기 위해서는 먼저 메모리 저장 공간을 이해해야 한다.

메모리 저장 공간

▼ 메모리 저장 공간 예시 코드

#include <cstdio>
#include <cstdlib>

// ++ Data 영역 ++
const static int gData = 1; 

int main()
{
    // ++ Stack 영역 ++
    int lData = 2;

    // ++ Heap 영역 ++
    // C WAY
    int* dDataWithC = static_cast<int*>(malloc(sizeof(int) * 1));
    if (dDataWithC != NULL)
    {
        *dDataWithC = 3;
        printf("dDataWithC   = %d\n", *dDataWithC);
    }
    free(dDataWithC);

    // CPP WAY
    int *dDataWithCPP = new int;
    *dDataWithCPP = 4;
    printf("dDataWithCPP = %d\n", *dDataWithCPP);
    delete dDataWithCPP;

    return (0);
}

아주 간단한 3가지 메모리 영역을 모두 사용하는 코드다.

하나씩 영역을 나눠서 분석해본다.

Data 영역

▼ Data 영역 예시 코드

const static int gData = 1; 

Data 영역은 프로그램이 시작될 때 할당돼서 프로그램이 종료될 때 해제되는 경우로 주로 전역 변수/상수들이 저장된다.

전역 변수/상수는 프로그램 전체에서 사용되는 만큼 한번 할당하면 중간에 임의로 메모리가 해제되지 않는다.

그래서 프로그램이 시작되는 순간 Data 영역에 저장하고 넘어가버린다.

 

게임에서 무분별하게 전역 변수/상수를 사용하면 안 되는 이유도 동일하다.

Data에 위치하는 메모리의 크기가 클수록 게임이 필요로 하는 메모리의 용량도 커진다.

게임에서 꼭 필요한 전역 변수/상수가 인지 생각하는 것이 게임 코드 최적화의 시작이다.

Stack 영역

▼ Stack 영역 예시 코드

int main()
{
    lData = 2;
    {
        lData2 = 3;
    }
}

Stack 영역은 나중에 메모리를 할당할수록 먼저 해제된다.

주로 할당과 해제가 문법적으로 자동으로 작동하는 지역 변수, 함수의 매개변수 등이 저장된다.

 

요즘엔 잘 보이지 않지만 종이컵을 버리는 수거함을 떠올리면 쉽다.

 

▼ 종이컵 수거함

Stack은 쌓이는 것을 의미하는데 종이컵이 위로 하나씩 쌓이는 것을 직관적으로 이해할 수 없다.

현실에서 그럴 리는 없겠지만 종이컵을 다시 꺼내려고 할 때에도 위에서 하나씩 꺼낼 수 있는 것도 판박이다.

 

▼ 할당과 해제가 명확하다.

int main()
{
    // lData1 메모리 할당
    lData1 = 1;
    {
        // lData2 메모리 할당
        lData2 = 2;
    }	// lData2 메모리 해제
    return (0);
}   // lData1 메모리 해제

일전에 괄호 편에서 정리했듯이 {중괄호}의 기능은 코드 문단의 시작과 끝을 지정하는 것이다.

다르게 말하면 {가 나오면 차례대로 Stack에 할당하고 }가 나오면 차례대로 Stack에서 해제한다.

[lData1 할당] - [lData2 할당] - [lData2 해제] - [lData1 해제]

메모리의 할당, 해제가 프로그래머가 아니라 컴파일러가 문법에 따라 직접 하고 있는 것이다.

Heap 영역

▼ Heap 영역 예시 코드

#include <cstdlib>

int main()
{
    // ++ Heap 영역 ++
    // C WAY
    int* dDataWithC = static_cast<int*>(malloc(sizeof(int) * 1));
    if (dDataWithC != NULL)
        *dDataWithC = 3;
    free(dDataWithC);

    // CPP WAY
    int *dDataWithCPP = new int;
    *dDataWithCPP = 4;
    delete dDataWithCPP;
}

Heap은 Stack과 반대로 먼저 생성한 메모리는 먼저 해제된다.

그리고 문법적으로 메모리의 할당과 해제를 하는 것이 아니라 프로그래머가 임의로 할당과 해제를 진행할 수 있다.

오늘 정리할 동적 할당도 이 Heap에 저장된다.

 

마찬가지로 종이컵 분배기를 생각하면 쉽다.


▼ 종이컵 분배기

종이컵을 채워 넣는 것은 위에서 채워 넣지만 빼는 곳은 아래다.

이렇게 구조를 가진 것은 동적 할당된 메모리는 다 썼을 경우 빠르게 해제하는 것을 목표로 하기 때문이다.

사용을 다한 메모리를 바로 해제함으로써 게임에서 메모리 사용량을 극적으로 최적화할 수 있다.

C와 CPP로 잘 짜인 게임의 경우엔 심하면 100배 이상 메모리 사용량이 차이나기도 한다.

 

이제 실제로 동적 할당을 해보자.

C 문법 메모리 할당(malloc), 해제(free)

C와 CPP에서 할당과 해제를 위해 사용하는 함수가 다르다.

그리고 각각의 방법에 따른 장단점이 명확하게 있으므로 둘 다 파악하는 것이 바람직하다.

 

▼ Malloc과 Free

#include <cstdlib>

int main()
{
    int* dDataWithC = static_cast<int*>(malloc(sizeof(int) * 1));
    if (dDataWithC != NULL)
        *dDataWithC = 3;
    free(dDataWithC);
    
    return (0);
}

malloc은 Memory Allocate의 약어로 메모리를 할당한다는 직관적인 뜻을 가지고 있다.

 

프로그래머가 할당할 메모리의 크기와 순간을 지정하기 때문에 해제 또한 프로그래머가 반드시 해줘야 한다.

Stack에 저장되는 생명주기가 확실한 경우와 달리 동적 할당된 변수는 언제 해제할지 컴파일러가 전혀 모르기 때문이다.

 

그렇기 때문에 언제나 malloc과 free는 세트로 다녀야 한다.

 

▼ 고객님, 단품 구매는 불가능합니다.

char *c = static_cast<char*>(malloc(sizeof(char) * 1));
free(c);

CPP 프로그래머라면 꿈에서도 할당과 해제를 한다는 말이 있다고 한다.

잘 때 꿈을 저장할 메모리를 할당하고 일어나면 해제해서 꿈을 지워버리는 것이다.

(사실은 그냥 꿀잠 잔거지만)

 

▼ malloc은 void* 형이기 때문에 반드시 명시적 형 변환을 해줘야 한다.

int* dDataWithC = static_cast<int*>(malloc(sizeof(int) * 1));

static_cast <변수형>을 사용해서 명시적 형 변환을 해주고 있다.

기본적으로 어느 변수형도 메모리 할당을 할 수 있도록 함수 자체가 void*형이기 때문에 실제로 대입하여 사용하려면 반드시 명시적 형 변환을 해줘야 한다.

 

이 할당된 메모리에 값을 대입할 때 if문을 통해서 할당한 메모리가 NULL인지 아닌지 확인하는 부분이 있다.

 

▼ 코드의 실수를 줄이기 위한 NULL 확인

if (dDataWithC != NULL)
    *dDataWithC = 3;

이는 비주얼 스튜디오에서 발생시키는 경고(C6011)로 C의 빈약한 에러 검출 때문이라고 한다.

malloc은 메모리 부족으로 할당이 실패하면 NULL이 반환되는데 그 경우에 생기는 문제를 방지하기 위해서 NULL 체크를 진행한다.

 

docs.microsoft.com/ko-kr/cpp/code-quality/c6011?view=msvc-160&viewFallbackFrom=vs-2019

 

C6011

Visual Studio c + + 코드 분석 경고 C6011에 대 한 참조입니다.

docs.microsoft.com

마이크로소프트 사를 비롯한 애플, 아마존, 구글과 같은 대기업의 권고에 따르면 CPP에서는 malloc, free를 쓰는 일이 되도록이면 없어야 한다고 한다.

이 권고는 대규모 게임회사에서도 철저하게 지키고 있는데 대표적으로 유비소프트가 있다.

CPP 문법 메모리 할당(new), 해제(delete)

CPP 위원회에서는 C의 이 malloc과 free가 너무 많은 실수를 만들 수 있다는 가능성이 별로 맘에 안 들었던 것 같다.

그래서 아예 새로운 메모리 할당 함수를 만들었는데 이게 바로 new와 delete이다.

 

▼ New와 Delete

int main()
{
    
    int* dDataWithCPP = new int;
    *dDataWithCPP = 4;
    printf("dDataWithCPP = %d\n", *dDataWithCPP);
    delete dDataWithCPP;

    return (0);
}

코드 구조는 같지만 길이만 약간 짧아졌다.

하지만 New의 장점은 자동으로 형 변환이 되어있다는 점에 있다.

 

심지어는 할당할 때 초기화 값을 줄 수도 있다.

 

▼ New를 통한 초기화

int main()
{
    char* dChar = nullptr;
    dChar = new char[5];
    delete [] dChar;

    int* dInt = new int[0, 1, 2, 3, 4];
    delete [] dInt;

    return (0);
}

CPP에서는 좀 더 명확하게 메모리 제어를 가능하도록 도와주기 위해 메모리 주소 NULL은 nullptr이라고 따로 있다.

C 메모리 할당 함수와 CPP 메모리 할당 함수 차이

이렇게만 보면 코드 작성의 형태만 다를 뿐 큰 차이는 없어 보인다.

CPP의 new, delete는 C의 malloc, free에 비해서 몇 가지 제약 사항이 있다.

 

좀 더 엄격한 에러를 검출하고 실수를 줄일 수 있기 위해 만든 만큼 실수를 만들 수 있는 C의 몇몇 메모리 할당 함수들을 사용할 수 없다.

 

예를 들자면 realloc이라고 하는 메모리 크기를 재할당 하는 함수도 new는 사용할 수 없다.

하지만 CPP 위원회에서는 메모리 크기를 재할당하는 경우를 애초에 만들지 않기를 권장한다.

20210119 추가 : 보다 안정적인 코드를 위한 방법들이다.

할당은 순서대로 해제는 역순으로

▼ 할당을 순서대로 하고 해제는 역순으로 하는 게 좋다.

int main()
{
    int* A = new int[2];
    int* B = new int[2];
    int* C = new int[2];

    delete[] C;
    delete[] B;
    delete[] A;

    return 0;
}

메모리문제 방지

동적 할당은 메모리에 직접적으로 접근하기 때문에 위험한 경우가 많다.

 

▼ 중첩 메모리 해제를 방지한다.

int main()
{
    int* A = new int[2];
    int* B = new int[2];
    int* C = new int[2];

    if (C != nullptr)
    {
        delete[] C;
        C = nullptr;
    }
    if (B != nullptr)
    {
        delete[] B;
        B = nullptr;
    }
    if (A != nullptr)
    {
        delete[] A;
        A = nullptr;
    }

    return 0;
}

▼ 함수에서 메모리 주소를 받아올 때는 반드시 nullptr 체크를 해야한다.

void DoSomethingWithArr2D(int** const ppArr)
{
    if (ppArr == nullptr)
        return;
}