当程序员说“针对接口编程,而不是对象”时,他们的意思是什么?

82
我已经开始了学习并应用TDD到我的工作流程中这个漫长而艰辛的过程。我认为TDD非常符合IoC原则。
在Stack Overflow上浏览了一些TDD标签的问题后,我得知建议针对接口而不是对象进行编程是一个好主意。
你能提供一些简单的代码示例来说明这是什么以及如何将其应用于真实的用例中吗?简单的示例对我(和其他想要学习的人)理解这些概念至关重要。

7
这更像是面向对象编程的一种概念,而不是仅适用于C#的特定事项。 - Billy ONeal
2
@Billy ONeal:可能是这样,但由于接口在C#/Java中的作用不同,我想先学习我最熟悉的语言。 - delete
11
@Sergio Boombastic:将编程概念应用于接口与Java或C#中的interface无关。事实上,在引用此语录的书籍出版时,Java和C#甚至还不存在。 - Jörg W Mittag
1
@Jörg:嗯,它确实与此有某些关系。最近的面向对象编程语言中的接口肯定是按照引述中描述的方式来使用的。 - Michael Petrotta
1
@Michael Petrotta:尽管如此,它们并不是很擅长这个。例如,List的接口表示在将元素添加到列表后,该元素会出现在列表中,并且列表的长度会增加1。那么,在interface List 中实际上哪里说了呢? - Jörg W Mittag
@Jörg:接口有助于定义,但不足以达到大写字母 I 的 接口。这听起来怎么样? - Michael Petrotta
7个回答

86

考虑以下内容:

class MyClass
{
    //Implementation
    public void Foo() {}
}

class SomethingYouWantToTest
{
    public bool MyMethod(MyClass c)
    {
        //Code you want to test
        c.Foo();
    }
}

因为MyMethod只接受MyClass,如果你想用一个模拟对象来替换MyClass以进行单元测试,那是不可能的。更好的方法是使用一个接口:

interface IMyClass
{
    void Foo();
}

class MyClass : IMyClass
{
    //Implementation
    public void Foo() {}
}

class SomethingYouWantToTest
{
    public bool MyMethod(IMyClass c)
    {
        //Code you want to test
        c.Foo();
    }
}

现在你可以测试MyMethod,因为它只使用了一个接口,而不是具体的实现。然后,你可以实现该接口以创建任何类型的模拟或虚假物来进行测试。甚至有像Rhino Mocks的Rhino.Mocks.MockRepository.StrictMock<T>()这样的库,它可以接受任何接口并在运行时为你构建一个模拟对象。


14
附注:在使用的语言中,“接口”并不总是要指一个实际的接口。它也可以是一个抽象类,这在Java或C#对继承的限制下是不完全不合理的。 - Joey
2
@Joey:是的,你可以使用抽象类,但如果这样做,你必须设计该类以便继承,这可能需要更多的工作。当然,在像C++这样没有语言级接口的语言中,你确实会这样做——制作一个抽象类。请注意,在Java和C#中,仍然最好使用接口,因为你可以从多个接口继承,但只能从一个类继承。允许多个接口继承鼓励你使接口更小,这是一件好事TM :) - Billy ONeal
6
@Joey:你甚至不需要使用任何东西。例如,在Ruby中,你编写程序的接口通常只有在英文文档中才有描述,如果有的话。但这并不会使它成为一个更少的接口。相反,把“interface”关键字随处粘贴到你的代码中,并不意味着你是在针对接口进行编程。思考实验:拿一份可怕的紧密耦合、内聚性差的代码。对于每个类,简单地复制并粘贴它,删除所有的方法体,将“class”关键字替换为“interface”,并更新代码中对该类型的所有引用。现在的代码好了吗? - Jörg W Mittag
那么这是否意味着每个类都需要先定义一个接口,然后使类去实现它?除了测试之外,这有什么好处吗?好的,接口可以被替换,但在实际生活中,大多数类实现很少被替换。有人能解释一下吗? - bobbyalex
那么,如果您不进行模拟测试或自动化测试,这个原则是否仍然适用?对我来说,这似乎是一种额外负担,非常不直观。每个类都需要接口吗,而它只会被一个类实现?真的吗? - bobbyalex
显示剩余9条评论

