原则22:选择定义并实现接口,而不是基类
抽象基类提供的是类继承结构的公共祖先。接口描述实现类的原子级功能。两者都更有千秋,却不尽相同。接口是一种合约式的设计:实现接口的类必须提供所有期望函数的实现。抽象基类提供一组相关类的共有抽象。这是老套的,它是这样的:继承是“ is a ”的关系,接口是“ behavies like ”的关系。这些陈词滥调已经说了很久了,因为它们的结构说明了彼此的不同:基类描述的是对象是什么,接口描述的是对象的表现方式。
接口描述的是一个功能集,更像是一个合约。 你可以在接口里创建任何占位符:方法,属性,索引器和事件。实现接口的类必须提供接口定义的所有元素的具体实现。你必须实现所有的方法,提供所有属性的访问器和索引器和定义所有事件。你确定并提前相同的行为到接口中。你可以使用接口作为参数和返回值。你还可以有更多机会重用代码因为不相关类可以实现同一个接口。更重要的是,其他开发者实现接口比继承基类会更容易。
你不能在接口做的是不能提供任何成员变量。接口没有任何实现,并且它们不能包含任何具体的数据成员。你的类要么全部实现接口的定义的所有元素,要么就没有实现接口。当然,你可以通过创建扩展方法让人觉得接口实现的错觉。System.Linq.Enumerable 类包含对于30个声明在 IEnumerable<T> 的扩展方法。扩展方法是实现 IEnumerable<T> 类的一剂良药。你可以查看原则8:
public static class Extensions
{
public static void ForAll<T>(
this IEnumerable<T> sequence, Action<T> action)
{
foreach (T item in sequence)
action(item);
}
}
// usage
foo.ForAll((n) => Console.WriteLine(n.ToString()));
抽象基类可以提供给子类一些实现,可以描述一些共有的行为。你可以指定数据成员,具体方法,实现虚函数,属性,事件和索引器。基类可以可以通过方法实现共有可重用功能。任何元素都可以是 virtual , abstract 或 nonvirtual 。抽象基类可以通过具体行为,而接口不行。
实现重用功能还有一个好处:如果你在基类添加方法,所有的子类都自动隐式的加强。在这个意义上,随着时间的推移,,基类提供了一种高效的方式来扩展几个类的行为:基类增加和实现功能,子类就立即合并这些行为。在接口中添加添加元素会破坏所有实现这个接口的类。它们没有包含这个元素的实现就不能通过编译。每个实现类都要更新以包含这个新元素。
如何选择抽象基类和接口其实就是一个随着时间的推移如何更好的支持你的抽象的功能的问题。接口是固定的,你发布的接口就是功能集的一个所有实现类要遵循的合约。基类可以随着时间推移而扩展。这些扩展会变成每个子类的一部分。
这两个模型可以混合复用的实现代码同时支持多个接口。.NET 框架很明显的例子就是 IEnumerable<T> 接口和 System.Linq.Enumerable 类。System.Linq.Enumerable 类包含大量定义在 System.Collection.Generic.IEnumerable<T> 接口中的扩展方法。这个分离有很重要的意义。任何类实现 IEnumerable<T> 直接就包含这些扩展方法。并且,还有额外一些没有在 IEnumerable<T> 定义的方法。这意味着开发者没有必要自己去实现这些方法。
检查实现 IEnumerable<T> 的天气观察类。
public enum Direction
{
North,
NorthEast,
East,
SouthEast,
South,
SouthWest,
West,
NorthWest
}
public class WeatherData
{
public double Temperature { get; set; }
public int WindSpeed { get; set; }
public Direction WindDirection { get; set; }
public override string ToString()
{
return string.Format("Temperature = {0}, Wind is {1} mph from the {2}",Temperature, WindSpeed, WindDirection);
}
}
public class WeatherDataStream : IEnumerable<WeatherData>
{
private Random generator = new Random();
public WeatherDataStream(string location)
{
// elided
}
private IEnumerator<WeatherData> getElements()
{
// Real implementation would read from
// a weather station.
for (int i = 0; i < 100; i++)
yield return new WeatherData
{
Temperature = generator.NextDouble() * 90,
WindSpeed = generator.Next(70),
WindDirection = (Direction)generator.Next(7)
};
}
#region IEnumerable<WeatherData> Members
public IEnumerator<WeatherData> GetEnumerator()
{
return getElements();
}
#endregion
#region IEnumerable Members
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return getElements();
}
#endregion
}
WeatherStream 类要模拟出一系列的天气观察。为了实现这点它实现了 IEnumerable<WetherData> 。这就得实现两个方法:GetEnumerator<T> 方法和类的 GetEnumerator 方法。后者的显示实现可以让客户端代码自然把泛型对象向上转换为 System.Object 。
实现了这两个方法 WeatherStream 类支持所有在 System.Linq.Enumerable 的扩展方法。这意味着 WeatherStream 可以是 LINQ 查询的数据源:
var warmDays = from item in new WeatherDataStream("Ann Arbor") where item.Temperature > 80 select item;
LINQ 查询语法会被编译成方法调用。上面查询会被翻译为下面的调用:
var warmDays2 = new WeatherDataStream("Ann Arbor").Where(item => item.Temperature > 80). Select(item => item);
在上面的代码,Where 和 Select 的调用看起来觉得它们是 IEnumerable<WeatherData> 的方法。但其实不是。这两个方法像是属于 IEnumerable<WeatherData> 因为它们是扩展方法。它们实际是 System.Linq.Enumerable 的静态方法。编译器翻译这些调用为下面的静态调用:
// Don't write this, for explanatory purposes
var warmDays3 = Enumerable.Select(Enumerable.Where( new WeatherDataStream("Ann Arbor"), item => item.Temperature > 80), item => item);
最后的这个版本是告诉你接口真的不可以包含实现。你通过使用扩展方法来模拟。 LINQ 就是在 System.Linq.Enumerable 类中创建 IEnumerable<T> 的扩展方法。
这让我回到使用接口左右参数和返回值的主题。接口可以任意不相关的类实现。接口的编码比基类的编码给其他开发者提供了更多的灵活性。这样非常重要所以 .NET 环境只支持单一继承关系。
下面三个方法完成的工作是相同的:
public static void PrintCollection<T>(IEnumerable<T> collection)
{
foreach (T o in collection)
Console.WriteLine("Collection contains {0}",o.ToString());
}
public static void PrintCollection(System.Collections.IEnumerable collection)
{
foreach (object o in collection)
Console.WriteLine("Collection contains {0}",o.ToString());
}
public static void PrintCollection(WeatherDataStream collection)
{
foreach (object o in collection)
Console.WriteLine("Collection contains {0}",o.ToString());
}
第一个方法是可重用的,任何实现 IEnumerable<T> 的类都可以使用这个方法。除了 WeahterDataStream ,List<T> , SortedList<T> , 数组 和 LINQ 查询的结果都可以使用。第二个方法对很多类也有用,但是只是用在不完美的非泛型 IEnumerable 上。最后的方法是最不能重用的。它不能被数组,ArrayList , DataTable , HashTable , ImageList 和其他集合类使用。在方法编码上使用接口作为参数类是更加普遍和更加容易重用。
使用接口定义类的 API 会更具灵活性。WeatherDataStream 类可以实现返回 WeatherData 对象的集合的方法。那会是像这样的:
public List<WeatherData> DataSequence
{
get { return sequence; }
}
private List<WeatherData> sequence = new List<WeatherData>();
这样会遗留一个很脆弱的问题。有时,你会将 List<WeatherData> 改为 数组或 SortedList<T> 。任何改变都会破坏之前的代码。当然,你可以改变参数类型,但也要改变类的 public 接口。改变类的 public 接口会引起大系统的更多改变;你需要在所有访问 public 属性的地方进行修改。
第二个问题是更直接和更令人困惑的:List<T> 类提供了改变它所包含的数据的多种方法。这个类的用户可以删除,修改甚至替换序列中的每个对象。这些绝大多数都不是你想要的。幸运的是,你可以限制使用这个类的用户的权限。为了不直接返回内部对象的直接引用,你可以返回接口给你的使用者。这其实就是返回 IEnumerable<WeatherData> 。
当你的类以类暴露属性,也就暴露了这个类的所有接口。使用接口,你就可以选择哪些方法和属性暴露给你的使用者。实现接口的类可以随着时间推移而改变实现细节。
更重要的是,不相关的类可以实现相同的接口。假设你正在构建一个应用程序管理员工,客户,和供应商。至少在类的结构是不相关的。但是它们共享了一些共有的功能。它们有名字,它们都要在程序的控制台展示名字。
public class Employee
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Name
{
get
{
return string.Format("{0}, {1}",LastName, FirstName);
}
}
// other details elided.
}
public class Customer
{
public string Name
{
get
{
return customerName;
}
}
// other details elided
private string customerName;
}
public class Vendor
{
public string Name
{
get
{
return vendorName;
}
}
// other details elided
private string vendorName;
}
Employee , Customer 和 Vendor 类都没有继承同一个基类。但是他们分享几个属性:名字(上面展示的),地址和联系电话号码。你可以提取这些属性到接口中:
public interface IContactInfo
{
string Name { get; }
PhoneNumber PrimaryContact { get; }
PhoneNumber Fax { get; }
Address PrimaryAddress { get; }
}
public class Employee : IContactInfo
{
// implementation elided.
}
这个接口通过让你知道构建这些不相关类的共有的任务来简化你编程的工作:
public void PrintMailingLabel(IContactInfo ic)
{
// implementation deleted.
}
这是对于实现 IContactInfo 所有实体的例行工作。 Customer , Employee 和 Vendor 有相同的工作——但是只是因为你提取它们到接口中了。
使用接口同时衣蛾意味着你可以让结构体 省去了拆箱的操作带来的损耗。当你把结构体放入箱中,这个箱可以实现结构体支持的接口。当你通过接口指针访问结构体,你不需要进行将结构体拆箱成要访问的那个对象。例如,想象这个接口定义一个链接和一个描述:
public struct URLInfo : IComparable<URLInfo>, IComparable
{
private string URL;
private string description;
#region IComparable<URLInfo> Members
public int CompareTo(URLInfo other)
{
return URL.CompareTo(other.URL);
}
#endregion
#region IComparable Members
int IComparable.CompareTo(object obj)
{
if (obj is URLInfo)
{
URLInfo other = (URLInfo)obj;
return CompareTo(other);
}
else
throw new ArgumentException("Compared object is not URLInfo");
}
#endregion
}
当你可以很简单地创建 URLInfo 对象的有序列表因为 URLInfo 实现了 IComparable<T> 和 IComparable 。即使代码依赖的是类 IComparable 也有更少的 封箱和拆箱次数因为使用者可以调用 IComparable.CompareTo() 而不用对对象进行拆箱操作。
基类描述和或实现相关具体子类的共有行为。接口描述的是不相关的具体类型可以实现的原子功能。都各有千秋。类定义了你创建的类是什么。接口描述实现类的功能行为。一旦你理解这些不同,你就可以创建更高效设计更好面对变化。使用类结构定义相关的类。使用接口暴露实现类的功能。
小结:
这个原则通过一系列的讲解告诉接口和类的区别,其实就是说在软件设计过程中更多关注的是对象能做什么,而不是对象是什么,越多行为的抽象,后期的问题就越少,多使用接口!