设计模式简记-面向对象

2.面向对象

2.1面向对象是什么

2.1.1面向对象编程与面向对象编程语言

  • 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
  • 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
  • is-a关系可用继承描述
  • 按照严格的定义,很多语言都不能算得上面向对象编程语言,但按照不严格的定义来讲,现在流行的大部分编程语言都是面向对象编程语言。
  • JavaScript,不支持封装和继承特性
  • Go 语言,摒弃了继承特性

2.1.2面向对象分析和面向对象设计

  • 面向对象分析英文缩写是 OOA,全称是 Object Oriented Analysis;

  • 面向对象设计的英文缩写是 OOD,全称是 Object Oriented Design。

  • 面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做,面向对象编程就是将分析和设计的的结果翻译成代码的过程。

2.2封装、抽象、继承、多态可以解决什么问题

2.2.1封装(Encapsulation)

  • 封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据

  • 语法机制提供访问权限控制:

    private、public 等关键字就是 Java 语言中的访问权限控制语法。

    private 关键字修饰的属性只能类本身访问,可以保护其不被类之外的代码直接访问。如果 Java 语言没有提供访问权限控制语法,所有的属性默认都是 public 的,那任意外部代码都可以通过类似 wallet.id=123; 这样的方式直接访问、修改属性,也就没办法达到隐藏信息和保护数据的目的了,也就无法支持封装特性了。

  • 意义:

    • 防止随意修改对象,维持的可读性、可维护性
    • 暴露仅需的方法给使用者,减轻使用者了解细节的负担,增加易用性

2.2.2抽象(Abstraction)

  • 抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。

  • java中的接口类

    public interface IPictureStorage {
      void savePicture(Picture picture);
      Image getPicture(String pictureId);
      void deletePicture(String pictureId);
      void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo);
    }
    
    public class PictureStorage implements IPictureStorage {
      // ...省略其他属性...
      @Override
      public void savePicture(Picture picture) { ... }
      @Override
      public Image getPicture(String pictureId) { ... }
      @Override
      public void deletePicture(String pictureId) { ... }
      @Override
      public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... }
    }
  • 意义:

    扫描二维码关注公众号,回复: 9264615 查看本文章
    • 过滤非必要信息:抽象作为一种只关注功能点不关注实现的设计思路,帮我们的大脑过滤掉许多非必要的信息。
    • 作为一种设计思想,可指导编码设计:基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等
    • 抽象思维:不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候,不用去修改其定义。

2.2.3继承(Inheritance)

  • 继承是用来表示类之间的 is-a 关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。

  • 语法机制来支持:

    比如 Java 使用 extends 关键字来实现继承,C++ 使用冒号(class B : public A),Python 使用 paraentheses(),Ruby 使用 <。不过,有些编程语言只支持单继承,不支持多重继承,比如 Java、PHP、C#、Ruby 等,而有些编程语言既支持单重继承,也支持多重继承,比如 C++、Python、Perl 等。

  • 意义:
    • 继承最大的一个好处就是代码复用。但也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。
    • 能描述is-a 关系。通过继承来关联两个类,反应真实世界中的这种关系,非常符合人类的认知,而且,从设计的角度来说,也有一种结构美感。
  • 弊端:过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差;子类和父类高度耦合,修改父类的代码,会直接影响到子类。

2.2.4多态(Polymorphism)

  • 多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。

    public static void main(String args[]) {
      DynamicArray dynamicArray = new SortedDynamicArray();
      test(dynamicArray); // 打印结果:1、3、5
    }
  • 语法机制支持:

    • 第一个语法机制是编程语言要支持父类对象可以引用子类对象,也就是可以将 SortedDynamicArray 传递给 DynamicArray。
    • 第二个语法机制是编程语言要支持继承,也就是 SortedDynamicArray 继承了 DynamicArray,才能将 SortedDyamicArray 传递给 DynamicArray。
    • 第三个语法机制是编程语言要支持子类可以重写(override)父类中的方法,也就是 SortedDyamicArray 重写了 DynamicArray 中的 add() 方法。
  • 接口类来实现多态特性

    public static void main(String[] args) {
      Iterator arrayIterator = new Array();
      print(arrayIterator);
    
      Iterator linkedListIterator = new LinkedList();
      print(linkedListIterator);
    }
  • 用 duck-typing 来实现多态特性:

    只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系,这就是所谓的 duck-typing,是一些动态语言所特有的语法机制

    class Logger:
        def record(self):
            print(“I write a log into file.”)
    
    class DB:
        def record(self):
            print(“I insert data into db. ”)
    
    def test(recorder):
        recorder.record()
    
    def demo():
        logger = Logger()
        db = DB()
        test(logger)
        test(db)
  • 意义
    • 多态特性能提高代码的可扩展性和复用性。
    • 多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等等。

