C++的可移植性和跨平台开发

Posted 计算机科学的艺术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++的可移植性和跨平台开发相关的知识,希望对你有一定的参考价值。



今天聊聊 C++ 的可移植性问题。如果你平时使用 C++ 进行开发,并且你对 C++ 的可移植性问题不是非常清楚,那么建议你看看这个文章。即使你目前没有跨平台开发的需要,了解可移植性方面的知识对你还是很有帮助的。

  C++ 的可移植性这个话题很大,包括了编译器、操作系统、硬件体系等很多方面,每一个方面都有很多内容。鉴于本人能力、精力都有限,只能介绍每一个方面最容易碰到的问题,供大家参考。

  下面我会分别从编译器、C++ 语法、操作系统、第三方库、辅助工具、开发流程等方面进行介绍。





编译器



在跨平台的开发过程中,很多问题都和编译器有关。因此我们先来聊聊编译器相关的问题。




★编译器的选择


  首先,GCC 是优先要考虑支持的,因为几乎所有操作系统平台都有 GCC 的实现。它基本上成了一个通用的编译器了。如果你的代码在 A 平台的 GCC 能够编译通过,之后拿到 B 平台用类似版本的 GCC 编译,一般也不会有太大问题。因此 GCC 是肯定要考虑支持的。

  其次,要考虑是否支持本地编译器。所谓本地编译器就是操作系统厂商自产的编译器。


  举例来说:

相对于 Windows 的本地编译器就是 Visual C++

相对于 Solaris 的本地编译器就是 SUN 的 CC

  如果你对性能比较敏感或者想用到某些本地编译器的高级功能,可能就得考虑在支持 GCC 的同时也支持本地编译器。




★编译警告


  编译器是程序员的朋友,很多潜在的问题(包括可移植性),编译器都是可以发现并给出警告的。如果你平时注意这些警告信息,可以减少很多麻烦。

  因此我强烈建议:

1. 把编译器的警告级别调高;

2. 不要轻易忽略编译器的警告信息。




★交叉编译器


  通俗地说,就是在 A 平台上编译出运行在 B 平台上的二进制程序。假设你要开发的应用是运行在 Solaris上,但是你手头没有能够运行 Solaris 的 SPARC 机器,这时候交叉编译器就可以派上用场了。一般情况下都使用 GCC 来制作一个交叉编译器。






语法


  目前还有相当一部分开发人员在使用老式编译器干活,这些老式编译器可能对C++98支持不够。因此,当你的代码移植到这些老式的编译器上时,可能会碰到一些稀奇古怪的问题(包括编译出错和运行时错误)。下面这些注意事项有助于你绕过这些问题。


强调一下,后面提到的好几个条款都是通过回避C++的新语法来保证移植性。如果你用的是新式编译器,那么你可以不理会这些条款。



