《代码整洁之道 clean code》 读书笔记(上篇)

这本书我准备用较快的时间来读一下,简单记录一下自己的一些读完的感悟,因为更多地编码技巧还是需要在实际编程和读源码的过程中进行锤炼。因为发现后面很多内容都是和 Java 语言的一些特性有关,但是不认还不甚了解,所以先发出前六章的读书感悟内容。

前记:

以前大一刚刚接触代码的时候,我也觉得自己作为一个程序员,可能会就像以前做数学一样,用很巧妙的各种方法解决问题,但是思路很难理解,字迹非常潦草,那时我觉得这样很帅,好像只有我是独树一帜和其他人不一样。这种想法,变换到写代码中就是,变量的随机命名,比如a,aa,aab,aba等等,恨不得越难读懂越好。后来发现自己的很多代码事后重新读的时候已经不记得是自己写的了,而且也没办法和其他人合作完成一项东西,自己添加或者修改功能也会改出各种各样的bug,其实这样一点都不帅。我希望更加追求代码的整洁优美,无论是从细节上还是从整体的结构上。统一的规范,简介有效稳定的封装,每个细节无可挑剔的精准,简单明了的处理好各种边界,这才是优雅。对此,两本书列入自我提高计划《代码整洁之道 clean code》和《设计模式》。

第一章 整洁代码

这一章主要就是说了一些作者们自己对于写代码的一些思想和理解,以及他们想对我们说的话。主要是说了几个混乱代码造成的后果,然后引出了优美的代码能带来的好处。

第二章 有意义的命名

第二章主要就是先说了一下命名的一些要遵循的小标准。因为英语基础的问题,对于一些命名还是不是很有感觉,提高英语吧。

这里列出几个我觉得比较重要的:

  1. 作用域越大,变量名越长。
  2. 每个概念对应一个词,比如(fetch、get、retrieve不要表示不同类中的相同功能)。
  3. 同一名称不要表示不同的概念,比如(add同时表示把a和b组合加进集合,和向集合中插入元素)。
  4. 避免无意义的前缀和后缀,比如(GPS软件的每个变量前都加GPS_前缀)。
  5. 类的方法名用动词或动词短语,属性访问器、修改器、断言用对应的前缀get、set、is。
  6. 每一个代码块、函数要尽量的短,明确。
  7. 避免代码重复。

扩展2.1:盘点那些命名方法(匈牙利命名法)

第三章 函数

函数是一个非常重要的工具,它可以帮助我们封装操作,避免重复代码。

1. 只做一件事

这是我觉得这一章对我启发最大的一句话,函数要尽量的短小,那么如何做到尽量的短小呢,那就是把函数拆分成多个小的函数。那么要拆到什么时候为止呢,那就是拆到最做一件事为止。因为如果这种情况在继续拆就会使得意思不再连贯了。

那么什么是一件事,这个一件事如何定义,我觉得这才是这里最有讲究的问题。直接说书中给到的说法:如果函数只做了该函数名下同一抽象层上的步骤,则该函数就是只做了一件事。 换句话说,每个函数只能在一个抽象层,我们知道函数不断递归的过程也就是不断地变得更抽象的过程,例如:从“把员工信息加入到企业中”抽象到“把元素加入到集合中”再到从内存中申请空间并移动信息“。这就是一个不断向下抽象,一步一步变到底层的过程。所以每一个函数只负责一个抽象层就是可以增加函数的表达能力,并且加强可读性。

2. 避免重复

这是我觉得本章关于函数的第二个比较有指导性意义的观点,文中认为,我们从建立函数概念到创造面向对象编程,这些都是在想办法避免重复。或者说叫代码重用。那么避免重复到底有什么意义呢?最简单的就是比如一个内容如果重复出现了多次,我们如果想要进行修改,就必须找到这一段出现的每一个地方,如果漏掉就会出现问题。这是我写代码实实在在遇到的问题,但是当时并不是因为不知道要避免重复而是没有能力或者偷懒没有对函数进行和完善的封装和改进。

3. 限制参数数量

