封装、继承、多态 & 组合,为什么组合大于继承?

封装、继承、多态 & 组合,为什么组合大于继承?

日和雫的设计模式系列第②节

第一期里我们已经深入了解了面向对象设计以及面向过程设计各自的优劣以及使用场景,接下来让我们将中心放到面向对象设计上来,继续讲解有关面向对象的一些核心概念,为后续讲解设计模式做铺垫。

如果在大学期间选修过Java或者C++课程的朋友们,对于封装、继承、多态肯定不会陌生,它们是面向对象的三大核心特性,但是对于组合这个概念,大家会经常听到,而很少会去专门探讨这个概念。因此,本文将为大家复习封装、继承、多态的概念以及作用,同时向大家详细介绍何为组合,以及回答"为什么组合大于继承"这个老生常谈的问题。

封装

封装,顾名思义,指的是通过某些手段限制外部对模块或者类的内部的字段、方法进行访问,达到业务隔离的效果,它的作用是:

  • 一是通过合理的模块、类以及属性访问权限的设计,能够让不同模块之间的边界更加清晰,有利于模块间的解耦;

  • 二是能够将模块内部的实现细节与外部隔离,这对模块调用方以及模块开发方都是有利的:

    • 对于模块调用方而言,使用该模块需要付出的学习成本少了,用起来会更加方便;
    • 对于模块开发方而言,模块代码混杂其他模块的代码更少了,也会更加有利于模块的维护,换句话来说,就是模块内聚性增强了;
  • 三是一定程度上加强了访问模块的安全性。好的模块封装能够将模块自身的状态以及可变性对外隐藏,避免将修改这些内容的风险暴露给外部模块,导致系统非预期行为的发生。

这里我们简单举个例子,以展现封装在安全性方面的重要性。

这是一个简单的库存单位类,它的业务含义很简单,就是表示一个物品的名称以及库存量,类本身提供了一系列方法用于修改对应物品的库存,这些方法都是线程安全的。

typescript 复制代码
package lesson2;
​
import lombok.AllArgsConstructor;
import lombok.Data;
​
/**
 * 货物类
 * @author 日和雫
 */
@Data
@AllArgsConstructor
public class Cargo {
​
    /**
     * 货物名称
     */
    private String name;
​
    /**
     * 货物数量
     */
    private Integer count;
​
​
    /**
     * 线程安全方法:添加货物量
     * @param count Integer
     */
    public synchronized void addCount(Integer count) {
        this.count += count;
    }
​
    /**
     * 线程安全方法:减少货物量
     * @param count Integer
     */
    public synchronized void remCount(Integer count) {
        this.count -= count;
    }
​
    public static void main(String[] args) {
        Cargo cargo = new Cargo("apple", 20);
        // 正确使用方法
        cargo.addCount(20);
        System.out.println(cargo.count);
        cargo.remCount(10);
        System.out.println(cargo.count);
​
        // 错误使用方法:不看类的源码的结果
        cargo.setCount(cargo.getCount() + 20);
        System.out.println(cargo.count);
        cargo.setCount(cargo.getCount() - 10);
        System.out.println(cargo.count);
    }
}

这样设计虽然看着好像没有什么大的问题,毕竟方法功能还是比较清晰的,但是实际存在一个很大的风险:就是这个类使用了@Data注解来生成Getter和Setter方法,在不被人察觉的情况下将这个类的库存字段给暴露出去了。

假如你的其他同事并没有详细看过这个类的内容的话,在实现一些业务时,很有可能就会直接暴露出去的这个字段进行修改,绕过了你在那些修改库存的方法中执行的操作,在并发条件下可能会导致非预期的结果。

由此可见,合理的封装在我们平时的开发工作中还是相当重要的。

继承

对于继承,它的含义是:一个新类型基于已有类型来定义,并自动拥有或扩展它的非私有属性和行为。

