最近在学习熊节老师的TDD实战营,大师特意推荐的一本提升编程技能的书。10年前的著作,今天读来还是感同身受,可见本书所传承的价值观、原则和77种实现模式,对于今日的开发人员,仍然具有指导作用。
序言中通过对什么是好的代码下了一个定义。所谓好的代码,除了其他所有要求之外,比如可以工作的、性能良好的、没有Bug的代码,还应该清晰准确地传达写作者的想法。
Martin Fowler在《重构:改善既有代码的设计》中说:“任何一个傻瓜都能写出机器能懂的代码。好得程序员应该写出人能懂的代码”。
Steve McConnell在《代码大全》中说:“不要过早优化,但也不要过早劣化”。
这本书将告诉如何在几乎不引入任何额外成本的前提下避免一些常见的低级错误————它们是常见的,因为几乎每个人都犯过并且还在泛着这些错误。
不仅为自己编程、也你的CPU老弟编程,也为其他人编程。
1引言
本书的目标是帮助读者通过代码表达自己的意图。
用代码来沟通有几个步骤:
- 必须在编程时保持清醒。能清楚地解释为类似“这个方法为什么被调用”,“那块代码为什么属于这个对象”之类的问题。
- 承认他人的重要性。不要以自我为中心,应该学会关心其他人,这样才能写出能与他人沟通的代码。
- 使用本书的实现模式,更有目的地编程,为他人编程。
实现模式介于设计模式和Java语言手册之间,设计模式更多关注的是对象之间如何组织、交换的决策,实现模式的粒度更小。而语言手册更多地介绍“能用Java做什么”,而实现模式聚焦于”为什么使用某种结构“或者”别人读到这段代码时会怎么想“。
2模式
设计决策无法复用,但大多数程序都遵循一组简单的法则:
- 程序更多的时候是在被阅读,而不是被编写。
- 没有“完工”一说。修改程序的投入会远大于最初编写程序的投入。
- 程序都由一组基本的语句和控制流概念组合而成。
- 程序的阅读者需要理解程序————既从细节上,也从概念上。有时他们从细节开始,逐渐理解概念;有时他们从概念开始,逐渐理解细节。
而模式就是基于这些共性的法则,可以借鉴这些模式,但不要盲目效仿,因为思考和实践自己的风格并在团队中讨论交流其实更有效。
模式最大的作用就是帮忙人们做决定。使用模式可以帮助程序员用更合理的方式来解决常见问题,从而把更多的时间、精力和创造力用来解决真正独一无二的问题。
3价值观与原则
价值观
沟通
代码是写给人看得,需要具备沟通的功能。
简单
简单更多地体现实现的思路,更具备可读性、可扩展性。
灵活
灵活是衡量那些低效代码与设计实践的一把标尺。
原则
价值观有普遍的意义,但往往难以直接应用;模式虽然可以直接应用,却是针对特定场景的;原则在价值观和模式之间搭建了桥梁。在没有模式可以应用,或者两个相互排斥的模式可以同时应用的场合,如何把编程原则弄清楚,对解决疑难会是一件好事。在面对不确定性的时候,对原则的理解可以创造出一些东西,同时能和其他的实践保持一致,而且结果一般很不错。
局部化影响
组织代码结构时,要保证变化只会产生局部化影响。也就是常说的高内聚、低耦合。
最小化重复
避免复制代码,将程序拆分为更小的代码片段----小段程序、小段方法和小型对象、小型包。
将逻辑和数据捆绑
将逻辑和逻辑所处理的数据放在同一方法、对象或者包中,就是为了在修改代码时,降低影响的范围。
对称性
程序中很多时候会存在类似add()和remove(),input和output()等方法。
一个缺少对称性的例子:
void process(){
input();
count++;
output();
}
可以修改为:
void process(){
input();
incrementCount();
output();
}
但这依然违反了对称性。input和output操作都是通过方法意图来命名的,但incrementCount()这个方法却以实现方式来命令。在追求对称性的时候,应该考虑为什么会增加这个数值,这样就可以重命名为tally().
声明式表达
尽可能声明式地表达意图。
变化率
将相同变化率的逻辑、数据放在一起;把具有不同变化率的逻辑、数据分离。即稳定和变化分离。比如一个对象中所有成员变量的变化率应该大多是相同的。只会在一个方法的生命周期内修改的成员变量应该是局部变量,两个同时变化但又和其他成员的变化步骤不一致的变量应该属于某个辅助对象。
变化率也是时间维度的对称。
4动机
良好的编程习惯和实现模式,可以降低开发、测试、维护的成本,提升经济效益。
5类
本章主要介绍了下列模式:
- 类:将数据和逻辑放在一起;一个类应该做一些有直接而明显的意义的事情,减少类的数量能改进系统的设计。
- 简单的基类名:位于继承体系根上的类应该由简单的名字,用以描述它的隐喻,要抽象而不是具象;对于重要的类,尽量用一个单词命名。
- 限定性的子类名:子类的类名应该表达它与基类之间的相似性和差异性,要具象;子类的名字有两重职责,不仅要描述这些类像什么,还要说明它们之间的区别是什么。
- 抽象接口:将接口和实现分离;针对接口编程,不要针对实现编程。
- 接口:用interface机制表现不常变化的抽象接口;命名以I开始。
- 有版本的接口:引入新的子接口,从而安全地对接口进行扩展;如果需要新增功能,但又不想破坏已有的实现,可以通过新增一个接口,来继承已有的接口,然后通过instanceof来区别对待原有的接口和新增的接口,虽然是一种丑陋的解决方案,但可以用来解决一类丑陋的问题。
- 抽象类:用抽象类来表现很可能变化的抽象接口;在抽象类和接口之间做决策时,需要考虑两点:1)接口会如何变化,因为接口改变的影响面更广,如果接口发生改变,相应的实现类都需要改变,比如新增或删除一个方法,那么所有实现类就要做相同的操作;2)实现类是否需要同时支持多个接口,因为java只支持单继承,但可以实现多个接口。抽象类在变化时,只要提供了默认实现,在抽象类中新增操作不会打扰现有的实现。
- 值对象:这种对象的行为就像数值一样;用来封装那些不可变的对象。函数式编程的钟爱。
- 特化:清晰地描述相关计算之间的相似性和差异性;一般存在两个极端:相同的逻辑处理不同的数据;不同的逻辑处理相同的数据。大多数情况处于这两个极端的中间。
- 子类:用一个子类表现一个维度上的变化;
- 实现:覆盖一个方法,从而表现一种计算上的变化;
- 内部类:将只在局部有用的代码放在一个私有的类中;当内部类被创建的时候,它的对象会悄悄地获得创建它的那个对象。如果要让内部类与其所处的对象实例完全分离,可以将其声明为static。
- 实现特有的行为:每个实例的逻辑都有不同;
- 条件语句:明确指定条件,以表现不同的逻辑;条件语句表达式的好处是所有逻辑仍然在同一个类中,阅读者不必四处寻找所有可能的计算路径。缺点是:除了修改对象本身的代码之外,没有其他办法修改它的逻辑。
- 委派:将操作委派给不同类型的对象,以表现不同的逻辑;不变的逻辑放在发起委派的类中,变化的逻辑交给被委派的对象。使用委派的一个常用技巧:把发起委派的对象作为参数传递给接受委派的方法。
- 可插拔的选择器:通过反射来调用方法,以表现不同的逻辑;
- 匿名内部类:在方法内部创建一个新对象,并覆盖其中的一两种方法,以表现不同的逻辑;
- 库类:如果一组功能不适合放进任何对象,就将其描述为一组静态方法。
6 状态
对象封装了行为和状态,前者被暴露给外部世界,后缀则为前者提供支持。
- 状态:使用可变的值进行计算;把世界看做许多不断变化的事物的总和,映射到计算机编程中,就涉及到状态变迁,而有效管理状态的关键在于:把相似的状态放在一起,确保不同的状态彼此分离,分离不变和可变部分。当然函数式编程语言更聚焦于不变状态。
- 访问:限制对状态的访问,从而保持灵活性;最小化权限原则,高内聚,低耦合。
- 直接访问:直接访问对象的内部状态;暴露了实现细节,缺乏灵活性。
- 间接访问:通过方法来访问状态,从而提高灵活性;允许在类内部直接访问,外部必须间接访问。
- 通用状态:把状态保持在字段中,使得该类的所有对象都拥有这些状态;将相关的状态封装成状态,提升阅读和表现力。
- 可变状态:如果各个实例拥有不同的状态,那么将这些状态放入一个map,并将map保存在实例变量中;缺点是表意不清晰。
- 外生状态:某些特殊用途的状态可以放入一个map,并由状态的使用者负责保存;
- 变量:变量提供了访问状态的命名空间;
- 局部变量:具备变量保存的状态只在单一作用域有效;局部变量常扮演的角色有:1)收集器:用变量来收集稍后需要的信息。比如result,表示计算或返回的结果值; 2)计数:i++; 3)解释:int top,int left; 4)复用:for(Clock each:getClocks()); 5)元素:在遍历集合时指代其中的元素。例如foreach,嵌套循环中的变量。
- 字段:字段保存的状态在对象的整个生命周期有效;常见角色:1)助手(helper):用于存放其他对象的引用,该对象会被当前对象的方法用到。2)标记(Flag):boolean型的标记表示“这个对象可能有两种不同的行为方式”。3)策略(Strategy):如果想要表达“这部分计算有几种不同的方式来进行”,就应该把一个“只执行者部分可变的计算”的对象保存在一个字段中。如果计算方式在对象生命周期中不发生变化,就在构造函数中给策略字段赋值,否则就提供一个方法来改变策略字段的值。4)状态(State):和策略字段类似,状态是自己设置相关的状态,而策略字段是由其他对象触发改变的。5)组件(components):用来保存由所在对象“拥有”的对象或数据。
- 参数:参数在一个方法被调用之初描述当时的状态;
- 收集参数:传入一个参数,用于收集来自多个方法的复杂结果;
- 参数对象:把常用的长参数列表打包成一个对象;
- 常量:不变的状态应该保存为常量;
- 按角色命名:根据变量在计算中扮演的角色为它们命名。如果遇到命名困难,通常是因为我们没有充分理解当时的计算逻辑。
- 声明时的类型:为变量声明一个尽可能通用的类型;
- 初始化:尽可能以声明式的方式对变量镜像初始化;
- 及早初始化:在创建实例的同时初始化字段;
- 延迟初始化:如果字段的求值动作开销大,可以考虑在第一次使用之前才初始化。
行为
- 控制流:将运算表达成一系列的步骤;程序员如何去表达或实现功能中,往往都会涉及到拆分任务,任务拆分之后的子程序的实现,如何组织这些子程序,更好地表达所思所想,就体现在对控制流的设计上,是一个主体控制流,还是多个并行的控制流等等。
- 主体流:明确表达控制流的主体;
- 消息:通过发送消息来表达控制流;
- 选择性消息:通过变动一条消息的实现者来表达选项;
public void displayShape(Shape subject, Brush brush){
brush.display(subject);
}
可以利用多态,在运行时动态地选择实现,提高了灵活性。
- 双重分发:通过在两条轴线上变动消息的实现者来表达级联的选项;
- 分解性消息:将复杂的计算分解成具有内聚性的块;
- 反置性消息:通过向同一个接受者发送消息序列,令多个控制流形成对称;
- 邀请性消息:通过发送可以用不同方式实现的消息,邀请未来的实现实体;给以后的程序员以扩展的机会。
- 解释性消息:发送消息去解释一段逻辑的意图;不要仅从代码数量上来衡量,要从可读性和解释性的角度去审视。
- 异常流:尽可能清晰地表达非寻常的控制流,而不干扰对主体流的表达;
- 防卫语句:用尽早返回来表达局部的异常流;
- 异常:用异常来表达非局部的异常流;
- 已检查异常:通过明确声明来保证异常被捕获;
- 异常传播:传播异常,根据需要转换异常,以便使其包含的信息合乎捕捉者的要求。
方法
- 组合方法:通过对其他方法的调用来组合新的方法;
- 揭示意图的名称:在名称上反映出方法打算做什么事;
- 方法可见性:尽可能降低方法的公开程度;
- 方法对象:把复杂的方法变成对象;
- 覆盖方法:通过覆盖一个方法来表达特殊化;
- 重载方法:为同样的计算提供不同的接口;
- 方法返回类型:尽可能声明一个泛化的返回类型;
- 方法注释:通过方法注释来传达不容易从代码中读出的想你想;
- 助手方法:增加小的、私有的方法来使计算的主体部分表达得更简明;
- 调试输出方法:用toString()输出有用的调试信息;也可以用log方式;
- 转换:清晰地表达从一个类型的对象到另一个类型的对象的转换;
- 转换方法:对于简单的、有限的转换,在源对象中提供一个方法,让它发挥转换后的对象;
- 转换构造器:对于大多数转换,在目标对象的勒种提供一个方法,让它接受一个源对象作为参数;
- 创建:清晰地表达对象的创建;
- 完整的构造器:编写一个构造器,让它发挥完全塑造好得对象;
- 工厂方法:将较复杂的创建表达成类中的静态方法,而非构造器;
- 内部工厂:将需要进一步解释或者日后需要调整的对象创建封装进一个助手方法;
- 容器访问器方法:为容器的限制性访问提供方法;
- 布尔值Setting方法:如果有助于沟通,为布尔值的两种状态分别提供一个设置方法;
- 查询方法:通过名为asXXX的方法返回布尔值;
- 相等性判断方法:同时定义equals()和hashCode();
- Gettting方法:在特殊情况下通过对一个字段的方法,用一个方法返回该字段;
- Setting方法:在更罕见的情况下提供一个设置字段的方法;
- 安全副本:对传入或传出访问器方法的对象进行复制,避免混淆。
容器
简单讲解Array、Iterable、Collection、List、Set、Map的注意事项和实现机制。
改进框架
从框架开发者和使用者的角度讲解改进框架。