接口/抽象类编码规范

7

我看到了一份提议的C#编码标准,其中提到“尽量提供一个包含所有抽象类的接口”。有人知道这背后的原理吗?


4
你能提供你看到这个提案的链接吗? - Jay Riggs
假设我上面提供的指南是你所阅读的,那么“接口”部分下面的第一点让我更加不安:“始终优先选择接口而非抽象类。”这甚至比你引用的指南更为强烈地推荐使用接口。 - Cody Gray
5个回答

15

.NET Framework设计准则对接口和抽象类有一些有趣的观点。

特别是,它们指出接口在API演进时比类不灵活。一旦您发布了一个接口,它的成员就永久固定了,任何添加都会破坏实现该接口的现有类型的兼容性。然而,发布一个类提供了更多的灵活性。成员可以随时添加,即使在初始版本发布后,只要它们不是抽象的。任何现有的派生类都可以继续正常工作。Framework中提供的System.IO.Stream抽象类就是一个例子。初始版本没有支持超时待处理的I/O操作,但2.0版本能够添加支持此功能的成员,即使是从现有的子类继承也可以。

因此,每个抽象基类都有一个相应的接口提供了很少的额外好处。接口不能公开暴露,否则你就回到了版本问题的起点。如果只公开抽象基类,那么用接口来增强它就没有什么意义。

此外,人们通常认为接口具有将合同与实现分离的优点。Krzysztof Cwalina认为这个观点是靠不住的:它错误地假设你不能使用类将合同与实现分开。通过编写与其具体实现分离的抽象类,很容易实现相同的分离优势。他写道:

我经常听到人们说接口指定合同。我认为这是一个危险的迷思。接口本身除了需要使用对象的语法之外并没有指定更多。接口作为合同的迷思会导致人们在尝试将合同与实现分离时做出错误的决策,而这是一种很好的工程实践。接口将语法与实现分开,这并不是很有用,而且这个迷思提供了虚假的“正确工程”的感觉。实际上,合同是语义,这些可以用某些实现很好地表达。

一般来说,建议优先使用类(class)定义,而不是接口(interface)。Krzysztof评论道:

在 .NET Framework 的三个版本中,我已经与团队中的许多开发人员讨论过这个准则。其中很多人,包括一开始不同意这个准则的人,都说他们后悔将某些 API 作为接口发布。我甚至没有听说有一个人后悔把类发布出去。

第二个准则认为,用抽象类代替接口实现解除约束与实现之间的绑定关系。这里的重点是,正确设计的抽象类仍然允许在约束和实现之间具有与接口相同程度的解耦。Brian Pepin 的个人观点是:

我开始做的一件事是尽可能地将合同嵌入我的抽象类中。例如,我可能想要将四个方法重载成每个重载提供一个越来越复杂的参数集。最好的方法是在抽象类上提供这些方法的非虚拟实现,并且将所有实现路由到提供实际实现的受保护的抽象方法。通过这样做,你可以一次编写所有无聊的参数检查逻辑。想要实现你的类的开发人员会感谢你。

也许最好重新审视那些被频繁提及的“规则”,即派生类与基类之间存在IS-A关系,而实现接口的类与接口之间存在CAN-DO关系。声称应该总是编写接口和抽象基类,而不考虑具体原因,似乎忽略了重点。


+1 我更喜欢你的解释。我仍在尝试理解为什么 Glav 说这使得使用 Rhino 进行测试更容易。 - Harvey Kwok
@Harvey:谢谢。我也不太清楚Glav的意思,因为我从未使用过任何这些测试模拟工具。这就是为什么我确保澄清可能会有基于特定原因的例外情况。 - Cody Gray

3

没有查看原始文章,我猜测原作者的建议是为了测试性和允许使用MoQ、RhinoMocks等工具轻松地模拟类。


Glav是正确的。至少在Rhino Mocks中,测试具体方法很麻烦。因此,让抽象类实现一个接口可以更好地实现可测试性。 - Simone

1

