当前位置: 首页 > 科技观察

说说C++的陷阱和套路

时间:2023-03-21 14:56:59 科技观察

本文转载自微信公众号“码砖杂工”,人民副总码仔的作者。转载本文请联系码砖手公众号。#1.简介C++是一种应用广泛的系统级编程语言,也是一种高性能的后端标准开发语言;C++虽然功能强大、灵活巧妙,但却是一门易学难精的专家级语言,不仅新手难以掌握,即使是老司机也容易陷入各种陷阱。本文结合楼主的工作经验和学习经验,简单介绍C++语言的一些高级特性;解释和澄清一些常见的误解;总结了比较容易出错的地方;希望通过这个可以提高大家对C++语言的理解,减少编程错误,提高工作效率。#2.陷阱##1.我在我的程序中使用了全局变量。为什么进程退出时内核莫名下降?规则:C++在不同模块(源文件)中定义的全局变量不保证构造顺序;但是保证在同一个模块(源文件)中定义的全局变量按照定义的顺序构造,按照定义的相反顺序析构。我们的程序在a.cpp中依次定义了全局变量X和Y;按照规则:先构造X,后构造Y;当进程停止执行时,先析构Y,后析构X;在Y中,那么核心的事情可能会发生。**结论**:如果全局变量存在依赖关系,那么将它们放在同一个源文件定义中,并按照正确的顺序定义它们,以确保依赖关系正确,而不是定义在不同的源文件中;对于系统中单个源文件的One-piecedependencies也要注意这个问题。##2.坑中坑:std::sort()相信至少有50%工作5年以上的C/C++程序员都被它忽悠过。听过无数悲伤的故事,《圣斗士星矢》,《仙剑》,还有别人的项目《天天爱消除》,有人中计,跑了几天程序就莫名其妙的崩溃了,一脸懵逼。排序算法对比较函数有很强的约束,不能乱来。如果你想使用它,你必须自己提供一个比较函数或函数对象。你必须搞清楚什么叫“严格弱排序”,必须满足以下三个特性:1.非自反性2.不对称性3.传递性尽量对索引或指针排序,而不是对对象本身排序,因为如果对象是相对大,交换(复制)对象比交换指针或索引更昂贵。##3.注意算子的短路,考虑玩家回血和法力(魔法)刷新给客户端的逻辑。玩家每3秒回一点HP,玩家每5秒回一点法力。回蓝和回血共用一个协议来通知客户端,也就是说只要有回血或者回蓝,就会通知客户端新的血量和法力值。玩家的心跳函数heartbeat()在主逻辑线程中循环调用```c++voidGamePlayer::Heartbeat(){if(GenHP()||GenMP()){NotifyClientHPMP();}}```ifGenHP如果回血则返回true,否则为false;每次调用GenHP不一定都回血,取决于是否达到3秒间隔。如果GenMP返回蓝色则返回true,否则返回false;不一定每次调用GenMP都会回血,取决于是否达到5秒间隔。实际操作发现,回血回魔的逻辑是错误的,字都麻木了。原来是接线员短路了。如果GenHP()返回true,则GenMP()将不会被调用,返回mana的机会可能会丢失。你需要修改程序如下:```c++voidGamePlayer::Heartbeat(){boolhp=GenHP();boolmp=GenMP();if(hp||mp){NotifyClientHPMP();}}```逻辑与(&&)和逻辑或(||)有同样的问题,如果(a&&b)如果a的表达式求值为false,b的表达式就不会求值。有时候,我们会写出类似if(ptr!=nullptr&&ptr->Do())的代码,它利用了运算符短路的语法特性。##4.不要让循环停止```c++for(unsignedinti=5;i>=0;--i){//...}```程序在这里运行,WTF?根本停不下来?问题很简单。无符号总是>=0。你心中是否有千军万马奔腾?解决这个问题很简单,但有时这类错误并不是那么明显,需要一个掩护来突出显示。##5.当心越界memcpy,memset有很强的限制,只能用于POD结构,不能用于stl容器或有虚函数的类。带有虚函数的类对象会有一个指向虚函数表的指针,memcpy会销毁这个指针。非POD执行memset/memcpy,免费送你四个字:**自求多福**##6.内存重叠复制内存时,如果src和dst重叠,需要用memmov,不能用memcpy。##7.理解用户栈空间是非常有限的,不能在栈上定义太大的临时对象。一般来说,用户栈只有几兆字节(典型大小为4M、8M),所以在栈上创建的对象不宜过大。##8、使用sprintf格式化字符串时,类型和符号必须严格匹配,因为sprintf的函数实现是根据格式化字符串从栈中取参数,任何不一致都可能导致不可预知的错误;/usr/include/inttypes.h定义了跨平台的格式化符号,比如格式化int64_t的PRId64##9.将非安全版本替换为c标准库的安全版本(带n标记)例如,使用strncpy代替strcpy,使用snprintf代替sprintf,strncat代替strcat,strncmp代替strcmp,memcpy(dst,src,n)保证[dst,dst+n]和[src,src+n]都有有效的虚拟内存地址空间.在多线程环境中,使用系统调用或库函数的安全版本,而不是非安全版本(_r版本)。请记住,标准的c函数(如strtok和gmtime)不是线程安全的。##遍历删除STL容器时,注意迭代器失效。vector,list,map,set等有不同的写法:vector,list,map,set等有不同的写法:```c++intmain(intargc,char*argv[]){//向量遍历删除std::vectorv(8);std::generate(v.begin(),v.end(),std::rand);std::cout<<"aftervectorgenerate...\n";std::copy(v.begin(),v.end(),std::ostream_iterator(std::cout,"\n"));for(autox=v.begin();x!=v.end();){if(*x%2)x=v.erase(x);else++x;}std::cout<<"aftervectorerase...\n";std::copy(v.begin(),v.end(),std::ostream_iterator(std::cout,"\n"));//遍历map删除std::mapm={{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;}```有时候遍历删除的逻辑不是那么明显,可能在循环中调整了另一个函数,和此函数只会在特定情况下删除当前元素。在这种情况下,程序运行了很长时间,但是当你和别人说说笑笑的时候,突然崩溃了,这很尴尬。圣斗士星矢项目曾经遇到过这个问题。基本规则是游戏服务器每周崩溃一次,折磨团队近一个月。比较low的处理方式可以将要删除的元素保存在另一个容器WaitEraseContainer中,然后通过单独的循环去删除要删除的元素。当然,我们更推荐一边遍历一边删除,这样效率更高,看起来像高手。#三。Performance##SpaceReplacementTime用空间换时间是提高性能的成语。Bitmap,intmap[]这些成语应该心里清楚。##减少复制和COW了解写入时复制。尽可能减少复制,比如通过共享,比如通过引用指针传递参数和返回值。##延迟计算和预计算比如游戏服务器端玩家的战力是由属性a和b决定的,也就是说属性a和b的任何变化都需要重新计算战力;但是如果在ModifyPropertyA(),ModifyPropertyB()之后,其实并没有必要重新计算战力,因为修改了属性A之后,马上就可以修改B,而且战力重新计算了两次。显然,第一次重算的结果很快就会被第二次重算覆盖。而且在很多情况下,我们可能需要在心跳中将最新的战力值推送给客户端。这样的话,在ModifyPropertyA()、ModifyPropertyB()中,我们其实只需要将战力弄脏,延迟计算即可,这样就可以避免不必要的计算。判断GetFightValue()中的FightValueDirtyFlag,如果是dirty,重新计算并清除dirtyflag;如果不脏,直接返回前面计算的结果。预计算的思路类似。##去中心化计算去中心化计算就是把任务打散打散,避免大量的计算而阻塞程序。##Hash减少字符串比较,构造hash,可能会多消耗一点存储空间,但是收益还是很可观的,值得一试。##Logthrottling日志的开销不容忽视。应该有档次,不能太奔放。日志可以用作调试方法,但发布应该是干净的。##为什么编译器不对局部变量和成员变量做默认初始化?因为效率,C++被设计为系统级编程语言,效率是优先考虑的。C++秉承的一个设计理念是“不要为不必要的操作付出任何额外的代价”。所以它不同于java。它默认不初始化成员变量和局部变量。如果您需要分配一个初始值,则由程序员来确保它。**结论**:从安全的角度来说,不应该使用未初始化的变量。定义变量时赋初值是个好习惯。许多错误是由不正确的初始化引起的。C++11支持成员变量在定义的时候直接初始化。成员变量尽可能在成员初始化列表中进行初始化,并且应该按照定义的顺序进行初始化。##了解函数调用的性能开销。性能敏感的函数考虑了内联堆栈帧的建立和销毁、参数传递和控制传递,这些都是对性能不利的。因为X86_64架构中通用寄存器的个数增加到16个,64位系统下参数个数少的函数调用将通过寄存器传递,而不是压入参数,而是栈帧的创建、取消和控制传递还是会影响性能。做作的。##递归的优缺点优点:容易写,容易理解。缺点:运行速度变慢,需要估计递归的深度。建议:首选非递归实现版本。递归函数必须有退出条件,不能递归太深,否则有爆栈的危险。#四。数据结构和容器##了解std::vector的方方面面和底层实现1.vector是动态展开的,2的幂翻起来。为了保证数据存储在一个连续的空间中,每次扩容时,都会对原来的进行扩容。所有成员都被复制到一个新的内存块;不要在vector中保存对象的指针,扩展会导致其失效;您可以改为保存其下标索引。2、运行时需要动态增减的vector本身不适合存储大对象,因为扩容会造成所有成员的copyconstruction,消耗很大,可以通过保存对象指针来代替。3、resize()是重新设置大小;reserve()是保留空间,不改变size(),可以避免多次扩容;clear()不会造成空间收缩,如果需要释放空间,可以跟空Vector交换,std::vector.swap(v),c++11中的shrink_to_fit()也可以收缩内存。4.理解at()和operator[]的区别:at()会检查下标越界,operator[]提供数组索引级访问,release版本不会检查下标,VC会检查在调试版本中;C++标准声明:operator[]不提供下标安全检查。5、C++标准规定std::vector底层是用数组实现的,认清这一点,好好利用。##常用数据结构**数组**:连续内存,随机存取,高性能,局部性好,不支持动态缩放,最常用,通常也是最正确的选择。**链表**:动态缩放,插入/脱离极快(尤其是有前后驱动指针),节点内存通常是不连续的(当然可以通过从固定内存池分配来避免),随机不支持访问。仅用于需要快速增删改查、动态伸缩的有限场景。例如,游戏中的地图被划分为格子,每个格子维护一个玩家链表(当格子进入玩家视野时需要遍历该链表),玩家会在格子之间频繁移动。移动。**搜索**:3种类型:bst、hashtable、基于有序数组的bsearch。二叉搜索树(RBTree),从头到尾有序,搜索速度最差logN,缺点是内存不连续,结点有额外的空间浪费;hashtable,好的哈希函数不好选,最差的搜索退化成链表。很难估计戳的数量。设置太大会浪费内存,设置太小会增加冲突的几率。展开的时候会卡住,乱序。基于有序数组的Bsearch局部性好,插入/删除速度慢。#五。最佳实践##对于启动时加载,运行时不会改变的查询结构,可以考虑使用sortedarray代替map,hashtable等,因为sortedarray支持二分查找,效率和map差不多。对于只需要在程序启动时构造(排序)一次的查询结构,有序数组可能比map和hash具有更好的内存命中率(localhitness)。运行时,对于稳定的查询结构(比如配置表,需要根据id查找配置表项,运行时不要增删改查),有序数组是不错的选择;如果不稳定,有序数组的插入和删除效率要优于map。,Hashtable较差,所以在选择有序数组的时候需要注意适用的场合。##std::map或std::unorder_map?想想他们的优点和缺点。Map由红黑树构成,unorder_map底层由哈希表构成。与红黑树相比,哈希表具有更高的搜索性能。哈希表的效率取决于哈希算法和冲突解决方法(通常是拉链法、哈希桶)和数据分布。如果负载因子高,命中率会降低。为了提高命中率,需要扩容和重新哈希。re-hash很慢,相当于卡住了。红黑树的平均复杂度比较好,所以如果数据量不是特别大,map可以胜任。##积极使用const要明白const不仅仅是一种语法保护机制,还会影响程序的编译和运行。const常量被编码成机器指令。##理解四种变换的含义和区别,避免错误使用,尽量少用向下变换(可以通过设计改进)static_cast、dynamic_cast、const_cast、reinterpret_cast,傻傻的糊涂?C++砖头说:总之,尽量少用casting,强制类型转换是CStyle,如果你的C++代码需要typecast,你需要考虑是不是设计有问题。##了解字节对齐字节对齐使内存访问更快。字节对齐与CPU架构有关。有些CPU访问特定类型的数据必须在某个地址对齐的内存位置,否则会触发异常。字节对齐的另一个作用是调整结构体成员变量的定义顺序,可能会减小结构体的大小,在某些情况下可以节省内存。##记住3条规则和5条规则。当然,C++11有更多copyctor和op=版本的&&。只需要在需要接管的时候自定义operator=和copyconstructor即可。如果编译器提供的默认版本很好用,那你就别费心了,别忘了把自定义版本的每一个组件都拷贝过来,想接手的话就自己处理吧。##组合优先于继承。继承是类之间最强的关系。典型的适配器模式包括类适配器和对象适配器。一般来说,推荐使用对象适配,而不是基于继承的类适配,比如STL中queue/stack的实现是基于对象适配的。##减少依赖,注意隔离+最小化文件间的依赖,使用前向声明拆解相互依赖。+了解pimpl技术。+头文件要自给自足,不要图省事all.h,不要包含不必要的头文件,也不要把该包含的头文件强加给用户去包含,总之,头文件应该不多也不少。##严格配对(RAII)打开的句柄要关闭,lock/unlock,new/delete,new[]/delete[],malloc/free要配对,可以使用RAII技术防止资源泄露,写compliantcodevalgrind对程序的内存使用有预期,需要释放干净,所以标准编程可以写出valgrindclean代码,否则再好的工具遇到代码也没用没有按照计划写。##理解多重继承的潜在问题,谨慎使用多重继承,会出现菱形继承问题,多个基类会出现成员变量相同的问题,需要谨慎对待。##多态用法抽象基类的析构函数需要加上virtual关键字主要是为了让基类的析构函数能够被正确调用。virtualdtor和普通的虚函数一样。当基类指针指向子类对象时,删除ptr。根据虚函数的特点,如果析构函数是普通函数,则调用ptr显式(基类)类型的析构函数;如果析构函数是虚函数,则调用子类的析构函数,然后调用基类的析构函数。##避免在构造函数和析构函数中调用虚函数在构造函数中,对象并没有完全构造出来。这时候调用虚函数可能绑定不正确,析构函数也是如此。从输入流中获取数据,需要处理数据不足的情况,加上trycatch;未被吞噬的异常会传播到网络数据流中读取数据,从数据库恢复数据时需要注意这个问题。##尽量不要在协议中传递float。如果传float,一定要了解NaN的概念,好好检查,避免恶意传输。可以考虑用整数代替浮点数,比如千分之五(5%%),省5。有时需要加上do{}while(0)或{},这样宏就可以看做是一条语句。要理解宏是在预处理阶段被替换的,不用的时候需要#undef,防止污染别人的代码,避免在宏定义中依赖特定的外部变量名。##智能指针理解基于引用计数的智能指针的实现,理解所有权转移的概念,理解shared_ptr和unique_ptr的区别和适用场景。不要滥用指针,指针带来了灵活性,但是如果没有这个必要,就不要使用它们(比如命名全局变量就可以,如果非要设置一个全局变量指针,那么在init中new,deleteinterminate,自找麻烦)。##考虑使用std::shared_ptr来管理动态分配的对象。指针可以带来灵活性,但不要滥用它。它的灵活性意味着一方面可以在运行时改变指针,可以用于多态。另一方面,它可以动态扩展和收缩不能固定大小的数组。对于固定大小的数组,在init中也是new/malloc。其实大可不必,而且会占用更多的sizeof(void*)字节,而且还要加一层间接访问。##size_t到底是什么?我应该使用有符号整数还是无符号整数?size_t类型旨在保存系统内存中可以保存的最大对象数。在32位系统中,一个对象的最小单位是一个字节,2的32次方内存中最多可以存储的对象个数是4G/1字节,正好可以存储一个unsignedint(typedefunsignedintsize_t)。同样,在64位系统中,unsignedlong是8个字节,所以size_t是unsignedlong的类型别名。对于索引和位置这样的变量,我们应该使用有符号的还是无符号的?金钱之类的属性呢?一句话:讲道理,用最自然最符合逻辑的类型。比如index不能为负,就用size_t,账户可能欠钱,那就用int换钱。例如:```templateclassvector{T&operator(size_tindex){}};```标准库给出了最好的演示,因为如果是有符号的,需要判断if(index<0||index>=max_num)throwout_of_bound();而如果是无符号整数,只需要判断if(index>=max_num),你同意吗?##整型一般用int和long,但是short和char需要很小心,大部分情况下防止溢出,用int,long就好,long一般等于机器字的长度,long可以直接放在寄存器中,硬件的处理速度更快。很多时候,我们希望使用short、char(8位整数)来达到减小结构体大小的目的。但是由于字节对齐的原因,可能并没有真正减少,而且1和2字节的整数位数太少,一不小心就会溢出,需要特别注意。因此,除非db和network对存储大小非常敏感,否则我们需要考虑是否将int和long换成short和char。#六。扩展##理解c++高级特性模板和泛型编程,union,bitfield,指向成员的指针,placementnew,显式析构,异常机制,嵌套类,局部类,命名空间,多重继承,虚继承,volatile,extern"C》等高级特性只会在特定情况下才会用到,但技巧也不是铺天盖地,平时还是需要自己去积累和理解,这样当需求出现的时候,就可以从自己的知识库工具中获取到处理它。##了解C++新标准,关注新技术,c++11/14/17、lambda、右值引用、move语义、多线程库等,c++推出已经13年了98/03标准到c++11标准,编程语言的思想在这13年里有了很大的发展。新的c++11标准吸收了其他语言的很多新特性。虽然新的c++11标准主要依靠引入新的库来支持新的特性,核心语言的变化很少,但新标准仍然引入了移动语义等核心语法变化。每个CPPer都应该了解新标准。##OOD设计原则不废话+设计模式六大原则(一):单一职责原则+设计模式六大原则(二):里氏代换原则+设计模式六大原则(三):依赖倒置原则+设计六大原则模式原则(四):接口隔离原则+设计模式六大原则(五):迪米特定律+设计模式六大原则(六):开闭原则##熟悉常用设计模式,灵活学习和运用,不是生搬硬套神化设计模式和反设计模式,这不是科学态度。设计模式是软件设计经验的总结,具有一定的价值;GOF书中的每一个设计模式,都有专门的段落说明其应用场景和适用性,限制和缺陷鼓励在正确评估得失的情况下使用,但显然,你需要先准确地得到她.