1. 程式人生 > >函式物件-摘自《C++沉思錄》Andrew Koenig

函式物件-摘自《C++沉思錄》Andrew Koenig

       函式物件提供了一種方法,將要呼叫的函式與準備傳遞給這個函式的隱式引數捆綁起來。這就允許我們使用相當簡單的語法來建立複雜的表示式

       函式物件表示例一種操作。通過組合函式物件,我們可以得到複雜的操作。之所以能進行這種組合,是因為函式物件可以把函式當作值來處理,從而帶來了很大的靈活性。

1.   一個例子

       標準庫中一個叫 find_if 的函式,它的引數是一對迭代器和一個判斷式(predicate)--一個生成布林值 truth 的函式。find_if 函式返回由這對迭代器限定的範圍內第一個使判斷式得到真值的迭代器的值。

       假設 v 為型別為 vector<int>的物件,可以這樣寫:

bool greater1000(int n) {
	return n > 1000;
}
find_if(v.begin(), v.end(), greater1000);

       首先,考慮用一個叫做 greater 的模板類的庫,我們可以這樣重寫 greater1000:

bool greater1000(int n) {
	greater<int> gt;
	return gt(n, 1000);
}

       接著,標準庫中有一個叫做 bind2nd 的函式配接器,假設已有函式物件 f,且 f 有兩個引數其中一個值 v,則 bind2nd 就建立一個新的函式物件 g,g(x) 具有和 f(x, v)相同的值。取名為 bind2nd 是因為這個配接器繫結值 v 到第二個引數 f 上。於是可以重寫 greater1000 為:

bool greater1000(int n) {
	greater<int> gt;
	return (bind2nd(gt, 1000)) (n);
}

       也可以寫成:

bool greater1000(int n) {
	return (bind2nd(greater<int>(), 1000)) (n);
}

       更新 find_if 為:

find_if(v.begin(), v.end(), bind2nd(greater<int>(), 1000));

       假設我們要使例子更實用一些,在 v 中查詢第一個大於 x 的值。那麼 find_if 可以寫成:

find_if(v.begin(), v.end(), bind2nd(greater<int>(), (x)));

       而 greater1000在這兒起不到什麼作用,原因在於值 1000 是在 greater 的定義中被建立的。如果試圖寫一個 greater1000 的更通用的版本,就會遇到麻煩:

bool greater_x(int n) {
	return n > x;
}

       x 的值在哪裡設定呢?我們不能將 x 作為第二個引數傳遞,因為 find_if要求它的判斷式只有一個引數。顯然,下一步我們必須讓 x 成為一個全域性變數。真煩!

       函式物件配接器所解決的問題之一,是把資訊從使用該函式物件(這裡是 find_if)的部分通過程式的另一部分(這個部分對要傳遞的資訊(此處資訊指 find_if 的函式體)一無所知)傳遞到第三部分中(這裡指與 bind2nd 有關的判斷表示式),在第三部分中,資訊將被取出來。

2.   函式指標

       在有些程式語言中,函式是“第一級值”(first-class value)。在這些語言中,可以將函式作為引數傳遞,並把它們當作值返回,還可把它們當作表示式的元件使用等。

       C++不屬於這類語言,但這一點並不是顯而易見的。因為對於C++程式而言,將函式作為引數傳遞,並把它們的地址儲存在資料結構中是很常見的操作。例如,假設我們想對某個陣列的所有元素都運用某個給定函式。如果知道這個函式有一個 int 引數,並且生成 void 型別,我們可以如下編寫程式碼:

void apply(void f(int), int* p, int n) {
	for (int i = 0; i < n; i++)
		f(p[i]);
}

       這是不是說明C++把函式也當作第一級值了呢?

       本例中的第一個隱蔽之處就是,f 雖然看上去像函式,其實根本就不是函式。相反,它是一個函式指標。和在 C 中一樣,C++不可能有函式型別的變數,所以任何宣告這種變數的企圖都將立即被轉換成指向函式的指標宣告。和在 C 中一樣,所有對函式指標的呼叫都等價於對這個指標所指向的函式的呼叫。所以,前面的例子就等價於

