C++中的那些坑

前言

学习一下公司大佬的C++课程,内容如题。

笔记:

几个常问题

问题1. 不初始化变量可能引起,在release下出现crash或死锁但是在Debug下正常运行,已经其它不可预测的运行结果,很难跟踪,所有变量最好在声明时就初始化,而且变量最好是需要使用时才声明,不要提前声明。

问题2. 程序只在Release下会崩溃或死锁,除了前面提到的变量为初始化,还有可能是因为:

  • 多线程竞争导致程序未按预期运行,出现资源无法满足(不理解)。
  • 由于性能的提高导致原本使用较少的资源出现了竞争。
  • 优化导致代码顺序改变或代码被意外优化掉了。

问题3. 像除0这样的操作会产生无意义的值NaN,用无意义的值NaN进行运算,会导致无明确定义的结果,在不同平台或编译器结果不一样,常见的运算结果是NaN、0。调试时可以使用std::isnan来进行捕获。

问题4. C++程序在启动过程中会初始化哪些变量

  • 静态初始化,编译时就确定值的全局变量或常量。
  • 动态初始化,函数(包括构造函数)计算获得值的全局变量。
  • 初始顺序是定义的顺序,销毁则是相反的顺序(注册到atexit)。

问题5. 函数内定义的静态变量,是在代码第一次执行时才初始化,并且就构造并初始化一次。虽然说第一次制作时才构造并初始化,该变量的空间占用确是程序执行前就会有了,和全局变量一样,销毁也和全局变量一样。

问题6. 怎么避免复制函数返回的临时变量?

  • 最简单的办法,将这个临时变量赋值给一个新变量,但是性能完全取决于拷贝构造函数,一般都不好。
  • 提供右值引用构造函数(A(A&& other)),或者右值引用复制构造函数(A& operator=(A&& other)),右值运算使用的std::move内部是对地址进行交换,没有复制操作,所以效率极高,右值概念是C++11及以后版本才有的。

问题7. wchar_t(宽字符) 的空间,Windows相关为2字节,Linux/Unix相关的为4字节,如果要考虑跨平台,不要使用wchar_t,可以使用uft8或utf16这种平台无关的编码

问题8. 代码的提交日志我们应该要写明什么内容?

  • 解决了什么问题,或添加了什么功能 what
  • 如果是解决问题,问题的根本原因是什么,为什么你的修改能修改掉这个问题 why
  • 是否还有遗留的问题或者需要注意的事项
  • 如果是新加功能,简单描述功能包含的内容

问题9. 单例模式有哪些写法?他们的问题是什么?

  • 使用全局静态变量、静态成员变量或本地静态变量,线程安全,用与不用都会存在,初始化顺序无法保证,难以控制对象的销毁,全局变量无法保证唯一性
  • 使用全局静态指针或静态成员指针,只有在使用时才会创建,线程不安全,需要自己处理多线程访问,可以提供销毁函数用于手动控制对象的销毁(如果后面有访问将会重新创建对象)
  • 使用本地静态指针,只有在使用时才会创建,C++11以上能保证线程安全,可以手动控制对象的销毁(如果后面有访问该对象程序将崩溃)

变量大总结

1.默认初始化,但其实内置变量如果没有特别处理,是不会初始化的,仅仅是在内存中占位声明。

unsigned count; // Default-initialized
unsigned count; // Default-initialized

2.值初始化

new unsigned();
unsigned count(); //有点像个函数调用
unsigned count{
    
    }; //C++11初始化数组,元素全部初始化为0

3.直接初始化

unsigned count(5); // 这样写是个人都知道这玩意初始化了
Counter count(5); // 调用构造函数,如果默认初始化要确定类确实有默认构造函数
Counter count(5, true);

4.拷贝初始化,依赖拷贝构造函数。

Counter count = 5; // 这种不能使用显式构造函数(explicit)
Counter count = Count(5); // 这种可以使用显式构造函数

5.列表初始化(List-initialized),依赖支持C++11或更新的标准。

unsigned counts[] {
    
    2,4};
Counter count {
    
    5};
Counter count {
    
    5, true};

