19条跨端cpp开发有效经验总结
鹿慕2022-02-18

跨多端开发避坑指南

前言

细想,专门从事跨多端开发已两年有余,前段时间因为组里跨桌面端项目需要回归windows下开发了整整2个月,怎么形容这两个月呢,嘿嘿,各种“肆无忌惮”的写法,终于不用在写一行代码考虑后面n个端的行为了,"劳动力"、"效率"得到大幅度解放,但是随着windows发版结束后,我负责mac的适配相关工作,在这个阶段,发现很多不"合规"的奇技淫巧(原定2个工作日的适配quota,大概进行了一周),作为一个略有想法的cpp程序员,遂产生了想写一个跨多端开发避坑指南的想法,想起过去看的Scott Meyers的《Effective C++》....努力写"xx条有效使用cpp开发跨端的经验",期望看完此文可以帮助大家在如何保持同一份cpp代码在多个平台编译和构建上行为一致上有一丝丝帮助。

跨多端开发下的复杂性,究其本质大多是因为两个原因引发的

多系统下平台差异

多编译器下行为不确定性

下面主要讲解的也将从这两个方面入手。

同时,在拜读了多份cpp程序员开发宝典里,还是觉得 Google C++ Style Guide是最有效的,最直接的避坑宝典,依旧推荐给大家:https://google.github.io/styleguide/cppguide.html

下面进入正文——

C++ VERSION 的选择

C++ version选择可以说对于跨终端开发是至关重要的,跨端开发一个比较难的点在于多平台下,如何很好的支撑平台差异点,随着C++版本的升级,越来越多的新feature在标准库中得到支持,这也就是意味着开发者可以更少的关注平台差异点,因此这里建议选择最新的稳定版本,截止到目前推荐使用C++17.

禁止在一个单独的编译中重复包含文件的现象出现

可以通过两种方式有效的避免此类情况

  • #pragma once,需要特别注意 这是一个非标准但是被广泛支持的前置处理符号,在主流的编译中clang,ms等均已支持。
#pragma once
#include<vector>
...
  • 使用#define的方式
#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_

...

#endif  // FOO_BAR_BAZ_H_

路径和头文件路径分隔符的问题

在windows中路径的识别对于正反斜杠均支持,但是在linux中,只能是/,此外,在linux中对于路径是严格区分大小写的,对于windows则忽略大小写。

建议:

  1. 对于路径均需严格保证大小写与实际路径的匹配
  2. 在代码中禁止对路径使用“\”,请用“/”代替。

此举,将在你从win到mac适配过程中,节省大量的工作量。

C标准库的头文件包含

在Windows下某些C标准库的头文件不用显式包含,但是在linux下需要显式包含。因此在跨端开发中,应在.c和.cpp文件中尽量包含这个文件中需要的头文件,并且这也是C语言标准从C99以后的标准要求。

代码文件格式

在跨终端开发中,特别是包含中文的部分,除非你的代码都是英文注释,否则很难避免在多平台下(特别是windows与类unix平台下的开发)交叉开发带来的中文乱码问题。

建议:全部使用UTF-8 BOM编码格式。

关于内联函数

定义:当函数被声明为内联函数之后, 编译器会将其内联展开, 而不是按通常的函数调用机制进行调用。

参考定义,自然他的优点,在函数体比较小的情况下,内联该函数可以令目标代码更高效,通常情况下,应该鼓励在函数比较短时使用内联。

