设计模式之美第二周

理论二:封装、抽象、继承、多态分别可以解决哪些编程问题?

本文主要针对四大特性,结合实际代码,帮助我们了解每个特性存在的意义和目的,以及它们能解决哪些编程问题。

封装 Encapsulation

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

对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是访问权限控制。

如果对类中属性访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更灵活,但是从另一方面来说过度灵活意味着不可控。除此之外,类仅仅通过有限的方法暴露必要的操作,也提高类的易用性。调用者不需要了解太多背后的业务细节,用错的概率也会减少。

抽象 Abstraction

封装主要讲的是如何隐藏信息、保护数据,而抽象讲的是如何隐藏方法的具体实现,让调用者之需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。

在面向对象编程中,我们常借助编程语言提供的接口类(Interface)或者抽象类(Abstract)这两种语法机制,来实现抽象。

抽象的意义,首先作为一种只关注功能不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。其次,抽象在代码设计中起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则、代码解耦等。

我们在定义类的方法时,也要有抽象思维,不要在方法定义中暴露太多的实现细节,以保证在某个时间点需要修改方法的实现逻辑时不用去修改其定义。

继承 Inheritance

为了实现继承特性,编程语言需要提供特殊的语法机制来支持,如Java中使用extends来实现继承,C++使用冒号,Python使用paraentheses()等。不过有些语言只支持单继承,如Java、PHP、C#、Ruby等,有些支持多重继承,如C++、python、Perl等。

继承存在的最大好处就是代码复用。不过过度使用继承层次过深过复杂,会导致代码可读性、可维护性变差。所以继承应该尽量少用,甚至不用。(多用组合少用继承)

多态 Polymorphism

多态能提高代码的可扩展性和复用性。除此之外多态也是很多设计模式、设计原则、编程技巧的代码实现基础。比如策略模式、基于接口而非实现编程、依赖倒置原则、里氏替换原则、利用多态去掉冗长的if-else语句等。

思考题

Java不支持继承的原因是多重继承存在副作用:钻石问题(菱形继承)

假设B和C都继承A,且都重写了A中同一方法,类D继承类B和类C,对于B、C重写的A中的方法,类D会继承哪一个会产生歧义。但是Java支持多接口实现,因为接口中的方法是抽象的,在实现接口时需要实现类自己实现,所以不会出现二义性问题。

理论三:面向对象比面向过程有哪些优势?面向过程过时了吗?

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

面向过程编程也是一种编程范式/风格,它以过程作为组织代码的基本单元。以数据与方法相分离为最主要的特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。
面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性。

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

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

对于大规模复杂程序开发来说,整个程序的处理流程错综复杂,并非只有一条主线。如果用面向过程编程这种流程化、线性的思维方式,去翻译这个网状结构,去思考如何把程序拆解为一组顺序执行的方法会比较吃力。

面向对象编程以类为思考对象。在进行面向对象编程的时候,我们并不是一上来就去思考如何讲复杂的流程拆解为一个一个方法,而是采用曲线救国的策略。先去思考如何给业务建模,如何将需求翻译为类,如何给类之间建立交互关系,完成这些工作完全不需要考虑错综复杂的处理流程。

除此之外,面向对象编程还提供了一种更加清晰、更加模块化的代码组织方式。

实际上利用面向过程的编程语言那样,也可以写出面向对象风格的代码。只不过可能会比用面向对象编程语言付出的代价更高一些。两种编程风格并不是完全对立的。

OOP代码更易复用、易扩展、易维护

封装特性是两种编程风格最基本的区别,面向对象将数据和方法绑定在一起,通过访问权限控制,只允许外部调用者通过类暴露的有限方法访问数据,而不会像面向过程那样,数据可以被任意方法修改。因此面向对象提供的封装特性更有利于提高代码的易维护性。

函数本身就是一种抽象,它隐藏了具体实现。我们在使用函数时之需要了解函数具有什么功能,而不需要了解它是怎么实现的。从这一点上两种编程风格都支持抽象特性。只是面向对象还提供了其他抽象特性的实现方式,如基于接口实现的抽象。基于接口的抽象可以让我们在不改变原有实现的情况下,轻松替换新的实现逻辑,提高了代码的可扩展性。