2.3 面向对象相对于面向过程的优势

2.3.1什么是面向过程编程与面向过程编程语言?

  • 面向对象
    • 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
    • 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
  • 面向过程
    • 面向过程编程也是一种编程范式或编程风格。它以过程(可以理解为方法、函数、操作)作为组织代码的基本单元,以数据(可以理解为成员变量、属性)与方法相分离为最主要的特点。
    • 面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(比如继承、多态、封装),仅支持面向过程编程。

2.3.2面向对象编程相比面向过程编程有哪些优势?

  • OOP 更加能够应对大规模复杂程序的开发

    大规模复杂程序的开发来说,整个程序的处理流程错综复杂,先思考如何给业务建模,如何将需求翻译为类,如何给类之间建立交互关系,而完成这些工作完全不需要考虑错综复杂的处理流程。当有了类的设计之后,然后再像搭积木一样,按照处理流程,将类组装起来形成整个程序。这种开发模式、思考问题的方式,能让我们在应对复杂程序开发的时候,思路更加清晰。

  • OOP 风格的代码更易复用、易扩展、易维护

    面向对象编程四大特性支持

  • OOP 语言更加人性化、更加高级、更加智能

    越高级的编程语言离机器越“远”,离我们人类越“近”,越“智能”。

    大胆想象一下,使用一种编程语言,可以无需对计算机知识有任何了解,无需像现在这样一行一行地敲很多代码,只需要把需求文档写清楚,就能自动生成想要的软件了。

2.4哪些代码设计看似是面向对象,实际是面向过程的?

2.4.1滥用 getter、setter 方法

  • 违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。
  • 给一些需要计算得出的属性设置setter方法,导致可以随意设置其值,权限不清。

2.4.2滥用全局变量和全局方法

  • 常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。
    • 单例类对象在全局代码中只有一份,所以,它相当于一个全局变量。
    • 静态成员变量归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量。
    • 而常量是一种非常常见的全局变量。
  • 常见的常量类定义方法如下:
public class Constants {
  public static final String MYSQL_ADDR_KEY = "mysql_addr";
  public static final String MYSQL_DB_NAME_KEY = "db_name";
  public static final String MYSQL_USERNAME_KEY = "mysql_username";
  public static final String MYSQL_PASSWORD_KEY = "mysql_password";
  
  public static final String REDIS_DEFAULT_ADDR = "192.168.7.2:7234";
  public static final int REDIS_DEFAULT_MAX_TOTAL = 50;
  public static final int REDIS_DEFAULT_MAX_IDLE = 50;
  public static final int REDIS_DEFAULT_MIN_IDLE = 20;
  public static final String REDIS_DEFAULT_KEY_PREFIX = "rt:";
  
  // ...省略更多的常量定义...
}
  • 定义一个如此大而全的 Constants 类,有发下缺点:

    • 影响代码的可维护性:

      在开发过程中,可能都要涉及修改这个类,比如往这个类里添加常量,那这个类就会变得越来越大,成百上千行都有可能,查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率。

    • 增加代码的编译时间:

      当 Constants 类中包含很多常量定义的时候,依赖这个类的代码就会很多。那每次修改 Constants 类,都会导致依赖它的类文件重新编译,因此会浪费很多不必要的编译时间。

    • 影响代码的复用性:

      复用本项目开发的某个类,而这个类又依赖 Constants 类。即便这个类只依赖 Constants 类中的一小部分常量,我们仍然需要把整个 Constants 类也一并引入,也就引入了很多无关的常量到新的项目中。

  • 正确定义:

    • 将 Constants 类拆解为功能更加单一的多个类,比如跟 MySQL 配置相关的常量,我们放到 MysqlConstants 类中;跟 Redis 配置相关的常量,我们放到 RedisConstants 类中。

    • 不单独地设计 Constants 常量类,而是哪个类用到了某个常量,我们就把这个常量定义到这个类中。比如,RedisConfig 类用到了 Redis 配置相关的常量,那我们就直接将这些常量定义在 RedisConfig 中,这样也提高了类设计的内聚性和代码的复用性。

2.4.3定义数据和方法分离的类

​ 数据定义在一个类中,方法定义在另一个类中