我一直认为接口驱动设计(Interface-Driven Design,IDD)在创建具体类时涉及以下步骤(对于非平凡类的最纯形式):

  1. 创建一个接口来描述对象必须展示的属性和行为,但不涉及这些应如何运作。
  2. 创建一个抽象基类作为接口的主要实现。实现接口所需的任何功能,但这些功能在具体实现之间不太可能有所不同。还为可能更改但不太可能更改的成员提供适当的默认(虚拟)实现。您还可以提供适当的构造函数(这在接口级别是不可能的)。将所有其他接口成员标记为抽象。
  3. 从抽象类创建具体类,覆盖最初由接口定义的成员的子集。

尽管上述过程有点冗长,但它确保了对最初制定的合同的最大遵守,并在替代实现中最小化了冗余代码。

这就是为什么我通常会将抽象类与接口配对的原因。


在这种模型下,接口的优势并不清楚。与仅编写抽象基类作为其实现相比,首先创建接口会带来哪些好处? - Cody Gray
如果我们能一直以那种方式做事就好了! - XIVSolutions
@Cody 这是一种非常纯粹的态度。如果您认为唯一(正确)的指定行为方式是通过接口,那么从抽象类开始被视为一种弱势方法。此外,接口将成员呈现为它们预期被调用的方式,而抽象类则将它们呈现为预期实现/覆盖的方式——这是语义上微妙的差异。 - Bradley Smith
2
很明显这是一种纯粹主义的态度。就像我熟悉的大多数纯粹主义态度一样,我仍然不理解其基本原理。你已经清楚地解释了如何进行过程,但却省略了为什么。你假设我接受唯一正确的行为规范方式是通过接口,但我并不接受,至少需要一些理由。在语义上的差异似乎并不是真正的差异,因为两种情况下的定义完全相同。 - Cody Gray
1
@Cody 有两种不同的观点;使用具体类的程序员只关心接口成员,而编写具体类的程序员可能不关心某些接口成员,相反地,可能对抽象类公开的非接口成员非常感兴趣。接口使消费者的生活更加简单,而抽象类使实现者的生活更加简单。没有这两种构造,你必须在两个开发者的需求之间做出妥协,这可能对整体设计有害。 - Bradley Smith

0

测试驱动开发(TDD)是您想要这样做的一个重要原因。如果您有一个直接依赖于抽象类的类,则无法在不编写可以在单元测试中实例化的子类的情况下对其进行测试。但是,如果您的依赖类仅依赖于一个接口,则可以使用诸如Rhino Mocks、NMock等模拟框架轻松提供此“实例”。

最终我认为这将取决于您如何发布产品。我们只发布二进制文件,客户不会扩展我们的工作。在内部,我们为几乎所有内容都有接口,因此可以完全隔离类以进行单元测试。这对于重构和回归测试带来了巨大的好处!

编辑:更新了示例

请考虑以下代码的单元测试:

// doesn't work - can't instantiate BaseClass directly
var target = new ClassForTesting(new BaseClass());      

// where we only rely on interface can easily generate mock in our tests
var targetWithInterface = new ClassForTestingWithInterface(MockRepository.GenerateStub<ISomeInterface>());

抽象类版本如下:

// dependent class using an abstract class
public abstract class BaseClass
{
     public abstract void SomeMethod();
}

public class ClassForTesting
{
    public BaseClass SomeMember { get; private set; }

    public ClassForTesting(BaseClass baseClass)
    {
        if (baseClass == null) throw new ArgumentNullException("baseClass");
        SomeMember = baseClass;
    }
}

而使用接口的相同内容是:

public interface ISomeInterface
{
    void SomeMethod();
}

public abstract class BaseClassWithInterface : ISomeInterface
{
    public abstract void SomeMethod();
}

public class ClassForTestingWithInterface
{
    public ISomeInterface SomeMember { get; private set; }

    public ClassForTestingWithInterface(ISomeInterface baseClass) {...}
}

这个怎么样?看起来他可以使用Rhino来模拟抽象类。https://dev59.com/qUbRa4cB1Zd3GeqP3szY - Harvey Kwok

0

我认为在一般情况下,是否需要接口还为时过早。因此,我认为我们不应该将“尝试使用所有抽象类提供一个接口”作为编码标准,除非该编码标准包含更多关于何时适用此规则的细节。

如果我根本不打算使用接口,我是否仍需定义一个接口以满足编码标准?


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接