在实际业务开发的过程中,经常发现有些缺陷反复出现,这里总结出业务中常见的问题进行归纳,以便警示大家的同时,为后续开发质量提高用做帮助。此清单的意图是为了提高本组代码质量和开发水平。
序号 |
缺陷描述 |
缺陷举例 |
缺陷造成的程序影响 |
1 |
入参在函数中仅作为查找使用,但将入参值传递 |
int Search(ComplicatedClass cc); |
值传递会造成性能开销,并且值传递在继承情况下会造成对象切片效应。 注意,当做只读但是还是按照值传递的情况,大多数情况只有三种 1. 内置类型,例如int,short,char etc.. 2. 迭代器对象,例如 auto it = listobject.begin(); 3. 函数对象,例如 std::function 具体原因有兴趣深究可以看30服务器中的Effective C++中文版[第三版] P89,这里直接截图结论。 |
2 |
所有会抛出异常的接口一定在调用外层需要catch |
for(int i = 0; i < infiMsgHead.labelNum; ++i) |
会抛出异常的接口如果不catch住,那么程序必定会崩溃。 这里提示出常见的基线中会遇到的异常接口,std::stoi,std::stol等等sto系列,std::map的at接口(欢迎补充) 另外需要额外提出,一点注意,范围过大的catch是不合适的,一般建议只在异常抛出的那行语句上加上catch。特别注意,直接在main函数的业务逻辑层上加catch是被禁止的。 另外额外需要注意,对于异步函数的异常是无法通过显式try_catch机制实现的。因为通常异步函数的定义处外围加上try catch毫无意义,因为那个定义处只是声明。所以异步函数尽量使用无异常的函数。如果真的不幸必须处理异步函数中的异常,请学习std::future。他能给你一个好的办法。 具体逻辑和背后原因可以学习The Modern Effective C++ Item 38的前两段。 |
3 |
函数实现中,反复使用复制粘贴代码,导致无谓的查找/函数调用开销 |
laneTmp.currentCycleGreenOnQueueLength = (double)iter->GetQueueLength(curCycIter->second.phTimeInfos[phaseId].phBeginTime);; if(cycleTimeInfo.size() > 1) { curCycIter++; laneTmp.lastCycleGreenOnQueueLength = (double)iter->GetQueueLength(curCycIter->second.phTimeInfos[phaseId].phBeginTime); laneTmp.passVehicleNumBetweenGreens = (double)iter->Flow(curCycIter->second.phTimeInfos[phaseId].phBeginTime, cycleTimeInfo.rbegin()->second.phTimeInfos[phaseId].phBeginTime); laneTmp.greenOnInterval = cycleTimeInfo.rbegin()->second.phTimeInfos[phaseId].phBeginTime.tv_sec - curCycIter->second.phTimeInfos[phaseId].phBeginTime.tv_sec; 注:phTimeInfos对象是一个std::map。 |
在取用标红字段的时候反复使用了多次,导致每次使用都需要在map中进行查找phaseId的值。这种情况应该针对常用部分设置一个引用别名。可以规避重复查找。提高效率的同时也提高了易读性。 |
4 |
入参未判断合法性,或者错误判断合法性 |
未判断合法性: static inline uint16_t CRC16(uint8_t *p, uint16_t len) 上述代码中的p使用前未判空。 错误判断合法性: static inline uint16_t CRC16(uint8_t *p, uint16_t len) 上述代码中判断len > 0。 |
未判空直接使用,或者使用范围越界大部分情况是造成程序崩溃abort,段错误。但实际上这种情况是最为“良好“”的结果,因为程序直接告诉你错误。有些情况会不报错继续运行,实则更难排查,体现更加诡异。 常见需要判断合法性的参数是:指针(普通,智能均需要),长度(数组,容器等任何可以理解为长度语义的入参) |
5 |
std::map容器使用operator[]操作的时候,未判断存在性 |
for(; int 这里的phTimeInfos是一个map,在使用的时候并未判断phaseId是否在map中有。 |
map的operator[]操作符十分特殊,在不存在的时候不会报错不会返回空,会就地使用容器传入类型的默认构造增加一个值。最常见的错误就是本来容器里只有123,然后经过一次不合理的查找,多出来了一个4。而且4里面的值全都是空的。
所以除非特别肯定,使用operator[]操作符的时候,map里真真真真的一定存在对应元素,那么可以不判空,但是一定要有对应注释突出真真真真的原因。 另外一点不同的,map容器的emplace接口并不是替换的语义,这里切记。 在例如 std::map a.emplace(1,1); a.emplace(1,2); std::cout << a[1] << std::endl; //这里输出的是1,不是2 |
6 |
C++11遍历语法,或者查找谓词入参使用值而并非引用 |
C++11遍历语法: for (auto bar = cycle->begin(); bar != cycle->end(); bar++)
查找谓词中: int main () { std::array if (it != foo.end()) return 0; |
和第一条类似,但这里额外列出这两点常见的容易误写的错误,以示警醒。 错误原因同第一条。 这里合适的做法是使用常引用: for (auto bar = cycle->begin(); bar != cycle->end(); bar++) int main () { std::array if (it != foo.end()) return 0; |
7 |
原子变量却使用了非原子变量用法 |
if (!running) //running是一个std::atomic_bool |
这种使用方法无法保证running一定是在running为false的时候,执行的running = true。因为在判断running的true和false后,其他线程可能已经将running设置成true了。详细的原因和背后逻辑可以用搜索引擎查找关键字,CAS,多线程。
这里正确的用法是使用atomic类的exchange接口或者使用compare_exchange_strong |
8 |
正确的使用std::move |
1. 局部变量用做返回值的时候,不应该move,否则会抑制RVO。造成逆优化。 PhaseWeakPtr Barrier::find(uint8_t phaseId) const 2. 对于常引用使用move并没有任何实际作用。 for (const auto& channel : SignalLightList) { 3. 对于局部变量用作传参给其他函数的时候,复杂结构体建议使用move。 } else { 4. 对于设置类语义的接口,一般建议入参设置成右值传参。 void rpc_client_core_sub::set_subcribe(std::function 5. 临时变量本身就是右值,没必要进行move转换一下。 case parameter_value: 6. 如果传入的右值/move进来的值,需要多次使用的时候,只能在最后一次使用move,前面只能用值传递。 |
|
9 |
构造函数应初始化所有成员,并且尽可能使用初始化列表构造 |
struct reply reply(const T& t, const std::string& format) //获取配置的回复 |
构造函数的目的,通常只有一件目的,就是将此类的所有成员函数均初始化好。或者说,构造函数至少必须要完成这个目的。
另外C++11的优化在于,可以使用初始化列表的方式,另构造更加高效,避免二次构造成员变量。左侧例子里应该将status这个成员变量放在初始化列表中。 |
10 |
STL容器在有empty接口的情况下用size接口判空。 |
if (ring.size() == 0 && p.id > 0 && p.id < MAX_PHASE_NUM) { |
empty()接口一般都是经过优化的,STL容器中可能会使用一个固定标志位来确定是否为空。而size()方法的实现不同stl不一样,低版本gcc的某些容器的size()接口是需要for each遍历一把容器的。在这种情况下,size() == 0的开销时间复杂度就是O(n)了。 |
11 |
非特殊原因情况下,使用全域enum类型 |
enum status_type |
全域枚举的弊端很多,最明显的就是容易表意不明,并且作用域范围太广容易造成不必要的问题。相比之下enum class的用法则更加C++,在传参的时候,需要保证类型匹配,在使用的过程中表意会更加明确,书写的时候,由于有了类型声明要求,表意也更加明确。 更多的弊端这里不详细列出,具体理由可以详细查看《Effective Modern C++》书中的第10节 优先选用限定作用于的枚举型别,而非不限作用于的枚举型别。 |
12 |
继承类虚函数应该显式override声明重写,并且子类接口或者整个子类中一定不能被继承的时候,显式声明final。 |
class Inductive : public PolicyBase { public: Inductive():PolicyBase(FULL_INDUCTIVE_MODE),log(Singleton virtual void apply(const std::shared_ptr virtual void initCycleTime(const std::shared_ptr }; |
这里应该使用override。 原因在于,若不小心写错了,写成: virtual void aooly(const std::shared_ptr 编译器并不会报错,会认为这是一个新的接口。而注明override的接口必须是有基类对应实现的。 若不想这个接口或者整个子类继续被继承,那么也应该显式的表明final。例如:
class Inductive final : public PolicyBase { public: void apply(const std::shared_ptr }; ps.这里只是示例,实际使用中,感应类不应该是final。 |
13 | new / make_shared 后生成的指针/智能指针,使用nullptr进行判空 | void loop(unsigned int msec, std::function { TimerSharedPtr ptimer = std::make_shared if (!ptimer) return; timer_loop(ptimer, msec, task); } |
new 和 make_shared不会返回空指针,在内存不足的时候,默认的行为是抛出异常。可以看到make_shared的源码是这样的(以下取自GCC4.9.2) /** * @brief Create an object that is owned by a shared_ptr. * @param __args Arguments for the @a _Tp object's constructor. * @return A shared_ptr that owns the newly created object. * @throw std::bad_alloc, or an exception thrown from the * constructor of @a _Tp. */ template inline shared_ptr<_Tp> make_shared(_Args&&... __args) { typedef typename std::remove_const<_Tp>::type _Tp_nc; return std::allocate_shared<_Tp>(std::allocator<_Tp_nc>(), std::forward<_Args>(__args)...); } 也就是只要函数返回了,那么返回值一定不为空,左边的标红地方代码为死代码。 但额外的是,如果你真的喜欢用nullptr来判空,对于new而言还是有办法达到的。 Task * ptask = new (std::nothrow) Task; if (!ptask) { std::cerr<<"allocation failure!"; } 注意,这里的写法是固定的,一定要有小括号,并且放在new后面。这样就可以屏蔽掉默认的抛出异常行为。 |
14 | 对于返回是值的函数,使用链式写法 | std::shared_ptr if (!curPhaseSptr) { //currentPhases返回的是一个std::list return; } |
左侧写法等效于 std::shared_ptr { auto tmpItem = _cycle->currentPhases().front(); curPhaseSptr = tmpItem.lock(); } 这里的curPhaseSptr 实际上是一个已经析构掉的临时变量的某个特性,极有可能已经是无效的或者是非法的。所以接下来的判断语句等效于对一个悬空值进行判断。 if (!curPhaseSptr) { return; } 这里额外延伸一下,对于返回值是引用的则可以使用链式写法,因为本身就是一个引用,从而生存期并不是由这里判断而是被引用的那个真实的地方是否析构判断。 但无论如何使用链式写法的时候,一定需要谨慎考虑生存期问题,尤其是链子中间返回的是值的时候。 |
15 | 设计基类的时候,基类的析构函数未写,或者析构函数并未标记成虚函数 | class FuncBase ~FuncBase(){} 错误写法2(并没有声明为虚函数) bool available(int msec = 100); ..... }; |
左侧的两个标红写法均有问题。 基类的析构函数没有声明为虚函数,析构的时候,基类指针只会调用基类的析构函数,而不会调用派生类的析构函数,从而导致所有派生类的析构函数不会被调用,派生类资源无法正确释放,造成内存泄漏。 |
16 | 如非真的必要,不要随意在类中使用静态变量,或者类方法中使用局部静态变量 | class StreamClient : public std::enable_shared_from_this { public: StreamClient() : { static void clean(const char* _dir, const char* _name) { static bool once = true; if (once) { .............. } } } |
类的静态变量和类方法中的局部静态变量的作用域都是每一个类对象共享的,绝大多数情况下,和我们真实想使用的经常是不一样的。因为静态变量这玩意绝大多数情况下,是在C时代想要实现C++类变量作用的一个奇技淫巧罢了。在C++时代,我们更加推荐的是使用类变量,而不是静态变量的手法。尤其是局部静态变量的作用范围,切记切记。 #include void g(Test a) { int Test::i = 0; int main() { |
17 | 毫无意义的缩进或者空格增加提交 | - |
这两行提交实际上完全没任何改动,只是因为不小心有个下面多了一个缩进。但因为提交代码的时候,并没有认真对比。 但这种提交一时爽,维护火葬场的行为。直接导致了所有审核人,在审核代码的时候都需要仔细逐字逐句对一遍,才发现是多了个空格或者缩进,并且后续查看代码改动的人又需要重新看一遍。会极度降低后续每一个触碰到这份提交的人的效率和时间。 所以提交代码请千万多花一点点时间,谨慎检查每一份提交,杜绝无意义的代码提交。 |
18 | 警惕 std::vector 的动态扩容与就地构造 | m_streams.emplace("cdmSub", make_unique m_streams.emplace("coreSub", make_unique m_streams.emplace("detectorSub", make_unique m_streams.emplace("cdmFunc", make_unique m_streams.emplace("coreFunc", make_unique m_streams.emplace("detectorFunc", make_unique 备注,这里的m_streams是一个std::vector容器,并且在一个构造函数中后续不会再对m_streams对象新增对象 |
C++11后开始常用容器中都增加了就地构造,这极大的提升了容器中新增元素的效率,不用外部生成一个然后复制进容器了。左侧代码成熟的利用了就地构造的特性完成了元素置入容器。但同时,左侧代码忽略了另一个隐藏的开销,元素的移动。 这里置入了 6 次,但在内存较为零散或预分配不足的时候(实际上对于嵌入式系统这种场景极为常见),这里却会移动 7 次。分别在添加第2,3,5个元素时移动所有容器中的内容,1 + 2 + 4。 这里为什么会移动所有元素的动作呢?是因为vector的特性是每次扩容元素容量等于当前容量大小乘以二,在原有地址上直接扩容空间翻倍后如果没有足够空间,分配器则会寻找一片能够容纳新空间的地址,然后把现在容器内的元素都移动过去。所以别看仅仅放进去了6个元素,但足足移动了7次。虽然后续随着元素的依次增加,发生移动的触发点只是log(n)的复杂度,但由于触发后每次移动的元素依旧是n,所以对于移动的开销若较大,则应该注意这个隐形开销。 这里结合左侧代码的业务特点,往容器中填入元素的个数是固定的。那么直接使用std::array即可,若不想过多占用栈内存,且后续使用场景中,不需要进行无序访问,那么std::list或者std::forward_list也是可以的,均不会产生动态扩容,也能够支持就地构造。 |
19 | 警惕资源管理类移动构造函数的写法 | class STREAM : public COM { STREAM(const char *filename) : srcaddr(new sockaddr_un), dstaddr(new sockaddr_un) { ....... } ~STREAM() { close(); } void close() { if (sockfd == INVALID_SOCKET) return; ::close(sockfd); sockfd = INVALID_SOCKET; unlink(srcaddr->sun_path); } private: int sockfd; std::unique_ptr std::unique_ptr }; STREAM类是一个unix domain套接字的资源管理类 |
正如Effective C++中 Item 13提到的,以对象管理资源。这是一个很好的行为。但同时也需要警惕和注意,用对象管理资源的时候,移动构造函数与析构函数都干了什么,需要时刻保持警惕。 左边的写法存在一个隐形的失效问题,具体体现在于当对STREAM对象进行移动的时候,移动后的新对象无法正常继续进行通信。 错误点在于,这里并未写移动构造函数,虽然编译器会默认帮我们添加,并且勤勤恳恳的将每一个成员变量依次移动到新对象中,这种行为在绝大多数的时候都能满足我们的要求,而恰恰在我们这个场景下,是达不到要求的,因为虽然新的STREAM对象中有了原来的fd和两个addr,但遗憾的是,在旧对象被移动走,析构的时候,会把这个fd关闭掉,导致新对象即便有了以前的fd,也徒劳无效,因为被旧对象析构的时候干掉了。 所以这里,我们比较好的做法应该是自行书写移动构造函数,对于移动构造函数中,显示将被移动走的socketfd置为INVALID_SOCKET,则在旧对象析构的时候,不会进行真正的::close()动作。 这里给出一点延伸,用对象管理资源的手法很好,也值得学习和推广,但尤其需要注意,使用此手法的时候,需要警惕析构函数和移动构造函数,并且最好显式将5种构造函数均手动指定,即便是默认,也显式指定为 =default,如果禁止,则显式指定为 =delete; |