1. 程式人生 > >一個面向物件程式範例-摘自《C++沉思錄》Andrew Koenig

一個面向物件程式範例-摘自《C++沉思錄》Andrew Koenig

       通常認為,面向物件程式設計有3個要素:資料抽象、繼承以及動態繫結。這裡有一個程式,雖然很小,但是非常完整地展現了這3個要素。

       這些技術在大程式中比較有意義,特別是在規模大且不斷修改的程式中更是如此。可惜這裡沒有足夠的篇幅來講解大程式,所以只給出了一個“玩具”程式。這個程式除了規模小一點之外,覆蓋面是足夠全面的,認真研究將會有所收穫。

1.   問題描述

       此程式涉及到內容是用來表示算術表示式的樹。例如,表示式 (-5) * (3 + 4) 對應的樹為:

       一個表示式樹包括代表常數、一元運算子和二元運算子的節點。這樣的樹結構在編譯器和計算器程式中都可能用到。

       我們希望能通過呼叫合適的函式來建立這樣的樹,然後列印該樹的完整括號化形式。例如,我們希望

#include <iostream>
int main(){
	Expr t = Expr("*", Expr("-", 5), Expr("+", 3, 4));
	cout << t << endl;
	t = Expr("*", t, t);
	cout << t << endl;
}

列印

       ((-5) * (3 + 4))

       ((-5) * (3 + 4)) * ((-5) * (3 + 4))

作為輸出。此外,我們不想為這些表示式的表示形式操心,更不想關心有關它們記憶體分配和回收的事宜。

       這個玩具程式所做的事情在很多需要處理複雜輸入的大型程式中是很典型的,例如編譯器、編輯器、CAD/CAM 系統等等。此類程式中通常要花費很大的精力來處理類似樹、圖和類似的資料結構。這些程式的開發者永遠需要面對諸如記憶體分配、靈活性和效率之類的問題。面向物件技術可以把這些問題區域性化,從而確保今後發生的一系列變化不會要求整個程式中的其他各個部分隨之做相應調整。

2.   面向物件的解決方案

       第1節的圖中有兩個截然不同的物件:節點(用圓圈表示)和邊(用箭頭表示)。我們首先只考慮節點,看看能夠理解到何種程度。

       每個節點包含一個值-- 一個運算元或者一個操作符-- 並且每個節點又具有零個、一個或兩個子節點。我們可以用一個聯合來容納具體值,用一個 List 來表示子節點,以包含聯合(union)和 List 的類來表示節點。不過這種表示方法需要設定一個專門的欄位來指示這個節點的型別。當需要用到一個型別欄位的時候,請停下來,考慮如果定義一系列類,用繼承組織起來,是否可以更有效地解決問題。

       我們這裡就按照這條思路考慮下去。這些類有一些共同點:每個類都要儲存一個值以及一些子節點。當然也有不少不同點,比如它們儲存的值的種類,子節點的數目。繼承使得我們可以捕捉這些共同點,而動態繫結幫助各個節點知曉它們的身份,這樣就不必讓這些物件的每個操作都必須時刻留心這個問題了。

       如果我們進一步考查這個樹結構,會發現這裡有 3 種節點。一種表示整數表示式,包含一個整數值,無子節點。另外兩個分別表示一元表示式和二元表示式,包含一個操作符,分別有一個或兩個子節點。我們希望列印各種節點,但是具體方式需要視要列印節點的型別而定。這就是動態繫結的用武之地了:我們可以定義一個 virtual 函式來指明應當如何列印各種節點。動態繫結將會負責在執行時基於列印節點的實際型別呼叫正確的函式。

       那麼這些節點之間的繼承關係如何?每一種節點似乎都與其他節點相互獨立。也就是說,一個沒有子節點的節點不是“一種”有一個子節點的節點,反之也一樣。顯然,我們需要另一個類來表示“節點”這個概念,但是這個類並不是表示某個具體的節點。我們的所有實際型別都將從這個公共基類中派生而來。

       我們將這個公共基類命名為 Expr_node。這個類相當簡單:

class Expr_node{
	friend ostream & operator<< (ostream &, const Expr_node &);

protected:
	virtual void print(ostream &) const = 0;
	virtual ~Expr_node() {}
};

       我們已經知道所要建立的物件的型別都是派生自 Expr_node,所以提供了虛解構函式。這可以保證在刪除由一個 Expr_node* 指標指向的物件時能夠呼叫到正確的派生類解構函式。

       我們知道需要用動態繫結來處理 print 操作,但是我們的例子程式用了 << 輸出操作符來打印表達式樹。動態繫結只用於成員函式,所以我們定義了一個虛擬函式 print,輸出操作符可以呼叫它來完成實際的工作。既然我們希望使用者使用輸出操作符,而不是 print 函式,那麼就把 print 函式設為 protected 的,把 operator<< 設為友元。

       從函式 print 的宣告可以看出,它是一個純虛擬函式,這就使得 Expr_node 成為抽象基類。這就體現了我們的意圖:不存在所謂的 Expr_node 物件,只有從其中派生出來的類有實際物件。Expr_node 類的存在只是為了獲得公共介面。

       最後,我們定義輸出操作符,它要呼叫合適的 print 函式:

ostream & operator<< (ostream & o, const Expr_node & e)
{
	e.print(o);
	return o;
}

       現在我們可以使用繼承來宣告我們的具體型別了。關於這些型別第一個要注意的是,此型別的物件必須能夠應使用者的要求而生成表示式樹。雖然我們還沒有考慮過使用者會如何建立這些樹結構,但是隻要稍微思索一下,就可以知道 Expr 類應該起著關鍵作用。所以,我們應當把 Expr 宣告為各具體型別的友元。

       這些具體型別中最簡單的一類是包含一個整數,沒有子節點的節點:

class Int_node: public Expr_node{
	friend class Expr;

	int n;

	Int_node(int k): n(k) {}
	void print(ostream & o) const { o << n; }
};

       其他型別又如何呢?每個類中都必須儲存一個操作符(這倒簡單),但是如何儲存子節點呢?在執行時之前,我們並不知道子節點的型別會是什麼,所以我們不能按值儲存子節點,必須儲存指標。假設我們有一個通用的 string 類來代表操作符,這樣一來我們的 一元和二元節點類如下所示:

class Unary_node: public Expr_node{
	friend class Expr;
	string op;
	Expr_node* opnd;
	Unary_node(const string & a, Expr_node* b): op(a), opnd(b) {}
	void print(ostream & o) const { o << "(" << op << *opnd << ")"; }
};
class Binary_node: public Expr_node{
	friend class Expr;
	string op;
	Expr_node* left;
	Expr_node* right;
	Binary_node(const string & a, Expr *b, Expr *c): op(a), left(b), right(c) {}
	void print(ostream & o) const { o << "(" << *left << op << *right << ")"; }
	int eval() const;
};

       這個設計方案可以用,不過有一個問題。使用者要處理的不是值,而是指標,所以必須記住分配和釋放物件。例如,我們需要這麼建立表示式樹:

       Expr t = Expr ("*", Expr ("-", 5), Expr ("+", 3, 4));

       但這不會湊效-- 建立一元和二元表示式的建構函式期望獲得指標,而不是物件。於是我們可以動態分配節點:

Binary_node* t = new Binary_node("*",
						new Unary_node("-", new Int_node(5)),
						new Binary_node("+", new Int_node(3), new Int_node(4)));

       當然,我們必須記住要刪除這些節點。可是這是不可能的!我們不再擁有指向內層 new 呼叫所構造的物件的指標來!我們希望 Binary_node 和 Unary_node 的解構函式刪除它們的運算元,但是這裡同樣不行。如果解構函式刪除了其運算元,可能會多次刪除物件,因為可能不止一個 Expr_node 指向同一個下層的表示式物件。