部分编程语言(JavaScript、Java、Python)在语法层面实现了继承的语义,比如说 Java 与 JavaScript(ES6) 中的 extends 关键字等等。通过继承,我们可以构建一个类以及基于其进行扩展的类,即父类和子类,它们之间的关系我们一般通过"is-a 关系 "来表示。子类可以复用父类的所有行为,同时根据自身需要去修改以及扩展父类的行为。

java 复制代码
/**
 * Java 的继承语法
 * @author 日和雫
 */
public class Extend {
​
​
     static class Child extends Father {
        public Child(String name) {
            super(name);
        }
​
        @Override
        public void speak() {
            System.out.println("My name is " + name + ", I'm a child.");
        }
    }
​
​
​
    static class Father {
        protected String name;
​
        public Father(String name) {
            this.name = name;
        }
​
        public void speak() {
            System.out.println("My name is " + name + ", I'm a father.");
        }
    }
​
    public static void main(String[] args) {
        Father father = new Extend.Father("John");
        father.speak();
        Child child = new Extend.Child("Mike");
        child.speak();
    }
}
js 复制代码
/**
 * JavaScript 的继承语法
 * @author 日和雫
 */
​
// 父类 (对应 Java 的 Father)
class Father {
    constructor(name) {
        // JS 中没有 protected,通常约定以 _ 开头表示受保护属性
        this._name = name; 
    }
​
    speak() {
        console.log(`My name is ${this._name}, I'm a father.`);
    }
}
​
// 子类 (对应 Java 的 Child)
class Child extends Father {
    constructor(name) {
        // 对应 Java 的 super(name);
        // 在 JS 中,子类构造函数必须先调用 super() 才能使用 this
        super(name); 
    }
​
    // 重写父类方法 (对应 Java 的 @Override)
    speak() {
        console.log(`My name is ${this._name}, I'm a child.`);
    }
}
​
// 测试代码 (对应 Java 的 main 方法)
const father = new Father("John");
father.speak(); // 输出: My name is John, I'm a father.
​
const child = new Child("Mike");
child.speak();  // 输出: My name is Mike, I'm a child.

通过上面的描述,大家应该能清晰了解到继承的作用了,那就是增强代码的复用性,提高开发效率。正因如此,继承在 Java、C++ 这类早期的面向对象编程语言中享有了一等公民的地位。

但是,如果大家经常阅读一些技术相关的文章的话,就会发现,社区中对于继承的批评一直都存在,并且像Rust、Golang这类新兴的编程语言,也都不再提供继承的语义实现了,这又是什么原因呢?

  • 第一,继承一定程度上会带来子类与父类的耦合。 这个结论可能会有一些反直觉,高内聚低耦合一直都是面向对象设计的目标之一,为什么继承作为面向对象的核心特性之一,反而还可能导致系统耦合呢?只能说凡事皆有两面,在子类继承了父类之后,尽管能够方便地复用父类代码,实际上其自身的行为和属性将会和父类进行强绑定,这个现象被称为 "白盒复用" 。

    最明显的一个例子就是方法签名,子类重写父类的方法时,方法签名必须与父类保持完全一致,父类中被重写的方法名一旦发生改变,所有重写它的子类和调用处都要跟着一起改。 在子类少的时候,修改的代价不高,但是一旦在复杂系统的场景下,这个代价将会随子类的数量以及复杂程度飞速增长,就出现了子类与父类紧耦合的情况。

  • 第二,继承会破坏父类的封装性。 有过Java开发经验的朋友应该知道,在Java中,子类是可以直接访问父类中的非私有属性的,这就意味着父类的部分内部状态会被暴露给子类,这样就出现了状态安全性的问题,我们在对封装的讲解中已经提到过了,这里不再补充。

    除此之外,由于在复杂的父类中存在方法互相调用的情况,子类在重写父类的方法时,如果不去了解父类具体的实现逻辑,很有可能会导致非预期的行为,换句话来说其实也就是加深了子类和父类的耦合,和第一点是类似的。

  • 第三,继承的滥用很容易产生冗长的继承链,一定程度上拉高了代码的复杂度,削弱了代码的可读性,代码调试起来也很困难。