​ MVC三层结构:一般情况下,VO、BO、Entity 中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的 Controller 类、Service 类、Repository 类中。这就是典型的面向过程的编程风格。

2.4.4为什么容易写出面向过程的风格?

  • 面向过程编程风格恰恰符合人的流程化思维方式

  • 面向对象编程要比面向过程编程难一些:在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。
  • 所以,基于这两点原因,很多工程师在开发的过程,更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格的了。

2.4.5 C语言的面向对象

  • 封装,在一个文件中定义变量为static,而把修改变量的函数对外开放,设为extern
  • 抽象,函数本身就是带走抽象的意思,在c语言头文件中,对外开放的函数头就是抽象。
  • 继承,定义一个结构体带函数指针与数据,然后把这个结构体嵌入到其他结构体中,模仿了组合概念,应该不是抽象。
  • 多态,函数指针实现多态

2.4.6”贫血模型“的开发模式为什么会流行?

  • 实现简单。Object仅仅作为传递数据的媒介,不用考虑过多的设计方面,将核心业务逻辑放到service层,用Hibernate之类的框架一套,完美解决任务。
  • 上手快。使用贫血模式开发的web项目,新来的程序员看看代码就能“照猫画虎”干活了,不需要多高的技术水平。所以很多程序员干了几年,仅仅就会写CURD。
  • 一些技术鼓励使用贫血模型。例如J2EE Entity Beans,Hibernate等。

2.4.7延伸观点

  • 用shell实现自动化脚本做的服务编排,一般都是面向过程,一步一步的。而k8s的编排却是面向对象的,因为它为这个顺序流抽象出了很多角色,将原本一步一步的顺序操作转变成了多个角色间的轮转和交互。
  • 从接触ddd才走出javaer举面向对象旗,干面向过程勾当的局面。所谓为什么“充血模型”不流行,我认为不外呼两个。
    • 一,规范的领域模型对于底层基础架构来说并不友好(缺少setget),所以会导致规范的领域模型与现有基础架构不贴合,切很难开发出完全贴合的基础架构,进而引深出,合理的业务封装却阻碍关于复用通用抽象的矛盾。
    • 二,合理的业务封装,需要在战略上对业务先做合理的归类分割和抽象。而这个前置条件很少也不好达成。进而缺少前置设计封装出来的“充血模型”会有种四不像的味道,反而加剧了业务的复杂性,还不如“贫血模型”来得实用。事实上快节奏下,前置战略设计往往都是不足的,所以想构建优秀的“充血模型”架构,除了要对业务领域和领域设计有足够的认知,在重构手法和重构意愿上还要有一定讲究和追求,这样才能让项目以“充血模型”持续且良性的迭代。
  • “充血模型”相对于“贫血模型”有什么好处?从我的经验来看,可读性其实可能“贫血模型”还好一点,这也可能有思维惯性的原因在里面。但从灵活和扩展性来说“充血模型”会优秀很多,因为好的“充血模型”往往意味着边界清晰(耦合低),功能内敛(高内聚)。

2.5抽象类和接口的区别,如何用普通类来模拟

