如何编写易于移植的C++ 程序

来源:互联网 发布:网络教育毕业个人评定 编辑:程序博客网 时间:2024/04/19 14:09

由于操作系统的差异,同一种操作系统本身版本的差异,目前C++标准库提供的功能仍然有限以及C++编译器产品不是完全兼容等问题,使得我们在移植大型应用程序的时候往往会出现很多难以解决的问题,如何合理的避免他们提高C++程序的移植性,本文作者从源代码的组织安排等方面提出了一些实用的建议。

 

当我们编写服务器端的软件产品时,我们往往需要为同一个软件产品推出多种不同平台版本。这是因为目前还没有哪个服务器操作系统可以一统天下。有不少服务器运行Windows 操作系统,但运行Linux和各种UNIX操作系统的服务器也很多,而且各种UNIX操作系统之间又有细微的差别。另外,在一些大企业(特别是大银行)中,运行关键业务的服务器往往是IBM 的大型机,它们的操作系统又会和一般的UNIX 有一些不同。

此外,软件依赖的中间件,调用的函数库,要求的编译器,都可看作平台的一部分。上述内容的任意组合会造成大量的可能性。如果平台移植性做得不好,那么很可能软件在你的开发环境能正常运行,但拿到客户的环境中会出现各种奇奇怪怪的问题。

或许你会说,这些都不是问题,用Java来写程序不就一切OK了?不幸的是,有时候一些遗产代码是用C写的,或者你必须依赖的某个关键函数库只提供了C API,经过评估又发现用Java重写,或者通过JNI以及其他可能的跨语言调用机制去封装这些遗产代码或者CAPI的工作量太大。那么这时候C++往往是更合适的选择。

用Java写程序可以跨平台的一大原因是Java有一个无所不包的标准库,而C++的标准库只提供了最基本的一些功能。要用C++写比较大的程序几乎一定会调用到标准库之外的API,而这些API未必可以跨平台。所以,编写易于移植的C++程序要注意的第一点是:如果能有选择,那么尽可能地使用跨平台的API。

比如,同样是对文件操作,Win 32 API 和UNIX操作系统提供的文件操作函数各不相同,选哪个呢?都不合适,最好还是依赖标准库,fstream或者fopen/fclose都可以。要创建线程并进行线程间同步,Win 32 API 和UNIX的做法又不一样。有没有跨平台的解决方案呢?有的,pthreads是跨平台的。如果你的系统需要有对字符串进行操作,是用MFC提供的CString还是标准库中的string呢?显然应该选后者,因为MFC 不是跨平台的。

那么,如果你不得不用到的某些API 没有跨平台的实现,只有各个平台自己的实现,怎么办呢?举个例子,在Windows平台,加载动态库是调用LoadLibrary;在UNIX平台,加载动态库是调用dlopen。似乎没有什么跨平台的实现。那么我们怎么办?可不可以在每处要加载动态库的地方都这么写?

#ifdef WIN32
HMODULE h = LoadLibrary(“libraryname”);
#elif defined(UNIX)
int h = dlopen(“libraryname”, RTLD_LAZY);
#endif

不少软件就是这么做的。但这样做很糟糕,因为把平台相关代码同其他的平台独立代码混在了一起,而且代码中会散布很多的#ifdef,影响阅读;而且如果稍后需要把代码移植到另一个平台,那么可能需要修改每一处加载动态库的地方,增加一个#elif defined(?),工作量会比较大。

推荐的做法是:自己封装一个跨平台的实现,在平台独立代码中只调用这个跨平台的API,把平台相关性隔离出去。当然,这层封装应该是很薄的,应该只需要用一两行的inline 函数以及几个typedef 即可。这样做的指导思想是,通过封装来增加间接层次,从而把平台独立代码和平台相关代码分离。

下面来看一下,这样做是不是可以了呢?

在main.cpp 中(假设我们需要在这个文件中加载动态库)这样写:

#include “platform_specific.hpp”

int main() {

handle_t h = MyLoadLibrary("libraryname");

// 之后使用动态库,然后卸载

}

 

