验证其参数的构造函数是否违反SRP?


66

我试图尽可能地遵守单一职责原则(SRP),并且习惯了某种严重依赖委托的特定模式(对于方法上的SRP)。我想知道这种方法是否合理,或者是否存在严重问题。

例如,要检查对构造函数的输入,我可以引入以下方法(Stream输入是随机的,可以是任何东西)

private void CheckInput(Stream stream)
{
    if(stream == null)
    {
        throw new ArgumentNullException();
    }

    if(!stream.CanWrite)
    {
        throw new ArgumentException();
    }
}

这种方法(可以说)做的不止一件事

  • 检查输入
  • 引发不同的异常

因此,为了遵守SRP,我将逻辑更改为

private void CheckInput(Stream stream, 
                        params (Predicate<Stream> predicate, Action action)[] inputCheckers)
{
    foreach(var inputChecker in inputCheckers)
    {
        if(inputChecker.predicate(stream))
        {
            inputChecker.action();
        }
    }
}

据说哪件事只会做一件事(是吗?):检查输入。对于输入的实际检查和异常的抛出,我介绍了类似的方法

bool StreamIsNull(Stream s)
{
    return s == null;
}

bool StreamIsReadonly(Stream s)
{
    return !s.CanWrite;
}

void Throw<TException>() where TException : Exception, new()
{
    throw new TException();
}

并可以CheckInput

CheckInput(stream,
    (this.StreamIsNull, this.Throw<ArgumentNullException>),
    (this.StreamIsReadonly, this.Throw<ArgumentException>))

这是否比第一种选择更好,还是我引入了不必要的复杂性?如果可行的话,还有什么办法可以改善这种模式?


26
我可以说这CheckInput仍然在做很多事情:它既遍历数组,调用谓词函数,调用了动作函数。那是否不违反SRP?
巴特·范·英根·谢瑙

8
是的,这就是我要提出的重点。
巴特·范·英根·谢瑙

135
重要的是要记住,这是单一责任原则;不是单一行动原则。它有一个责任:验证流是否已定义且可写。
David Arno

40
请记住,这些软件原理的全部重点是使代码更具可读性和可维护性。原始的CheckInput比重构的版本更容易阅读和维护。实际上,如果我在代码库中遇到了最终的CheckInput方法,我会全部删除并重写它以匹配您最初的内容。
17年

17
这些“原则”实际上是无用的,因为您可以以无论您想采用的最初想法如何的方式定义“单一责任”。但是,如果您尝试严格地应用它们,我想您最终会得到这种代码,坦率地说,很难理解。
Casey

Answers:


151

SRP可能是最被误解的软件原理。

从模块构建软件应用程序,从模块构建模块,从模块构建模块...

在底部,诸如这样的单个功能CheckInput仅包含一小部分逻辑,但是随着您的前进,每个后续模块都封装了越来越多的逻辑,这是正常现象

SRP与执行单个原子操作无关。这是一个单一的责任,即使该责任需要采取多种行动……最终也是关于维护可测试性

  • 它促进封装(避免使用神物),
  • 它促进了关注点的分离(避免在整个代码库中进行涟漪变化),
  • 它通过缩小职责范围来帮助可测试性。

CheckInput通过两次检查实现并引发两个不同异常的事实在某种程度上是无关紧要的

CheckInput职责范围很窄:确保输入符合要求。是的,有多个要求,但这并不意味着有多个职责。是的,您可以拆分支票,但这有什么帮助?在某些时候,必须以某种方式列出检查。

让我们比较一下:

Constructor(Stream stream) {
    CheckInput(stream);
    // ...
}

与:

Constructor(Stream stream) {
    CheckInput(stream,
        (this.StreamIsNull, this.Throw<ArgumentNullException>),
        (this.StreamIsReadonly, this.Throw<ArgumentException>));
    // ...
}

现在,CheckInput做的更少...但是其调用者做的更多!

您已将需求列表从CheckInput进行封装的地方转移到了Constructor可见的地方。

这是一个好变化吗?这取决于:

  • 如果CheckInput仅在此处调用if :它是有争议的,一方面使需求可见,另一方面使代码混乱。
  • 如果CheckInput多次以相同的要求被调用,则它违反了DRY,并且存在封装问题。

重要的是要意识到,一个单一的责任可能意味着很多工作。自动驾驶汽车的“大脑”负有单一责任:

开车到目的地。

这是一项责任,但需要协调大量传感器和参与者,做出大量决策,甚至可能有相互矛盾的要求1 ...