6.全局/文件/类/命名空间范围静态变量的初始化

  • 如果没有初始化静态变量一般来说是0,但是也不一定,所有一定要初始化
  • 建议使用显式的初始化
  • 一般来说,都是在程序开始运行时(main函数开始执行前)或者在加载时初始化的
  • 在程序退出时(main函数执行之后)或者卸载时销毁

7.全局/文件/类/命名空间范围动态变量初始化,由于动态变量初始化时机为运行时,时机不确定,所以不要在程序中依赖动态变量的初始化顺序。

// Counter.h
class Counter {
    
    
public:
	explicit Counter(unsigned initial = 0);
	unsigned getCount() const;
};

// Source1.cpp
Counter gCounter1;
// 这样写没问题,可以保证gCounter1构造并初始化了
Counter gCounter2 {
    
    gCounter1.getCount()};
// Source2.cpp
// 这样写有问题,因为Source2.cpp运行到这里时gCounter1可能还没有构造并初始化
Counter gCounter3 {
    
    gCounter1.getCount()};

8.局部静态变量初始化,之前提到过在函数第一次运算时初始化,建议使用显式构造,和动态全局变量一样要注意初始化顺序依赖,初始化过程能保证线程安全。

Allocator & GetAllocator()
{
    
    
	static Allocator sInstance;
	return sInstance;
}

9.局部非变量,如果为初始化将是未知值,需要程序员保证它的线程安全,加锁什么的。当离开范围时自动销毁。

{
    
    
	std::lock_guard<std::mutex> lock(dataMutex);
}

10.标量类型(Scalar types)中的坑,标量指内置的什么char啊,float啊,无符号的那一堆东西。可惜内部视频无法截图发互联网,只能手打了。

  • 标量的溢出问题,数字太大装不下,或者高字节类型转低字节类型时。
  • 无符号类型-有符号类型的相互转换。
  • float和double的性能不一样,一般来说float的精度都够用了,double在特别情况下才考虑。
  • 无符号整数在溢出时会保证进行warp around(文明的核平甘地bug),有符号整数溢出会发生未定义行为。
	void* buffer = malloc(-1); // 实际会分配0xFFFFFFFF... bytes

11.除0,浮点溢出,算0的平方根等算数错误会导致未定义行为(NaN计算结果),或者导致代码被意外优化掉,导致代码在Release下崩溃。

	unsigned char* createBuffer(int length)
	{
    
    
		// clang编译器会将这一行完全优化掉 比较length + 1肯定比length大 结果永远为false
		if(length + 1 < length) return nullptr;
		unsigned char* buffer = new unsigned char[length + 1]// ...
	}

12.请使用更多的本地变量。本地变量更少的暴露到外部,性能更好,需要的管理更少(系统已经帮你管完 了),线程安全,还更好理解,命名可以很简单。

  • 不要为了几个函数之间的数据共享定义一堆全局变量,本地变量什么时候用就什么时候定义,同样也不要为了类内的几个函数共享参数而定义一大堆成员变量,成员变量只写类的通用变量就行了,过多的成员变量会影响类的理解,对于函数共享数据请使用参数传递。
  • 使用更多的本地变量而不是动态分配的变量,一是动态分配难管理,二是本地变量在栈上分配效率很高。但是要注意,栈的空间是很有限的(不要超过8k),分配一个超大的数值可能导致栈溢出,所以我们一般不把大的对象,数组一类的往栈上分配。
  • 将本地变量放到越小的范围会更好。
  • 注意类实例的构造&析构开销。

函数大总结

1.如果要传递大的结构或者对象,请使用const引用而不是直接传值,如果不需要改变参数,使用const的指针或引用而不是非const的。const不仅仅是给编译器看的,也是给代码阅读者看的,表明这个参数不会改变。对于返回值,返回大的结构体或对象,通常也使用引用或指针(数学库中可能会直接返回整个结构体)。

bool login(const LoginOptions & options);

2.函数参数不要写太多,如果实在不行就将参数做成结构体或类单独传递,太多的参数不利于阅读理解。

