代码规范
良好的代码行为
无论什么时候,每个程序员都可能会遇到代码行为不符合预期的情况。在本节中,我们将学习一些处理这个问题的技巧。首先,我们将看到一些预防错误的工具。在下一章中,我们将讨论调试,即在程序中出现不可避免的错误时,找出这些错误的过程。
防御式编程
在这一节中,我们将讨论一些情况以防止出现编程错误的可能性,或增加它们在运行时被发现的可能性,我们把这种技术称为防御式编程。
科学计算中代码通常是十分庞大且复杂的,我们要做好会犯错的准备。良好的编程习惯是使用一些工具:如果有人已经遇到过这种错误了,就没有必要再重蹈覆辙。这些工具中的一些将在其他章节中描述:
- 构建系统,如 Make、Scons、Bjam;见第22节。
- 用SVN、Git 进行源代码管理;见第23节。
- 回归测试和考虑到测试的设计(单元测试)
首先,我们将对运行时进行仔细检查,在这里我们要测试不可能或不应该发生的事情。
断言
在一个程序可能出错的地方,我们可以区分 error 和 bug。error 是指合法地发生但不应该发生的事情,文件系统是错误的常见来源:一个程序想打开一个文件,但由于用户输入错误的文件名,事实上文件并不存在。或者是程序写入一个文件中但磁盘已经满了,而其他错误可能来自算术,如溢出错误。
另一方面,程序中的 bug 是一种不能合法地发生的现象。当然,这里的 “合法”意味着 “根据程序员的意图”,bug通常可以被描述为”计算机总是按照我们的要求去做,而没有按照我们的想法去做“。
断言的作用是检测程序中的 bug :断言是一个判断在程序中的某一处是否正确的判断式。因此,断言的失败意味着我们没有按照我们的意图进行编程。一个断言通常是我们的编程语言中的一个声明,或者是一个预处理程序宏;当断言失败时,我们的程序将继续采取错误的行为。
断言的例子:
- 如果一个子程序有一个数组参数,那么在对数组进行索引之前,最好测试一下实际参数是否是一个空指针,然后再对数组进行索引。
- 同样地,我们可以测试一个动态分配的数据结构是否有空指针。
- 如果我们计算一个数字结果,而这个数字结果的某些数学属性是成立的,例如我们正在编写一个正弦函数,其结果一定是在$[-1,1]$之间,我们应该测试这个属性是否真的适用于该结果。
一旦程序得到充分的测试,断言往往被禁用,这样做的原因是断言执行起来可能很浪费。例如,如果我们有一个复杂的数据结构,我们可以写一个复杂的完整性测试,并在断言中执行该测试,在每次对数据结构的访问之后结构。
由于断言在”生产“版本中的代码中被禁用,所以对任何存储的数据不会产生影响。
C语言的断言宏
C标准库有一个 assert.h 头文件,它提供了一个 assert() 宏。插入 assert(foo) 有以下效果:如果 foo 是零(假),就会在标准错误上输出一条诊断信息。
Assertion failed: foo, file filename, line line-number
其中包括表达式的字面文本、文件名和行号;随后程序被中止。下面是一个例子:
#include<assert.h>
void open_record(char *record_name)
{
assert(record_name!=NULL);
/* Rest of code */
}
int main(void)
{
open_record(NULL);
}
断言宏可以通过定义 NDEBUG 宏来禁用。
Fortran 的断言宏
#if (defined( GFORTRAN ) || defined( G95 ) || defined ( PGI) )
# define MKSTR(x) "x"
#else
# define MKSTR(x) #x
#endif
#ifndef NDEBUG
# define ASSERT(x, msg) if (.not. (x) ) \ call assert( FILE , LINE ,MKSTR(x),msg)
#else
# define ASSERT(x, msg)
#endif
subroutine assert(file, ln, testStr, msgIn)
implicit none
character(*) :: file, testStr, msgIn
integer :: ln
print *, "Assert: ",trim(testStr)," Failed at ",trim(file),":",ln
print *, "Msg:", trim(msgIn)
stop
end subroutine assert
这被用作
ASSERT(nItemsSet.gt.arraySize,"Too many elements set")
错误代码的使用
在一些软件库中(例如 MPI 或 PETSc ),每个子程序都会返回一个结果,要么是函数值,要么是参数,以表明该程序的成功或失败。良好的编程实践是检查这些错误参数,即使我们认为不可能出错。
以总是有一个错误参数方式编写我们自己的子程序也是一个好主意,让我们考虑一个执行某些数值计算的函数的情况。
float compute(float val)
{
float result;
result = ... /* some computation */
return result;
}
float value,result;
result = compute(value)
看起来不错?如果计算可能失败怎么办:
result = ... sqrt(val) ... /* some computation */
我们如何处理用户传递一个负数的情况?
float compute(float val)
{
float result;
if (val<0) { /* then what? */
} else
result = ... sqrt(val) ... /* some computation */
return result;
}
我们可以输出一个错误信息,并提供一些结果,但这个信息可能没有被注意到,而调用的环境并没有真正收到任何关于出了问题的通知。
下面的方法更加灵活:
int compute(float val,float *result)
{
float result;
if (val<0) {
return -1;
} else {
*result = ... sqrt(val) ... /* some computation */
}
return 0;
}
float value,result; int ierr;
ierr = compute(value,&result);
if (ierr!=0) { /* take appropriate action */
}
我们可以通过编写以下内容来节省大量的输入工作:
#define CHECK_FOR_ERROR(ierr) \
if (ierr!=0) { \
printf("Error %d detected\n",ierr); \
return -1 ; }
....
ierr = compute(value,&result); CHECK_FOR_ERROR(ierr);
使用一些 cpp 宏,我们甚至可以定义
#define CHECK_FOR_ERROR(ierr) \
if (ierr!=0) { \
printf("Error %d detected in line %d of file %s\n",\
ierr,__LINE__,__FILE__); \
return -1 ; }
请注意,这个宏不仅打印出错误信息,而且还做了进一步的返回。这意味着,如果我们将会得到一个完整的调用树的回溯,如果一个错误发生时,我们将得到一个完整的调用树的回溯。(在 Python 语言中,这是错误的方法,因为回溯是内置的)。
防范内存错误
在科学计算中,我们不可避免地会处理大量的数据。一些编程语言使管理数据变得容易,而另一些,可以说使数据出错变得容易。
以下是一些内存冲突的例子
- 在数组边界外写入。如果地址在用户内存之外,我们的代码可能会以一个分配冲突的错误中止,而且这个错误是很容易发现的。如果该地址在一个数组的范围之外,它将破坏数据,但不会使程序崩溃。这样的错误可能很长时间都不会被发现,因为它可能没有任何影响,只是在我们的计算中引入了微妙的错误值。
- 读取数组边界以外的数据比写入时的错误更难发现,因为它往往不会中止我们的代码,而只是引入错误的值。
- 使用未初始化的内存与超出数组边界的读取类似,并且可以在很长时间内不被发现。这方面的一个变种是通过将内存附加到一个未分配的指针上,这种特殊的错误可以表现为有趣的行为。比方说,我们注意到我们的程序出现了问题,我们用调试模式重新编译找到了错误,现在这个错误不再发生了。这可能是由于在低优化水平下,所有分配的数组都被填满了零。因此,我们的代码原本是在读取一个随机值,但现在却得到的是一个零。
本节包含了一些防止在处理我们已经为数据预留的内存时出现错误的技术。
数组边界检查和其他内存的技术
在并行代码中,内存错误经常会通过 MPI 例程的崩溃而显示出来,这几乎不可能是一个 MPI 问题或我们的集群的问题。
Fortran 的编译器通常支持数组绑定检查。由于这使得我们的代码变慢,所以我们只能在代码的开发阶段启用它。
内存泄漏
如果一个程序分配了内存,但随后失去了对该内存的追踪,我们就说它有内存泄漏。操作系统认为该内存正在使用中,而实际上并没有,因此,计算机的内存会被分配的内存填满,而这些内存却没有任何用处。
在这个例子中,数据被分配在一个词法范围内:
for (i=.... ) {
real *block = malloc( /* large number of bytes */ )
/* do something with that block of memory */
/* and forget to call "free" on that block */
}
内存块在每个迭代中都被分配,但是一个迭代的分配在下一个迭代中不再可用,一个类似的例子可以用在条件中分配来做。
应该指出的是,这个问题在 Fortran 中要轻得多,因为在 Fortran 中,当一个变量超出范围时,内存会被自动分配。 有各种检测内存错误的工具,如 Valgrind , DMALLOC, Electric Fence。 关于Valgrind,见第27.3.2.1节。
使用自己的内存分配函数
许多编程错误来自不正确使用动态分配的内存:程序写到超出界限,或写入尚未分配的内存,或已经被释放的内存。虽然一些编译器可以在运行时进行边界检查,但这会降低程序的速度。一个更好的策略是编写我们自己的内存管理。一些如 PETSc 库 ,已经提供了一个增强的 malloc 。如果我们有这样的功能,我们当然应该利用它( gcc 编译器有一个 mcheck 函数,定义在 mcheck.h 中有类似的功能)。
如果我们用 C 语言写代码,我们可能会知道 malloc 和 free 调用:
int *ip;
ip = (int*) malloc(500*sizeof(int));
if (ip==0) {/* could not allocate memory */}
..... do stuff with ip .....
free(ip);
我们可以通过以下方式为自己节省一些写代码的时间:
#define MYMALLOC(a,b,c) \
a = (c*)malloc(b*sizeof(c)); \
if (a==0) {/* error message and appropriate action */}
int *ip;
MYMALLOC(ip,500,int);
运行时检查内存使用情况(通过编译器生成的边界检查,或者通过像 valgrind 或 Rational Purify 等工具)是很浪费的,但我们可以通过在我们的 malloc 中添加一些功能来发现许多问题,我们在这里要做的是在事后检测内存损坏。
我们在被分配对象的左边和右边分配几个整数(下面代码的第1行),并在其中放入一个可识别的值(第2行和第3行)以及对象的大小(第2行),然后我们返回指针到实际请求的内存区域(第4行)。
#define MEMCOOKIE 137
#define MYMALLOC(a,b,c) { \
char *aa; int *ii; \
aa = malloc(b*sizeof(c)+3*sizeof(int)); /* 1 */ \
ii = (int*)aa; ii[0] = b*sizeof(c); \
ii[1] = MEMCOOKIE; /* 2 */ \
aa = (char*)(ii+2); a = (c*)aa ; /* 4 */ \
aa = aa+b*sizesof(c); ii = (int*)aa; \
ii[0] = MEMCOOKIE; /* 3 */ \
}
现在我们可以写我们自己的 free ,它可以测试对象的边界是否没有被写入过。
#define MYFREE(a) { \
char *aa; int *ii,; ii = (int*)a; \
if (*(--ii)!=MEMCOOKIE) printf("object corrupted\n"); \
n = *(--ii); aa = a+n; ii = (int*)aa; \
if (*ii!=MEMCOOKIE) printf("object corrupted\n"); \
}
我们可以扩展这个想法:在每一个被分配的对象中,也存储两个指针,这样分配的内存区域成为一个双链表。然后我们可以写一个 CHECKMEMORY 宏,测试所有分配的对象是否损坏。
这种解决内存损坏问题的方法相当容易编写,而且开销很小。每个对象最多只有5个整数的内存开销,而且几乎没有性能损失。
(在某些系统上,我们可以不为 malloc 写一个装饰器,而是改变系统的行为例程。在 Linux 上,malloc 调用的 Hooks 可以用我们自己的例程来代替;见http://www.gnu.org/s/libc/manual/html_node/Hooks-for-Malloc.html)。
具体技术:Fortran
使用 Implicit none。
把所有的子程序放在模块中,这样编译器就可以检查缺少的参数和不匹配的类型,它还允许用 fdepend 来自动建立依赖关系。
使用 C 语言预处理器进行条件编译之类的工作。
测试
在测试代码的正确性方面有各种理念。
- 正确性证明:程序员写出描述部分代码预期行为的断言并通过数学方法证明这些断言的正确性。
- 单元测试:每个例程都要单独进行正确性测试,而对于数字码来说,这种方法通常很难做到。因为输入的浮点数,可能本质上是无穷大,并且知道什么能构成足够的输入集并不容易。
- 集成测试:测试子系统
- 系统测试:测试整个代码。这通常适用于数字码,因为我们常常有已知解的问题模型,或者有一些属性,如需要在全局解上保持不变的边界。
- 测试驱动设计:程序开发过程中可以随时进行以驱动为要求的测试。
在并行代码中我们遇到了一类新的测试困难。许多算法被执行并行时,会以稍微不同的顺序执行,导致出现不同的舍入行为。例如,并行的向量和计算会使用部分和。一些算法具有数值误差的固有阻尼,例如静态的迭代方法(第5.5.1节),但其他算法没有这种内置的误差修正(非稳态方法;5.5.8节)。因此,同一个迭代过程可能需要不同的迭代次数,这取决于有多少个处理器被使用。
测试驱动设计和开发
在测试驱动设计中,非常强调代码总是可测试性。其基本思想如下:
- 整个代码和部分都应该是可测试的。
- 当扩展代码时,只做允许测试的最小的改变。
- 对于每一个变化,都要进行前后测试。
- 在添加新的功能之前保证正确性。