序,前言
重视代码
facebook把code review作为重点KPI考核,并采用连坐制。
code wins argument
当两人为一个问题争执不下时,不妨以最快的速度用代码把想法写出来,事实胜于雄辩。代码是负债不是资产
代码越多,维护所要付出的成本就越高。
如果代码结构越好,做了越多单元测试,代码质量越好、越小、耦合越松,那么添加新代码付出的成本就越少。本书观点:代码质量与整洁度成正比
一,整洁代码
糟糕混乱的代码给项目带来的负面影响
略过。什么是整洁代码
关键字: 只做好一件事,简单直接,意图明确,单元测试,尽量少的依赖关系,看起来像是专门解决那个问题而存在。破窗理论和童子军军规
第一扇窗被打破,但是没有人管,接下来会有更多的窗被打破,烂代码不去管它只会越来越烂。
当你离开一个地方时,要让它比你来之前更干净。代码要易读
我们编写代码时,读和写花费的时间比例超过10:1,要想代码易写,首先做到易读。面向对象设计的原则(SOLID)
单一职责原则
开闭原则
里氏代换原则
接口隔离原则
依赖倒置原则
二,有意义的命名
名副其实:为变量或函数起一个能反映其含义的名字并不容易,但起名字花的时间是值得的,好的命名能让读者快速理解代码,减少维护成本。这和TDD四原则里的“揭示意图”是一致的。
-
作者详细介绍了命名需要注意的问题,
结合我们的项目,印象比较深的是“每个概念对应一个词”。
项目代码中通常会有一些特定职责的类,xxxHandler, xxxManager, xxxProcessor, xxxBuilder, xxxHelper... 这样的类名含义模糊,令人费解,而这些类往往在做类似的事情。
三,函数的原则
短小
if、else、while语句内的代码块应该只有一行,该行大抵是一个函数调用语句。
这样的函数不仅能保持短小,而且调用的函数具有说明性的名称,从而增加了文档上的价值。
所以函数的缩进不能多于两层。只做一件事
写函数是为了把大一些的概念(换言之,函数名称)拆分为另一抽象层上的一系列步骤。
判断函数是否不止做了一件事,还有一个办法就是看是否还能再拆出一个函数。每个函数一个抽象层级
要让代码有自顶向下的阅读顺序,向下规则:每个函数后面跟着下一抽象层级的函数。Switch语句
问题:太长,做了不止一件事,违反单一职责原则,违反开闭原则,到处存在类似结构的函数。
解决方案:把switch语句放在工厂类,使用接口多态的接受派遣。使用描述性的名称
函数越短小,功能越集中,越便于取个好名字。函数参数
- 参数越少越好,参数超过三个时,排序、琢磨、忽略的问题都会加倍体现。
- 一元函数的普遍形式:A.操作参数,转换,返回。B.传入event事件。
- 参数过多,最好先封装成类再传入。
- 避免使用标识参数,传入true/false,明显违反“只做一件事”的原则。
- 避免使用输出参数,如果要修改对象的状态,要调用对象自己的函数:
appendFooter(report);
应该改成
report.appendFooter();
- 分隔指令与询问
public boolean set(String attribute, String value);
该函数设置某个属性的值,如果设置成功返回true,如果不存在这个属性返回false。
就会导致以下语句出现:
if (set("userName", "Leo")) {...}
应该改成
if (attributeExists("userName")) {
set("userName", "Leo")
}
-
使用异常替代返回错误码
从指令式函数返回Error Code,轻微违反了指令与询问分隔的原则,而且导致更深层次的嵌套结构。使用异常,错误处理代码就能从主路径代码中分离出来,得到简化。
if (deletePage(page) == E_OK) {
if (registry.deleteReference(page.name) == E_OK) {
if (configKeys.deleteKey(page.name.makeKey()) == E_OK){
logger.log("page deleted");
} else {
logger.log("configKey not deleted");
}
} else {
logger.log("deleteReference from registry failed");
}
} else {
logger.log("delete failed");
return E_ERROR;
}
改成
try {
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
} catch (Exception e) {
logger.log(e.getMessage());
}
try/catch代码块违反了只做一件事的原则,应该把try和catch代码块主体部分抽出来,另外形成函数
public void delete(Page page) {
try {
deletePageAndAllReferrence(page);
} catch (Exception e) {
logError(e);
}
}
private void deletePageAndAllReferrence(Page page) {
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}
private void logError(Exception e) {
logger.log(e.getMessage());
}
别重复自己
结构化编程
只要函数保持短小,循环偶尔出现return, break, continue没有问题,避免使用goto。函数修改的策略
对于冗长复杂的函数,先加单元测试覆盖每行丑陋的代码,然后分解函数、修改名称、消除重复,同时保持单元测试通过。
小结
函数是动词,类是名称,编程艺术是语言设计的艺术。大师级程序员把系统当成故事来讲,而不是当作程序来写。
四、注释
当我们无法用代码表达意图时才使用注释。尽量避免使用注释,用代码表达意图。
五、格式
- 格式的目的
- 代码格式关乎沟通。
- 代码风格和可读性仍会影响到可维护性和扩展性。
- 垂直格式
- 短文件通常比长文件易于理解,尽量短小而精悍。
- 概念间垂直方向上的区隔,不同的思路段落之间用空白行隔开,例如单元测试中的Given、When、Then用空白行隔开。
- 垂直方向上,关系密切的概念应该相互靠近。
- 垂直顺序,被调用的函数应该放在执行调用的函数下面。
- 横向格式
- 水平方向的区隔和靠近,赋值语句=号左右加空格,函数参数之间加空格,运算符优先级高的× / ÷左右不加空格,优先级低的+/-左右加空格。
- 水平对齐。
- 缩进表示层次。
- 空范围。
-
格式范例:
六、对象和数据结构
-
数据抽象
隐藏实现关乎抽象!类并不简单的用getter、setter将其变量推向外间,
而是暴露抽象接口,以便用户无需了解数据的实现就能操作数据本体。
以抽象形态表述数据。
//具象点
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);
}
//具象机动车
public interface Vehicle {
double getFuelTankCapacityInGallons();
double getGallonsOfGasoline();
}
//抽象机动车
public interface Vehicle {
double getPercentFuelRemaining();
}
- 数据、对象的反对称性
对象把数据隐藏于抽象之后,暴露操作数据的函数。
数据结构暴露其数据,没有提供有意义的函数。-
对象与数据结构之间的二分原理:
过程式代码(使用数据结构)便于在不改动数据结构的前提下添加新函数,
面向对象代码便于在不改动既有函数的前提下添加新类。
反过来说,
过程式代码难以添加新数据结构,因为必须修改所有函数,
面向对象代码难以添加新函数,因为必须修改所有类。
-
德墨忒尔定律(迪米特法则,Law Of Demeter)
也叫做“最少了解原理”,模块不应该了解它所操作对象的内部情形。
C类的函数f()只能调用以下对象的方法:
- C类的对象
- f()创建的对象
- 通过参数传入的对象
- C类的实体变量对象
另一种解释:只暴露应该暴露的接口方法,只依赖需要依赖的对象。
System应该只暴露close()的接口方法,而不该暴露close()内部的细节,
Person应该只依赖Container(硬件设备容器)的接口,而不该直接依赖System(操作系统)。
这样做也符合依赖倒置原则,也就是面向接口编程。
火车失事
下列代码应该切分成三行。
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
是否违反德墨忒尔定律,取决于ctxt、options、scratchDir、absolutePath是对象还是数据结构。
如果是对象,应该隐藏内部结构。
如果是数据结构,则需要暴露内部结构,不算违反德墨忒尔定律。
混杂
尽量避免混合结构,一半是对象,一半是数据结构,既有执行操作的函数,又有getter/setter。同时增加了添加函数和添加数据结构的难度。
隐藏结构
经查,发现上述代码获取outputDir是为了根据路径得到BufferedOutputStream,创建文件,
String outFile = outputDir + "/" +className.replace('.', '/') + ".class";
FileOutputStream fout = new FileOutputStream(outFile);
BufferedOutputStream bos = new BufferedOutputStream(fout);
ctxt应该仅仅暴露获取BufferedOutputStream的接口方法,隐藏具体实现。
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);
-
数据传送对象
DTO是只有公共变量(包括私有变量+公共getter/setter)、没有函数的类,是最精炼的数据结构。
Active Record是一种特殊的DTO形式,同时也会拥有save、find方法,通常是对数据库或其他数据源的之间翻译(就是我们项目中的Domain Object,一个类对应数据库一张表)。Active Record往往被塞进业务规则方法,导致数据结构和对象的混杂体。
应该把Active Record当成数据结构,另外创建包含业务规则、隐藏内部数据的独立对象。
小结
- 数据结构暴露数据,没有明显行为。
过程式代码操作数据结构,添加新的函数无需修改数据结构,但添加新的数据结构需要修改所有函数。 - 对象暴露行为,隐藏数据。
面向对象式代码操作对象,添加新的类无需修改既有函数,但添加新的函数需要修改所有类。
不应对任何一种抱有成见,根据具体情况使用。
七、错误处理
对错误的处理很有必要,也很重要,要保证出现错误时程序仍能正常运行。
但不能因此让代码逻辑变得混乱。
使用异常而非返回码
使用异常进行错误处理,能把实现部分和错误处理分离,以免错误处理影响实现部分的逻辑。先写Try-Catch-Finally
使用不可控异常
我的理解:可控异常违反开闭原则,修改内层方法抛出一个可控异常,外层方法都必须修改捕获这个异常,导致从内到外的修改链。
但是使用java编写文件处理、反射的程序时,不可避免的需要捕获可控异常。
自定义的异常都应该继承RuntimeException(不可控异常)。给出异常发生的环境说明
-
依调用者需要定义异常类
定义异常,要考虑它们如何被捕获。
对于第三方API抛出各种不同异常的情况,可以打包API抛出通用的异常类型,简化调用时的代码。
直接在调用API的地方捕获异常:
打包调用API,简化调用代码:
定义常规流程
采取特例模式(SPECIAL CASE PATTERN),创建一个类或配置一个对象,用来处理特例。客户代码就不用应付异常行为了。不要返回或传递null值
方法返回null,不如抛出异常或返回特例对象,否则会有NullPointerException的隐患。
小结
将错误处理和主要逻辑隔离,就能写出整洁而强壮的代码。
八、边界
我们不可避免的需要使用第三方或者其他团队开发的组件,整合到我们自己的代码中,这章主要讲如何保持软件边界整洁。
- 使用第三方代码
- Map的接口功能非常丰富,接收者不要删除其中的映射
- Map的接口一旦修改,许多地方代码需要修改
- 不要把Map(或其他在边界上的接口)在系统中传递,否则也要保留在类中,避免从公共API返回边界接口,或把边界接口作为参数传递给公共API。
通过编写学习型测试来理解第三方代码
-
使用尚不存在的代码
通信控制器依赖于Transmitter API,但API尚未定义且不受我们控制。
先定义适合通信控制器使用的接口Transmitter,一旦提供了Transmitter API,就编写Transmitter Adapter来跨接。适配器封装了与API的互动,并且如果API发生变动,只需要修改适配器。
小结
边界上会发生我们不可控的改动,要避免我们的代码过多依赖第三方代码的细节。
使用Sensor类封装第三方接口的返回结果,或使用Adapter模式将第三方接口转换为我们需要的接口。
使用Adapter模式不仅能将不兼容的接口改写成兼容的接口,还能对第三方接口重新封装来避免边界变化对系统的影响。
九、单元测试
- TDD三定律
- 在编写不能通过的单元测试前,不能编写生产代码
- 只能编写刚好不能通过的单元测试,不能编译也算不通过
-
只能编写刚好足以通过当前失败测试的生产代码
我对上述定律的理解,首先必须先写单元测试再写实现,同时在写单元测试和实现时必须保持小步前进。由于真实项目的业务逻辑往往很复杂,一个story如何拆分tasking,需要在小步和项目进度之间做权衡,也取决于对TDD和重构掌握的熟练程度。
整洁的测试
测试代码最重要的是可读性,明确、简洁、有足够的表达力。一个测试一个断言
作者认为单个断言是个好的准则,一个测试方法的断言数量要尽量少。
但太过强调单个断言,会导致given和when部分有很多重复代码。
我自己TDD的体会,一个测试方法对应一个test-case,如果测试用例拆得足够小,测试方法中的断言自然就会少,这和作者提到的每个测试一个概念应该是一致的。F.I.R.S.T
测试还应遵守以下5条规则。
- 快速(fast) 测试应该能快速运行,太慢了你就不会频繁的运行,就不会尽早发现问题。
- 独立(independent) 测试应该相互独立,某个测试不应该为下个测试设定条件。当测试相互依赖,一个没通过导致一连串的测试失败,使问题诊断变的困难。
- 可重复(repeatable) 测试应该可以在任何环境中重复通过。
- 自足验证(self-validating) 测试应该有布尔值输出,无论通过或失败,不应该是查看日志文件去确认
- 及时(timely) 单元测试应该恰好在使其通过的生产代码之前编写。
小结
关于单元测试的内容还有很多,这一章主要还是强调保持整洁的测试。
十、类
类的组织
公共静态常量 - 私有静态变量 - 私有实体变量 - 公共方法 - 私有方法,保证自顶向下的阅读顺序。类应该短小
如何判断一个类是否太长,主要看类是否承担了多个职责。
单一职责原则是OO最容易理解和遵循的原则,通常也是被违反得最多的原则。类或模块应有且只有一条加以修改的理由。系统应该有许多短小的类而不是巨大的类组成,每个小类封装一个职责。内聚
如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。内聚性高,意味着类中的方法和变量相互依赖,相互结合成一个逻辑整体。
保持内聚性就能得到短小的类,一旦发现类失去内聚性,就拆分它!当某些实体变量只被少数方法使用,就应该拆分出一个类。为了修改而组织
通过多态将一个大类中的细节隔离,等同于把修改隔离,符合开闭原则。
让调用方依赖接口而不依赖细节,符合依赖倒置原则。
隔离细节,更利于单元测试。
十一、系统
将系统的构造和使用分开:构造和使用是不一样的过程。
工厂
使用抽象工厂模式,将构造的细节隔离于应用程序之外。依赖注入(DI/IOC)
在依赖管理情景中,对象不应该负责实例化对自身的依赖,反之,它应该将这份权责移交给其他有权利的机制,从而实现控制的反转。扩容
“一开始就做对的系统”纯属神话。
反之,我们应该只实现今天的用户的需求。
然后重构,明天再扩容系统,实现新用户的需求。面向切面编程(AOP)
AOP中,被称为方面(aspect)的模块构造指明了系统中哪些点的行为会以某种一致的方式被修改,从而支持某种特定的场景。这种说明是用某种简洁的声明(Attribute)或编程机制来实现的。
小结
这一章的概念和描述比较多,例子不多,看完并没有很深的体会。
但是工厂模式、依赖注入、AOP这些在项目中都有应用,关于Spring AOP可以参考《Spring实战》和这篇文章Spring之AOP由浅入深
十二、迭进
简单设计规则1 运行所有测试
紧耦合的代码难以编写测试。同样编写测试越多,就会越遵循DIP之类的原则,使用依赖注入,接口和抽象等工具尽可能减少耦合。如此一来设计就会有长足进步。遵循有关编写测试并持续运行测试的、明确的规则,系统就会更贴近OO低耦合度、高内聚的目标。简单设计规则2 重构
在重构过程中,可以应用有关优秀软件设计的一切知识,提升内聚性,降低耦合度。换句话说:消除重复,保证表达力,尽可能的减少类和方法的数量。不可重复
重复代表着额外的工作、额外的风险和额外不必要的复杂度。重复有多种表现。雷同的代码行是一种。不但是从代码行的角度,也要从功能上消除重复。揭示程序员意图
十三、并发编程
为什么要并发
并发是一种解耦策略,它帮助我们把做什么(目的)和何时(时机)做分解开。
在单线程应用中,目的与时机紧密耦合。
而解耦目的与时机能明显地改进应用程序的吞吐量和结构。
从结构的角度看,应用程序看起来更像是许多台协同工作的计算机,而不是一个大循环。
单线程程序许多时间花在等待Web套接字I/O结束上面。迷思与误解
并发总能改进性能:并发有时能改进性能,但只在多个线程或处理器之间能分享大量等待时间的时候管用。
并发编程无需修改设计:并发算法的设计可能与单线程系统的设计极不相同,目的与时机的解耦往往对系统结构产生巨大影响。
在采用Web和EJB容器时,理解并发问题不重要:最好了解容器在做什么,如何应付并发更新、死锁等问题。
并发会在性能和编写额外代码上增加一些开销。
正确的并发是复杂的,即使对于简单的问题也是如此。
并发缺陷并非总能重现,所以常被看做偶发事件而忽略,而未被当做真的缺陷看待。
并发常常需要对设计策略的根本性修改。
平时工作中并发编程涉及得很少,读起来体会不深,转载一篇java并发编程相关的文章,以后继续学习
关于Java并发编程的总结和思考