关于内联函数,或许很多非跨端程序员或认为不足为重,其实这里有几个非常值得在跨端开发被重视的问题:

  1. 过度的内联,会导致程序臃肿,特别是对于移动端,一方面c++代码的体积问题一直不能很好的得到解决,另一方面也会使得程序变慢。
  2. 在导出头文件中非恰当的使用内联,会导致在跨模块开发中带来意向不到的结果。这里举个例子,在提供跨终端SDK时,通常会提供导出头文件,但是如果在导出头文件里不恰当的内联,将使得编译从当前单元跨越到另外一个模块,可能会引发一系列问题
  1. 尽管编译器对内联函数都有或多或少优化,但是不同编译器不尽相同,实践下来良好的内联使用习惯依旧能帮助大家,譬如,我们在移动端的某个cpp项目中,通过去内联,减少了一定的包大小,实践证明编译器在择优选择的过程中不一定会完美契合。关于内联的编译器优化可以参考:https://isocpp.org/wiki/faq/inline-function

综上在跨端开发中因尽量避免使用内联,这里给出几个可以衡量的准则(经验值?):

  1. 行数超过10行禁止使用内联(google 建议)
  2. 在非get函数里禁止使用内联(经验值, 这一条争议会比较大,但在我看来只有在get某成员变量值时使用内联是有必要的,其他都没有必要且可能会带来“惊喜”)
  1. 内联函数务必要有适当的修饰符(const)
  2. 析构函数如果有自定义内容,禁止使用内联(google 建议,通常析构函数远比你想想的做的要多)

关于基础类型定义

请使用基础类型定义,禁止使用自定义基础类型。

看过团队的几个代码库,在基础类型的使用上有些同学甚至三方库也非常喜欢自定义,譬如

typedef std::int8_t  int8;
    typedef std::int16_t int16;
    typedef std::int32_t int32;
    typedef std::int64_t int64;

    typedef std::uint8_t   uint8;
    typedef std::uint16_t  uint16;
    typedef std::uint32_t  uint32;
    typedef std::uint64_t  uint64;

在进行跨模块开发以及代码融合时,这些基础类型的自定义经常会出现歧义,redefine等等,或许你会说这样的定义应该要有自己的#define保护,但是大多数程序员不会这么做,这里强烈不建议自定义基础类型,标准库提供的已经足够简略和通用,请方便自己开发的时候同时照顾下团队同学。

CHAR的定义

char的定义需要显示是unsigned还是signed。

需要注意的是,char在标准中不指定为signed或unsigned,不同的编译器可能会有不一样的结果,在发生隐式转换时可能会有超出期望的结果,譬如,char强转int时,发现在x86平台下是按照有符号处理的,但是在ARM32下被当成了无符号导致问题,ARM64正常有符号,当然你可以通过指定CFLAG += fsigned-char 来解决,但是此类问题应当在规范时就被避免掉。

关于宽字符的问题

你需要知道的:在Windows中,wchar_t占两个字节,Linux中占四个字节,这里有几个问题

  1. 导致体积占用大小不同。
  2. 程序移植带来困难
  1. 隐式转换结果不符合预期

跨端开发应避免wchar的普遍使用,以避免宽窄字符转换带来的开销以及额外的问题,应普遍使用utf-8作为主要的编码,这也是主流的思路。即时是特殊场景也可以用使用utf16,避免使用wchar。简而言之,除非必要,否则请不要使用。

应该限定字符串数组在保存为字节流时,使用编码为uft-8

请在字符串前加u8"", 特别是包含中文的部分,习惯在vs下开发的同学也需要额外注意,vs默认的文件编码是gb2312, 这会有概率导致字符串可能会不小心被保存为gbk编码格式。

同时u8仅限在字符串前使用,在字符前使用是没有任何意义的,即时在ms上会编译通过,在clang下会提示

int pos = targetID.rfind(u8'_'); // error: use of undeclared identifier 'u8' ...

避免连续两个尖括号的定义

例如

std::vector<std::vector<int>> vec

在Windows下这么写没问题,那么在某些平台下可能编译不过,提供两种方式:

  • 可以在连续两个尖括号符号之间留一个空格,即
std::vector<std::vector<int>  > vec;
  • 也可以typedef

