티스토리 뷰

Computer Language/C & C++

[C++] 동적 할당

HONGGG 2023. 12. 14. 06:45

동적 할당

동적 할당은 프로그램 실행 중에 메모리를 할당하고 해제하는 것을 말한다. 이는 정적 할당과 대조되는데, 정적 할당은 컴파일 타임에 메모리가 할당되고 해제되는 것을 의미합니다.

 

C/C++는 동적 할당을 주로 malloc, calloc, realloc, free함수new, delete 연산자를 사용하여 할당한다. 다른 언어에서도 유사한 메커니즘이 존재한다. [동적할당 방법]

 

정적 할당

정적 할당은 선언된 변수들의 메모리 크기를 컴파일 시간에 예측하며 사용에 있어 편리하다. 하지만 편리한 만큼 편의성을 제공해주는 기능들이 이미 규칙으로 통제되기 때문에 유연한 사용은 어려운 법이다.

 

동적 할당

동적 할당은 사용자 = 개발자가 언제, 얼만큼 새로운 값을 메모리에 할당할 것이고 언제 제거할 것인지 지정할 수 있다.

이는 매우 강력하면서도 매우 위험할 수 있는 방법으로 메모리를 효율적으로 사용할 수 있는 방법의 시작점이면서 실수로 인하여 메모리에 쓰레기 값을 생성할 수도 있는 방법이다.

 

 


변수 할당

정적 할당

// 정적 할당
int a = 5;
int b(5);
int c = int(5);

 

위 코드는 int형 변수를 정적 할당하는 방법이다. 정적 할당 변수의 크기는 컴파일 타임에 결정되며, 프로그램의 실행 전에 컴파일러에 의해 할당되고, 해당 변수의 메모리 크기는 변수의 자료형에 따라 정해진다.  위 코드는 int형 변수가 3개이니 64비트 환경에서 12바이트 정도의 크기가 할당된다.

 

정적 할당(static allocation)은 주로 스택(stack) 또는 데이터(data) 영역에 할당된다. 정적 할당은 컴파일 타임에 메모리가 할당되며, 프로그램의 수명 동안 메모리 위치가 고정된다.

 

변수 설명
전역 변수(Global Variables) 프로그램 시작 시 데이터 영역에 할당되어 프로그램이 실행되는 동안 유지
정적 변수(Static Variables) 함수 내에서 static 키워드로 선언된 변수는 데이터 영역에 할당되며 프로그램의 수명 동안 유지
int globalVariable; 		// 전역 변수, 데이터 영역에 할당

void exampleFunction() {
    static int staticVariable; 	// 정적 변수, 데이터 영역에 할당
    int variable = 0;		// 정적 변수, 스택 영역에 할당
    // ...
}

int main(void) {
	return 0;
}

 

 

동적 할당

int *a = new int(5);

 

위 코드는 동적할당을 하는 방법 중 하나이다. 앞서 언급한 new, malloc, realloc, calloc들로 동적 할당이 가능하며, 동적 할당을 해제하는 방법들도 따로 존재하기에 다른 포스트를 참고하길 바란다.

 

동적 할당을 하면 총 3가지 작업이 일어난다.

 

 

  1. 변수의 선언 위치에 따라 스택(Stack) 혹은 데이터(Data) 영역포인터 변수가 메모리에 할당된다.
  2. 동적 할당 함수나 연산자를 통해 컴퓨터에게 힙 영역의 할당을 요구한다.
  3. 컴퓨터가 힙영역프로그램이 요구하는 만큼의 공간을 할당해준다.

 

위 과정이 앞서 말한 동적 할당을 통한 변수가 메모리에 올라가는 단계이다. 이를 통해 포인터 변수는 스택 영역에(대다수) 선언되고 힙영역에 실제 값이 생성되며 실제 값의 주소값을 포인터 변수가 가지게 되며 연결된다.

 

이제 포인터 변수를 통해 우리는 스택 영역의 주소 값과 힙 영역의 실제 값을 모두 확인할 수 있다.

int *p &p *p p
포인터 변수 선언 포인터 변수의 주소 값 포인터 변수가 가리키는 실제 값 포인터 변수가 가리키는 주소값
#include <iostream>

