Base* p = new Derived; delete p;는 컴파일도 실행도 문제없어 보인다. 하지만 Base의 소멸자가 가상이 아니면 delete는 정적 타입 Base의 소멸자만 호출한다. Derived가 잡은 자원은 해제되지 않고, 표준상으로는 미정의 동작이다. 소멸자도 함수 바인딩 편에서 본 규칙을 그대로 따르기 때문이다. 이 글에서는 g++ 13.3과 AddressSanitizer로 비가상 소멸자의 문제를 재현하고, 컴파일러 경고, 스마트 포인터별 차이, 순수 가상 소멸자, 가상 소멸자의 비용을 확인한다.
이 시리즈의 다른 글
- C++ 고급 문법 (19) — 함수 바인딩(Function Binding)
- C++ 고급 문법 (20) — CRTP(Curiously Recurring Template Pattern)
- C++ 고급 문법 (22) — 추상 클래스(Abstract Class)
- C++ 고급 문법 (23) — 인터페이스(Interface)
비가상 소멸자로 delete
#include <cstdio>
#include <cstring>
struct Base {
Base() { std::fprintf(stderr, " Base()\n"); }
~Base() { std::fprintf(stderr, " ~Base()\n"); } // 비가상 소멸자
};
struct Derived : Base {
char* buf;
Derived() : buf(new char[64]) { std::fprintf(stderr, " Derived() alloc 64\n"); }
~Derived() { delete[] buf; std::fprintf(stderr, " ~Derived() free 64\n"); }
};
int main()
{
Base* p = new Derived;
delete p; // 정적 타입 Base의 소멸자만 호출
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o nv nonvirtual.cpp
$ ./nv # ASan 보고 중 에러 종류와 크기만 발췌
Base()
Derived() alloc 64
~Base()
ERROR: AddressSanitizer: new-delete-type-mismatch:
size of the allocated type: 8 bytes;
size of the deallocated type: 1 bytes.
$ ASAN_OPTIONS=new_delete_type_mismatch=0 ./nv # 타입 불일치 검사를 끄면 누수가 보인다
ERROR: LeakSanitizer: detected memory leaks
SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).
~Derived()가 불리지 않아 64바이트가 누수되고, ASan은 할당한 크기(8바이트)와 해제하려는 타입 크기(1바이트)가 다르다고 보고한다. 컴파일 경고는 하나도 없었다.
가상 소멸자
#include <cstdio>
#include <cstring>
struct Base {
Base() { std::fprintf(stderr, " Base()\n"); }
virtual ~Base() { std::fprintf(stderr, " ~Base()\n"); } // 가상 소멸자
};
struct Derived : Base {
char* buf;
Derived() : buf(new char[64]) { std::fprintf(stderr, " Derived() alloc 64\n"); }
~Derived() { delete[] buf; std::fprintf(stderr, " ~Derived() free 64\n"); }
};
int main()
{
Base* p = new Derived;
delete p; // 정적 타입 Base의 소멸자만 호출
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o v virtual.cpp
$ ./v
Base()
Derived() alloc 64
~Derived() free 64
~Base()
소멸자가 가상이면 delete p가 vtable을 통해 ~Derived()를 먼저 호출하고, 이어서 ~Base()가 자동으로 불린다.
컴파일러 경고
struct Shape {
virtual double area() const = 0; // 가상 함수는 있는데
~Shape() = default; // 소멸자는 비가상
};
struct Circle : Shape { double area() const override { return 3.14; } };
void destroy(Shape* s) { delete s; }$ g++ -std=c++20 -Wall -c warn.cpp 2>&1 | grep warning
warn.cpp:7:26: warning: deleting object of abstract class type ‘Shape’ which has non-virtual destructor will cause undefined behavior [-Wdelete-non-virtual-dtor]
$ g++ -std=c++20 -Wall -Wnon-virtual-dtor -c warn.cpp 2>&1 | grep warning
warn.cpp:1:8: warning: ‘struct Shape’ has virtual functions and accessible non-virtual destructor [-Wnon-virtual-dtor]
warn.cpp:5:8: warning: base class ‘struct Shape’ has accessible non-virtual destructor [-Wnon-virtual-dtor]
warn.cpp:5:8: warning: ‘struct Circle’ has virtual functions and accessible non-virtual destructor [-Wnon-virtual-dtor]
warn.cpp:7:26: warning: deleting object of abstract class type ‘Shape’ which has non-virtual destructor will cause undefined behavior [-Wdelete-non-virtual-dtor]
-Wall의 -Wdelete-non-virtual-dtor는 다형 클래스를 기반 포인터로 지우는 지점만 잡는다. -Wnon-virtual-dtor를 켜면 클래스 선언 단계에서 경고하지만, 앞의 예제처럼 가상 함수가 하나도 없는 기반 클래스는 어느 쪽도 잡지 못한다.
스마트 포인터별 차이
#include <cstdio>
#include <memory>
struct Base { ~Base() { std::fprintf(stderr, " ~Base\n"); } }; // 비가상
struct Derived : Base { ~Derived() { std::fprintf(stderr, " ~Derived\n"); } };
int main()
{
std::fprintf(stderr, "shared_ptr<Base>(new Derived):\n");
{ std::shared_ptr<Base> p(new Derived); } // 삭제자가 생성 시점의 Derived*로 기록됨
std::fprintf(stderr, "make_shared<Derived> -> shared_ptr<Base>:\n");
{ std::shared_ptr<Base> p = std::make_shared<Derived>(); }
std::fprintf(stderr, "unique_ptr<Base>(new Derived):\n");
{ std::unique_ptr<Base> p(new Derived); } // default_delete<Base> -> delete (Base*)
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o sm smart.cpp
$ ./sm # 소멸자 출력만 발췌
shared_ptr<Base>(new Derived):
~Derived
~Base
make_shared<Derived> -> shared_ptr<Base>:
~Derived
~Base
unique_ptr<Base>(new Derived):
~Base
shared_ptr은 생성 시점의 실제 타입으로 삭제자를 기록해 두므로 비가상 소멸자여도 ~Derived가 불린다. unique_ptr<Base>는 delete (Base*)를 하므로 일반 포인터와 같은 문제가 생긴다.
순수 가상 소멸자
struct Interface {
virtual ~Interface() = 0; // 순수 가상 소멸자: 다른 순수 가상 함수가 없을 때 추상 클래스로 만드는 방법
};
struct Impl : Interface {};
int main()
{
Impl i; // ~Impl()은 결국 ~Interface()를 호출한다
}$ g++ -std=c++20 -Wall -o pv purevd.cpp 2>&1 | grep -E 'undefined|error'
purevd.cpp:(.text._ZN4ImplD2Ev[_ZN4ImplD5Ev]+0x26): undefined reference to `Interface::~Interface()'
collect2: error: ld returned 1 exit status
파생 클래스 소멸자는 항상 기반 소멸자를 호출하므로, 순수 가상 소멸자도 정의가 있어야 링크된다.
#include <cstdio>
struct Interface {
virtual ~Interface() = 0;
};
Interface::~Interface() { std::puts(" ~Interface"); } // 순수 가상이어도 정의가 필요하다
struct Impl : Interface { ~Impl() override { std::puts(" ~Impl"); } };
int main()
{
Interface* p = new Impl;
delete p;
}$ g++ -std=c++20 -Wall -o pvf purevd_fix.cpp
$ ./pvf
~Impl
~Interface
비용과 선택 기준
#include <cstdio>
struct Point { float x, y; ~Point() {} };
struct PointV { float x, y; virtual ~PointV() {} };
int main()
{
std::printf("sizeof(Point)=%zu sizeof(PointV)=%zu\n", sizeof(Point), sizeof(PointV));
}$ g++ -std=c++20 -Wall -o c cost.cpp
$ ./c
sizeof(Point)=8 sizeof(PointV)=16
가상 함수가 없던 클래스에 가상 소멸자만 추가해도 vptr 8바이트가 붙는다. 다형적으로 쓰지 않는 값 타입에는 붙이지 않는다.
| 클래스 용도 | 소멸자 |
|---|---|
| 기반 포인터로 소유·삭제하는 다형 기반 클래스 | public virtual |
| 상속은 하지만 기반 포인터로 삭제하지 않음 (믹스인, CRTP 기반) | protected 비가상 (protected 생성자 편 참고) |
| 상속하지 않는 클래스 | 비가상, 필요하면 final |
| 다른 순수 가상 함수 없이 추상 클래스로 만들기 | 순수 가상 + 정의 |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 가상 함수 없는 기반 클래스를 기반 포인터로 delete | 경고 없이 파생 소멸자 누락 | 설계 시 소멸자 정책을 정하고, ASan으로 테스트 |
shared_ptr에서 잘 되던 코드를 unique_ptr로 변경 | 파생 소멸자 누락 | 기반 소멸자를 가상으로 |
| 순수 가상 소멸자에 정의 없음 | 링크 에러 | 클래스 밖에 본문 정의 |
| STL 컨테이너 상속 후 기반 포인터로 delete | 컨테이너 소멸자는 비가상 | 상속 대신 포함 (private 상속 편 참고) |
모든 클래스에 습관적으로 virtual | vptr로 크기 증가, 트리비얼 타입 아님 | 다형 기반 클래스에만 |
마무리
- 기반 포인터로 delete할 클래스는 소멸자를
virtual로 선언해야 파생 소멸자가 불린다. - 가상 함수가 없는 기반 클래스는 컴파일러 경고로도 잡히지 않으므로 ASan의
new-delete-type-mismatch와 LeakSanitizer로 확인한다. shared_ptr은 생성 시점 타입으로 삭제하지만unique_ptr은 그렇지 않다.