2.5.1什么是抽象类和接口?区别在哪里?

  • Java 这种编程语言中,如何定义抽象类:

    // 抽象类
    public abstract class Logger {
      private String name;
      private boolean enabled;
      private Level minPermittedLevel;
    
      public Logger(String name, boolean enabled, Level minPermittedLevel) {
        this.name = name;
        this.enabled = enabled;
        this.minPermittedLevel = minPermittedLevel;
      }
    
      public void log(Level level, String message) {
        boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
        if (!loggable) return;
        doLog(level, message);
      }
    
      protected abstract void doLog(Level level, String message);
    }
    // 抽象类的子类:输出日志到文件
    public class FileLogger extends Logger {
      private Writer fileWriter;
    
      public FileLogger(String name, boolean enabled,
        Level minPermittedLevel, String filepath) {
        super(name, enabled, minPermittedLevel);
        this.fileWriter = new FileWriter(filepath); 
      }
    
      @Override
      public void doLog(Level level, String mesage) {
        // 格式化level和message,输出到日志文件
        fileWriter.write(...);
      }
    }
    // 抽象类的子类: 输出日志到消息中间件(比如kafka)
    public class MessageQueueLogger extends Logger {
      private MessageQueueClient msgQueueClient;
    
      public MessageQueueLogger(String name, boolean enabled,
        Level minPermittedLevel, MessageQueueClient msgQueueClient) {
        super(name, enabled, minPermittedLevel);
        this.msgQueueClient = msgQueueClient;
      }
    
      @Override
      protected void doLog(Level level, String mesage) {
        // 格式化level和message,输出到消息中间件
        msgQueueClient.send(...);
      }
    }
    • 过上面的这个例子,我们来看一下,抽象类具有哪些特性。总结了下面三点。
      • 抽象类不允许被实例化,只能被继承。也就是说,你不能 new 一个抽象类的对象出来(Logger logger = new Logger(…); 会报编译错误)。
      • 抽象类可以包含属性和方法。方法既可以包含代码实现(比如 Logger 中的 log() 方法),也可以不包含代码实现(比如 Logger 中的 doLog() 方法)。不包含代码实现的方法叫作抽象方法。
      • 子类继承抽象类,必须实现抽象类中的所有抽象方法。对应到例子代码中就是,所有继承 Logger 抽象类的子类,都必须重写 doLog() 方法。
  • Java 这种编程语言中,如何定义接口:

    // 接口
    public interface Filter {
      void doFilter(RpcRequest req) throws RpcException;
    }
    // 接口实现类:鉴权过滤器
    public class AuthencationFilter implements Filter {
      @Override
      public void doFilter(RpcRequest req) throws RpcException {
        //...鉴权逻辑..
      }
    }
    // 接口实现类:限流过滤器
    public class RateLimitFilter implements Filter {
      @Override
      public void doFilter(RpcRequest req) throws RpcException {
        //...限流逻辑...
      }
    }
    // 过滤器使用demo
    public class Application {
      // filters.add(new AuthencationFilter());
      // filters.add(new RateLimitFilter());
      private List<Filter> filters = new ArrayList<>();
    
      public void handleRpcRequest(RpcRequest req) {
        try {
          for (Filter filter : fitlers) {
            filter.doFilter(req);
          }
        } catch(RpcException e) {
          // ...处理过滤结果...
        }
        // ...省略其他处理逻辑...
      }
    }
    • 代码非常简洁。结合代码,我们再来看一下,接口都有哪些特性。总结了三点:

      • 接口不能包含属性(也就是成员变量)。

      • 接口只能声明方法,方法不能包含代码实现。

      • 类实现接口的时候,必须实现接口中声明的所有方法。

      • jdk8允许接口中定义默认方法和静态方法

      • public interface AnimalFactory {
          static Animal create(Supplier<Animal> supplier) {
              return supplier.get();
          }
          void run();
          void swim();
        }

2.5.1为什么需要抽象

  • 抽象类更多的是为了代码复用

  • 既然继承本身可以实现复用,为什么一定要用抽象?

    抽象父类相对于普通父类,优势是优雅的支持多态:

    在 Logger 父类中,定义一个空的 log() 方法,让子类重写父类的 log() 方法,这样实现多态有以下缺点:

    • 在 Logger 中定义一个空的方法,会影响代码的可读性
    • 当创建一个新的子类继承 Logger 父类的时候,有可能会忘记重新实现 log() 方法。而基于抽象类的设计思路,编译器会强制要求子类重写 log() 方法,否则会报编译错误。
    • Logger 可以被实例化,换句话说,我们可以 new 一个 Logger 出来,并且调用空的 log() 方法。这也增加了类被误用的风险。

2.5.2为什么需要接口

  • 接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。
  • 接口是一个比抽象类应用更加广泛、更加重要的知识点。比如,经常提到的“基于接口而非实现编程”,就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想。

2.5.3如何模拟抽象类和接口两个语法概念

  • C++用抽象类模拟接口

    抽象类 Strategy 没有定义任何属性,并且所有的方法都声明为 virtual 类型(等同于 Java 中的 abstract 关键字),这样,所有的方法都不能有代码实现,并且所有继承这个抽象类的子类,都要实现这些方法

    从语法特性上来看,这个抽象类就相当于一个接口。

    class Strategy { // 用抽象类模拟接口
      public:
        ~Strategy();
        virtual void algorithm()=0;
      protected:
        Strategy();
    };
  • java普通类模拟接口

    • 可以让类中的方法抛出 MethodUnSupportedException 异常,来模拟不包含实现的接口,并且能强迫子类在继承这个父类的时候,都去主动实现父类的方法,否则就会在运行时抛出异常。
    • -避免这个类被实例化呢?只需要将这个类的构造函数声明为 protected 访问权限就可以了。
    public class MockInteface {
      protected MockInteface() {}
      public void funcA() {
        throw new MethodUnSupportedException();
      }
    }