文中作者还认为,函数的参数数量不宜过多,说实话,这一点是我没有意识到的,但是细细体味确实有一定的道理。函数作为一个封装起来供大家使用的黑盒子,自然是有数据入口和数据出口的。

关于函数的数据流入和流出:

在我目前的看法中,数据入口(或者也可以叫函数获取信息的途径)有三个:

  1. 通过给实例调用所属类的成员函数,相当于把实例中的信息传入到了函数中。
  2. 函数传入参数
  3. 使用全局变量

数据出口(或者也可以叫函数反馈信息的途径)也有三个:

  1. 函数返回值将内容传出
  2. 修改传入参数和全局变量
  3. 内部调用其他控制函数进行控制。

这里我进行自我感觉良好的一波分析,首先是数据入口,最好的方法就是使用对象的方法调用,然后传入参数也还可以,最后是使用全局变量,注意这里不是全局常量,如果是常量还可以,变量因为数值会变化,所以很容易出现得到的不是自己希望的值得情况。再说数据出口,最保险的就是函数自带的提供的返回值功能,保险安全。第二就是通过修改传入的参数和全局变量,这种方式之前我总使用,为了提高效率,但是这其实是非常不好的行为,因为这样做就失去了数据的安全性保护。最后的是内部调用其他控制函数进行控制,我目前感觉最好不要这样做,第一点是会让函数调用者不知道自己的函数内部还对哪些数据进行了修改,第二点是可能会产生一些函数调用时序性问题,比如是否可以在函数里面调用 Init() 函数进行初始化(这个问题也没少坑我)。

关于参数的问题:

传入参数也是有讲究的,简单来说,参数越少越好,如果需要传入多个参数,也需要让参数是一种有序的结构传入的,比如二维点坐标,天生就有两个点,但是更好的方法是把点在笛卡儿坐标系中的两个维度的坐标封装成一个Point类,函数则是传入这个类的对象。参数多还有可能有一个原因就是想分多钟情况,所以传入了一个操作标识flag,flag = 1会做什么,flag = 2最做什么,这样的情况我们希望将函数拆成两个,将 flag 这个标识压到函数名中。

4. 异常代替返回错误码

这个不太熟悉,是一个java的错误抛出机制,之后会学一下,之前写编译原理大作业确实是使用返回错误码的形式,但是因为嵌套层数太多了所以有点麻烦,就改成了修改全局变量的报错信息,在每一层都检查一遍,但是还是非常的影响结构。

5. 避免switch

switch 也是一个被作者强力禁止的内容,但是在不得不使用的时候,他会把switch语句尽量的封装到底层的函数中。其中提出的一个 switch 的缺点就是:每加一种情况都需要重新的修改switch,但是我们知道,改不如加,希望之后《设计模式》之中可以有办法对这个问题进行解决。直觉告诉我是有的!

6. 分隔指令和询问

函数按照使用目的来分可以有两个,引用文中的话 “函数要么做什么事,要么回答什么事,但二者不可兼得” 。最经典的,我之前自己写函数总是会构造出这种结构:

bool set(int x, int val){
    if (vis[x]) {
        return 0;
    } else {
        vis[x] = val;
        return 1;
    }
}
int main(){
    if (set(5, 1)) {
        printf("error\n");
    } else {
    	printf("succsee\n");
    }
    return 0;
}

但是这样这个函数其实是做了两个事情,判断和插入。其实应该修改为这样的形式:

bool isVisisted(int x){
	return vis[x];
}
void set(int x, int val){
    vis[x] = val;
}
int main(){
	if(isVisisted(5)){
		printf("error\n");
	} else {
		set(5, 1);
		printf("success\n");
	}
	return 0;
}

小结:

就好像文学作家会用文字来写他们的故事,程序员就是用代码来建造自己的世界。而函数就是我们创造出来,用来更好的表达我们的意思的工具,我们用各种各样的函数来一点一点的构成我们的故事。那么我们就需要一个好的工具包,也是就函数集,它们应该短小精炼,各司其职,互不影响,而又可以组合出各种各样的复杂形式。

