从面向对象解读设计思想

从面向对象解读设计思想

作者:哲思

时间:2021.8.30

邮箱:1464445232@qq.com

GitHub:zhe-si (哲思) (github.com)

前言

很早就想总结一下自己对面向对象的理解,借这次公开课梳理了一下思路,并在之后撰写成本文。

对于面向对象概念性的介绍与理解当前网上已经有很多了,但却很少有人能讲出怎样用好面向对象,也就是如何用面向对象的思想设计出好的程序。所以本文将侧重“设计”二字来讲述这个问题。

当然,本文只是我参照当下所学和做的项目产生的认识,可能随着见识的提升和技术的发展,推翻一些当下所写。但是,其中对设计的思考,想必是走向更高位置的必经之路。

注:本文举例所用的代码统一使用Kotlin,一种包含诸多高级特性、可代替Java并能够编译成诸多类型的产物、已经成为Android官方推荐的高级语言。

1.什么是面向对象

首先,给大家一个思考题。

小明是一个志存高远的程序员。一天,由于业务需要,他想要在原有数据类型Number的基础上拓展两个新的子数据类型A与B,但操作时需要统一使用父类型Number进行操作,同时需要支持调用顺序无关的相加(add)的方法(假设相加逻辑为A.numA + B.numB,相加结果始终为C类型)。

小明的设计之魂涌上心头,打算不光要实现,还要实现一个更灵活、易拓展的设计,但没有什么好的思路,你能帮帮他吗?

1.1.面向对象的含义

从小明的问题回过头,我们开门见山的给出面向对象编程的定义:

面向对象编程就是将事物抽象成对象,针对对象所持有的数据和与之相关的行为进行编程

想要了解这个概念,就不得不从老生常谈的编程范式的历史讲起。

当计算机世界初开的时候,世界上只有低级语言,即机器语言和汇编语言。这种语言,从计算机的角度,一步步告诉计算机它该先做什么,再做什么。而我们需要把我们实际的问题转化为计算机的基本模型:存储器、运算器、控制器、输入/输出设备,也就是把什么数据存起来,什么数据和什么数据取出来做运算。我们把这种编程方式叫做指令式编程

后来,人们为了让编程语言更符合人的理解,所以将最能描述事物本质同时又足够抽象的数学概念引入其中,我们可以像解数学题一样定义变量、对变量相加减(此处的变量指用一个标识符代指一个数据)、甚至定义函数来表示一个通用操作过程。这样,我们就可以通过数学去描述现实事物,并将事物的发展转化为一步步的运算过程。我们把这种编程方式叫作过程式编程,也算指令式编程的一种延伸。

在编写程序的过程中,人们发现编程的本质就是处理数据,也就是数据和操作(对数据的处理)。而二者有着非常明显的对应关系,一组相关的数据,总是对应一组相关的操作。而这样的组合,便满足了我们生活中对于绝大多数事物(也就是对象)的描述。我们将现实中的事物对应程序中的对象,让程序的运行变成对象与对象间的交互对象成为程序中的基本单元,将一类对象相关的数据和数据对应的操作封装到一起作为类,而对象则是该类的一个具体实例,这便是面向对象编程

编程的发展史便是不断抽象来让编程符合人的认知和事物的本质。包括之后出现的函数式编程、响应式编程,都是如此。但之后的编程范式都没有完全逃脱面向对象的思想,同时都是在一些具体场景下的产物。世界是由事物组成的,这已经符合了我们对世界基本的认知,这也是面向对象一直经久不衰的原因。

1.2.面向对象的三大特征

这里要首先强调一个概念:类型。面向对象将一切看成对象,通过类去描述对象,这里的类,在程序中,就是类型。我们将一类对象定义为一种类型,并在类型中声明属性和方法(这些都是该类型的特征)。可以说,面向对象编程,从计算机角度来说,就是面向类型编程!

接下来,我们将细说面向对象的概念。而面向对象的三大特征则是对其概念最好的描述:封装、继承、多态

三者可以说从三个层面对面向对象进行了描述。封装是面向对象最基本的表现,继承是面向对象最核心的行为,多态是面向对象最重要的能力

1.2.1.封装

封装:将不需要外部看到的数据和对应方法放到类内,外部不可见,只暴露外部需要看到的数据和方法。

这是面向对象的初衷和最基本的表现,将相关的数据放到一起,将数据对应的方法放到一起,实现了高内聚。

同时,进行信息隐藏,将内部数据和逻辑隐藏到类的内部,只让外部看到这个类的外部表现对应的数据和操作,实现了低耦合。

举个经典的例子:

属性 行为
名字、颜色、尾巴长短 吃饭、叫、尾巴长不长
class Dog(
    val name: String,
    val color: String,
    private val tailLength: Double
) {
    private val description: String
        get() = "${color}色、尾巴${tailLength}厘米的狗${name}"
    
    fun eat() {
        println(description + "正在吃饭")
    }

    fun shout() {
        println(description + "正在叫:汪汪汪!")
    }
      
    fun isTailLong(): Boolean {
        return tailLength > 15
    }
}

fun main() {
    // 一个狗的实例对象
    val dog1 = Dog("dog1", "黑白", 12.5)
    // 狗暴露出的外部信息与行为
    println(dog1.name)
    println(dog1.color)
    dog1.eat()
    dog1.shout()
    println(dog1.isTailLong())
}

在这里狗的属性与行为都被封装到Dog类中。

当前场景下外界不需要了解狗的尾巴具体是多长,所以将尾巴具体长度的信息隐藏,而暴露判断尾巴长不长的方法。同时对内部实现所需的狗的自我描述description也进行隐藏,只能通过对外暴露的行为间接访问。

这样,外部可以通过Dog来访问狗的各种外在信息与行为,同时也看不到内部具体的实现。

本例中,封装的是一个实体类,将一个实体相关的数据和方法放到一个类中。但如果只是这样,实现的方法有很多种,称不上使用了面对对象,因为现实事物都有一个很重要的描述方式:依据特征去分类

1.2.2.继承

继承:依据相关类的共有特征进行层级分类,具体类包含抽象类(它的上一层分类)的特征,二者是一种“is-a”的关系。