正因为上面的这些问题,谨慎使用继承成为了社区的共识,GoF(设计模式:可复用面向对象软件的基础)的作者在书中也明确写道:优先使用对象组合,而不是类继承,即"组合优于继承" ,有关组合的细节我们在后续也会进行介绍,在这里我们只需要了解到,继承并不是解决代码复用性问题的唯一方案即可。

多态

多态也是一个比较好理解的概念,它指的是同一套方法或接口在不同的环境中具备不同的行为,这个环境可以指不同的实例对象,也可以指不同的参数列表(方法重载)。

打一个最简单的比方:动物可以发出各种声音与同类进行交流,不同的动物发声的方式却各不相同------狗可以通过声带振动发出犬吠,蝉则依靠腹部的发音膜发出蝉鸣。在这个例子中,发声可以视为动物的共有活动,映射到代码中,这个发声的方法就属于动物接口的内容,而狗和蝉就相当于实现了动物接口的实例,它们拥有它们专有的发声实现逻辑,这个时候就体现出了多态的语义。

对于多态,我们一般将其分成两大类别:编译时多态(静态多态)和运行时多态(动态多态)。在面向对象编程语言中,我们经常会通过声明一个父类类型的对象字段,把一个子类类型的对象赋值给它,以实现代码上的多态调用,这种编码方式一般称作向上转型。这种写法的好处是开发者不再需要考虑这个字段的实际类型是什么 ,只要这些类型都继承或实现自父类或者接口,这段代码都支持运行,有利于代码复用。

然而,尽管开发者不需要考虑字段的实际类型,到程序具体执行时,依旧需要得到字段的具体类型,才能知道该调用哪个类型的方法,那么程序该怎么拿到这个实际类型信息呢?目前存在两种方案,一种是在编译期获取,另一种是在运行时获取,这也就是区分编译时多态和运行时多态的核心标准。

关于各种编程语言在编译期以及运行期获取字段实际类型的原理各有不同,也存在对应的优缺点,能讲的内容很多,但不是本系列重点,所以这里就不再多说了,如果有深入了解的朋友,也可以在评论区中留下你的理解~

好了,我们已经对多态的概念进行了讲解,那么它能在我们的开发中发挥哪些作用呢?

  • 通过多态,多种不同类型但是具有相同行为的类可以共享一份使用代码,有利于代码的复用;
  • 通过多态,在不修改现在任何代码的前提下,随时可以通过开发新的子类来扩展程序功能,使得代码更易于扩展与迭代。

组合

好了,对于面向对象的三大核心特性已经介绍完了,接下来要介绍的是组合。组合其实也是面向对象的特性之一,近些年来也备受吹捧,颇有取代继承地位之势。

在面向对象编程中,组合(Composition)指的是将多个对象组合在一起以构建更复杂功能的一种系统设计方式 。在前面我们提到过,继承能够体现出父类与子类之间一种 "is a" 的关系,而组合则是体现出容器类与其持有的组件类之间一种 "has a" 的关系。

在将系统合理拆分的条件下,一个大的业务对象内部可以根据具体的角色或者细节,内聚成不同的组件对象,最外侧的容器对象用于暴露外部接口,在实现具体的接口时,尽可能地将逻辑委托给其他组件对象去实现,而自己只做一些代理(Proxy)或者门面(Facade)性质的工作。

根据组件与容器的之间的绑定程度的强弱(即组件脱离容器后依旧有价值(能在其他地方被使用)),组合可以再分为组合(强组合)与聚合(弱组合) 。