★小心 for 循环变量的作用域(不支持新标准)


  在 C++ 98 标准中,for 循环变量的作用域局限在循环体内。但某些老的编译器(例如Visual C++ 6)认为 for 循环变量的作用域在循环体外。所以如下的代码可能导致移植问题。

 for (int i = 0; i < XX; i++)  { // ... }  for (int i = 0; i < XXX; i++)  { // ... } 

  

建议修改为不同的循环变量名,如下所示:

  for (int i = 0; i < XX; i++)  { // ... }  for (int j = 0; j < XXX; j++)  { // ... } 


★不要使用全局类对象,改用单键(标准未定义)


  全局类对象的构造函数先于main() 函数执行,如果某个模块中同时包含若干个全局类对象,则它们的构造函数的调用顺序是不确定的。而单键是在第一次调用时被初始化,能避免此问题。另外,单键虽然解决了构造问题,但是析构依然有隐患。



★保持 inline 函数尽量简单


  不要在inline 函数内部使用局部静态变量,不要在 inline 函数使用可变参数。

  因为这些做法有可能导致可移植性的问题。



★不要依赖函数参数的求值顺序(标准未定义)


  C++ 标准没有明确规定函数参数的求值顺序。因此,如下的代码行为是不确定的。

void Foo (int a, int b); int n = 1; foo (++n, ++n);



★慎用模板特化(不支持新标准)


  某些老式编译器对“模板偏特化”或“模板全特化”支持不够。

  举例:VC6 不支持“模板偏特化”。



★模板继承中,引用基类成员要小心(不支持新标准)


为了直观,给出如下例子:

template <typename T> 
class TBase   protected:  typedef std::vector<T> Container;  Container m_container; }; 
template <typename T> 
class TDerived : public TBase<T>  typedef TBase<T> BaseClass;  public:  void Func()  typename BaseClass::Container foo; // 可移植       Container foo; // 不可移植       this->m_container.clear(); // 可移植       m_container.clear(); // 不可移植     } }; 



★慎用 RTTI(不支持新标准、标准未定义)


  (先声明一下,这里说的 RTTI 主要是指  操作符和type_info 类型)

  首先,由于某些老式编译器可能不支持 typeid 操作符和type_info 类型,会导致移植性的问题,这是慎用 RTTI 的一个原因。(如果你用的是新式编译器,不用考虑这个因素)

  其次,由于标准对于 type_info 类型的约束比较简单。这导致了不同的编译器对 type_info 的实现有较大差异。如果你确实要使用 type_info 类型,建议仅仅使用它的  operator== 和 operator!= 这两个成员函数(只有这两个函数是明确定义的)

  所以,如果你确实需要在运行时确定类型,又不想碰到上述问题,可以考虑在自己的类体系中加入类型信息来实现。例如:MFC 和 wxWidgets 都是这么干的。



★慎用嵌套类(不支持新标准)


  如果在内部类访问外部类的非公有成员,要把内部类声明为外部类的friend。


  如下代码存在移植问题。

class COuter  private:   char* m_name;  public:   class CInner   {   void Print(COuter* outer)   {   cout << outer->m_name;      }    }; }; 

应该改为如下代码:

class COuter private:  char* m_name;   public:  class CInner; // 前置声明  friend class CInner;  class CInner  void Print(COuter* outer)  cout << outer->m_name;     }   }; }; 



★不要定义参数类型相近的函数(标准未定义)


先看如下代码:

