期待的接口
《编程絮语》之三
定义接口时需要注意什么?是实现,还是消费?窃以为,接口是抽象了的服务,服务的消费者只会关心服务能够提供什么,而不会考虑服务如何实现。例如在ATM机上取款,取款人只需要考虑怎样插入储蓄卡,怎么选择功能项,然后输入正确的密码和取款金额,再等待正确数额的钞票从机器中吐出,最后取走。至于内部的实现机制,则不在取款人的思考范畴。因此,接口必须符合调用者的期待,不然就会给设计带来障碍。接口的定义是为调用者准备的,接口具备的方法以及方法具备的签名,都必须站在调用者的角度来考虑。当调用者是测试用例时,这样的设计就变成了测试驱动设计。
例如编写一个银行账务管理系统,存取款服务的接口定义应该是这样:
public interface IBankService
{
bool Withdraw(Money money);
bool Deposit(Money money);
}
在IBankService的实现类中,会通过调用一个Account类,实现存款和取款的功能:
public class Account
{
public bool Add(Money money)
{
//实现
}
public bool Substract(Money money)
{
//实现
}
}
public class BankServiceImpl : IBankService
{
private Account m_account;
public BankServiceImpl(Account account)
{
m_account = account;
}
public bool Withdraw(Money money)
{
try
{
m_account.Substract(money);
return true;
}
catch
{
return false;
}
}
public bool Deposit(Money money)
{
try
{
m_account.Add(money);
return true;
}
catch
{
return false;
}
}
}
注意IBankService和Account的方法名,两者均实现了存取款功能,为何名称大相径庭?原因就在于对象的调用者并不相同。IBankService暴露给UI,实际上就代表了它是与存取款业务直接相关的。Withdraw和Deposit的命名正好符合这样的逻辑。对于Account而言,表现出来的是帐户上的余额是增加或减少,它并不知道存取款的业务逻辑。
虽然设计者才是接口定义的主宰,然而调用者作为顾客,他才是真正的上帝。调用者说:“我希望使用这样的接口。”潜在的含义是,当我创建这样的实现类时,当我传递需要的输入实参时,你已经帮我考虑好了。调用者就像是守候在无人售货机前的顾客,选一罐可口可乐,然后按价塞入相应的钱币,就听到叮里咣当,最后滚出的一定是一罐可口可乐,而不是百事可乐。接口的设计者需要考虑调用者的感受,同时却不能对调用者做出任何假设。无人售货机如果只能接收五元和十元的纸币,就必须能够防止顾客放入错误的钱币。
一旦服务提供的接口并不符合调用者的期待,就存在一个“适配”的工作,这正是Adapter模式的意图。Adapter对象是一个高明的调解人,负责将两个不协调的接口统一,既有效地保证了第三方接口对象的重用,又能够很好的支持服务的扩展。
虽然服务的定义者必须要符合调用者的期待,但反过来,定义者也给予了调用者一定的限制。此时,接口代表一种规约,它是对调用者进行了合理的限制。以Java的线程处理为例,就要求执行多线程逻辑的对象必须要实现Runnable接口,否则Thread的start()方法就不能执行。
class MyThreadStart implements Runnable
{
public void run()
{ //执行相关操作 }
}
Thread controller = new Thread(new MyThreadStart());
controller.start();
如果方法要求传入的参数类型为抽象类型,则表明该方法的实现可能是变化的。这同样属于对接口的期待。好的设计不应该与具体类型耦合在一处,而是应该将创建具体对象的职责交由调用者去决定。“依赖注入”的方式正是基于这一点,例如Order实体对象的定义:
public interface IOrderRepository { }
public class Order
{
public Order(IOrderRepository repository) { }
}
Order类的定义对IOrderRepository接口存在一个期待,即我们应该传入实现IOrderRepository接口的具体类对象,而不是其他类型。这种对接口的期待是开放的。例如,我们可以在单元测试的时候,考虑定义MockOrderRepository类去实现IOrderRepository接口,从而完成对真正的资源库对象进行模仿。
如果实现接口的类包含的公开方法比接口宽,就需要思考这样的设计是否合理。因为,这意味着这些公开方法对扩展是封闭的,违背了开放封闭原则。调用者在调用这样的类时,如果仍然采用多态的方式去调用,则需要对接口类型进行强制转换。例如:
public interface IConfigReader
{
string Read(string section);
}
public class XmlConfigHandler : IConfigReader
{
public string Read(string section) { }
public void Write(string section,string value) { }
}
public class ConfigSettingManager
{
private string m_section;
public ConfigSettingManager(string section)
{
m_section = section;
}
public void Config(IConfigReader reader)
{
string value = reader.Read(m_section);
if (value != expectedValue)
{
((XmlConfigHandler)reader).Write(m_section, expectedValue);
}
}
}
如上的设计是不协调的。Config()方法的实现破坏了程序结构的平衡与和谐。它带来两个问题。其一,方法的实现与期待不符。既然参数类型为IConfigReader,则表明Config方法期待对配置文件的读功能。那么,对写功能的调用就是不合理的。其二,方法引入了与XmlConfigHandler的具体依赖关系,从而让IConfigReader接口对可扩展性做出的努力付诸东流。
对上述设计的修改基于两种不同的策略,有两种不同的结果。如果对接口方法的期待更加细粒度,即希望分别对读操作和写操作进行区别对待,可以再定义一个IConfigWriter接口。如果XmlConfigHandler类需要实现Write()方法,就可以实现IConfigWriter接口。这就对Write()方法实现了抽象,使得它与Read()方法能够处于相同的抽象层面。如果不需要做这样的区别对待,且读操作和写操作的变化方向与变化粒度是一致的,就可以将其定义为一个统一的接口,例如IConfigHandler:
public interface IConfigHandler
{
string Read(string section) ;
void Write(string section, string value) ;
}
所以,在通常情况下,我们不应该对传入的接口对象进行强制类型转换。接口是设计者对调用者的一种约束和控制,如果进行强制类型转换,说明设计者自己违背了这样的约束,丢失了对调用者的控制力。只有一种例外,即标记接口的使用。Uncle Bob在《敏捷软件开发》中提出的Acyclic Visitor模式即使用了这样的标记接口,如下所示:
public interface ModemVisitor
{ }
public interface HayesVisitor
{
void visit(Hayes modem);
}
public class Hayes
{
public void accept(ModemVisitor v)
{
try
{
HayesVisitor hv = (HayesVisitor)v;
hv.visit(this);
}
catch (){}
}
}
标记接口通常被定义为空。它不是为了调用者的期待而定义,其意图是抽象,将那些不能抽象在一起的类,利用一个标记绑定起来,为其提供统一的接口。标记接口保证了调用方法的一致性。虽然强制类型转换会引入具体依赖,却不会有任何副作用,因为在方法实现中,设计者的期待本身就是要转换的类型。这里不存在扩展,如Hayes类中accept()方法的实现,它期待的只能是HayesVisitor类型。