用现实生活中的案例做例子:像人体与自身的器官就是一种强组合,器官无法脱离人体存在(不考虑器官移植);而手机和手机壳就是一种弱组合,手机壳脱离了手机依旧可以单独存在,也能套用到其他型号的手机上去。强组合的容器和组件之间互相依存,实际上属于一个独立业务模型的不同成分;弱组合中的组件可以视作为当前业务模型想要实现某个逻辑的"外援",容器将这个逻辑的实现委托给这个组件去实现。

前面我们提到过,社区普遍认可"组合优于继承"的看法,那为什么大家对组合有这么高的评价呢,它相比继承的优势到底在哪里呢?其实,组合的优势就在于能够很好地弥补继承的大部分缺点:

  • 在继承中,子类可以访问父类状态,会破坏父子类之间的封装,但是组合不会,状态被封装到了组件类中,容器想使用它只能通过组件向其提供的方法或者接口;
  • 在继承中,子类强依赖于父类的实现,会导致耦合,而组合通过委托的方式将具体的工作交给组件类去处理,有利于系统的解耦;
  • 滥用继承,可能会导致极其复杂的父子继承链,大大降低代码的可读性以及可维护性,而组合的结构往往是扁平的,这种结构在迭代时往往是进行横向扩展而非纵向扩展,从而降低了代码逻辑链过深的风险。

这些,便是我们要复用代码时往往会建议优先使用组合而非继承的原因。

当然,还是需要说明的是,尽管组合是一套很好的复用方案,但是它也无法完全替代继承,毕竟继承相比组合还是有着更高的开发效率,以及更低的认知门槛,对于一些简单的父子关系,使用继承实现还是要比组合来得方便得多。

总结

到此为止,对面向对象几大特性的介绍就完成了。简单总结一下:封装、继承、多态为面向对象的三大核心特性,它们在实际开发中起到了非常重要的作用。

  • 封装有利于系统中不同业务细节的解耦,同时也有助于保证模块访问的安全性;
  • 继承有利于子类对父类代码逻辑的复用,同时让父子类之间的关系更加清晰;
  • 多态能够用一套代码兼容多种实现,既有利于代码复用,也有利于代码的可扩展性以及可维护性;
  • 组合的存在是为了解决滥用继承导致的一系列问题,像父子类代码耦合、父类封装性被破坏以及继承链冗长等等。

也因此,在现代软件工程中,往往遵从"组合优于继承"的共识。

好了,有关于封装、继承、多态以及组合的介绍已经完全结束了,感谢大家能够看到这里。下一篇文章将继续讲解面向对象的六大设计原则,内容不会仅仅停留在概念上,针对每一个原则,文章中都会给出对应的实践方法,希望能够帮助到大家!

往期文章:

1.面向对象与面向过程,谁才是"银弹"?

相关推荐
代钦塔拉5 小时前
【C++/Qt 进阶】设计模式系列(6):桥接模式——把“两个独立变化的维度“拆开
c++·qt·设计模式
haluhalu.5 小时前
深入理解AIAgent(1.3)--- 设计模式
人工智能·python·设计模式·langchain
高晶1 天前
CANopen 在 BMS 系统中的 C 语言实战指南
设计模式·程序员
博风1 天前
13、设计模式应用举例:工厂方法模式:创建药品对象
设计模式·工厂方法模式
代钦塔拉1 天前
【C++/Qt 进阶】设计模式系列(5):适配器模式——给“不兼容的接口“贴一层翻译膜
c++·qt·设计模式
bigbread2 天前
策略模式 + 工厂方法模式在项目中的实战应用
设计模式
高晶2 天前
C 语言实现 BMS 系统中 SPI 通信协议实战指南
设计模式·程序员
代钦塔拉2 天前
【C++/Qt 进阶】设计模式系列(2):工厂模式——从游戏角色到披萨店
c++·算法·设计模式
高晶2 天前
2A3 单端甲类功放从零制作全攻略:电路设计、焊接调试到煲机维护一站式指南
前端·设计模式