这是面向对象最核心的行为与标志。子类继承父类,表示子类“is-a”父类,子类从父类得到子类共有的方法,并进行个性化实现与拓展,是一种父类别下的具体类别,有着父类包含的特征,也可以拥有自己独有的特征。而父类是一组相关子类共同特征的集合,可以从抽象层面代指子类

比如以下的例子,

狗、猩猩、猫、兔子,都是(is-a)动物,“动物”是那些具体动物的上一级分类(当然,这里还可以说它们都是哺乳动物,这分类的依据,需要根据需求和实际情况而定),包含了具体动物在“动物”这个抽象层面的共同特征。

动物

属性 行为
名字、颜色 吃饭、叫
abstract class Animal(
    val name: String,
    val color: String
) {
    // description是通用的内部特征,但会随着不同的实例而变化,所以将会改变的子类个性化特征描述otherDescription与子类型名typeName抽象出来,让子类实现
    protected abstract val otherDescription: String
    protected abstract val typeName: String
  
    protected val description: String
        get() = "${color}色${otherDescription}的${typeName}${name}"

    abstract fun eat()
    abstract fun shout()
}

于是,我们将狗的抽象特征提取到动物抽象类中,

class Dog(
    name: String,
    color: String,
    private val tailLength: Double
): Animal(name, color) {

    override val otherDescription = "、尾巴${tailLength}厘米"
    override val typeName = "狗"

    override fun eat() {
        println(description + "正在吃饭")
    }

    override fun shout() {
        println(description + "正在叫:汪汪汪!")
    }

    fun isTailLong(): Boolean {
        return tailLength > 15
    }
}

并引入新的动物类别:猩猩。它也是动物的一种,包含动物的特征。

class Orangutan(
    name: String,
    color: String,
): Animal(name, color) {

    override val otherDescription = ""
    override val typeName = "猩猩"

    override fun eat() {
        println(description + "正在吃饭")
    }

    override fun shout() {
        println(description + "正在叫:嗷嗷~!")
    }
}

我们虽然提取了狗和猩猩的抽象特征“动物”,但当我们直接需要狗或者猩猩对象时,二者的外在表现没有任何区别。我们可以调用它们的抽象特征和特有特征。

    // main()中
    // 当我们需要狗的时候,直接实例化一只狗,可以调用它的抽象特征(如:name、eat等)以及特有特征(isTailLong)
    println("**************** 1 *******************")
    val dog1 = Dog("dog1", "黑白", 12.5)
    println(dog1.name)
    println(dog1.color)
    dog1.eat()
    dog1.shout()
    println(dog1.isTailLong())

    // 需要猩猩也是同理
    println("**************** 2 *******************")
    val orangutan1 = Orangutan("orangutan1", "黑")
    println(orangutan1.name)
    orangutan1.eat()
    orangutan1.shout()
// 输出
**************** 1 *******************
dog1
黑白
黑白色、尾巴12.5厘米的狗dog1正在吃饭
黑白色、尾巴12.5厘米的狗dog1正在叫:汪汪汪!
false
**************** 2 *******************
orangutan1
黑色的猩猩orangutan1正在吃饭
黑色的猩猩orangutan1正在叫:嗷嗷~!

但是当我们只需要关注动物的抽象特征、不关心具体动物的特有特征时,可以用“动物”这个抽象类别去统一代指和对待。从抽象层面,狗、猩猩都是动物。

    // 当我们只需要所有的动物,不需要区分是狗还是猩猩,则可以用父类去统一代指具体类,并调用其抽象的共有特征(但这些抽象特征的具体表现不同)
    println("**************** 3 *******************")
    val animals = listOf(dog1, orangutan1, Dog("dog2", "白", 15.2), Orangutan("orangutan2", "棕"))
    for (animal in animals) {
        println(animal.name)
        println(animal.color)
        animal.eat()
        animal.shout()
        println()
    }
// 输出
**************** 3 *******************
dog1
黑白
黑白色、尾巴12.5厘米的狗dog1正在吃饭
黑白色、尾巴12.5厘米的狗dog1正在叫:汪汪汪!

orangutan1
黑
黑色的猩猩orangutan1正在吃饭
黑色的猩猩orangutan1正在叫:嗷嗷~!

dog2
白
白色、尾巴15.2厘米的狗dog2正在吃饭
白色、尾巴15.2厘米的狗dog2正在叫:汪汪汪!

orangutan2
棕
棕色的猩猩orangutan2正在吃饭
棕色的猩猩orangutan2正在叫:嗷嗷~!

1.2.3.多态

多态:相同的特征,在不同情况下有不同的表现

这是面向对象最重要的能力,也是它灵活、易拓展和复用的原因。多态本身的内涵非常宽泛,有重载多态、子类型多态、参数多态、结构多态、行多态等。从面向对象角度,最常用的是子类型多态。但不管是那种多态,都符合以上的定义,都可以在调用相同的特征后产生不同的表现。

比如,重载多态,通过重载函数(函数本身即可理解为一种能力或特征,放到类中,即该类型的特征),调用时使用不同的参数(类别、个数)进而得到不同的表现。

class Number(val num: Int) {
 fun add(number: Number): Int {
        return num + number.num
    }
    
    fun add(number: Int): Int {
        return number + num
    }
}

fun main() {
    val n1 = Number(5)
    println(n1.add(6))
    println(n1.add(Number(6)))
}

而子类型多态,在继承的例子中已有表现,Animal父类指代不同的子类型(狗、猩猩)时,虽然一视同仁的调用了共有的特征animal.eat()animal.shout(),但却产生了不同的表现,如狗的“汪汪”叫和猩猩的“嗷嗷”叫。

这(子类型多态)是通过定义具体子类型,并调用抽象父类型的共有特征实现的多态。父类型声明了一组类型的共有特征,但不一定直接实现,可以延迟到子类型去实现,进而基于子类型不同的实现方式产生不同的表现(多态)。而这种多态的实现方式,是基于继承实现的

由于在讲面向对象,所以以下我们所说的多态都特指子类型多态,如果描述其他类型多态,会具体说明。

1.3.面向对象的思想