继承特性是面向对象特有的,能避免代码重复写很多遍,提高了代码的复用性。

多态特性也是面向对象特有,在需要修改一个功能实现的时候,可以通过实现一个新的子类的方式,在子类重写原来的逻辑功能。用子类替换父类遵从了“对修改关闭,对扩展开放”的原则,提高代码的扩展性。除此之外,多态特性使得不同类对象可以传递相同的方法,执行不同的逻辑,提高代码复用性。

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

面向对象时,开发者是在思考如何给业务建模、如何将真实世界映射为类或者对象,能更聚焦到业务本身,而不是思考如何和机器打交道。

思考

  • Unix/Linux这些复杂系统,也是基于C语言这种面向过程的编程语言开发的,怎么看待这种现象?
    • 操作系统是业务无关的,它更接近于底层计算机,因此更适合用面向过程的语言编写。并且和硬件打交道需要考虑到语言本身翻译成机器语言的成本和执行效率。
    • 不过操作系统虽然是面向过程的C语言实现,但是其设计逻辑是面向对象的。它用结构体同样实现了信息的封装,内核源码中也不乏继承和多态思想的体现。面向对象思想并不局限于具体语言。

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

滥用getter、setter方法

在项目开发中,有时定义完类的属性之后,就顺手将属性的getter、setter方法都定义上。IDE或者Lombok插件会自动生成所有属性的getter、setter方法。

这种方法是不推荐的,因为其违反了面向对象的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。 例如下面这段代码

public class ShoppingCart {
    private int itemsCount;
    private double totalPrice;
    private List<ShoppingCartItem> items = new ArrayList<>();

    public int getItemsCount() {
        return this.itemsCount;
    }

    public void setItemCount(int itemsCount) {
        this.itemsCount = itemsCount;
    }

    public double getTotalPrice() {
        return this.totalPrice;
    }

    public void setTotalPrice(double totalPrice) {
        this.totalPrice = totalPrice;
    }

    public List<ShoppingCartItem> getItems() {
        return this.items;
    }

    public void addItems(ShoppingCartItem item) {
        items.add(item);
        itemCount++;
        totalPrice += item.getPrice();
    }
    ...
}

在这个代码中虽然我们将itemsCount和totalPrice定义为private,但是外部可以通过setter方法随意修改这两个属性的值。可能会导致和items属性的值不一致。暴露不该暴露的setter方法明显违反了面向对象的封装特性。数据没有任何访问权限,任何代码都可以随意修改,代码就退化成了面向过程编程风格了。

对于items我们没有设置setter方法,这样的设计看起来没有任何问题,而实际上并不是。items属性的getter方法返回的是一个List容器。外部调用者在拿到这个容器后,是可以操作容器内部数据的。比如obj.getItems().clear()会清空购物车,这样也会导致类属性中三个数据不一致。

正确的方法是应该专门在类中提供clear方法,并且修改getItems返回类型为Collections.undermidifiableList()。此时外部调用要修改就会抛出UnsupportedOperationException异常,避免容器中的数据被修改。(这里还存在一个问题,虽然items容器中数据不会被修改,但是容器中每个对象ShoppingCartItem的数据仍然可以修改)

滥用全局变量和全局方法

面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。

  • 单例类对象在全局代码中只有一份,相当于全局变量
  • 静态成员变量归属类上的数据,被所有实例化对象共享,也相当于一定程度上的全局变量
  • 常量是非常常见的全局变量,放到一个Constant类中
  • 静态方法一般用来操作静态变量或者外部数据。如各种Utils类,里面的方法一般都会定义成静态方法。静态方法将方法和数据分离,破坏了封装特性,是典型的面向过程风格。

如:

public class Constants {
    public static final String MYSQL_ADDR_KEY = "mysql_addr";
    ...
}

我们会把程序中所有用到的常量都集中放到这个Constants类中,这并不是一个很好的设计思路。

  • 首先会影响代码的可维护性。开发同一项目的工程师很多,在开发过程中可能都要涉及修改这个类,查找修改可能比较费时,并且会增加提交代码冲突的概率。
  • 其次,这样的设计会增加代码的编译时间。 依赖这个类的代码很多,每次修改Constants类都会导致依赖它的类重新编译。
  • 最后,这样设计会影响代码的复用性。 如果我们在另一个项目中复用本项目的一个类,该类又依赖Constants类,即使只依赖其中的一部分我们仍然需要将整个Constants类也一起并入。引入许多无关的常量到新项目中。

