SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++

new/delete 동작 원리 이해하기

보리남편 김 주부 2024. 7. 22. 00:36

💬 C++ core guidelines에 따르면 메모리 관리는 RAII가 되는 smart pointer를 권장하고 있다.
하지만 이미 레거시 코드에 녹여져 있는 new/delete는 어떻게 해야 하나?🤔 또 malloc/free는?😨
C++에서 malloc/free못 본같긴 하지만 간단히 new/delete와의 차이점을 정리하고 new/delete좀 더 쪼개서 동작시키는 내용을 정리해 본다.

 

🚀 우선 new/delete 동작 원리를 알아보자.


Example
new/delete를 사용한 프로그램 코드이다.
class Point
{
    int x, y;
public:
    Point(int a, int b) { std::cout << "Point()" << std::endl; }
    ~Point() { std::cout << "~Point()" << std::endl; }
};

int main()
{
    Point* p1 = new Point(1, 2); // 1
    delete p1;                   // 2
}
Result
Point()
~Point()

 

주석 1, 2의 동작 원리를 정리하면 다음과 같다.

주석 1 : new

  • 메모리 할당 : operator new(unsigned long)
  • 생성자 호출 : Point(1,2)

주석 2 : delete

  • 소멸자 호출 : ~Point()
  • 메모리 해제 : operator delete(void*, unsigned long) 호출

 

new/delete의 연속된 동작을 나눠서 동작시킬 수 있는데 그 방법을 확인해 보자.

 

new/delete 메모리 할당/해지와 생성자/소멸자 호출을 나눠서 호출하기


Example
class Point
{
    int x, y;
public:
    Point(int a, int b) { std::cout << "Point()" << std::endl; }
    ~Point() { std::cout << "~Point()" << std::endl; }
};
int main()
{
    //1
    Point* p1 = static_cast<Point*>(operator new(sizeof(Point)));
    //2
    new(p1) Point(0, 0);
    std::construct_at(p1, 0, 0);
    //3
    p1->~Point();
    std::destroy_at(p1);
    //4
    operator delete(p1);
}
Result
Point()
Point()
~Point()
~Point()

 

new/delete와 malloc/free 차이 정리

malloc : 메모리 할당
new : 객체 생성(메모리 할당 + 생성자 호출)

free : 소멸자 호출 없이 메모리 해지
delete : 객체 파괴(소멸자 호출 + 메모리 해지)

 

주석의 내용을 정리하면 다음과 같다.

주석 1 : 메모리만 할당
Point* p1 = static_cast<Point>(operator new(sizeof(Point)));
new 연산자를 메모리 size 만큼 직접 호출하면 되는데 이때 void가 리턴이 되니 캐스팅을 해주면 된다.
malloc처럼 메모리만 할당한다.

주석 2 : 이미 할당된 메모리에 생성자만 호출(placement new or 위치지정 new)
new(p1) Point(0, 0);
new(주소) Point(0,0)처럼 new 뒤에 이미 할당된 (생성자를 호출할) 주소를 넣어주면 된다.

📜 Info : std::construct_at(p1, 0, 0);
c++20부터는 별도의 함수로 분리되었고 주소와 생성자에게 전달할 인자만 넘겨주면 된다.

주석 3 : 메모리 해제 없이 소멸자만 호출
p1->~Point()
소멸자를 직접 호출하면 된다.

📜Info : std::destroy_at(p1)
C++17부터는 소멸자를 호출하는 별도의 함수가 생겼다.

주석 4 : 소멸자 호출 없이 메모리 해지(free 와 유사)
operator delete(p1)

 

이를 통해 malloc/free를 new/delete로 대체할 수 있음을 확인하였다.

혹시 대체용이 아닌 경우에도 동작을 분리시켜야 할 케이스가 있을까  

 

🚀 new/delete 동작 분리가 필요한 사례를 확인해 보자.


기본 생성자가 없는 클래스에 여러 개의 메모리 할당 시

아래 코드의 결과는 어떻게 될까? 🤔

Example
기본 생성자가 없는 클래스로 힙에 10개 생성을 시도하는 프로그램 코드이다.
#include <iostream>
class Point
{
 int x, y;
public:
 // 디폴트 생성자가 없는 Point
 Point(int a, int b) { std::cout << "Point()" << std::endl; }
 ~Point() { std::cout << "~Point()" << std::endl; }
};
int main()
{
 Point* p1 = new Point[10]; //1
}
Result
error: no matching function for call to 'Point::Point()'
   20 |         Point* p1 = new Point[10];

 

