[C++] 메모리 영역, 동적 할당, 스마트 포인터

세동네·2022년 8월 11일
0
post-thumbnail

· 동적 할당

컴퓨터의 메모리는 여러 종류의 데이터를 저장하기 위한 영역이 나뉘어 있다.

이번 내용에 집중할 부분은 힙 영역과 스택 영역이다. C++의 new 연산자를 활용하면 동적으로 메모리를 할당할 수 있고, 그 안에 값을 저장할 수 있다.

int* ptr = new int(5);

이러한 코드는 5라는 정수형 데이터를 동적으로 할당한다는 뜻이고, 이 메모리는 힙 영역에 올라간다.

이때 포인터 변수를 동적 할당에 사용하는 것과 주소를 저장하는 용도로 사용하는 것의 차이를 명확하게 알 필요가 있다.

int num = 5;

int* allc_ptr = new int(5);	// Dynamic allocation
int* ref_ptr = #

두 포인터 변수는 명확한 차이가 있다. allc_ptr은 메모리에 존재하지 않는 데이터를 새롭게 만들어 할당한 것이고, ref_ptr은 이미 존재하는 변수를 '참조'하는 것 뿐이다.

또 다른 예를 보자.

int num1 = 5;
int num2 = 10;

int* arr = new int[2];

arr[0] = num1;
arr[1] = num2;

해당 코드가 의미하는 것은 '배열'을 동적으로 할당한 것이다. arr의 메모리를 해제하면 해당 배열에 접근할 수 없게 되는 것이고, 그 내용인 num1, num2는 사라지지 않는다.

이처럼 포인터 변수에 동적으로 할당한다는 것이 무슨 의미인지, 동적으로 할당하는 데이터가 어떤 타입인지 명확히 알아야 이번 내용을 쉽게 이해할 수 있다.

· 메모리 누수

앞서 살펴본 메모리 구조에서 스택 영역은 컴파일 타임에, 힙 영역은 런 타임에 크기가 결정 된다고 하였다.

컴파일 타임에 자동으로 메모리에 올라가는 스택 영역의 데이터는 해당 함수를 벗어나면 자동으로 메모리가 해제되지만, 사용자가 직접 메모리에 올리는 힙 영역의 데이터는 마찬가지로 사용자가 직접 메모리를 해제해주어야 한다. 이 내용을 반드시 기억하자.

즉, 포인터 변수로 특정 데이터를 동적 할당하고 데이터 사용이 끝났다면 반드시 할당된 메모리를 해제해주어야 한다. 메모리를 해제하지 않으면 해당 데이터는 프로그램이 종료되기 전까지 메모리에서 사라지지 않고 영원히 남는다.

int* ptr = new int(5);	// Allocate
delete(ptr);			// Deallocate

위 예시의 포인터 변수 ptr은 그 자체는 지역 변수이다. 포인터 변수 자체는 동적으로 할당된 것이 아니라 그저 주소를 데이터 타입으로 삼고 저장하는 변수이므로 스택 영역의 메모리에 할당된다. 따라서 해당 포인터 변수를 선언한 함수를 벗어나면 포인터 변수는 자동으로 메모리가 해제된다.

하지만 포인터 변수에 동적으로 5라는 데이터를 할당해준다면 이 5는 힙 영역에 동적으로 메모리를 할당 받는다. 이때 5에 대한 메모리를 delete 연산자로 해제하지 않고 ptr 변수를 포함한 함수가 종료된다면 스택 영역의 ptr 변수는 사라지고, 힙 영역의 5는 그 어떤 변수도 접근할 수 없게 된다. 이것이 메모리 누수이다.

· 스마트 포인터

모던 C++라 불리는 C++ 11 이상의 표준에선 메모리 및 리소스 누수와 예외 안정성을 보장하는 스마트 포인터(Smart Pointer) 기술을 제공한다.

앞서 말한 것처럼 동적으로 할당한 메모리는 반드시 해제해주어야 하는데, 스마트 포인터는 이러한 동적 할당 메모리를 자동으로 해제해서 사고를 예방해준다.

