从三个特性重新思考面向对象

前言

  • 软件工程是一个系统而有层次的科学,软件设计直接决定了代码质量与维护成本,对于企业软件开发来说,软件质量建设是一个功不在当下的系统性工程,更要求从基础做起,形成团队共识甚至公约,为软件的规模化发展铺垫道路
  • 今天就从面向对象中的三个特性谈谈软件设计思想与方法

一、软件设计与代码质量

Any fool can write code that a computer can understand. Good programmers write code that humans can understand. ——Martin Fowler

  • 软件生命周期与维护成本:

从三个特性重新思考面向对象_第1张图片

  • 如何设计软件?

    Kent Beck给出了简单设计原则

    1. 通过所有测试(Passes its tests)
    2. 尽可能消除重复 (Minimizes duplication)
    3. 尽可能清晰表达 (Maximizes clarity)
    4. 更少代码元素 (Has fewer elements)
    5. 以上四个原则的重要程度依次降低

二、封装

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:
    1. itself
    2. its parameters
    3. any objects it creates/instantiates
    4. 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**,从中也能看到最小知识、面向组合的影子
  • 可以用下面这个梗概括一下:
    1. 找个能让你笑的男人
    2. 找个有稳定工作的男人
    3. 找个喜欢做家务的男人
    4. 找个诚实的男人
    5. 不要让他们四个人见面

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对于面向对象的阐释,以及面向组合、面向接口等等,这些发展十数年依然有效作用在软件设计领域前沿的话题。

你可能感兴趣的:(架构,迪米特法则)