前言
- 软件工程是一个系统而有层次的科学,软件设计直接决定了代码质量与维护成本,对于企业软件开发来说,软件质量建设是一个功不在当下的系统性工程,更要求从基础做起,形成团队共识甚至公约,为软件的规模化发展铺垫道路
- 今天就从面向对象中的三个特性谈谈软件设计思想与方法
一、软件设计与代码质量
Any fool can write code that a computer can understand. Good programmers write code that humans can understand. ——Martin Fowler
- 软件生命周期与维护成本:
-
如何设计软件?
Kent Beck给出了简单设计原则:
- 通过所有测试(Passes its tests)
- 尽可能消除重复 (Minimizes duplication)
- 尽可能清晰表达 (Maximizes clarity)
- 更少代码元素 (Has fewer elements)
- 以上四个原则的重要程度依次降低
二、封装
1.对象与封装
- 2003年图灵奖得主Alan Kay关于面向对象的描述中,强调对象之间只能通过消息来通信——就像细胞一样,每个细胞都能自给并相互独立,又可以不断构建成功能更强的组织和器官
- 如果按今天程序设计语言的通常做法,发消息就是方法调用,对象之间就是靠方法调用来通信的
- 面向对象的根基是封装
- 封装的重点在于对象提供了哪些行为,而不是有哪些数据
2.图灵奖得主的启示
- 如何设计一个类: 1.先要考虑其对象应该提供哪些行为; 2.再根据这些行为提供对应的方法; 3.最后考虑实现这些方法要有哪些字段
- 将行为与实现分离,对外尽可能暴露行为
- 如下某个含有用户名的对象对外提供修改用户名的功能,第二种使用意愿的方式就优于第一种简单的Setter,这不仅仅是函数命名的问题以及减少Getter/Setter访问权限的问题,是一种面向对象思想的体现
public void setUserName(final String userName) {
this.userName = userName;
}
public void changeUserName(final String userName) {
this.userName = userName;
}
3.迪米特法则与重构
PaperBoy的故事
- 我们先讲一个收银员(PaperBoy)与他的顾客(Customer)的故事
- 超市里有很多顾客,每个顾客有属于自己的东西:比如姓名和钱包
@Getter
public class Customer {
private String name;
private Wallet wallet;
}
- 我们先只聚焦于钱包里的钱
public class Wallet {
private float value;
public float getTotalMoney() {
return value;
}
public void setTotalMoney(float newValue) {
value = newValue;
}
public void addMoney(float deposit) {
value += deposit;
}
public void subtractMoney(float debit) {
value -= debit;
}
}
- 顾客去前台结账的时候,收银员登场,他这样收钱合适不?
public class PaperBoy {
public void receive(Customer customer, float payment) {
// 这么付钱有什么问题?
Wallet wallet = customer.getWallet();
if (wallet.getTotalMoney() > payment) {
wallet.subtractMoney(payment);
} else {
// ...
}
}
}
- 问题就这样产生了: 1.收银员只想向顾客收钱,顾客却将整个钱包给了收银员 2.当前顾客的钱包里只有现金,如果以后添加了车钥匙、银行卡、身份证,收银员是不是都可以看得见 3.万一收银员不老实。。。
- 问题可不仅如此: 1.收银员和顾客被钱包耦合到了一起,钱包的变化影响了收银员和顾客的行为 2.如果顾客钱包被偷了,变成了这样victim.setWallet(null),收银员收钱还会碰到NPE 3.保险起见,收银员收钱还要判断下wallet != null,这样收银员承担了额外的工作量
- 显然这样的设计缺乏扩展性与维护性,是需要重构的
- 顾客持有自己的钱包,谨慎对外暴露属于自己的东西
public class Customer {
@Getter
private String name;
private Wallet wallet;
public float getPayment(float bill) {
if (wallet != null) {
if (wallet.getTotalMoney() > bill) {
wallet.subtractMoney(bill);
}
}
return wallet.getTotalMoney();
}
}
- 把属于顾客的钱包还给顾客管理,收银员不再操心如何去拿顾客的钱包
public class PaperBoy {
public void receive(Customer customer, float payment) {
float customerPayment = customer.getPayment(payment);
if (customerPayment == payment) {
// say thank you and give customer a receipt
} else {
// come back later and get my money
}
}
}
- 为什么这样的设计更好: 1.更贴合现实,应用设计要符合真实的业务场景 2.符合最小知识原则,对象之间的边界划分清楚,每个角色不需要关心维护不在分内的事情,完成解耦 3.系统的扩展性更佳,顾客的钱怎么获取与收银员都没有关系 4.符合面向对象的程序设计思想,暴露行为而不是暴露实现细节
迪米特法则应用
- 通过PaperBoy的故事我们分析了一个常见的软件应用场景,下面对迪米特法则做一些总结
- 对于具有这样特征的代码坏味道要保持警觉—— 识别Martin Fowler所说的特性依赖(Feature Envy):函数对某个类的兴趣高过对自己所处类的兴趣
- 如下就是一段被称为火车残骸(Message Chain)的代码,开发中需要尽量避免
someClass.m1().m2().m3().m4();
而这样的函数链式调用(Fluent Interface)是不一样的
someStr.split().steam().map().reduce();
两者的区别是前者返回了别的对象(拿了顾客钱包),而后者依然是对象(Stream)本身
- 核心思想:不要和陌生人说话(不要拿顾客的钱包)
- David Bock阐述的迪米特法则:
A method of an object should invoke only the methods of the following kinds of objects:
- itself
- its parameters
- any objects it creates/instantiates
- its direct component objects
- 代理模式、装饰者模式等都是迪米特法则应用的体现,最终达到对象的高内聚
- 信息论实质:迪米特法则是在限制软件实体之间通信的宽度和深度
三、继承
1.接口继承与实现继承
- 软件设计的重要职责之一就是消除代码重复,提高代码复用性,继承往往被认为是承担该职责的有效途径之一,可事实是这样嘛?
- 创建列表对象的时候,我们会使用
List<String> list = new ArrayList<>();
而不会使用
ArrayList<String> list = new ArrayList<>();
- 前者是从父类的视角自上而下的看待对象创建,是接口继承;后者是从子类的视角去看待,是实现继承;两者的区别就在于面向接口还是面向具体的实现类
- 需要使用继承的时候思考:是接口继承还是实现继承,如果是实现继承是否可以使用组合的方式
- 现在流行的Spring应用开发框架,就倡导使用注解这种组合方式替换掉实现继承 比如,引用某个对象的相似行为,经常使用的是这种组合的方式(@Resource),而不是实现继承(SomeService extends BaseService)
@Service
public void SomeService {
@Resource
private BaseService baseService;
public void someMethod() {
baseService.baseMethod();
}
}
2.面向组合编程
多继承与组合
- 我们都知道C++支持多继承,Java只支持单继承,面对多变的需求,两者应对方式殊途同归
- 我们先来看一个社会角色管理的例子,每个角色都有独特的行为,比如孩子需要孝敬父母,员工需要完成工作,老板需要制定公司战略等
- A类型人既是孩子,还是公司员工
struct TypeAPerson
: Child
, Underling
{
// 子女角色相关接口
void getAdviceFromParent() {...}
// 员工角色相关接口
void acceptTask() {...}
};
- B类型人既是孩子的家长,也是父母的孩子,还是公司老板
struct TypeBPerson
: Child
, Parent
, Boss
{
// 子女角色相关接口
void getAdviceFromParent() {...}
// 父母角色相关接口
void tellStory() {...}
// 老板角色相关接口
void assignTask() {...}
};
- 可见,AB类型人具有重复的子女角色,可将该角色抽象出来
struct BaseTypePerson
: Child
{
// 子女角色相关接口
void getAdviceFromParent() {...}
};
- A类型人就可以继承Underling和BaseTypePerson,而B类型人就可以继承Parent,Boss和BaseTypePerson
- 而如果某个属于B类型的人从公司退休,不再继承Boss即可
struct TypeBPerson
: BaseTypePerson
, Parent
{
// 子女角色相关接口
void getAdviceFromParent() {...}
// 父母角色相关接口
void tellStory() {...}
};
实现继承到面向组合
-
但是面对只支持单继承的Java看来,上面的做法是行不通的(虽然多继承带来了对象复用的便利性,同时也引入了对象关系的复杂性)
-
实现继承会产生类爆炸的问题: 比如, 我们对一个基础类C附加行为a1,a2,a3,生成带有各种行为组合的类集合X; 采用继承的方式,结果集是 X = {Ca1,Ca2,Ca3,Ca1,a2,Ca1,a3,Ca2,a3,Ca1,a2,a3} ,足足有8个类; 这还只是添加三种行为,如果再加一种,就要维护16个类了
-
更可怕的是,使用实现继承将产生大量中间类,这些类没有任何价值,仅仅是因为语法不支持多继承用以传递父类的属性和行为
-
由此可见,单根继承的最大问题在于——只能解决单个变化方向的问题,对于多个变化方向无能为力
-
然后,面向组合编程登上舞台并大放异彩
-
再次需要明确的是: 类的作用,是为了模块化,我们应该遵从高内聚低耦合的原则去划分类,让软件容易应对变化,是我们无论采取何种方法论都应该遵从的原则;
而对象,是我们运行时承载了数据和行为的实体:它的种类和数量应该与领域的真实概念存在清晰、明确、直接的映射
-
我们将各个角色进行拆分,保证每个角色功能单一,通过角色的组合完成对象的构建
-
我们给一个Java版本的组合实现方案
public class TypeAPerson {
private Child child;
private Underling underling;
// 子女角色相关接口
void getAdviceFromParent() {
child.getAdviceFromParent();
}
// 员工角色相关接口
void acceptTask() {
underling.acceptTask();
}
}
- 同样的,其他类型人都可以通过这种简单角色组合完成
- 采用组合的方式,只需要引用带有这些属性的类即可; 一个 2n 的软件复杂度变成了 n ,类的组织得到指数降级
- 进一步讲,Java语言最初对于组合编程支持较弱,所有称之为“类”的不一定都是“类”; TypeAPerson引用对象的方式在Ruby中称为module,在Scala中称为trait,C++则是私有继承; Java逐渐在后面 Qi4j 和 Java8 default method 才慢慢找补回来
四、多态
1.真正的面向对象
- 只使用封装和继承的编程方式,我们称之为基于对象(Object Based)编程; 只有把多态加进来,才能称之为面向对象(Object Oriented)编程
- 多态:一个接口,多种形态
- 多态需要构建抽象——找到不同对象的共同点
- 封装是面向对象的根基,软件靠各种封装好的对象逐步组合出来; 继承给了继承体系内的所有对象一个约束,让它们有了统一的行为; 多态让整个体系能够更好地应对未来的变化
2.面向接口编程
接口隔离原则
- 不强迫使用者去依赖他们用不到的方法
- 比如,有个用来处理一些有共同属性但是不同行为的对象
class SomeRequest {
RequestType getType();
String getSomeA() {}
String getSomeB() {}
String getSomeC() {}
}
- 然后定义了一个接口去统一这些行为,每个需要不同行为的对象去按需索取
interface IHandler {
void handle(SomeRequest request);
}
class SomeClassA implements IHandler {
String handle(SomeRequest request) {
return request.getSomeA();
}
}
// 其他对象不一一列举
- 由此就有了问题: 虽然看似面向接口,但是SomeRequest这个类也可以是一种接口; 并且没有明确职责,将不同的对象需不需要的行为都封装到了一起; 接口也因此变得不稳定,任何新增的行为都会影响接口变化
- 我们来给接口“瘦身”,将上帝类根据功能拆分,行为表现相似的接口只返回指定功能
interface IRequest {}
interface SomeRequestA extends IRequest {
String getSomeA() {}
}
interface SomeRequestB extends IRequest {
String getSomeB() {}
}
interface SomeRequestC extends IRequest {
String getSomeC() {}
}
- 然后,我们去获取行为的时候行动的目的性就很明确了
interface IHandler<T extends IRequest> {
void handle(T request);
}
class SomeClassA implements IHandler<SomeRequestA> {
String handle(SomeRequestA request) {
return request.getSomeA();
}
}
- 这就是接口隔离的本质,Martin Fowler称之为**Role Interface**,从中也能看到最小知识、面向组合的影子
- 可以用下面这个梗概括一下:
- 找个能让你笑的男人
- 找个有稳定工作的男人
- 找个喜欢做家务的男人
- 找个诚实的男人
- 不要让他们四个人见面
Duck Typing
- 如果走起来像鸭子,叫起来像鸭子,那它就是鸭子
- 静态语言构建多态的条件: 1.存在继承关系; 2.子类重写父类方法; 3.父类引用指向子类对象
- 动态语言如Python不需要继承也可以实现多态
- 两个对象(Duck和FakeDuck)即使不在一棵继承树上,但只要有相同的方法接口就是一种多态
class Duck
def quack
# 鸭子叫
end
end
class FakeDuck
def quack
# 模拟鸭子叫
end
end
def make_quack(quackable)
quackable.quack
end
- 并不需要关心是不是真的“鸭子”,只要有相同的“叫声”就都是鸭子
make_quack(Duck.new)
make_quack(FakeDuck.new)
小结
-
软件工程是一个系统而有层次的科学,软件设计直接决定了代码质量与维护成本,软件质量建设是一个功不在当下的系统性工程,更要求我们从基础做起,形成团队共识甚至公约,为软件的规模化发展铺垫道路。
-
这篇小文,我们从面向对象最基本的三个特性出发探讨软件设计方法;当然,这只是软件设计方法论的冰山一角,我们抓住了几个非常底层的建设基点——OO理论的祖师爷Alan Kay对于面向对象的阐释,以及面向组合、面向接口等等,这些发展十数年依然有效作用在软件设计领域前沿的话题。