3.   控制代碼類

       事情越說越混淆了。我們不僅把記憶體管理這類煩心事推給了使用者,而且對使用者來說也沒有什麼方便辦法來處理這些事情。我們得好好想想了。

       首先回過頭來看看問題,如果我們再次回顧一下第一節的圖,就可以發現,在 Expr_node 類族中,僅僅只表示了圖中的圓圈,而沒有對箭頭建模。我們之所以陷入困境,正是因為這裡把箭頭描述成為簡單的指標。我們還強迫這些類的使用者必須親自操作指標,這把事情搞得更加複雜。這些問題與我們在第 5 章《代理類》和第 6 章《控制代碼類》解決過的問題相似,在那裡我們用一個控制代碼類來管理指標。在這個問題裡,這樣的控制代碼類對我們的抽象建模似乎更加精確。

       理解了這些麻煩,我們就認識到,類 Expr 應當是一種控制代碼類,表示一個邊,或者說該樹結構根源於一個邊。既然使用者關心的其實只是樹(子樹),而不是樹中的單個節點,就可以用 Expr 來隱藏 Expr_node 繼承層次。既然使用者所要生成的是 Expr 而不是 Expr_node,我們就希望 Expr 的建構函式能代表所有 3 種 Expr_node。每個 Expr 建構函式都將建立 Expr_node 的派生類的一個合適物件,並且將這個物件的地址儲存在正在建立中的 Expr 物件中。Expr 類的使用者不會直接看到 Expr_node 物件。

       這樣一來,我們有:

class Expr{
	friend ostream & operator<< (ostream &, const Expr &);

	Expr_node* p;

public:
	Expr(int);										// 建立一個 Int_node
	Expr(const string &, Expr);						// 建立一個 Unary_node
	Expr(const string &, Expr, Expr);				// 建立一個 Binary_node
	Expr(const Expr &);		
	Expr & operator= (const Expr &);
	~Expr() { delete p; }
};

       此建構函式建立適當的 Expr_node,並且將其地址儲存在 p 中:

Expr::Expr(int n)
{
	p = new Int_node(n);
}
Expr::Expr(const string & op, Expr t)
{
	p = new Unary_node(op, t);
}
Expr::Expr(const string & op, Expr left, Expr right)
{
	p = new Binary_node(op, left, right);
}

       解構函式負責釋放在建構函式中分配的節點。這樣我們就會發現,再也沒有記憶體管理方面的麻煩了。

       由於 Expr 建構函式為 Expr_node 分配了記憶體,我們需要實現複製建構函式和賦值操作符管理下層的 Expr_node。如果 Expr 的解構函式銷燬了 p 所指向的物件,那麼在複製或賦值一個 Expr 時就需要生成該物件的一個副本。正如我們在第 5 章中所見到的,可以向 Expr_node 派生類層次中加入一個虛擬函式 copy,在 Expr 物件中可以使用它。

       不過在寫程式碼之前,首先應該考慮我們是否真的需要複製操作。在這裡,Expr 的操作並不改變下層的 Expr_node。如果能避免複製下層的 Expr_node,則可能會更有效率。

       避免複製的常用方法是讓每一個 Expr_node 包含一個引用計數,指明同時有多少 Expr 指向同一個 Expr_node。Expr 類和 Expr_node 類將協同管理引用計數,當且僅當一個 Expr_node 引用計數等於 0 時,該節點才被刪除。

       我們需要在 Expr_node 類中加入引用計數,當一個新的 Expr_node 派生類物件生成時,將引用計數初始化為 1。Expr 類將幫助管理引用計數,所以將其宣告為友元:

class Expr_node{
	friend class Expr;
	friend ostream & operator<< (ostream &, const Expr &);

	int use;
protected:
	Expr_node(): use(1) {}
	virtual void print(ostream &) const = 0;
	virtual ~Expr_node() {}
};

       當 Expr 類“複製”一個 Expr_node 時,該 Expr 將其引用計數增 1,當引用者為 0 時刪除底層的 Expr_node:

class Expr{
	// 和前面的一樣
public:
	Expr(const Expr & t) { p = t.p; ++p->use; }
	Expr & operator= (const Expr & t);
};

       複製建構函式遞增引用計數,另 p 指向其複製目標所指向的同一個 Expr_node。解構函式遞減引用計數,如果此引用是對該 Expr_node 的最後一個引用,則銷燬該 Expr_node。賦值操作符必須分別遞增右邊和左邊物件的引用計數。如果能夠首先處理右邊物件的引用計數,就可以保證在自我賦值的情況下仍然工作正常:

Expr & Expr::operator =(const Expr & rhs)
{
	rhs.p->use++;
	if(--p->use == 0)
		delete p;
	p = rhs.p;
	return *this;
}

       我們還得定義輸出操作符,現在它是針對 Expr 而不是針對 Expr_node 操作的。下面的程式碼體現了額外的中間層:

ostream & operator<< (ostream & o, const Expr & t)
{
	t.p->print(o);
	return o;
}

       最後,需要更改每個派生自 Expr_node 的類,另其操作為私有,將 Expr 類宣告為友元,儲存 Expr 而不是儲存指向 Expr_node 的指標。例如:

class Binary_node: public Expr_node{
	friend class Expr;

	string op;
	Expr left;
	Expr right;

	Binary_node(const string & a, Expr b, Expr c): op(a), left(b), right(c) {}
	void print(ostream & o) const { o << "(" << left << op << right << ")"; }
};

       有了這些,我們最早的那個 main 程式可以工作了,使用者也可以自由地宣告 Expr 型別的物件和臨時物件。並且,使用者可以構造任意複雜的表示式,並且列印它們,而無需考慮記憶體管理的問題。


4.   擴充套件1:新操作

       目前我們的系統能力還十分有限-- 只能建立和打印表達式。一旦這個目標實現,我們的客戶可能會要求計算表示式的值。把原來的程式稍微改改:

int main(){
	Expr t = Expr("*", Expr("-", 5), Expr("+", 3, 4));
	cout << t << " = " << t.eval() << endl;
	t = Expr("*", t, t);
	cout << t << " = " << t.eval() << endl;
}

       執行此程式應該得到:

       ((-5) * (3 + 4)) = -35

       ((-5) * (3 + 4)) * ((-5) * (3 + 4)) = 1225

       對於這個問題的簡單表述給了我們有關解決方案的思路:計算一個 Expr 的方式與列印它相同。使用者將會對某些 Expr 呼叫 eval;eval 可以將實際的工作委託給組成 Expr 的節點。

       所以我們的 Expr 類如下:

class Expr{
	friend ostream & operator<< (ostream &, const Expr &);
	Expr_node* p;

public:
	Expr(int);
	Expr(const string &, Expr);
	Expr(const string &, Expr, Expr);
	Expr(const string &, Expr, Expr, Expr);
	Expr(const Expr & t) { p = t.p; ++p->use; }
	~Expr() {}
	Expr & operator= (const Expr &);
	int eval() const { return p->eval(); }	// 新增的
};

       其中 eval 所需要做的,就是把對錶達式的求值請求傳遞給其指向的 Expr_node。

       這樣,Expr_node類就得添上另一個純虛擬函式:

class Expr_node{
protected:

	virtual int eval() const = 0;
	// 和前面的一樣
};

       還必須向 Expr_node 的每一個派生類新增一個函式來實現求值運算。 Int_node 的求值是最簡單的,只要直接返回其值即可:

class Int_node: public Expr_node{
	friend class Expr;

	int n;

	Int_node(int k): n(k) {}
	void print(ostream & o) const { o << n; }
	int eval() const { return n; }				// 新增的
};

       原則上,Unary_node 的求值運算也很容易:我們先確定運算元,接著進行運算。

       但是此時此刻,我們構建 Expr_node 時並沒有將操作符儲存在其中。為了計算表示式,我們只限於使用那些知道應當如何計算的操作符。我們只需檢查所儲存的操作符是否是幾個操作符之一,如果不是就丟擲一個異常。對於 Unary_node,我們允許負號:

class Unary_node: public Expr_node{
	friend class Expr;

	string op;
	Expr opnd;
	Unary_node(const string & a, Expr b): op(a), opnd(b) {}
	void print(ostream & o) const { o << "(" << op << opnd << ")"; }
	int eval() const;			// 新增的
};

int Unary_node::eval() const
{
	if(op == "-")
		return -opnd.eval();
	throw "error, bad op " + op + " int UnaryNode";
}

       現在我們可以很好地對算術表示式求值了。

       再反思一下我們必須做什麼,還有,也許更重要的是,不必做什麼。增加一個新的操作無須觸及已有的操作的程式碼。因為我們的類抽象對算術表示式進行了精確建模,所以擴充套件該程式以計算表示式,所需要增加的程式碼是非常至少。跟打印表達式的情況相似,動態繫結機制使得計算表示式的過程簡化到只需指出計算各個節點的方法,然後執行時系統就可以呼叫正確的 eval 函式。

