如何写出整洁代码

这几天在看Martin的《代码整洁之道》,感觉说的真的很好啊。其中最重要的思想和重构有一些相似:代码不止是给机器看的,更是给人看的。良好的编码风格可以帮助你更快速地找到Bug以及更新代码。

这里总结出每一章的要点,以便后期回顾。

有意义的命名

在“有意义的命名”这一章里,Martin强调**命名需要有意义,让人一目了然,见名知义。**其实这个思想在《重构》里面也体现了不少了。一般来说,对于用在循环中的临时变量你可以使用没有意义的名称i,j等,在其他的时候基本上变量和函数等名称都需要有意义。其实我在有些时候对循环中的临时变量也会给出有意义的名称,比如在编写矩阵运算的时候:

1
2
3
4
5
for(int row=0;row<m.row();row++){
    for(int col=0;col<m.row();col++){
        ...
    }
}

可能有人的英文不是很好,对命名很头疼。那么这里推荐你一个自动生产名称的网站,他会帮助你生成名称。

Martin还建议不要害怕长的变量名,越长的变量名越能告诉人们变量的作用(当然不要特别特别长)。名称的长短应该按其作用域决定,作用域长的名称长。

Martin不建议使用编码,像是匈牙利标记法(其实我以前也用的这个),因为那时候编译器是不做类型检查的,所以需要匈牙利标记法来帮助程序员快速知道变量类型。但是现在的IDE,你把鼠标放上去,直接把整个变量声明给你显示出来。所以不再需要这些特有的命名方法了。也不要用成员前缀(像是表示成员变量的m_,表示接口的I和类的C),道理一样。

不要在命名的时候使用废话或者令人迷惑的命名,这样可能会误导别人。尤其是使用一些专有的,业界常见的词的时候。比如accountList就会让人觉得这个变量是一个List类型。你可以改成accountBunch或者accountGroup

函数

简单来说就一句话:函数就要短小精悍。Martin十分不推荐特别长的函数,因为读起来会很头疼(的确是)。我们应该使用重构的方法将函数变成一小块一小块的,然后给每一块函数一个有意义的名字。一个函数最好在20行左右。

而且函数需要符合单一职责原则,一个函数只做一个事情。处理底层的代码和抽象层的代码不应该在一个函数中同时出现。

判断函数是否已经够小的方法是看这个函数内的代码在不在同一抽象层级上。也就是说一个函数要不就都调用接口,要不就都实现底层,不要混着用。

接下来又是switch躺枪的时间。你只要用了switch,函数代码肯定会增多。Martin的方法是将switch中的每个case返回工厂产出的对象。但是在C++中这或许不是一个好办法。尽管如此,我们仍然可以将switch单独提出到一个函数中来减少其他函数的负担。

函数的参数也不要过长,一般来说两个就够了,三个都比较长了。如果函数参数很长的话,建议将参数打包(虽然是这么说,但是我看很多C/C++库的参数都很长啊😂)。不过将表示相同东西的参数打包成一个结构体还是很舒服的。比如我在编写OpenGL的时候需要一个自动绘制几何图形的类:

1
GL_GeoMesh(GeoType type, GLfloat* vertices, unsigned int num, string imagepath, GLenum wrap_s, GLenum wrap_t, GLenum format, GLenum innerformat, arma::fvec pos, arma::fvec nangle)

这个参数的确长的过分啊。所以我就将一些用于创建贴图的参数集成到一起,将一些表示集合图形信息的参数集成到一起:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct TexInfo{
    string path;
    GLint  wrap_s_type;
    GLint  wrap_t_type;
    GLint  format;
    GLint  internalformat;
};

struct GeoMeshInfo{
    GeoType type;
    GLfloat* vertices;
    unsigned int num;
    arma::fvec pos;
    arma::fvec nangle;
};

GL_GeoMesh(GeoMeshInfo meshinfo, TexInfo textureinfo);

这样就将参数缩小到两个了,看上去好多了,而且也很清晰。

对于参数,Martin还建议不要传入Bool量,因为布尔量总是表示“在true的时候做一件事,在False的时候做另一件事”,容易让编写者违反单一职责原则。所以像show(bool isshow)这种函数就要拆分成showWindow(), hideWindow()两个函数。