void apply(void (*fp)(int), int* p, int n) {
	for (int i = 0; i < n; i++)
		(*fp)(p[i]);
}

       那又怎麼樣?函式和函式指標之間有什麼重大差異嗎?這個差異和任何指標與其所指向的物件之間的差異是類似的:不可能通過操縱指標建立這樣的物件C++函式的總儲存空間在程式執行之前就固定了。一旦程式開始執行,就無法建立新函數了。為了理解為什麼說不能動態建立新函式是個問題,我們來思考一下如何寫一個C++函式,以便把兩個函式組合起來成為第三個函式。組合是我們所能想到的建立新函式的最簡單的方法之一。現在為了簡單起見,我們將假設每個函式都有一個整數引數並返回一個整數結果。然後,假設我們有一對函式 f 和 g:
       extern int f(int);

       extern int g(int);

       我們希望能夠使用下面的語句:

       int (*h)(int) = compose(f, g);

       具有一種特性,就是對於任何整數 n 而言,h(n)(你會回想起來,它等價於 (*h)(n))將會等於 f(g(n))  。

       C++沒有提供直接做這件事的方法。我們可以杜撰出如下的程式碼:

//這段程式碼無效
int (*compose(int f(int), int g(int)))(int x) {
	int result(int n) { return f(g(n)); }
	return result;
}

       這裡,compose 檢視用兩個函式 f 和 g 來定義一個函式,當應用於 x 時可以得到 f(g(x)) 的函式;但是由於兩個原因它不可能成功。第一個原因就是C++不支援巢狀函式,這就意味著 result 的定義是非法的。而且由於 result 需要在塊作用域之內訪問 f 和 g,所以沒有簡便的方法可以繞過這個限制。也就是說,我們不能簡單的使 result 全域性化:

//這段程式碼也不起作用
int result(int n) { return f(g(n)); }

int (*compose(int f(int), int g(int)))(int x) {
	return result;
}

       這個例子的問題出在 f 和 g 在 result 中沒有定義。
       第二個問題更難以捉摸,假設C++允許巢狀函式--畢竟有些C++實現把它當作一種擴充套件,那麼這樣做會成功嗎?

       可惜的是,答案是“實際上不會成功”。為了瞭解原因,我們稍微修改了一下 compose 函式:

//這段程式碼還是不起作用
int (*compose(int f(int), int g(int)))(int x) {
	int (*fp)(int) = f;
	int (*gp)(int) = g;

	int result(int n) { return fp(gp(n)); }
	return result;
}

       其中所做的改變是要將 f 和 g 的地址複製到兩個區域性變數 fp 和 gp 中去。現在,假設我們呼叫 compose,它返回一個指向 result 的指標。因為 fp 和 gp 是 compose 的區域性變數,所以一旦 compose 返回它們就消失了。如果我們現在呼叫 result,它將試圖使用這些區域性變數,但是這些變數已經被刪除了。結果很可能導致程式執行崩潰。

       通過檢查上面 compose 的最後一個版本,我們應該很容易明白這個程式失敗的原因。然而,第一個版本中也存在著相同的問題。唯一不同的是第一個版本中的 f 和 g 不是普通的區域性變數,而是形參。這個區別無關大局:當 compose 返回時它們也消失;也就是說但 result 試圖訪問它們時也會導致崩潰。

       那麼,顯然編寫 compose 函式除了需要常規的基於堆疊的實現外,還需要某種自動記憶體管理。實際上,把函式當作第一級值處理的語言通常也支援垃圾回收機制。儘管C++將垃圾收集作為語言的標準部分會給很多方面帶來好處,但是存在太多的困難使我們不能這樣定義C++。有沒有辦法來回避這種侷限,並能以一種更通用的方式將函式作為值處理呢?


