⚠️此处假设你已经熟悉对象的定义
问题
我们的主角Aru送了一路🎁总算到达了QP家里。
Aru给QP的🎁是制作🍮的原材料。
QP拿了Aru提供的原材料,开始制作🍮。
面向过程的设计
对上述内容进行分析,我们很容易发现,这个过程中有一个主要的数据内容:🍮。
因为我们的问题似乎始终是围绕🍮展开的。
其中,Aru负责提供原材料,而QP负责把布丁制作出来。
那么,我们可以很轻易地得出这样一段代码:
Aru aru = new();
QP qp = new();
var material = aru.GetMaterial();
var pudding = qp.MakePudding(material);
class Aru
{
public Material GetMaterial()
{
Material material = new();
// 省略一些乱七八糟的复杂的提供原材料的逻辑
return new();
}
}
class QP
{
public Pudding MakePudding(Material material)
{
Pudding pudding = new(material);
// 省略一些乱七八糟的复杂的做🍮的逻辑
return pudding;
}
}
这段代码看起来很完美。你说得对,这段代码确实很完美。
虽说,我们这里存在两个类和两个对象,但是我们的代码实际上并没有体现面向对象的特性。在这里, Material 并不是我们需要关注的对象,它仅仅是一种数据。Pudding 也是同理。
得到Material → 把Material塞给QP → 得到Pudding
我们如此将Material暴露在外,万一有人不小心把Material给 吃 修改了,那么QP做出来的🍮就会有问题。很明显,这样的设计会 泄露 核心的 业务逻辑,造成隐患。
⚠️时刻记住一点:
你不能假定代码的使用者(甚至是你自己)会按照你的期望去调用你的代码。
这个设计体现的是一种 过程式 的思维,我们关注的是 数据 而非对象的 行为 。
面向对象的设计
事实上,我们并不是在批驳 过程式 不好,每个问题都有适用的解决方案,只不过,基于对象 能够帮助你更好地 划分问题域, 分清对象的 职责。
那么,对于上述问题,基于对象 的设计应该是怎样的呢?
分析
首先,我们还是从 捕获行为 开始。
- 提供原材料 →
GetMaterial
- 制作布丁 →
MakePudding
到目前为止,看上去和 过程式 没有什么区别。
下面,来到我们最核心的问题:
既然我并不需要在设计中体现 Material ,怎样避免将 Material 这个状态暴露在外呢?
这里有个很简单暴力的做法:
我们直接把Aru
作为参数塞给QP
。
class QP
{
public Pudding MakePudding(Aru aru)
{
var material = aru.GetMaterial();
Pudding pudding = new(material);
// 省略一些乱七八糟的复杂的做🍮的逻辑
return pudding;
}
}
这样,我们就避免了将 Material 暴露在外,整个过程一气呵成。
我知道有同学要叫了:“QP做🍮的时候,难道一定要依赖Aru吗?😠”
这话不假,QP这里需要的是一个能给它提供原材料的对象,而非一定是Aru。
比如QP家里的厨房可能还存着一些原材料。甚至是提供🍮原材料的武装直升机。
好叭,现在我们的设计需求非常清晰了。
QP需要任何一个能给她提供原材料的对象,不管这个对象是什么玩意儿,只要具有 GetMaterial
的行为就行。
接口
在C#中,我们可以使用 接口(Interface) 来实现这个需求。
interface IMaterialProvider
{
Material GetMaterial();
}
⚠️ 对于C#中的任何
interface
, 命名一般以I
开头。
⚠️ 对于interface
中的内容,它们的访问修饰符默认为public
。
⚠️ 如果你需要在interface
中表示一个状态,请使用 属性 而非 字段。
在这里,IMaterialProvider
就是一个 接口。
我知道它看上去有点奇怪,里边的那个 GetMaterial
看上去像个方法,但它并没有方法体,或者说,并没有 实现 。
好叭,这暂时不重要。我们先接着往下看。
对应的,QP可以把它 MakePudding
的方法参数换成 IMaterialProvider
。
最后就会变成这个亚子:
class QP
{
public Pudding MakePudding(IMaterialProvider provider)
{
var material = provider.GetMaterial();
Pudding pudding = new(material);
// 省略一些乱七八糟的复杂的做🍮的逻辑
return pudding;
}
}
对于Aru,我们只需要让它实现 IMaterialProvider
这个接口就行。
class Aru : IMaterialProvider
{
public Material GetMaterial()
{
return new Material();
}
}
值得一提的是,如果你现在把Aru的 GetMaterial
方法给删掉,你会发现Aru很生气😡,你的程序告诉你:你必须给Aru实现 GetMaterial
这个方法,不然不让你通过编译。
好叭,有的同学会认为:“我跟着做了,也确实让代码跑起来了,但是我感觉这么设计凭空让整个程序更复杂了,是不是哪里出了什么问题?🤔”
举个栗子
假如,QP家里的厨房也有一些原材料,很明显,QP也同样可以从她的厨房那边拿到原材料。
亦或者说,她的厨房也 具有提供原材料的能力。
class Kitchen : IMaterialProvider
{
public Material GetMaterial()
{
Material material = new();
// 省略一些乱七八糟的复杂的提供原材料的逻辑
return material;
}
}
现在,你就可以做这样的事情:
Kitchen kitchen = new();
Aru aru = new();
QP qp = new();
var pudding1 = qp.MakePudding(kitchen);
var pudding2 = qp.MakePudding(aru);
你会发现,在这里,QP 事实上并不需要关心原材料是从哪里来的,只要你能提供原材料,她就能帮你做出🍮。除非你的材料有问题
真正的意图
事实上,我们回到 问题域->设计 这一步,我们会发现,这样设计不仅仅是为了 高可扩展性 。
职责分离
对于 QP 来说,她的职责在于 制作布丁,于是她需要 依赖 一个具有 GetMaterial
能力的对象,至于那个对象是谁、怎么Get
的,她并不关心,因为那不是她的 职责 。
对于 Aru 来说,她的 职责 在于通过实现 GetMaterial
来提供原材料,至于那些个原材料到底用来做🍮、🍰、🍚 亦或别的什么,她并不关心,因为那不是她的 职责 。
通过使用 接口 ,我们可以把每个对象的 职责、角色 或 关注点 分离开。
并行开发
对于如果使用 过程式 的设计,我们需要先...,再...,然后...,最后...。这整个过程是线性的,我需要先把前面逻辑的 实现 代码写完,才能继续往下写,这样的效率明显不高。
而使用 接口 ,我们可以很清晰地根据对象的不同职责来 并行 地编写 实现 的代码,互相之间并不会有太大的 耦合 。
同理,设计的时候,我们也往往先设计 接口 ,然后再去做 接口 的 实现 ,这样的设计方式也能够帮助我们更好地 分工 。
应对变更
写过一些代码的同学应该都知道,在软件开发中,变更是一个非常常见的事情。
如果Aru
哪天送不了原材料了,而是让Nico
去送,那么我们只需要让Nico
实现 IMaterialProvider
这个接口就行了。
对于QP
来说,她并不需要关心这个变更,反正她也不知道是谁给她提供的原材料。
事实上,对于任何一个 接口 的 实现 与 接口 的调用者而言,都是如此。
还有更多...
当然啦,接口 还有太多太多的作用,这里就不一一列举了,我们之后跟大家介绍 分层架构 与 依赖注入 的时候,会再次提到 接口 的作用。
试试看?
问题扩展
如果现在,我们的问题变成了这样:
我们的主角Aru送了一路🎁总算到达了QP家里。
Aru给QP的🎁是制作🍮的原材料。
在制作🍮之前,QP要先验证一下原材料是不是正常的。
验证完之后,QP再开始制作🍮。
分析
注意到 可以发现,这个问题中,我们多了一个 验证 的步骤。
- 提供原材料 →
GetMaterial
- 验证原材料 →
ValidateMaterial
- 制作🍮 →
MakePudding
我知道有的同学要说:“这一步我会,搞一个验证的接口呗。😋”
但是,你先别急 我们可以先考虑一下,这个验证的行为,QP自个是不是也能做呢?
进一步而言,ValidateMaterial
这个行为是否可以归进 MakePudding
里边呢?
这其实是一个很有意思的问题,也是需要一定经验才能给出答案的问题:
在什么情况下,我才需要专门用接口分离出一块逻辑呢?
对于一些看似很简单的问题而言,看上去好像确实可以少写一两个接口。
但是,如果你的问题域足够复杂,请务必 不要 为了少写几个接口而把所有的行为都塞进一个方法或接口里边。
⚠️ 个人经验而言:
对于任何一个稍微上点规模的项目而言,在大多数情况下,你几乎预测不到你现在写的代码会在未来的哪个时候变得异常复杂。
所以,正儿八经地设计你的 接口 是非常有必要的。 $$
实现
interface IMaterialProvider
{
Material GetMaterial();
}
interface IMaterialValidator
{
bool Validate(Material material);
}
class QP
{
public Pudding MakePudding(IMaterialProvider provider, IMaterialValidator validator)
{
var material = provider.GetMaterial();
if (!validator.Validate(material))
{
throw new Exception("Material is invalid.");
}
Pudding pudding = new(material);
// 省略一些乱七八糟的复杂的做🍮的逻辑
return pudding;
}
}
你看,我甚至不需要写 IMaterialValidator
的实现类,这段代码就能通过编译。
因为我能肯定,当QP需要 MakePudding
的时候,一定会给我一个 IMaterialValidator
的实现。
至于那个验证的实现有没有真的在验证那就是它的事情了