还有就是对函数命名的规则,使用动词,最好是动词和名词的方式,比如writeFiled()就比write()好,表示对字段写入。我觉得,如果你的函数是成员函数,在没有使用到内部变量的时候或者不想让,没必要暴露出内部对象类型的情况下,可以直接使用动词,比如file.close(),而如果你是编写面向过程的函数的话,最好将类型列出来,比如InitLinklist(), InitBinaryTree()等。

最后函数需要无副作用,也就是说,你的函数在做这件事的时候不要做多余的事情。比如我编写OpenGL代码中,绘制几何体类中这样的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void Draw(GL_Program* program){
    arma::fmat model = getTranslate(position[0], position[1], position[2])*getRotateXYZ(angle[0]+180, angle[1], angle[2]);
    program->uniformMatrix4fv("model", model.memptr());
    glBindVertexArray(VAO);
    GLenum texturetype = GL_TEXTURE_2D;
    glBindTexture(texturetype, Texture);
    glDrawArrays(drawtype, 0, vertex_num);
    glBindVertexArray(0);
    glBindTexture(texturetype, 0);
    program->useProgram();
}

这里倒数第二行就额外使用了着色器。其实使用着色器不是这个函数要做的事情,这个在这里就是副作用了,到时第二行代码应当删去。

而且也不要将参数当作返回值输出(其实我在很多很多的面向过程的代码中经常看到这种情况),容易误导别人,而且也不好记忆。的确是这样。我在看Linux编程的时候,里面的函数有时候是要传入参数,有时候参数是当作返回值的。如果是错误标志error还好认一些,但是有些参数实在是不记得,每次都要查询API文档。

注释

有关注释的要点就是:没有注释的注释就是好注释。除非万不得已,否则别写注释。 我们要争取让程序自己描述自己(比如良好的命名,易懂的代码体),而不需要过度的注释来修饰程序。因为注释存在以下的缺点:

  • 很容易被人忘记维护。我们常常不定期维护代码,但是注释的话可能就不是很重视了。但是没有维护的代码就是过期的注释,可能会给人误导(注释的内容和现状不符)
  • 注释不能美化代码。注释是为了让人们更好地阅读代码。但是如果你代码本身很糟的话,再多的注释都拯救不了你(而且你维护的注释也多了起来)。尝试让自己的代码自描述。