...但是,它们都被封装了。所以客户不在乎。

1 乘客安全,他人安全,遵守规定,...


2
我认为您使用“封装”一词及其派生词的方式令人困惑。除此之外,很好的答案!
Fabio Turati

4
我同意您的回答,但是自动驾驶汽车的争论常常诱使人们打破SRP。如您所说,它是由模块组成的模块。您可以确定整个系统的用途,但是应该将该系统分解为自己。您几乎可以解决所有问题。
Sava B.

13
@SavaB .:可以,但是原理保持不变。尽管模块的范围比其组成部分的范围大,但模块应承担单一责任。
Matthieu M.

3
@ user949300好吧,那只是“开车”。确实,“驾驶”是责任,“安全”和“合法”是关于其如何履行责任的要求。我们经常在陈述责任时列出要求。
Brian McCutchon '17

1
“ SRP可能是最被误解的软件原理。” 正如这个答案所证明的:)
迈克尔·迈克尔(Michael

41

引用Bob叔叔的建议零售价(https://8thlight.com/blog/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html):

单一责任原则(SRP)规定,每个软件模块都应有一个且只有一个更改理由。

...这个原则是关于人的。

...编写软件模块时,您要确保在请求更改时,这些更改只能来自一个人,或者是代表一个狭义定义的业务职能的紧密联系的一群人。

...这就是我们不将SQL放在JSP中的原因。这就是为什么我们不在计算结果的模块中生成HTML的原因。这就是业务规则不应该知道数据库模式的原因。这就是我们分开关注的原因。

他解释说,软件模块必须解决特定利益相关者的担忧。因此,回答您的问题:

这是否比第一种选择更好,还是我引入了不必要的复杂性?如果可行的话,还有什么办法可以改善这种模式?

IMO,您应该只查看一种方法,而应该查看更高的级别(在这种情况下为类级别)。也许我们应该看看您的班级当前正在做什么(这需要对您的方案进行更多的解释)。目前,您的班级仍在做同样的事情。例如,如果明天有一些关于某些验证的更改请求(例如:“现在流可以为空”),那么您仍然需要转到此类并更改其中的内容。


4
最佳答案。为了详细说明OP,如果警卫检查来自两个不同的利益相关者/部门,checkInputs()则应分开,说成checkMarketingInputs()checkRegulatoryInputs()。否则,可以将它们全部合并为一种方法。
user949300

36

不可以,SRP不会通知此更改。

问问自己为什么检查器中没有检查“传入的对象是流”。答案是显而易见的:该语言阻止调用者编译在非流中传递的程序

C#的类型系统不足以满足您的需求;您的检查正在实施无法在类型系统中表达的不变量。如果有办法说该方法采用了不可为空的可写流,那么您可能已经写了,但是没有,所以您做的第二件事就是:在运行时强制执行类型限制。希望您也对此进行了文档记录,以便使用您的方法的开发人员不必违反它,使其测试用例通过并解决问题。

在方法上放置类型不违反单一职责原则;该方法也不强制其前提条件或主张其后置条件。


1
同样,使创建的对象处于有效状态是构造函数从根本上一直负的责任。如果像您提到的那样,它需要运行时和/或编译器无法提供的其他检查,则确实没有解决方法。
SBI

23

并非所有责任都是平等的。

在此处输入图片说明

在此处输入图片说明

这是两个抽屉。他们都有责任。它们每个都有使您知道它们属于什么的名称。一个是银器抽屉。另一个是垃圾抽屉。

那有什么区别呢?银器抽屉清楚地表明其中不包含什么。但是,垃圾抽屉会接受任何适合的物品。把勺子从银器抽屉里拿出来似乎很不对劲。但是,我很难想到如果将其从垃圾抽屉中取出,将会遗漏的任何东西。事实是,您可以宣称任何事情都具有单一责任,但是您认为哪个具有更集中的单一责任?

具有单一责任的对象并不意味着这里只能发生一件事。责任可以嵌套。但是这些嵌套的责任应该是有道理的,当您在这里找到它们时,它们不应让您感到惊讶,如果它们消失了,您应该会错过它们。

所以当你提供

CheckInput(Stream stream);

我发现自己并不担心它同时检查输入和引发异常。我会担心它是否同时检查输入和保存输入。那真是令人讨厌的惊喜。如果它消失了,我将不会错过。


21

当您陷入困境并编写奇怪的代码以符合重要软件原理时,通常您会误解了该原理(尽管有时该原理是错误的)。正如Matthieu的出色回答所指出的那样,SRP的全部含义取决于“责任”的定义。

经验丰富的程序员会看到这些原理,并将它们与我们弄错的代码记忆联系起来;经验不足的程序员会看到它们,可能根本没有与它们相关的信息。这是漂浮在太空中的抽象,全是咧嘴笑,没有猫。他们猜到了,通常情况很糟。在开发编程技巧之前,怪异的,过于复杂的代码与普通代码之间的区别根本不明显。

不论个人后果如何,这都不是必须遵守的宗教诫命。这更多是一条经验法则,旨在形式化编程马感的一个元素,并帮助您使代码尽可能简单明了。如果产生相反的效果,那么您应该寻找一些外部输入。

在编程中,你不能去不是试图通过它只是盯着推断从第一原理的标识符的意义很大wronger,并以书面形式去标识符有关编程一样多,在实际的代码标识。


14

CheckInput角色

首先,让我把明显的摆在那里,CheckInput 做一两件事,哪怕是检查各个方面。最终,它检查输入。有人可能会争辩说,如果您正在处理称为的方法,那不是一回事DoSomething,但是我认为可以放心地检查输入内容不是很模糊。

如果您不希望将用于检查输入的逻辑放入类中,则为谓词添加此模式可能会很有用,但是这种模式对于您要实现的目标来说似乎很冗长。如果这是您希望获得的结果,那么直接通过IStreamValidator单一方法传递接口可能会更直接isValid(Stream)。任何实现类的人IStreamValidator都可以使用谓词,StreamIsNull或者StreamIsReadonly如果愿意,可以使用谓词,但是回到中心点,为了维护单一责任原则而做出的更改是相当荒谬的。

完整性检查

我的想法是,我们所有人都可以通过“健全性检查”来确保您至少要处理不可为空且可写的Stream,并且这种基本检查不会以某种方式使您的类成为流的验证器。请注意,最好将更复杂的检查留在班上,但这是画线的地方。一旦需要通过从流中读取状态或将资源专用于验证来开始更改流的状态,就已经开始对流进行正式验证就是应该放入其自己的类中的条件。

结论

我的想法是,如果您要应用某种模式来更好地组织班级的某个方面,那么应该将其纳入自己的班级。由于模式不合适,因此您还应该首先质疑它是否确实属于其自己的类。我的想法是,除非您相信流的验证将来可能会改变,特别是如果您认为此验证本质上甚至可能是动态的,那么您描述的模式是一个好主意,即使它可能最初是微不足道的。否则,无需任意增加程序的复杂度。让我们称锹为锹。验证是一回事,但是检查空输入不是验证,因此我认为在不违反单一责任原则的情况下将其保留在您的类中是安全的。


4

该原则着重指出,一段代码不应“仅做一件事情”。

SRP中的“责任”应该在需求级别上理解。代码的责任是满足业务需求。如果一个对象满足多个独立业务要求,则违反SRP 。独立意味着一个需求可以更改,而另一个需求保持不变。

可以想象引入了一个新的业务需求,这意味着该特定对象不应该检查可读性,而另一个业务需求仍然需要该对象检查可读性?否,因为业务需求未在该级别指定实现细节。

违反SRP的实际示例是这样的代码:

var message = "Your package will arrive before " + DateTime.Now.AddDays(14);

该代码非常简单,但是仍然可以想象文本将与预期的交付日期无关地更改,因为这些文本是由业务的不同部门决定的。


几乎满足所有要求的不同类别听起来像是一场噩梦。
whatsisname

@whatsisname:那么也许SRP不适合您。没有设计原则适用于所有类型和大小的项目。(但请注意,我们只在谈论独立的需求(即可以独立更改),而不仅仅是任何需求,因为那将取决于所指定的细粒度。)
JacquesB

我认为,SRP不仅需要情境判断的元素,而且很难用一个醒目的短语来描述。
whatsisname 2017年

@whatsisname:我完全同意。
JacquesB

如果对象满足多个独立业务需求,则违反SRP
Juzer Ali,

3

我喜欢@EricLippert的答案

问问自己为什么检查器中没有检查流中传入的对象。答案是显而易见的:该语言阻止调用者编译在非流中传递的程序

C#的类型系统不足以满足您的需求;您的检查正在实施无法在类型系统中表达的不变量。如果有办法说该方法采用了不可为空的可写流,那么您可能已经写了,但是没有,所以您做的第二件事就是:在运行时强制执行类型限制。希望您也对此进行了文档记录,以便使用您的方法的开发人员不必违反它,使其测试用例通过并解决问题。

EricLippert认为这是类型系统的问题。并且由于您要使用单一职责原则(SRP),因此您基本上需要类型系统来负责此工作。

实际上可以在C#中执行此操作。我们可以null在编译时捕获文字的,然后null在运行时捕获非文字的。这不像完整的编译时检查那样好,但是与从未在编译时赶上来相比,这是一个严格的改进。

因此,您知道C#的情况Nullable<T>吗?让我们反转一下,然后做一个NonNullable<T>

public struct NonNullable<T> where T : class
{
    public T Value { get; private set; }
    public NonNullable(T value)
    {
        if (value == null) { throw new NullArgumentException(); }
        this.Value = value;
    }
    //  Ease-of-use:
    public static implicit operator T(NonNullable<T> value) { return value.Value; }
    public static implicit operator NonNullable<T>(T value) { return new NonNullable<T>(value); }

    //  Hack-ish overloads that prevent null-literals from being implicitly converted into NonNullable<T>'s.
    public static implicit operator NonNullable<T>(Tuple<T> value) { return new NonNullable<T>(value.Item1); }
    public static implicit operator NonNullable<T>(Tuple<T, T> value) { return new NonNullable<T>(value.Item1); }
}

现在,不用写

public void Foo(Stream stream)
{
  if (stream == null) { throw new NullArgumentException(); }

  // ...method code...
}

, 写吧:

public void Foo(NonNullable<Stream> stream)
{
  // ...method code...
}

然后,有三个用例:

  1. 用户Foo()使用非null进行呼叫Stream

    Stream stream = new Stream();
    Foo(stream);
    

    这是所需的用例,它可以使用-或-不使用NonNullable<>

  2. 用户Foo()使用null 拨打电话Stream

    Stream stream = null;
    Foo(stream);
    

    这是一个调用错误。这里NonNullable<>可以帮助通知用户他们不应该这样做,但实际上并不能阻止他们。无论哪种方式,都会导致运行时NullArgumentException

  3. 用户来电Foo()null

    Foo(null);

    null不会隐式转换为NonNullable<>,因此用户会运行时之前在IDE中遇到错误。正如SRP建议的那样,这将空检查委托给类型系统。

您也可以扩展此方法以声明关于参数的其他内容。例如,因为你想要一个写流,你可以定义一个struct WriteableStream<T> where T:Stream用于检查都nullstream.CanWrite在构造函数中。这仍然是运行时类型检查,但是:

  1. 它用WriteableStream限定符修饰类型,向调用者发出信号。

  2. 它在代码中的单个位置进行检查,因此您不必throw InvalidArgumentException每次都重复检查。

  3. 通过将类型检查职责推给类型系统(由通用装饰器扩展),它更好地符合SRP。


3

您的方法目前是程序性的。您正在拆分Stream对象并从外部对其进行验证。请勿这样做-破坏封装。让Stream负责自己的验证。在我们有一些适用于SRP的类之前,我们无法寻求应用SRP。

这是一个Stream仅在通过验证后才执行操作的操作:

class Stream
{
    public void someAction()
    {
        if(!stream.canWrite)
        {
            throw new ArgumentException();
        }

        System.out.println("My action");
    }
}

但是现在我们违反了SRP!“一个班级只有一个改变的理由。” 我们混合了1)验证和2)实际逻辑。我们有两个可能需要更改的原因。

