对象健身操详解
优秀设计背后的核心概念并不高深,七条评判代码质量原则就基本上能够涵盖,它们具体是:
- 内聚性
- 松耦合
- 零重复
- 封装
- 可测试性
- 可读性
- 单一职责
对于上述原则,大家可能都耳熟能详,但在实际应用时可能就没那么容易去做了,这首先涉及到你是否能够严肃的对待它们,还是差不多就行。其次,你是否具备足够的经验或技术来具化它们,使之成为可能。由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 and 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
- 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!