如何改进呢?

  • 将Constants类拆解为功能更加单一的多个类
  • 另一种更好的思路是,并不单独地设计Constants常量类,而是哪个类用到了某个常量,就把这个常量定义到这个类中,提高了类设计的内聚性和代码的复用性

对于Utils类,它的出现主要是解决了多个类需要用到一块相同的功能逻辑,为了避免代码重复。通常为了复用会通过继承特性,将相同的属性和方法提取出来,定义到父类中,子类复用父类中的属性和方法。但是有时候从业务含义上,这些类并不一定具有继承关系,仅仅为了代码复用生硬地抽象一个父类,会影响代码可读性。所以只包含静态方法Utils类就出现了,它实现了公用的方法但是不需要共享任何数据,因此不需要定义任何属性。同时也要注意不要实现大而全的Utils类,最好细化一下。

定义数据和方法分离的类

传统的MVC分为Model层、Controller层、View层,在做前后端分离之后,三层结构在后端开发时会稍微有些调整,被分为Controller层、Service层、Repository层。Controller层负责暴露接口给前端调用,Service层负责核心业务逻辑,Repository层负责数据读写。在每一层中我们又会定义相应的VO(ViewObject)、BO(BusinessObject)、Entity。一般情况下VO、BO、Entity只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的Controller类、Service类、Repository类中。这就是典型的面向过程的编程风格。

实际上这种开发模式叫做基于贫血模型的开发模式,也就是我们现在非常常用的一种Web项目开发模式。

面向对象编程中,为什么容易写出面向过程风格的代码?

主要是面向过程符合人的流程化思维方式。面向对象则是一种自底向上的思考方式,不是先去思考执行流程来分解任务,而是将任务翻译成一个一个的小模块,设计类之间的交互,最后按照流程将类组装起来完成整个任务。这种思考路径比较适合复杂程序开发,不是特别符合人类的思考习惯。

除此之外,面向对象中类的设计挺需要技巧,需要一定设计经验,要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,类之间的交互等诸多问题。

不管使用面向过程还是面向对象,最终目的都是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,就大可不用避讳在面向对象编程中写面向过程风格的代码。

理论五: 接口VS抽象类的区别?如何用普通的类模拟抽象类和接口?

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

抽象类

  • 不允许被实例化,只能被继承。
  • 抽象类可以包含属性和方法。方法既可以包含代码实现,也可以不包含代码实现。不包含代码实现的方法叫做抽象方法。
  • 子类继承抽象类,必须实现抽象类中的所有抽象方法。

接口

  • 接口不能包含属性(即成员变量)
  • 接口只能声明方法,方法不能包含代码实现
  • 类实现接口的时候,必须实现接口中声明的所有方法

抽象类实际上就是类,只不过是一种不能被实例化的特殊类,只能被子类继承,is-a的关系。接口表示has-a的关系,表示具有某些功能。对于接口,有一个更加形象的叫法,就是协议。

抽象类和接口能解决什么编程问题?

抽象类是为代码复用而生的,多个子类可以继承抽象类中定义的属性和方法,避免在子类中重复编写相同的代码。普通的类继承虽然也可以解决代码复用问题,但是无法使用多态特性,会增加类被无用的风险。虽然也可以通过设置私有的构造函数的方式来解决,不过显然没有抽象类优雅。

接口更侧重于解耦,是对行为的一种抽象,相当于一组协议或者契约,可以类比API接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。

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

我们可以通过抽象类来模拟接口。首先接口的定义:接口中没有成员变量,只有方法声明,没有方法实现,实现接口的类必须实现接口中的所有方法。

class Strategy {
    public:
        ~Strategy();
        virtual void algorithm() = 0;
    protected:
        Strategy();
}

上述C++代码中用抽象类模拟了一个接口,类中没有定义任何属性,并且所有方法都是virtual类型。