在platform_specific.hpp 中这样写:

#ifdef WIN32

typedef HMODULE /* WIN32 handle type */ handle_t;

inline handle_t MyLoadLibrary(const string& libname)

{

return LoadLibrary(libname.c_str());

}

#elif defined(UNIX)
typedef int /* UNIX handle type */ handle_t;
inline handle_t MyLoadLibrary(const string& libname)
{
return dlopen(libname.c_str(), RTLD_LAZY)
}
#endif

这样确实做到了“把平台独立代码和平台相关代码分离”,main.cpp中是平台独立代码,platform_specific.hpp中是平台相关代码,两者分离开来了。移植到新的平台时不需要对main.cpp做任何修改,只需要修改platform_specific.hpp中的MyLoadLibrary 的实现,而且只要改这一处就可以了。但这样做的一个问题是,platform_specific.hpp会变得非常混乱,充满了#ifdef。想象一下,除了MyOpenLibrary,可能还会有MyCloseLibrary,MyBindSymbol,等等,所有自己封装的跨平台API(也就是实现中需要写#ifdef(某种OS)的API)都在里面了。这个文件会变得难以维护,而且很可能是多个人在维护(每个人负责一个不同的平台),修改会非常频繁(特别是如果几个平台的版本同步开发的话)。有没有更好的做法呢?

不妨这样做:在platform_specific.hpp中,只放这些内容:

#ifdef WIN32

#include “win32_specific.hpp”

#endif

#ifdef UNIX

#include “unix_spefic.hpp”

#endif

 

而把平台相关的实现部分放在各个平台自己的头文件中去。比如,win32_speific.hpp 是这样的:

typedef HMODULE /* WIN32 handle type */ handle_t;

inline handle_t MyLoadLibrary(const string& libname)

{

return LoadLibrary(libname.c_str());

}

在unix_specific.hpp 是这样的:

typedef int /* UNIX handle type */ handle_t;

inline handle_t MyLoadLibrary(const string& libname)
{
return dlopen(libname.c_str(), RTLD_LAZY)\
}


这样就极大地减少了# i f d e f 的数目。除了在platform_specific.hpp中会出现#ifdef(需要支持几个平台就有几个),其他所有文件中都不再需要。而且也分离了关注焦点:负责实现平台独立功能的人就专注于编写和维护main.cpp,而负责移植到各个平台的人就编写和维护各自平台的os_specific.hpp。不会造成多人修改同一个文件的冲突,平台独立代码和平台相关代码也得到了很好的分离。

有两点值得注意:
第一点,platform_specific.hpp中没有用到#elif,而是用了独立的#ifdef #endif 块。这样做的目的是为了支持下面这样的拓扑结构:

#ifdef WIN32

#include “win32_specific.hpp”

#endif

#ifdef WINCE

#include “wince_specific.hpp”

#endif

#ifdef UNIX

#include “unix_spefic.hpp”

#endif

#ifdef SOLARIS

#include “solaris_specific.hpp”

#endif

#ifdef AIX

#include “aix_specific.hpp”

#endif

 

WIN32和WINCE不冲突,WINCE是特殊的WIN32;Solaris和AIX是两种特殊的UNIX,和UNIX也不冲突。如果用了#elif就无法同时#include,但用上面这种拓扑结构就可以做到,而且可以把各个UNIX平台都一样的东西实现在unix_specific.hpp中,而把Solaris和AIX有差异的东西实现在solaris_specific.hpp和aix_specific.hpp 中,实现进一步的平台细分。

第二点,win32_specific.hpp、unix_specific.hpp等只能用来封装平台相关的API,不能包含过多的平台独立逻辑。

下面举一个反例:

在unix_specific.hpp 中:

int main()

{

// 做平台无关的事情

int h = dlopen(“library”, RTLD_LAZY);

// 继续做平台无关的事情

}

在win32_specific.hpp 中:

int main()

{

// 做平台无关的事情

HMODULE h = LoadLibrary(“library”);

// 继续做平台无关的事情

}

 