int main() {
    int x = 42;         // 정수 변수
    int *ptr = &x;      // 포인터 변수, x의 주소를 가리킴

    std::cout << "Address of x: " << &x << std::endl;         // x의 주소 출력
    std::cout << "Value of x: " << x << std::endl;            // x의 값 출력

    std::cout << "Address stored in ptr: " << ptr << std::endl; // 포인터 변수 ptr이 가리키는 주소 출력
    std::cout << "Value pointed by ptr: " << *ptr << std::endl; // 포인터 변수 ptr이 가리키는 값 출력

    std::cout << "Address of ptr: " << &ptr << std::endl;      // 포인터 변수 ptr 자체의 주소 출력

    return 0;
}
Address of x: 0x7ffeefbff3dc
Value of x: 42
Address stored in ptr: 0x7ffeefbff3dc
Value pointed by ptr: 42
Address of ptr: 0x7ffeefbff3e0

 

 


메모리 누수(Memory Leak)

우리는 앞서 메모리를 할당하는 방법을 배웠다. 그런데 메모리 할당으로 사용된 변수의 용도가 사라지면 어떻게 해야할까? 그렇다 메모리를 할당 해제해야 한다.

 

만약 동적 할당 변수를 다 사용하고 해제하지 않으면 컴퓨터는 해당 공간은 계속 사용되는 공간으로 인식하며 메모리 공간을 계속 가지고 있는다. 이는 프로그래밍 언어에서 어떠한 규칙도 어긴것이 아니기에 아무런 오류가 발생하지 않는다. 이런 현상을 '메모리 누수'(Memory leak)라고 한다.

 

반대로 이미 할당이 해제된 값을 다시 할당 해제하려 한다면 에러가 발생한다. 즉, 모든 동적 메모리는 반드시 해제되어야하고 이미 존재하지 않는 값을 다시 해제할 수 없다.

 

 

프로그램 종료와 메모리 누수

위 설명을 통해 우리는 메모리 누수를 조심하며 동적 할당을 해야하는 것을 배웠다. 그러면 메모리 누수를 발생시킨 프로그램이 그대로 종료되면 메모리 누수가된 주소값은 재부팅이 될때까지 반영구적으로 존재하는가? 그렇지 않다.


운영체제가 프로그램이 종료될 때 프로세스에 할당된 모든 메모리와 자원을 해제하기 때문이다. 이는 운영체제 수준에서 이루어지며, 메모리 누수로 인해 발생한 동적 할당된 메모리도 포함된다. 프로세스의 메모리 공간은 운영체제에 의해 통제되기 때문에 운영체제는 프로세스가 사용한 메모리의 주소 범위를 알고 있다.

즉, 메모리 누수로 실제로 할당된 메모리 주소를 알 수 없어도, 운영체제는 해당 프로세스가 사용한 메모리 블록의 메타데이터를 추적하고 프로세스 종료 시에 해당 메모리를 반환한다.

 

결론적으로 메모리 누수는 프로그램이 실행되는 동안에만 영향을 미치며, 프로세스가 종료되면 해당 프로세스가 사용한 모든 자원은 운영체제에 의해 해제된다. 하지만 프로그래머가 메모리를 명시적으로 해제하지 않으면, 프로그램이 실행되는 동안 해당 메모리는 계속 사용되므로, 그 동안에는 메모리 누수로 인한 자원 낭비와 성능 저하가 발생할 수 있다. 좋은 프로그래밍 습관은 메모리를 동적으로 할당했으면 반드시 필요한 시점에 명시적으로 메모리를 해제하는 것이다.

 

매니지드 언어(Managed language) 경우에는 프로그램이 종료되면 동일하게 운영체제가 알아서 처리하지만, 프로그램 종료 전까지 가비지 컬렉션이라는 기능이 몇번의 플래그 시스템을 통해 메모리 누수가 일어나는 자리를 알아서 할당 해제하기도 한다.

 


배열과 동적 할당

배열의 동적 할당은 변수의 동적 할당과 조금 다른면이 있다.

 