결과에서도 보시다시피 Point 클래스는 기본 생성자가 없어서 이 방식으로는 10개를 할당할 수가 없다.
💬 Point class를 내가 만들었다면 기본 생성자를 추가해서 해결했겠지만, 다른 사람이 만든 클래스라면 함부로 생성자를 넣긴 힘들다. 중괄호 초기화 리스트를 사용하여 아래처럼 사용 가능하지만

Point* p1 = new Point[10]{ {0,0}, {0,0}, {0, 0}, {0, 0}, {0, 0}, {0, 0},{0,0}, {0,0}, {0, 0}, {0, 0} };
✔ Since C++11

 

아래와 같이 메모리 할당과 생성자 호출을 나눠서 사용할 수 있다.

// 힙에 Point 10개 메모리 할당
Point* p1 = static_cast<Point*>(operator new(sizeof(Point) * 10)); 

//아래와 같이 전체 생성자 호출
for (int i = 0; i < 10; i++)
{
    new(&p1[i]) Point(0, 0);
}

📌 메모리 생성과 생성자를 분리하였다면 메모리 해지와 소멸자 호출도 분리하는 것이 원칙이다.
gcc에서 했을 때는 소멸자가 무한대로 호출되어 죽었다. 생각지 못한 에러가 발생하기도 하니 원칙을 따르자.

//전체 소멸자 호출
for (int i = 0; i < 10; i++)
{
    p2[i].~Point();
}

//메모리 해제
operator delete(p2);

 

위 설명의 전체코드는 아래와 같다.

#include <iostream>
class Point
{
 int x, y;
public:
 Point(int a, int b) { std::cout << "Point()" << std::endl; }
 ~Point() { std::cout << "~Point()" << std::endl; }
};
int main()
{
    //메모리 할당
 Point* p1 = static_cast<Point*>(operator new(sizeof(Point) * 10));
    //생성자 호출
 for (int i = 0; i < 10; i++)
 {
  new(&p1[i]) Point(0, 0);
 }
    //소멸자 호출(객체 파괴)
 for (int i = 0; i < 10; i++)
 {
  p1[i].~Point();
 }
    //메모리 해지
 operator delete(p1);
}
Result
Point()
Point()
Point()
Point()
Point()
Point()
Point()
Point()
Point()
Point()
~Point()
~Point()
~Point()
~Point()
~Point()
~Point()
~Point()
~Point()
~Point()
~Point()

 

vector 사용 시

우리가 자주 사용하는 vector가 메모리 할당/해지와 생성자/소멸자 호출이 분리되어 있는 대표적인 예이다. 아래 코드를 보자.

Example
vector 사용을 통해 메모리 할당/해지와 생성자/소멸자 호출 분리에 대한 내용을 확인해 볼 수 있는 프로그램 코드이다.
#include <iostream>
#include <vector>
class DBManager
{
public:
 DBManager() { std::cout << "connect DB\n"; }
 ~DBManager() { std::cout << "disconnect DB\n"; }
};
int main()
{
    std::vector<DBManager> v(5);
    std::cout << "size = " << v.size() << ", capacity = " << v.capacity() << std::endl;
    std::cout << "=========" << std::endl;
    v.resize(3);
    std::cout << "resize(3), size = " << v.size() << ", capacity = " << v.capacity() << std::endl;
    std::cout << "=========" << std::endl;
    v.resize(4);
    std::cout << "resize(4), size = " << v.size() << ", capacity = " << v.capacity() << std::endl;
}
Result
connect DB
connect DB
connect DB
connect DB
connect DB
size = 5, capacity = 5
=========
disconnect DB
disconnect DB
resize(3), size = 3, capacity = 5
=========
connect DB
resize(4), size = 4, capacity = 5
disconnect DB
disconnect DB
disconnect DB
disconnect DB

 

이 코드는 DBManager를 5개 보관하고 있는 vector를 생성 후 resize를 3-> 4로 조정하였다.
resize를 하면서 2개를 줄이고, 이후 1개를 늘렸지만 vector 특성상 (* vector 특성에 대해서는 별도로 설명하지 않는다.)
capacity는 5개로 유지가 되어 메모리 해지는 없다.

