重新温习软件设计之路(3)

本文是我学习课程《软件设计之美》的学习总结第三部分,分享面向对象的三个特点和五个设计原则的理解。

上一篇:体会软件设计之美(2)

1 面向对象的三个特点

我们都知道面向对象有三个重要的特点:封装、继承 和 多态。

封装

封装是面向对象的根基,它指导我们将紧密相关的信息放在一起,形成一个单元,并在此基础之上我们可以一层一层地逐步向上不断构建更大的单元。

封装的重点在于:对象提供了哪些行为,而不是有哪些数据。因此,我们在设计一个类的时候,先考虑其对象应该提供哪些行为,根据行为提供对应的方法,最后考虑实现这些方法需要有哪些字段。

在封装过程中,我们可以遵循一个“最小接口暴露”原则,即尽量减少内部实现细节的暴露 和 减少对外暴露的接口。

继承

继承是面向对象的重要特性,目前有两种类型:实现继承 和 接口继承。

实现继承,是站在子类的角度向上看,面对的是子类。

Child obj = new Child();

接口继承,是站在父类的角度向下看,面对的是父类。

Parent obj = new Child();

在使用继承上,我们常见的误解是:将实现继承当做了一种代码复用的方式。

合理使用继承的方式是:一个通用原则(组合优于继承) 和 一个编程思想(面向组合编程),它们其实也是 分离关注点 的具体实践。

多态

只使用封装和继承的编程方式,只能称之为基于对象编程,只有把多态加入进来,才能称之为面向对象编程。换句话说,多态将基于对象与面向对象区分开来了。

所谓多态,就是 一个接口,多种形态。

实现多态,我们需要找出不同事物的共同点(前提是 分离关注点),从而建立起抽象。

多态不一定要依赖于继承来实现,在面向对象编程中,更重要的是 封装和多态。比如,面向接口编程,只要遵循相同的接口,就可以表现出多态。

2 SOLID设计原则

在面向对象的设计原则中,比较成体系的当属SOLID原则,SOLID是五个设计原则首字母的缩写,它们分别是:

  • 单一职责原则(Single Responsibility Principle, SRP)

  • 开放封闭原则(Open-Closed Principle, OCP)

  • Liskov替换原则(Liskov Substitution Principle, LSP)

  • 接口隔离原则(Interface Segregation Principle, ISP)

  • 依赖倒置原则(Dependency Inversion Principle, DIP)

SOLID原则出自 Robert Martin 的著作《敏捷软件开发:原则、模式与实践》和《架构整洁之道》,他在这两本书对SOLID原则进行了完整的阐述。

单一职责原则

一个模块最理想的状态是不改变(虽然几乎不可能实现),其次是少改变,这是一个模块设计好坏的衡量标准。Robert Martin在《敏捷软件开发:原则、模式与实践》中对单一职责的定义是:“一个模块应该有且只有一个变化的原因”,到了《架构整洁之道》中定义变为了:“一个模块应该对一类且仅对一类行为者(actor)负责”。郑晔老师说,这个定义的演化其实是从考虑变化的原因 到 考虑变化的来源,这些变化的来源就是不同的行为者(actor),我们需要将不同行为者负责的代码放到不同的地方去。因此,看起来它和分离关注点有着异曲同工之处,即都是通过拆分来达到更小的粒度以应对潜在变化带来的影响。理解好单一职责原则,我们需要:

  • 理解封装,知道要把什么样的内容放到一起;
  • 理解分离关注点,知道要把不同的内容拆分开来;
  • 理解变化的来源,知道把不同行为者负责的代码放到不同的地方;

单一职责原则可以应用于不同的层次:小到一个函数,大到一个系统。

开放封闭原则

软件实体(类、模块、函数)应该对扩展开放,对修改封闭。

对扩展开放,就是新需求应该用新代码实现。

对修改封闭,就是不修改已有的代码。

实现开放封闭原则的前提是:在软件内部留好扩展点。郑晔老师说道,扩展点就是我们需要去设计的地方,每一个扩展点都是一个需要设计的模型。而构建模型的难点,首先在于分离关注点,其次在于找到共性(即从变化中找到不变)

一旦构建好扩展点,系统就可以逐渐地稳定下来。

在具体实现中,要设计扩展点,一般都需要面向接口编程,即实现面向对象最重要的特征—多态。

下面就是一个从变化中找到共性的重构代码小案例:

重构之前:

public class ReportService
{
    public void Process()
    {
        // 01.获取当天的订单
        List<Order> orders = GetDailyOrders();
        // 02.生成统计信息
        OrderStatistics statistics = GenerateOrderStatistics(orders);
        // 03.生成统计报表
        GenerateStatisticsReport(statistics);
        // 04.发送统计邮件
        SendStatisticsMail(statistics);
    }
}