우선 배열을 동적 할당하면 배열의 첫 자리의 주소를 전달한다. 즉, 배열 포인터 변수는 배열의 첫 자리의 주소값을 가진다는 소리이다. 이는 실제 배열 포인터를 사용할 때 중요한 사실이된다.

 

 

위 그림은 배열 포인터 변수를 '++'연산자로 배열 내부의 주소 값의 위치를 이동하는 것이다.

 

여기서 그럴 경우는 거의 없긴 하지만 주의할 점은 원본 배열 포인터를 직접 '++'나 '--' 같은 연산자로 주소값을 변경하였다면 다시 첫번째 자리로 돌려놓아야 다음 번에 해당 배열 포인터 변수를 사용하기에 편하다.

 

그렇지 않다면 이러한 기능을하는 함수를 따로 만들어서 다른 포인터 변수로 ++, --연산 작업을 해버리고 해당 포인터 변수를 제거하는 것이 편하다.

 

#include <iostream>

// Call by Address
void example(int *p2) {
    p2++; // 다음 원소를 가리키도록 포인터 이동
    std::cout << "After '++' Operation: " << *p2 << std::endl;
}

int main() {
    const int SIZE = 5;
    int *p1 = new int[5] {1, 2, 3, 4, 5};
	
    example(p1);

    int *p3 = arr; // 배열의 첫 번째 원소를 가리키는 포인터

    // 원본 배열 포인터를 직접 '++' 연산자로 주소값 변경
    std::cout << "Original Pointer Value: " << *p3 << std::endl;

    p3++; // 다음 원소를 가리키도록 포인터 이동
    std::cout << "After '++' Operation: " << *p3 << std::endl;

    // 다시 첫 번째 자리로 돌려놓기
    p3 = arr; 
    std::cout << "After Resetting to the First Element: " << *p3 << std::endl;

    return 0;
}

 

 

메모리 할당 해제

앞서 new 키워드로 동적 할당을 했다면, new와 같이 사용되는 delete 키워드로 배열의 동적 할당을 해제할 수 있다.

단, 이때 주의할 것은 delete 키워드는 배열에게 사용하기 위해 대괄호([])를 함께 사용해야한다.

 

대괄호를 사용하지 않는다면, delete 키워드는 해당 포인터가 가리키는 주소 값만 제거하고 해당 배열의 나머지 값들은 해제하지 않는다.

 

 

 


생성과 소멸

동적 객체를 생성과 소멸하기 위해서는 new와 delete 문이 사용된다.

 

new를 호출하면 기본적으로 생성자가 호출되고(생성자를 지정하지 않으면 기본 생성자가 호출된다.) delete를 호출하면 소멸자가 호출된다.

 

아래는 new와 delete를 사용하여 객체를 생성하고 소멸시키는 간단한 예제이다. 이 예제에서는 동적으로 할당된 객체에 생성자와 소멸자가 호출되는 것을 확인할 수 있다.

 

#include <iostream>

class MyClass {
public:
    MyClass() {
        std::cout << "Constructor called." << std::endl;
    }

    ~MyClass() {
        std::cout << "Destructor called." << std::endl;
    }
};

int main() {
    // 객체를 동적으로 할당하고 생성자 호출
    MyClass *obj = new MyClass;

    // 할당된 메모리를 해제하고 소멸자 호출
    delete obj;

    return 0;
}
Constructor called.
Destructor called.

 

 


[ ]연산자

배열을 사용해보았다면 []연산자는 눈에 익은 사용방법일 것이다. []연산자는 포인터 배열을 선언하는 것과 동일하게 배열을 생성되는데, 동적할당 과정을 거치지 않아도 되어 굉장히 편하다.

그럼, []연산자와 동적 할당은 어떻게 다른 것인가?

 

 

생성자/소멸자 자동 호출

[]연산자는 선언될 때 생성자를 호출하고 스코프를 나가게 되면 소멸자를 호출한다.

#include <iostream>

class MyClass {
public:
    MyClass() {
        std::cout << "Constructor called." << std::endl;
    }

    ~MyClass() {
        std::cout << "Destructor called." << std::endl;
    }
};

