Object Responsibility

11/7/2024 12:30:00 PM

⚠️此处假设你已经熟悉对象的定义

问题

我们的主角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做出来的🍮就会有问题。很明显,这样的设计会 泄露 核心的 业务逻辑,造成隐患。

⚠️时刻记住一点:
你不能假定代码的使用者(甚至是你自己)会按照你的期望去调用你的代码。

这个设计体现的是一种 过程式 的思维,我们关注的是 数据 而非对象的 行为

面向对象的设计

事实上,我们并不是在批驳 过程式 不好,每个问题都有适用的解决方案,只不过,基于对象 能够帮助你更好地 划分问题域, 分清对象的 职责

那么,对于上述问题,基于对象 的设计应该是怎样的呢?

分析

首先,我们还是从 捕获行为 开始。

到目前为止,看上去和 过程式 没有什么区别。

下面,来到我们最核心的问题:

既然我并不需要在设计中体现 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再开始制作🍮。

分析

注意到 可以发现,这个问题中,我们多了一个 验证 的步骤。

我知道有的同学要说:“这一步我会,搞一个验证的接口呗。😋”

但是,你先别急 我们可以先考虑一下,这个验证的行为,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 的实现。

至于那个验证的实现有没有真的在验证那就是它的事情了