重构之后:

(1)找到共性,构建模型

public interface IOrderStatisticsConsumer
{
    void Consume(OrderStatistics statistics);
}

public class StatisticsReporter : IOrderStatisticsConsumer
{
    public void Consume(OrderStatistics statistics)
    {
        // To do : GenerateStatisticsReport(statistics);
    }
}

public class StatisticsMailer : IOrderStatisticsConsumer
{
    public void Consume(OrderStatistics statistics)
    {
        // To do : SendStatisticsMail(statistics);
    }
}

(2)稳定入口,屏蔽变化

public class ReportService
{
    private List<IOrderStatisticsConsumer> _consumers;

    ......
    
    public void Process()
    {
        // 01.获取当天的订单
        List<Order> orders = GetDailyOrders();
        // 02.生成统计信息
        OrderStatistics statistics = GenerateOrderStatistics(orders);

        foreach (var consumer in _consumers)
        {
            consumer.Consume(statistics);
        }
    }
}

(3)新来需求,代码扩展

public class StatisticsSender : IOrderStatisticsConsumer
{
    public void Consume(OrderStatistics statistics)
    {
        // To do : SendStatistics2OtherSystem(statistics);
    }
}

综述,每做一次模型的构建,核心的类就会朝着稳定的方向前进一步。

Liskov替换原则

Barbara Liskov是一位图灵奖获得者(2008),以她名字命名的Liskov替换原则影响深远。所谓Liskov替换原则,就是子类型(subtype)必须能够替换其父类型(basetype)

郑晔老师强调,理解该原则需要站在父类的角度 而不是 子类的角度,一旦站在子类的角度代码中就会出现如下所示的RTTI(运行时类型识别)相关的代码。

public void Handle(Handler handler)
{
    if (handler is ReportHandler)
    {
        // 生成报告
        (handler as ReportHandler).Report();
        return;
    }

    if (handler is NotificationHandler)
    {
        // 发送通知
        (handler as NotificationHandler).SendNotification();
        return;
    }
}

换句话说,关心子类是一种实现继承的表现,而接口继承才是我们努力的方向,接口继承也更符合Liskov替换原则。

因此,我们要用父类的角度去思考,设计行为一致的子类。

接口隔离原则

所谓接口隔离原则,就是在接口中不要放置使用者用不到的方法。

因为接口往往是由程序员来完成的,程序员很多时候没有区分开使用者和设计者,这也是没有做好分离关注点的一种体现。而接口设计不好,最常见的问题就是“胖”接口。

解决方案就是,识别出接口不同角色的使用者,面向不同的使用者设计小接口

从更广泛角度看,它也告诉我们不要依赖于任何不需要的东西,指导我们在高层次上进行设计。

依赖倒置原则

所谓依赖倒置原则,就是高层模块不应该依赖于低层模块,二者应该依赖于抽象

理解依赖倒置的关键在于理解倒置,即让高层模块与低层模块实现解耦,高层模块尽量保持相对稳定,低层模块去依赖高层定义好的接口(即抽象),高层模块不会随着低层代码的变化而变化。

计算机科学中的所有问题都可以通过引入一个中间层得到解决。

—— David Wheeler

郑晔老师将此原则简化为一点:依赖于抽象,并从中推导出几条可以覆盖大部分情况的指导编码的具体规则:

  • 任何变量都不应该指向一个具体的类;

  • 任何类都不应该继承自具体类;

  • 任何方法都应该改写父类中已经实现的方法;

因此,在设计时我们需要依赖一个稳定的抽象,而具体的实现类往往大部分场景下都是由DI容器这类框架去负责调用和组装。

3 小结

本文我们学习了面向对象的三个特点和SOLID五个设计原则,它们可以指导我们如何设计可以应对长期变化的软件。

  • SRP,一个类的变化来源应该是单一的。

  • OCP,不要随便修改一个类。

  • LSP,应该设计好类的继承关系。

  • ISP,识别对象的不同角色来设计小接口。

  • DIP,依赖于构建出来的抽象而不是具体类。

基于分离关注点的结构将不同的内容区分开来,再基于SOLID五大原则将它们组合起来。SOLID五大原则也是可以树立在我们心中的标尺,作为一个标准指导我们的设计。

如果将这些设计原则比作“道”,那么设计模式就可以称得上是“术”了,每个设计模式都是一个特定问题场景的解决方案。

最后,感谢郑晔老师的这门《软件设计之美》课程,让我受益匪浅!我也诚心把它推荐给关注Edison的各位童鞋!

参考资料

郑晔,《软件设计之美》(极客时间课程,推荐订阅学习

 

posted @ 2021-01-12 10:53  EdisonZhou  阅读(187)  评论(0编辑  收藏  举报