int main() {
    // 배열 선언 및 초기화
    MyClass myArray[3]; // MyClass의 기본 생성자가 호출됨

    // 배열이 스코프를 빠져나가면 각 원소의 소멸자가 호출됨
    return 0;
}
Constructor called.
Constructor called.
Constructor called.
Destructor called.
Destructor called.
Destructor called.

 

 

이말은 즉슨, []연산자는 정적 할당을 기준으로 이루어진다는 것이다. 정적 배열은 컴파일 타임에 크기가 결정되고, 프로그램의 스택 혹은 데이터 영역에 메모리가 할당된다. 즉, 실행 중 크기가 변경되지 않는다.

 

 

배열 크기가 변수인 경우

[]연산자를 사용하여 동적 할당을 할수없다. []연산자는 반드시 상수 값을 통해서 크기를 선언되어야 한다.

 

#include <iostream>

class MyClass {
public:
    MyClass() {
        std::cout << "Constructor called." << std::endl;
    }

    ~MyClass() {
        std::cout << "Destructor called." << std::endl;
    }
};

int main() {
    // 정적으로 배열을 선언하고 초기화
    const int size = 3;
    MyClass staticArray[size]; // 배열의 크기는 컴파일 타임에 결정됨

    // 배열이 스코프를 빠져나가면 각 원소의 소멸자가 호출됨
    return 0;
}

 

위 코드에서 staticArray 배열의 크기는 3이되고 size라는 변수는 컴파일 타임에 크기가 결정되기에 실행되면 자동으로 4byte의 크기를 할당 받는다. 하여 정적 할당이 되어야하는 []연산자가 이상 없이 돌아간다.

 

 

그럼 이건?

코드를 몇개 보자.

 

코드1
#include <iostream>

int main() {
    int size;
    int array[size];
    
    std::cout << sizeof(array) / sizeof(int) << std::endl;

    delete[] array;
    
    return 0;
}
코드2
#include <iostream>

int main() {
    int size;

    std::cout << "Enter the size of the array: ";
    std::cin >> size;

    int array[size]={0,};
    
    return 0;
}
더보기
Enter the size of the array: 10

 

위 두 코드는 아무런 문제가 없이 돌아갈까? 결론만 말하자면 실행된다.

단, 컴파일러 단에서 경고문이 발생할 수 있다.

 

📌 munmap_chunk(): invalid pointer

다양한 원인이 존재하는 오류 메시지로, 대부분 선언 과정에서 오류가 있거나 잘못된 값을 선언, malloc을 통한 잘못된 메모리 값을 할당하거나 기존 값을 수정할때 발생된다. 원본 포인터는 free()에 전달되기 전 수정되거나 변경되면 안되고 처음 선언된 값과 동일해야한다.

 

 


컴파일 타임 크기 결정

정적 할당은 컴파일 단위에서 그 크기가 결정된다. 크기가 결정된다는 것은 프로세스가 실행되고 해당 변수가 할당될 때, 미리 결정된 크기로 스택 프레임에 바로 설정되는 것을 의미한다.

 

void example() {
	int n = 5;
	int *arr = new int[n];
}


위 코드의 경우, 함수 내에서 선언된 정적 변수 n은 컴파일 타임에 크기가 결정되지만, 해당 크기는 런타임에 변수가 할당되는 스택 프레임 내에서 설정된다.

 

따라서 n의 크기는 컴파일러에 의해 컴파일 타임에 알려져 있지만, 해당 크기의 메모리는 런타임에 함수가 호출될 때 스택에 할당된다.

변수 arr은 포인터이며, new int[n]를 통해 동적으로 할당된 배열을 가리키게 된다. 배열의 크기는 n이므로, 해당 크기의 메모리가 힙(heap) 영역에 동적으로 할당된다.

 

arr 자체는 스택 프레임 내에 크기가 결정되는 것이 아니라, 힙에 할당된 배열의 첫 번째 원소를 가리키는 포인터일 뿐입니다. 따라서 arr의 크기는 포인터의 크기와 동일하게 결정되며, 이는 컴파일러와 시스템 아키텍처에 따라 다르지만 보통은 4바이트 또는 8바이트입니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함