这样做是很不好的。有一部分平台无关代码会被拷贝粘贴,重复出现在了两个地方。拷贝粘贴是编程之大忌。所以一定要注意,那些封装函数只能是很简单的只有一两行的inline 函数,而且不能出现平台独立的代码。

采用这种源文件拓扑结构,可以极大地提高软件的可移植性,而且给编写第一个平台版本带来的麻烦也不大。如果你的开发策略是各个平台同步开发,那么这样做可以让各个平台以及跨平台模块的开发者毫不冲突地工作于不同的源代码文件;如果你的开发策略是先全力发布一个平台的版本,然后移植到另一个平台,那么用这样的源代码结构同样可以给你带来极大的好处:假设第一个版本是Windows 的,稍候发布Linux 版本,那么一开始只有main.

cpp(在这里代表所有的平台独立代码)和win32_specific.hpp。移植的时候只要照着win32_specific.hpp的实现,编写一个linux_specific.hpp 即可。

维护起来也很省心,以后出升级版本或者出patch/servicepack,都只需要在一棵代码树上工作,而没有很多合并修改分支的烦恼。而且还有一个好处是,如果一个bug只在某个平台出现而在其他平台没有,那么找bug基本上只要在那个平台对应的os_specific.hpp中看即可,这是分离关注焦点带来的好处。

正如我前面说过的,平台除了指操作系统,也可以指更广泛的概念,比如中间件或者你依赖的某个第三方库。只要你对平台的依赖是局部性的,而非全局性(比如对框架的依赖),那么这种方法都可适用。我在这里选择了用#ifdef和#include配合来选择性地包含和编译平台相关代码。这是通用性最好也最省事的做法,C和C++都支持,所有平台上的编译器都支持。当然,还有其他的办法,比如配合使用namespace 定义、using namespace导入语句、模板的实例化(把操作系统类型作为一个模板参数),也能做到。对预编译器和#号深恶痛绝

的朋友不妨可以试试。

这样的文件结构也可以用于makefile。编译时用make -e OS=YOURTARGETOS [其他参数]来选择性地为某个平台进行构建。其中makefile 应包含这样的内容:

include $(ROOT)/buildenv/default.inc #平台独立的构建信息

include $(ROOT)/buildenv/$(OS).inc #平台相关的构建信息,比如不同平台

#上不同编译器的参数定义

 

因为包含次序在后的宏定义可以覆盖前面的,所以default.inc中还可以为各平台的编译器提供缺省值(比如把编译器缺省定义成cc,有的平台可以覆盖成gcc或者xlC等等;优化参数在default.inc中缺省定义成-O3,在支持更高优化程度的平台.inc中覆盖成-O5,诸如此类)。宏除了覆盖的话,也可以连接。关于makefile的写法在此限于篇幅就不详述了。事实上还有自动工具(autoconf、autoheader、automake 等)同GNU make配套,可以生成平台相关的文件并进行平台相关构建(具体用法可以通过Google查找文档),但我觉得很多情况下杀鸡不需要用牛刀除了整体结构,还有很多细节需要注意。比如文件路径分隔符“/”和“\”的不同(boost::path很好地封装了这个不同),这个操作系统的文件系统是否区分大小写,Big Endian和Little Endian的区分,不同平台上字长的不同,以及不同平台/编译器的缺省对齐方式的不同,等等。另外,要注意一些C++ 编译器提供的API 其实扩展了ANSI或者ISO 的标准,比如SGI STL 中的hash_map、hash_set 和rope,还有某些C库提供的snprintf之类函数,这些API 其实不是跨平台的,应避免使用(比如S/390 上的C 库就不带snprintf 函数,绝大部分STL实现都没有hash_map、hash_set 和rope)。不过如果你觉得使用它们会带来很大方便,也可以用,只是你不得不在不支持这些API的平台的os_specific.hpp 中自己实现snprintf 或者hash_map、rope等等。篇幅所限,这些细节就不展开说了。

 

