通常情況下,需要調用由用戶提供的函數的算法是難以實現重用的。而實現重用的要害就在於尋找一種封裝用戶定義代碼的有效途徑。
引言
<!-- frame contents -->
<!-- /frame contents -->
“代碼重用”是軟件工程追求的神聖目標之一。采用面向對象(object-oriented, OO)的程序設計方法的一個主要方面也就是為了代碼重用,這可以從任何介紹OO程序設計的書籍看得出來。然而實際應用中,使用C++一類的OO語言來實現代碼重用比我們想象的要難得多。事實上,正如一位作者所說,由於C++程序員普遍傾向於創建自己的容器類,“C++對科學計算軟件的可重用性造成了很大的阻礙”。
在本文中,我展示了怎樣用C++語言創建可重用的數學例程。相對於OO來說,我使用的方法更依靠於通用編程(Generic Programming)。為了討論的方便,我使用了一個廣泛應用的估計算法——Newton-Raphson算法來作為例子。Newton-Raphson算法必須調用一個用戶定義的函數。本文首先給出了用戶定義函數的典型(並不讓人滿足)封裝方法,然後提出了一種建立在模板和操作符重載基礎上的更加令人滿足的封裝方式。
Newton-Raphson算法
在科學計算和財經工程領域,許多數值算法都是通用的(至少在理論上是),可廣泛地用於解決一類問題。一個大家熟悉的例子就是Newton-Raphson例程,它可用來尋找方程f(x)=0的數值解。標准的數學表達式f(x)表示f是變量x的函數,其通常的表達形式為f(x,a,b,...)=0,f被定義為多於一個變量的函數。在這種情況下,Newton-Raphson算法試圖把x以外的變量固定並作為參數,而尋找關於變量x的數值解。
由於Newton-Raphson算法需要知道被求解函數的確切表達,其傳統實現方法是直接將代碼嵌入到客戶應用程序中。這就使得算法的實現代碼經過針對不同被求解函數的少量修改後在客戶程序中反復出現。
同許多其它數學例程一樣,Newton-Raphson算法的具體實現是應該與特定用戶無關的。並且,重復編碼在任何情況下都應該盡量避免。我們很自然地會想到把該類例程作為庫函數來實現,以使客戶程序可以直接調用它們。但是,這種實現方式必然會涉及到如何將用戶自定義函數(Newton-Raphson例程需要調用該函數)封裝成可以作為參數傳遞的形式。下面部分描述了一種通常的,也是存在很多問題的用戶定義函數封裝方法。
通常的實現途徑——函數指針
現在的任務就是把Newton-Raphson算法作為一個庫例程來實現,客戶程序可以直接調用該例程來對任何形如f(x,a,b,..)=0的方程求取關於x的數值解。問題的要害就是算法的實現必須使用(能夠調用)f(x,a,b,...)形式的通用函數,而該函數的具體定義由庫的用戶在以後提供,並且只能在運行時才提交給庫。對於C和C++程序員,一種自然的可能方式就是把函數指針作為參數傳遞給庫例程:
typedef double (*P2F)(double);
double NewtonRaphson(P2F func_of_x, double x_init,) {
...
//通過函數指針調用函數
double y = func_of_x( x_init );
...
}
該庫例程工作得很好,但這僅僅是對於恰好只有一個參數的函數來說的。在C++中,程序員可以對庫函數進行重載,為具有不同參數數目的用戶定義函數分別定義一個例程。但是這樣會使得庫代碼出現大量的重復,並且更為糟糕的是,你不知道到底需要定義多少個這樣的庫例程。
另一種想法就是利用可選參數,如下面語句所示:
typedef double (*P2F)(double, ...);
這似乎看來可以結束這個問題的討論了。但是幸運也不幸運的是,C++不答應如上面代碼所期望的那樣使用可選參數。由於指向函數的指針必須准確地知道函數參數的類型和個數,該typedef定義的函數指針就只能與有一個double類型參數並跟上C風格的varargs的函數匹配,而不能用於包含了更多指定類型參數的函數。
當然還有其它的傳遞多參數函數的途徑,比如說可使用函數外殼。但是這種方法對於作者來說,除了求助於全局變量以外,並不清楚該怎樣去做。
為使其簡化,就需要使用一組包含了一定參數的構造,這些構造定義了復雜的用戶函數,並為庫例程通過傳遞單個參數來調用這個函數提供了途徑。這就將是一個對象——一個純粹並簡單的對象。因此,我為通用函數f(x,a,b,...)定義了一個類,並將其命名為FuncObj。(為了簡化敘述,從現在開始,參數的個數被固定為3個。)
class FuncObj {
private:
double _a;
int _b;
public:
FuncObj(double a_in, int b_in);
// 用x, a, b的形式定義用戶定義函數
double theFunc(double x_init);
};
你可能試圖通過向先前定義的庫例程傳遞一個指向FuncObj對象的theFunc成員函數的指針來調用該例程。但是這種方法不能工作,至少因為兩點原因。首先,在成員函數的表示中包含有類的名稱,指向它的指針不能用於需要一個指向普通函數的指針的地方。其次,指向成員函數的指針必須通過一個該類的對象實例來存取。我將在下一個部分解決這兩個問題。(需要注重的是,把theFunc定義成static類型無法真正解決問題,因為這樣的話,theFunc就不能存取FuncObj的非靜態成員變量,而正是這些成員變量保存了運算所需的其它“常值”變量。)
使用指向成員函數的指針
正如上一部分所討論到的,必須對庫接口進行修改,以便通過指向成員函數的指針來訪問。並且庫接口應該定義成函數模板,使得它不局限於某一個特定的類。
template
double NewtonRaphson(T & func, double (T::*func_of_x)(double),double x_init, ) {
...
// 通過對象(引用)和指向成員函數的指針
// 調用成員函數
double y = (func.*func_of_x)( x_init );
...
}
這段代碼能夠正常工作,但是其語法顯得有些難於理解。
為指向成員函數的指針創建類型定義是使代碼簡化並更可讀的一種有效途徑,就象先前為指向簡單函數的指針創建類型定義那樣。換句話說,創建帶參數化類型的類型定義會使程序顯得更加易懂,如下所示:
template
typedef double (T::*P2MF)(double);
假如上述代碼符合C++語法的話,P2MF的類型就是指向類T的成員函數的指針,該函數需要一個double型的參數,並返回一個double類型的值。然而遺憾的是,C++不支持包含了模板的類型定義。
按照計算機科學的慣例,最終的解決辦法就是引入另一級重定向。在該例中,可以通過定義一個封裝模板來使得上述類型定義變得合法:
template strUCt P2MFHelper {
typedef double (T::*P2MF)(double);
};
上面的代碼中演示了一種希奇但是又很有趣的templete和typedef用法。現在,我可以重新定義庫函數如下:
template
double NewtonRaphson(T & func,
P2MFHelper::P2MF func_of_x,
double x_init, ) {
...
double y = (func.*func_of_x)( x_init );
...
}
注重func.*func_of_x兩邊的括號對代碼的正確編譯和運行是必需的,因為接下來的函數調用比操作符.*具有更高的優先級。同時Helper類和庫函數也可以合並到一個單一的模板類中,並且能夠達到同樣的目的。然而,把Newton-Raphson例程設計成一個類並不是一種好的方式。不用說,這將會導致語法變得稍微有一些復雜。
現在,該庫例程可以適用於具有不同參數數目的用戶定義函數,只要客戶程序員定義一個FuncObj風格的類來封裝每一種該類型的函數。但是,這種方法還具有兩個小的問題。首先,你無法再向Newton-Raphson歷程傳遞指向普通函數的指針。其次,同時傳遞一個對象和一個指向成員函數的指針將使程序變得不健壯和不經濟。下面我來討論如何克服這兩個缺點。
使用函數對象
函數對象是一個重載了函數調用操作符()的類。因此,就能夠使用函數對象重載了的操作符()來代替函數調用操作符。下面是我重新定義的FuncObj:
class FuncObj {
public:
// 操作符重載
double operator() (double x_init) {
// 操作符重載實現代碼
// 實現以x, a, b形式定義的用戶定義函數
}
};
現在,庫例程可以直接使用函數對象進行簡單的定義:
template
double NewtonRaphson(T & func,double x_init,) {
...
// 調用函數對象實例的成員函數或()運算符
double y = func( x_init );
...
}
從上面的代碼可以看出,只向NewtonRaphson庫例程傳遞了一個參數化了的變量。該類型可以用一個對象引用或者指向普通函數的指針來代替。並且,庫例程內部的“函數調用”變得更加簡單了。作為一個例子,下面看一看如何用該通用庫例程來求解一個無解析解的復雜方程x3 + 2ex + 7 = 0。函數對象定義如下:
class FuncObj {
private:
double _a;
double _b;
public:
FuncObj(double a_in, double b_in) :_a(a_in), _b(b_in) {};
// 重載操作符
double operator() (double x_in) {
return ( x_in*x_in*x_in + _a*eXP(x_in) + _b );
}
double solve(double x_in) {
// 調用通用庫例程
return NewtonRaphson(*this, x_in,other_arguments);
}
};
在主程序中,只需要簡單地調用庫例程:
void main() {
FuncObj fo(2.0, 7.0);
// 間接調用Newton-Raphson庫例程
double solution_1 = fo.solve(-4.0);
// 直接調用庫例程
double solution_2 = NewtonRaphson(fo, -4.0,other_arguments);
}
注重該版本的庫函數同時被用在函數對象的內部和外部。main函數演示了通過傳遞一個對象引用來直接調用該庫例程的方法。但是,通過向該庫函數傳遞一個合適的指向函數的指針來調用它也是可行的。
結論
通過充分利用C++兩個強大的機制,我完成了一種可重用的Newton-Raphson算法的庫實現。
該庫是通用的,可廣泛用於解決一些類似的問題,並且對客戶程序的要求只是為該庫函數要調用的用戶定義函數(對象)重載()操作符即可。
本文討論的方法可應用到許多常見的可用來解決一類數學問題的科學算法。比如說,對任意復雜函數的數值積分,就可以用相同的方法來處理。C++語言的兩個要害特性——命名函數模板和操作符重載,使得這種簡單、健壯的解決途徑變得可行。把函數定義成對象所造成的開銷其實是可以忽略不計的。事實上,現實應用中的許多復雜函數都已經被定義成了對象,當它們需要被Newton-Raphson算法一類的通用數學例程調用時,只要簡單地在類中重載()操作符即可。因此,用一個可重用的本地庫來實現一整套數學算法是可行的。