第2章 可移植性分析
1) 数据类型(符号、大小)
2) 结构大小
3) 数据交换(位序、文件类型)
4) 依赖系统的API
5) 语言(英语、汉语等)
写出能够正确而有效地运行的软件是很困难的。因此,如果某个程序能在一个环境里工作,当你需要把它移到另一个编译系统,或者处理器,或者操作系统上时,不会希望再重复做太多原来已经做过的工作。最理想的情况是什么都不用改。 这种理想就是程序的可移植性。实际上,“可移植性”常被用来指一个更弱的概念,其意思是说,与凭空写出这个程序相比,对它做些修改挪到另一个地方将更容易一些。这种修改越容易做,我们就说这个程序的可移植性越强。
你可能会奇怪,为什么我们还要为可移植性费心呢?如果软件都是准备在某些特定条件下,在一个特定环境里运行的,为什么还要在使它具有更广泛的可接受性方面白费精力呢?
首先,任何成功的程序,几乎总是注定要被以原来不曾预料的方式,用到从未想到的地方去。
把一个软件构造得比它的规范更一般些,结果就会是以后的更少维护和更好使用。第二,环境总是在变。当编译系统、操作系统或者硬件升级的时候,其特性可能就不同了。程序对特殊特征的依赖越少,它也就越少可能崩溃,也越容易适应改变以后的环境。最后,也是最重要的,可移植的程序总是更优秀的程序。为把程序构造得更具有可移植性的努力也会使它具有更好的设计,更好的结构,经过更彻底的测试。一般地说,可移植程序设计的技术与优良程序设计的技术是密切相关的。
当然,可移植性的程度也应当根据现实来考虑。不存在什么绝对的可移植程序,只有那种已经在足够多的环境里试验过的程序。但是,我们仍然可以把可移植性作为一个目标,力图去开发那种几乎不用修改就能够运行在任何环境上的软件。甚至当这个目的不能完全达到时,在程序构造过程中花在可移植性上的功夫也将会得到回报,例如在这个软件需要升级的时候。
我们的看法是:应该设法写这样的软件,它能工作在它必须活动于其中的各种标准、界面和环境的交集里。不要为纠正每个移植性问题写一段特殊代码,正相反,应该修改这个软件本身,使它能够在新增加的限制下工作。利用抽象和封装机制限制和控制那些无法避免的不可移植代码。通过将软件维持在各种限制的交集里面,局部化它的系统依赖性,这样你的代码在被移植后仍将更加清晰、更具通用性。
1语言
盯紧标准。得到可移植代码的第一步当然是使用某种高级语言,应该按照语言标准(如果有的话) 去写程序。二进制不可能很容易地移植,但是源代码可以。当然,即使这样做也还会有问题,在编译系统如何将源程序翻译到机器指令的方式方面,也可能有些东西没有精确定义,对标准语言也是如此。
广泛使用的语言只存在一个实现的情况是罕见的,通常都有多个编译系统提供商,对于不同操作系统,又有不同的版本,还有随着年月更替而不断出现的不同发行版本。它们对你的源代码可能做出不同解释。
为什么语言标准不是一个严格定义呢?有时标准是不完全的,对某些特性之间的相互作用没有给出定义。有时标准会有意地不对某些东西做出定义,例如,C 和C++语言里的char 类型可以是有符号的或是无符号的,而且不必正好是8位。把这些事项留给写编译系统的人去解决,有可能产生出更有效的实现,或者避免语言对它能在其上运行的硬件提出太多限制。
当然,这种做法可能给程序员带来困难。政治上和技术上的相容性问题也可能导致某种妥协,使标准对某些细节不做具体定义。最后,语言都是极端复杂的,编译系统也很复杂,理解中可能出错,实现里面也可能有毛病。
有时语言根本没有经过标准化。C 语言正式的ANSI/ISO标准在1988年颁布,而ISO 的C++的标准直到1998年才被批准,在我们写这些的时候,还没有一个在用的编译系统支持这个正式标准。Java 是更新一些的语言,与标准化的距离还有许多年。一个语言标准的开发通常总是要等到这个语言已经有了许多不同的、互相冲突的实现,有了进行统一的需求的时候;此外,它也必须已经被广泛使用,值得付出标准化的代价。在这期间,还是有许多程序需要写,有许多环境需要支持。
综上所述,虽然在给人的印象上,参考手册和标准是一种严格规范,但它们从来也不能完全地定义一个语言。这样,由不同实现给出的就可能都是合法的,但却又是互不相容的解释。有时甚至实现中还存在错误。
有一个很有意思的小问题。下面的外部说明在C 或者C++里都是不合法的: *x[] = { "abc" };
对大多数编译系统而言,只有不多几个正确诊断出x 缺少char 类型说明符;好几个系统给出类型不匹配的警告(它们明显是采用了语言的老定义,错误地推论出x 是一个整型指针的数组) ,还有几个在编译这段非法代码时一点牢骚也不发。
在主流中做程序设计。某些编译系统不能辨识上面的错误,这当然很不幸,也说明了与可移植性有关的一个重要问题。任何语言都有黑暗的角落,在那里实践会出现分歧。例如C 和C++的位域,回避它们是比较稳健的做法。我们应该只使用那些在语言的定义里毫无歧义、而且又很容易理解的东西。这类特性更可能是到处都能用的,也会在任何地方都具有同样的行为方式。我们称这种东西为语言的主流。
要想确定哪里是主流有时也非常困难,但我们很容易辨明哪些东西是在主流之外。一些全新的东西,例如C 里面的//注释或者complex 类型;或者那些特定的与某种体系结构有关的东西,如near 或者far ;它们一定会带来麻烦。如果某个特性是如此地不寻常、不清楚,为了理解它,你在阅读定义时必须去咨询一个“语言律师”,一个专家,那么请不要用它。
在下面的讨论里,我们要把注意力集中在C 和C++,它们是常被人用来写可移植程序的通用程序语言。C 语言标准已经有了十几年的历史,这个语言也是很稳固
的。人们正在为建立一个新标准而工作,所以,也可以说是喷发在即。在另一方面,C++标准则是全新的,各种实现还没有时间汇合到一起。
什么是C 语言的主流?这个术语常被用来指那些已经建立起来的语言使用风格,但我们最好还是为将来做点准备。例如,原来的C 版本并不要求函数原型,说明sqrt 是一个函数的方式是写:
double sqrt();
这里定义了函数的返回值类型,对参数则什么也没说。ANSI C 增加了函数原型,它把所有东西都刻画清楚了:
double sqrt( double );
ANSI C 标准要求编译系统也要接受原来的语法,不过你无论如何也应该为自己的所有函数都写出原型。这样做能保证得到更安全的代码,保证所有的函数调用都得到完全的检查。
此外,如果界面改变了,编译系统也能够捕捉到它们。如果你的代码里有调用: func( 7, pi )
如果函数func 没有原型,那么编译系统就很可能不检验func 调用的正确性。如果后来有关的库改变了,func 改变为有了3个参数,必须修改软件这件事很可能被忽略,因为C 语言的老语法关闭了对函数参数的类型检查。
C++是一个更庞大的语言,有最新的标准,所以它的主流就更难辨别清楚。例如,虽然我们希望STL 能够变成主流,但这件事一时半会是不可能实现的。况且当前的一些实现根本就不支持它。
警惕语言的麻烦特性。我们已经提过,标准里常常有意遗留下一些东西,不给以定义或者不加以清楚的说明,通常这是为了给写编译系统的人更大的自由度。这种东西的列表实在太长了。
数据类型的大小。在C 和C++里,基本数据类型的大小并没有明确定义,给出的仅仅是下面这些规则:
此外,还规定char 至少必须有8位,short 和int 至少是16位,long 至少应该是32位。这里有许多不加保证的性质。甚至没要求一个指针值应该能够放进一个int 中。
很容易确定在一个特定编译系统里的各种类型的大小:
在我们正常使用的大部分机器上,输出都是一样的:
但是完全可能有其他情况。例如,在某些64位机器上产生的是:
在早期的PC 机上,典型的输出是:
对于早期的PC 机,硬件支持多种指针。为处理这些麻烦事,人们发明了一些指针修饰符,如far 和near 等,它们都不是标准的,但这些魔鬼保留字仍然在纠缠着当前的编译系统。如果在你的编译系统里基本类型的大小能够改变,或者你使用几种有着不同数据类型大小的机器,那么就应该在这些不同配置之下试试你的程序。
标准头文件sdtdef.h 里定义了一些类型,它们对可移植性能有些帮助。这其中最常用的是size_t,它是一个无符号的整数类型,是sizeof 运算符的返回类型。有些函数(例如strlen ) 返回这种类型的值;也有不少函数(如malloc ) 要求
这种类型的参数。
我们将忽略对浮点数计算方面各种问题的讨论,关于这个问题可以写出整本书。幸运的是,大多数现代机器都支持IEEE 的浮点硬件标准,这也使浮点算术的有关特性都合理地定义清楚了。
求值顺序。在C 和C++语言里,有关表达式中的运算对象、副作用产生以及函数参数的求值顺序都没给出明确定义。例如,赋值语句
这里的第二个getchar 也有可能率先执行,表达式的书写顺序不一定就是它们的执行顺序。在语句
里,count 的增值可能在它被用做ptr 的下标之前或者之后完成。同样,在
里,第一个输入字符可能被打印在后面(未必是第一个打印) 。在
里,errno 也可能在log 调用之前就求了值。
对于各种表达式如何求值,实际也有些规则。按照定义,在每个分号处,或者到一个函数被实际调用的时刻,所有的副作用或者函数调用都必须完成。运算符&&和||总是从左到右执行,而且只执行到表达式的真值能够确定时为止(包括有关副作用,也只到此时为止) 。在运算符?:里,条件先被求值(包括副作用) ,此后,后面两个表达式中只有一个被求值。
Java 对求值的顺序有严格定义,它要求所有的表达式,包括副作用,都严格地从左向右进行。然而,有一本权威性手册中提出建议,不要写“过分”依赖这种行为的代码。这是一个合理建议,如果存在着把Java 转换到C 或者C++的可能性,情况就更是如此,因为C 、C++都没有如上的保证。语言间的转换是对可移植性的一种极端性测试,虽然这种东西并不太有用。
char 的符号问题。char 数据类型到底是有符号还是无符号的,C 和C++并没有对此给出明确规定。在结合了char 和int 的代码里,这个问题就有可能造成麻烦,
例如getchar ()函数得到int 值,调用它的代码就可能出问题。假设你写了:
如果char 是无符号的,c 值将在0和255之间;而如果char 是有符号的,对于2补码机器上8位字符的最一般配置情况,c 的值将在-128与127之间,这种情况将造成一些影响。例如我们用字符作为数组的下标,或者用它去与EOF 做比较(EOF 通常在stdio.h 里定义为-1) 。
如果char 是无符号类型,条件s[i]==EOF将总是假的:
假设getchar 返回EOF ,存入s[i]的值将是255(即0xFF ,这是把-1转换到unsigned char所得到的结果) 。如果char 是无符号的,在与EOF 做比较时这个值还是255,这必然导致比较的失败。
即使char 是有符号的,上面的代码同样也不正确。在执行中遇到EOF 值时,这里的比较就会成功。但是,在这种情况下正常输入的字节0xFF 也会被当作EOF ,从而导致这个循环不正确地结束。所以,无论char 的符号情况如何,你都必须把getchar 的返回值存入一个int ,以便与EOF 做比较。下面是按可移植方式写出的同一循环:
Java 没有unsigned 修饰符。在这里所有的整型都是有符号的,只有16位char
类型是无符号的。
算术或者逻辑移位。在对有符号的量用运算符>>做右移时,这个移位可以是算术的(符号位将在移位的过程中复制传播) ,也可以是逻辑的(移位中空出的位被自动补0) 。同样,根据从C 和C++学到的经验,Java 把>>保留作算术右移,为逻辑右移另外提供了一个>>>。
字节顺序。在类型short 、int 和long 里,字节的顺序并没有规定,具有最低地址的字节可能是最高位的字节,也可能是最低位的字节。这是一种依赖硬件的特性,在本章的后面部分我们将做详细讨论。
结构或类成员的对齐。在结构、类或者联合里,各个成分的对齐方式并没有规定。这里只规定各成分一定按说明的顺序排列。例如,在下面的结构里:
struct
{
char c;
int i;
}
成分i 的地址与结构开始位置的距离可能是2、4或者8个字节。很少有机器允许int 存储在奇数边界上,一般都要求占据n 个字节的基本数据类型存放在n 字节的边界上。例如,double 一般是8个字节长,所以需要存储在8的倍数的地址上。在这之上,写编译程序的人还可能再做进一步调整,例如可能为了执行性能做进一步的强制对齐。
绝不能假定在一个结构里各成员占着连续的存储区。对齐限制实际上会造成结构中的“空洞”,在上面的struct X 里,至少存在一个字节的未用空间。空洞的存在说明了一个结构可能比它成员的大小之和更大一些,在不同的机器上又可能具有不同大小。如果需要分配空间,用以存放上述结构,就必须去申请sizeof (struct X) 个字节,而绝不应该是sizeof(char)+sizeof(int)个。
位域。位域对机器的依赖太强,无论如何都不应该用它。
从上面关于危险特征的长表里可以总结出下面的规则,不要使用副作用,除了在很少的几个惯用结构里,例如:
a[i++] = 0;
c = *p++;
*s++ = *t++;
不要用char 与EOF 做比较。总使用sizeof 计算类型和对象的值。决不右移带符号的值。
你所用的数据类型应该足够大,足够存储你希望放在里面的值。
用多个编译系统试验。人们很容易认为自己已经理解了可移植性,但是编译系统能看出某些你没有看到的问题。进一步说,不同编译程序有时会对你的程序有不同的看法,因此,你应该尽量利用这些帮助。打开编译程序所有的警告开关,在同一机器或者不同机器上试用多种编译系统,试用一个C++编译系统处理你的C 程序。
由于不同编译系统在接受的语言方面可能有差异,所以,即使你的程序能用一个编译系统完成编译,你甚至都无法保证它在语法上是正确的。如果几个编译系统都能接受你的代码,那么你的胜算就大得多。对于这本书里的每个C 程序,我们都在三个互不相干的操作系统(Unix,Plan9和Windows) 上用三个C 编译系统和若干C++编译系统处理过。这是一个清醒的试验,它确实挖出了数十个移植性错误,而这些问题通过人工的大量仔细检查也没有发现。
所有这些错误的更正都是非常简单的。
当然,编译系统本身也会引起可移植性问题,因为它们可能对语言中未加规定的行为做出各自不同的选择处理。即使如此,我们的途径仍然是有希望的。我们应该努力去构造这样的软件,使它们的行为能够独立于具体的系统、环境或者编译之间的差异,而不应该去写那种以某种方式展现这些差异情况的代码。简而言之,我们应该设法回避那些很有可能变动的性质和特征。
2头文件和库
头文件和库提供了许多服务,它们是基本语言的扩充。有关例子包括C 里通过stdio 、C++里通过iostream 、Java 里通过java.io 完成的输入和输出等。严格地说,这些都不是语言的组成部分,但它们又是和语言本身一起定义,并被期望作为支持有关语言的环境的一个组成部分。在另一方面,由于库通常都覆盖了范围
相当广泛的一组操作,常要处理与操作系统有关的问题,因此也就很容易成为不可移植问题的避风港。
使用标准库。在这里,应该提出与核心语言同样的建议:盯紧标准,特别是其中比较成熟的、构造良好的成分。C 语言定义了标准库,其中包括许多函数,它们处理输入输出、字符串操作、字符类检测、存储分配以及另外的许多工作。如果你把与操作系统的交互限制在这些函数的范围内,那么如果要从一个系统搬到另一个,你的代码很有希望还能具有同样的行为方式,执行得很好。不过你也要当心,因为存在许多标准库的实现,其中有些包含了标准里未定义的行为。
例如:ANSI C没有定义串复制函数strdup ,然而许多系统里都提供了它,甚至在那些声明自己完全符合标准的系统里。一个有经验的程序员可能会根据习惯去使用strdup ,完全没意识到它并不在标准之中。而后,当这个系统被移植到某个未提供这个功能的系统上时,程序在编译时就会出问题。这种问题是库引起的移植麻烦中最主要的一类。要解决这类问题,只能靠严格按照标准行事,并要在多种不同环境里测试你的程序。
在头文件或者包定义里声明了标准函数的界面。与头文件有关的一个问题是它们往往非常杂乱,因为它们必须设法在一个文件里同时服侍多种不同的语言。例如,我们常常看到,像stdio.h 这样的头文件需要同时为老的C 语言、ANSIC 和C++编译程序服务。在这种情况下,文件里到处都散布着#if和#ifdef一类的条件编译指示符号。由于语言预处理程序并不很灵活,这些文件常常都非常复杂,有时可能还包含着错误。
这里是从我们的某系统里摘录的一段,它比其他许多类似的东西还好一些,因为它具有很好的格式:
虽然这个例子相对而言是清晰的,它也确实印证了我们前面的说法,像这样的头文件(和程序) 的结构过于复杂,很难进行维护。针对每个编译系统或者环境建立一个独立的头文件,事情可能更容易些。这样就要求维护一组文件,但其中的每一个都是自足的,适应一个特定系统。
这样做也减少了像在严格的ANSI C环境里包括strdup 这一类的错误。 头文件还可能“污染”名字空间,因为它里面的某个函数可能正好与程序里的函数同名。
例如,我们的weprintf 原来被称为wprintf ,但是后来发现,在一些环境里根据新的C 标准在stdio.h 里定义了一个函数,用的也是这个名字。我们只好修改自己函数的名字,以便能在这种系统里完成编译,同时也是为未来做点准备。如果遇到的问题源于一个错误的实现,而不是规范的合法变化,我们就会想办法绕过它,可以采用的方法是在引入头文件时对有关名字重新做定义:
这样做的效果,是把头文件里所有的wprintf 都映射到stdio_wprintf,使它们不会再与我们的函数发生冲突。在此之后,我们就可以用原来的wprintf ,不必再改名了。这种写法有些臃肿,而且还付出了额外代价,与程序连接的库将会调用我们的wprintf 函数,而不是调用原来的那个。对于一个函数而言,这可能
不必特别担心。但是,确实有些系统给出的环境是非常混乱的,我们必须尽可能地保持代码的清晰性。应该用注释说明这个结构到底做了些什么,绝不能再用条件编译把它弄得更糟糕了。
如果发现在有的环境里定义了wprintf ,那么就应该假定所有的环境里都有这种定义。这样,这个修改就是永久性的,你完全不需要再去维护那些#ifdef语句。当然,更简单的方式是绕道而行,而不是去做斗争,这样做也更安全些。这也就是我们做的事,把函数名字改成weprintf 。
即使你总能严格地按规矩办事,环境本身也非常干净,仍然很容易走出限定的范围,例如无意识地假定某些自己喜欢的性质在所有地方都对等等。这方面的例子如,ANSIC 定义了6个信号,函数signal 能够捕捉到它们。而在POSIX 里定义了19个,大部分Unix 系统支持32个或者更多的信号。如果你想要用一个非ANSI 信号,这很明显就牵涉到功能和可移植性之间的权衡问题,你必须决定哪方面对自己更重要。
目前还存在许多其他标准,它们又不是程序语言定义的组成部分。这方面的例子包括操作系统和网络界面、图形界面,以及许多其他类似的东西。这其中有些东西试图跨越多个系统,例如POSIX ;另一些则是为某个特定系统度身打造的,例如各种不同的Microsoft Windows API 。与上面类似的建议也适用于这些方面。如果你选择广泛适用的具有良好构造的标准,如果你能盯住最核心的使用最广泛的特性,你的程序就能更具可移植性。
3程序组织
达到可移植性的方式,最重要的有两种,我们将把它们称为联合的方式和取交集的方式。
联合方式使用各个特殊途径的最佳特征,采用条件式的编译和安装,根据各个具体环境的特殊情况分别进行处理。这样,结果代码是所有方案的一种联合,它可以利用各系统在能力方面的优点。这种方式的缺点包括:安装过程的规模和复杂性,由代码中大量费解的编译条件造成的复杂性等等。
只使用到处都可用的特征。我们建议采用取交集的方式,即:只使用那些在所有目标系统里都存在的特性,绝不使用那些并不是到处都能用的特征。强求使用普遍可用特性也有危险性,这可能限制了目标系统的范围,或者限制了程序的功能。此外,也可能在某些系统里导致性能方面的损失。
为了比较这两种不同方式,我们来看一些使用联合方式的例子,以及采用交集方式对它们重新进行整理的情况。正如你将要看到的,联合方式的代码从设计上看根本就是不可移植的,虽然它们声称可移植性是自己的目标;而交集代码不仅是可移植的,通常也更加简单。
下面是个小例子,这里试图处理环境中因为某些原因而没有标准头文件stdlib.h 的情况:
如果偶然用用的话,这种防御式测试还是可以接受的,但频繁地这样做就很不好了。这里也提出了另一个问题:到底有多少stdlib 函数最后出现在这种形式的或者其他类似形式的条件代码里。如果在程序里用到了malloc 或者realloc ,那么肯定也需要用其他的函数,例如free 。
如果unsigned int的大小与size_t(这是malloc 和realloc 参数的正确类型) 不一样,那么又会出什么问题?进一步说,我们怎么知道STDC_HEADERS或_LIBC确实已经定义了,而且定义正确?怎么保证绝不会有其他名字能在某种环境里启动这里的代换?任何像这样的条件代码都是不完全的、不可移植的,因为总会遇到某个系统不能与这里的条件协调,这时我们就必须重新编辑这些#ifdef。如果能不通过这类条件编译解决问题,我们就能够根除这些在程序维护中最令人头疼的事情。
然而,这个例子力图解决的问题确实是存在的,那么,怎样做才能一劳永逸地解决它呢?我们认为,宁可事先假设标准头文件是存在的。如果确实没有的话,那
就是其他人的问题了。而如果实际情况就是没有,那么更简单的办法是与本软件一起发送一个头文件,在其中定义函数malloc 、realloc 和free ,与ANSIC 定义它们的形式完全相同。在程序里总包含这个文件,而不是在代码中到处打上面这样的绷带。这样,我们就能知道必要的界面总是可用的。
避免条件编译。使用#ifdef和其他类似预处理指示写的条件编译是很难管理的,因为在这种情况下有关信息趋向于散布在整个源文件里。
在这个摘录中,最好是在各定义之后用#endif,而不是在最后堆积一批#endif。但是,实际问题是,无论写程序时的动机如何,这段代码都是高度不可移植的,因为它对每个系统的行为不同,对每个新环境必须再写一个新的#ifdef。用一个串,其中使用一个一般性的词可能更方便,完全是可移植的,而且也提供了同样的信息:
这就不需要任何条件代码,因为它对所有系统都完全一样。
将编译时的控制流(由#ifdef语句确定的) 和运行时的控制流混在一起,会使情况变得更坏,因为这种东西极其难读。
对于那些明显无害的应用,条件编译常常可以用更清晰的形式取代。例如#ifdef常被用来控制排错代码的执行:
用一个带常量条件的正规的条件语句也能把事情做得同样好:
如果DEBUG 的值是0,大部分编译系统对这段程序不会产生任何目标代码,不过它们会检查被排除代码的语法。与此相反,#ifdef里完全可能隐藏着语法错误,而以后一旦把#ifdef打开,有关代码就会阻碍编译的执行。
有时人们用条件编译排除掉一大段代码:
或
在编译时采用有条件地替换文件的方式,可以完全避免这种条件代码。下一节里我们还要回到这个问题。
如果需要修改一个程序去适应某个新环境,你不应该以该程序的一个新副本作为出发点。
相反,你应该设法调整现存的代码。你可能需要对代码的主体做一些修改。如果采用编辑程序副本的方式,慢慢地你就会做出许多发散的版本。对于一个程序,只要可能,应该只存在惟一的一套源文件。如果你发现某些东西需要改变,以便把程序移植到某个特定的环境去,那么请设法找到一种办法,使改造后的东西在所有地方都能用。如果认为需要,也可以修改内部的界面,但应该保持代码里不出现#ifdef。这种做法每次都将使你的代码变得更具可移植性,而不是变得更特殊。应该缩小交集,而不是放宽联合。
我们已经说了许多反对条件编译的话,也展示了由它引起的一些问题。但我们还没有提到这其中最恶劣的问题:这种代码几乎是无法测试的。一个#ifdef实际上把单个的程序变成了两个分别编译的程序,我们很难弄清程序的每个变形是否都已经编译过、测试过。如果在一个#ifdef块里做了修改,那么在其他地方也可能需要做这种修改。要想验证有关的修改,就要求环境条件能够打开对应的#ifdef。虽然在某些其他配置方式下也需要类似修改,这些情况也不可能检测到。此外,如果程序里需要增加一个新#ifdef块,我们很难把这种修改孤立出来,很难确定需要满足哪些额外条件才能到达这个地方,很难确定为解决这个问题还要修改哪些地方。最后,如果代码里确有某些东西,按照条件它们将被忽略,那么编译就根本看不到它。这里完全可能是些乱七八糟的东西,而我们却根本就不知道,直到某个不幸的用户试图在某个环境里编译程序,恰巧触发了有关条件。下面的程序当_MAC有定义时是能够编译的,如果不是这样就会出毛病:
基于上述理由,我们更喜欢只使用那些对所有目标环境都是共同的特性。这样我们就能编译和测试所有代码。如果某些东西产生可移植性问题,我们不是增加条件性代码,而是设法重写代码,设法避免这些问题。沿着这条路走下去,程序的可移植性将逐步增强,其本身也会不断得到改进,而不是变得越来越复杂。
有些大系统在发布时带有一个配置脚本,以便能根据局部环境的情况对代码做一些剪裁。
在编译的时候,这个脚本将检测局部环境的各方面特性—头文件和库的位置,字的字节顺序,各种类型的大小,实现者已知可能崩溃的情况(这虽然出人意料,但却是很常见的) ,如此等等,由此生成一套配置参数或者make 文件,以便对有关情况做出正确配置和设置。这些脚本可能很大、很复杂,是软件发布的重要组成部分,需要不断进行维护,以保证它们能完成任务。有的时候这种技术确实是必须的。但是从另一个角度说,代码的可移植性越强,#ifdef越少,它的配置和安装也就会越简单、越可靠。
练习:研究你的编译系统如何处理括起来放在条件块里面的代码,例如:
在什么情况下它确实做了语法检查?什么时候它产生实际的代码?如果你能用的编译系统不止一个,它们互相比较的情况又如何?
4隔离
我们宁愿能有这样一个源程序,它能在所有系统上编译,不需要做任何修改。不过这常常是不现实的。虽然如此,任由不可移植代码散布在程序的各处仍然是不对的,而这也正是条件编译造成的问题之一。
把系统依赖性局限在独立文件里。如果不同系统需要不同的代码,应该使这种差异局限在独立的文件里,一个文件对应一个系统。例如,文本编辑器Sam 能在Unix 、Windows 和许多其他系统上运行。这些环境的系统界面差别极大,但是Sam 的绝大部分代码在各处都是一样的。
这里对每个特定环境提供一个独立文件,覆盖系统的变化情况。unix.c 提供到Unix 系统的界面代码,而windows.c 用于Windows 环境。这些文件实现了一个到操作系统的可移植界面,掩盖掉它们之间的差别。Sam 实际上是针对它自己的虚拟操作系统写的,这个虚拟操作系统可以移植到各种实际系统上,方法就是写出几百行C 代码,利用可用的系统调用实现十来个小的无法移植的操作。
各种操作系统的图形环境几乎是互不相干的,Sam 处理这个问题的办法就是为自己的图形提供一个可移植库。与直接为某个给定系统修改代码相比,建立这种库要做更多的工作(例如,关联XWindow 系统的界面代码大约有Sam 所有其他部分的一半那么大) ,但是从长远看,累计起来的工作量则要小得多。作为这个工作的副产品,该图形库本身也很有价值,可以单独使用,并已经把几个其他程序弄得可移植了。
Sam 是个很老的程序,今天,各种可移植的图形环境,例如OpenGL 、Tcl/Tk和Java 等已经在许多不同平台上可以使用了,利用这些东西写你的代码,而不是用专有图形库,这将使你的程序具有更强的可用性。
把系统依赖性隐藏在界面后面。抽象是一种强有力的技术,应该通过它划清程序的可移植部分与不可移植部分之间的界限。大部分程序设计语言所附带的I/O库就是一个很好的例子,它们使用可供打开/关闭、读和写的文件概念,从不提及任何物理位置或结构,为二级存储器提供了一种抽象。使用这些界面的程序将能在任
何实现了它们的系统上运行。
Sam 程序的实现是抽象的另一个例子。这里定义了与文件系统和图形操作相关的界面,程序里只使用界面所提供的特征,而界面本身则可以使用下层系统提供的任何功能。对不同系统而言,界面的实现可能差别很大。但是,只使用界面的程序则与这些情况完全无关,当它需要搬到另一处时根本不需要做任何修改。
Java 的可移植途径也是个很好的例子,它也说明了沿着这条路上有可能走多么远。一个Java 程序被翻译为一种“虚拟机器”上的一系列操作。所谓虚拟机就是一个模拟的计算机,它可以在任何现实的计算机上实现。Java 的库提供了一套一致的对下层系统特性进行访问的功能,包括图形、用户界面、网络以及其他类似的东西。库将被映射到局部系统提供的功能上。从理论上说,完全可能在任何地方,无须任何改变地运行同一个Java 程序(甚至是翻译之后的程序) 。
5数据交换
正文数据很容易从一个系统搬到另一个系统去,这是在不同系统间交换任意信息的最简单的方式。
用正文做数据交换。正文容易用各种工具操作,以原来未曾预计到的方式去处理。例如,如果一个程序的输出并不正好适合做另一个程序的输入,我们可以用一个Awk 或Perl 脚本去矫正它;可以用grep 选择或者删除其中的一些行;可以用你最喜欢的编辑器对它做各种更复杂的修改。正文文件很容易做文档,甚至可能再不需要做文档,因为人完全可以直接阅读它们。
正文文件里可以写注释,指明处理这些数据需要什么版本的软件。例如在PostScript 文件里的第一行就说明了它的编码方式:
%!PS-Adobe-2.0
与此相反,处理二进制文件就需要有专门的工具,甚至在同一台机器上,这些工具常常也不能一起使用。存在许多使用广泛的工具,其作用就是把任意的二进制数据转换成正文,以便能以最不容易出毛病的形式发送出去。这其中包括Macintosh 系统里的binhex ,Unix 系统的uuencode 和uudecode ,以及各种各样的为在
电子邮件里传递二进制数据而使用的MIME 编码工具。在第9章,我们还要给出一组包装和解包例程,它们能用于对二进制数据进行编码,使其能够可移植地进行传递。存在这么多工具,这个情况就足以说明二进制格式存在某些问题。
正文数据交换中也存在一个不和谐音:PC 系统使用一个回车符‘\r’和一个换行符‘\n’来结束一个行,而在Unix 系统里只用一个换行符。回车是一种称为电传打字机的古老设备的产物,这种设备需要用一个回车(CR)操作使打字部件回到行的开始,再用另一个独立的换行操作(LF)将它推进到一个新行。
虽然今天的计算机已经根本没有什么车可以回了,大部分PC 软件仍然期望在每行的最后有这种组合(习惯上称为CR LF,读为“curliff ”) 。如果没有回车符,一个文件就可能被当作一个极长的行,行和字符计数都可能出错,或发生不能预期的更改。有些软件能很得体地适应这些情况,但大部分软件都不行。PC 并不是仅有的罪犯,由于一系列兼容性方面的考虑,某些现代的网络标准,例如HTTP ,也用CR LF作为行限界符号。
我们建议只使用标准化的界面,这将在任何给定系统上一致地建立CRLF ,无论是(在PC 上) 输入时去掉\r,到输出时再把它加回去;还是(在Unix 上) 什么也不做。对那些必须从这边搬到那边的文件,就需要使用能在两种格式间完成转换的程序。
练习:写一个程序从文件中去掉荒谬的回车。写第二个程序把它们加进去,方法是将每个换行符替换为一个回车和一个换行。你将如何测试这些程序?
6.字节顺序和结构大小
虽然存在着上面讨论里提出的各种缺点,二进制数据有时仍然是必需的,因为它更紧凑,解码也更迅速。在计算机网络领域,二进制形式是许多工作的基础,紧凑和速度都是最根本的原因。但是,二进制数据确实存在许多移植性问题。
至少有一个情况可以确定:所有现代机器都使用8位字节。但是,不同机器在如何表示大于一个字节的对象时就有许多不同方式,特别依赖某种特定方式就是一个错误。一个短整数(通常是16位,含两个字节) 的低位字节可能存储在比其高位字节低的存储地址(低尾端方式) ,或者存在较高的存储地址(高尾端方式) 。这方面
的决定确实带有随意性,有的机器甚至同时支持两种方式。
这样一来,虽然对采用高尾端或低尾端方式的机器而言,存储器都被看成是某种顺序下的字序列,它们对一个字里各字节的解释却采用了相反的顺序。在下面的图中,从地址0开始的4个字节表示某个十六进制整数。对高尾端机器而言,它是0x11223344,而对低尾端机器就是0x44332211。
要想知道实际机器中的字节顺序问题,可以看下面的程序:
在32位的高尾端机器上,其输出是:
11 22 33 44
在低尾端机器上,输出就会是:
44 33 22 11
但是,在PDP-11机器(这是某个时期的一种卓越的机器,现在还可以在许多嵌入式系统中看到) 上是:
22 11 44 33
在具有64位long 类型的机器上,只要改变常数的大小,就可以看到类似现象。 这看起来像是一个无聊的问题,但是,如果我们需要把整数通过具有一个字节宽度的界面(例如通过网络连接) 传输时,就必须选定首先传输哪个字节,而这个选择必然要牵涉到低尾端/高尾端的问题。换句话说,该程序应该以明显方式做某些事,而这些事在:
fwrite( &x, sizeof(x), 1, fp );
中是以隐含方式处理的。如果在一台计算机里写出一个int (或者short 、long ) ,而要另一台计算机读它,这将是件很不安全的事情。
例如,假设源计算机用下面方式写
unsigned short x;
fwrite( &x, sizeof(x), 1, fp );
而接收的计算机用下面方式读
unsigned short x;
fread( &x, sizeof(x), 1, fp );
如果两台机器具有不同的字节顺序,x 的值将无法保持原样。如果x 在开始时是0x1000,它在到达时可能就是0x0010。
人们通常用条件编译和字节交换来解决这种问题,写的东西大概是:
当存在许多二字节和四字节整数需要交换时,这种方式就显得非常笨拙。在实践中,在把这些字节从一个地方传到另一个地方的过程中,可能需要多次完成这类交换。
对short 而言,事情已经很不妙了,对于更长的数据类型,情况当然会更糟糕,因为这时存在着更多的排列其中字节的方式。如果再加上结构成员之间的大小可能不同的填充、对齐方面的限制,以及各种老式机器千奇百怪的字节顺序,问题
看起来很难对付。
数据交换时用固定的字节序。这里提出一种解决办法:写可移植的代码,总按照正规的顺序写出各个字节:
一次一个字节地读回,把数据重装起来:
这个方法也可以推广到结构,你只要把结构成员按照一种规定顺序写出去,一次一个字节,丢掉填充。无论你采用什么字节顺序,所有事情都将能够一致地完成。这里惟一的要求是:对于传送中所采用的字节顺序和各种对象的大小,发送方和接收方都应该有同样的认识。
如果你在C 或C++里工作,那么你就得自己做这件事。采用一次处理一个字节的方式,最关键的收获就是既不需要使用#ifdef,又能对所有8位字节的机器解决问题。我们将在下一章里继续这方面的讨论。
当然,解决问题的最好办法常常还是把信息转换到正文形式,这种形式(除了CRLF 问题外) 完全是可移植的,这里根本不存在表达的歧义性问题。当然,这并不一定就是正确的解,因为时间和空间都是极其重要的。有些数据,特别是浮点数据,在传过printf 和scanf 时有可能丢失精度,主要是由于截断问题。如果你必须完全精确地交换浮点数据,那么就必须弄清你确有很好的格式化I/O库。这种库是存在的,不过它可能不是你工作环境的一部分。
想要以可移植的方式用二进制形式表示浮点数据非常困难,另一方面,如果你足够细心,通过正文可以做好这件事。
在使用标准库函数处理二进制文件时,还有一个很微妙的可移植问题—必须以二进制方式打开文件:
如果忽略了这里的‘b ’,在所有Unix 系统上都不会出任何问题。但是在Windows 系统里,程序输入中遇到的第一个control-Z 字节(八进制的032,十六进制的1A ) 将结束有关的读入动作。在另一方面,如果按二进制模式读入正文文件,会导致\r字节被留在了输入里面,而在输出时又不会自动地产生它。
7可移植性和升级
可移植性问题中还有一个最让人头疼的因素,那就是,系统软件总是在随着时间推移而不断变化,这种变化有可能发生在系统的任何层面上,导致程序的现存版本之间无缘无故地发生互不相容的情况。
如果改变规范就应改变名字。我们最喜欢(如果可以用这个词的话) 用的例子是Unix 系统中echo 命令的性质变化。在开始时,它的设计仅仅是想做对参数的回应:
但是,到了后来,echo 变成了许多shell 脚本里的关键部分,希望产生格式化输出的需求变得越来越强烈,所以echo 被改造为解释它的参数,从某种意义上看更像printf 了:
这种新特性确实很有用,但它也使许多shell 脚本产生了移植性问题,因为它们可能就依赖于echo 命令除了回应之外什么也不做。而
%echo $PATH
的行为方式则依赖于现在所用的echo 是什么版本的。如果不巧在变量值里包
含了一个反斜线符号(在DOS 或Windows 系统里很容易遇到这种情况) ,e cho 可能对它做出某种特殊解释。这个问题类似于用printf(str)或printf("%s",str)产生输出时的差别。请看看,如果在str 里正好有一个百分号,会发生什么事情。
我们说的还仅仅是echo 故事中的一部分,但是它已经阐明了根本的问题:对系统的修改可能产生不同版本的软件,有时我们有意地使它们在行为方面有些变化,但同时也无意地造成了许多移植性问题,而这些问题常常是很难绕过去的。如果给echo 的新版本另起一个不同的名字,造成的麻烦可能要少得多。
现在再看一个更具启发性的例子。考虑Unix 命令sum ,它打印出一个文件的大小和它的检验和。下面一串命令是想验证一次信息传递是否完全成功:
由于传递之后的检验和相同,我们可以合理地推断,新副本和老文件是一样的。 而后系统被改进了,版本发生了变化,有人发现检验和的算法并不完美,因此就修改了sum ,使用了更好的算法。另外的什么人也做了同样研究,给出sum 的另一个更好的算法。这样发展下来,到了今天,实际中已经存在着许多不同版本的sum ,每个版本给出的回答都不同。我们把一个文件复制到附近另一台机器上,想看看sum 算出的是什么:
是文件破坏了吗?还是仅仅因为我们恰好用到不同版本的sum ?或许两者都是?
这样的sum 就形成了一个完美的可移植性灾难:一个程序,其目的是为了帮助人们在从一台机器到另一台复制软件时做检查。但是,由于存在许多互不兼容的版本,从原来的意图看,它已经变成毫无意义的东西了。
对于原本要应付的简单工作而言,最早的sum 是很好的,其低技术的检验和算法也是适宜的。虽然“修改”它有可能造就出一个更好的程序,但从中得到的并不多,至少完全不足以抵消这种不相容的代价。问题不在于性能提升,而在于互不相容的程序又偏偏用了同样名字。这种变化带来的版本问题还会折磨我们许多年。
维护现存程序与数据的相容性。当一个软件(例如一个字处理系统) 的新版本发布时,新版本通常都能读入由较早版本产生的文件。我们也可以断定,由于增加了新的原来没有的特征,文件格式也可能有变化。但是,新版本常常没有提供一种方式,使之能写出原来格式的文件。
这样,新版本的用户,即使他们没有使用新版本中的任何新特征,也无法与那些使用旧版本的人们共享文件。这将迫使每个人都去升级。无论是作为一个工程观点,还是作为一种市场策略,这种设计都是特别令人遗憾的。
向后兼容是使程序符合其过去规范的一种能力。如果你打算修改一个程序,那就应该保证你没有破坏老的程序和依赖于它的数据。应该正确地修改文档,提供一
些办法去恢复原来的行为方式。最重要的是,应该仔细考虑你计划做的改变是不是真正的改进,与你将引进的不可移植性的代价相比,是不是真正值得去做。
8小结
可移植代码是一个非常值得去追求的理想,因为有如此多的时间被浪费在修改程序方面,无论是把程序从一个系统移到另一个系统,还是为了它本身演化的需要,或是因为它运行的系统发生了变化,在这些情况下需要设法维持程序的继续运行。当然,可移植性不是随便就能得到的,它要求实现中的特别注意,也需要开发者具有对所有的潜在目标系统在可移植问题方面的知识。
我们已经指出了追求可移植性的两种途径,即联合和交集。联合途径相当于为在每个目标系统上工作而写一个版本,利用条件编译一类的机制,把这些代码尽可能地汇集在一起。
这种途径的缺点很多,它造成过多的代码,而且常常是很多非常复杂的代码。它很难更新,也很难测试。
交集途径是设法以一种形式写出尽量多的代码,使它能在每种系统上运行而不需要做任何修改。把无法逃避的系统依赖性封装在独立的源文件里,其作用就像是程序与基础系统之间的界面。交集方法也有缺点,包括可能存在性能方面,甚至特征方面的损失。但是从长远的观点看,这种途径的利大于弊。
下面是几个重要的移植问题:
● 数据类型(符号和类型大小)
● 数据交换(结构大小、字节顺序)
● 依赖系统的API
第2章 可移植性分析
1) 数据类型(符号、大小)
2) 结构大小
3) 数据交换(位序、文件类型)
4) 依赖系统的API
5) 语言(英语、汉语等)
写出能够正确而有效地运行的软件是很困难的。因此,如果某个程序能在一个环境里工作,当你需要把它移到另一个编译系统,或者处理器,或者操作系统上时,不会希望再重复做太多原来已经做过的工作。最理想的情况是什么都不用改。 这种理想就是程序的可移植性。实际上,“可移植性”常被用来指一个更弱的概念,其意思是说,与凭空写出这个程序相比,对它做些修改挪到另一个地方将更容易一些。这种修改越容易做,我们就说这个程序的可移植性越强。
你可能会奇怪,为什么我们还要为可移植性费心呢?如果软件都是准备在某些特定条件下,在一个特定环境里运行的,为什么还要在使它具有更广泛的可接受性方面白费精力呢?
首先,任何成功的程序,几乎总是注定要被以原来不曾预料的方式,用到从未想到的地方去。
把一个软件构造得比它的规范更一般些,结果就会是以后的更少维护和更好使用。第二,环境总是在变。当编译系统、操作系统或者硬件升级的时候,其特性可能就不同了。程序对特殊特征的依赖越少,它也就越少可能崩溃,也越容易适应改变以后的环境。最后,也是最重要的,可移植的程序总是更优秀的程序。为把程序构造得更具有可移植性的努力也会使它具有更好的设计,更好的结构,经过更彻底的测试。一般地说,可移植程序设计的技术与优良程序设计的技术是密切相关的。
当然,可移植性的程度也应当根据现实来考虑。不存在什么绝对的可移植程序,只有那种已经在足够多的环境里试验过的程序。但是,我们仍然可以把可移植性作为一个目标,力图去开发那种几乎不用修改就能够运行在任何环境上的软件。甚至当这个目的不能完全达到时,在程序构造过程中花在可移植性上的功夫也将会得到回报,例如在这个软件需要升级的时候。
我们的看法是:应该设法写这样的软件,它能工作在它必须活动于其中的各种标准、界面和环境的交集里。不要为纠正每个移植性问题写一段特殊代码,正相反,应该修改这个软件本身,使它能够在新增加的限制下工作。利用抽象和封装机制限制和控制那些无法避免的不可移植代码。通过将软件维持在各种限制的交集里面,局部化它的系统依赖性,这样你的代码在被移植后仍将更加清晰、更具通用性。
1语言
盯紧标准。得到可移植代码的第一步当然是使用某种高级语言,应该按照语言标准(如果有的话) 去写程序。二进制不可能很容易地移植,但是源代码可以。当然,即使这样做也还会有问题,在编译系统如何将源程序翻译到机器指令的方式方面,也可能有些东西没有精确定义,对标准语言也是如此。
广泛使用的语言只存在一个实现的情况是罕见的,通常都有多个编译系统提供商,对于不同操作系统,又有不同的版本,还有随着年月更替而不断出现的不同发行版本。它们对你的源代码可能做出不同解释。
为什么语言标准不是一个严格定义呢?有时标准是不完全的,对某些特性之间的相互作用没有给出定义。有时标准会有意地不对某些东西做出定义,例如,C 和C++语言里的char 类型可以是有符号的或是无符号的,而且不必正好是8位。把这些事项留给写编译系统的人去解决,有可能产生出更有效的实现,或者避免语言对它能在其上运行的硬件提出太多限制。
当然,这种做法可能给程序员带来困难。政治上和技术上的相容性问题也可能导致某种妥协,使标准对某些细节不做具体定义。最后,语言都是极端复杂的,编译系统也很复杂,理解中可能出错,实现里面也可能有毛病。
有时语言根本没有经过标准化。C 语言正式的ANSI/ISO标准在1988年颁布,而ISO 的C++的标准直到1998年才被批准,在我们写这些的时候,还没有一个在用的编译系统支持这个正式标准。Java 是更新一些的语言,与标准化的距离还有许多年。一个语言标准的开发通常总是要等到这个语言已经有了许多不同的、互相冲突的实现,有了进行统一的需求的时候;此外,它也必须已经被广泛使用,值得付出标准化的代价。在这期间,还是有许多程序需要写,有许多环境需要支持。
综上所述,虽然在给人的印象上,参考手册和标准是一种严格规范,但它们从来也不能完全地定义一个语言。这样,由不同实现给出的就可能都是合法的,但却又是互不相容的解释。有时甚至实现中还存在错误。
有一个很有意思的小问题。下面的外部说明在C 或者C++里都是不合法的: *x[] = { "abc" };
对大多数编译系统而言,只有不多几个正确诊断出x 缺少char 类型说明符;好几个系统给出类型不匹配的警告(它们明显是采用了语言的老定义,错误地推论出x 是一个整型指针的数组) ,还有几个在编译这段非法代码时一点牢骚也不发。
在主流中做程序设计。某些编译系统不能辨识上面的错误,这当然很不幸,也说明了与可移植性有关的一个重要问题。任何语言都有黑暗的角落,在那里实践会出现分歧。例如C 和C++的位域,回避它们是比较稳健的做法。我们应该只使用那些在语言的定义里毫无歧义、而且又很容易理解的东西。这类特性更可能是到处都能用的,也会在任何地方都具有同样的行为方式。我们称这种东西为语言的主流。
要想确定哪里是主流有时也非常困难,但我们很容易辨明哪些东西是在主流之外。一些全新的东西,例如C 里面的//注释或者complex 类型;或者那些特定的与某种体系结构有关的东西,如near 或者far ;它们一定会带来麻烦。如果某个特性是如此地不寻常、不清楚,为了理解它,你在阅读定义时必须去咨询一个“语言律师”,一个专家,那么请不要用它。
在下面的讨论里,我们要把注意力集中在C 和C++,它们是常被人用来写可移植程序的通用程序语言。C 语言标准已经有了十几年的历史,这个语言也是很稳固
的。人们正在为建立一个新标准而工作,所以,也可以说是喷发在即。在另一方面,C++标准则是全新的,各种实现还没有时间汇合到一起。
什么是C 语言的主流?这个术语常被用来指那些已经建立起来的语言使用风格,但我们最好还是为将来做点准备。例如,原来的C 版本并不要求函数原型,说明sqrt 是一个函数的方式是写:
double sqrt();
这里定义了函数的返回值类型,对参数则什么也没说。ANSI C 增加了函数原型,它把所有东西都刻画清楚了:
double sqrt( double );
ANSI C 标准要求编译系统也要接受原来的语法,不过你无论如何也应该为自己的所有函数都写出原型。这样做能保证得到更安全的代码,保证所有的函数调用都得到完全的检查。
此外,如果界面改变了,编译系统也能够捕捉到它们。如果你的代码里有调用: func( 7, pi )
如果函数func 没有原型,那么编译系统就很可能不检验func 调用的正确性。如果后来有关的库改变了,func 改变为有了3个参数,必须修改软件这件事很可能被忽略,因为C 语言的老语法关闭了对函数参数的类型检查。
C++是一个更庞大的语言,有最新的标准,所以它的主流就更难辨别清楚。例如,虽然我们希望STL 能够变成主流,但这件事一时半会是不可能实现的。况且当前的一些实现根本就不支持它。
警惕语言的麻烦特性。我们已经提过,标准里常常有意遗留下一些东西,不给以定义或者不加以清楚的说明,通常这是为了给写编译系统的人更大的自由度。这种东西的列表实在太长了。
数据类型的大小。在C 和C++里,基本数据类型的大小并没有明确定义,给出的仅仅是下面这些规则:
此外,还规定char 至少必须有8位,short 和int 至少是16位,long 至少应该是32位。这里有许多不加保证的性质。甚至没要求一个指针值应该能够放进一个int 中。
很容易确定在一个特定编译系统里的各种类型的大小:
在我们正常使用的大部分机器上,输出都是一样的:
但是完全可能有其他情况。例如,在某些64位机器上产生的是:
在早期的PC 机上,典型的输出是:
对于早期的PC 机,硬件支持多种指针。为处理这些麻烦事,人们发明了一些指针修饰符,如far 和near 等,它们都不是标准的,但这些魔鬼保留字仍然在纠缠着当前的编译系统。如果在你的编译系统里基本类型的大小能够改变,或者你使用几种有着不同数据类型大小的机器,那么就应该在这些不同配置之下试试你的程序。
标准头文件sdtdef.h 里定义了一些类型,它们对可移植性能有些帮助。这其中最常用的是size_t,它是一个无符号的整数类型,是sizeof 运算符的返回类型。有些函数(例如strlen ) 返回这种类型的值;也有不少函数(如malloc ) 要求
这种类型的参数。
我们将忽略对浮点数计算方面各种问题的讨论,关于这个问题可以写出整本书。幸运的是,大多数现代机器都支持IEEE 的浮点硬件标准,这也使浮点算术的有关特性都合理地定义清楚了。
求值顺序。在C 和C++语言里,有关表达式中的运算对象、副作用产生以及函数参数的求值顺序都没给出明确定义。例如,赋值语句
这里的第二个getchar 也有可能率先执行,表达式的书写顺序不一定就是它们的执行顺序。在语句
里,count 的增值可能在它被用做ptr 的下标之前或者之后完成。同样,在
里,第一个输入字符可能被打印在后面(未必是第一个打印) 。在
里,errno 也可能在log 调用之前就求了值。
对于各种表达式如何求值,实际也有些规则。按照定义,在每个分号处,或者到一个函数被实际调用的时刻,所有的副作用或者函数调用都必须完成。运算符&&和||总是从左到右执行,而且只执行到表达式的真值能够确定时为止(包括有关副作用,也只到此时为止) 。在运算符?:里,条件先被求值(包括副作用) ,此后,后面两个表达式中只有一个被求值。
Java 对求值的顺序有严格定义,它要求所有的表达式,包括副作用,都严格地从左向右进行。然而,有一本权威性手册中提出建议,不要写“过分”依赖这种行为的代码。这是一个合理建议,如果存在着把Java 转换到C 或者C++的可能性,情况就更是如此,因为C 、C++都没有如上的保证。语言间的转换是对可移植性的一种极端性测试,虽然这种东西并不太有用。
char 的符号问题。char 数据类型到底是有符号还是无符号的,C 和C++并没有对此给出明确规定。在结合了char 和int 的代码里,这个问题就有可能造成麻烦,
例如getchar ()函数得到int 值,调用它的代码就可能出问题。假设你写了:
如果char 是无符号的,c 值将在0和255之间;而如果char 是有符号的,对于2补码机器上8位字符的最一般配置情况,c 的值将在-128与127之间,这种情况将造成一些影响。例如我们用字符作为数组的下标,或者用它去与EOF 做比较(EOF 通常在stdio.h 里定义为-1) 。
如果char 是无符号类型,条件s[i]==EOF将总是假的:
假设getchar 返回EOF ,存入s[i]的值将是255(即0xFF ,这是把-1转换到unsigned char所得到的结果) 。如果char 是无符号的,在与EOF 做比较时这个值还是255,这必然导致比较的失败。
即使char 是有符号的,上面的代码同样也不正确。在执行中遇到EOF 值时,这里的比较就会成功。但是,在这种情况下正常输入的字节0xFF 也会被当作EOF ,从而导致这个循环不正确地结束。所以,无论char 的符号情况如何,你都必须把getchar 的返回值存入一个int ,以便与EOF 做比较。下面是按可移植方式写出的同一循环:
Java 没有unsigned 修饰符。在这里所有的整型都是有符号的,只有16位char
类型是无符号的。
算术或者逻辑移位。在对有符号的量用运算符>>做右移时,这个移位可以是算术的(符号位将在移位的过程中复制传播) ,也可以是逻辑的(移位中空出的位被自动补0) 。同样,根据从C 和C++学到的经验,Java 把>>保留作算术右移,为逻辑右移另外提供了一个>>>。
字节顺序。在类型short 、int 和long 里,字节的顺序并没有规定,具有最低地址的字节可能是最高位的字节,也可能是最低位的字节。这是一种依赖硬件的特性,在本章的后面部分我们将做详细讨论。
结构或类成员的对齐。在结构、类或者联合里,各个成分的对齐方式并没有规定。这里只规定各成分一定按说明的顺序排列。例如,在下面的结构里:
struct
{
char c;
int i;
}
成分i 的地址与结构开始位置的距离可能是2、4或者8个字节。很少有机器允许int 存储在奇数边界上,一般都要求占据n 个字节的基本数据类型存放在n 字节的边界上。例如,double 一般是8个字节长,所以需要存储在8的倍数的地址上。在这之上,写编译程序的人还可能再做进一步调整,例如可能为了执行性能做进一步的强制对齐。
绝不能假定在一个结构里各成员占着连续的存储区。对齐限制实际上会造成结构中的“空洞”,在上面的struct X 里,至少存在一个字节的未用空间。空洞的存在说明了一个结构可能比它成员的大小之和更大一些,在不同的机器上又可能具有不同大小。如果需要分配空间,用以存放上述结构,就必须去申请sizeof (struct X) 个字节,而绝不应该是sizeof(char)+sizeof(int)个。
位域。位域对机器的依赖太强,无论如何都不应该用它。
从上面关于危险特征的长表里可以总结出下面的规则,不要使用副作用,除了在很少的几个惯用结构里,例如:
a[i++] = 0;
c = *p++;
*s++ = *t++;
不要用char 与EOF 做比较。总使用sizeof 计算类型和对象的值。决不右移带符号的值。
你所用的数据类型应该足够大,足够存储你希望放在里面的值。
用多个编译系统试验。人们很容易认为自己已经理解了可移植性,但是编译系统能看出某些你没有看到的问题。进一步说,不同编译程序有时会对你的程序有不同的看法,因此,你应该尽量利用这些帮助。打开编译程序所有的警告开关,在同一机器或者不同机器上试用多种编译系统,试用一个C++编译系统处理你的C 程序。
由于不同编译系统在接受的语言方面可能有差异,所以,即使你的程序能用一个编译系统完成编译,你甚至都无法保证它在语法上是正确的。如果几个编译系统都能接受你的代码,那么你的胜算就大得多。对于这本书里的每个C 程序,我们都在三个互不相干的操作系统(Unix,Plan9和Windows) 上用三个C 编译系统和若干C++编译系统处理过。这是一个清醒的试验,它确实挖出了数十个移植性错误,而这些问题通过人工的大量仔细检查也没有发现。
所有这些错误的更正都是非常简单的。
当然,编译系统本身也会引起可移植性问题,因为它们可能对语言中未加规定的行为做出各自不同的选择处理。即使如此,我们的途径仍然是有希望的。我们应该努力去构造这样的软件,使它们的行为能够独立于具体的系统、环境或者编译之间的差异,而不应该去写那种以某种方式展现这些差异情况的代码。简而言之,我们应该设法回避那些很有可能变动的性质和特征。
2头文件和库
头文件和库提供了许多服务,它们是基本语言的扩充。有关例子包括C 里通过stdio 、C++里通过iostream 、Java 里通过java.io 完成的输入和输出等。严格地说,这些都不是语言的组成部分,但它们又是和语言本身一起定义,并被期望作为支持有关语言的环境的一个组成部分。在另一方面,由于库通常都覆盖了范围
相当广泛的一组操作,常要处理与操作系统有关的问题,因此也就很容易成为不可移植问题的避风港。
使用标准库。在这里,应该提出与核心语言同样的建议:盯紧标准,特别是其中比较成熟的、构造良好的成分。C 语言定义了标准库,其中包括许多函数,它们处理输入输出、字符串操作、字符类检测、存储分配以及另外的许多工作。如果你把与操作系统的交互限制在这些函数的范围内,那么如果要从一个系统搬到另一个,你的代码很有希望还能具有同样的行为方式,执行得很好。不过你也要当心,因为存在许多标准库的实现,其中有些包含了标准里未定义的行为。
例如:ANSI C没有定义串复制函数strdup ,然而许多系统里都提供了它,甚至在那些声明自己完全符合标准的系统里。一个有经验的程序员可能会根据习惯去使用strdup ,完全没意识到它并不在标准之中。而后,当这个系统被移植到某个未提供这个功能的系统上时,程序在编译时就会出问题。这种问题是库引起的移植麻烦中最主要的一类。要解决这类问题,只能靠严格按照标准行事,并要在多种不同环境里测试你的程序。
在头文件或者包定义里声明了标准函数的界面。与头文件有关的一个问题是它们往往非常杂乱,因为它们必须设法在一个文件里同时服侍多种不同的语言。例如,我们常常看到,像stdio.h 这样的头文件需要同时为老的C 语言、ANSIC 和C++编译程序服务。在这种情况下,文件里到处都散布着#if和#ifdef一类的条件编译指示符号。由于语言预处理程序并不很灵活,这些文件常常都非常复杂,有时可能还包含着错误。
这里是从我们的某系统里摘录的一段,它比其他许多类似的东西还好一些,因为它具有很好的格式:
虽然这个例子相对而言是清晰的,它也确实印证了我们前面的说法,像这样的头文件(和程序) 的结构过于复杂,很难进行维护。针对每个编译系统或者环境建立一个独立的头文件,事情可能更容易些。这样就要求维护一组文件,但其中的每一个都是自足的,适应一个特定系统。
这样做也减少了像在严格的ANSI C环境里包括strdup 这一类的错误。 头文件还可能“污染”名字空间,因为它里面的某个函数可能正好与程序里的函数同名。
例如,我们的weprintf 原来被称为wprintf ,但是后来发现,在一些环境里根据新的C 标准在stdio.h 里定义了一个函数,用的也是这个名字。我们只好修改自己函数的名字,以便能在这种系统里完成编译,同时也是为未来做点准备。如果遇到的问题源于一个错误的实现,而不是规范的合法变化,我们就会想办法绕过它,可以采用的方法是在引入头文件时对有关名字重新做定义:
这样做的效果,是把头文件里所有的wprintf 都映射到stdio_wprintf,使它们不会再与我们的函数发生冲突。在此之后,我们就可以用原来的wprintf ,不必再改名了。这种写法有些臃肿,而且还付出了额外代价,与程序连接的库将会调用我们的wprintf 函数,而不是调用原来的那个。对于一个函数而言,这可能
不必特别担心。但是,确实有些系统给出的环境是非常混乱的,我们必须尽可能地保持代码的清晰性。应该用注释说明这个结构到底做了些什么,绝不能再用条件编译把它弄得更糟糕了。
如果发现在有的环境里定义了wprintf ,那么就应该假定所有的环境里都有这种定义。这样,这个修改就是永久性的,你完全不需要再去维护那些#ifdef语句。当然,更简单的方式是绕道而行,而不是去做斗争,这样做也更安全些。这也就是我们做的事,把函数名字改成weprintf 。
即使你总能严格地按规矩办事,环境本身也非常干净,仍然很容易走出限定的范围,例如无意识地假定某些自己喜欢的性质在所有地方都对等等。这方面的例子如,ANSIC 定义了6个信号,函数signal 能够捕捉到它们。而在POSIX 里定义了19个,大部分Unix 系统支持32个或者更多的信号。如果你想要用一个非ANSI 信号,这很明显就牵涉到功能和可移植性之间的权衡问题,你必须决定哪方面对自己更重要。
目前还存在许多其他标准,它们又不是程序语言定义的组成部分。这方面的例子包括操作系统和网络界面、图形界面,以及许多其他类似的东西。这其中有些东西试图跨越多个系统,例如POSIX ;另一些则是为某个特定系统度身打造的,例如各种不同的Microsoft Windows API 。与上面类似的建议也适用于这些方面。如果你选择广泛适用的具有良好构造的标准,如果你能盯住最核心的使用最广泛的特性,你的程序就能更具可移植性。
3程序组织
达到可移植性的方式,最重要的有两种,我们将把它们称为联合的方式和取交集的方式。
联合方式使用各个特殊途径的最佳特征,采用条件式的编译和安装,根据各个具体环境的特殊情况分别进行处理。这样,结果代码是所有方案的一种联合,它可以利用各系统在能力方面的优点。这种方式的缺点包括:安装过程的规模和复杂性,由代码中大量费解的编译条件造成的复杂性等等。
只使用到处都可用的特征。我们建议采用取交集的方式,即:只使用那些在所有目标系统里都存在的特性,绝不使用那些并不是到处都能用的特征。强求使用普遍可用特性也有危险性,这可能限制了目标系统的范围,或者限制了程序的功能。此外,也可能在某些系统里导致性能方面的损失。
为了比较这两种不同方式,我们来看一些使用联合方式的例子,以及采用交集方式对它们重新进行整理的情况。正如你将要看到的,联合方式的代码从设计上看根本就是不可移植的,虽然它们声称可移植性是自己的目标;而交集代码不仅是可移植的,通常也更加简单。
下面是个小例子,这里试图处理环境中因为某些原因而没有标准头文件stdlib.h 的情况:
如果偶然用用的话,这种防御式测试还是可以接受的,但频繁地这样做就很不好了。这里也提出了另一个问题:到底有多少stdlib 函数最后出现在这种形式的或者其他类似形式的条件代码里。如果在程序里用到了malloc 或者realloc ,那么肯定也需要用其他的函数,例如free 。
如果unsigned int的大小与size_t(这是malloc 和realloc 参数的正确类型) 不一样,那么又会出什么问题?进一步说,我们怎么知道STDC_HEADERS或_LIBC确实已经定义了,而且定义正确?怎么保证绝不会有其他名字能在某种环境里启动这里的代换?任何像这样的条件代码都是不完全的、不可移植的,因为总会遇到某个系统不能与这里的条件协调,这时我们就必须重新编辑这些#ifdef。如果能不通过这类条件编译解决问题,我们就能够根除这些在程序维护中最令人头疼的事情。
然而,这个例子力图解决的问题确实是存在的,那么,怎样做才能一劳永逸地解决它呢?我们认为,宁可事先假设标准头文件是存在的。如果确实没有的话,那
就是其他人的问题了。而如果实际情况就是没有,那么更简单的办法是与本软件一起发送一个头文件,在其中定义函数malloc 、realloc 和free ,与ANSIC 定义它们的形式完全相同。在程序里总包含这个文件,而不是在代码中到处打上面这样的绷带。这样,我们就能知道必要的界面总是可用的。
避免条件编译。使用#ifdef和其他类似预处理指示写的条件编译是很难管理的,因为在这种情况下有关信息趋向于散布在整个源文件里。
在这个摘录中,最好是在各定义之后用#endif,而不是在最后堆积一批#endif。但是,实际问题是,无论写程序时的动机如何,这段代码都是高度不可移植的,因为它对每个系统的行为不同,对每个新环境必须再写一个新的#ifdef。用一个串,其中使用一个一般性的词可能更方便,完全是可移植的,而且也提供了同样的信息:
这就不需要任何条件代码,因为它对所有系统都完全一样。
将编译时的控制流(由#ifdef语句确定的) 和运行时的控制流混在一起,会使情况变得更坏,因为这种东西极其难读。
对于那些明显无害的应用,条件编译常常可以用更清晰的形式取代。例如#ifdef常被用来控制排错代码的执行:
用一个带常量条件的正规的条件语句也能把事情做得同样好:
如果DEBUG 的值是0,大部分编译系统对这段程序不会产生任何目标代码,不过它们会检查被排除代码的语法。与此相反,#ifdef里完全可能隐藏着语法错误,而以后一旦把#ifdef打开,有关代码就会阻碍编译的执行。
有时人们用条件编译排除掉一大段代码:
或
在编译时采用有条件地替换文件的方式,可以完全避免这种条件代码。下一节里我们还要回到这个问题。
如果需要修改一个程序去适应某个新环境,你不应该以该程序的一个新副本作为出发点。
相反,你应该设法调整现存的代码。你可能需要对代码的主体做一些修改。如果采用编辑程序副本的方式,慢慢地你就会做出许多发散的版本。对于一个程序,只要可能,应该只存在惟一的一套源文件。如果你发现某些东西需要改变,以便把程序移植到某个特定的环境去,那么请设法找到一种办法,使改造后的东西在所有地方都能用。如果认为需要,也可以修改内部的界面,但应该保持代码里不出现#ifdef。这种做法每次都将使你的代码变得更具可移植性,而不是变得更特殊。应该缩小交集,而不是放宽联合。
我们已经说了许多反对条件编译的话,也展示了由它引起的一些问题。但我们还没有提到这其中最恶劣的问题:这种代码几乎是无法测试的。一个#ifdef实际上把单个的程序变成了两个分别编译的程序,我们很难弄清程序的每个变形是否都已经编译过、测试过。如果在一个#ifdef块里做了修改,那么在其他地方也可能需要做这种修改。要想验证有关的修改,就要求环境条件能够打开对应的#ifdef。虽然在某些其他配置方式下也需要类似修改,这些情况也不可能检测到。此外,如果程序里需要增加一个新#ifdef块,我们很难把这种修改孤立出来,很难确定需要满足哪些额外条件才能到达这个地方,很难确定为解决这个问题还要修改哪些地方。最后,如果代码里确有某些东西,按照条件它们将被忽略,那么编译就根本看不到它。这里完全可能是些乱七八糟的东西,而我们却根本就不知道,直到某个不幸的用户试图在某个环境里编译程序,恰巧触发了有关条件。下面的程序当_MAC有定义时是能够编译的,如果不是这样就会出毛病:
基于上述理由,我们更喜欢只使用那些对所有目标环境都是共同的特性。这样我们就能编译和测试所有代码。如果某些东西产生可移植性问题,我们不是增加条件性代码,而是设法重写代码,设法避免这些问题。沿着这条路走下去,程序的可移植性将逐步增强,其本身也会不断得到改进,而不是变得越来越复杂。
有些大系统在发布时带有一个配置脚本,以便能根据局部环境的情况对代码做一些剪裁。
在编译的时候,这个脚本将检测局部环境的各方面特性—头文件和库的位置,字的字节顺序,各种类型的大小,实现者已知可能崩溃的情况(这虽然出人意料,但却是很常见的) ,如此等等,由此生成一套配置参数或者make 文件,以便对有关情况做出正确配置和设置。这些脚本可能很大、很复杂,是软件发布的重要组成部分,需要不断进行维护,以保证它们能完成任务。有的时候这种技术确实是必须的。但是从另一个角度说,代码的可移植性越强,#ifdef越少,它的配置和安装也就会越简单、越可靠。
练习:研究你的编译系统如何处理括起来放在条件块里面的代码,例如:
在什么情况下它确实做了语法检查?什么时候它产生实际的代码?如果你能用的编译系统不止一个,它们互相比较的情况又如何?
4隔离
我们宁愿能有这样一个源程序,它能在所有系统上编译,不需要做任何修改。不过这常常是不现实的。虽然如此,任由不可移植代码散布在程序的各处仍然是不对的,而这也正是条件编译造成的问题之一。
把系统依赖性局限在独立文件里。如果不同系统需要不同的代码,应该使这种差异局限在独立的文件里,一个文件对应一个系统。例如,文本编辑器Sam 能在Unix 、Windows 和许多其他系统上运行。这些环境的系统界面差别极大,但是Sam 的绝大部分代码在各处都是一样的。
这里对每个特定环境提供一个独立文件,覆盖系统的变化情况。unix.c 提供到Unix 系统的界面代码,而windows.c 用于Windows 环境。这些文件实现了一个到操作系统的可移植界面,掩盖掉它们之间的差别。Sam 实际上是针对它自己的虚拟操作系统写的,这个虚拟操作系统可以移植到各种实际系统上,方法就是写出几百行C 代码,利用可用的系统调用实现十来个小的无法移植的操作。
各种操作系统的图形环境几乎是互不相干的,Sam 处理这个问题的办法就是为自己的图形提供一个可移植库。与直接为某个给定系统修改代码相比,建立这种库要做更多的工作(例如,关联XWindow 系统的界面代码大约有Sam 所有其他部分的一半那么大) ,但是从长远看,累计起来的工作量则要小得多。作为这个工作的副产品,该图形库本身也很有价值,可以单独使用,并已经把几个其他程序弄得可移植了。
Sam 是个很老的程序,今天,各种可移植的图形环境,例如OpenGL 、Tcl/Tk和Java 等已经在许多不同平台上可以使用了,利用这些东西写你的代码,而不是用专有图形库,这将使你的程序具有更强的可用性。
把系统依赖性隐藏在界面后面。抽象是一种强有力的技术,应该通过它划清程序的可移植部分与不可移植部分之间的界限。大部分程序设计语言所附带的I/O库就是一个很好的例子,它们使用可供打开/关闭、读和写的文件概念,从不提及任何物理位置或结构,为二级存储器提供了一种抽象。使用这些界面的程序将能在任
何实现了它们的系统上运行。
Sam 程序的实现是抽象的另一个例子。这里定义了与文件系统和图形操作相关的界面,程序里只使用界面所提供的特征,而界面本身则可以使用下层系统提供的任何功能。对不同系统而言,界面的实现可能差别很大。但是,只使用界面的程序则与这些情况完全无关,当它需要搬到另一处时根本不需要做任何修改。
Java 的可移植途径也是个很好的例子,它也说明了沿着这条路上有可能走多么远。一个Java 程序被翻译为一种“虚拟机器”上的一系列操作。所谓虚拟机就是一个模拟的计算机,它可以在任何现实的计算机上实现。Java 的库提供了一套一致的对下层系统特性进行访问的功能,包括图形、用户界面、网络以及其他类似的东西。库将被映射到局部系统提供的功能上。从理论上说,完全可能在任何地方,无须任何改变地运行同一个Java 程序(甚至是翻译之后的程序) 。
5数据交换
正文数据很容易从一个系统搬到另一个系统去,这是在不同系统间交换任意信息的最简单的方式。
用正文做数据交换。正文容易用各种工具操作,以原来未曾预计到的方式去处理。例如,如果一个程序的输出并不正好适合做另一个程序的输入,我们可以用一个Awk 或Perl 脚本去矫正它;可以用grep 选择或者删除其中的一些行;可以用你最喜欢的编辑器对它做各种更复杂的修改。正文文件很容易做文档,甚至可能再不需要做文档,因为人完全可以直接阅读它们。
正文文件里可以写注释,指明处理这些数据需要什么版本的软件。例如在PostScript 文件里的第一行就说明了它的编码方式:
%!PS-Adobe-2.0
与此相反,处理二进制文件就需要有专门的工具,甚至在同一台机器上,这些工具常常也不能一起使用。存在许多使用广泛的工具,其作用就是把任意的二进制数据转换成正文,以便能以最不容易出毛病的形式发送出去。这其中包括Macintosh 系统里的binhex ,Unix 系统的uuencode 和uudecode ,以及各种各样的为在
电子邮件里传递二进制数据而使用的MIME 编码工具。在第9章,我们还要给出一组包装和解包例程,它们能用于对二进制数据进行编码,使其能够可移植地进行传递。存在这么多工具,这个情况就足以说明二进制格式存在某些问题。
正文数据交换中也存在一个不和谐音:PC 系统使用一个回车符‘\r’和一个换行符‘\n’来结束一个行,而在Unix 系统里只用一个换行符。回车是一种称为电传打字机的古老设备的产物,这种设备需要用一个回车(CR)操作使打字部件回到行的开始,再用另一个独立的换行操作(LF)将它推进到一个新行。
虽然今天的计算机已经根本没有什么车可以回了,大部分PC 软件仍然期望在每行的最后有这种组合(习惯上称为CR LF,读为“curliff ”) 。如果没有回车符,一个文件就可能被当作一个极长的行,行和字符计数都可能出错,或发生不能预期的更改。有些软件能很得体地适应这些情况,但大部分软件都不行。PC 并不是仅有的罪犯,由于一系列兼容性方面的考虑,某些现代的网络标准,例如HTTP ,也用CR LF作为行限界符号。
我们建议只使用标准化的界面,这将在任何给定系统上一致地建立CRLF ,无论是(在PC 上) 输入时去掉\r,到输出时再把它加回去;还是(在Unix 上) 什么也不做。对那些必须从这边搬到那边的文件,就需要使用能在两种格式间完成转换的程序。
练习:写一个程序从文件中去掉荒谬的回车。写第二个程序把它们加进去,方法是将每个换行符替换为一个回车和一个换行。你将如何测试这些程序?
6.字节顺序和结构大小
虽然存在着上面讨论里提出的各种缺点,二进制数据有时仍然是必需的,因为它更紧凑,解码也更迅速。在计算机网络领域,二进制形式是许多工作的基础,紧凑和速度都是最根本的原因。但是,二进制数据确实存在许多移植性问题。
至少有一个情况可以确定:所有现代机器都使用8位字节。但是,不同机器在如何表示大于一个字节的对象时就有许多不同方式,特别依赖某种特定方式就是一个错误。一个短整数(通常是16位,含两个字节) 的低位字节可能存储在比其高位字节低的存储地址(低尾端方式) ,或者存在较高的存储地址(高尾端方式) 。这方面
的决定确实带有随意性,有的机器甚至同时支持两种方式。
这样一来,虽然对采用高尾端或低尾端方式的机器而言,存储器都被看成是某种顺序下的字序列,它们对一个字里各字节的解释却采用了相反的顺序。在下面的图中,从地址0开始的4个字节表示某个十六进制整数。对高尾端机器而言,它是0x11223344,而对低尾端机器就是0x44332211。
要想知道实际机器中的字节顺序问题,可以看下面的程序:
在32位的高尾端机器上,其输出是:
11 22 33 44
在低尾端机器上,输出就会是:
44 33 22 11
但是,在PDP-11机器(这是某个时期的一种卓越的机器,现在还可以在许多嵌入式系统中看到) 上是:
22 11 44 33
在具有64位long 类型的机器上,只要改变常数的大小,就可以看到类似现象。 这看起来像是一个无聊的问题,但是,如果我们需要把整数通过具有一个字节宽度的界面(例如通过网络连接) 传输时,就必须选定首先传输哪个字节,而这个选择必然要牵涉到低尾端/高尾端的问题。换句话说,该程序应该以明显方式做某些事,而这些事在:
fwrite( &x, sizeof(x), 1, fp );
中是以隐含方式处理的。如果在一台计算机里写出一个int (或者short 、long ) ,而要另一台计算机读它,这将是件很不安全的事情。
例如,假设源计算机用下面方式写
unsigned short x;
fwrite( &x, sizeof(x), 1, fp );
而接收的计算机用下面方式读
unsigned short x;
fread( &x, sizeof(x), 1, fp );
如果两台机器具有不同的字节顺序,x 的值将无法保持原样。如果x 在开始时是0x1000,它在到达时可能就是0x0010。
人们通常用条件编译和字节交换来解决这种问题,写的东西大概是:
当存在许多二字节和四字节整数需要交换时,这种方式就显得非常笨拙。在实践中,在把这些字节从一个地方传到另一个地方的过程中,可能需要多次完成这类交换。
对short 而言,事情已经很不妙了,对于更长的数据类型,情况当然会更糟糕,因为这时存在着更多的排列其中字节的方式。如果再加上结构成员之间的大小可能不同的填充、对齐方面的限制,以及各种老式机器千奇百怪的字节顺序,问题
看起来很难对付。
数据交换时用固定的字节序。这里提出一种解决办法:写可移植的代码,总按照正规的顺序写出各个字节:
一次一个字节地读回,把数据重装起来:
这个方法也可以推广到结构,你只要把结构成员按照一种规定顺序写出去,一次一个字节,丢掉填充。无论你采用什么字节顺序,所有事情都将能够一致地完成。这里惟一的要求是:对于传送中所采用的字节顺序和各种对象的大小,发送方和接收方都应该有同样的认识。
如果你在C 或C++里工作,那么你就得自己做这件事。采用一次处理一个字节的方式,最关键的收获就是既不需要使用#ifdef,又能对所有8位字节的机器解决问题。我们将在下一章里继续这方面的讨论。
当然,解决问题的最好办法常常还是把信息转换到正文形式,这种形式(除了CRLF 问题外) 完全是可移植的,这里根本不存在表达的歧义性问题。当然,这并不一定就是正确的解,因为时间和空间都是极其重要的。有些数据,特别是浮点数据,在传过printf 和scanf 时有可能丢失精度,主要是由于截断问题。如果你必须完全精确地交换浮点数据,那么就必须弄清你确有很好的格式化I/O库。这种库是存在的,不过它可能不是你工作环境的一部分。
想要以可移植的方式用二进制形式表示浮点数据非常困难,另一方面,如果你足够细心,通过正文可以做好这件事。
在使用标准库函数处理二进制文件时,还有一个很微妙的可移植问题—必须以二进制方式打开文件:
如果忽略了这里的‘b ’,在所有Unix 系统上都不会出任何问题。但是在Windows 系统里,程序输入中遇到的第一个control-Z 字节(八进制的032,十六进制的1A ) 将结束有关的读入动作。在另一方面,如果按二进制模式读入正文文件,会导致\r字节被留在了输入里面,而在输出时又不会自动地产生它。
7可移植性和升级
可移植性问题中还有一个最让人头疼的因素,那就是,系统软件总是在随着时间推移而不断变化,这种变化有可能发生在系统的任何层面上,导致程序的现存版本之间无缘无故地发生互不相容的情况。
如果改变规范就应改变名字。我们最喜欢(如果可以用这个词的话) 用的例子是Unix 系统中echo 命令的性质变化。在开始时,它的设计仅仅是想做对参数的回应:
但是,到了后来,echo 变成了许多shell 脚本里的关键部分,希望产生格式化输出的需求变得越来越强烈,所以echo 被改造为解释它的参数,从某种意义上看更像printf 了:
这种新特性确实很有用,但它也使许多shell 脚本产生了移植性问题,因为它们可能就依赖于echo 命令除了回应之外什么也不做。而
%echo $PATH
的行为方式则依赖于现在所用的echo 是什么版本的。如果不巧在变量值里包
含了一个反斜线符号(在DOS 或Windows 系统里很容易遇到这种情况) ,e cho 可能对它做出某种特殊解释。这个问题类似于用printf(str)或printf("%s",str)产生输出时的差别。请看看,如果在str 里正好有一个百分号,会发生什么事情。
我们说的还仅仅是echo 故事中的一部分,但是它已经阐明了根本的问题:对系统的修改可能产生不同版本的软件,有时我们有意地使它们在行为方面有些变化,但同时也无意地造成了许多移植性问题,而这些问题常常是很难绕过去的。如果给echo 的新版本另起一个不同的名字,造成的麻烦可能要少得多。
现在再看一个更具启发性的例子。考虑Unix 命令sum ,它打印出一个文件的大小和它的检验和。下面一串命令是想验证一次信息传递是否完全成功:
由于传递之后的检验和相同,我们可以合理地推断,新副本和老文件是一样的。 而后系统被改进了,版本发生了变化,有人发现检验和的算法并不完美,因此就修改了sum ,使用了更好的算法。另外的什么人也做了同样研究,给出sum 的另一个更好的算法。这样发展下来,到了今天,实际中已经存在着许多不同版本的sum ,每个版本给出的回答都不同。我们把一个文件复制到附近另一台机器上,想看看sum 算出的是什么:
是文件破坏了吗?还是仅仅因为我们恰好用到不同版本的sum ?或许两者都是?
这样的sum 就形成了一个完美的可移植性灾难:一个程序,其目的是为了帮助人们在从一台机器到另一台复制软件时做检查。但是,由于存在许多互不兼容的版本,从原来的意图看,它已经变成毫无意义的东西了。
对于原本要应付的简单工作而言,最早的sum 是很好的,其低技术的检验和算法也是适宜的。虽然“修改”它有可能造就出一个更好的程序,但从中得到的并不多,至少完全不足以抵消这种不相容的代价。问题不在于性能提升,而在于互不相容的程序又偏偏用了同样名字。这种变化带来的版本问题还会折磨我们许多年。
维护现存程序与数据的相容性。当一个软件(例如一个字处理系统) 的新版本发布时,新版本通常都能读入由较早版本产生的文件。我们也可以断定,由于增加了新的原来没有的特征,文件格式也可能有变化。但是,新版本常常没有提供一种方式,使之能写出原来格式的文件。
这样,新版本的用户,即使他们没有使用新版本中的任何新特征,也无法与那些使用旧版本的人们共享文件。这将迫使每个人都去升级。无论是作为一个工程观点,还是作为一种市场策略,这种设计都是特别令人遗憾的。
向后兼容是使程序符合其过去规范的一种能力。如果你打算修改一个程序,那就应该保证你没有破坏老的程序和依赖于它的数据。应该正确地修改文档,提供一
些办法去恢复原来的行为方式。最重要的是,应该仔细考虑你计划做的改变是不是真正的改进,与你将引进的不可移植性的代价相比,是不是真正值得去做。
8小结
可移植代码是一个非常值得去追求的理想,因为有如此多的时间被浪费在修改程序方面,无论是把程序从一个系统移到另一个系统,还是为了它本身演化的需要,或是因为它运行的系统发生了变化,在这些情况下需要设法维持程序的继续运行。当然,可移植性不是随便就能得到的,它要求实现中的特别注意,也需要开发者具有对所有的潜在目标系统在可移植问题方面的知识。
我们已经指出了追求可移植性的两种途径,即联合和交集。联合途径相当于为在每个目标系统上工作而写一个版本,利用条件编译一类的机制,把这些代码尽可能地汇集在一起。
这种途径的缺点很多,它造成过多的代码,而且常常是很多非常复杂的代码。它很难更新,也很难测试。
交集途径是设法以一种形式写出尽量多的代码,使它能在每种系统上运行而不需要做任何修改。把无法逃避的系统依赖性封装在独立的源文件里,其作用就像是程序与基础系统之间的界面。交集方法也有缺点,包括可能存在性能方面,甚至特征方面的损失。但是从长远的观点看,这种途径的利大于弊。
下面是几个重要的移植问题:
● 数据类型(符号和类型大小)
● 数据交换(结构大小、字节顺序)
● 依赖系统的API