標籤:

C++中的deleting destructor

C++中的delete expression或者(delete operator)在多繼承下,如果使用不正確的話,可能存在程序崩潰的情況。如下代碼所示:

// main.cppclass Base1{ int mem_b1;public: Base1() : mem_b1(0) {} ~Base1() {}};class Base2{ int mem_b2;public: Base2() : mem_b2(0) {} ~Base2() {}};class Derived : public Base1, public Base2{ int mem_d;public: Derived() : mem_d(0) {} ~Derived() {}};int main(){ Base2 *b2 = new Derived(); delete b2;}

上面代碼是最簡單的多繼承場景,我們使用clang++編譯該代碼示例,然後執行目標文件時得到如下錯誤。

*** Error in `./a.out": free(): invalid pointer: 0x0867800c ***Aborted

該錯誤的產生的原因很簡單,"Base2 *b2 = new Derived();"程序語句會對new得到的內存地址進行調整(這裡調整的偏移量是4個位元組),由於new和delete的內存地址不匹配,所以會產生上述錯誤。但是如果上述多繼承體系使用虛析構函數,就不會在delete時產生上述問題。

其實上述示例代碼在C++標準中是未定義行為,我們的示例代碼符合上述未定義行為的情況,待deleted object的動態類型是Derived,其靜態類型是Base2,Base2是Derived的基類,但是繼承體系沒有使用虛析構函數。

  • 5.3.5.3 In the first alternative (delete object), if the static type of the object to be deleted is different from its dynamic type, the static type shall be a base class of the dynamic type of the object to be deleted and the static type shall have a virtual destructor or the behavior is undefined.

但是這裡仍有兩個問題需要解答。

  • 為什麼delete出錯時報出的信息和free()相關?
  • 為什麼將析構函數修改為虛函數能夠解決上述問題,實現機制是什麼?

下面我們會通過delete和deleting destructor兩個部分對這兩個問題進行分析解答。

1.C++中的delete

2.deleting destructor

1.C++中的delete expression

delete expression在《More Effective C++》和《C++ Primer》中都有介紹,我這裡摘抄Primer的描述。

  • 一條new表達式的執行過程總是先調用operator new函數以獲取內存空間,然後在得到內存空間中構造對象。與之相反,一條delete表達式的執行過程總是先銷毀對象,然後調用operator delete函數釋放對象所佔的空間。
  • 我們提供新的operator new函數和operator delete函數的目的在於改變內存分配的方式,但是不管怎樣,我們都不能改變new運算符和delete運算符的基本含義

而我們需要探索的是delete expression的第二個部分operator delete,就是內存釋放的部分。operator delete是允許用戶自定義的部分,只要符合固定的形式,用戶可以自定義內存釋放的操作。operator delete在標準庫有相應的實現,在LLVM的libcxx中的實現如下:

// https://code.woboq.org/llvm/libcxxabi/src/cxa_new_delete.cpp.html#_ZdlPv/*[new.delete.single]* Executes a loop: Within the loop, the function first attempts to allocate the requested storage. Whether the attempt involves a call to the Standard C library function malloc is unspecified.* Returns a pointer to the allocated storage if the attempt is successful. Otherwise, if the current new_handler (18.6.2.5) is a null pointer value, throws bad_alloc.* Otherwise, the function calls the current new_handler function (18.6.2.3). If the called function returns, the loop repeats. * The loop terminates when an attempt to allocate the requested storage is successful or when a called new_handler function does not return.*/__attribute__((__weak__, __visibility__("default")))void *operator new(std::size_t size)#if !__has_feature(cxx_noexcept) throw(std::bad_alloc)#endif{ if (size == 0) size = 1; void* p; while ((p = std::malloc(size)) == 0) { std::new_handler nh = std::get_new_handler(); if (nh) nh(); else throw std::bad_alloc(); } return p;}/* [new.delete.single]If ptr is null, does nothing. Otherwise, reclaims the storage allocated by the earlier call to opertor new.*/__attribute__((__weak__, __visibility__("default")))voidoperator delete(void* ptr)#if __has__feature(cxx_noexcept) noexcept#else throw()#endif{ if (ptr) std::free(ptr);}

上述代碼中我們可以看到libcxx中的new和delete都是通過調用C庫中的malloc和free實現的,Visual Studio中new和delete的核心也是malloc和free。岔開一下話題,對於new的實現,這裡有兩個部分需要注意,一是當new的對象的大小為0時,還是會強行分配一個size=1的內存塊,這個和空對象在棧上會佔用1個位元組空間相似,Bijarne Stroustrup對於此的解釋為「To ensure that the addresses of two different objects will be different.」,見C++ Style and Technique FAQ。第二個需要的注意的地方是類型new_handler,new在分配內存失敗時會調用該類型的方法,用戶可以自定義這個handler。

由於C++允許用戶定義自己operator new和operator delete,所以這兩個方法在libcxx中的實現都帶有weak屬性,以便用戶定義自己的operator delete和operator new實現時能夠在link-time對標準庫中的版本進行覆蓋。

  • The weak attribute causes the declaration to be emitted as a weak symbol rather than a global. This is primarily useful in defining library functions which can be overridden in user code, though it can also be used with non-function declarations. Weak symbols are supported for ELF targets, and also for a.out targets when using the GNU assembler and linker.

現在已經了解到delete在Clang和Visual Studio中都是通過free實現的,所以現在的問題就轉換為當free的內存地址和malloc返回的內存地址不相同時會發生什麼,在知乎上有很多關於這個問題的討論,見為什麼malloc時需要指定size,對應的free時不需要指定size? - 知乎和free一塊修改過的malloc指針會發生什麼? - 知乎,簡單來說這是一個未定義行為。

2.deleting destructor

前面我們已經知道錯誤產生的本質原因,本質原因是free的內存地址和malloc的內存地址不相同,這個在C++中是未定義行為。但為什麼將示例代碼繼承體系中的析構函數改為虛析構就可以解決這個問題,機制是什麼?

虛析構函數在C++中是一個老生常談的話題,虛析構的目的就是在析構時實現正確的析構行為。我們將示例代碼中的析構函數改為虛析構,其對應的虛表如下:

# g++ -fdump-class-hierarchy main.cppVtable for Base1Base1::_ZTV5Base1: 4u entries0 (int (*)(...))04 (int (*)(...))(& _ZTI5Base1)8 (int (*)(...))Base1::~Base112 (int (*)(...))Base1::~Base1Vtable for Base2Base2::_ZTV5Base2: 4u entries0 (int (*)(...))04 (int (*)(...))(& _ZTI5Base2)8 (int (*)(...))Base2::~Base212 (int (*)(...))Base2::~Base2Vtable for DerivedDerived::_ZTV7Derived: 8u entries0 (int (*)(...))04 (int (*)(...))(& _ZTI7Derived)8 (int (*)(...))Derived::~Derived12 (int (*)(...))Derived::~Derived16 (int (*)(...))-820 (int (*)(...))(& _ZTI7Derived)24 (int (*)(...))Derived::_ZThn8_N7DerivedD1Ev28 (int (*)(...))Derived::_ZThn8_N7DerivedD0Ev

我們可以看到Base1、Base2和Derived的虛析構函數在虛表中分別有兩個表項,為什麼會有兩個表項,refspecs.linuxfoundation.org有相關的解釋,解釋如下:

  • The entries for virtual destructors are actually pairs of entries. The first destructor, called the complete object destructor, performs the destruction without calling delete() on the object. The second destructor, called the deleting destructor, calls delete() after destroying the object. Both destroy any virtual bases; a separate, non-virtual function, called the base object destructor, performs destruction of the object but not its virtual base subobjects, and does not call delete().

上面對這兩個表項進行了說明,第二個虛析構表項用於在delete object調用,這兩個表項對應的的符號分別使用D0和D1表示,如下所示。那麼這兩個表項是解決該問題的關鍵嗎?

<ctor-dtor-name> ::= C1 # complete object constructor ::= C2 # base object constructor ::= C3 # complete object allocating constructor ::= D0 # deleting destructor ::= D1 # complete object destructor ::= D2 # base object destructor

我們回到問題的關鍵,析構函數是虛的,"delete b2;"可以執行正確的析構行為,但是operator delete呢?operator delete應該也是多態的,這樣才能執行正確的內存釋放操作。但是operator new和operator delete不能是virtual的,因為new發生在構造函數調用之前,delete發生在析構函數執行之後,不能訪問對象的數據成員

所以提出了一種deleting destructor的概念,使用該destructor將delete操作包裹起來,以便在delete object的時候,不僅第一步能夠實現正確的析構操作,也能在第二步實現正確的delete操作。

  • deleting destructor of a class T - A function that, in addition to the actions required of a complete object destructor, calls the appropriate deallocation function (i.e,. operator delete) for T.

正確的delete操作的關鍵是獲得正確的對象地址,在單繼承下不存在這樣的問題,new一個子類對象賦值給基類指針,地址不會進行調整,this指針進行調整的場景只存在於多繼承的情況。而多繼承時,將delete操作virtual化,就可以獲得正確的對象地址,從而執行正確的delete操作。Itanium C++ABI使用thunk機制來處理多繼承的情況,virtual thunk是一個用來調整this指針的函數,調整完this指針完之後,再調用對應的虛函數。前面Derived虛表中的兩個表項_ZThn8_N7DerivedD0Ev和_ZThn8_N7DerivedD1Ev就是相應的thunk函數,這兩個函數都會對this指針進行調整,調整的offset存儲在虛表中的。下面我們分別驗證代碼中是否生成了deleting destructor以及具體的調整細節。

我們將目標文件的符號列印出來如下,其中我們可以看到對於Base1、Base2和Derived分別生成了針對delete destructor的符號,_ZN5Base1D0Ev、_ZN5Base2D0Ev、_ZN7DerivedD0Ev和_ZThn8_N7DerivedD0Ev。對於_ZThn8_N7DerivedD0Ev我們使用c++filt得到其名字為non-virtual thunk to Derived::~Derived()。

Symbol table ".symtab" contains 105 entries: Num: Value Size Type Bind Vis Ndx Name 0: 00000000 0 NOTYPE LOCAL DEFAULT UND 1: 00000000 0 FILE LOCAL DEFAULT ABS crtstuff.c .......................... 74: 08048810 39 FUNC WEAK DEFAULT 13 _ZN5Base1C2Ev 75: 08048a20 75 FUNC WEAK DEFAULT 13 _ZN5Base1D0Ev 76: 00000000 0 FUNC GLOBAL DEFAULT UND _Unwind_Resume 77: 08048a10 15 FUNC WEAK DEFAULT 13 _ZN5Base1D2Ev 78: 08048840 39 FUNC WEAK DEFAULT 13 _ZN5Base2C2Ev 79: 080489c0 75 FUNC WEAK DEFAULT 13 _ZN5Base2D0Ev 80: 080489b0 15 FUNC WEAK DEFAULT 13 _ZN5Base2D2Ev 81: 08048780 138 FUNC WEAK DEFAULT 13 _ZN7DerivedC2Ev 82: 08048900 75 FUNC WEAK DEFAULT 13 _ZN7DerivedD0Ev 83: 08048890 100 FUNC WEAK DEFAULT 13 _ZN7DerivedD2Ev 84: 08048b38 8 OBJECT WEAK DEFAULT 15 _ZTI5Base1 85: 08048b48 8 OBJECT WEAK DEFAULT 15 _ZTI5Base2 86: 08048b50 32 OBJECT WEAK DEFAULT 15 _ZTI7Derived 87: 08048b31 7 OBJECT WEAK DEFAULT 15 _ZTS5Base1 88: 08048b40 7 OBJECT WEAK DEFAULT 15 _ZTS5Base2 89: 08048b28 9 OBJECT WEAK DEFAULT 15 _ZTS7Derived 90: 08048b80 16 OBJECT WEAK DEFAULT 15 _ZTV5Base1 91: 08048b70 16 OBJECT WEAK DEFAULT 15 _ZTV5Base2 92: 08048b08 32 OBJECT WEAK DEFAULT 15 _ZTV7Derived 93: 08048980 33 FUNC WEAK DEFAULT 13 _ZThn8_N7DerivedD0Ev 94: 08048950 33 FUNC WEAK DEFAULT 13 _ZThn8_N7DerivedD1Ev 95: 00000000 0 FUNC GLOBAL DEFAULT UND _ZSt9terminatev 96: 0804b060 44 OBJECT GLOBAL DEFAULT 27 _ZTVN10__cxxabiv117__clas 97: 0804b0a0 44 OBJECT GLOBAL DEFAULT 27 _ZTVN10__cxxabiv121__vmi_ 98: 00000000 0 FUNC GLOBAL DEFAULT UND _ZdlPv 99: 00000000 0 FUNC GLOBAL DEFAULT UND _Znwj ........

我們使用Clang++得到示例代碼的中間代碼,相應析構函數的代碼如下所示:

# non-virtual thunk to Derived::~Derived()define linkonce_odr void @_ZThn8_N7DerivedD1Ev(%class.Derived* %this) unnamed_addr #0 align 2 { %1 = alloca %class.Derived*, align 4 store %class.Derived* %this, %class.Derived** %1, align 4 %2 = load %class.Derived** %1 %3 = bitcast %class.Derived* %2 to i8*# 對this指針進行調整,offset為-8 %4 = getelementptr inbounds i8* %3, i64 -8 %5 = bitcast i8* %4 to %class.Derived*# 調用Derived的析構函數 call void @_ZN7DerivedD1Ev(%class.Derived* %5) ret void}# non-virtual thunk to Derived::~Derived()define linkonce_odr void @_ZThn8_N7DerivedD0Ev(%class.Derived* %this) unnamed_addr #0 align 2 { %1 = alloca %class.Derived*, align 4 store %class.Derived* %this, %class.Derived** %1, align 4 %2 = load %class.Derived** %1 %3 = bitcast %class.Derived* %2 to i8*# 對this指針進行調整,offset為-8 %4 = getelementptr inbounds i8* %3, i64 -8 %5 = bitcast i8* %4 to %class.Derived*# 調用Derived的析構函數 call void @_ZN7DerivedD0Ev(%class.Derived* %5) ret void}define linkonce_odr void @_ZN7DerivedD2Ev(%class.Derived* %this) unnamed_addr #0 align 2 { %1 = alloca %class.Derived*, align 4 store %class.Derived* %this, %class.Derived** %1, align 4 %2 = load %class.Derived** %1 %3 = bitcast %class.Derived* %2 to i8* %4 = getelementptr inbounds i8* %3, i64 8 %5 = bitcast i8* %4 to %class.Base2*# 調用Base2::~Base2() call void @_ZN5Base2D2Ev(%class.Base2* %5) %6 = bitcast %class.Derived* %2 to %class.Base1*# 調用Base1::~Base1() call void @_ZN5Base1D2Ev(%class.Base1* %6) ret void}define linkonce_odr void @_ZN7DerivedD1Ev(%class.Derived* %this) unnamed_addr #0 align 2 { %1 = alloca %class.Derived*, align 4 store %class.Derived* %this, %class.Derived** %1, align 4 %2 = load %class.Derived** %1# 調用基類的析構函數 call void @_ZN7DerivedD2Ev(%class.Derived* %2) ret void}define linkonce_odr void @_ZN7DerivedD0Ev(%class.Derived* %this) unnamed_addr #0 align 2 { %1 = alloca %class.Derived*, align 4 store %class.Derived* %this, %class.Derived** %1, align 4 %2 = load %class.Derived** %1# 調用Derived::~Derived() call void @_ZN7DerivedD1Ev(%class.Derived* %2) %3 = bitcast %class.Derived* %2 to i8*# 調用operator delete,使用c++filt _ZdlPv得到operator delete call void @_ZdlPv(i8* %3) #4 ret void}

上面的中間代碼有兩點需要注意,第一就是deleting destructor對應的thunk函數@_ZThn8_N7DerivedD0Ev對this指針進行了offset為-8的調整,然後調用了deleting destructor也就是@_ZN7DerivedD0Ev。而@_ZN7DerivedD0Ev首先調用 @_ZN7DerivedD1Ev函數,然後調用operator delete。Derived對應的虛表以及Derived的內存布局如下所示。thunk函數位於non-primary base也就是Base2對應的虛表中,offset為-8。

# clang -cc1 -fdump-record-layouts main.cpp*** Dumping AST Record Layout 0 | class Derived 0 | class Base1 (primary base) 0 | (Base1 vtable pointer) 4 | int mem_b1 8 | class Base2 (base) 8 | (Base2 vtable pointer) 12 | int mem_b2 16 | int mem_d | [sizeof=20, dsize=20, align=4 | nvsize=20, nvalign=4]# g++ -fdump-class-hierarchy main.cppVtable for DerivedDerived::_ZTV7Derived: 8u entries (int (*)(...))0 (int (*)(...))(& _ZTI7Derived) (int (*)(...))Derived::~Derived (int (*)(...))Derived::~Derived (int (*)(...))-8 (int (*)(...))(& _ZTI7Derived) (int (*)(...))Derived::_ZThn8_N7DerivedD1Ev (int (*)(...))Derived::_ZThn8_N7DerivedD0Ev

下面我們使用gdb對main.cpp對應的目標文件進行調試,調試過程中Derived::deleting destructor對應的thunk函數如下,此時this指針為0x804c010,對this指針進行offset為-8的調整以後調用deleting destructor。

Dump of assembler code for function _ZThn8_N7DerivedD0Ev:=> 0x08048980 <+0>: push %ebp 0x08048981 <+1>: mov %esp,%ebp 0x08048983 <+3>: sub $0x8,%esp 0x08048986 <+6>: mov 0x8(%ebp),%eax 0x08048989 <+9>: mov %eax,-0x4(%ebp) 0x0804898c <+12>: mov -0x4(%ebp),%eax# 調整this指針 0x0804898f <+15>: add $0xfffffff8,%eax# 將參數存放到棧上 0x08048994 <+20>: mov %eax,(%esp)# 調用deleting destructor 0x08048997 <+23>: call 0x8048900 <Derived::~Derived()> 0x0804899c <+28>: add $0x8,%esp 0x0804899f <+31>: pop %ebp 0x080489a0 <+32>: ret

deleting destructor彙編代碼如下,此時this指針為0x804c008,該函數首先調用Derived::~Derived(),然後調用operator delete。

0x08048900 <+0>: push %ebp 0x08048901 <+1>: mov %esp,%ebp 0x08048903 <+3>: sub $0x18,%esp 0x08048906 <+6>: mov 0x8(%ebp),%eax 0x08048909 <+9>: mov %eax,-0x4(%ebp)=> 0x0804890c <+12>: mov %eax,%ecx 0x0804890e <+14>: mov %esp,%edx 0x08048910 <+16>: mov %eax,(%edx) 0x08048912 <+18>: mov %ecx,-0x10(%ebp)# 調用Derived::~Derived(),其中會調用Base2::~Base2()和Base1::~Base1() 0x08048915 <+21>: call 0x8048890 <Derived::~Derived()> 0x0804891a <+26>: jmp 0x804891f <Derived::~Derived()+31> 0x0804891f <+31>: mov -0x10(%ebp),%eax 0x08048922 <+34>: mov %eax,(%esp)# 調用opertor delete 0x08048925 <+37>: call 0x8048590 <_ZdlPv@plt> 0x0804892a <+42>: add $0x18,%esp 0x0804892d <+45>: pop %ebp

至此虛析構函數能夠解決開篇bug的原因已經找到了(實驗平台僅限於Linux+Clang),就是虛析構函數能夠添加deleting destructor,通過調整this指針,該方法能夠對正確的地址進行delete。我們實驗的平台是Linux+Clang,ABI為Itanium C++ABI,因此實現機製為deleting destructor + virtual thunk。對於Visual Studio來說,則是另外一種機制"scalar deleting destructor"和"vector deleting destructor",該機制我沒有具體研究,但是對於this指針的調整是必須的。

總之,開篇提到的問題表面上是未定義行為,但實質上是delete和new的內存地址不匹配導致的。對於該問題的解決辦法,表面上是虛析構函數,實質上是通過調整this指針,也就是內存地址,從而能夠調用正確的delete行為。

註:C++: Deleting destructors and virtual operator delete博客對於該問題的解釋很明白,另外感謝@顏志鵬同學


推薦閱讀:

static_cast<const char*>("Fuck GTK+")
用CPUID檢測各大OJ測評機所用的CPU(以及日常黑BZOJ)

TAG:C |