对象健身操,改进你的代码质量
(给ImportNew加星标,提高Java技能)
链接:http://kaelzhang81.github.io/2018/06/10/对象健身操
优秀设计背后的核心概念并不高深,七条评判代码质量原则就基本上能够涵盖,它们具体是:
内聚性
松耦合
零重复
封装
可测试性
可读性
单一职责
对于上述原则,大家可能都耳熟能详,但在实际应用时可能就没那么容易去做了,这首先涉及到你是否能够严肃的对待它们,还是差不多就行。其次,你是否具备足够的经验或技术来具化它们,使之成为可能。由 Jeff Bay 提出的对象健身操就是这样的具体方法来帮助你轻松实现上述原则,称之为“九诫”:
方法只使用一级缩进(One level of indentation per method)。
拒绝使用else关键字(Don’t use the ELSE keyword)。
封装所有的原生类型和字符串(Wrap all primitives and Strings)。
一行代码只有一个 “.” 运算符(One dot per line)。
不要使用缩写( Don’t abbreviate)。
保持实体对象简单清晰(Keep all entities small)。
任何类中的实例变量都不要超过两个(No classes with more than two instance variables)。
使用一流的集合(First class collections)。
不使用任何 Getter/Setter/Property(No getters/setters/properties)。
戒条一:方法只使用一级缩进
该戒条理解起来很简单,只需要在方法内没有嵌套的 if/switch/for/while 等关键字,使用重构中的 extract method 手法完全可以做到。其目的有 2 个:
实现函数的单一职责。
函数变得更加简明,定位错误更加容易。
class Board {
...
String board() {
StringBuffer buf = new StringBuffer();
for(int i = 0; i < 10; i++){
for(int j = 0; j < 10; j++)
buf.append(data[i][j]);
buf.append("\n");
}
return buf.toString();
}
}
应用诫条重构后的代码:
class Board {
...
String board(){
StringBuffer buf = new StringBuffer();
collectRows(buf);
Return buf.toString();
}
Void collectRows(StringBuffer buf){
for(int i = 0; i < 10; i++)
collectRow(buf, i);
}
Void collectRow(StringBuffer buf, int row){
for(int i = 0; i < 10; i++)
buf.append(data[row][i]);
buf.append("\n");
}
}
该诫条的另外一种常见形式是每个方法长度不超过 5。
诫条二:拒绝使用 else 关键字
方式一:卫语句或提前返回
public static void endMe(){
if(status == DONE){
doSomething();
}else{
doSomethingElse();
}
}
public static void endMe(){
if(status == DONE){
doSomething();
return;
}
doSomethingElse();
}
方式二:使用三元操作符
public static Node head(){
if(isAdvancing()){
return first;
}else{
return last;
}
}
public static Node head(){
return isAdvancing() ? first : last;
}
其他方法:
使用多态
空对象模式
策略模式
状态模式
具体可参见各种设计模式的书籍,在此就不复述了。
注:使用该诫条需要关注代码清晰度的变化。
诫条三:封装所有的原生类型和字符串
该诫条对应反模式:Primitive Obsession 通过包装类来封装原生类型和字符串,比较常见的有:Hour、Money 等类。使得类型的使用上更具可读性和安全性。但这并不意味着使用诸如 Java 语言提供的类似对象包装器,使用 Integer 类并不会在表达意图上带来额外的优势,而使用表达意图含义的包装器既能澄清其用法,又能让意图变得明显。
public interface Account {
void credit(int amount);
void debit(int amount);
}
应用诫条后的代码:
public interface Account {
void credit(Money amount);
void debit(Money amount);
}
重构前任意的 int 型数值都可以参与账户转账业务,重构后只能是 Money 类型才合法。
注:如果原生类型变量拥有行为时,有必要对其进行封装。
诫条四:一行代码只有一个 “.” 运算符
违反该诫条的代码形式为:obj.m1().m2().m3(),对象需要同时与另外多个对象交互。在 Martin Fowler《重构》中,将其命名为“消息链条(Message Chain)”,别名“火车残骸”。该行为暴露了细节,破坏了封装性,让类的边界跨入了其不应知道的类中,违反了“迪米特法则”(只和身边的朋友交流)。
迪米特法则的通俗解释:你可以玩自己的玩具,可以玩你制造的玩具,还要别人送给你的玩具,但是永远不要碰别人的玩具。
class Board {
...
class Piece {
...
String representation;
}
class Location {
...
Piece current;
}
String boardRepresentation() {
StringBuffer buf = new StringBuffer();
for (Location l : squares())
buf.append(l.current.representation.substring(0, 1));
return buf.toString();
}
}
应用诫条后的代码:
class Board {
...
class Piece {
...
private String representation;
String character() {
return representation.substring(0, 1);
}
void addTo(StringBuffer buf){
buf.append(character());
}
}
class Location {
...
private Piece current;
void addTo(StringBuffer buf){
current.addTo(buf);
}
}
String boardRepresentation() {
StringBuffer buf = new StringBuffer();
for (Location l : squares())
l.addTo(buf);
return buf.toString();
}
}
在流式编程及内部 DSL 中也常有,但这些代码一般称之为“流畅接口(Fluent Interface)”:
public class GraphDslSample {
public static void main(String[] args) {
Graph()
.edge()
.from("a")
.to("b")
.weight(40.0)
.edge()
.from("b")
.to("c")
.weight(20.0)
.edge()
.from("d")
.to("e")
.weight(50.5)
.printGraph();
}
}
二者的区别在于观察形成链条的每个方法返回的是别的对象,还是自身。如果返回的是别的对象,就属于消息链条。
注:附带好处可读性进一步提升。
诫条五:不要使用缩写
所有实体对象的名称只包含一到两个单词,不能使用缩写。好处是避免名字中重复上下文信息。
使用缩写的一般原因:
不停地方法调用—意味着有必要消除重复。
方法名太长—意味着职责没有放在正确的位置或有缺失的类。
class EO{
...
void shipOrder();
}
// 方法调用上存在冗余
EO order = new EO();
order.shipOrder();
应用诫条后的代码:
class EntityOrder{
...
void ship();
}
// 方法调用更自然
EntityOrder order = new EntityOrder();
order.ship();
诫条六:保持实体对象简单清晰
类的行数不超过 50 行,每个包不超过 10 个文件。
超过 50 行的类通常做不止一件事,这使得它们更难理解,更难以重用。另外一个好处就是可在一个屏幕上显示,不用滚屏,使得代码更易于阅读者理解。
挑战是将会出现很多成组的行为,它们的逻辑应该在一起的。这就需要包机制来平衡。由于包内文件数量的限制,包会更加内聚,且会有一个明确的意图。
class SomeClass
{
// 300 lines of code
// 20 properties
// 20 methods
public function simpleLogic()
{
// 30 lines of code
}
}
class SomeClass
{
// 50 lines of code
// 5 properties
// 5 methods
public function simpleLogic()
{
// 10 lines of code
}
}
诫条七:任何类中的实例变量都不要超过两个
将一个对象从拥有大量属性状态,解构成分层次的、相互关联的多个对象,直接产生一个更实用的对象模型。
这可能是最难做到的诫条了,但会促进代码的高内聚性和更好的封装性。它依赖于诫条三(封装所有的原生类型和字符串)。
一图胜千言:
代码示例:
class Name{
String first;
String middle;
String last;
}
应用诫条后的代码:
class Name{
Surname family;
GivenNames given;
}
class Surname{
String family;
}
class GivenNames{
List<String> names;
}
实际操作时可沿两个方向进行:
将对象实例变量按照相关性分离在两个部分中
创建一个新的对象来封装两个已有变量
诫条八:使用一流的集合
任何包含集合的类中,不应包含其他成员变量。这样集合的各种行为就有了明确的依附物,这些行为包含各种过滤器、针对每个元素的特殊规则、多个集合的处理(拼接、交集等等)。
public class BlogPost
{
public readonly string[] ContentBlocks;
//...//
}
public class ContentBlocks
{
public readonly string[] Blocks;
}
public class BlogPost
{
public readonly ContentBlocks Content;
public readonly bool AddHeadline;
public readonly string Category;
}
应用诫条后的代码:
public void Publish(BlogPost post)
{
if(post.Category == "News")
{
return;
}
contentBlocks.Except(post.Content.Blocks[0])
.Foreach(block => writer.WriteBlock(block);
}
诫条九:不使用任何 Getter/Setter/Property
别名:告诉而不要询问(Tell, don’t ask)原则。
通过该诫条迫使程序员在完成编码后,一定要为这段代码的行为找到一个合适的位置,确保它在对象模型中的唯一性。
其好处如下:
提升代码封装性
减少重复性错误
实现新特性时,有一个更合适的位置去引入变化。
// Game
private int score;
public void setScore(int score) {
this.score = score;
}
public int getScore() {
return score;
}
// Usage
game.setScore(game.getScore() + ENEMY_DESTROYED_SCORE);
应用诫条后的代码:
// Game
public void addScore(int delta) {
score += delta;
}
// Usage
game.addScore(ENEMY_DESTROYED_SCORE);
应用
对象健身操由于过于严苛,一般会用于 codekata 之类的编码练习,以便参与者能够加深其对面向对象编程的理解程度。
那么它是否在真实业务场景下是没有价值的?我的建议是首先掌握其精髓,然后尽量尝试。何况 Jeff Bay 就在一个规模超过 10W 行的系统中严格应用全部的9条规则,并取得不错效果。
最后别忘了,为你的代码编写测试用例。
练习
写一个新的 Job Application domain,要求如下:
domain 中包含5个实体:
Jobs
Jobseekers
Employers
Resumes
Job Applications
实体交互如下:
Employers 能够发布 jobs。
Employers 也应能看见他们所发布的 jobs 清单。
Jobseekers 能够保存 jobs 以便以后查看。
Jobseekers 可以申请 employers 发布的 jobs。
employers 可以发布两类 Jobs: JReq 和 ATS。
JReq 类型的 jobs 需要 resume 才能申请它们。
ATS 类型的 jobs 无需 resume。
Jobseekers 不能以他人的 resume 申请 job。
Jobseekers 能够以不同的 resumes 申请不同的 jobs。
Jobseekers 能够保存 jobs 清单以便后续查看。
Jobseekers 能够查看已申请的 jobs 清单。
Employers 能够通过 job 或者 day 查看申请 job 的 jobseekers。并且可以联合 job 和 day 来查看申请 job 的 jobseekers。
能够获取任一 jobseekers 在给定 day 的 jobs 申请情况。
能够以 csv 或html格式获取 job 申请报告。
能够从 job 申请报告中确定 jobseeker,job,employer 和 job 申请日期。
通过 job 和 employer 应该能够看到有总 job 申请数量。
通过 job 和 employer 应该能够看到有多少 job 申请失败,以及有多少 job 累计成功
Jobseekers 在显示时应以他们的名字。
Employers 在显示时应以他们的名字。
Jobs 显示时应显示一个 title 和发布它的 employer 的名字。
系统能够处理具有相同 title 的多个 jobs。
系统能够处理同名的多个 Jobseekers。
系统能够处理同名的多个 employer。
案例
C#:https://gist.github.com/onlytiancai/1738383
https://github.com/dennisdoomen/objectcalisthenics
JS:https://github.com/bennadel/Object-Calisthenics
Golang:https://github.com/rafos/object-calisthenics-in-go
Java:https://github.com/apaxmai/ObjectCalisthenics
Python:https://github.com/halucinka/object_calisthenics
工具
对象健身操分析器:https://plugins.jetbrains.com/plugin/8080-object-calisthenics-analyzer
参考文献
《软件开发沉思录》
http://www.infoq.com/cn/minibooks/thoughtworks-anthology
https://github.com/TheLadders/object-calisthenics
看完本文有收获?请转发分享给更多人
关注「ImportNew」,提升Java技能
好文章,我在看❤️