[C++] 포인터(pointer) 2편

HY K·2024년 8월 28일

명품 C++ 프로그래밍

목록 보기
19/24

이전에 이어서, 포인터에 대해서 계속 공부해보자. 이번에 다룰 포인터는 메모리 사용과 관련이 깊은, 스마트 포인터(smart pointer)와 raw 포인터(raw pointer)이다.


1. Raw 포인터(Raw Pointer)

raw 포인터는 앞서 설명한 바와 같이 C++에서 기본적으로 사용되는 포인터이다. 이 포인터는 특정한 메모리 주소를 직접적으로 가리키며, 개발자가 메모리 할당과 해제를 직접 관리해야 한다는 특징을 가지고 있다.

new 연산자와 delete 연산자

new 연산자는 힙 메모리로 단일 개겣를 동적으로 할당하기 위해서 사용한다. 이 연산자는 객체의 생성자를 호출하여, 메모리를 초기화 시킨다.

Type *ptr = new Type;
  • Type : 할당하려는 데이터 타입
  • ptr : Type 타입의 포인터로, 할당된 메모리의 시작 주소를 가리킨다.

예시를 들면 다음과 같다.

int *intPtr = new int; // int 타입의 메모리를 동적 할당
*intPtr = 5; // 할당된 메모리에 값 5 저장

cout << *intPtr <<endl; //5가 출력된다.

new 연산자는 class에도 사용할 수 있다. new 연산자는 생성자를 호출하여 객체를 초기화 하게 된다.

class MyClass{
public:
	MyClass(){ cout << "MyClass 생성자 호출!" <<endl; }
};

int main(){
	MyClass *obj = new MyClass; // MyClass 객체의 생성자가 호출
    delete obj; // 메모리 해제
}

delete 연산자는 new 연산자를 통해서 동적으로 할당된 메모리를 해제하는데 사용한다. 이를 통해 메모리 누수를 방지할 수 있게 된다. 예시를 통해 살펴보자.

delete ptr; // ptr 포인터의 메모리를 해제

또 다른 예시이다.

delete intPtr; // 이전에 new로 할당한 intPtr 포인터 메모리 해제
intPtr = nullPtr; // Dangling Pointer 방지

delete 연산자를 사용해서 포인터를 해제한 후, 해당 포인터를 nullptr 로 초기화 하는 것은 매우 유용하게 쓰일 수 있다. 만약 그렇지 않으면, 메모리를 해제했음에도 불구하고 해제 전의 주소를 가리키는 Dangling Pointer 가 되어서 치명적 오류를 발생시킬 수 있기 때문이다.

new[] 연산자와 delete[] 연산자

new[] 연산자는 배열을 동적으로 할당하기 위해서 사용하는 연산자이다. new의 연장선상에 있는 연산자지만, 여러 개의 요소를 한번에 할당할 수 있다는 차이점이 있다.

Type *ptr = new Type[size];
  • type : 배열의 자료형이다.
  • size : 할당할 배열의 크기이다.
int *arr = new int[5];
for(int i=0; i<5; i++)
	arr[i] = i*10;

for(int i = 0; i<5; i++)
	cout << arr[i] <<" ";
cout<<endl;
}

delete[] 연산자는 new[] 연산자를 통해서 할당한 배열 메모리를 해제하는데 사용한다. delete와 다르게 배열의 모든 요소에 대해서 소멸자를 호출하고, 메모리를 삭제하게 된다.

new / new[], 그리고 delete와 delete[]를 헷갈리는 일이 없도록 하자. 둘을 혼용해서 사용하게 된다면, 정의되지 않은 동작과 관련된 문제가 발생할 소지가 있다.

또한 new를 통해서 힙 메모리 영역에 동적으로 할당하게 된다면, 반드시 delete 연산자를 통해서 메모리 할당을 해제해주어야 한다. 그렇지 않으면 메모리 누수(memory leakage) 문제가 발생하여 추후 프로그램/프로세스에서 사용할 수 있는 메모리가 아예 사라지게 된다.

2. 스마트 포인터(Smart Pointer)

C++에 도입된 스마트 포인터는, 동적 메모리 관리를 쉽게 하기 위한 템플릿 클래스이다. 스마트 포인터는 일반 포인터와 매우 유사하게 동작하지만, 객체의 솜려과 메모리 해제를 자동으로 처리하여 메모리 누수와 같은 문제 발생을 방지할 수 있다. C++ 표준 라이브러리에서는, std::unique_ptr, std::shared_ptr, std::weak_ptr 이렇게 3가지를 제공하고 있다.

std::unique_ptr

std::unique_ptr은 소유권이 단일한 객체를 가리키는 스마트 포인터이다. 1개의 unique_ptr만이 특정 자원을 소유할 수 있으며, 복사할 수 없다. 복사를 시도할 경우 컴파일 오류가 발생한다. 이동 연산(std::move)은 가능해서 소유권을 다른 unique_ptr에게 넘길 순 있다.

예시 코드를 살펴보자.

#include<iostream>
#include<memory>

class MyClass{
public:
	MyClass(){std::cout<<"MyClass 생성"<<std::endl;}
    ~MyClass(){std::cout<<"MyClass 소멸"<<std::endl;}
};