我们可以通过验证装饰器解决此问题。首先,我们需要将其转换Stream为接口并将其实现为具体的类。

interface Stream
{
    void someAction();
}

class DefaultStream implements Stream
{
    @Override
    public void someAction()
    {
        System.out.println("My action");
    }
}

现在,我们可以编写一个装饰器,该装饰器包装Stream,执行验证并针对给定Stream的实际操作逻辑进行延迟。

class WritableStream implements Stream
{
    private final Stream stream;

    public WritableStream(final Stream stream)
    {
        this.stream = stream;
    }

    @Override
    public void someAction()
    {
        if(!stream.canWrite)
        {
            throw new ArgumentException();
        }
        stream.someAction();
    }
}

现在,我们可以按自己喜欢的方式组合这些内容:

final Stream myStream = new WritableStream(
    new DefaultStream()
);

是否需要其他验证?添加另一个装饰器。


1

班级的工作是提供符合合同的服务。一个类总是有一个合同:一组使用它的要求,并承诺只要满足要求,它便会说明其状态和输出。通过文档和/或断言,此合同可以是显式的,也可以是隐式的,但它始终存在。

类合同的一部分是,调用方为构造函数提供一些不能为null的参数。履行合同班级的责任,因此检查呼叫者是否已履行合同的一部分可以轻松地视为在班级责任范围之内。