C++11标准里已经解决了此问题,如果确认编译器版本已经支持了这个特性(参考: https://isocpp.org/wiki/faq/cpp11-language-misc

In C++98 this is a syntax error because there is no space between the two >s. C++11 recognizes such two >s as a correct termination of two template argument lists.),此条可以忽略,但是通常两个>>的情况也意味着嵌套使用,typedef后通常阅读性也会得到提高。

对于平台差异的代码部分处理

跨端开发难免出现平台差异性代码,对于这部分的处理,对于简短的部分建议使用if def的方式区别,对于功能性的、代码较多的建议使用分文件开发,xxxx_win.cpp, xxxx_mac.cpp, xxxx_linux.cpp, 可以参考chromium的代码在大量使用这种方式。

同时对于差异性代码部分,应保持除非必要否则不定义的原则,因尽可能保持跨端的代码处理方式,过多的平台差异性将势必导致维护性变的很差。

应避免使用非标准的编译器支持的关键词

  1. c++标准关键词参考 : https://baike.baidu.com/item/C%2B%2B%E5%85%B3%E9%94%AE%E5%AD%97/5773813
  2. 双底杠开头的关键词多为Microsoft定义的c++关键词,跨端开发中应尽量避免,诸如:__super, __wchar_t, __stdcall__stdcall等等,详细的请参考:https://docs.microsoft.com/zh-cn/cpp/cpp/keywords-cpp?view=msvc-170#microsoft-specific-c-keywords

Assert的使用

Assert在pc时代是作为一个广泛(甚至是烂泛)使用的警告处理方式,在移动端以及类unix系统中,debug下表现通常会比windows更加猛烈些,通常是阻塞式的处理,特别是移动端会导致程序继续运行不下去,不像windows弹个框给你一个continue的选项。

因此在跨端开发中应避免直接使用assert,可以考虑使用重定义后的assert,同时合情合理使用重定义后的assert。

#ifdef  NDEBUG
#define ALOG_ASSERT(_Expression) ((void)0)
#else
#define ALOG_ASSERT(_Expression) do {                                 \
    ...                                                                \ 这里可以额外做error级别日志输出,是否进行assert阻塞式处理。
    if(HandleAssert())                                                \
    {                                                                 \
        assert(_Expression);                                          \
    }                                                                 \
} while (false)
#endif

关于继承

Composition is often more appropriate than inheritance. When using inheritance, make it public.

google的这个定义应该还是非常准确的,通常组合比继承更合适,即时要使用也必须是publice的方式。应尽量保持“is a”的情况下使用继承,如果你想使用私有继承, 你应该替换成把基类的实例作为成员对象的方式。

对于重载的虚函数或虚析构函数, 使用 override, 或 (较不常用的) final 关键字显式地进行标记. 在部分clang编译器下,编译器要求务必显示声明,否则会报错,ms则没有此类要求。

关于Static变量

感兴趣的小伙伴可以研究一下c++的特性““Dynamic Initialization and Destruction with Concurrency”,其中里面有定义静态、动态变量析构的顺序,线程生命周期的对象全部在静态变量之前析构,静态变量按照后构造的先析构的栈式顺序释放。实际在实践中发现apple的clang编译器和运行时库对c++11的这个特性支持,未实现静态变量析构的多线程安全。

因此在目前阶段,如果有用到全局静态变量时需要考虑到析构多线程安全的问题,否则线上在个别平台会发生crash。

一个比较简单的思路:从全局静态变量替换为局部静态变量且不释放,直到进程被kill。这里还有一个变相的好处:把加载时机从load变成了此代码段真正运行时。

eg:
old:
static std::recursive_mutex& m_mutex;
new:
static std::recursive_mutex& mutex()
{
static std::recursive_mutex& mutex = *(new std::recursive_mutex());
return mutex;
}

关于模板