扩展3.1:单一权责原则(Single Responsibility Principle, SRP)
扩展3.2:开放闭合原则(Open Closed Principle, OCP)

第四章 注释

因为在公司中参与的实际合作项目比较少(就是没有),所以对于注释这一章说的很多地方还是不是很了解。还是采用列出几个自己体会到的细节点的形式来说这一章吧。

  • 注释越少越好,只有代码表达不清楚的地方才用注释,可以用变量名、函数名表达清楚的意思不要用注释。
  • 避免注释出现歧义,因为会误导其他人。(这一条好似废话)
  • 避免不必要的注释,注释也不是越多越好,比如每一个变量都注明是做什么的也没必要。否则大家都不愿意看,自动选择忽略,那么随着代码修改,可能原来的注释就会失真。
  • 不要注释代码,不要的代码请删掉(这个是因为有版本控制工具,但是我还不会用)
  • TODO注释是程序员认为应该做但是还没做完的事情,用这个注释给自己立flag
  • 极少的标记性注释可以帮助凸显出某一部分,但是用多了就会变成杂乱的背景效果,适得其反。
  • 注意维护注释的时效性,不要改了代码不改注释。

第五章 格式

这一章说的主要是代码的格式问题,或者说是排版问题,虽然之前一直知道格式很重要,但是文中说的一些优秀格式带来的好处还是很打动我,还是列出一些我觉得有意义的小规则。

  • 适当合理的使用空行,每个空白行都是一条线索,标识出新的独立概念。
  • 关系密切的概念应该互相靠近,并且概念相关的代码也应该放到一起,相关性越强,彼此之间的距离就应该越短。
  • 被调用的函数应该放在执行调用的函数下边,这样就建立了一种自顶向下贯穿源代码模块的良好信息流。
  • 认真使用缩进规则等,团队合作应该先规定好统一的代码格式风格。

第六章 对象和数据结构

受益很多的一章,解答了为什么我之前的封装总是感到不满意,自己写的类模块,调用起来总是没有那种调用 stl 的稳定感。真的是全程高能,收获max。

1. 关于面向过程和面向对象

首先我们看两份代码,他们的功能相同,都是计算一个图形的面积:

// 面向过程代码(过程式形状代码)
public class Square {
    public Point topLeft;
    public double side;
}
public class Rectangle {
    public Point topLeft;
    public double height;
    public double width;
}
public class Circle {
    public Point center;
    public double radius;
}
throw new NoSuchShapeException
public class Geometry {
    public final double PI = 3.14159265358979323846;
    
    public doubke area(Object shape) throws NoSuchShapeException
    {
        if (shape instanceof Square) {
            Square s = (Square)shape;
            return s.side * s.side;
        }
        else if (shape instanceof Rectangle) {
            Rectangle r = (Rectangle)shape;
            return r.height * r.width;
        }
        else if (shape instanceof Circle) {
            Circle c = (Circle)shape;
            return PI * c.radius * c.radius;
        }
        throw new NoSuchShapeException;
    }
}
/*********************************************************/
// 面向对象代码(多态式形状代码)
public class Square implements Shape {
    private Point topLeft;
    private double side;
    
    public double area() {
        return side*side;
    }
}

public class Rectangle implements Shape {
    private Point topLeft;
    private double height;
    private double width;
    
    public double area() {
        return height * width;
    }
}

public class Circle implements Shape {
    private Point center;
    private double radius;
    public final double PI = 3.14159265358979323846;
    
    public double area() {
        return PI * radius * radius;
    }
}

通过上面代码我们发现:如果想要为每个图形都添加一个输出函数,那么显然上面的写法(面向过程)是比较好的,我只需要添加一个print函数为就可以了,下面的写法就需要为每一个类型的图形都添加一个print函数。那么如果是想要添加一种新的类型呢,那么又是下面的写法(面向对象)更好一些,因为它可以直接添加一个新的图形类,但是上面的写法就需要修改Geometry类。我们遵照改不如加的原则发现:过程式代码(使用数据结构的代码)便于在不改动既有数据结构的前提下添加新函数。面向对象代码便于在不改动既有函数的前提下添加新类。