班级实施合同的想法是埃菲尔编程语言的设计者Bertrand Meyer合同设计的想法引起。埃菲尔语言是对合同的规范和检查的一部分。


0

正如在其他答案中指出的那样,SRP常常被误解。这与仅执行一项功能的原子代码无关。这是关于确保您的对象和方法只做一件事情,而一件事情只在一个地方完成。

让我们来看一个伪代码中的不良示例。

class Math
    private int a;
    private int b;
    def constructor(int x, int y) 
        if(x != null)
          a = x
        else if(x < 0)
          a = abs(x)
        else if (x == -1)
          throw "Some Silly Error"
        else
          a = 0
        end
        if(y != null)
           b = y
        else if(y < 0)
           b = abs(y)
        else if(y == -1)
           throw "Some Silly Error"
        else
         b = 0
        end
    end
    def add()
        return a + b
    end
    def sub()
        return b - a
    end
end

在我们相当荒谬的示例中,Math#constructor的“职责”是使数学对象可用。为此,首先要清理输入,然后确保值不为-1。

这是有效的SRP,因为构造函数仅做一件事。它正在准备Math对象。但是,它不是很容易维护。它违反了DRY。

因此,让我们通过另一遍

class Math
    private int a;
    private int b;
    def constructor(int x, int y)
        cleanX(x)
        cleanY(y)
    end
    def cleanX(int x)
        if(x != null)
          a = x
        else if(x < 0)
          a = abs(x)
        else if (x == -1)
          throw "Some Silly Error"
        else
          a = 0
        end
   end
   def cleanY(int y)
        if(y != null)
           b = y
        else if(y < 0)
           b = abs(y)
        else if(y == -1)
           throw "Some Silly Error"
        else
         b = 0
        end
    end
    def add()
        return a + b
    end
    def sub()
        return b - a
    end