最后,必须提到,软件应尽可能地具有良好的逻辑和物理设计,这一点非常重要。移植到一个不同的平台,本质上是对软件做修改。设计得越好的软件修改起来越容易。糟糕的设计会导致软件逻辑不清、代码都纠缠在一起,做一点点改动都会牵一发而动全身。这样的软件是很难移植的。而设计得好的软件,对局部做改动不会影响到其余部分,而且一个改动只需要做一次,不需要做全局的查找且替换还担心遗漏一处就造成bug,这样的软件移植起来会很省心。


----------------------------------------------------------------------------------------
编写可移植C/C++程序的要点

1.分层设计,隔离平台相关的代码。就像可测试性一样,可移植性也要从设计抓起。一般来说,最上层和最下层都不具有良好的可移植性。最上层是GUI,大多数GUI都不是跨平台的,如Win32 SDK和MFC。最下层是操作系统A ...

1.分层设计,隔离平台相关的代码。就像可测试性一样,可移植性也要从设计抓起。一般来说,最上层和最下层都不具有良好的可移植性。最上层是GUI,大多数GUI都不是跨平台的,如Win32 SDK和MFC。最下层是操作系统API,大多部分操作系统API都是专用的。

  如果这两层的代码散布在整个软件中,那么这个软件的可植性将非常的差,这是不言自明的。那么如何避免这种情况呢?当然是分层设计了:

  最底层采用Adapter模式,把不同操作系统的API封装成一套统一的接口。至于封装成类还是封装成函数,要看你采用的C还是C++写的程序了。这看起来很简单,其实不尽然(看完整篇文章后你会明白的),它将耗去你大量的时间去编写代码,去测试它们。采用现存的程序库,是明智的做法,有很多这样的库,比如,C库有glib(GNOME的基础类),C++库有ACE(ADAPTIVE CommunicationEnvironment)等等,在开发第一个平台时就采用这些库,可以大大减少移植的工作量。

  最上层采用MVC模型,分离界面表现与内部逻辑代码。把大部分代码放到内部逻辑里面,界面仅仅是显示和接收输入,即使要换一套GUI,工作量也不大。这同时也是提高可测试性的手段之一,当然还有其它一些附加好处。所以即使你采用QT或者GTK+等跨平台的GUI设计软件界面,分离界面表现与内部逻辑也是非常有用的。

  若做到了以上两点,程序的可移植性基本上有保障了,其它的只是技术细节问题。

  2.事先熟悉各目标平台,合理抽象底层功能。这一点是建立在分层设计之上的,大多数底层函数,像线程、同步机制和IPC机制等等,不同平台提供的函数,几乎是一一对应的,封装这些函数很简单,实现Adapter的工作几乎只是体力活。然而,对于一些比较特殊的应用,如图形组件本身,就拿GTK+来说吧,基于XWindow的功能和基于Win32的功能,两者差巨大,除了窗口、事件等基本概念外,几乎没有什么相同的,如果不事先了解各个平台的特性,在设计时就精心考虑的话,抽象出来的抽口在另外一个平台几乎无法实现。

  3.尽量使用标准C/C++函数。大多数平台都会实现POSIX(Portable Operating SystemInterface)规定的函数,但这些函数较原生(Native)函数来说,性能上的表现可能较次一些,用起来也不如原生函数方便。但是,最好不要贪图这种便宜而使用原生函数函数,否则搬起的石头最终会轧到自己的脚。比如,文件操作就用fopen之类的函数,而不要用CreateFile之类的函数等。

  4.尽量不要使用C/C++新标准里出现的特性。并不是所有的编译器都支持这些特性,像VC就不支持C99里面要求的可变参数的宏,VC对一些模板特性的支持也不全面。为了安全起见,这方面不要太激进了。

  5.尽量不要使用C/C++标准里没有明确规定的特性。比如你有多个动态库,每个动态库都有全局对象,而且这些全局对象的构造还有依赖关系,那你迟早会遇到麻烦的,这些全局对象构造的先后顺序在标准里是没有规定的。在一个平台上运行正确,在另外一个平台上可能莫明其妙的死机,最终还是要对程序作大量修改。