知道了这一点,就方便我们在设计结构的时候就计划好使用哪一种结构。那么有没有可以兼得的办法呢,首先要明确,两种办法都用不仅没办法同时得到两者的优点,还会同时产生两者的缺点。文中说了两种方法,作为扩展之后会学一下。

2. 数据抽象

隐藏实现并非是只是在变量之间放上一个函数层那么简单。隐藏实现关乎抽象!类并不是简单地通过取值器和赋值器将其变量推向外间,而是暴露抽象接口,以便用户无需了解数据的实现就能操作数据本体。

这句话是什么意思呢?我们来看下面两份代码:

// 具象点
public class Point {
    public double x;
    public double y;
}
/***************************************************/
// 抽象点
public interface Point {
    double getX();
    double getY();
    void setCartesian(double x, double y);
    double getR();
    double getTheta();
    void setPolar(double r, double theta);
}

比较两份代码,我们更喜欢第二份代码,为什么呢,因为它就是把点进行了抽象(隐藏了数据细节),我们实际上并不知道这个点是二维笛卡尔坐标的形式存储,还是极坐标形式,或者两者都不是。但是这不影响我们使用它!每次对它进行的修改操作都是一个合法的原子操作,这样也就是暴露了接口而不是数据。

3. 德墨忒尔律(The Law of Demeter)

德墨忒尔律认为:模块不应该了解它所操作对象的内部情形。

确切来说,类 C 的一个方法 f 只应该调用以下对象的方法:

  • C
  • 由 f 创建的对象;
  • 作为参数传递给 f 的对象
  • 由 C 的实体变量持有的对象

方法不应调用由任何函数返回的对象的方法。 乍一看,这也太难实现了吧!如果抽象层多一些,不知不觉就会做这种事出来,如果是在很明确的分工下,一人负责一个抽象层还好一些;如果是独行侠,那因为对每一层都有了解,这种事情很难避免掉。但是我们总是有各种各样的方法来避免出现这样的调用。

不该出现的连续调用代码:

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

稍微改进一些但是还是违背德墨忒尔律的代码:

Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();

转换成传入参数的形式:

BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

4. 数据传输对象(DTO)

在数据在函数之间进行传递的过程中(可以作为参数,也可以是返回值),有时候需要把一些信息进行打包,比如:数据库通信,解析套接字传递的消息。也就像是 C++ 中的结构体,作为一个数据结构,它最好不要包含方法函数,而且数据是可以对外展示的,无需隐藏。

扩展6.1:VISITOR模式

扩展6.2:双向分派


扩展知识:

扩展2.1:盘点那些命名方法(匈牙利命名法)

扩展3.1:单一权责原则(Single Responsibility Principle, SRP)

扩展3.2:开放闭合原则(Open Closed Principle, OCP)

扩展6.1:VISITOR模式

扩展6.2:双向分派

还有五个扩展之后会百度学习一下,然后写一下,可能会每一个内容都新开一个博客,把地址放在这里。


后记

要想写好一个工程,先要做好的就是注重每一个细节,把做项目看成是建一座大楼,我们则既是设计师,又是工匠。要想盖好大楼,既要计划好完整的框架,又要注重每一个小细节的打磨。代码既是给机器读的,也是给人读的,也要有一定的优美感。就像写文章,我们不会一下就写好肯定要涂涂改改,代码也一样,我们会不断地修改之前的结构,修改变量名,让我们的代码可读性更佳。不要觉得这样浪费时间,如果可以改出一个好的代码结构,真的会让之后的工作事半功倍。不然会把大部分时间浪费在读自己之前的代码上,记住欲速则不达。另外要时刻注意分层的思想,之前觉得分层思想只是把数据的流动进行明确,把一系列复杂的操作进行拆分,但是现在发现,不仅如此,更重要的是,分层,更是为了从概念层面上,将不同等级的抽象进行拆分,同一抽象级别的内容分到同一个层级,这样才能让逻辑上更易懂。

要继续变强啊,少年!