void Foo(short n) { // .... } 
void Foo(long n); { // .... } 
Foo(0); // 会导致二义性错误 

  假如没有出现最后一行的那个调用,光编译前面两个重载的Foo 函数是不会出错的。这反而增加了该问题的隐蔽性。

  下面来解释一下:

  万一这两个 Foo 函数存在于某个公共函数库中,编译这个库都很正常。但是使用这个库的某个程序调用了 Foo(0); 结果就编译失败了。



★不要依赖标准类型的字长(标准未定义)


  某些标准类型(例如 int、wchar_t)的字长会随着具体的平台而改变。



★用枚举代替类的静态成员常量(不支持新标准)


  某些老式的编译器不支持类的静态成员常量,可以用枚举来代替。

class CFoo   static const int MIN = 0; // 不可移植   enum { MAX = 64 }; // 可移植 };







异常处理



  早期的老式编译器生成的代码,如果 new 失败会返回空指针。如今这种编译器应该不多见了,万一你在用的编译器还有这种行为,那你就惨了。碰到这种老式编译器,可以考虑重载 new 操作符来抛出 bad_alloc 异常,便于进行异常处理。

  稍微新式一点的编译器,就不是仅仅返回空指针了。当 new 操作符发现内存告急,按照标准的规定(参见 C++ 03 标准 18.4.2章节),它应该去调用 new_handler 函数(原型为 typedef void (*new_handler)();)。标准建议 new_handler 函数干如下三件事:

1. 设法去多搞点内存来

2. 抛出 bad_alloc 异常

3. 调用 C++ 标准库的 abort() 或 exit() 来退出进程

  由于 new_handler 函数是可以被重新设置的(通过调用 set_new_handler),所以上述几种行为都可能出现。


  综上所述, new 分配内存失败,有可能三种可能:

1. 返回空指针

2. 抛出异常

3. 进程立即终止

  如果你希望你的代码具有较好的移植性,你就得把这三种情况都考虑到。



★慎用异常规格


        按照标准(参见 C++ 03 标准 18.6.2章节),如果一个函数抛到外面的异常没有包含在该函数的异常规范中,那么应该调用 unexcepted()。但是并非所有编译器生成的代码都遵守标准(比如某些版本的 VC 编译器)。如果你的需要支持的编译器在异常规范上的行为不一致,那就得考虑:从你的代码中去掉所有的异常规范声明。



★不要跨模块抛出异常


  (先声明:此处说的“模块”是指动态库)

  如果你的工程中包含了动态库,不要把异常抛到模块的导出函数之外。

  毕竟现在 C++ 还没有 ABI 标准(估计将来也未必会有),跨模块抛出异常会有很多不可预料的行为。



★不要使用结构化异常处理(SEH)


  如果你以前习惯于用 SEH,在你打算写跨平台代码之前,要改掉这个习惯。包含有 SEH 的代码只能在 Windows 平台上编译通过(肯定无法跨平台的)。



★关于 catch(...)


  照理说,catch(...) 语句只能够捕获 C++ 的异常类型,对于访问违例、除零错等非 C++ 异常是无能为力的。但是某些情况下(比如某些 VC 编译器),诸如“访问违例、除零错”之类的异常,也可以被 catch(...) 所捕获。

  所以,你如果希望代码的可移植性好,就不能在程序逻辑中依赖上述 catch(...) 的行为——换句话说:你不能指望 catch(...) 来捕获“访问违例、除零错”之类的非 C++ 异常。






硬件体系



  这次聊的话题主要是和硬件体系有关的。比如你的程序需要支持不同类型的 CPU(x86、SPARC、PowerPC),或者是同种类型不同字长的 CPU(比如 x86、amd64),这时候你就需要关心一下硬件体系的问题。




★基本类型的大小


  C++ 中基本类型的大小(占用的字节数)会随着 CPU 字长的变化而变化。

  所以,假如你要表示一个 int 占用的字节数,千万不要直接写“4”(顺便说一下,直接写“4”还犯了 Magic Number 的大忌),而应该写 “sizeof(int)” ;

  反过来,如果你要定义一个大小必须为4字节的有符号整数,也不要直接用 int,而要用预先 typedef 好的定长类型(比如 boost 库提供的int32_t、ACE 库提供的 ACE_INT32 ...)。

  (指针的大小也有上述的问题)



★字节序


  通俗地打个比方,在一个大尾序的机器上有一个4字节的整数 0x01020304,通过网络或者文件传到一台小尾序的机器上就会变成 0x04030201;据说还有一种中尾序的机器(不过我没接触过),上述整数会变成 0x02010403

  如果你编写的应用程序中涉及网络通讯,一定要在记得进行主机序和网络序的翻译;如果涉及跨机器传输二进制文件,也要记得进行类似的转换。



★内存对齐


  由于 C++ 标准中没有定义内存对齐的细节,因此,你的代码也不能依赖对齐的细节。凡是计算结构体大小的地方,都老老实实写上 sizeof()

  有些编译器支持 #pragma pack 预处理语句(可以用来修改对齐字长),不过这种语法不是所有编译器都支持,要慎用。




★移位操作


  对于有符号整数的右移操作,有些系统默认使用算数右移(最高的符号位不变),有些默认使用逻辑右移(最高的符号位补0)。所以,不要对有符号整数进行右移操作。

  顺便说一下,即使没有移植性问题,代码中也尽量少用移位运算符。







操作系统



  刚开始做跨平台开发的新手,多半都会碰上和 FS 相关的问题。所以先来聊一下 FS。

  归纳下来,开发中容易碰上的 FS 差异主要有如下几个:目录分隔符的差异;大小写敏感的差异;路径中禁用字符的差异。

  为了应对上述差异,你要注意如下几点:


◇文件和目录命名要规范



  在给文件和目录命名时,尽量只使用字母和数字。

  不要在同一目录下放两个只有大小写不同的文件(例如 foo.cpp 与 Foo.cpp)。

  不要使用某些 OS 的保留字(例如 aux、con、nul、prn)作文件名或目录名。

  提醒一下:

  刚才说的命名,包括了源代码文件、二进制文件和运行时创建的其它文件。


◇#include 语句要规范



  当你写 #include 语句时,要使用正斜线“/”(各平台通用)而不要使用反斜线“\”(仅在 Windows 家族可用)。#include 语句中的文件和目录名要和实际名称保持大小写完全一致。


◇代码中涉及FS操作,尽量使用现成的库



  已经有很多成熟的、用于 FS 的第三方库(比如 boost::filesystem)。如果你的代码涉及到 FS 的操作(比如目录遍历),尽量使用这些第三方库,可以帮你省不少事。




★文本文件的 回车(CR)与 换行(LF)


  由于几个知名的操作系统对回车/换行的处理不一致,导致了这个很烦人的问题。

  目前的局面是:Windows 同时使用 CR 和 LF;Linux 和大部分的 Unix 使用 LF;苹果的 Mac OS 系列使用 CR。

  对于源代码管理,好在很多版本管理软件(比如 CVS、SVN、Git)都会智能地处理这个问题,让你从代码库取回本地的源码能适应本地的格式。

  如果你的程序需要在运行时处理文本文件,要留意本文方式打开和二进制方式打开的区别。另外,如果涉及跨不同系统传输文本文件,要考虑进行适当的处理。




★文件搜索路径(包括搜索可执行文件和动态库)


  在 Windows 下,如果要执行文件或者加载动态库,一般会搜索当前目录;而 Posix 系统则不尽然。

  所以,如果你的应用涉及到启动进程或加载动态库,就要小心这个差异。



★环境变量


  对于上述提到的搜索路径问题,有些同学想通过修改 PATH 和 LD_LIBRARY_PATH 来引入当前路径。

  如果要使用这种方法,建议你只修改进程级的环境变量,不要修改系统级的环境变量(修改系统级有可能影响到同机的其它软件,产生副作用)。




★动态库


◇动态库导出函数


  如果你的应用程序使用动态库,强烈建议动态库导出标准 C 风格的函数;尽量不要导出类,也不要导出涉及名称重载的函数。


◇符号表


  如果在 Posix 系统中加载动态库,切记慎用 RTLD_GLOBAL  这个标志位。

  这个标志位会 Enable 全局符号表,有可能会导致多个动态库之间的符号名冲突(一旦碰到这种事,会出现匪夷所思的运行时错误,极难调试)。

  


★服务/看守进程


  由于 C++ 开发的模块大部分是后台模块,经常会碰到服务的问题。编写服务需要调用好几个系统相关的 API,导致了与操作系统的紧密耦合,很难用一套代码搞定。

  因此,比较好的办法是抽象出一个通用的服务外壳,然后把业务逻辑代码作为动态库挂载到它下面。这样的话,至少保证了业务逻辑的代码只需要一套;服务外壳的代码虽然需要两套代码(一个用于 Windows、一个用于 Posix),但他们是业务无关的,可以很方便地重用。





★默认栈大小


  不同的操作系统,“栈的默认大小”差别很大,从几十 KB(据说 Symbian 只有12K)到几 MB 不等。


以上是关于C++的可移植性和跨平台开发的主要内容,如果未能解决你的问题,请参考以下文章

如何一眼分辨是C还是C++

如何一眼分辨是C还是C++

ANSI C 兼容或 C++ 跨平台 GUI 库?

干货 | 编写可移植C/C++程序的一些要点

java编程入门小结

java编程入门小结