如何写出好看的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如何优雅的关闭文件描述符。这些细节让我印象深刻,好看的代码是如此重要,列一些点提醒自己时刻牢记。

函数里尽量不要出现数字和字符串常量

最低级的代码就是充斥着大量数字如01-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,得不偿失。

但是职业操守要有,干一行爱一行,尽量把自己的工作做好吧。

吐槽一下,如果把“改代码”换成“服务重构”,为期三个月的大项目,就会有不少人抢着干了。呵呵啊,程序员不光要沦为搬砖工,还得抢着搬,要美其名曰“建筑基础工程优化”。