end

在此过程中,我们对DRY有了更好的了解,但是我们仍然可以使用DRY。另一方面,SRP似乎有些偏离。现在,我们有两个功能相同的工作。cleanX和cleanY都清除输入。

让我们再试一次

class Math
    private int a;
    private int b;
    def constructor(int x, int y)
        a = clean(x)
        b = clean(y)
    end
    def clean(int i)
        if(i != null)
          return i
        else if(i < 0)
          return abs(i)
        else if (i == -1)
          throw "Some Silly Error"
        else
          return 0
        end
    end
    def add()
        return a + b
    end
    def sub()
        return b - a
    end
end

现在终于有了更好的DRY,并且SRP似乎达成了共识。我们只有一个地方负责“消毒”工作。

从理论上讲,该代码可维护性更好,但是当我们修复错误并加强代码时,我们只需要在一个地方进行即可。

class Math
    private int a;
    private int b;
    def constructor(int x, int y)
        a = clean(x)
        b = clean(y)
    end
    def clean(int i)
        if(i == null)
          return 0
        else if (i == -1)
          throw "Some Silly Error"
        else
          return abs(i)
        end
    end
    def add()
        return a + b
    end
    def sub()
        return b - a
    end
end

在大多数实际情况下,对象会更复杂,并且SRP将应用于一堆对象。例如,年龄可能属于父亲,母亲,儿子,女儿,因此,不是由4个类来确定从出生日期算起的年龄,而是有一个Person类来完成该任务,而这4个类则继承自该类。但是我希望这个例子可以帮助解释。SRP与原子动作无关,而与完成“工作”有关。


-3

说到SRP,鲍勃叔叔不喜欢到处散布空值检查。通常,作为一个团队,您应该尽可能避免对构造函数使用null参数。当您在团队外部发布代码时,情况可能会发生变化。

在没有首先确保所讨论类的内聚性的情况下强制执行构造函数参数的非空性会导致调用代码(特别是测试)膨胀。

如果您真的想执行此类合同,请考虑使用Debug.Assert或类似的方法来减少混乱:

public AClassThatDefinitelyNeedsAWritableStream(Stream stream)
{
   Assert.That(stream.CanWrite, "Put crucial information here, and not inane bloat.");

   // Go on normal operation.
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.