上面已经说过,三大特征是从三个层面去描述面向对象。封装从代码手段层面将相关的数据和对应的操作集中放到一起,让程序聚合成类和对象的基本单元;继承从核心行为层面,给予了类聚合相关特征、灵活分类的能力;多态从表现和结果层面,描述了基于这种分类所带来的好处,即可拓展性和可复用性。


好了,读到这里,想必大家对面对对象的基本概念和想法有了初步的理解,这些知识是当前网上比较“流行”的内容,也足够大家去面试或回答本科课堂的问题(甚至比较自信的说,算是比较透彻的了 😎)。

但我想问,你真的理解了面向对象了吗?或者说,你能用好面向对象并写出好的设计吗?再或者说,你已经有了足够好的想法,去解决本节一开始提出的思考题吗?

我想你也看到了,这不是结束,只是这篇文章的开始。面向对象的思想博大精深,也不断与时俱进,需要丰富的经验与知识储备,更需要我们不断的探索。

我们直接给出思考题比较基本和经典的解法,也希望大家能在继续阅读的过程中,发现当前解法在不同场景(或拓展场景)的问题,并产生自己新的想法:

// 解法: 二次分派
typealias C = Int

abstract class Number {
    abstract fun add(number: Number): C
    abstract fun add(a: A): C
    abstract fun add(b: B): C
}

class A(private val numA: C): Number() {
    override fun add(number: Number): C {
        return number.add(this)
    }

    override fun add(a: A): C {
        return a.numA + numA
    }

    override fun add(b: B): C {
        return b.numB + numA
    }
}

class B(val numB: C): Number() {
    override fun add(number: Number): C {
        return number.add(this)
    }

    override fun add(a: A): C {
        return a.add(this)
    }

    override fun add(b: B): C {
        return b.numB + numB
    }
}

fun main() {
    val n1: Number = A(3)
    val n2: Number = B(4)
    println(n1.add(n2))
    println(n2.add(n1))
}

// 输出
// 7
// 7

那么,面向对象的思想到底是什么?

面向对象是一种语言无关、技术无关的一种编程范式,强调编程时思考问题的角度与方法。

我们回过头再去看面向对象的定义:面向对象编程就是将事物抽象成对象,针对对象所持有的数据和与之相关的行为进行编程

简单来说,我们要分析目标数据和操作,将世界合理的抽象为有层级分类的对象结构,每个对象结构是一组数据和与之相关的操作的集合,通过对象的创建、运行、交互和销毁实现一个程序。

这里的对象,不光包括以上例子中的实体类对象(如:Animal、Dog等),还包括一些抽象的对象(如:行为、消息、算法、状态等)。

这里的关键点就是对世界的抽象与分类,难点则有很多,如:对象的交互方式、销毁的时机等。由此也可以看出来,对目标事物本质的思考与理解才是最重要的,有了不同的抽象与分类形式,进而才会有其他问题(如:交互方式)的产生。

对世界进行抽象与分类的过程,也是认知事物相同与不同的过程。“道生一,一生二,二生三,三生万物。”可以说,正因为不同,因为变化,才产生了世界上的万物!所以想要描述好世界上的事物,就必须描述好事物的变化规律

2.面向“变化”编程

在我看来,良好的设计最重要、也是最主要的就是面向“变化”编程。

处理好了变化,不光拥有了良好的拓展性,更提升了复用性、可读性、稳定性、可测试性、兼容性等诸多指标,甚至性能也会有所提升。

2.1.什么是变化?

那么,什么是“变化”?

变化就是一组可以独立改变的事物。

具体来讲,从抽象类衍生出各种有着特有特征的具体类就是变化;产生了(或可能产生)新的事物进而需要拓展新的类就是变化;在不同的情况下事物需要有不同的表现就是变化。这三种变化,总结起来,就是:衍生细节、拓展种类、封装差异。

2.1.1.衍生细节

小明开了一家快餐店,由于小明随便卖了点吃的(方便面),所以大家来了都招呼小明:老板,来份吃的。

后来,小明拓展业务,也做起了手抓饼。由于有的顾客想吃方便面,有的想吃手抓饼,而“吃的”是二者的统称,无法区分二者,所以之后顾客进门就说:老板,来份方便面/手抓饼。或者说:老板,吃的中来份方便面/手抓饼。当然,也有一些不拘小节的顾客,进门就说:老板,随便来份吃的。

在这个例子中:

初期,由于没有或不需要那么多细节,大家不关系与区分具体种类,所以用统称去描述。

后期,发生了变化,由于业务细化,内容变得丰富起来,细节变多,这个统称无法去描述与区分那些不同的细节,需要用更具体的类别去描述所需,而具体的类别自然也被包括在笼统的类别中。当然,也存在一些不关心这些细节的场景与需要,而直接用统称去描述它们。

这就是对细节的衍生。从一开始简单的业务需要,到后来逐渐细化业务,区分细节。对应的,就是一开始的抽象类(也许一开始不认为它是抽象类),到后来从抽象类衍生的、可以区分细节的具体类。

而这样从抽象类衍生具体类,非常自然的应对了这种细节衍生的变化。不但解决了新的要区分细节的需要,还不破坏原本对抽象事物的使用。

2.1.2.拓展种类

小红也开了一个快餐店,她的拿手绝活是牛肉包子,吸引许多人慕名来吃,每个进来都招呼小红:老板,来个牛肉包子。

后来,小红也拓展业务,想到自己牛肉包子好吃,自己做别的包子估计也不错,于是出了新的猪肉包子。每个进来卖猪肉包子的顾客可以仿照卖牛肉包子招呼小红:老板,来个猪肉包子。

在这个例子中:

初期,业务场景单一,只有一个种类。

后期,发生了变化,由于业务拓展,新的类似业务产生,有了新的种类。同时,由于二者的相似性,拓展时如果完全重新拓展,成本较高。由此,将这类业务的共同特征提取,作为公共的抽象业务(抽象类),基于此抽象业务去拓展新的业务。

这就是对种类的拓展。从一开始单一的业务需要,到后来逐渐拓展业务,添加业务种类。对应的,就是一开始只有一个具体类别,到后来需要高效拓展,提取公共业务封装到抽象类中,并基于抽象类去拓展新的具体类别。