19
这完全是关于密切程度的问题。如果你编写针对实现(一个实现的对象)的代码,那么你就与该代码“另一个”产生了相当亲密的关系,作为其使用者。这意味着你必须知道如何构造它(例如,它有哪些依赖项,可能是作为构造函数参数,也可能是作为设置器),何时处理它,而且你可能没有它就无法做太多事情。
面向实现对象的接口可以让你做几件事情 -
1. 首先,你可以/应该利用工厂来构造对象的实例。IOC容器可以很好地为你完成此操作,或者你可以自己制作。通过将构建职责放在你之外,你的代码可以假设它正在得到所需的内容。在工厂墙的另一侧,你可以构造类的真实实例或模拟实例。在生产中,你当然会使用真实实例,但是在测试过程中,你可能需要创建stubbed或动态mocked实例,以测试各种系统状态,而无需运行整个系统。
2. 你不必知道对象在哪里。这在分布式系统中非常有用,你想要通信的对象可能在你的进程中或甚至不在同一个系统中。如果你曾经使用Java RMI或旧版EJB进行编程,你就知道“与接口通信”的常规程序是隐藏了代理,该代理执行远程网络和封装任务,而客户端则不必关心。WCF有一个类似的哲学,“与接口通信”,让系统确定如何与目标对象/服务进行通信。
**更新**
有人要求提供IOC容器(工厂)的示例。虽然几乎所有平台上都有很多容器,但它们的核心功能都是这样的:
1. 在应用程序启动过程中初始化容器。一些框架通过配置文件或代码或两者同时来完成此操作。 2. “注册”你希望容器为你创建的实现,作为它们实现的接口的工厂(例如,为Service接口注册MyServiceImpl)。在此注册过程中,通常可以提供一些行为策略,例如每次创建新实例还是使用单个(ton)实例。 3. 当容器为你创建对象时,它将任何依赖项作为创建过程的一部分注入到这些对象中(即,如果你的对象依赖于另一个接口,则会提供该接口的实现,依此类推)。
伪码可能如下所示:
IocContainer container = new IocContainer();

//Register my impl for the Service Interface, with a Singleton policy
container.RegisterType(Service, ServiceImpl, LifecyclePolicy.SINGLETON);

//Use the container as a factory
Service myService = container.Resolve<Service>();

//Blissfully unaware of the implementation, call the service method.
myService.DoGoodWork();

+1 对于第一点。那非常清晰易懂。第二点就飞过我的头了。:P - delete
1
@Sergio:hoserdude 的意思是,有很多情况下你的代码并不知道对象的实际实现者是谁,因为它是由框架或其他库代表你自动实现的。 - Billy ONeal
你能给出一个好的、基本的IoC容器工厂的例子吗? - bulltorious

9

在编写针对接口的程序时,您将编写使用接口实例而不是具体类型的代码。例如,您可能会使用以下模式,其中包含构造函数注入。虽然不需要构造函数注入和控制反转的其他部分才能针对接口进行编程,但由于您来自TDD和IoC角度,我已经将其连接起来,以便为您提供一些您希望熟悉的上下文。

public class PersonService
{
    private readonly IPersonRepository repository;

    public PersonService(IPersonRepository repository)
    {
        this.repository = repository;
    }

    public IList<Person> PeopleOverEighteen
    {
        get
        {
            return (from e in repository.Entities where e.Age > 18 select e).ToList();
        }
    }
}

传入的是接口类型的存储库对象。传递接口的好处在于能够“交换”具体实现而不更改使用方式。

例如,在运行时,IoC容器将注入与数据库连接的存储库。在测试时,您可以传递模拟或存根存储库来执行PeopleOverEighteen方法。