6.尽量不要使用准标准函数。有些函数大多数平台上都有,它们使用得太广泛了,以至于大家都把它们当成标准了,比如atoi(把字符串转换成整数)、strdup(克隆字符串)、alloca(在栈分配自动内存)等等。不怕一万,就怕万一,除非明白你在做什么,否则还是别碰它们为好。

  7.注意标准函数的细节。也许你不相信,即使是标准函数,抛开内部实现不论,就其外在表现的差异也有时令人惊讶。这里略举几个例子:

  int accept(int s, struct sockaddr *addr, socklen_t *addrlen);addr/addrlen本来是输出参数,如果是C++程序员,不管怎么样,你已经习惯于初始化所有的变量,不会有问题。如果是C程序员,就难说了,若没有初始化它们,程序可能莫名其妙的crash,而你做梦也怀疑不到它头它。这在Win32下没问题,在Linux下才会出现。

  int snprintf(char *str, size_t size, const char *format, ……);第二个参数size,在Win32下不包括空字符在内,在Linux下包括空字符,这一个字符的差异,也可能让你耗上几个小时。

  int stat(const char *file_name, struct stat*buf);这个函数本身没有问题,问题出在结构stat上,st_ctime在Win32下代表创建(create)时间,在Linux下代表最后修改(change)时间。

  FILE *fopen(const char *path, const char *mode);在读取二进制文件,没有什么问题。在读取文本文件可要小心,Win32下自动预处理,读出来的内容与文件实际都长度不一样,在Linux则没有问题。

  8.小心数据标准数据类型。不少人已经吃过int类型由16位转变成32位带来的苦头,这已经是陈年往事了,这里且不谈。你可知道char在有的系统上是有符号的,在有的系统是无符号的吗?你可知道wchar_t在Win32下是16位的,在Linux下是32位的吗?你可知道有符号的1bit的位域,取值是0和-1而不是0和1吗?这些貌合神离的东东,端的是神出鬼没,一不小心着了它的道。

  9.最好不要使用平台独有的特性。比如Win32下DLL可以提供一个DllMain函数,在特定的时间,操作系统的Loader会自动调用这个函数。这类功能很好用,但最好不要用,目标平台可不能保证有这种功能。

  10.最好不要使用编译器特有的特性。现代的编译器都做很人性化,考虑得很周到,一些功能用起非常方便。像在VC里,你要实现线程局部存储,你都不调用TlsGetValue /Tls TlsSetValue之类的函数,在变量前加一个__declspec( thread)就行了,然而尽管在pthread里有类似的功能,却不能按这种方式实现,所以无法移植到Linux下。同样gcc也有很多扩展,是在VC或者其它编译器里所没有的。

  11.注意平台的特性。比如:

  在Win32下的DLL里面,除非明确指明为export的函数外,其它函数对外都是不可见的。而在Linux下,所有的非static的全局变量和函数,对外全部是可见的。这要特别小心,同名函数引起的问题,让你查上两天也不为过。

  目录分隔符,在Win32下用‘\\’,在Linux下用‘/’。

  文本文件换行符,在Win32下用‘\r\n’,在Linux下用‘\n’,在MacOS下用‘\r’。

  字节顺序(大端/小端),不同硬件平台的字节顺序可能不一样。

  字节对齐,在有的平台(如x86)上,字节不对齐,无非速度慢一点,而有的平台(如arm)上,它完全用错误的方式去读取数据,而且不会给你一点提示。若出问题,可能让你一点头绪都没有。

  12.最好清楚不同平台的资源限制。想必你还记得DOS下同时打开的文件个数限制在几十个的情形吧,如今操作系统的功能已经强大多了,但是并非没有限制。比如Linux下的共享内存默认的最大值是4M。若你对目标平台常见的资源限制了然于胸,可能有很大的帮助,一些问题很容易定位


原创粉丝点击