前言
《计算机程序的构造和解释》中有这么一句话:代码必须能够被人阅读,只是机器恰好可以执行。
让软件能工作和让软件保持整洁,是两种截然不同的工作。我们中的大多数人脑力有限,只能更多的把精力放在让代码能工作上,这也没啥问题。问题是,大多数人在程序能工作时就以为万事大吉了。
将本书与《重构…》《设计模式…》 一起看,相互印证,会有豁然开朗的感觉。比如要重构一段代码,重构、使其符合某个设计模式、使代码更整洁,这些都是统一的。
《clean code》 主要为我们分享:
- 什么是整洁代码
- 在函数、类、系统、并发等层面上,如何编写整洁代码
什么是不好的代码
- 改动一个需求,涉及的类很多
- 不能主要通过新增代码,甚至是新增类解决 新需求,而需要更改现有代码/类
- 写的很复杂,那一定说明你没有把事情想清楚。
- 业务逻辑无明显的边界,互相调用,在更高层进行组合,由于层级不明确通常面临着树状结构变成网状结构(一个有条理的文章一定是树状结构的)。
代码层面的整洁
- 函数只做该函数名下同一抽象层上的步骤,函数中的语句都要在同一抽象层级上。 PS:人的沟通也是如此。
-
给变量、函数、类起一个有意义的名字,名称应与抽象层级相符。这个知道的多,做到的少。
- 比如测试类的测试方法写一个test就完事
- 最理想的参数数量是0,其次是一,再次是二,应尽量避免三。为何?参数与函数名通常处在不同的抽象层级,它要求你了解目前并不特别重要的细节。从测试角度看,参数更让人为难。
- 应当避免布尔参数(或枚举等类似选择行为的参数),它明显表示函数做了不止一件事
-
输出参数更让人难以理解。
- 在面向对象编程之前的岁月里,有时的确需要输出参数。然而,面向对象语言中对输出参数的大部分需求已经消失了,因为this也有输出函数的意味在内。
- writeField(outputStream,name) 可以把writeField写成outputStream的成员之一;把outputStream 写成当前类的成员变量,从而无需传递它;还可以分离出FieldWriter类,在其构造方法中采用outputStream,并且包含一个write方法。
- 函数要么做什么事,要么回答什么事儿,这二者不可兼得。不可能兼得的原因不是写起来有问题,而是用起来经常会带来可读性损失。
- 重复可能是软件中一切邪恶的根源,面向对象的继承、aop、面向组件编程多少都是消除重复的策略
- 写代码和写别的东西很像,在写论文或文章时,你先想写什么就写什么,然后再打磨它。初稿也许粗陋无序,你就斟酌推敲,直至达到你心目中的样子。关键是,你别写完代码就写别的代码去了
- 如果你做好了上述细节,那么基本不需要注释,注释是一种失败,原因很简单:程序猿不能坚持维护注释。
- 代码格式关乎沟通,而沟通是代码开发者的头等大事,而不是“让代码能工作”。
- 如果一个函数很长,那么通常需要拆分。一个变量列表很长,通常也需要拆分。
-
错误处理
- 使用异常而非返回码
-
测试代码和生产代码一样重要,它需要被思考、设计和照料,没有了测试,你就很难做改动。
- 测试应该有布尔值输出,你不应查看日志文件来确认测试是否通过
- 正常的业务代码中,或许应为方便测试留有一席之地。比如,一个对象虽然理论上只需提供get方法,但为了方便测试,可以提供set方法以直接注入demo数据。
这些具体细节与《重构——改善既有代码的设计》是相辅相成的
- 前者突出编写代码(代码还未完成),后者突出重构。
- 以方法为例,前者要求“方法参数越少越好,不要超过三个”(“坏味道”描述的比较明确),后者提出重构方法的各种技巧。
- 知道重构的技巧并不是难点,这又回到了老问题:知易行难,还是知难行易。
类
内聚比较直观的定义:类应该只有少量实体变量,类中的每个方法都应该操作一个或多个这种变量。通常而言,方法操作的变量越多,就越黏聚到类上。
保持函数和参数列表短小的策略,有时会导致为一组子集方法所用的实体变量数量增加,出现这种情况时,往往意味着至少要有一个类要从大类中挣扎出来。
假设一个有许多变量的大函数function,你想把该函数中某一部分拆解成单独的函数subFunction。不过,subFunction使用了function中的4个变量,这4个变量都作为参数传递到subFunction么?只需要将这4个变量提升为实体变量,并将4个实体变量和subFunction一起抽出一个类即可。所以,拆函数往往跟拆类是一起的。保持内聚性,就会得到许多短小的类。
对象和数据结构
对比以下两个定义:
public class Point{
public double x;
public double y;
}
public class Point{
private double x;
private double y;
public void setX(double x){
this.x = x;
}
public double getX(){
return x;
}
public void setY(double y){
this.y = y;
}
public double getY(){
return y;
}
}
public interface Point{
double getX();
double getY();
void setCartesian(double x,double y);
double getR();
double getTheta();
void setPolar(double r,double theta);
}
第一段和第二段代码本质是一样的,第三段代码漂亮之处在于:
- 你不知道该实现会是在矩形坐标系中,还是在极坐标系中,可能两个都不是!然而,该接口还是明白无误的呈现了一种数据结构。
- 固定了一套存取策略,你可以单独读取某个坐标,但必须通过一次原子操作设定所有坐标。
无论出于怎样的初衷,setter/getter都把私有变量公开化,诱导外部函数以过程式程序使用数据结构的方式使用这些变量。
隐藏实现并非只是在变量之间放上一个函数层那么简单,隐藏实现关乎抽象!第一/二段代码更像数据结构,暴露其数据,没有提供有意义的函数。第三段代码,对象,把数据隐藏于抽象之后,暴露操作数据的函数(暴露行为)。暴露什么,不暴露什么,直接暴露,还是间接操作,都关于抽象。
过程式代码(使用数据结构的代码)便于在不改动既有数据结构的前提下添加新函数,面向对象代码便于在不改动既有函数的前提下添加新类。老练的程序猿知道,一切都是对象只是一个传说,合适的才是最好的。比如dto,本身就是为了传输数据存在的,全是setter/getter 方法。
行为 | 数据 | |
---|---|---|
对象 | 暴露 | 隐藏 |
数据结构 | 没有 | 暴露 |
隐藏什么,就容易更改什么。暴露什么,就难以新增什么。
系统层面的整洁
-
构造和使用分开
- 一栋写字楼在建设时,有建筑工人和起重机,而在使用时只有白领和玻璃幕墙。启动代码、类构造代码很特殊,不要和使用逻辑混杂在一起。
- 这或许是spring 在整洁代码这块的意义
-
系统新增功能不可避免,但是否可以不和原有代码写在一起呢?
- 原有的代码,类尽量内聚。模块边界尽量清晰。
- 通过aop等办法,将新业务逻辑和原来的业务代码织入到一起。这要求,合理的切分关注面,模块化系统性关注面
- 良好的抽象,通过提供新的实现类的方式新增功能
并发代码的整洁
- 对象是过程的抽象,线程是调度的抽象
- 并发是一种解耦策略,它帮助我们把做什么(目的)和何时(时机)分开。
- 分离线程相关代码和线程无关代码
并发编程的难点
public class x{
private int lastIdUserd;
public int getNextId(){
return ++lastIdUserd;
}
}
所谓线程相互影响,是因为线程在执行getNextId时有许多路径可行,需要理解Just-in-time编译器如何对待生成的字节码,还要理解java内存模型认为什么东西具有原子性。
就生成的字节码而言,对于在getNextId方法中执行的两个线程,有12870种不同的可能执行路径,如果lastIdUsed 的类型从int变为long,则可能路径的数量则增至2704156种。当然,多数路径都得到正确的结果,问题是其中一些不能得到正确结果。
这一段,让笔者想到了数据库事务,它满足ACID特性。
- 对于java,开发人员直接写的是代码,会被翻译成字节码。字节码分为指令和数据两个部分。无特殊指令,os不保证指令的原子性,数据的可见性;jvm 提供的字节码指令类似。
- 对于数据库,开发人员直接写的是sql,会被解释为执行计划。数据库保证事务(sql序列)的ACID
它们的一个共同点是:虽然可以实现,但jvm不会直接保证一行java代码是原子的,数据库不会直接保证一个sql执行是原子的。尤其是代码,原子性和可见性(反映在sql中类似隔离性)的保证需要开发人员介入。
并发模型
大部分并发问题,都是这三个问题的变种
- 生产者消费者模型
- 读者作者模型,共享资源主要为读者提供信息源,偶尔被作者线程更新。
- 宴席哲学家。前两者多少带点协作性质,哲学家模型则纯粹是对资源的分时使用
《重构 改善既有代码的设计》
我们以前理解继承和接口,都是先知道概念,然后解释这个有什么用。其实,更自然的路径应该是,碰到问题,发现需要这样的一个工具。
《重构 改善既有代码的设计》有一个“提炼接口”一节,中间提到:类之间彼此互用的方式有若干种。“使用一个类”通常意味着用到该类的所有责任区。另一种情况是,某一组客户只使用类责任区中的一个特定子集。再一种情况则是,这个类需要与所有协助处理某些特定请求的类合作。对于后两种情况,将真正用到的这部分责任分离出来通常很有意义,因为这样可以使系统的用法更清晰,同时也更容易看清系统的责任划分。如果新的类需要支持上述子集,也比较能够看清子集内有些什么东西。
《重构 改善既有代码的设计》有一个“梳理并分解继承体系”一节,中间提到:某个继承体系同时承担两项责任,建立两个继承体系,并通过委托关系让其中一个可以调用另一个。
《重构 改善既有代码的设计》有一章名为“处理概括关系”,包括
- 提炼子类、超类、接口
- 折叠继承体系
- 塑造模板函数
- 以委托取代继承
重构是一种微操作。很多稍有规模的修改,如果不能以重构的方式进行,常常很快就不知道自己改到哪了,这也是很多所谓“重写”项目面临的最大风险:一旦开始,不能停止。
代码的“坏味道”是这本书给行业最重要的启发。很多人常常是无法嗅到代码坏味道的,因此,他们会任由代码腐坏,那种随便添加 if 或标记的做法就是嗅不出坏味道的表现。
许式伟:怎么搬代码?和删除代码类似,我们要找到和该功能相关的所有代码。但是我们做的不是删除,而是将散落在系统中的代码集中起来。我们把对系统的每处修改变成一个函数,比如叫 doXXX_yyyy。这里 XXX 是功能代号,yyyy 则依据这段搬走的代码语义命个名。如果某个地方有好几个功能都加了 doXXX_yyyy 这样的调用,集中了这个功能所有代码后,这个功能与系统的耦合也就清楚了。