Martin也提倡一些注释:

  • Doc文档注释,像是JavaDocDoxygen。这些注释在编写API接口的时候还是很有必要注上去的。但是不要对每个函数写Doc文档,那样你维护的注释会特别多。像那种小的,一看就知道怎么用的函数就不用写注释了(比如getX(), getPosition()
  • TODO注释。这种注释可以告诉你还有什么工作没做,防止你忘记。大多数IDE都可以自动识别并列出TODO(比如vscode的TODOList插件),便于你的定位。当然,事情做完之后一定要删除注释。(我就常常在代码中留下TODO注释,方便下次接着写,不得不说TODO是个好东西,记录了之后忘记的东西少多了)
  • 法律信息。这个不用多说,你基本上随便找一个库的源代码,他们源文件开头基本上都有法律信息。比如SDL2:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/*
  Simple DirectMedia Layer
  Copyright (C) 1997-2018 Sam Lantinga <slouken@libsdl.org>

  This software is provided 'as-is', without any express or implied
  warranty.  In no event will the authors be held liable for any damages
  arising from the use of this software.

  Permission is granted to anyone to use this software for any purpose,
  including commercial applications, and to alter it and redistribute it
  freely, subject to the following restrictions:

  1. The origin of this software must not be misrepresented; you must not
     claim that you wrote the original software. If you use this software
     in a product, an acknowledgment in the product documentation would be
     appreciated but is not required.
  2. Altered source versions must be plainly marked as such, and must not be
     misrepresented as being the original software.
  3. This notice may not be removed or altered from any source distribution.
*/
  • 提供信息的注释。这种注释会提供一些信息,像是返回值是什么之类的。但是仍然建议让代码自己描述自己。
  • 对意图的解释。有时候你的代码中可能用了什么较为复杂,生僻的算法,或者一些“黑科技(短小但是能表示复杂逻辑的代码)”,你可以为这些代码写一些注释来阐述。或者当你在if语句中给了一堆花里胡哨的逻辑判断之后,你也可以注释(但是更好的做法是重构这个代码,用有意义的变量来代替判断中较小的表达式)
  • 警示。当你的代码有某些缺陷,或者你想要告诉其他人一些警示的时候,你可以写一些注释。

下面是坏注释的典型代表:

  • 喃喃自语:自己说给自己听的注释。
  • 误导性注释:不要在注释里面写和显示不符,误导性的话语
  • 日志式注释:现在都有git这种版本管理器了,你还在注释里面写日志干啥!
  • 废话注释:比如/*这是一个默认构造函数*/这种。
  • 括号后面的注释:不知道你有没有看过这种注释:
1
2
3
4
5
6
7
8
9
return new Scaffold (
      appBar: new AppBar(
        title: new Text('Startup Name Generator'),
        actions: <Widget>[
          new IconButton(icon: new Icon(Icons.list), onPressed: _pushSaved)
        ],  //Widget[]
      ),    //AppBar
      body: _buildSuggestions(),
    );//ScaffFold

没错这就是Flutter的代码。要不是这些括号后面的注释是vscode给我自动生成,不计入总代码的提示代码,我TM再多写几个嵌套得疯!说真话,这种一层套一层,俄罗斯套套套套娃的代码,就应该重构!(什么?你说我怎么不给这个代码重构?这是我学习Flutter写的测试代码,重构个头)

  • 归属和署名:就是这种author VisualGMQ这种啦。放心,git会记得是谁写的,到时候一blame你也跑不掉。
  • 注释掉的代码:这种是最占空间,遗留时间最长的代码。你说这注释的代码没用把,诶没准是重要代码,到时候要还原回去的。你说有用吧,诶他可能就没用。所以在你准备停止休工的时候,至少在git push上去的时候,把所有没用的注释代码删了,将所有的有用的注释代码上写上为什么留下来的理由,以便于下次回忆起来。
  • HTML注释:我是不是很懂在注释里面写HTML是干啥。除非你是为了Doxygen或者JavaDoc写的。HTML代码看上去格式一片混乱,难以下眼。
  • 非本地信息:当你写代码的时候,一定要写在离需要注释的最近的地方。不然你让人上哪找对应的地方去。
  • 信息过多:不要写许多信息,像是实现的细节等。

格式

这里Martin提出了两种编写代码的格式:竖直格式水平格式 格式就要像阅读报纸一样,先是大纲,然后才是细节。这也就要求我们在设计类的时候,将大的函数放在前面,将那些大函数使用的小函数放在后面。这样从上往下读的时候,你就可以很顺溜。试想你如果将小函数放上面,你在读下面的函数的时候就要不断向上滚动。 在竖直方向上,距离也很重要。一般每个函数实现之间会空一行,用以分割函数。在函数声明中呢用途一样的函数会放在一起。如果有函数被调用,那么尽量将被调用函数放在调用函数下面。这样在看调用函数的时候一眼就可以读到被调用函数。而且一般变量需要放在最上面。因为函数里面总是需要用到变量的,代码阅读者需要提前知道这些变量。

我的做法一般是将公共变量放在最上面,代表需要他人了解。然后是公共函数。然后是私有和保护变量,函数(代表我不希望别人关注这些):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class Vector{
public:
    static unsigned int Vector_Num;
    Vector(float x, float y);
    Vector operator+(const Vector& other);
    Vector operator-(const Vector& other);
    Vector operator*(const Vector& other);
    
    void normlize();

    float getX();
    float getY();
    void setXY(float x, float y);
    ...
private:
    float x;
    float y;
};

关于水平格式,不要在水平方向上写很多很多的代码。反正我是不喜欢左右划屏。
而且关于空循环,请一定要将分号另起一行。这样的代码:

1
while(bufer.size()!=0 && i<=2);

不知道多难看到最后那个分号,改成:

1
2
while(bufer.size()!=0 && i<=2)
    ;

这样会好很多。

最后就是,如果团队规定了规则,按照团队的来。一个团队的代码就要整整齐齐。

总之,代码要又瘦又长

对象和数据结构

对象和数据结构分别是面向对象和面向过程的产物。其两者有反对称性:**类在改变成员变量的时候比较方便,但是在改变函数的时候比较麻烦。数据结构(指纯数据,像是C的struct)则修改变量方便,修改函数麻烦。**所以怎么使用对象和数据结构,完全看你的需求。

而且对象不应该“和陌生人说话”。也就是说如果B对象里面包含了C对象,A对象里面包含了B对象,那么不应该有办法从A对象中通过B对象获得C对象。因为如果在B和C对象之间增加了D对象的关联,你的getB().getC()方法可能就要变成getB().getD().getC()了。这就是得墨忒尔规则

updatedupdated2023-06-082023-06-08