다만 사이즈가 줄어든 만큼 소멸자가 호출이 되고, (메모리 제거 없이 객체만 파괴 - 소멸자 호출)
다시 늘린 만큼(+1) 생성자가 호출된 것을 확인할 수 있다. (메모리 공간은 있기에 객체 생성)

 

메모리 할당/해지와 생성자/소멸자 호출이 분리가 필요한 사례를 좀 더 확인해 보자.

 

vector의 다른 예시도 알아보자.

Example
#include <iostream>
#include <vector>

class Point
{
 int x;
 int y;
public:
 Point(int a, int b) : x(a), y(b) {}
 ~Point() {}
};
int main()
{
    Point pt(0, 0);
    std::vector<Point> v(10);      // 1
    std::vector<Point> v(10, pt);  // 2
}
Result
주석 1 : 에러 발생!
에러의 원인을 확인해 보자.
vector 내부에서 메모리를 할당한 후 생성자가 호출되어야 하는데 Point 클래스는 디폴트 생성자가 없어서 에러가 발생한다.
/usr/local/include/c++/12.2.0/bits/stl_construct.h:119:7: error: no matching function for call to 'Point::Point()'
119 |       ::new((void*)__p) _Tp(std::forward<_Args>(__args)...);

이처럼 에러로그를 통해 생성자 호출 시 발생한 것을 알 수 있다.(메모리 할당은 통과했다는 뜻)

주석 2 : 정상동작!
Point pt(0,0);
std::vector v(10, pt);
이처럼 초기화를 한 객체를 전달하면 메모리 할당 후 pt를 복사해서 할당(복사 생성자 호출)하기에 이 코드는 정상적으로 동작을 한다.

 

vector를 약식으로 만들어 보면서 내부적으로 어떻게 동작하는지 이해해 보자.

Example
std::vector를 약식으로 만든 프로그램 코드이다.
vector(int)와 vector(int,const T&) 생성자의 내부 동작에 대한 주석을 보면서 이해해 보자.
template<typename T> class vector
{
 T* buff;
 int size;
public:
   // 아래 생성자를 통해 위 주석1. std::vector<Point> v(10); 가 에러난 원인을 확인해보자.
    vector(int sz) : size(sz)
    {
        //buff = new T[size];
        // 에러 로그를 통해 std::vector 는 메모리 할당과 생성자 호출이 동시에 일어나지 않았기에 분리해야 한다.
        buff = static_cast<T*>(operator new(sizeof(T) * size));


        for (int i = 0; i < size; i++)
        {
        new(&buff[i]) T; // 생성자 호출을 해야 하지만 Point 는 기본 생성자가 없어서 에러가 발생한다.
        }
    }

    // 아래 생성자를 통해 위 주석2. 가 정상동작 하는 이유를 확인해보자.
    vector(int sz, const T& value) : size(sz)
    {
        // 1. 메모리만 먼저 할당
        buff = static_cast<T*>(operator new(sizeof(T) * size));

        // 2. 생성자 호출
        for (int i = 0; i < size; i++)
        {
            new(&buff[i]) T(value);  // T(value)는 복사 생성자 Point(Point &a) 호출
        }
    }
    ~vector()
    {
        for (int i = 0; i < size; i++)
        {
        buff[i].~T();
        }
        operator delete(buff);
        size = 0;
    }
};

 

Point 클래스에서 복사 생성자를 정의하지 않았지만 (사용자가 정의하지 않아) 컴파일러가 자동 생성했기 때문에 vector(int,const T&)를 사용할 수 있다.

📌 STL 컨테이너에 저장되는 타입 조건을 정리해 보면
. 복사 생성자소멸자가 반드시 있어야 한다. (기본 생성자는 없어도 대안은 있다.)
. 복사생성자와 소멸자는 유저가 정의하지 않아도 컴파일러가 제공하기에 명시적으로 삭제하지 않는 한 동작한다는 말이 된다.
Point(Point& a) = delete; // 명시적 삭제

 

💬 new/delete 메모리 할당/해지와 생성자/소멸자 호출을 분리해서 사용하는 방법과 사용 예시들을 다뤄봤다. 필요한 순간에 유용하게 활용되었으면 하고 아래 참고에 메모리 관리에 관한 C++ Core Guidelines 내용을 링크 걸어놨으니 추가로 확인해 보기 바란다.

 

참고


C++ 핵심 가이드라인 한글화 프로젝트 링크

C++ Core Guidelines 원문 링크

 

728x90