3.   函式物件

       困難在於我們的 compose 函式想要建立新函式,而C++不允許直接這麼做。無論何時面對這樣一個問題時,我們都應該考慮用一個類物件來表示這種解決方案。如果 compose 不能返回一個函式,那麼它可能返回一個行為和函式類似的類物件

       這樣的物件叫做函式物件通常,函式物件是某種類型別的物件,該類型別包括一個 operator() 成員函式。有了這個成員函式就可以把類物件當作函式來使用。那麼,舉例來說,如果我們寫這樣一個類:

class F {
public:
	int operator() (int);
	// ...
}

則類 F 物件的行為將在某種程度上類似於那些採用整數引數並返回整數結果的函式。例如,在

int main() {
	F f;
	int n = f(42);
}

中,f(42)等價於 f.operator()(42)。也就是說,f(42)獲得物件 f,用引數 42 呼叫它的 operator() 成員函式。

       我們可以把這些技巧作為運用類的基礎來組合函式:

class Intcomp {
public:
	Intcomp(int (*f0)(int), int (*g0)(int)): fp(f0), gp(g0) {}

	int operator() (int n) const {
		return (*fp)((*gp)(n));
	}

private:
	int (*fp)(int);
	int (*gp)(int);
};

       這裡,建構函式準備記住函式指標 f0 和 g0,operator()(int) 完成這兩個函式的組合。首先,operator()傳遞它的 int 引數 n 給由 gp 指向的函式,然後把 gp 返回的結果傳給 fp。所以,如果已有和前面一樣的函式 f 和 g,我們就能使用 Intcomp 來將它們組合起來:

extern int f(int);
extern int g(int);

int main() {
	Intcomp fg(f, g);
	fg(42);					// 等價於f(g(42))
}

       這項技術起碼在原理上解決了組合問題,因為每個 Intcomp 都專門開闢出位置來存放被組合函式的標識。然而由於它只能組合函式,卻不能組合函式物件,所以仍然不是一個實用的解決方案。這樣一來,譬如說我們不能用 Intcomp 來聯合一個具有任何特性的 Intcomp。換句話說,儘管可以使用 Intcomp 類來聯合兩個函式,但不能用它來聯合多於兩個的函式。對此我們能做些什麼改進呢?


4.   函式物件模板

       我們似乎可以建立一個類,其物件不僅可以用來組合函式,而且還可以用來組合函式物件。在C++中定義一個這樣的類的常見做法是採用我們稱之為 Comp 的模板。模板類 Comp 將有一些模板引數,其中包括兩個將被組合在一起的事物的型別。為以後的運用著想,我們還將再給 Comp 增加兩個型別引數。當我們呼叫 Comp 物件時,我們會給它一個型別值,而它則會返回另一個型別的值。從而,我們就把這兩種型別作為 Comp 模板的兩個新增加的型別引數。通過這種方法,我們再也不會受到只能處理返回 int 的函式的限制了:

template<class F, class G, class X, class Y> class Comp {
public:
	Comp(F f0, G g0): f(f0), g(g0) {}
	Y operator() (X x) const {
		return f(g(x));
	}
private:
	F f;
	G g;
};

       這裡的指導思想是型別 Comp<F, G, X, Y>的物件能夠將型別為 F 的函式(或者函式物件)與另一個型別為 G 的函式(或者函式物件)組合起來,得到一個引數型別為 X,結果型別為 Y 的物件。除此之外,細節都與類 Intcomp 幾乎相同。可以用類 Comp 來聯合前面的整數函式 f 和 g:

int main() {
	Comp<int (*)(int), int (*)(int), int, int> fg(f, g);
	fg(42);						// 呼叫f(g(42))
}

       這樣做可以湊效,但是需要兩次規定函式型別 int(*)(int),實在不值得欣賞。實際上,如果我們想組合函式 f 和 g,則 fg 的完全型別必須作為第一個模板引數。這將使得型別變得令人望而生畏:

Comp<Comp<int (*)(int), int (*)(int), int, int>, int (*)(int), int, int> fgf(fg, f);

       有沒有將它簡化到實用程度的方法呢?