模板的出现极大的方便了程序员,在未进入跨终端领域之前,虽了解它的一些诟病(代码膨胀&不合理的使用带来的性能损耗),也一直认为是一个非常棒的feature,随着移动端对包大小的要求越来越严格,模板的使用在跨终端上被限制,需要更为合理的使用,否则将膨胀的非常厉害。在漫长的去模板化过程中有些经验值可以输出,供大家参考。

  1. 在涉及到移动端的跨终端开发里,应尽量避免使用模板,除非它带来足够多的收益,比如json序列化,通篇用cjson的方式替换,从开发体验和代码膨胀比上来看,替换就显得不值得,比如自定义std标准容器,看似省了不少膨胀,但是代码的维护性和可读性降低了很多,同样不值得替换。
  2. 尽可能选择小的模板编译单元,比如原来一个模板类,改为类里的模板函数
  1. 通常情况下模板可以以各种方式被除去,这里不是说在裸写一遍模板换实参的方法。
  2. 应尽可能的减少模板膨胀的速度,换句话说如果有可能应该尽量限制模板被特化的可能,譬如,我们的日志序列化,对于任意struct或者class在实现了ToString()方法后均可以实现日志自动化输出,任意类型在进入到LOG_IMPL中都会生成一份具体类型的实体,经过略微改造后,限制需要被序列化的类型需要显示继承IOBJECT的接口类,改造后,在同样进入到LOG_IMPL中所有的类型只会有一份类型(IOBJECT*)实例化,此举在实践过程中大约减少了我们五分之一的包大小。
  1. 在多重继承中,特别是公共模块基类如果包含模板,去模板的收益一般会比较大,因尽量限制基类中出现模板,除非必要,否则应以任何方式替换。

最后再插一嘴,模板对于使用者确实是极大的方便,但是在跨终端领域似乎对于模板的构建者有着更为严格的要求,需要着重考虑如何避免被膨胀,此外对于性能的要求也更为严格,c++11里有不少提供模板性能的方式,&&配合std::forward实现完美转发,等等,有兴趣的可以看下《Effective Modern C++》。

以上也适用于 宏。

关于编译器

跨端开发势必要了解多种平台下的编译器,这里面主要代表是clang、ms(也成vs)、gcc等等,编译器的主要区别,这里不做主要的介绍了,可以去google下clang的前世今生,以及几种编译器的区别,和对应的使用平台。

clang作为一款飞速发展的编译器,除了编译速度有飞速的提升外,错误提示也非常明确,这里强烈建议跨端开发者,如果有可能优先进行clang作为主要的默认编译器进行开发,良好的错误提示将提高极大的效率,同时clang的代码检查将更为严格和规范,这也利于代码进行跨平台编译。

这里再再插一句,之前在知乎上看过一篇文章对比各种编译器,在比较clang与gcc时,排在第一次位的不是我们通常说的编译速度和错误提示以及更小的编译产物(这些都是普遍知道的),是 license,gcc的GPL的限制让BSD许可下的以LLVM为代表的飞速发展,如果不是这个限制相信今天以LLVM为代表的的一系列编译器都是属于gcc。

所以“做技术的同学不要以为技术牛就可以打天下,精准的市场地位有时候可以解决很多问题”, 这句话说的还挺好的,与君共勉。

关于转换层

如果做跨模块开发,请坚守一个原则,转换层不要做任何业务代码逻辑以及特殊定向代码逻辑。

转换层也成语言胶水层,是c++到oc, c++到java,以及其他,彼此相互语言转换的代码层。

通常wrapper坚守原则后,维护性会得到大幅度提升,专注于c++代码的即可,对于语言转换层,业界也有不少自动化转译的工具,诸如Djinni。

结束

在通往跨端开发的路上,我渐渐的从一个小白到逐渐羽翼丰满,除了要感谢团队给的机会外,非常感谢这一路上很多同学、特别是跨部们的同学帮助,感谢,比心~

另外团队目前也在搞基于跨桌面端的研发框架支撑相关工作,也会很快出炉,敬请期待。

最后回归主题,跨端cpp开发闭坑指南远不止这些,欢迎一起补充添加。鸣谢。