首页 > 学院 > 开发设计 > 正文

用C++实现可重用的数学例程

2019-11-17 05:07:37
字体:
来源:转载
供稿:网友
通常情况下,需要调用由用户提供的函数的算法是难以实现重用的。而实现重用的要害就在于寻找一种封装用户定义代码的有效途径。

  引言

  “代码重用”是软件工程追求的神圣目标之一。采用面向对象(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算法一类的通用数学例程调用时,只要简单地在类中重载()操作符即可。因此,用一个可重用的本地库来实现一整套数学算法是可行的。

发表评论 共有条评论
用户名: 密码:
验证码: 匿名发表