int main(){
	std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>();
    // 객체 생성 및 소유권 부여
    
    std::unique_ptr<MyClass> ptr2 = std::move(ptr1);
    // 소유권 이전, ptr1은 자동으로 nullptr로 변화
    
    if(!ptr1){
    	std::cout<<"ptr1은 더이상 객체를 소유하지 않음!"<<std::endl;
    }
}

std::move를 통해서 소유권을 이전하고, ptr2는 스코프를 벗어나게 되면 MyClass 객체가 소멸함과 동시에 메모리를 자동으로 해제하게 된다.

unique_ptr은 객체 간의 소유권을 명확하게 부여할 수 있으며, 메모리 누수를 쉽게 방지할 수 있다는 장점을 가지고 있다.

std::shared_ptr

std::shared_ptr은 여러 개의 포인터가 동일한 객체를 참조할 수 있도록 설계한 스마트 포인터이다.

참조 카운터(reference count)를 사용해서 몇개의 shared_ptr이 객체를 참조하는지 추적이 가능하며, 참조 카운터가 0이 되면(=모든 shared_ptr이 삭제가 된다면) 객체가 소멸되고, 메모리도 같이 해제되게 된다. 스마트 포인터답게, 자동 메모리 해제 기능을 가지고 있다.

예시 코드를 살펴보자.

#include <iostream>
#include <memory>
using namespace std;

class MyClass{
public:
	MyClass(){ cout<<"MyClass 생성"<<endl;}
    ~MyClass(){cout<<"MyClass 소멸"<<endl;}
};

int main(){
	shared_ptr<MyClass> ptr1 = make_shared<MyClass>():
    // 객체 생성 및 소유권 부여
    shared_ptr<MyClass> ptr2 = ptr1;
    // ptr2가 ptr1과 같은 객체를 참조한다
    
    cout<<"참조 카운트: "<<ptr1.use_count()<<endl;
    // 출력은 2
    cout<<"참조 카운트: "<<ptr2.use_count()<<endl;
    // 출력은 2
    
    ptr1.reset(); // ptr1이 소유권을 포기
    cout<<"참조 카운트: "<<ptr2.use_count()<<endl;
    // 출력은 1
}

이러한 shared_ptr은 동일한 메모리를 여러 포인터가 안전하게 공유할 수 있다는 장점을 가지고 있지만, 순환 참조(Circular Reference) 문제가 발생할 수 있다. 순환 참조란, shared_ptr이 서로를 참조하게 된다면 참조 카운터가 0이 되지 않아서, 메모리 해제가 이루어지지 않아 메모리 누수가 발생하는 일을 의미한다.

std::weak_ptr

std::weak_ptr은 shared_ptr이 관린하는 객체애 대한 약한 참조(weak reference)를 제공하는 스마트 포인터이다. 참조 카운트에 영향을 전혀 주지 않으며, 순환 참조를 방지하기 위해서 사용한다.

참조 카운트를 증가시키지 않기 때문에 share_ptr의 소유권을 가지지 않으며, expired() 메서드를 통해서 객체의 존재 여부를 판독할 수 있다. 또한 소유권을 가지지 않으므로 순환 참조 문제를 방지할 수 있다.

#include <iostream>
#include <memory>
using namespace std;

class MyClass{
public:
	shared_ptr<MyClass> partner;
	MyClass(){ cout<<"MyClass 생성"<<endl;}
    ~MyClass(){cout<<"MyClass 소멸"<<endl;}
};

int main(){
	shared_ptr<MyClass> obj1 = make_shared<MyClass>();
    shared_ptr<MyClass> obj2 = make_shared<MyClass>();
    
    obj1->partner = obj2; // obj1과 obj2가 서로를 참조한다.
    obj2->partner = obj1; // 이렇게 구성되면 순환 참조 발생
    
    // 메모리 누수가 발생할 수 있다.
    
    // weak_ptr를 사용하면 순환 참조를 방지할 수 있다.
    shared_ptr<MyClass> obj3 = make_shared<MyClass>();
    shared_ptr<MyClass> obj4 = make_shared<MyClass>();
    
    obj3->partner = weak_ptr<MyClass>(obj4);
    obj4->partner = weak_ptr<MyClass>(obj3);
    // 서로를 참조하지만, 소유권을 보유하지도 참조 카운트 증가도 하지 않기에
    // 순환 참조 문제가 발생하지 않는다.
}

3. 스마트 포인터 요약

  • std::unique_ptr : 객체의 소유권 및 수명이 명확히 규정된 상태에서 사용한다. 주로 new와 delete 연산자를 안전하게 대체하기 위해서 사용한다.
  • std::shared_ptr : 객체가 여러 곳에서 공유되어야만 하며, 명확한 소유권 권리가 필요한 경우에 사용한다.
  • std::weak_ptr : 순환 참조를 방지해야 하거나, 객체가 여전히 유효한지 알아내기 위해서 사용한다. 주로 shared_ptr과 함께 사용된다.
profile
로봇, 드론, SLAM, 제어 공학 초보

0개의 댓글