C++陷阱与用法

前言

  • C++是一门很容易采坑的语言,所以对其一些常见的陷阱和误解进行收集与总结,同时对一些新的特性进行了解。
  • 此文包含了自己遇到的问题以及在公司看到一位师兄写的文章的一些内容,外加一些网上看到的(代码已验证)

正文

  1. 编译器为什么不给局部变量和成员变量做默认初始化

    • 因为效率,C++被设计为系统级的编程语言,效率是优先考虑的方向,c++秉持的一个设计哲学是不为不必要的操作付出任何额外的代价
    • 从安全的角度出发,定义变量的时候赋初值是一个好的习惯,很多错误皆因未正确初始化而起,C++11支持成员变量定义的时候直接初始化,成员变量尽量在成员初始化列表里初始化,且要按定义的顺序初始化
  2. 全局变量

    • C++在不同模块(源文件)里定义的全局变量,不保证构造顺序;但保证在同一模块(源文件)里定义的全局变量,按定义的先后顺序构造,按定义的相反次序析构
    • 如果全局变量有依赖关系,那么就把它们放在同一个源文件定义,且按正确的顺序定义,确保依赖关系正确,而不是定义在不同源文件;对于系统中的单件,单件依赖也要注意这个问题
  3. new与delete

    • delete之后加上语句delete p; p = nullptr;(避免重复释放)
    • 保证delete[]匹配new[]delete匹配new
    • 例外:typedef T type[N]; T * pT = new type; delete[] pT;
  4. 模板特化

    • C++ 本身要求,那几个自动生成的特殊的构造函数以及运算符必须是非模版
    • 模版产生的函数一定与普通函数不等价。也就是意味着,模版无法生成那些函数与运算符,也不能重写虚函数
    • C++ 不允许在类域内显式特化类成员函数
  5. enum hack

    • enum hack的行为更像#define而不是const,如果你不希望别人得到你的常量成员的指针或引用,你可以用enum hack替代之。(为什么不直接用#define呢?首先,因为#define是字符串替换,所以不利于程序调试。其次,#define的可视范围难以控制,比如你怎么让#define定义的常量只在一个类内可见呢?除非你用#undef
    • 使用enum hack不会导致 “不必要的内存分配”
    • 使用技巧:

      1
      2
      3
      4
      5
      6
      7
      class Game {
      private:
      // static const int GameTurn;
      enum {GameTurn = 10};
      int scores[GameTurn];
      };
      // const int Game::GameTurn = 10;
  6. #define宏

    • 多用圆括号#define ADD(a, b) ((a)+(b))
    • 看情况使用花括号(用大括号将宏定义的多条表达式括起来)(if语句`do{}while(0)语句`等)

      1
      2
      3
      4
      5
      6
      #define SWAP(a,b) \
      { \
      a ^=b; \
      b ^=a; \
      a ^=b; \
      }
  7. 结构体的string赋值

    • 代码1:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      #include <iostream>
      #include <string>
      #include <cstdlib>
      using namespace std;

      struct World {
      std::string name;
      struct World *next;
      };

      int main() {
      World *wr = (World*)malloc(sizeof(World));
      wr->name = "hello";
      cout << wr->name << endl;
      return 0;
      }
      • 最终的输出不是简单的一个hello,可以发现返回的code不是0并且会运行很长时间(Segmentation fault)。原因是这里用的是C中的malloc而不是new
      • new在分配内存时会调用默认的构造函数,而malloc不会调用(string没有调用构造函数导致错误)
    • 代码2:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      #include <iostream>  
      #include <string>
      using namespace std;

      int main() {
      std::string s = "hello";
      const char *str = s.c_str(); // 指针常量
      cout << str << endl;
      s[1] = 'm';
      cout << str << endl;

      return 0;
      }
      • 两个输出分别为hellohmllo。原因是string的c_str()返回的指针是由string管理的,因此它的生命期是string对象的生命期,而string类的实现实际上封装着一个char*的指针,而c_str()直接返回该指针的引用,因此string对象的改变会直接影响已经执行过的c_str()返回的指针引用
  8. eof与fail

    • 只要遇到结束符,流就会将状态置为EOF,而不管置位前的操作是否成功。因此,不能在调用函数后通过eof来判断函数调用是否读到文件末尾了,而应该直接判断调用本身是否成功
    • 在文件eof的时候也会同时置fail,所以,循环读取文件的时候,要将fail和eof结合起来使用:在循环判断中使用fail,fail失败后再使用eof
  9. stream与buffer

    • 前面stream后面buffer
    • 作用:代表一个设备 数据临时存储
    • 访问方式:FIFO 随机访问
    • 数据内容:字符流 二进制

    • 当然,并不是说stream和buffer就毫无关系了,stream为了提高性能,实现的时候就用到了buffer

  10. 操作符短路

    • 考虑以下函数:

      1
      2
      3
      4
      5
      void  Play::get() {
      if (getApple() || getMelon()) {
      eat();
      }
      }
      • 如果getApple()返回true,就不会就不会调用getMelon()了。这就是操作符短路
    • 修改如下:

      1
      2
      3
      4
      5
      6
      7
      void  Play::get() {
      bool apple = getApple();
      bool melon = getMelon();
      if (apple || melon) {
      eat();
      }
      }
  11. stl容器的遍历删除要小心迭代器失效,vector,list,map,set等各有不同的写法

  12. 理解const

    • 积极的使用const,理解const不仅仅是一种语法层面的保护机制,也会影响程序的编译和运行(const常量会被编码到机器指令)
  13. 四种转型

    • 尽量少用转型,强制类型转换是C Style,如果你的C++代码需要类型强转,你需要去考虑是否设计有问题
  14. 规范的代码

    • 打开的句柄要关闭,加锁/解锁,new/delete,new[]/delete[],malloc/free要配对,可以使用RAII技术防止资源泄露,编写符合规范的代码
  15. 抽象基类的析构函数要加virtual关键字

    • virtual dtor跟普通虚函数一样,基类指针指向子类对象的时候,delete ptr,根据虚函数特征,如果析构函数是普通函数,那么就调用ptr显式(基类)类型的析构函数;如果析构函数是virtual,则会调用子类的析构函数,然后再调用基类析构函数
  16. 避免在构造函数和析构函数里调用虚函数

    • 构造函数里,对象并没有完全构建好,此时调用虚函数不一定能正确绑定,析构亦如此
  17. 协议尽量不要传float,如果传float要了解NaN的概念,要做好检查,避免恶意传播

  18. 字节对齐

    • 字节对齐能让存储器访问速度更快。
    • 字节对齐跟cpu架构相关,有些cpu访问特定类型的数据必须在一定地址对齐的储存器位置,否则会触发异常。
    • 字节对齐的另一个影响是调整结构体成员变量的定义顺序,有可能减少结构体大小,这在某些情况下,能节省内存
  19. 减少依赖,注意隔离

    • 最大限度的减少文件间的依赖关系,用前向声明拆解相互依赖。了解pimpl技术。
    • 头文件要自给自足,不要包含不必要的头文件,也不要把该包含的头文件推给user去包含,一句话,头文件包含要不多不少刚刚好
  20. 整型一般用int,long就好,用short,char需要很仔细,要防止溢出

    • 大多数情况下,用int,long就很好,long一般等于机器字长,long能直接放到寄存器,硬件处理起来速度也更快
    • 由于字节对齐,用short,char可能大小并不能真正减少,而且1,2个字节的整型位数太少,很容易溢出
  21. std::map还是std::unorder_map

    • 想清楚他们的利弊,map是用红黑树做的,unorder_map底层是hash表做的,hash表相对于红黑树有更高的查找性能。hash表的效率取决于hash算法和冲突解决方法(一般是拉链法,hash桶),以及数据分布,如果负载因子高,就会降低命中率,为了提高命中率,就需要扩容,重新hash,而重新hash是很慢的,相当于卡一下。而红黑树有更好的平均复杂度,所以如果数据量不是特别大,map是胜任的
  22. 循环可终止

    • 示例:

      1
      2
      3
      4
      for (unsigned int i = 5; i >=0; --i)
      {
      // ...
      }
  23. 组合优先于继承,继承是一种最强的类间关系

  24. 了解延迟计算和分散计算

    • 分散计算是把任务分散,打碎,避免一次大计算量,卡住程序
    • 延迟计算和分散计算都是常见的套路
  25. 从输入流获取数据,要做好数据不够的处理,要加try catch;没有被吞咽的exception,会被传播

  26. 内存拷贝小心内存越界;memcpy,memset有很强的限制,仅能用于POD结构,不能作用于stl容器或者带有虚函数的类

    • 带虚函数的类对象会有一个虚函数表的指针,memcpy将破坏该指针指向
  27. 用户stack空间很有限

    • 一般而言,用户栈只有几兆(典型大小是4M,8M),所以栈上创建的对象不能太大;虽然递归函数能简化程序编写,但也常常带来运行速度变慢的问题,所以需要预估好递归深度,优先考虑非递归实现版本
  28. 函数调用的性能开销

    • 函数调用的性能开销(栈帧建立和销毁,参数传递,控制转移),性能敏感函数考虑inline
    • X86_64体系结构因为通用寄存器数目增加到16个,所以64位系统下参数数目不多的函数调用,将会由寄存器传递代替压栈方式传递参数,但栈帧建立、撤销和控制转移依然会对性能有所影响
  29. 安全版本函数

    • 用c标准库的安全版本(带n标识)替换非安全版本 ,比如用strncpy替代strcpy,用snprintf替代sprintf,用strncat代替strcat,用strncmp代替strcmp,memcpy(dst, src, n)要确保[dst,dst+n]和[src, src+n]都有有效的虚拟内存地址空间。;
    • 多线程环境下,要用系统调用或者库函数的安全版本代替非安全版本(_r版本),谨记strtok,gmtime等标准c函数都不是线程安全的
  30. 理解std::vector的底层实现

    • vector是动态扩容的,2的次方往上翻,为了确保数据保存在连续空间,每次扩充,会将原member悉数拷贝到新的内存块; 不要保存vector内对象的指针,扩容会导致其失效 ;可以通过保存其下标index替代
    • 运行过程中需要动态增删的vector,不宜存放大的对象本身 ,因为扩容会导致所有成员拷贝构造,消耗较大,可以通过保存对象指针替代
    • 理解at()和operator[]的区别 :at()会做下标越界检查,operator[]提供数组索引级的访问,在release版本下不会检查下标,VC会在Debug版本会检查;c++标准规定:operator[]不提供下标安全性检查
    • resize()是重置大小;reserve()是预留空间,并未改变size(),可避免多次扩容; clear()并不会导致空间收缩 ,如果需要释放空间,可以跟空的vector交换,std::vector .swap(v),c++11里shrink_to_fit()也能收缩内存
  31. std::sort()的比较函数有很强的约束

    • 如果要用,要自己提供比较函数或者函数对象,一定搞清楚什么叫严格弱排序
    • 尽量对索引或者指针sort,而不是针对对象本身,因为如果对象比较大,交换(复制)对象比交换指针或索引更耗费
  32. 用sprintf格式化字符串的时候,类型和格式化符号要严格匹配,因为sprintf的函数实现里是按格式化串从栈上取参数,任何不一致,都有可能引起不可预知的错误; /usr/include/inttypes.h里定义了跨平台的格式化符号,比如PRId64用于格式化int64_t

  33. 了解智能指针,理解基于引用计数法的智能指针实现方式,了解所有权转移的概念,理解shared_ptr和unique_ptr的区别和适用场景

后续

  • 首先很感谢我认识的师兄,在他们那里学到了很多的知识
  • 本文后面会不时的更新,有错误的地方点击右下角联系我,谢谢!
-------------本文结束感谢您的阅读-------------
谢谢你请我吃糖果!
0%