除了用抽象类来模拟接口,我们还可以用普通类来模拟接口。类中虽然包含方法不符合接口定义,但是我们可以让类中的方法抛出异常来模拟不包含实现的接口,并且能强迫子类在继承这个父类的时候都去主动实现父类的方法,否则就会在运行时抛出异常。为了避免该类被实例化,我们将类的构造函数声明为protected方法就可以了。

public class MockInterface {
    protected MockInterface() {}
    public void funcA() {
        throw new MethodUnSupportedException();
    }
} 

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

要表示一种is-a的关系,并且是为了解决代码复用的问题,就用抽象类。

要表示一种has-a的关系,并且是为了解决抽象而非代码复用的问题,就可以使用接口。

抽象类是一种自下而上的设计思路,现有子类的代码重复,然后再抽象成上层的父类。而接口正好相反,它是一种自上而下的设计思路,在编程的时候一般是先设计接口再去考虑具体的实现。

理论六:为什么基于接口而非实现编程?有必要为每个类都定义接口吗?

Program to an interface, not an implematation。这句话最早出自1994年GoF的设计模式这本书,是一种比较抽象泛化的思想。此处的interface不要局限于编程语言中的接口。

如果落实到具体代码,这条原则中的接口可以理解为编程语言中的接口或者抽象类。

应用这条原则可以有效地提高代码质量,实现接口和实现相分离,封装不稳定的实现,暴露稳定的接口。当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。

  • 函数的命名不能暴露任何实现细节
  • 封装具体的实现细节
  • 为实现类定义抽象的接口,具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。

总之,我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。在定义接口的时候,不要暴露任何实现细节,接口的定义只表明做什么,而不是怎么做。而且在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,能否做到在替换具体的接口实现的时候,不需要任何接口定义的改动。

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

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

理论七:为什么要多用组合少用继承?如何决定该用组合还是继承?

为什么不推荐用继承?

比如,我们设计一个关于鸟的类AbstractBird,所有细分的鸟都继承这个抽象类。 大部分鸟都可以飞,我们可不可以在抽象类中定义fly()方法呢?答案是不行,因为还有特例,如果我们对所有不会飞的鸟都重写fly方法并且抛出异常也可行,但是不够优雅。一方面增加了代码量另一方面也违背的Least Knowledge Principle(最小知识原则/迪米特法则),暴露了不该暴露的接口给外部,增加了类使用过程中被误用的概率。

此时再通过抽象类派生出两个细分的类,AbstractFlyableBird/AbstractUnFlyableBird(),这样继承关系就变成3层。如果此时再关注鸟会不会叫等等特点,继承关系就会越来越复杂,导致代码可读性变差。也破坏了类的封装特性,将父类的实现细节暴露给子类,子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改就会影响所有子类的逻辑。

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

我们可以利用组合Composition、接口、委托delegation三个技术手段,一起解决刚刚继承存在的问题。

接口表示某种行为特性,针对会飞特性我们可以定义Flyable接口,只让会飞的鸟去实现这个接口,对于会叫、会下蛋这些行为特性,类似定义Tweetable接口、EggLayable接口。不过接口只声明方法,不定义实现,也就是所有会下蛋的类都要实现一遍LayEgg()方法,会导致代码重复的问题。

我们可以针对三个接口再定义三个实现类,通过组合和委托技术来消除代码重复。

public interface Flyable() {
    void fly();
}
public class FlyAbility implements Flyable {
    @Override
    public void fly() {...}
}
...

public class Ostrich implements Tweetable, Egglayable {
    private TweetAbility tweetAbility = new TweetAbility();
    private EggLayAbility eggLayAbility = new EggLayAbility();

    @Overide 
    public void tweet() {
        tweetAbility.tweet();
    }

    @Overide
    public void layEgg() {
        eggLayAbility.layEgg();
    }
}

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

如果类之间的继承结构稳定,继承层次比较浅(最多两层),继承关系不复杂我们就可以大胆使用继承。反之则尽量使用组合。

有一些设计模式会固定使用继承或组合:

  • 装饰者模式decorator pattern、策略模式Strategy pattern、组合模式Composite pattern都使用了组合关系
  • 模版模式template pattern则使用了继承关系

还有一些特殊场景必须使用继承。如果不能改变函数的入参类型,而入参又非接口,为了支持多态只能采用继承来实现。