Andrew Koenig 和 Barbara Moo 堪稱C++研究領域的”第一神仙眷侶”,看他們的書非常有條理性。這次要解釋的是C++中的另一個常見問題。
找出一種優美的控制記憶體分配的方法來繫結不同子類物件到容器中多麼複雜的一句話,莫慌,其實很簡單,跟著步伐來看。

首先假設我們要設計一系列交通工具的類,一般來說我們會定義一個交通工具的基類,裡面存放所有交通工具都有的成員和屬性,比如這樣:

class Vehicle {
public:
    virtual double weight() const = 0;
    virtual void start() = 0;
    // ......
};

然後會有一些交通工具繼承關係,比如這樣:

class RoadVehicle : public Vehicle { /* ...... */ };
class AutoVehicle : public RoadVehicle { /* ...... */ };
class Aircraft    : public Vehicle { /* ...... */ };
class Helicopter  : public Aircraft { /* ...... */ };

現在我們要定義一個容器,來儲存不同型別的交通工具。

這個要求看起來簡單,但沒有想象中那麼容易。化繁為簡,比如我們用一個陣列來儲存不同的交通工具,首先我可能會這麼寫:

Vehicle parking_lot[1000];

仔細一想,這麼寫好像不對,為什麼呢?因為 Vehicle 裡面有純虛擬函式,所以 Vehicle 是個抽象類,抽象類是不會有物件的,所以這麼定義是肯定不行的。一般分析也就到這裡為止了,但繼續想一下,如果我把 Vehicle 中的所有純虛擬函式去掉,那麼這種定義好像就是OK的,語法上不會有問題,但是有另一個問題,比如下面這樣的賦值:

Helicopter x = /* ...... */
parking_lot[num_vehicles++] = x;


這樣的賦值會導致 Helicopter 物件被轉換成一個Vehicle物件,它將丟失自己的 Helicopter 屬性,這可不是我們想要的,這就好像把一個 double 數轉換成整型放進整形陣列裡,丟失了自己的小數部分。

看到這裡,馬上有人會提出,那麼在 parking_lot 中儲存 Vehicle 的指標不就可以了嗎?我們一起來看看:

Vehicle *parking_lot[1000];    // 指標陣列


然後我們重複上面的賦值操作:

Helicopter x = /* ...... */
parking_lot[num_vehicles++] = &x;

看起來一切OK,但是有經驗的程式設計師(比如說我,:))一眼就看出這裡很危險,為什麼危險呢?因為儲存指標本身就是一件危險的事情,具體說來,這裡的 x 看起來是一個區域性變數,如果 x 被釋放掉了,那麼 parking_lot 陣列裡的指標立馬成了懸垂指標,指向什麼內容就不知道了。一個富有責任心的程式設計師是鐵定不會這麼幹的。

那我們是不是就沒折了呢?也不是,既然放指標不行,那麼我複製一下這個物件算了,如下:

Helicopter x = /* ...... */
parking_lot[num_vehicles++] = new Helicopter(x);


雖然浪費了些時間和記憶體,但是這麼做看起來確實可以,自己分配了記憶體當然要由自己來釋放,所以我們繼續規定在 delete 這個 parking_lot 的時候,我們也釋放其中所指向的物件。如果這麼幹只有自己管理記憶體這麼一個負擔的話,我想我還能接受,但是這裡有一個不那麼明顯的問題。就是我們放入 parking_lot 中的物件,必須要是已知型別的物件,一說到這裡有的看官就立馬明白了我的意思了,也就是說對於那些編譯時型別未知的物件,這裡就沒辦法儲存了,舉個例子,比如我需要在 parking_lot[p] 中放 parking_lot[q] 的物件,該怎麼辦呢?我們並不知道 parking_lot[q] 的物件型別,所以我們沒辦法複製這個物件,同時,我們不能讓 parking_lot 中有兩個指標指向同一個物件,因為我們在刪除這個容器時會把裡面的物件也刪掉,如果有兩個指標指向同一個物件那麼就會刪除兩次。當然,你可以用別的方法來避免,但這還是讓我無法忍受了。

