本项目C++开发中的常见问题汇总
编译时
OOP中的声明与定义问题
-
定义与声明文件
在cpp项目开发中都知道需要将声明与定义分离,这是由于include机制造成的。include机制只是简单的将目标位置嵌入对应文件中的代码,这意味着A.cpp中嵌入B.cpp,在链接过程中将会出现两次B.cpp的定义(一次在A.cpp的include处,另一次在链接B.cpp中)。将声明和定义分离后,尽管可能存在多次声明,但它们保持一致,是合法的;链接时只会链接一份定义。
以上做法既是一种规范也是一种惯例,如果有意打破会发生什么?比如使用hpp文件包含全部内容,更具体而言就是在头文件中给予类的成员函数的实现。答案是部分合法,仅当该文件在整个上下文中只出现一次。hpp是C++中的头文件,既能声明又能定义。但是如果出现多次,仍然会出现重定义问题。
所以遵循规范是一种最保险的做法。
-
Include Guard
为了解决循环引用问题,诞生了使用宏来保护整个文件的做法。具体方法如下:
#ifndef _XXX_H #define _XXX_H //... #endif
编译过程中如果已出现该部分,则
ifndef
使得这段重复内容将被自动跳过,从而避免了循环引用。需要注意Include Guard的保护范围是整个文件,包括所有引入的其他头文件和声明。否则仍然可能出现循环引用问题。
-
相互依赖
如果类A和类B具有相互依赖(你中有我,我中有你),则必然需要循环引用。尽管Include Guard解决了循环栈过深的问题,但它解决得太彻底以至于任何循环都不允许存在。因此极有可能出现的情况是A.h能引用B.h,但B.h再引用A.h时被Include Guard打断了。
解决方案是在B.h中显式声明类A,但不提供细节。这是合法的因为声明在完全一致的前提下可以重复。同时A和B各自的依赖只能为指针,因为指针在编译时不依赖细节。
-
命名空间
如果循环依赖涉及命名空间,也可以用重声明的方法解决。但是重声明不只是声明命名空间,还必须声明完整被该文件所使用的全部内容,因为命名空间并不是主体,命名空间中的内容才是主体,例如你无法使用指针指向命名空间。一种简化的做法是使用静态类,这样也只用声明类而不提供细节。
模板元编程中的声明与定义问题
-
定义与声明文件
对于模板函数以及单纯的模板类,使用分离的方式仍然是规范。但是对于模板成员函数,规则就被打破了,原因在于模板成员函数需要具体的类型的细节。当使用到模板成员函数,则必须将其就地实现。因为模板的特殊性,这并不会产生重定义问题。
OOP中的组织问题
-
继承、虚继承
普通的继承,也就是静态的继承,保留基类字段,类型转换时进行截取。虚继承解决基类重复问题,对于重复的基类仅保留一个备份。
如果设计出现菱形继承,不管是从规范上还是语义上都应当使用虚继承。
-
虚函数、纯虚函数
虚函数和纯虚函数使用的是vtable,使得子类可重写父类。如果编译中出现new_allocator的问题,大概率是直接使用了纯虚函数未被实现的虚类。
模板元编程中的组织问题
-
自动推导
很多情况下无法对模板类型进行自动推导。如返回值推导、局部变量推导。c++11中可以使用decltype进行显式推导,但是lambda作为类函数暂不能被推导。(c++中类似的反射机制都是静态机制,编译时完成,一切泛型设计都要遵照静态设计)
-
以字符串泛型
需要static const char *来使字符串泛型成立。可能引发一系列警告,例如使用了未命名的命名空间(修复方法未知)
-
继承
模板类不允许被继承,除非限定了类型。模板类可以继承一个基类来实现泛型多态。
指针与引用
-
类型转换
静态类型转换在编译时完成,要求非多态。动态类型转化在运行时进行,要求多态。一个强制多态的方法是显式定义虚析构函数。
类型转换中的模板类型要带上量词,同时要注意转换的是指针还是引用。智能指针需要
std::pointer_static_cast
或std::pointer_dynamic_cast
。 -
量词
在量词为const的成员函数中,成员变量都是const。如果需要使用非const形式的成员,则需要进行const类型转换。
链接时
重定义
除了真的重定义外,还有可能:
-
不规范的声明和定义
即多次重引用了hpp,引用了cpp等。
-
定义中用了错误的作用域运算符
定义时使用了基类或者其他类的作用域运算符。
-
.o文件未更新
如果在新文件中引入过去的声明,且过去声明的定义生成.o文件未被更新,则make自动跳过该文件。此时链接中出现多次定义,造成重定义问题。
-
引入了版本不对应的库
静态库的源文件和库文件不匹配。原因和.o未更新相似。
未定义
除了真的未定义外,还有可能:
-
Include Guard
尤其可能出现在模板成员函数中,错误的include结构导致无法看见具体的定义。需要更改include入口或重新组织include结构。
-
定义中忘了加作用域运算符
定义时使用了基类或者其他类的作用域运算符。
-
.o文件未更新
如果在新文件中取消过去的声明,且过去声明的定义生成.o文件未被更新,则make自动跳过该文件。此时链接中未出现定义,造成重定义问题。
-
引入了版本不对应的库
静态库的源文件和库文件不匹配。原因和.o未更新相似。
运行时
拷贝、引用、指针
在C++的OOP中格外关注三者的问题。普通的赋值,是拷贝,拷贝实际上是一个新的与右值不同的对象,类型转换还会带来Slicing的问题。引用则解决上述问题,是对象本身,但处理起来较复杂,引发左值右值引用的概念相关的问题。指针是一种动态引用机制,概念上简单,但是管理复杂。
-
拷贝
首先是比较难发现的拷贝误用。如果要对对象进行写操作,却使用了拷贝,那么被写的对象并不是对象本身,而是被新创建的对象。
再就是剪裁问题。父类的拷贝并不包含子类的字段,复制后全部为空,这使得多态产生问题。
-
引用
首先是返回值返回引用是一种不好的规范。返回引用当且仅当返回的内容在内存中持久化,例如对象的成员变量。返回临时变量必然会造成问题。如果目的是避免拷贝,解决方法是std::move。
对于右值引用,不能滥用。右值引用使得资源拥有权被转移,用来解决拷贝的额外开销问题。但是滥用则可能违背了程序本身的设计初衷,使得共享的资源被独占或被错误占有。
-
指针
只讨论智能指针。尽管智能指针将指针管理方便化,但并未完全解决内存问题。一个最全面的对问题的概括就是生命周期问题,具体而言就是希望对象应当在合适被释放。
不当的设计使得对象过早释放,产生空指针。解决方法是持久化的数据使用共享指针。
过晚的释放带来内存泄漏。例如循环共享指针会使得指针计数器错误,具体而言就是存在自己持有自己。这样对象自始至终无法被自动回收,造成内存泄露。
核心转储
-
空指针
使用智能指针前,先进行空指针判断。弱指针可以使用if null表达式:
if (auto p = wp.lock) //...
判断指针是否相同可以使用owner_before:
!(a.owner_before(b) || b.owner_before(a))
这是判断指针是否指向相同的对象。
-
错误的类型转换
动态类型转换在运行时进行了转换,但是转换发生了错误。
bad weak_ptr
主要来自enable_shared_from_this。
-
不是指针创建
对象不是指针创建的,就没有持有者,因此没有共享指针。 -
非public继承
enable_shared_from_this的原理是在make_shared等初始化时生成一个指向自己的虚指针。非public将这些内容隐藏了。
-
多继承
建议基类继承enable_shared_from_this。子类同时继承enable_shared_from_this则会造成冲突。
-
构造中
构造中不能使用shared_from_this(),原因是指针还没被创建。
bad std::function
使用了一个未初始化的std::function。解决方法也是提前判空,或者提供一个空的初值。
堆栈溢出
具体情况具体分析,一般来自于递归、while。
初始化
-
构造中的虚函数
构造中使用虚函数是危险行为。构造中还没进行动态绑定。
-
随机初始化
如果没有显式初始化,很可能初始化值不是零值,造成一些错误。