这样提取抽象类并拓展其他具体类,非常统一、高效的应对了这种种类拓展的变化。不但解决了创建新类别的要求,还支持原有公共逻辑与接口的复用。


这里可能会有同学困惑,两个例子都是现有一个具体类,后来需要产生新类,为啥一个是衍生细节、一个是拓展种类呢?

首先,请注意衍生细节在初期的抽象类指没有或不需要那么多细节,本例子讲方便面虽是一个具体类,但在当前不区分细节的场景下,是当作一个抽象类——“吃的”去处理的。当然,有一些场景,可能一开始就是只需要抽象类(如:一开始我认为长得像猫和老鼠中的杰瑞的都是老鼠,后来才知道老鼠还分仓鼠、田鼠等具体种类,甚至其下还有更具体的种类)。

而拓展种类,需要抽象类是因为需要提取公共逻辑与接口,实现复用。

所以虽然看上去都是实现抽象类和具体类,但是二者出发点不同,抽象类和具体类的概念出现的顺序也不同。


2.1.3.封装差异

小明的快餐店不景气,快要交不起店铺租用费了。他想起老朋友小红的快餐店的包子十分火爆,想去投靠小红。

小红十分善良,同意了小明的请求。两人一合计,打算将两个快餐店合成一个,同时不分你我,一起经营,并合并了菜单。

菜单:

吃的:方便面、手抓饼、包子(牛肉包子、猪肉包子)

在这个例子中:

当前,存在两个相似业务(两人的快餐店),虽然存在许多实现上的不同(一个是小明的,一个是小红的,菜单也不同),但大体业务形式是相似的(都支持顾客点餐)。

既然是相似的业务,外部就希望可以统一的看待它们,不希望自己处理这些不同。而这些不同,就是差异,就是变化。

为了统一对待相似业务,可以提取它们的宏观公共业务逻辑与表现接口,封装为总体业务的类。而实现上的不同,则交由实现接口(抽象类)的具体类去实现。

这就是对差异的封装。从一开始有多个相似的业务,到后来需要统一对待,隐藏差异。对应的,就是一开始有多个业务逻辑类,到后来提取相同的宏观公共业务逻辑与接口作为总体业务类,而变化的具体实现则延迟到实现对应接口的具体类中去实现。

这样提取提取相似的业务逻辑与表现、封装变化的具体实现的方式,对外透明且清晰的应对了这种存在差异而导致的变化。不但让差异对外透明,而且精简了重复的逻辑,同时支持具体实现的复用。

2.2.怎么面向变化编程

面向变化编程的过程,也是封装变化、调整结构支持变化的过程。

什么是变化的章节,我们讲到了三种常见变化,在实际问题中,我们不一定每次都要分清这是什么变化,因为变化的表现千变万化,一个变化可能所属多个变化的种类,难以明确分类,更难以一一归纳。但不要害怕,以上三种变化从大的方面基本涵盖了变化的情形,其他大多是它们的变种。其次,上面的三种变化是给出了三种典型情况,希望以此为例,说明变化是什么以及解决变化的通用方法。

2.2.1.封装变化

面向变化编程的第一步就是封装变化。

封装变化有三个重点:1. 让变化对外界透明;2. 将变化缩小为它本质的样子;3. 将变化的影响范围缩到最小

  • 将变化封装的结果就是变化对外界透明。

    对变化的细节感兴趣的,我们允许它们感知到变化;对于更多对变化对细节不感兴趣的,我们要让变化可以像一类事物去对待,而将变化的实现延迟到具体类中实现,减少外界对变化的了解。这不但减轻了外界处理变化、了解变化细节的复杂度与工作量,降低了代码复杂度,同时也将变化的影响控制在了内部范围,便于查找与修改。

  • 将变化封装的难点就是将变化缩小到它本质的样子。

    换句话说,就是找出变化的本质,并将其封装,不要将没有变化的部分也封装其内。找出变化的本质可是一个技术活,需要你切实了解到底是什么引发了变化,拨开应用场景对变化的层层封装,将真正的变化抽象出来。当我们将本质的变化封装起来,也就最大限度的复用了代码,减少了每次变化的代码体积,同时逻辑也将更加清晰,好处自然不言而喻。

  • 将变化的影响范围缩到最小,是封装变化的重要目的。

    除了将变化的部分透明的封装到内部,同时要减少对外暴露的表现形式,让行为与职责更加单一明确。而在变化的类内部,变化的部分影响的范围也应尽可能的缩小。最好,变化的类就是变化本身。减少变化的影响范围,本身也反映了对变化的理解程度以及对变化的抽象能力。当我们将变化的影响范围缩到最小,我们也就尽可能的掌控了变化。

2.2.2.支持变化

面向变化编程的核心一步,就是调整变化的封装结构,支持更多的变化。

变化虽带来复杂性,但更是发展与进步的标志。项目只应该因为业务而终止,而不应该是代码无法继续维护和拓展而结束。面对变化,不但不应该逃避,我们反而应该去拥抱变化。

当我们已经通过封装变化将变化尽可能集中,接下来,我们就可以让变化在此处支持拓展,并在不同的情形下选择不同的具体表现。而支持变化的方法,无外乎还是提取公共特征与相同的抽象业务形式(接口),并将不同的实现延迟到具体类。但困难的是如何对这些特征及接口进行抽象,让其足够清晰且灵活。

同时,在实现的过程中,大家也在尽可能的不让新的变化影响原本的逻辑,这样做可以让变化更灵活、易拓展与复用。而方法还是将变化尽可能的封装,将变化的逻辑更集中的放到具体的实现类中,让变化可以自描述。当然,外界如果想使用一个新的类别,就必须使用新类别的声明。想要优化这个问题,除了将新类别的引用封装到单个地方来隐藏其他地方的显示引用外,还可以通过代码生成、依赖注入等方式隐式引用。

2.3.在面向对象中面对变化

藏了这么长时间,终于讲到了如何用面向对象去解决变化问题 😁。在什么是变化章节中,我们讲了变化就是在原本的业务基础上产生了新的业务需求,主要有三种情况:衍生细节、拓展种类、封装差异;在怎么面向变化编程章节中,我们讲了要对变化进行抽象、封装和拓展。但大家有没有一种莫名的熟悉感?这不就是我们在面向对象中学到的东西吗?