對於編譯時的未知物件,聰明的程式設計師已經想到辦法解決了。為什麼我們要知道它們是什麼?只要它們自己知道自己是什麼,然後告訴我們就OK了唄!good boy!說明白些,就是我們可以讓繼承自 Vehicle 的類來告訴別人他們到底是什麼,一個簡單的辦法就是在 Vehicle 中定義的 copy 的純虛擬函式,然後繼承自 Vehicle 的類都設計自己的 copy 函式,用來把自己複製一份返回給呼叫者,這樣呼叫者就不用知道這些亂七八糟的交通工具是什麼了。我們來繼續修改程式碼:

class Vehicle {
public:
    virtual double weight() const = 0;
    virtual void start() = 0;
    virtual Vehicle *copy() const = 0;
    // ......
};

然後我們修改 Helicopter 類,增加一個 copy 函式:

Vehicle *Helicopter::copy() const
{
    return new Helicopter(*this);
}

這樣我們就再也不需要知道x的型別或者是 parking_lot[q] 的型別了,直接呼叫 x.copy() 函式或者 parking_lot[q]->copy() 函式就OK了。

parking_lot[num_vehicles++] = x.copy();
parking_lot[p] = parking_lot[q]->copy();


我們完美的解決了上面提到的第二個問題,但程式設計師從來都是追求完美的,那麼我們有辦法解決這個顯示處理記憶體分配的問題嗎?這也是程式設計師幸福的地方,別的領域追求完美是極其困難的,但程式碼總能讓我們欣喜。《C++ 沉思錄》裡提到了一個非常深刻的概念——“用類來表示概念”,到底是個什麼意思呢?就是說我們設計類,不光可以是一個具體的事物,同樣,也可以是一個概念,比如,你可以用類來表示人,男人,女人等等,同樣你可以用類來表示家庭,人是具體的,而家庭只是一個概念,家庭裡肯定有有人,所以把控了家庭這個概念,也就把控了人(不要跟我抬槓說有些人沒有家庭,舉個例子而已,親!)。

具體表現在程式碼上就是我們通過定義一個代理類,來表達這些不同的交通工具,這個代理類應該可以代表不同的交通工具,同時它需要幫助我管理記憶體,而且需要能夠例項化,因為這樣我就不用再糾結上面那個 Vehicle 是抽象類沒辦法定義容器的問題,所以,這個代理類的作用是讓我能夠定義代理類的容器,同時不需要我來考慮記憶體的管理問題,而且要支援編譯時型別未知的情況。

代理類只是一個管理交通工具的管理者,它不是一個具體的東西,就跟大明星的經紀人一樣。那看來它必須儲存一個明星,也就是得有一個指向交通工具的指標,同時它需要上臺面,那麼它需要真實的建構函式,同時它需要能夠放進容器,所以它需要一個預設建構函式:

class VechicleProxy {
public:
    VechicleProxy();
    VechicleProxy(const Vehicle &);
    ~VechicleProxy();
    VechicleProxy(const VechicleProxy &);
    VechicleProxy &operator=(const VechicleProxy &);
private:
    Vehicle *p;
};

上面多加了幾個建構函式和賦值操作符,也不難理解,畢竟是一個真實的類嘛。其中以 const Vehicle& 為引數的複製建構函式就提供了為任意交通工具做代理的能力。一切看起來OK,但是在預設建構函式裡我們能夠為 p 指標賦值什麼呢?好像只能賦為0了。這個零指標也就是說通常說的空代理。那麼讓我們來完成這個代理類的成員函式吧:

VechicleProxy::VechicleProxy(): p(0) { }
VechicleProxy::VechicleProxy(const Vehicle &BigStar): p(BigStar.copy()) {}
VechicleProxy::~VechicleProxy() { delete p; }
VechicleProxy::VechicleProxy(const VechicleProxy &v): p(v.p ? v.p->copy() : 0) {}
VechicleProxy::operator=(const VechicleProxy &v)
{
    if (this != &v)
    {
        delete p;
        p = (v.p ? v.p->copy() : 0);
    }
    return *this;
}

這裡沒有什麼多餘的祕密了,仔細點都OK。寫到這裡我們終於可以定義一個完美的 parking_lot 了。

VehicleProxy parking_lot[1000];
Helicopter x;
parking_lot[num_vehicles++] = x;


總結一下:

當我們使用繼承和容器的時候,通常需要處理兩個問題:記憶體的分配編譯時型別未知物件的繫結使用一個被成為代理類的東西,我們把複雜的繼承層次壓縮到了一起,讓這個類能夠代表所有的子型別,用類來表示概念的武器果然犀利。