3
需要注意的是,并不一定需要使用IoC容器才能有效地使用接口。+1 - Billy ONeal
那么基本上,我获得的唯一好处就是能够使用一个 Mocking 框架? - delete
并且可扩展性。测试性是低耦合、高内聚系统的一个好的副作用。通过传递接口,您消除了对实际类执行操作的关注。您不再关心它如何执行操作,而只关心它声称执行操作。这强制实现关注点分离,并允许您专注于当前工作的具体要求。 - Michael Shimmins
@Sergio:你需要像这样的东西来进行任何类型的模拟,不仅仅是框架。@Michael:我以为“DDD”是“开发人员驱动开发”...那应该是指TDD吧? - Billy ONeal
1
@Billy - 我假设你正在进行领域驱动开发。 - Michael Shimmins
@Billy - 我们终于明白了。虽然问题中提到了三次TDD,但我们应该读DDD。幸运的是,更新后它仍然适用 ;) - Michael Shimmins

3

这意味着要考虑通用性而非特定性。

假设你有一个应用程序,需要向用户发送一些消息进行通知。如果你使用 IMessage 等接口进行开发

interface IMessage
{
    public void Send();
}

您可以按用户自定义接收消息的方式。例如,某人想通过电子邮件收到通知,因此您的IoC将创建一个EmailMessage具体类。其他人想要短信,则创建一个SMSMessage实例。

在所有这些情况下,通知用户的代码都不会改变。即使添加另一个具体类。


@Billy: 感谢你提醒SOLID中的“O”部分! - Lorenzo
StackExchange坏掉了你的链接 :( http://en.wikipedia.org/wiki/Solid_%28object-oriented_design%29 - Billy ONeal
@Billy:哎呀……它吃掉了最后一个括号。这个链接应该可以工作 - Lorenzo
1
http://meta.stackexchange.com/questions/72386/google-chrome-stack-exchange-url-escaper-extension - Billy ONeal

2
在进行单元测试时,使用接口进行编程的最大优势在于它允许您将要分别测试或模拟的任何依赖项与代码片段隔离开来。
我之前在某个地方提到过的一个例子是使用接口访问配置值。而不是直接查看ConfigurationManager,您可以提供一个或多个接口,让您访问配置值。通常,您会提供一个从配置文件中读取的实现,但是对于测试,您可以使用一个仅返回测试值或引发异常等的实现。
还考虑一下数据访问层。如果您的业务逻辑与特定的数据访问实现紧密耦合,则很难在没有所需数据的整个数据库的情况下进行测试。如果您的数据访问被隐藏在接口后面,则可以为测试提供所需的数据。
使用接口增加了可用于测试的“表面积”,使得可以进行更细粒度的测试,真正测试您的代码的各个单元。

2
像一个阅读完文档后会使用代码的人一样测试你的代码。不要基于你编写或阅读代码时所掌握的知识来进行任何测试。你希望确保你的代码符合期望。
在最佳情况下,你应该能够将你的测试用作示例,Python中的doctest就是一个很好的例子。
如果你遵循这些准则,改变实现不应该成为问题。
根据我的经验,测试你的应用程序的每个“层”是一个好习惯。你将有原子单元,它本身没有依赖关系,你将有依赖其他单元的单元,直到最终到达应用程序本身。
你应该测试每个层,不要依赖于测试单元A也会测试单元A依赖的单元B这个事实(继承也适用这条规则)。即使你可能感到自己在重复,这也应该被视为实现细节。
请记住,一旦编写了测试,在测试代码几乎肯定会更改的同时,测试本身几乎不太可能更改。
在实践中,还存在IO和外部世界的问题,因此最好使用接口,以便在必要时创建模拟对象。
在更动态的语言中,这并不是一个大问题,你可以使用鸭子类型,多重继承和混合来组成测试用例。如果你开始不喜欢继承,那么你可能做对了。

1

这个视频教程讲解了如何在C#中实践敏捷开发和测试驱动开发。

通过针对接口编码,你可以在测试中使用模拟对象而不是真实对象。通过使用一个好的模拟框架,你可以在模拟对象中做任何你想做的事情。


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