跨多端开发避坑指南
细想,专门从事跨多端开发已两年有余,前段时间因为组里跨桌面端项目需要回归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++版本的升级,越来越多的新feature在标准库中得到支持,这也就是意味着开发者可以更少的关注平台差异点,因此这里建议选择最新的稳定版本,截止到目前推荐使用C++17.
可以通过两种方式有效的避免此类情况
1 . #pragma once,需要特别注意 这是一个非标准但是被广泛支持的前置处理符号,在主流的编译中clang,ms等均已支持。
#pragma once
#include<vector>
...
2 . 使用#define的方式
#ifndef FOO_BAR_BAZ_H_
#define FOO_BAR_BAZ_H_
...
#endif // FOO_BAR_BAZ_H_
在windows中路径的识别对于正反斜杠均支持,但是在linux中,只能是/,此外,在linux中对于路径是严格区分大小写的,对于windows则忽略大小写。
建议:
此举,将在你从win到mac适配过程中,节省大量的工作量。
在Windows下某些C标准库的头文件不用显式包含,但是在linux下需要显式包含。因此在跨端开发中,应在.c和.cpp文件中尽量包含这个文件中需要的头文件,并且这也是C语言标准从C99以后的标准要求。
在跨终端开发中,特别是包含中文的部分,除非你的代码都是英文注释,否则很难避免在多平台下(特别是windows与类unix平台下的开发)交叉开发带来的中文乱码问题。
建议:全部使用UTF-8 BOM编码格式。
定义:当函数被声明为内联函数之后, 编译器会将其内联展开, 而不是按通常的函数调用机制进行调用。
参考定义,自然他的优点,在函数体比较小的情况下,内联该函数可以令目标代码更高效,通常情况下,应该鼓励在函数比较短时使用内联。
关于内联函数,或许很多非跨端程序员或认为不足为重,其实这里有几个非常值得在跨端开发被重视的问题:
综上在跨端开发中因尽量避免使用内联,这里给出几个可以衡量的准则(经验值?):
请使用基础类型定义,禁止使用自定义基础类型。
看过团队的几个代码库,在基础类型的使用上有些同学甚至三方库也非常喜欢自定义,譬如
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的定义需要显示是unsigned还是signed。
需要注意的是,char在标准中不指定为signed或unsigned,不同的编译器可能会有不一样的结果,在发生隐式转换时可能会有超出期望的结果,譬如,char强转int时,发现在x86平台下是按照有符号处理的,但是在ARM32下被当成了无符号导致问题,ARM64正常有符号,当然你可以通过指定CFLAG += fsigned-char 来解决,但是此类问题应当在规范时就被避免掉。
你需要知道的:在Windows中,wchar_t占两个字节,Linux中占四个字节,这里有几个问题
跨端开发应避免wchar的普遍使用,以避免宽窄字符转换带来的开销以及额外的问题,应普遍使用utf-8作为主要的编码,这也是主流的思路。即时是特殊场景也可以用使用utf16,避免使用wchar。简而言之,除非必要,否则请不要使用。
请在字符串前加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下这么写没问题,那么在某些平台下可能编译不过,提供两种方式:
1 . 可以在连续两个尖括号符号之间留一个空格,即
std::vector<std::vector<int> > vec;
2 . 也可以typedef
C++11标准里已经解决了此问题,如果确认编译器版本已经支持了这个特性(参考: https://isocpp.org/wiki/faq/cpp11-language-miscIn 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的代码在大量使用这种方式。
同时对于差异性代码部分,应保持除非必要否则不定义的原则,因尽可能保持跨端的代码处理方式,过多的平台差异性将势必导致维护性变的很差。
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则没有此类要求。
感兴趣的小伙伴可以研究一下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,随着移动端对包大小的要求越来越严格,模板的使用在跨终端上被限制,需要更为合理的使用,否则将膨胀的非常厉害。在漫长的去模板化过程中有些经验值可以输出,供大家参考。
最后再插一嘴,模板对于使用者确实是极大的方便,但是在跨终端领域似乎对于模板的构建者有着更为严格的要求,需要着重考虑如何避免被膨胀,此外对于性能的要求也更为严格,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开发闭坑指南远不止这些,欢迎一起补充添加。鸣谢。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8