使用类封装数值算法的固有优缺点是什么?

计算科学 算法
2021-12-01 01:03:24

科学计算中使用的许多算法具有与数学密集程度较低的软件工程形式中通常考虑的算法不同的固有结构。特别是,个别数学算法往往非常复杂,通常涉及数百或数千行代码,但仍然不涉及任何状态(即不作用于复杂的数据结构)并且通常可以归结为 - 就编程而言接口——作用于一个(或两个)数组的单个函数。

这表明一个函数,而不是一个类,是科学计算中遇到的大多数算法的自然接口。然而,这个论点对于如何处理复杂的多部分算法的实现几乎没有提供任何见解。

传统的方法是简单地让一个函数调用许多其他函数,并在此过程中传递相关参数,而 OOP 提供了一种不同的方法,其中算法可以封装为类。为了清楚起见,通过将算法封装在一个类中,我的意思是创建一个类,其中将算法输入输入到类构造函数中,然后调用公共方法来实际调用算法。C++ 伪代码中多重网格的这种实现可能如下所示:

class multigrid {
    private:
        x_, b_
        [grid structure]

        restrict(...)
        interpolate(...)
        relax(...)
    public:
        multigrid(x,b) : x_(x), b_(b) { }
        run()
}

multigrid::run() {
     [call restrict, interpolate, relax, etc.]
}

那么我的问题如下:与更传统的没有课程的方法相比,这种做法的优点和缺点是什么?是否存在可扩展性或可维护性的问题?需要说明的是,我不是打算征求意见,而是为了更好地理解采用这种编码实践的下游影响(即,在代码库变得非常大之前可能不会出现的影响)。

4个回答

封装和数据隐藏对于科学计算中的可扩展极为重要。考虑矩阵和线性求解器作为两个例子。用户只需要知道算子是线性的,但它可能具有内部结构,例如稀疏性、内核、层次表示、张量积或 Schur 补码。在所有情况下,Krylov 方法都不依赖于操作符的细节,它们只依赖于MatMult函数的动作(也许还有它的伴随动作)。类似地,线性求解器接口(例如非线性求解器)的用户只关心线性问题是否得到求解,不需要或不想指定所使用的算法。事实上,指定这些东西会阻碍非线性求解器(或其他外部接口)的能力。

接口很好。取决于实现是不好的。无论您是使用 C++ 类、C 对象、Haskell 类型类还是其他语言特性来实现这一点,都是无关紧要的。接口的功能、健壮性和可扩展性是科学图书馆中最重要的。

从事数值软件工作 15 年,我可以毫不含糊地说:

  • 封装很重要。您不想传递指向数据的指针(如您所建议的那样),因为它公开了数据存储方案。如果您公开存储方案,您将永远无法再次更改它,因为您将访问整个程序中的数据。避免这种情况的唯一方法是将数据封装到类的私有成员变量中,并且只让成员函数对其进行操作。如果我阅读了您的问题,您会认为一个计算矩阵特征值的函数是无状态的,它将指向矩阵条目的指针作为参数并以某种方式返回特征值。我认为这是错误的思考方式。在我看来,这个函数应该是一个类的“const”成员函数——不是因为它改变了矩阵,而是因为它是一个对数据进行操作的函数。

  • 大多数 OO 编程语言允许您拥有私有成员函数。这是您将一个大型算法分解为一个较小算法的方法。例如,特征值计算所需的各种辅助函数仍然在矩阵上运行,因此自然是矩阵类的私有成员函数。

  • 与许多其他软件系统相比,类层次结构通常不如图形用户界面那么重要。在数值软件中肯定有一些地方很突出——杰德在另一个回答中概述了这个线程,即可以表示矩阵的多种方式(或者,更一般地说,有限维向量空间上的线性算子)。PETSc 非常一致地做到这一点,对所有作用于矩阵的操作都使用虚函数(他们不称其为“虚函数”,但事实就是如此)。在典型的有限元代码中还有其他领域使用 OO 软件的这种设计原则。浮现在脑海中的是多种求积公式和多种有限元,所有这些都自然地表示为一个接口/多个实现。物质定律描述也属于这一组。但是,这可能是真的,并且有限元代码的其余部分并没有像在 GUI 中那样普遍使用继承。

仅从这三点就应该清楚,面向对象编程也绝对适用于数字代码,忽略这种风格的许多好处是愚蠢的。BLAS/LAPACK 不使用这种范式可能是真的(MATLAB 公开的常用接口也没有),但我敢猜测,过去 10 年编写的每一个成功的数值软件实际上都是,面向对象。

仅当代码的结构是分层的时才应使用类。由于您提到算法,它们的自然结构是流程图,而不是对象的层次结构。

在 OpenFOAM 的情况下,算法部分是根据通用运算符(div、grad、curl 等)实现的,这些运算符基本上是在不同类型的张量上运行的抽象函数,使用不同类型的数值方案。这部分代码基本上是由许多在类上运行的通用算法构建的。这允许客户端编写如下内容:

solve(ddt(U) + div(phi, U)  == rho*g + ...);

传输模型、湍流模型、差分方案、梯度方案、边界条件等层次结构是根据 C++ 类实现的(同样,在张量上是通用的)。

我注意到 CGAL 库中有一个类似的结构,其中各种算法作为函数对象组打包在一起,这些函数对象与几何信息捆绑在一起以形成几何内核(类),但这又是为了将操作与几何分离(从面,来自点数据类型)。

层次结构 ==> 类

程序,流程图 ==> 算法

即使这是一个老问题,我认为值得一提的是Julia的特殊解决方案。这种语言所做的是“无类 OOP”:主要构造是类型,即类似于structC 中的 s 的复合数据对象,在其上定义了继承关系。这些类型没有“成员函数”,但每个函数都有一个类型签名并接受子类型。例如,您可以拥有一个抽象Matrix类型和子类型DenseMatrix, ,并拥有一个具有 specializationSparseMatrix的泛型方法多重调度用于选择最合适的版本来调用。do_something(a::Matrix, b::Matrix)do_something(a::SparseMatrix, b::SparseMatrix)

this这种方法比基于类的 OOP 更强大,如果您采用“方法是作为其第一个参数的函数”的约定(例如在 Python 中很常见),则这种方法相当于仅基于第一个参数的继承进行调度。某种形式的多重分派可以在例如 C++ 中模拟,但有相当大的扭曲

主要区别在于方法不属于类,但它们作为单独的实体存在,并且继承可以发生在所有参数上。

一些参考资料:

http://docs.julialang.org/en/release-0.4/manual/methods/

http://assoc.tumblr.com/post/71454527084/cool-things-you-can-do-in-julia

https://thenewphalls.wordpress.com/2014/03/06/understanding-object-oriented-programming-in-julia-inheritance-part-2/