如何写出好看的C++代码
常跟人说,今年是我编程第十年了,听起来很夸张,不过是真的。经过2010、15和17年,现在算有自己的编码习惯和风格;但是每次回头看前一阵的代码,都会有很多地方不满意,甚至确信不该那么写;或许是因为懈怠,或许是并未熟记本应坚持的好习惯。
360离职前交接代码,老大帮我把1、2、3、4、5、6、7
改成enum
结构体,用XXXX_BIT
代替;甚为惭愧,于是把一坨if-else
改成switch-case
,具体内容抽象成函数。写go parser
那段时间,扶摇教给我如何写规范的git commit log
,以及scala
如何优雅的关闭文件描述符。这些细节让我印象深刻,好看的代码是如此重要,列一些点提醒自己时刻牢记。
函数里尽量不要出现数字和字符串常量
最低级的代码就是充斥着大量数字如0
、1
、-1
等,没人知道是什么意思,作为函数返回值,意义极其不明确;或者各种裸字符串"something wrong"
,每次都要跳到代码逻辑里修改。正确的做法是定义成宏或者const
常量,或者enum
(c++11以后考虑enum class
)。
不管是用作返回值,还是标志位、大小等,都会变得意义明确;而且放在合适的头文件,一次修改,全体收益。
我要是review
代码,绝对不给出现数字和字符串的通过。
#define INVALID_FD -1
#define STATUS_OK 0
#define STATUS_FAIL 0
#define TEXT_FAIL "something wrong"
if (get_file_content(data) == STATUS_OK) {
...
}
命名风格统一且清晰明了
最好能通过名字,一眼看出是类、函数、const
函数还是宏,是全局变量、静态变量、局部变量还是常量;是锁、文件描述符、socket还是指针;功能是get、set、push、pop还是del。类的成员变量和成员函数,也要保持一致的明明习惯,能通过名字看出是类成员。
其实参考内核代码就很好,例如Windows一般是大驼峰,而linux则是小写加下划线。而c++类名一般大写开头,宏一般全大写,而函数则全部小写加下划线;变量名加单字符前缀用以区分。
// 例如全局变量加g_, 静态变量加c_,类成员变量加_,局部变量什么都不加
g_global_num, s_static_num, c_const_num, m_local_num, _class_data
g_mutex, g_log_fd, g_server, client, sock, ret
控制好空格和回车
if(x>0){...}
for(int i=0;i<100;++i){
...
}
int x(){
...
}
int y(){
...
}
上面这段代码看着很乱,好看的应该是这样的:
if (x>0) {
...
}
for (int i=0; i<100; ++i) {
...
}
int x() {
...
}
int y() {
...
}
声明放在合适的头文件/源文件
尽量把公用的声明放到头文件,例如宏、通用函数,不仅能减少代码,还会促使人们更好的组织自己的代码结构。举个例子,redis代码风格很差,而nginx和leveldb简直是典范。
这还涉及到文件划分,什么函数或类应该单独抽出来,哪些可以混在一起;还是要根据耦合程度,完全独立且可能被其他地方引用的代码,就要公用。我见过每个文件都有一个类似convert_to_string
的函数,互相拷贝,连抽成一个common.cpp
都懒得做。
头文件放太多东西,也会导致编译速度下降,很多人提倡PImpl
,可惜这个要求太高了,最后难免弄得更乱。
但是有一点要牢记:项目里不出现两个功能一模一样的函数。
引用的每个头文件都要知道其作用,且规范引用路径
这个简直是编译灾难,而且新人看代码会一脸懵逼,想单独写个简单测试程序,甚至得把所有项目依赖搞过来。
要我说,就不应该有把所有头文件include
到一起,功能划分好,按需引用;而且引用路径要规范,有的是#include "test.h"
,有的是#include <code/src/include/test.h>
。
最好的结果是把源文件、引用的头文件拎出来,能直接编译;不然就是不规范。拔出萝卜带出泥,光引用的头文件就十几个,它们又引用一堆,代码怎么能算组织好。
函数参数尽量不要超过四个
经常见到这样的函数:
int set_something_to_redis(int count, int number, DataInfo* info, int timeout, bool exist, int& succ_num, bool& result, int& used_time) {
...
}
真的太长了,要么把参数组织成结构体:
#define __OUT
enum class REDIS_OPT {
REDIS_GET = 0,
REDIS_SET = 1,
...
};
struct RedisOptData{
REDIS_OPT opt;
DataInfo *info;
__OUT int succ_num;
__OUT bool result;
};
要么回车分割好,堆在一行真的不行。
控制函数行数,小屏幕也能看全
一个好的函数应该是这样的(每行也尽量注意,别有的太长,其他太短):
int good_function(Param* param)
{
int status = STATUS_OK;
do {
if (!check_param(param)) {
ERR_LOG(TEXT_PARAM_ERROR);
status = STATUS_PARAM_ERROR;
break;
}
if (do_thing1(param) != STATUS_OK) {
...
break;
}
...
} while (0);
release_resources();
return status;
}
整个屏幕能看到所有代码,如果已经足够抽象,还是太长,那就没办法,业务太复杂。
尽量让每条语句逻辑独立且意义完整
也是针对函数,从头看到尾,每句话都要跟这个函数相关,而不要把人带出去思考:这个是干啥的?怎么实现的不重要,做了什么一定要清晰,要么函数命名规范,要么加注释。
更进一步,需要多条语句完成的工作,为什么不抽象成另一个函数?如果觉得函数调用影响效率,inline或者lambda或者宏?
这里有一条边界,一个工作需要多个工作流程来完成,每个流程是一条语句(包括函数调用、for循环等语句块);如果一个流程需要不止一个操作,那么这个流程就是可以抽象成单独的函数。
这其实也对函数做了一定要求:函数名字即所功能实现,函数功能要么很新鲜很简短。大家应该都见过好多个代码相似难辨差异的函数,绝大多数流程一样,只是为了业务需求,拷贝过来做了简单修改,却留下一堆垃圾。
有一次,某个并不复杂的业务需求,同事写了个递归全排列(先不说代码的bug:错误检查、死循环等),这个风格并不好。如果需要全排列,我们可以写个生成全排列的函数,得到排列结果再进一步处理;而不是直接把业务逻辑揉在一起。
局部变量在最小作用域声明
经常见到函数开头几句定义一大堆变量,实际上很多只在某个for循环内部用到,这个完全可以定义在for里头。我的意见是尽量靠近第一次使用的地方定义,同时尽量限定在最小作用域;还有一个好处,在循环这种场景,每次都会执行默认初始化,不必担心历史残留数据。
int test(std::vector<Data*>& arr)
{
int status = 0; // 整个函数会用到
for (...) {
std::vector<Data*> tmp; // 这个就不用定义在外头了
get_tmp_from_somewhere(tmp);
arr.insert(tmp.begin(), tmp.end());
}
return 0;
}
不写超过两行的重复代码
if (cond1()) {
do_thing1();
do_thing2();
do_tingg3();
do_thing4();
} else {
do_thing1();
do_thing2();
do_tingg3();
do_thing5();
}
这里的三句完全可以当成一个函数,或者写个lambda处理,千万不要直接堆砌。一般乱代码就是从这种小地方开始的,往后大家就会照猫画虎。
参数检查统一且抽象成函数
通常我们的调用链都会比较长,复杂的功能都会层层向下调用,包括RPC;所谓统一
,指的是参数需要有一定保证,例如最基本的指针类型保证、地址合法性保证等。从安全角度考虑,每个函数都要做充分的参数检查,确保不会出现异常甚至漏洞;同时函数不应该做过多检查,只关注自己做的事情相关的部分,例如一个名为set_user_info
的函数,不应该关心具体的user_info
,只需要关心set
。
但是抽象成函数是必要的,这个不用多说,总不能开头一大段全是检查参数吧?
也可以组织成这种格式:
int do_work(Param* param)
{
if (!check(param->a)) return STATUS_ERROR_1;
if (!check(param->b)) return STATUS_ERROR_2;
...
reutrn do_work_internal(param);
...
}
尽量只有一条返回语句
这个和下一条注意事项相关,如果要错误检查,就可能会有提前返回,并且做资源释放。可以用do{}while(0)
技巧,把资源释放放到统一的地方,同时确保只有一个return,让函数更清晰,甚至能让编译器更高效的工作。
时刻注意资源释放和错误检查
这没什么好说的,错误检查、new(new会抛异常)完要delete、异常时资源回收,可以灵活使用析构函数。其实应该放第一条。
写最短和最高效的代码
这里就很虚了,具体的几个小case:
- 需要多层
if-else
时可以用!
+break
代替,尽量只用一层;通过逻辑分析,避免出现火箭代码 if-else
太多时考虑用switch-case
代替vec.empty()
代替vec.size() == 0
- 多用
typedef
(c++11用using) - 参数记得用
const T &
减少复制(c++11有所不同) - 偶尔增加个参数,再做功能分流,可能要比重载函数更好看
其他的想起来再加。。。
不要多次出现cout,只打必要充分的log
甚至不要出现cout,作为线上服务,这个东西没用;如果是为了调试,可以打debug log。打log也要做到充分必要,而且不能影响看代码,不然整个函数一般都是打log;能一次打印就不要多次。
适当了解内存管理和编译原理
尽管c++有boost库,包括往后的现代c++,都提供了丰富的智能指针,但是程序员了解些内存管理和编译原理知识还是很有帮助的。
最起码的内存知识,要知道堆栈变量和全局变量的区别、虚拟内存和物理内存、内核地址空间和用户地址空间、新建线程的栈从何而来、内存泄漏常见原因、共享内存原理、fork之后父子进程共享和不共享什么、线程共享和独占的内容。
前几天有朋友跟我说加了锁的代码还是会有竞态,导致异常,就是最常见的:
if (g_data == NULL) {
Lock();
g_data = new Data(); // 指令重排
Unlock();
}
理论上来说,确实会有问题;但实际上并不会,因为我们并没有用到编译优化。如果做过反汇编的话,就知道debug模式不加优化,生成的汇编极其啰嗦,指令虽然会重排,但是赋值给data时,绝对已经是调用过构造函数的。
积极拥抱c++11
上周末刚看完《Effective Modern C++》,一如既往的庖丁解牛,让人见识现代C++(C++11以后)的魅力。真是很难拒绝,尤其是C++14,堪称完美。
没什么理由不拥抱变化。
经常阅读优秀的代码
有几份代码对我影响很大:WRK、ReactOS和nginx,前两份是Windows内核代码,但是代码特别工整,注释也规范;nginx真的写的很好,不记得有几个函数超过了一屏,而且把函数拎出来基本都能看懂,自洽很重要。
不过真正对成长帮助大的,还是看业务代码,和框架依赖。每个部门都有引以为傲的好代码,我觉得看那些挺好,很容易吸收,也很容易发现问题,一来一回就有进步了。
尊重现实,尽量做好
客观原因确实存在——好多人并不在乎代码质量,老大们更不在乎,虽然他们写的基本都挺不错;还会有别的原因,时间紧、任务重等,或者就是不想让代码变清晰,觉得没意义。很伤感也很现实,尤其是需要改动老代码,往往吃力不讨好,只是看着更清晰了,也很少有人会因此夸夸你;还可能因为代码简短,觉得你没做事情;或者不小心引入bug,得不偿失。
但是职业操守要有,干一行爱一行,尽量把自己的工作做好吧。
吐槽一下,如果把“改代码”换成“服务重构”,为期三个月的大项目,就会有不少人抢着干了。呵呵啊,程序员不光要沦为搬砖工,还得抢着搬,要美其名曰“建筑基础工程优化”。