作者
我不想种地责编
郭芮
C++是一门被广泛使用的系统级编程语言,更是高性能后端标准开发语言;C++虽功能强大,灵活巧妙,但却属于易学难精的专家型语言,不仅新手难以驾驭,就是老司机也容易掉进各种陷阱。
本文结合作者的工作经验和学习心得,对C++语言的一些高级特性,做了简单介绍;对一些常见的误解,做了解释澄清;对比较容易犯错的地方,做了归纳总结;希望借此能增进大家对C++语言了解,减少编程出错,提升工作效率。
一、我的程序里用了全局变量,但为什么进程正常停止的时候会莫名其妙的core掉?
Rule:C++在不同模块(源文件)里定义的全局变量,不保证构造顺序;但保证在同一模块(源文件)里定义的全局变量,按定义的先后顺序构造,按定义的相反次序析构。
我们程序在a.cpp里定义了依次全局变量X和Y;
按照规则:X先构造,Y后构造;进程停止执行的时候,Y先析构,X后析构;但如果X的析构依赖于Y,那么core的事情就有可能发生。
结论:如果全局变量有依赖关系,那么就把它们放在同一个源文件定义,且按正确的顺序定义,确保依赖关系正确,而不是定义在不同源文件;对于系统中的单件,单件依赖也要注意这个问题。
二、编译器为什么不给局部变量和成员变量做默认初始化?
因为效率,C++被设计为系统级的编程语言,效率是优先考虑的方向,c++秉持的一个设计哲学是不为不必要的操作付出任何额外的代价,所以它有别于java,不给成员变量和局部变量做默认初始化,如果需要赋初值,那就由程序员自己去保证。
结论:从安全的角度出发,定义变量的时候赋初值是一个好的习惯,很多错误皆因未正确初始化而起,C++11支持成员变量定义的时候直接初始化,成员变量尽量在成员初始化列表里初始化,且要按定义的顺序初始化。
三、std::sort()的比较函数有很强的约束,不能乱来!
相信工作5年以上至少50%的C/C++程序员都被它坑过,我已经听到过了无数个悲伤的故事,《圣斗士星矢》,《仙剑》,还有别人家的项目《天天爱消除》,都有人掉坑,程序运行几天莫名奇妙的Crash掉,这锅好沉。
如果要用,要自己提供比较函数或者函数对象,一定搞清楚什么叫“严格弱排序”,一定要满足以下3个特性:
非自反性非对称性传递性尽量对索引或者指针sort,而不是针对对象本身,因为如果对象比较大,交换(复制)对象比交换指针或索引更耗费。
四、注意操作符短路
考虑游戏玩家回血回蓝(魔法)刷新给客户端的逻辑。玩家每3秒回一点血,玩家每5秒回一点蓝,回蓝回血共用一个协议通知客户端,也就是说只要有回血或者回蓝就要把新的血量和魔法值通知客户端。
玩家的心跳函数heartbeat()在主逻辑线程被循环调用:
voidGamePlayer::Heartbeat(){if(GenHP()
GenMP()){NotifyClientHPMP();}}
如果GenHP回血了,就返回true,否则false;不一定每次调用GenHP都会回血,取决于是否达到3秒间隔。
如果GenMP回蓝了,就返回true,否则false;不一定每次调用GenMP都会回血,取决于是否达到5秒间隔。
实际运行发现回血回蓝逻辑不对,Word麻,原来是操作符短路了,如果GenHP()返回true了,那GenMP()就不会被调用,就有可能失去回蓝的机会。OMG,你需要修改程序如下:
voidGamePlayer::Heartbeat(){boolhp=GenHP();boolmp=GenMP();if(hp
mp){NotifyClientHPMP();}}
逻辑与()跟逻辑或(
)有同样的问题,if(ab)如果a的表达式求值为false,b表达式也不会被计算。
有时候,我们会写出if(ptr!=nullptrptr-Do())这样的代码,这正是利用了操作符短路的语法特征。
五、理解std::vector的底层实现
vector是动态扩容的,2的次方往上翻,为了确保数据保存在连续空间,每次扩充,会将原member悉数拷贝到新的内存块;不要保存vector内对象的指针,扩容会导致其失效;可以通过保存其下标index替代。
运行过程中需要动态增删的vector,不宜存放大的对象本身,因为扩容会导致所有成员拷贝构造,消耗较大,可以通过保存对象指针替代。
resize()是重置大小;reserve()是预留空间,并未改变size(),可避免多次扩容;clear()并不会导致空间收缩,如果需要释放空间,可以跟空的vector交换,std::vectort.swap(v),c++11里shrink_to_fit()也能收缩内存。
理解at()和operator[]的区别:at()会做下标越界检查,operator[]提供数组索引级的访问,在release版本下不会检查下标,VC会在Debug版本会检查;c++标准规定:operator[]不提供下标安全性检查。
C++标准规定了std::vector的底层用数组实现,认清这一点并利用这一点。
六、用c标准库的安全版本(带n标识)替换非安全版本
比如用strncpy替代strcpy,用snprintf替代sprintf,用strncat代替strcat,用strncmp代替strcmp,memcpy(dst,src,n)要确保[dst,dst+n]和[src,src+n]都有有效的虚拟内存地址空间。
多线程环境下,要用系统调用或者库函数的安全版本代替非安全版本(_r版本),谨记strtok,gmtime等标准c函数都不是线程安全的。
七、理解函数调用的性能开销(栈帧建立和销毁,参数传递,控制转移),性能敏感函数考虑inline
X86_64体系结构因为通用寄存器数目增加到16个,所以64位系统下参数数目不多的函数调用,将会由寄存器传递代替压栈方式传递参数,但栈帧建立、撤销和控制转移依然会对性能有所影响。
八、理解userstack空间很有限,不能在栈上定义过大的临时对象,递归函数要有退出条件且不能递归过深
一般而言,用户栈只有几兆(典型大小是4M,8M),所以栈上创建的对象不能太大;虽然递归函数能简化程序编写,但也常常带来运行速度变慢的问题,所以需要预估好递归深度,优先考虑非递归实现版本。
九、内存拷贝小心内存越界
memcpy,memset有很强的限制,仅能用于POD结构,不能作用于stl容器或者带有虚函数的类。
带虚函数的类对象会有一个虚函数表的指针,memcpy将破坏该指针指向。
对非POD执行memset/memcpy,免费送你四个字:自求多福。
十、用sprintf格式化字符串时,类型和格式化符号要严格匹配
因为sprintf的函数实现里是按格式化串从栈上取参数,任何不一致,都有可能引起不可预知的错误;/usr/include/inttypes.h里定义了跨平台的格式化符号,比如PRId64用于格式化int64_t
十一、stl容器的遍历删除要小心迭代器失效,vector、list、map、set等各有不同的写法
#includevector#includemap#includeiterator#includeiostream#includealgorithmintmain(intargc,char*argv[]){//vector遍历删除std::vectorintv(8);std::generate(v.begin(),v.end(),std::rand);std::coutaftervectorgenerate...\n;std::copy(v.begin(),v.end(),std::ostream_iteratorint(std::cout,\n));for(autox=v.begin();x!=v.end();){if(*x%2)x=v.erase(x);else++x;}std::coutaftervectorerase...\n;std::copy(v.begin(),v.end(),std::ostream_iteratorint(std::cout,\n));//map遍历删除std::mapint,intm={{1,2},{8,4},{5,6},{6,7}};for(autox=m.begin();x!=m.end();){if(x-first%2)m.erase(x++);else++x;}return0;}
有时候遍历删除的逻辑不是这么明显,可能循环里调了另一个函数,而该函数在某种特定的情况下才会删除当前元素,这样的话,就是很长一段时间,程序都运行得好好的,而当你正跟别人谈笑风生的时候,忽然crash,这就尴尬了。
圣斗士星矢项目曾经遭遇过这个问题,基本规律是一个礼拜gameservercrash一次,折磨团队将近一个月。
比较low的处理方式可以把待删元素放到另一个容器WaitEraseContainer里保存下来,再走一趟单独的循环,删除待删元素。
当然,我们推荐在遍历的同时删除,因为这样效率更高,也显得行家里手。
十二、积极的使用const,理解const不仅仅是一种语法层面的保护机制,也会影响程序的编译和运行
const常量会被编码到机器指令。
十三、理解四种转型的含义和区别,避免用错,尽量少用向下转型(可以通过设计加以改进)
static_cast,dynamic_cast,const_cast,reinterpret_cast,傻傻分不清?
C++砖家说:一句话,尽量少用转型,强制类型转换是CStyle,如果你的C++代码需要类型强转,你需要去考虑是否设计有问题。不管您信不信,我反正是信了。
十四、打开的句柄要关闭,加锁/解锁,new/delete,new[]/delete[],malloc/free要配对,可以使用RAII技术防止资源泄露,编写符合规范的代码
Valgrind对程序的内存使用方式有期望,需要干净的释放,所以规范编程才能写出valgrind干净的代码,不然再好的工具碰到不按规划写的代码也是武功尽废啊。
十五、理解多继承潜在的问题,慎用多继承
多继承会存在菱形继承的问题,多个基类有相同成员变量会有问题,需要谨慎对待。
十六、有多态用法抽象基类的析构函数要加virtual关键字
主要是为了基类的析构函数能得到正确的调用。
virtualdtor跟普通虚函数一样,基类指针指向子类对象的时候,deleteptr,根据虚函数特征,如果析构函数是普通函数,那么就调用ptr显式(基类)类型的析构函数;如果析构函数是virtual,则会调用子类的析构函数,然后再调用基类析构函数。
十七、避免在构造函数和析构函数里调用虚函数
构造函数里,对象并没有完全构建好,此时调用虚函数不一定能正确绑定,析构亦如此。
十八、从输入流获取数据,要做好数据不够的处理,要加trycatch;没有被吞咽的exception,会被传播
从网络数据流读取数据,从数据库恢复数据都需要注意这个问题。
十九、协议尽量不要传float
如果传float要了解NaN的概念,要做好检查,避免恶意传播。
二十、定义宏要遵循常规,要对每个变量加括弧
有时候需要加do{}while(0)或者{},以便能将一条宏当成一个语句。要理解宏在预处理阶段被替换,不用的时候要#undef,要防止污染别人的代码
二十一、了解智能指针,理解基于引用计数法的智能指针实现方式,了解所有权转移的概念,理解shared_ptr和unique_ptr的区别和适用场景
考虑用std::shared_ptr管理动态分配的对象。
二十二、了解c++高阶特性:模板和泛型编程,union,bitfield,指向成员的指针,placementnew,显式析构,异常机制,nestedclass,localclass,namespace,多继承、虚继承,volatile,externC等
有些高级特性只有在特定情况下才会被用到,但技多不压身,平时还是需要积累和了解,这样在需求出现时,才能从自己的知识库里拿出工具来对付它。
二十三、了解C++新标准,