C++의 스마트 포인터는 std 네임스페이스에 정의되어 있으며, 세 종류로 나뉜다.

· 유니크 포인터(unique_ptr)

유니크 포인터는 특정 객체를 하나의 스마트 포인터만이 가리킬 수 있게 한다. 이 의미를 명확하게 하기 위해 객체를 '소유'한다는 표현을 사용하기도 한다.

#include <iostream>

int main() {
	std::unique_ptr<int> ptr1(new int(5));
	auto ptr2 = std::make_unique<int>(10);

	std::cout << *ptr1 << std::endl;
	std::cout << *ptr2 << std::endl;
}
================== Output ==================
5
10

유니크 포인터를 선언하는 두 가지 방법이다. 유니크 포인터의 특징은 객체를 소유하는 것이기 때문에, 특정 객체를 소유 중인 유니크 포인터를 다른 포인터에 대입하여 객체를 가리키게 하는 것이 불가능하다.

하나의 객체는 하나의 포인터만 가리켜야 하는 유니크 포인터의 특성상 unique_ptr 클래스의 객체는 복사 생성자를 가지지 않고, 따라서 이러한 대입 형태를 지원하지 않는다. 대신 객체에 대한 소유권을 이전하는 것은 가능하다.

이렇게 move() 함수로 객체에 대한 소유권을 이전할 수 있지만, 주의할 점은 객체를 이전한 유니크 포인터는 아무것도 가리키는 것이 없고, 소멸한 포인터로 취급되는 것이다.

#include <iostream>
#include <vector>

int main() {
	std::unique_ptr<int> ptr(new int(5));
	auto ptr2 = move(ptr);

	std::cout << ptr << std::endl;
	std::cout << ptr2 << std::endl;

	ptr.reset();
	ptr2.reset();
	std::cout << std::endl;

	std::cout << ptr << std::endl;
	std::cout << ptr2 << std::endl;
}
================== Output ==================
0000000000000000
00000232C12BAC10

0000000000000000
0000000000000000

스마트 포인터는 적절한 시기에 자동으로 소멸되므로 메모리 해제를 해주지 않아도 되지만, 원하는 시기에 메모리 해제를 하고 싶다면 reset() 함수를 활용할 수 있다. ptr2로 소유권을 이전하여 ptr의 메모리가 해제되었고, reset() 함수로 ptr2의 메모리를 직접 해제한 결과와 ptr이 스마트 포인터의 역할을 다해 자동으로 메모리를 해제한 결과가 같은 것을 확인할 수 있다.

- 유니크 포인터의 예외

  • make_unique() 함수의 한계 중 하나는 배열 형태의 유니크 포인터를 초기화할 수 없다는 것이다.

    이처럼 디버깅하여 객체의 모습을 관찰하면 make_unique()를 활용했을 때 마치 배열이 정상적으로 생성되지 않은 것처럼 보인다. 실제로 인덱스에 접근할 수 있기 때문에 배열이 생성되지 않은 것은 아니지만, 객체 선언과 함께 초기화하는 것이 불가능하다.

    앞서 본 것처럼 make_unique() 함수는 기본적으로 예외 안정성을 포함하고 인자로 동적 할당식이 아닌 값을 던져주기에 편리성 덕분에도 자주 사용하지만, 상황에 따라 불리하게 작용할 수도 있음에 유의하자.

· 쉐어드 포인터(shared_ptr)

하나의 객체를 하나의 포인터만 가리키던 유니크 포인터와 달리 복사 생성자를 가져 하나의 객체를 여러 포인터가 가리킬 수 있는 포인터를 쉐어드 포인터라고 한다.

이러한 쉐어드 포인터는 참조 카운트(Reference count)를 가지며, shared_ptr 객체의 use_count() 함수로 참조 카운트를 알아낼 수 있다.

#include <iostream>

int main() {
	std::shared_ptr<int> ptr1(new int(5));
	
	std::cout << ptr1.use_count() << std::endl;

	auto ptr2 = ptr1;

	std::cout << ptr1.use_count() << std::endl;
}
================== Output ==================
1
2

참조 카운트가 0이 되면 동적 할당한 메모리가 해제된다.