3.函数参数是引用,请不要将指针指向的对象传过去,因为指针有可能是空指针。遇到使用别人的库而迫不得已时要做安全性检查,自己写的情况下可以重载指针参数版本的函数。

4.在函数开始时使用断言(断言(assert)的用法)捕获错误,能够在Debug期间更快的发现问题并解决问题。

bool loadTexture(const char* name, int numMips)
{
    
    
	assert(name && *name); //期望name不为空且name指向的值不为0
	assert(numMips > 0);   //期望numMips大于0
	// assert发现问题以后的处理 出现问题程序也要能正常运行
	// ...
}

5.在函数退出前释放资源(包括文件忘了关,句柄忘了关,网络socket忘了关,内存忘了释放)

  • 函数常常会提前退出,需要记住去释放资源。
  • 动态内存分配请使用智能指针(C++11)。小提示:std::unique_ptr可以使用自定义deleter(lambda)。

类的大总结

1.C++11及以 后类成员声明了就会默认初始化,而在以前的C++版本必须写在构造函数里面。

2.对于代码量少的函数请声明inline,但是要注意一下inline函数不要声明在.cpp文件里面,否则只有在这个cpp文件里面调用才是内联的,其它文件中调用都不是。

3.在有继承的情况下,基类析构函数一定要写成虚析构函数。

4.循环#includes 会导致奇怪的编译错误,包含太多头文件会减慢编译速度。所以如果可能,在头文件中前向声明(forward declare)其他要使用的类,在.cpp文件中再#include。

// Nope
#include "ClassB.h"
class ClassA{
    
    
	// ...
	ClassB* mMemberB;
};

// Yes
class ClassB;
class ClassA{
    
    
	// ...
	ClassB* mMemberB;
};

这种声明在仅仅通过指针和引用的方式使用这个ClassB时可以这样做,因为引用和指针不需要细节,仅仅在使用时才去查看,而我们在.cpp文件中才会使用,所有在.cpp中#include刚好满足要求。如果直接使用,只能完整包含ClassB的头文件。

代码规范大总结

1.避免硬编程(hard code),硬编程可能会产生错误并且难以理解。除非是有些时候的封装需要和保护措施。

2.变量,函数,类,文件的名字要有意义,一个一看就懂的名字好过大量注释。不要写无用注释,也不要将注释写的看不懂,如果一个函数(类)太复杂以至于注释都不好写了,说明这个函数(类)需要被拆分。注释的目的是为了帮助他人更快速的看懂代码,以及帮助你几个月后还能看得懂自己的代码。如果要正式的话,注释需要用英文写。

版本控制大总结

1.改一个东西做一次提交,不要全部改完了再一起提交,好几个修改一起提交会让人难以理解,也会导致后期对某个问题的查看出现困难。

2.代码提交前请自查一次代码规范。

3.修改请及时提交。一般来说,下午做一个修改,每天早上提交最好,否则如果你晚上提交的版本有问题会导致第二天早上影响QA的工作。

4.当需要开发新Feature时请分出一个分支单独开发,开发完成以后再和主分支做整合。

汇编和链接大总结

1.开发项目时请不要变更开发环境,否则可能会导致:

  • 编译器/CRT/SDK版本变化,导致类的布局变化
  • 宏变化:类成员可能会被宏禁止,改变布局
  • 包含路径变化:相同文件名引用不同的头文件
  • VS CRT:静态库链接错误,导致无法安全地传递std::string(顺便说一下,一般游戏引擎都有自己的string或者使用C风格字符串而不用标准库string标准库string有时候过于多功能了)
  • RTTI:链接失败,错误表现…
  • 出现不太常见的中断选项:对齐、有符号/无符号字符…

2.关闭确实无害的warning,去除所有的警告。当然也不是说要把所有警告当成error来看待。有些警告99%表明了有BUG,而很多警告其实是无害的。

SDK和第三方库大总结

1.当我们编写shader,打包,做数据等等时都会用到很多第三方库,第三方库可能是库也可能是可执行程序,通常我们需要将SDK根路径公开为环境变量以方便访问。


猜你喜欢

转载自blog.csdn.net/qq_37856544/article/details/121785995