C++ 고급 문법 (21) — 가상 소멸자(Virtual Destructor)

Base* p = new Derived; delete p;는 컴파일도 실행도 문제없어 보인다. 하지만 Base의 소멸자가 가상이 아니면 delete는 정적 타입 Base의 소멸자만 호출한다. Derived가 잡은 자원은 해제되지 않고, 표준상으로는 미정의 동작이다. 소멸자도 함수 바인딩 편에서 본 규칙을 그대로 따르기 때문이다. 이 글에서는 g++ 13.3과 AddressSanitizer로 비가상 소멸자의 문제를 재현하고, 컴파일러 경고, 스마트 포인터별 차이, 순수 가상 소멸자, 가상 소멸자의 비용을 확인한다.

이 시리즈의 다른 글

비가상 소멸자로 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 상속 편 참고)
모든 클래스에 습관적으로 virtualvptr로 크기 증가, 트리비얼 타입 아님다형 기반 클래스에만

마무리

  • 기반 포인터로 delete할 클래스는 소멸자를 virtual로 선언해야 파생 소멸자가 불린다.
  • 가상 함수가 없는 기반 클래스는 컴파일러 경고로도 잡히지 않으므로 ASan의 new-delete-type-mismatch와 LeakSanitizer로 확인한다.
  • shared_ptr은 생성 시점 타입으로 삭제하지만 unique_ptr은 그렇지 않다.

참고

답글 남기기