- 쉐어드 포인터의 참조 순환

  • 이러한 쉐어드 포인터는 한 객체를 여러 포인터가 참조할 수 있다는 특징 때문에 메모리 누수가 발생할 수 있다.

    int 타입 변수와 자신 클래스 타입의 shared_ptr를 멤버로 가지는 클래스가 있다고 하자.

    #include <iostream>
    using namespace std;
    
    class Test {
    private:
      int num;
      shared_ptr<Test> ptr;
    
    public:
      Test(int _num) {
         num = _num;
         cout << num << " is created!" << endl;
      }
    
      ~Test() {
         cout << num << " is destroyed!" << endl;
      }
    
      void SetPtr(shared_ptr<Test> _ptr) {
         ptr = _ptr;
      }
    };
    
    int main() {
      shared_ptr<Test> obj1 = make_shared<Test>(5);
      shared_ptr<Test> obj2 = make_shared<Test>(10);
    
      obj1->SetPtr(obj2);
      obj2->SetPtr(obj1);
    
      obj1.reset();
    }
    ================== Output ==================
    5 is created!
    10 is created!

    정의된 Test 클래스의 객체를 shared_ptr로 동적 할당해 총 2개를 생성하였고, 각 객체의 shared_ptr<Test> 타입 멤버 변수 ptr이 서로를 가리키도록 하였다.

    즉, obj1 객체 자체가 아닌 그 멤버인 shared_ptr<Test> 타입ptr 변수가 obj2를 가리키고, obj2의 멤버 변수 shared_ptr<Test> 타입의 ptrobj1을 가리키는 것이다.

    이 상태에서 obj1 객체를 해제한다면, 스마트 포인터인 obj1 자체는 사라지지만, 원래 obj1이 가리키고 있던 5 값을 가지는 Test 객체를 아직 obj2의 멤버 ptr이 가리키고 있으므로 참조 카운트가 0이 되지 않아 메모리가 해제되지 않는다. 이 상태에서 obj2의 메모리가 해제된다면 obj2가 가리키고 있던 10을 가지는 Test 객체를 obj1의 멤버 ptr이 가리키고 있었으므로 역시 참조 카운트가 1이라서 해제되지 않고 메모리에서 사라지지 않는다.

    위 출력 결과에서 소멸자가 호출되지 않은 것을 확인할 수 있다. 이러한 현상을 '순환 참조'라고 한다.

· 약 포인터(weak_ptr)

쉐어드 포인터의 순환 참조를 해결할 수 있는 포인터이다. 쉐어드 포인터는 기본적으로 객체를 공유할 때 참조 카운트를 증가시키지만, 약 포인터는 쉐어드 포인터의 객체를 가리킬 수 있지만 참조 카운트를 증가시키지 않는다.

위 쉐어드 포인터의 순환 참조 예시에서 클래스의 멤버 변수 ptr의 데이터 타입을 shared_ptr<Test>에서 weak_ptr<Test>로 바꿔주기만 하면 간단하게 약 포인터로 순환 참조를 해결할 수 있다.

#include <iostream>
using namespace std;

class Test {
private:
    int num;
    weak_ptr<Test> ptr;

public:
    Test(int _num) {
        num = _num;
        cout << num << " is created!" << endl;
    }

    ~Test() {
        cout << num << " object is destroyed!" << endl;
    }

    void SetPtr(shared_ptr<Test> _ptr) {
        ptr = _ptr;
    }
};

int main() {
    shared_ptr<Test> obj1 = make_shared<Test>(5);
    shared_ptr<Test> obj2 = make_shared<Test>(10);

    obj1->SetPtr(obj2);
    obj2->SetPtr(obj1);

    obj1.reset();
}
================== Output ==================
5 is created!
10 is created!
5 object is destroyed!
10 object is destroyed!

· 참고

[TCP School] 메모리 구조
C++ new와 delete를 사용한 동적 메모리 할당
[MS Document] 스마트 포인터(최신 C++)
스마트 포인터 (Smart Pointer)
C++ <객체의 유일한 소유권 - unique-ptr>
스마트 포인터 3 std::weak_ptr

0개의 댓글