5.   隱藏中間型別

       讓我們想想要實現的目標。現在,我們已經有一種可以聯合兩個函式或者兩個函式物件的方法,但表示聯合的函式物件的型別太複雜。我們希望能夠寫這樣的語句:

       Composition fg(f, g);       // 過於樂觀

       當這隻能是奢望。原因在於,當我們稍後相求 fg(42) 的值時,編譯器不知道表示式應該採用哪些型別。無論 fg(42) 的型別是什麼,都必須隱含在 fg 的型別中,而且要和  fg 所接受的引數的型別相似。所以,我們最多隻能這樣寫:

       Composition<int, int> fg(f, g);

       這裡,第一個int是函式物件 fg 接受的引數的型別,第二個 int 是它被呼叫時返回結果的型別。有了這個定義,起碼就不難寫出部分該類的部分定義了:

template<class X, class Y> class Composition {
public:
	// ...
	Y operator() (X) const;
	// ...
};

       但是我們怎樣才能實現這個類呢?這種情況下,建構函式應該怎樣編寫呢?

       建構函式提出了一個有趣的問題,因為建構函式 Composition 必須能夠接受函式和函式物件的任何組合--尤其是 Composition。這就意味著建構函式必須是一個模板,以適應所有這些可能情況

template<class X, class Y> class Composition {
public:
	template<class F, class G> Composition(F, G);
	Y operator() (X) const;
	// ...
};

       但是這樣做迴避了問題。型別 F 和 G 不屬於類 Composition 的型別,因為它們在這裡不是模板引數。然而,類 Composition 也許可以通過儲存 Comp<F, G, X, Y>的物件來發揮作用。如果不把 F 或者 G 作為類 Comp 本身的模板引數,就很難做到這一點。所幸的是,C++提供了一種叫做繼承的機制。

6.   一種型別包羅永珍

       假設我們重寫 Comp,使 Comp<F, G, X, Y>繼承自某個不依賴於 F 或者 G 的其他類。我們稱這個類為 Comp_basc<X, Y>。那麼,在類 Composition 中,我們就可以儲存一個指向 Comp_base<X, Y> 的指標。

       從 Comp_base 開始著手,可能是由內向外解開這個死結的好方法。從概念上講,Comp_base<X, Y> 物件可能表示的是一個接受 X 引數並返回 Y 結果的任意函式物件。因此,我們將給它提供一個虛擬函式 operator(),因為所有繼承自 Comp_base<X, Y> 的類中的 operator() 都接受一個相同型別(叫做X)的引數以及返回一個相同型別(叫做Y)的結果。由於我們不希望特地為一個普通的 Comp_base 定義 operator(),所以將它建立成純虛擬函式。另外,由於涉及到繼承,所以 Comp_base 還需要一個虛解構函式。

       作一下預測的話,我們會清楚目標是要能夠複製 Composition 物件。複製 Composition 物件涉及到要在不知道確切型別的情況下複製某種 Comp 型別的物件。所以,我們需要在 Composition 中有一個純虛擬函式來複制派生類物件。

       具備了所有的這些前提,就可以得到下面這個基類:

template<class X, class Y> class Comp_base {
public:
	virtual Y operator()(X) const = 0;
	virtual Comp_base* clone() const = 0;
	virtual ~Comp_base() {}
};

       現在,我們用 Comp_base 作為基類來重寫類 Comp,並且為 Comp 增加一個適當的 clone 函式,該函式將覆蓋 Comp_base 中的純虛擬函式:

template<class F, class G, class X, class Y> class Comp: public Comp_base<X, Y> {
public:
	Comp(F f0, G g0): f(f0), g(g0) {}

	Y operator() (X x) const { return f(g(x)); }
	Comp_base<X, Y>* clone() const {
		return new Comp(*this);
	}

private:
	F f;
	G g;
};

       這樣,我們可以令 Composition 類包含一個指向 Comp_base 的指標:

template<class X, class Y> class Composition {
public:
	template<class F, class G> Composition(F, G);
	Y operator() (X) const;

private:
	Comp_base<X, Y>* p;
	// ...
};

       無論類何時獲得一個指標型別的成員,我們都應該考慮複製該類物件的時候如何處理這個指標。這種情況下,我們希望複製底層物件,按照第5章討論的那樣將類 Composition 作為一個代理--從根本上說,這就是我們預先在類 Comp_base 中新增 clone 函式的原因。因此,我們必須在類 Composition 中寫個顯式的複製建構函式和解構函式:

template<class X, class Y> class Composition {
public:
	template<class F, class G> Composition(F, G);
	Composition(const Composition &);
	Composition & operator=(const Composition &);
	~Composition();
	Y operator() (X) const;
private:
	Comp_base<X, Y>* p;
};

7.   實現

       至此,實現類 Composition 應該是相當簡單的事情了。在用一對型別為 F 和 G 的物件構造 Composition<X, Y>時,我們將建立一個 Comp<F, G, X, Y>物件,並把它的地址儲存到指標 p 中。下面看上去有點奇怪的語法就是定義一個模板類的模板成員的方法。

       針對 Composition<X, Y>的每個變數,它都定義了建構函式 Composition<F, G>,所以建構函式的全稱就是:

Composition<X, Y>::Composition<F, G>(F, G)

       我們這樣定義這個建構函式:

template<class X, class Y> template<class F, class G> Composition<X, Y>::Composition(F f, G g):
	p(new Comp<F, G, X, Y> (f, g)) {}

      這個建構函式用一個指向 Comp<F, G, X, Y>的指標初始化了型別為 Comp_base<X, Y>的成員 p。由於類 Comp<F, G, X, Y>繼承自類 Comp_base<X, Y>,所以這個初始化是有效的。

       解構函式只刪除 p 所指向的物件:

template<class X, class Y> Composition<X, Y>::~Composition() {
	delete p;	
}


       複製建構函式和賦值操作符利用了類 Comp_base 中的純虛擬函式 clone:

template<class X, class Y> Composition::Composition(const Composition & c): p(c.p->clone()) {}
template<class X, class Y> Composition & operator=(const Composition & c) {
	if (this != &c) {
		delete p;
		p = c.p->clone();
	}
	return *this;
}

       最後,operator() 運用類 Comp_base 中的虛 operator():

template<class X, class Y> Y Composition::operator() (X x) const {
	return (*p) (x);		// p->operator() (X)
}

       到這一步了我們希望可以這樣寫:

extern int f(int);
extern int g(int);
extern int h(int);

int main() {
	Composition<int, int> fg(f, g);
	Composition<int, int> fgh(fg, h);
}

並且希望 fg 和 fgh 是同一型別,儘管它們所做的工作互不相同。


8.   討論

       研究這個例子的意義之一,讓大家體會到我們必須做大量的工作才能繞過一個看似簡單的語言侷限。另一點就是這個例子說明了擴充套件語言以使之允許函式組合,決不像看上去的那麼簡單。另外,如果我們已經一次性定義了這些函式物件,以後就可以直接使用它們了。一旦理解了這些概念,我們就能夠用清晰簡潔的形式來使用它們,而跟這個錯綜複雜的形式揮手道別。

       例如,有一個叫做 transform 的標準庫函式,它對序列中每個元素都運用函式或者函式物件,從而獲得一個新的序列。如果 a 是一個有 100 個元素的陣列,那麼

       transform(a, a+100, a, f);

將依次對 a 的每個元素運用 f,並將結果存回到 a 的對應元素中。

       假若我們希望用 transform 使陣列的每個元素都加上一個整數 n 呢?那麼,我們可以定義一個加整數的函式物件:

class Add_an_integer {
public:
	Add_an_integer(int n0): n(n0) {}
	operator() const (int x) { return x + n; }
private:
	int n;
}

然後應該呼叫

       transform(a, a + 100, a, Add_an_integer(n));

為了這個目的而定義一個獨立的函式物件是很麻煩的。

       實際上,我們可以做得更好。標準庫提供了一種叫做函式配接器的模板,我們可以在聯合的過程中使用它們來定義類似 Add_an_integer 的類,而不必去編寫類的定義。