2.5.4如何决定该用抽象类还是接口?

  • 如果要表示一种 is-a 的关系,并且是为了解决代码复用的问题,就用抽象类;
  • 如果要表示一种 has-a 关系,并且是为了解决抽象而非代码复用的问题,就用接口。

2.6基于接口而非实现编程

2.6.1如何理解「接口」

越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。

2.6.2如何将这条原则应用到实战中?

设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用,我们封装了图片存储相关的代码逻辑,提供了一个统一的 AliyunImageStore 类,供整个系统来使用。具体的代码实现如下所示:

public class AliyunImageStore {
  //...省略属性、构造函数等...
  
  public void createBucketIfNotExisting(String bucketName) {
    // ...创建bucket代码逻辑...
    // ...失败会抛出异常..
  }
  
  public String generateAccessToken() {
    // ...根据accesskey/secrectkey等生成access token
  }
  
  public String uploadToAliyun(Image image, String bucketName, String accessToken) {
    //...上传图片到阿里云...
    //...返回图片存储在阿里云上的地址(url)...
  }
  
  public Image downloadFromAliyun(String url, String accessToken) {
    //...从阿里云下载图片...
  }
}

// AliyunImageStore类的使用举例
public class ImageProcessingJob {
  private static final String BUCKET_NAME = "ai_images_bucket";
  //...省略其他无关代码...
  
  public void process() {
    Image image = ...; //处理图片,并封装为Image对象
    AliyunImageStore imageStore = new AliyunImageStore(/*省略参数*/);
    imageStore.createBucketIfNotExisting(BUCKET_NAME);
    String accessToken = imageStore.generateAccessToken();
    imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
  }
  
}

整个上传流程包含三个步骤:创建 bucket(你可以简单理解为存储目录)、生成 access token 访问凭证、携带 access token 上传图片到指定的 bucket 中。

后来自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上。为了满足这样一个需求的变化,我们该如何修改代码呢?

  • 以上代码几个细节问题:
    • AliyunImageStore 类中有些函数命名暴露了实现细节,比如,uploadToAliyun() 和 downloadFromAliyun()
    • 将图片存储到阿里云的流程,跟存储到私有云的流程,可能并不是完全一致的:阿里云的图片上传和下载的过程中,需要生产 access token,而私有云不需要 access token
  • 解决这个问题的根本方法就是,在编写代码的时候,要遵从“基于接口而非实现编程”的原则,具体来讲,我们需要做到下面这 3 点:

    • 函数的命名不能暴露任何实现细节。比如,前面提到的 uploadToAliyun() 就不符合要求,应该改为去掉 aliyun 这样的字眼,改为更加抽象的命名方式,比如:upload()。
    • 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
    • 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。
  • 基于以上原则,重构代码如下:

    public interface ImageStore {
      String upload(Image image, String bucketName);
      Image download(String url);
    }
    
    public class AliyunImageStore implements ImageStore {
      //...省略属性、构造函数等...
    
      public String upload(Image image, String bucketName) {
        createBucketIfNotExisting(bucketName);
        String accessToken = generateAccessToken();
        //...上传图片到阿里云...
        //...返回图片在阿里云上的地址(url)...
      }
    
      public Image download(String url) {
        String accessToken = generateAccessToken();
        //...从阿里云下载图片...
      }
    
      private void createBucketIfNotExisting(String bucketName) {
        // ...创建bucket...
        // ...失败会抛出异常..
      }
    
      private String generateAccessToken() {
        // ...根据accesskey/secrectkey等生成access token
      }
    }
    
    // 上传下载流程改变:私有云不需要支持access token
    public class PrivateImageStore implements ImageStore  {
      public String upload(Image image, String bucketName) {
        createBucketIfNotExisting(bucketName);
        //...上传图片到私有云...
        //...返回图片的url...
      }
    
      public Image download(String url) {
        //...从私有云下载图片...
      }
    
      private void createBucketIfNotExisting(String bucketName) {
        // ...创建bucket...
        // ...失败会抛出异常..
      }
    }
    
    // ImageStore的使用举例
    public class ImageProcessingJob {
      private static final String BUCKET_NAME = "ai_images_bucket";
      //...省略其他无关代码...
    
      public void process() {
        Image image = ...;//处理图片,并封装为Image对象
        ImageStore imageStore = new PrivateImageStore(...);
        imagestore.upload(image, BUCKET_NAME);
      }
    }
  • 总结:在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。在定义接口的时候,不要暴露任何实现细节。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。

