前言
《计算机程序的构造和解释》中有这么一句话:代码必须能够被人阅读,只是机器恰好可以执行。
让软件能工作和让软件保持整洁,是两种截然不同的工作。我们中的大多数人脑力有限,只能更多的把精力放在让代码能工作上,这也没啥问题。问题是,大多数人在程序能工作时就以为万事大吉了。
将本书与《重构…》《设计模式…》 一起看,相互印证,会有豁然开朗的感觉。比如要重构一段代码,重构、使其符合某个设计模式、使代码更整洁,这些都是统一的。
高水平的代码具有某种一目了然的简单性。专家能够弄清什么是不可简化的复杂性,可以识别和消除大部分意外的复杂性。当系统复杂性增加时,为系统增加更多价值的成本保持不变。
所谓优雅,相对应的是坑。只有见过足够多的坑,才会形成自己的编码理念。
《clean code》 主要为我们分享:
- 什么是整洁代码
- 在函数、类、系统、并发等层面上,如何编写整洁代码
Erlang 的作者之一 Joe Armstrong:”Make it work, then make it beautiful, then if you really, really have to, make it fast. 90 percent of the time, if you make it beautiful, it will already be fast. So really, just make it beautiful!”
什么是不好的代码
- 改动一个需求,涉及的类很多
- 不能主要通过新增代码,甚至是新增类解决 新需求,而需要更改现有代码/类
- 写的很复杂,那一定说明你没有把事情想清楚。
- 业务逻辑无明显的边界,互相调用,在更高层进行组合,由于层级不明确通常面临着树状结构变成网状结构(一个有条理的文章一定是树状结构的)。
代码层面的整洁
- 函数只做该函数名下同一抽象层上的步骤,函数中的语句都要在同一抽象层级上。 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种。当然,多数路径都得到正确的结果,问题是其中一些不能得到正确结果。
这一段,让笔者想到了数据库事务,一行代码就像 一句sql 一样。
- 对于java,开发人员直接写的是代码,会被翻译成字节码。无特殊指令,os不保证指令的原子性,数据的可见性;jvm 提供的字节码指令类似。
- 对于数据库,开发人员直接写的是sql,会被解释为执行计划。数据库保证事务(sql序列)的ACID
它们的一个共同点是:虽然可以实现,但jvm不会直接保证一行java代码是原子的,数据库不会直接保证一个sql执行是原子的。尤其是代码,原子性和可见性(反映在sql中类似隔离性)的保证需要开发人员介入。
并发模型
大部分并发问题,都是这三个问题的变种
- 生产者消费者模型
- 读者作者模型,共享资源主要为读者提供信息源,偶尔被作者线程更新。
- 宴席哲学家。前两者多少带点协作性质,哲学家模型则纯粹是对资源的分时使用
《重构 改善既有代码的设计》
我们以前理解继承和接口,都是先知道概念,然后解释这个有什么用。其实,更自然的路径应该是,碰到问题,发现需要这样的一个工具。
《重构 改善既有代码的设计》有一个“提炼接口”一节,中间提到:类之间彼此互用的方式有若干种。
- “使用一个类”通常意味着用到该类的所有责任区。
- 另一种情况是,某一组客户只使用类责任区中的一个特定子集。
- 再一种情况则是,这个类需要与所有协助处理某些特定请求的类合作。 对于后两种情况,将真正用到的这部分责任分离出来通常很有意义,因为这样可以使系统的用法更清晰,同时也更容易看清系统的责任划分。如果新的类需要支持上述子集,也比较能够看清子集内有些什么东西。
《重构 改善既有代码的设计》有一个“梳理并分解继承体系”一节,中间提到:某个继承体系同时承担两项责任,建立两个继承体系,并通过委托关系让其中一个可以调用另一个。
《重构 改善既有代码的设计》有一章名为“处理概括关系”,包括
- 提炼子类、超类、接口
- 折叠继承体系
- 塑造模板函数
- 以委托取代继承
重构是一种微操作。很多稍有规模的修改,如果不能以重构的方式进行,常常很快就不知道自己改到哪了,这也是很多所谓“重写”项目面临的最大风险:一旦开始,不能停止。
代码的“坏味道”是这本书给行业最重要的启发。很多人常常是无法嗅到代码坏味道的,因此,他们会任由代码腐坏,那种随便添加 if 或标记的做法就是嗅不出坏味道的表现。
许式伟:怎么搬代码?和删除代码类似,我们要找到和该功能相关的所有代码。但是我们做的不是删除,而是将散落在系统中的代码集中起来。我们把对系统的每处修改变成一个函数,比如叫 doXXX_yyyy。这里 XXX 是功能代号,yyyy 则依据这段搬走的代码语义命个名。如果某个地方有好几个功能都加了 doXXX_yyyy 这样的调用,集中了这个功能所有代码后,这个功能与系统的耦合也就清楚了。