2.3.1.用面向对象衍生细节

什么是变化:衍生细节的例子中,从抽象类中衍生细节,产生具体类,不就是继承吗?这种变化体现了继承允许一种类别可以在其基础上去完成个性化的实现与拓展的能力。而由于指向不同的具体类,在使用抽象类的统一调用中,产生不同的表现(表现不同的细节),就是多态呀。

将该例子实现为代码,并针对怎么面向变化编程所讲进行具体说明,如下:

原本小明的快餐店,卖吃的,不需要那么多细节,

class Food() {
    fun sellFood() {
        println("老板,来份吃的")
    }
}

class FastFoodShopXM {
    val food = Food()
    
    fun orderFood() {
        food.sellFood()
    }
}

后来,细化了业务,具体区分了“吃的”的种类,

open class Food {
    open fun sellFood() {
        // 此处省略随便点了点吃的之后一系列过程
        println("老板,随便来电吃的")  // 这里代表对某个具体类的封装,也可以将该逻辑是现在外部,不提供本默认实现,同时给orderFood随机传入一个具体类别
    }
}

class InstantNoodles: Food() {
    override fun sellFood() {
        // 此处省略点了方便面之后一系列过程
        println("老板,来份方便面")
    }
}

class HandCake: Food() {
    override fun sellFood() {
        // 此处省略点了手抓饼之后一系列过程
        println("老板,来份手抓饼")
    }
}

// 对原FastFoodShopXM进行调整,使支持新的场景
class FastFoodShopXM {
    // 点餐
    fun orderFood(food: Food) {
        food.sellFood()
    }
}

fun main() {
    val fastFoodShopXM = FastFoodShopXM()
    // 顾客点不同的食物
    fastFoodShopXM.orderFood(InstantNoodles())
    fastFoodShopXM.orderFood(HandCake())
    fastFoodShopXM.orderFood(Food())
}

// 老板,来份方便面
// 老板,来份手抓饼
// 老板,随便来电吃的

此例子中,变化是从一个抽象种类衍生包含更多细节的具体种类,对应原来的Food,到后来更具体的InstantNoodles和HandCake。

  • 封装变化:对外部透明

    我们将这些变化的细节通过继承封装到具体类中,而抽象类Food中的sellFood作为公共特征与接口,让外部可见,而变化的具体实现(点具体食物及之后的所有操作)封装到具体类中,对外部透明。

  • 封装变化:将变化缩小为它本质的样子

    这里我们可以进行如下实现:

    open class FastFoodShopXMTest {
        open fun orderFood() {
            // 这里还包括一些点餐的其他逻辑
            println("老板,随便来电吃的")
        }
    }
    
    class FastFoodShopXMTestInstantNoodles: FastFoodShopXMTest() {
        override fun orderFood() {
            // 这里还包括一些点餐的其他逻辑
            println("老板,来份方便面")
        }
    }
    
    class FastFoodShopXMTestHandCake: FastFoodShopXMTest() {
        override fun orderFood() {
            // 这里还包括一些点餐的其他逻辑
            println("老板,来份手抓饼")
        }
    }
    
    fun main() {
        FastFoodShopXMTest().orderFood()
        FastFoodShopXMTestInstantNoodles().orderFood()
        FastFoodShopXMTestHandCake().orderFood()
    }
    
    // 老板,随便来电吃的
    // 老板,来份方便面
    // 老板,来份手抓饼
    

    这里,我们将变化的类扩大到了快餐店,同样实现了需求。但先不说有多个快餐店分别对应某种食物很不符合正常逻辑(这只是一个明显的例子),在orderFood中也并不是只包括与食物相关的逻辑(如点餐收银),而这部分逻辑是相似且可复用的。可以想象,这部分逻辑通过复制粘贴的方式重复出现在各个实现类中,极大增加了代码复杂度(这里还要单独覆盖测试等,因为你不能跟测试说这几段代码是一样的,所以测试覆盖率可以直接翻倍)。甚至一些不熟悉代码的同学还会自己重复实现一遍,而让这段逻辑的正确性更难以保证。

    这里,当然也有解决方案,那就是将其他逻辑提取封装到抽象类中,并在子类中复用(但谁能保证子类一定调用已经按照正确顺序调用这些逻辑方法?)。甚至使用模版方法模式,将流程中与食物相关的逻辑封装为抽象方法,交由具体类去实现(这里,不感觉逻辑已经更复杂了吗?而且与食物相关 (某个单独流程) 的代码也无法单独复用。当流程中变化的东西开始增加,你要写多少类才能覆盖所有情况?)。解决问题的办法肯定不止一条,但如果要引入大量代码来解决问题,为什么不一开始就不让问题发生?

    对变化本身小而精巧的描述,是其复用性提升的关键,而我们可以灵活复用的单位,自然是对象或类。

    通过传递参数让复用单位从类下降到对象也是一个实用的技巧。(但本文暂不具体讨论)

  • 封装变化:将变化的影响范围缩到最小

    本例中,我们将变化本身(从抽象食物衍生出具体食物)封装为一组类,同时在Food中只对外暴露一个sellFood接口,外部只用也只可以调用它,相比于暴露出sellFood内部的多个具体步骤,虽然少了一些使用的灵活性(请问:这些灵活性是该场景下有必要的吗?),但却让外面了解了变化最少的细节,减少了变化对外的影响,也让变化的拓展更灵活丰富。

  • 支持变化

    本例中,我们提取公共特征与相同的抽象业务形式(sellFood)到抽象类Food,并将不同的实现延迟到具体类,同时每个类都自描述了与其变化的具体实现相关的逻辑,无需外界为其补充描述,拓展时对外部影响小(基本无需修改外部代码),对继续变化支持较好。

2.3.2.用面向对象拓展种类

什么是变化:拓展种类的例子中,为了拓展新的种类,提取包含公共特征和接口的抽象类,作为拓展的基准,来拓展新类。面向对象通过继承实现从抽象类拓展新类,通过继承表现出来的多态作为拓展的子类变化的部分的不同表现。

将该例子实现为代码,并针对怎么面向变化编程所讲进行简要说明(详细说明请见用面向对象衍生细节,与此类似),如下:

原本小红的快餐店只有牛肉包子,

class BeefBun {
    fun sellBun() {
        println("老板,来份牛肉包子")
    }
}

class FastFoodShopXH {
    fun orderFood(beefBun: BeefBun) {
        beefBun.sellBun()
    }
}

后来小红拓展了包子业务,提取包子的抽象类,并新增了猪肉包子,

abstract class Bun() {
    abstract fun sellBun()
}

class BeefBun: Bun() {
    override fun sellBun() {
        println("老板,来份牛肉包子")
    }
}

class PorkBun: Bun() {
    override fun sellBun() {
        println("老板,来份猪肉包子")
    }
}

class FastFoodShopXH {
    fun orderFood(bun: Bun) {
        bun.sellBun()
    }
}

fun main() {
    val fastFoodShopXH = FastFoodShopXH()
    fastFoodShopXH.orderFood(BeefBun())
    fastFoodShopXH.orderFood(PorkBun())
}

// 老板,来份牛肉包子
// 老板,来份猪肉包子

本例中,通过提取公共特征与接口到抽象类Bun,我们将拓展种类的变化单独封装到每个具体子类中,变化对外部透明(外部统一使用Bun操作具体的包子类),同时让变化(不同的包子种类)自描述,减少对不同包子种类对外部对影响,对继续变化(增加新的包子种类)成本较小。

2.3.3.用面向对象封装差异

什么是变化:封装差异的例子中,需要对外部隐藏差异,而在不同情况下有不同的表现的要求,不就是多态吗?而在面对对象中想要实现多态,就要使用继承机制,继承是面对对象实现多态的方法

将该例子实现为代码,并针对怎么面向变化编程所讲进行简要说明(详细说明请见用面向对象衍生细节,与此类似),如下:

小明和小红的快餐店在以上代码的基础上,合并为一个快餐店,并封装菜单新的层级类别以及小明和小红分别对几种食物的制作过程,

open class Food {
    open fun sellFood() {
        println("老板,随便来电吃的")
    }
}

class InstantNoodles: Food() {
    override fun sellFood() {
        println("老板,来份方便面。(小明制作)")
    }
}

class HandCake: Food() {
    override fun sellFood() {
        println("老板,来份手抓饼。(小明制作)")
    }
}

abstract class Bun: Food() {
    final override fun sellFood() {
        sellBun()
    }

    abstract fun sellBun()
}

class BeefBun: Bun() {
    override fun sellBun() {
        println("老板,来份牛肉包子。(小红制作)")
    }
}

class PorkBun: Bun() {
    override fun sellBun() {
        println("老板,来份猪肉包子。(小红制作)")
    }
}

class FastFoodShop {
    fun orderFood(food: Food) {
        food.sellFood()
    }
}

fun main() {
    val fastFoodShop = FastFoodShop()
    fastFoodShop.orderFood(BeefBun())
    fastFoodShop.orderFood(InstantNoodles())
    fastFoodShop.orderFood(PorkBun())
    fastFoodShop.orderFood(HandCake())
    fastFoodShop.orderFood(Food())
}

// 老板,来份牛肉包子。(小红制作)
// 老板,来份方便面。(小明制作)
// 老板,来份猪肉包子。(小红制作)
// 老板,来份手抓饼。(小明制作)
// 老板,随便来电吃的

本例中,我们通过继承实现了一组不断变化的食物类别族,允许我们通过抽象的类别统一对待它的子类别。它们在不同层级上提取并复用了公共的特征与接口,并表现了具体食物种类、制作过程的多态。由此,我们通过面向对象封装了差异。

通过以上三个例子,我们可以很清楚的发现,我们就在使用封装和继承,其表现是透明和多态,通过这种抽象与实现去封装变化。而变化,自然是代码中最不稳定也是最需要复用的部分。我们准确封装好变化,每个模块都是稳定的部分,那不就是高内聚吗?模块中的变化准确封装,可以独立灵活的变化,且对外暴露的变化接口最小,那其他模块自然没有耦合之说,自然实现了低耦合。

当然,面向对象不是封装变化唯一的途径,甚至不能说最好(因为在不同场景下有不同情况),但绝对是适用性最广且相性极好的方式。但是,在实际情况下,我们也要根据情况灵活选择合适的方式去面向变化编程。注意,核心是面向变化编程,面向对象只是一种手段。

3.解读面向对象的六大原则

首先,直接给出面向对象的六大原则:

  • 里氏替换原则(Liskov Substitution Principle)
  • 单一职责原则(Single Responsibility Principle)
  • 开闭原则(Open Closed Principle)
  • 迪米特法则(Law of Demeter),又叫“最少知道法则”
  • 接口隔离原则(Interface Segregation Principle)
  • 依赖倒置原则(Dependence Inversion Principle)

可以说,以上六大原则,都是面对对象为了更好的服务于“面向变化编程”的。

3.1.里氏替换原则

里氏替换原则:如果对每一个类型为S的对象o1,都有类型为T的对象o2,使得以T定义的所有程序P在所有的对象o1都代替o2时,程序P的行为没有发生变化,那么类型S是类型T的子类型。

简单来说:子类永远可以替换父类,且不会造成错误、异常

这是一个用来规范继承的原则,该原则包含四层含义:1. 子类必须完全实现父类的方法;2. 子类可以有自己的实现;3. 覆盖或实现父类的方法时,输入参数可以放大;4. 覆盖或实现父类的方法时,返回值可以缩小。

通过该原则,继承标准的实现了一个“is-a”语义,让父类是子类的公共特征与接口的集合,可以从抽象层面代指子类。也是通过这样一个标准的抽象与实现的语义,才可以让我们将变化的抽象公共特征提取,并交由子类去实现变化的细节。

3.2.单一职责原则

单一职责原则:一个类应该有且仅有一个原因引起类的变更

单一职责原则要求一个接口或一个类只能有一个原因引起变化,也就是一个接口或者类只能有一个职责,它就负责一件事情。

从定义上,我们就可以看出,这是在控制变化。在面向变化编程中我们讲到,我们要封装变化,让变化本身最小,同时让变化对外暴露的接口最小。可以说,每一个变化,就对应一个类,二者是一对一的关系,变化本身是独立的。而一个抽象类则对应了一组相关的变化