2.6.3是否需要为每个类定义接口?

  • 做任何事情都要讲求一个“度”,过度使用这条原则,增加开发负担

  • 回到设计原则初衷:将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。

    上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。

  • 如果在业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。

  • 越是不稳定的系统,越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那就没有必要为其扩展性,投入不必要的开发时间。

2.7为何说多用组合少用继承?如何决定用组合还是继承?

2.7.1为什么不推荐使用继承?

  • 虽然可以解决问题,但不够优美。因为除了鸵鸟之外,不会飞的鸟还有很多,比如企鹅。对于这些不会飞的鸟来说,我们都需要重写 fly() 方法,抛出异常。这样的设计,一方面,徒增了编码的工作量;另一方面,也违背了最小知识原则(Least Knowledge Principle,也叫最少知识原则或者迪米特法则),暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。
  • 总之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。这也是为什么我们不推荐使用继承。

2.7.2组合相比继承有哪些优势?

  • 可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题。

    public interface Flyable {
      void fly();
    }
    public interface Tweetable {
      void tweet();
    }
    public interface EggLayable {
      void layEgg();
    }
    public class Ostrich implements Tweetable, EggLayable {//鸵鸟
      //... 省略其他属性和方法...
      @Override
      public void tweet() { //... }
      @Override
      public void layEgg() { //... }
    }
    public class Sparrow impelents Flayable, Tweetable, EggLayable {//麻雀
      //... 省略其他属性和方法...
      @Override
      public void fly() { //... }
      @Override
      public void tweet() { //... }
      @Override
      public void layEgg() { //... }
    }

2.7.3如何判断该用组合还是继承?

  • 尽管鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。

    继承改写成组合意味着要做更细粒度的类的拆分。也就意味着,要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。

  • 如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,就尽量使用组合来替代继承。

  • 还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

  • 有的时候,从业务含义上,A 类和 B 类并不一定具有继承关系。比如,Crawler 类和 PageAnalyzer 类,它们都用到了 URL 拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现 Crawler 类和 PageAnalyzer 类继承同一个父类,而父类中定义的却只是 URL 相关的操作,会觉得这个代码写得莫名其妙,理解不了。这个时候,使用组合就更加合理、更加灵活。具体的代码实现如下所示:

    public class Url {
      //...省略属性和方法
    }
    
    public class Crawler {
      private Url url; // 组合
      public Crawler() {
        this.url = new Url();
      }
      //...
    }
    
    public class PageAnalyzer {
      private Url url; // 组合
      public PageAnalyzer() {
        this.url = new Url();
      }
      //..
    }
  • 有一些特殊的场景要求我们必须使用继承。如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。比如下面这样一段代码,其中 FeignClient 是一个外部类,我们没有权限去修改这部分代码,但是我们希望能重写这个类在运行时执行的 encode() 函数。这个时候,我们只能采用继承来实现了。

    public class FeignClient { // feighn client框架代码
      //...省略其他代码...
      public void encode(String url) { //... }
    }
    
    public void demofunction(FeignClient feignClient) {
      //...
      feignClient.encode(url);
      //...
    }
    
    public class CustomizedFeignClient extends FeignClient {
      @Override
      public void encode(String url) { //...重写encode的实现...}
    }
    
    // 调用
    FeignClient client = new CustomizedFeignClient();
    demofunction(client);

2.7.4延伸观点

Paul Shan:我的观点比较极端,用接口,组合和委托代替继承。原因如下:

  1. 人无法预知未来,现在比较稳定的类继承关系将来未必稳定。
  2. 两种设计之间的选择耗费资源,每次都要为这个问题拿捏一下,甚至争论一下,不如把争论放在业务逻辑的实现上。
  3. 相对于接口+组合+委托增加的复杂度,代码统一成接口+组合+委托带来的好处更多,利于阅读和交流,毕竟读代码的次数大于写的次数,读一种类型的代码的难度远低于读两种类型。
  4. 新的编程语言让接口+组合+委托变得容易,例如Kotlin就有专门的语法糖支持,消除了很多模板代码。
  5. 接口+组合+委托符合矢量化思想,那就是将物体特征分成不同的维度,每个维度独立变化。继承则是将物体分类,抽取共性,处理共性,操作的灵活性大打折扣,毕竟现实中的物体特征多,共性少。

猜你喜欢

转载自www.cnblogs.com/wod-Y/p/12331409.html