5.   擴充套件2:增加新的節點型別

       我們已經看到,資料抽象和動態繫結使得在系統中增加新操作變得非常容易。現在再看看這兩項機制如何使得我們可以增加新的節點種類,而完全不必改變使用節點的程式碼。

       假設我們希望新增一種 Ternary_node 型別來表示三元操作符,如 ?:(也就是 if-then-else 操作符)。首先,我們宣告 Ternary_node,並且定義其操作為:

class Ternary_node: public Expr_node{
	friend class Expr;

	string op;
	Expr left;
	Expr middle;
	Expr right;

	Ternary_node(const string & a, Expr b, Expr c, Expr d): op(a), left(b), middle(c), right(d) {}
	void print(ostream & o) const;
	int eval() const;
};

void Ternary_node::print(ostream &o) const
{
	o << "(" << left << " ? " << middle << " : " << right << ")";
}

int Ternary_node::eval() const
{
	if(left.eval())
		return middle.eval();
	else
		return right.eval();
}

       這些宣告與 Binary_node 的宣告相似,實際上,就是首先拷貝一份 Binary_node 的程式碼,然後修改成這個樣子的。下面,我們要為 Ternary_node 定義一個 Expr 建構函式:

class Expr{
	friend class Expr_node;
	friend ostream & operator<< (ostream &, const Expr &);
	Expr_node* p;

public:
	Expr(int);
	Expr(const string &, Expr);
	Expr(const string &, Expr, Expr);
	Expr(const string &, Expr, Expr, Expr);			// 新增的
	Expr(const Expr & t) { p = t.p; ++p->use; }
	~Expr() {}
	Expr & operator= (const Expr &);
	int eval() const { return p->eval(); }
};

Expr::Expr(const string & op, Expr left, Expr middle, Expr right)
{
	p = new Ternary_node(op, left, middle, right);
}

       搞定了!

       有一位曾經做過 C 編譯器的先生看到這個例子之後,感慨地說:“當時我們往 C 語言編譯器裡新增 ?:操作符的時候都快絕望了,前前後後花費了幾個星期甚至幾個月-- 而你居然只用區區 18 行程式碼就做到了!”雖然編譯器比這個例子要龐大得多,但他的這番評論卻十分中肯。這種擴充套件只需增加新的類和函式,已經寫好的執行程式碼一行也不用動,只需要在已經存在的類定義新增一個宣告。然後,我們愜意地發現,這個修改後的程式碼首次編譯時即正確執行,這種情況在 C++ 及其強型別檢查機制下是經常見到的。

6.   反思

       我們已經看到了面向物件程式設計是如何簡化程式的設計和更新過程的。解決方案的實質是要對希望模擬的下層系統中的物件進行建模。當我們分析出表示式樹是由節點和邊所構成,便可以設計資料抽象來對樹進行建模。繼承讓我們抓住了各種節點型別之間的相似之處,而動態繫結幫助我們為各種型別節點定義操作,讓編譯器來負責安排在執行時能夠呼叫正確的函式。這樣,資料抽象加上動態繫結可讓我們集中精力考慮每個型別的行為和實現,而不必關心與其他物件的互動。

       我們已經看到,面向物件設計使得新增新的操作和新的型別都輕而易舉。實際程式設計中我們還希望能夠新增更多的東西,包括:

       * 新增 Ternary_node 之後,我們還需要新增關係運算。這很容易:只需要修改 Bianry_node::eval 函式,使它支援關係操作符即可。

       * 我們也希望新增賦值表示式。這就有點技巧來。賦值要求我們向表示式樹中新增一種變數型別。我們可以新增一個新類來表示一個名字及其值的單元,從而避免全功能的符號表。這樣一來,就需要定義兩個新的表示式型別:一個表示變數,一個向變數賦值。

       * 表示式樹很有用,但是語句有時更強大。我們可以根據列印和執行語句的操作,設計一個平行的語句層次。

       關鍵在於,這個程式可以非常優雅地進化。我們可以從一個簡單的規範開始,設計一個解決方案,使用該設計,觀察該模型的執行情況。當我們需要修改時,可以很輕鬆地增加新的操作和新的型別,以擴充套件應用程式和測試我們改變的部分。我們所需要做的修改是孤立的。每一個修改所需要做的工作跟修改的說明是相稱的。

       想想如果沒有動態繫結,表示一個表示式樹會是多麼困難!