让变化本身最小,就是在告诉我们每个对变化的封装只包含一个变化,且不包含与变化无关的逻辑部分。

让变化对外暴露的接口最小,则是在说抽象变化的宏观表现,将每个完整而独立的功能当成一个整体。简单来说,就是多个相同层面的功能不能放到一起,尽可能暴露更抽象层面的表现接口。

3.3.开闭原则

开闭原则:类应该对拓展开放,对修改关闭

对拓展开放,对修改关闭,不正是面向变化的编程方法——支持变化的表述?

我们通过提取抽象的公共特征与接口作为对外表现接口来屏蔽外部对内部变化的感知,通过继承实现包含不同细节的具体类进行拓展,通过尽可能的自描述与其他优化方法降低外部对具体类的引用。

3.4.最少知道原则

最少知道法则:一个对象应该对其他对象有最少的了解

最少知道原则让我们尽可能少的允许直接引用某个对象,同时尽可能少的给外部(包括可以直接引用的“朋友”)暴露自己的细节。

在面向变化编程中,要求封装变化,每个变化都对应一个类,每个变化都只在有此变化的类中被引用。同时,针对变化良好的封装要求我们控制变化对外部的影响,只提供更抽象的表现接口。由此,我们要暴露变化本身最宏观的表现,让外部了解变化最少的细节。

3.5.依赖倒置原则

依赖倒置原则:要依赖抽象,不要依赖具体的类

类之间的依赖关系也是变化之间的依赖关系。该原则要求我们面向接口编程,而不是面向实现。换句话说,是要求我们依赖变化的抽象类(包含了变化的公共特征与抽象表现接口),而不是包含变化细节的具体类。

本原则,正是我们对变化的处理方法。面向变化编程要求我们在外部关心的是变化的抽象表现,而变化的细节全部封装到内部。这不但是封装变化的要求,同时也是支持变化的基础。

3.6.接口隔离原则

接口隔离原则:不应该依赖它不需要的接口,类间的依赖关系应该建立在最小的接口上

该原则要求我们明确真正独立改变的变化到底是什么。这就对应我们封装变化的关键之一:将变化缩小为它本质的样子。

通过切实了解到底是什么引发了变化,进而确定每组变化的公共特征与抽象外在表现,才能保证变化的独立性和灵活性,才能保证项目的可拓展和代码的可复用性。

4.解读设计模式

在这里,先澄清,将设计模式放到这里,并不是说设计模式属于面对对象的一部分。二者是并行的,但设计模式在标准实现中用了面向对象,同时符合良好的面向对象设计的思想(也就是符合面向变化编程的思想。但注意,面向变化编程也是面向对象并行的,可见面向变化编程末尾总结部分),所以经常将设计模式当成面对对象的进阶知识。

4.1.解读“多用组合,少用继承”

在具体解读设计模式之前,先问大家一个问题:你们知道为啥要“多用组合,少用继承”吗?

这个问题,直接关系到大家最这句话本身内涵的理解。

从事物的关系层面理解,组合是“has-a”关系,而继承是“is-a”关系。在我看来,事物与事物有以下五种关系:无关系、“use-a”、“has-a”、“implement-a”、“is-a”,严格意义上,“implement-a”是“is-a”在纯接口情况下的一种特殊情形。而事物与事物的关系,就直接回答了我要用哪种选择。

如:策略模式,将一组算法封装(算法就是其变化的细节),而算法/策略相对于当前使用它的主体,就是一种“has-a”的关系,使用的是组合。而装饰者模式,不管是装饰器还是被装饰的基本实现类,都是抽象类,虽然装饰类可以不断组合实现这个抽象类的具体类,但不管套了多少层,相对于抽象类,都是“is-a”关系,使用的是继承;而装饰类相对于被装饰对象,则是一种“has-a”关系,使用的是组合。

从封装变化的方法角度讲,二者则没有区别,只是看问题的粒度不同。支持用组合,是因为可以它可以细粒度的动态改变,表现出多态。但这种变化,不也是继承变化的抽象类实现的?话又说回来了,面向对象是通过继承实现的多态。而用继承,在使用它的层面,不也是组合吗?所以,区分二者,其实是在强调封装变化的粒度问题,也就是封装变化讲到的:将变化缩小到它本质的样子,而具体的例子,也在用面向对象衍生细节封装变化:将变化缩小为它本质的样子中讲到。

4.2.解读“工厂”

工厂有三种:简单工厂(不算一个设计模式,而是一个设计习惯)、工厂方法和抽象工厂。

简单工厂,可以根据一些条件去有选择的生产(实例化病返回)一个产品(具体类)。这就是在封装变化,更具体来说,就是封装对变化的具体引用,并将工厂作为了解具体变化的唯一位置。由此,可以更好的支持变化。

工厂方法,可以在不同的实现类中决定实际生产(实例化并返回)的产品(具体类),将具体类的引用与选择延迟到子类中。这也是一种变相的对变化的具体引用的封装,向外部透明具体的变化。

抽象工厂,可以定义一组生产相关产品的方法,基于此产生不同“风格”的工厂,每个工厂可以生产该“风格”的一组相关产品。这可以说是对工厂方法的拓展,工厂方法只能生产一种抽象种类的产品,抽象工厂可以生产一组抽象种类的产品。其目的,也是对变化(成组统一的变化)的封装。

4.3.解读“观察者模式”

观察者模式是最常用的设计模式之一,通过订阅和发布的形式实现一个消息的通知与同步,是一种一对多的依赖关系。

那观察者模式是在处理哪类问题?或者说,是在处理什么变化?

顾名思义,这是在处理观察者的变化。对某个发布的主题订阅的观察者是不断在变化的,数量不定,具体类型不定。但只要实现了抽象观察者类,向被观察者注册,就可以收到对应的消息。抽象的观察者类,就是观察者的变化的公共特征和抽象对外接口的集合,并基于此产生了与“观察”相关的一组具体变化。

4.4.解读设计模式

