有時我們想這樣管理某些對象,要讓某種類型的對象能夠自我銷毀,即“delete this”。很明顯這種管理方式需要此類型對象被配置設定在堆中。而其它一些時候我們想獲得一種保障:“不在堆中配置設定對象,進而保證某種類型的類不會發生記憶體洩漏。”若在嵌入式系統上工作,就有可能遇到這種情況,發生在嵌入式系統上的記憶體洩漏是極其嚴重的,其堆空間是非常珍貴的。有沒有可能編寫出代碼來要求或禁止在堆中産生對象(heap-based object)呢?通常是可以的,不過這種代碼也會把“on the heap”的概念搞得比你腦海中所想的要模糊。
要求在堆中建立對象
先從必須在堆中建立對象開始說。為了執行這種限制,必須找到一種方法禁止以調用“new”以外的其它手段建立對象。這很容易做到。非堆對象(non-heap object)在定義它的地方被自動構造,在生存時間結束時自動被釋放,是以隻要禁止使用隐式的構造函數和析構函數,就可以實作這種限制。
把這些調用變得不合法的一種最直接的方法是把構造函數和析構函數聲明為private。這樣做副作用太大。沒有理由讓這兩個函數都是private。最好讓析構函數成為private,讓構造函數成為public。我們可以引進一個專用的僞析構函數,用來通路真正的析構函數。用戶端調用僞析構函數釋放他們建立的對象。(異常處理體系要求所有在棧中的對象的析構函數必須申明為公有!)
例如,如果我們想僅僅在堆中建立代表unlimited precision numbers(無限精确度數字)的對象,可以這樣做:
class UPNumber {
public:
UPNumber();
UPNumber(int initValue);
UPNumber(double initValue);
UPNumber(const UPNumber& rhs);
// 僞析構函數 (一個const 成員函數, 因為即使是const對象也能被釋放。)
void destroy() const { delete this; }
...
private:
~UPNumber();
};
然後用戶端這樣進行程式設計:
UPNumber n; // 錯誤! (在這裡合法, 但是
// 當它的析構函數被隐式地
// 調用時,就不合法了)
UPNumber *p = new UPNumber; //正确
delete p; // 錯誤! 試圖調用private 析構函數
p->destroy(); // 正确
另一種方法是把全部的構造函數都聲明為private。這種方法的缺點是一個類經常有許多構造函數,類的作者必須記住把它們都聲明為private。否則如果這些函數就會由編譯器生成,構造函數包括拷貝構造函數,也包括預設構造函數;編譯器生成的函數總是public。是以僅僅聲明析構函數為private是很簡單的,因為每個類隻有一個析構函數。
通過限制通路一個類的析構函數或它的構造函數來阻止建立非堆對象,但是這種方法也禁止了繼承和包容(containment):
class UPNumber { ... }; // 聲明析構函數或構造函數
// 為private
class NonNegativeUPNumber:
public UPNumber { ... }; // 錯誤! 析構函數或
//構造函數不能編譯
class Asset {
private:
UPNumber value;
// 錯誤! 析構函數或構造函數不能編譯
};
這些困難不是不能克服的。通過把UPNumber的析構函數聲明為protected(同時它的構造函數還保持public)就可以解決繼承的問題,需要包含UPNumber對象的類可以修改為包含指向UPNumber的指針:
class UPNumber { ... }; // 聲明析構函數為protected
class NonNegativeUPNumber:
public UPNumber { ... }; // 現在正确了; 派生類
// 能夠通路protected 成員
class Asset {
public:
Asset(int initValue);
~Asset();
...
private:
UPNumber *value;
};
Asset::Asset(int initValue)
: value(new UPNumber(initValue)) // 正确
{ ... }
Asset::~Asset()
{ value->destroy(); } // 也正确
判斷一個對象是否在堆中
如果我們采取這種方法,我們必須重新審視一下“在堆中”這句話的含義。上述粗略的類定義表明一個非堆的NonNegativeUPNumber對象是合法的:
NonNegativeUPNumber n; // 正确
那麼現在NonNegativeUPNumber對象n中的UPNumber部分也不在堆中,這樣說對麼?答案要依據類的設計和實作的細節而定,但是讓我們假設這樣說是不對的,所有UPNumber對象 —即使是做為其它派生類的基類—也必須在堆中。我們如何能強制執行這種限制呢?
沒有簡單的辦法。UPNumber的構造函數不可能判斷出它是否做為堆對象的基類而被調用。也就是說對于UPNumber的構造函數來說沒有辦法偵測到下面兩種環境的差別:
NonNegativeUPNumber *n1 =
new NonNegativeUPNumber; // 在堆中
NonNegativeUPNumber n2; //不再堆中
也許你想你能夠在new操作符、operator new和new 操作符調用的構造函數的互相作用中玩些小把戲。可可以這樣修改UPNumber,如下所示:
class UPNumber {
public:
// 如果建立一個非堆對象,抛出一個異常
class HeapConstraintViolation {};
static void * operator new(size_t size);
UPNumber();
...
private:
static bool onTheHeap; //在構造函數内,訓示
//對象是否被構造在堆上
};
// obligatory definition of class static
bool UPNumber::onTheHeap = false;
void *UPNumber::operator new(size_t size)
{
onTheHeap = true;
return ::operator new(size);
}
UPNumber::UPNumber()
{
if (!onTheHeap) {
throw HeapConstraintViolation();
}
proceed with normal construction here;
onTheHeap = false; // 為下一個對象清除标記
}
如果不再深入研究下去,就不會發現什麼錯誤。這種方法利用了這樣一個事實:“當在堆上配置設定對象時,會調用operator new來配置設定raw memory”,operator new設定onTheHeap為true,每個構造函數都會檢測onTheHeap,看對象的raw memory是否被operator new所配置設定。如果沒有,一個類型為HeapConstraintViolation的異常将被抛出。否則構造函數如通常那樣繼續運作,當構造函數結束時,onTheHeap被設定為false,然後為構造下一個對象而重置到預設值。
這是一個非常好的方法,但是不能運作。請考慮一下這種可能的用戶端代碼:
UPNumber *numberArray = new UPNumber[100];
第一個問題是為數組配置設定記憶體的是operator new[],而不是operator new,不過(倘若你的編譯器支援它)你能象編寫operator new一樣容易地編寫operator new[]函數。更大的問題是numberArray有100個元素,是以會調用100次構造函數。但是隻有一次配置設定記憶體的調用,是以100個構造函數中隻有第一次調用構造函數前把onTheHeap設定為true。當調用第二個構造函數時,會抛出一個異常,你真倒黴。
即使不用數組,bit-setting操作也會失敗。考慮這條語句:
UPNumber *pn = new UPNumber(*new UPNumber);
這裡我們在堆中建立兩個UPNumber,讓pn指向其中一個對象;這個對象用另一個對象的值進行初始化。這個代碼有一個記憶體洩漏,我們先忽略這個洩漏,這有利于下面對這條表達式的測試,執行它時會發生什麼事情:
new UPNumber(*new UPNumber)
它包含new 操作符的兩次調用,是以要調用兩次operator new和調用兩次UPNumber構造函數。程式員一般期望這些函數以如下順序執行:
調用第一個對象的operator new
調用第一個對象的構造函數
調用第二個對象的operator new
調用第二個對象的構造函數
但是C++語言沒有保證這就是它調用的順序。一些編譯器以如下這種順序生成函數調用:
調用第一個對象的operator new
調用第二個對象的operator new
調用第一個對象的構造函數
調用第二個對象的構造函數
編譯器生成這種代碼絲毫沒有錯,但是在operator new中set-a-bit的技巧無法與這種編譯器一起使用。因為在第一步和第二步設定的bit,第三步中被清除,那麼在第四步調用對象的構造函數時,就會認為對象不再堆中,即使它确實在。
這些困難沒有否定讓每個構造函數檢測*this指針是否在堆中這個方法的核心思想,它們隻是表明檢測在operator new(或operator new[])裡的bit set不是一個可靠的判斷方法。我們需要更好的方法進行判斷。
例如你決定利用一個在很多系統上存在的事實,程式的位址空間被做為線性位址管理,程式的棧從位址空間的頂部向下擴充,堆則從底部向上擴充:
在以這種方法管理程式記憶體的系統裡(很多系統都是,但是也有很多不是這樣),你可能會想能夠使用下面這個函數來判斷某個特定的位址是否在堆中:
// 不正确的嘗試,來判斷一個位址是否在堆中
bool onHeap(const void *address)
{
char onTheStack; // 局部棧變量
return address < &onTheStack;
}
這個函數背後的思想很有趣。在onHeap函數中onTheSatck是一個局部變量。是以它在堆棧上。當調用onHeap時,它的棧架構(stack frame)(也就是它的activation record)被放在程式棧的頂端,因為棧在結構上是向下擴充的(趨向低位址),onTheStack的位址肯定比任何棧中的變量或對象的位址小。如果參數address的位址小于onTheStack的位址,它就不會在棧上,而是肯定在堆上。
到目前為止,這種邏輯很正确,但是不夠深入。最根本的問題是對象可以被配置設定在三個地方,而不是兩個。是的,棧和堆能夠容納對象,但是我們忘了靜态對象。靜态對象是那些在程式運作時僅能初始化一次的對象。靜态對象不僅僅包括顯示地聲明為static的對象,也包括在全局和命名空間裡的對象。這些對象肯定位于某些地方,而這些地方既不是棧也不是堆。
它們的位置是依據系統而定的,但是在很多棧和堆相向擴充的系統裡,它們位于堆的底端。先前記憶體管理的圖檔到講述的是事實,而且是很多系統都具有的事實,但是沒有告訴我們這些系統全部的事實,加上靜态變量後,這幅圖檔如下所示:
onHeap不能工作的原因立刻變得很清楚了,不能辨識堆對象與靜态對象的差別:
void allocateSomeObjects()
{
char *pc = new char; // 堆對象: onHeap(pc)
// 将傳回true
char c; // 棧對象: onHeap(&c)
// 将傳回false
static char sc; // 靜态對象: onHeap(&sc)
// 将傳回true
...
}
現在你可能不顧一切地尋找區分堆對象與棧對象的方法,在走頭無路時你想在可移植性上打主意,但是你會這麼孤注一擲地進行一個不能獲得正确結果的交易麼?絕對不會。我知道你會拒絕使用這種雖然誘人但是不可靠的“位址比對”技巧。
令人傷心的是不僅沒有一種可移植的方法來判斷對象是否在堆上,而且連能在多數時間正常工作的“準可移植”的方法也沒有。如果你實在非得必須判斷一個位址是否在堆上,你必須使用完全不可移植的方法,其實作依賴于系統調用,隻能這樣做了。是以你最好重新設計你的軟體,以便你可以不需要判斷對象是否在堆中。
如果你發現自己實在為對象是否在堆中這個問題所困擾,一個可能的原因是你想知道對象是否能在其上安全調用delete。這種删除經常采用“delete this”這種聲明狼籍的形式。不過知道“是否能安全删除一個指針”與“隻簡單地知道一個指針是否指向堆中的事物”不一樣,因為不是所有在堆中的事物都能被安全地delete。再考慮包含UPNumber對象的Asset對象:
class Asset {
private:
UPNumber value;
...
};
Asset *pa = new Asset;
很明顯*pa(包括它的成員value)在堆上。同樣很明顯在指向pa->value上調用delete是不安全的,因為該指針不是被new傳回的。
幸運的是“判斷是否能夠删除一個指針”比“判斷一個指針指向的事物是否在堆上”要容易。因為對于前者我們隻需要一個operator new傳回的位址集合。因為我們能自己編寫operator new函數,是以建構這樣一個集合很容易。如下所示,我們這樣解決這個問題:
void *operator new(size_t size)
{
void *p = getMemory(size); //調用一些函數來配置設定記憶體,
//處理記憶體不夠的情況
把 p加入到一個被配置設定位址的集合;
return p;
}
void operator delete(void *ptr)
{
releaseMemory(ptr); // return memory to
// free store
從被配置設定位址的集合中移去ptr;
}
bool isSafeToDelete(const void *address)
{
傳回address是否在被配置設定位址的集合中;
}
這很簡單,operator new在位址配置設定集合裡加入一個元素,operator delete從集合中移去項目,isSafeToDelete在集合中查找并确定某個位址是否在集合中。如果operator new 和 operator delete函數在全局作用域中,它就能适用于所有的類型,甚至是内建類型。
在實際當中,有三種因素制約着對這種設計方式的使用。第一是我們極不願意在全局域定義任何東西,特别是那些已經具有某種含義的函數,象operator new和operator delete。正如我們所知,隻有一個全局域,隻有一種具有正常特征形式(也就是參數類型)的operator new和operator delete。這樣做會使得我們的軟體與其它也實作全局版本的operator new 和operator delete的軟體(例如許多面向對象資料庫系統)不相容。
我們考慮的第二個因素是效率:如果我們不需要這些,為什麼還要為跟蹤傳回的位址而負擔額外的開銷呢?
最後一點可能有些平常,但是很重要。實作isSafeToDelete讓它總能夠正常工作是不可能的。難點是多繼承下來的類或繼承自虛基類的類有多個位址,是以無法保證傳給isSafeToDelete的位址與operator new 傳回的位址相同,即使對象在堆中建立。
我們希望這些函數提供這些功能時能夠不污染全局命名空間,沒有額外的開銷,沒有正确性問題。幸運的是C++使用一種抽象mixin基類滿足了我們的需要。
抽象基類是不能被執行個體化的基類,也就是至少具有一個純虛函數的基類。mixin(“mix in”)類提供某一特定的功能,并可以與其繼承類提供的其它功能相相容。這種類幾乎都是抽象類。是以我們能夠使用抽象混合(mixin)基類給派生類提供判斷指針指向的記憶體是否由operator new配置設定的能力。該類如下所示:
class HeapTracked { // 混合類; 跟蹤
public: // 從operator new傳回的ptr
class MissingAddress{}; // 異常類,見下面代碼
virtual ~HeapTracked() = 0;
static void *operator new(size_t size);
static void operator delete(void *ptr);
bool isOnHeap() const;
private:
typedef const void* RawAddress;
static list<RawAddress> addresses;
};
這個類使用了list(連結清單)資料結構跟蹤從operator new傳回的所有指針,list是标準C++庫的一部分。operator new函數配置設定記憶體并把位址加入到list中;operator delete用來釋放記憶體并從list中移去位址元素。isOnHeap判斷一個對象的位址是否在list中。
HeapTracked類的實作很簡單,調用全局的operator new和operator delete函數來完成記憶體的配置設定與釋放,list類裡的函數進行插入操作和删除操作,并進行單語句的查找操作。以下是HeapTracked的全部實作:
// mandatory definition of static class member
list<RawAddress> HeapTracked::addresses;
// HeapTracked的析構函數是純虛函數,使得該類變為抽象類。
//然而析構函數必須被定義,是以我們做了一個空定義。.
HeapTracked::~HeapTracked() {}
void * HeapTracked::operator new(size_t size)
{
void *memPtr = ::operator new(size); // 獲得記憶體
addresses.push_front(memPtr); // 把位址放到list的前端
return memPtr;
}
void HeapTracked::operator delete(void *ptr)
{
//得到一個 "iterator",用來識别list元素包含的ptr;
list<RawAddress>::iterator it =
find(addresses.begin(), addresses.end(), ptr);
if (it != addresses.end()) { // 如果發現一個元素
addresses.erase(it); //則删除該元素
::operator delete(ptr); // 釋放記憶體
} else { // 否則
throw MissingAddress(); // ptr就不是用operator new
} // 配置設定的,是以抛出一個異常
}
bool HeapTracked::isOnHeap() const
{
// 得到一個指針,指向*this占據的記憶體空間的起始處,
const void *rawAddress = dynamic_cast<const void*>(this);
// 在operator new傳回的位址list中查到指針
list<RawAddress>::iterator it =
find(addresses.begin(), addresses.end(), rawAddress);
return it != addresses.end(); // 傳回it是否被找到
}
盡管你可能對list類和标準C++庫的其它部分不很熟悉,代碼還是很一目了然。
隻有一個地方可能讓你感到困惑,就是這個語句(在isOnHeap函數中)
const void *rawAddress = dynamic_cast<const void*>(this);
前面說過帶有多繼承或虛基類的對象會有幾個位址,這導緻編寫全局函數isSafeToDelete會很複雜。這個問題在isOnHeap中仍然會遇到,但是因為isOnHeap僅僅用于HeapTracked對象中,我們能使用dynamic_cast操作符的一種特殊的特性來消除這個問題。隻需簡單地放入dynamic_cast,把一個指針dynamic_cast成void*類型(或const void*或volatile void* 。。。。。),生成的指針将指向“原指針指向對象記憶體”的開始處。但是dynamic_cast隻能用于“指向至少具有一個虛拟函數的對象”的指針上。我們該死的isSafeToDelete函數可以用于指向任何類型的指針,是以dynamic_cast也不能幫助它。isOnHeap更具有選擇性(它隻能測試指向HeapTracked對象的指針),是以能把this指針dynamic_cast成const void*,變成一個指向目前對象起始位址的指針。如果HeapTracked::operator new為目前對象配置設定記憶體,這個指針就是HeapTracked::operator new傳回的指針。如果你的編譯器支援dynamic_cast 操作符,這個技巧是完全可移植的。
使用這個類,即使是最初級的程式員也可以在類中加入跟蹤堆中指針的功能。他們所需要做的就是讓他們的類從HeapTracked繼承下來。例如我們想判斷Assert對象指針指向的是否是堆對象:
class Asset: public HeapTracked {
private:
UPNumber value;
...
};
我們能夠這樣查詢Assert*指針,如下所示:
void inventoryAsset(const Asset *ap)
{
if (ap->isOnHeap()) {
ap is a heap-based asset — inventory it as such;
}
else {
ap is a non-heap-based asset — record it that way;
}
}
象HeapTracked這樣的混合類有一個缺點,它不能用于内建類型,因為象int和char這樣的類型不能繼承自其它類型。不過使用象HeapTracked的原因一般都是要判斷是否可以調用“delete this”,你不可能在内建類型上調用它,因為内建類型沒有this指針。
禁止堆對象
判斷對象是否在堆中的測試到現在就結束了。與此相反的領域是“禁止在堆中建立對象”。通常對象的建立這樣三種情況:對象被直接執行個體化;對象做為派生類的基類被執行個體化;對象被嵌入到其它對象内。我們将按順序地讨論它們。
禁止使用者直接執行個體化對象很簡單,因為總是調用new來建立這種對象,你能夠禁止使用者調用new。你不能影響new操作符的可用性(這是内嵌于語言的),但是你能夠利用new操作符總是調用operator new函數這點,來達到目的。你可以自己聲明這個函數,而且你可以把它聲明為private。例如,如果你想不想讓使用者在堆中建立UPNumber對象,你可以這樣編寫:
class UPNumber {
private:
static void *operator new(size_t size);
static void operator delete(void *ptr);
...
};
現在使用者僅僅可以做允許它們做的事情:
UPNumber n1; // okay
static UPNumber n2; // also okay
UPNumber *p = new UPNumber; // error! attempt to call
// private operator new
把operator new聲明為private就足夠了,但是把operator new聲明為private,而把iperator delete聲明為public,這樣做有些怪異,是以除非有絕對需要的原因,否則不要把它們分開聲明,最好在類的一個部分裡聲明它們。如果你也想禁止UPNumber堆對象數組,可以把operator new[]和operator delete[]也聲明為private。(operator new和operator delete之間的聯系比大多數人所想象的要強得多)
有趣的是,把operator new聲明為private經常會阻礙UPNumber對象做為一個位于堆中的派生類對象的基類被執行個體化。因為operator new和operator delete是自動繼承的,如果operator new和operator delete沒有在派生類中被聲明為public(進行改寫,overwrite),它們就會繼承基類中private的版本,如下所示:
class UPNumber { ... }; // 同上
class NonNegativeUPNumber: //假設這個類
public UPNumber { //沒有聲明operator new
...
};
NonNegativeUPNumber n1; // 正确
static NonNegativeUPNumber n2; // 也正确
NonNegativeUPNumber *p = // 錯誤! 試圖調用
new NonNegativeUPNumber; // private operator new
如果派生類聲明它自己的operator new,當在堆中配置設定派生對象時,就會調用這個函數,于是得另找一種不同的方法來防止UPNumber基類的配置設定問題。UPNumber的operator new是private這一點,不會對包含UPNumber成員對象的對象的配置設定産生任何影響:
class Asset {
public:
Asset(int initValue);
...
private:
UPNumber value;
};
Asset *pa = new Asset(100); // 正确, 調用
// Asset::operator new 或
// ::operator new, 不是
// UPNumber::operator new
實際上,我們又回到了這個問題上來,即“如果UPNumber對象沒有被構造在堆中,我們想抛出一個異常”。當然這次的問題是“如果對象在堆中,我們想抛出異常”。正像沒有可移植的方法來判斷位址是否在堆中一樣,也沒有可移植的方法判斷位址是否不在堆中,是以我們很不走運,不過這也絲毫不奇怪,畢竟如果我們能辨識出某個位址在堆上,我們也就能辨識出某個位址不在堆上。但是我們什麼都不能辨識出來。