设计模式还有很多,以上只是举了几个例子。设计模式的准则就是:1)中意于组合而不是继承,2)依赖于接口而不是实现,3)高内聚,低耦合。而这些也正是面向变化编程的描述。每一个设计模式都有对应的使用场景,每一种场景描述的正是一种常见变化(单例模式亦可看作一种特殊的变化:限制变化,只可产生一个实例)。而设计模式应对这些常见变化的方法,亦是处理这些变化的经典方法,是好的面向变化编程的范例。

5.从需求中来,到需求中去

从一开始讲面对对象的基本概念与思想,到后来提出面向“变化”编程、详细讲解如何面向“变化”编程、如何通过面对对象实现面向“变化”编程,再到后来通过面向“变化”的视角解读面向对象的六大原则与设计模式,来说明与论证面向“变化”的地位,我们已经从浅入深的讲解与论证了基于面向“变化”的良好面向对象设计方法的定义、方法与作用,但有一点没有说明——那就是怎么发现“变化”。

如何发现“变化”可谓是面向变化编程的重中之重。如何找到“变化”,如何找准“变化”的本质,如何确定变化最抽象的外在表现,直接关系到我们能否成功封装“变化”,能否良好的封装“变化”,能否让项目可以继续灵活变化。

5.1.从哪里发现“变化”?

要问一切的变化从哪里来,那自然是从需求中来

要问一切的变化到哪里去,那一定是到需求中去

可以说,需求,即是变化、是项目本身的目的与意义!

那从哪里发现“变化”?那当然是从需求中发现变化。

面向对象编程就是如此,它并非出自本能或技术本身,而是需要我们先理解需求,再确定有哪些类与对象、对象与对象之间有哪些交互,最后进行实现。

而面向变化编程则进一步要求我们,不光要分析需求中的基本元素及交互,同时要找到需求中的变化。

代码是对应需求的,代码中对变化的封装即是对需求中的变化进行封装,代码中支持新的变化即是支持新的需求中的变化。我们找到变化、封装变化、支持变化,都是为了更好的应对需求。

5.2.如何发现“变化”?

那么,如何在需求中发现“变化”呢?

首先,是去熟悉、理解需求,知道需求有什么、什么是重点、怎么变化的(需求的发展与变更历史)。

接下来,关注需求中频繁修改、新增、删除的部分,确定它们的范围、修改的原因和目的。

最后,依据变化的基本类别,对需求中的变化进行定性分析,并进一步迭代其范围,最终确定需求中的变化。

5.3.哪里才是真正的变化?

读完了如何发现“变化”,我想绝大多数同学都还是一头雾水。

没错,如果光了解方法,我也不清楚哪里才是真正的变化。发现“变化”没有什么捷径或窍门(至少我没找到),只有继续去熟悉业务、熟悉需求。想来也是这个道理,一个业务不熟的程序猿连完成一个普通业务需求都很陌生缓慢,更不要说去设计项目了。一个好的架构师想必不但有着丰富的架构知识与经验,也一定对项目有着无与伦比的熟悉度与见解。

但谁都是慢慢才了解业务的,需要一个熟悉的过程,并在这个过程中不断去理解业务,寻找真正的变化。在这里,温馨提示,不要着急,不要看到需求中比较直观的改变就认为它是真正的变化。请冷静分析、沉着思考是不是背后还有更本质的变化形式。当然,如果有把握,就放心大胆的封装你找到的变化吧。因为除了神,没有人能找到变化的最本质形式。或者最本质,那可能就是“一”,已经没有实际的参考价值了,而对变化相对最合适的解读,需要你在抽象与实际间去平衡。反复迭代对变化的封装,也是封装变化重要的方法与必经的步骤。而许多小的重构与优化,也往往可以促使发现更大的重构机会。

6.面向对象的泥沼

6.1.面对对象的过度崇拜

面向对象已经火了很多个年头,并作为编程教学内容、面试与工作的必考知识而广为流传。但作为一名程序猿,总发现身边或网上存在对面对对象的过度崇拜、对设计模式的过度崇拜,一言不合,就各种方法、模式往上堆,甚至过度设计而不知。

面对对象固然是一个解决现实中问题的一个普适性良好的编程范式与思想,但绝非任何情形都是好的选择,而连编程范式或思想都不算、只适用于一些特定场合的设计模式就更是这样了。

面向“变化”编程章节所讲的快餐店的例子,变化的部分是食物的种类与制作,也可以说是菜单。那么,不说更复杂的情况,完全可以通过一个配置文件去实现菜单的描述,通过一个菜单类去封装对配置文件的读取与解析,拓展性与灵活性远高于面向对象去封装的同时,代码也更加简洁清晰。(当然,这里我不是在自黑上面所讲的面向对象的例子,就像例子中的注释所写,其中省略了很多涉及多个对象的复杂操作步骤。随着复杂度的增加,就去权衡这种面向数据的编程方式和面向对象的利弊了)

当然,有人“夸”面对对象,自然有人黑面向对象,把它说的好像一无是处。我的观点,是不信谣,不传谣。同时,我对面向对象的思想十分认可,认为这是对实际生活非常好的描述方式。

6.2.面向对象只是好的解决方案的一部分

在快餐店的“面向数据”实现方式中可以看到,此处主要用了一个文件表格去封装食物变化的差异。但眼尖的同学可能已经看到,这里是用类去封装配置文件的读取与解析的。

在前面讲过,一个类,就对应一个变化。那这里封装的是什么变化呢?是对不同配置文件的解析方式。由此看出,面对对象可以广泛的出现在编程的各种场合,而在很多时候,面对对象是好的解决方案的一部分,但也只是一部分。

6.3.学会设计本身

想要设计出好的解决方案,就要学会设计本身,而不能局限于任何一个编程范式中,什么面向过程、面向对象、函数式编程、响应式编程等等,都平等对待,按需使用。而好的设计最核心、也是最主要的,就是面向“变化”编程。而想要找到“变化”,就要回到具体的需求中去寻找答案。

面向对象本就是抽象的思想,而设计的路更是玄而又玄,学习的路还有很长,需要足够的知识与经验积累,也需要有好的项目机遇。希望每一个小伙伴都能在编写更好的代码的道路上不断砥砺前行!

posted @ 2022-03-11 12:00  _哲思  阅读(583)  评论(0编辑  收藏  举报