本节摘要:接口是一组方法签名的命名契约,类通过实现接口把自己挂进横向类型网络。本节讲接口的元数据表示、显式实现与隐式实现的差异、C# 8 默认方法的来由,以及接口与抽象类的选型框架,顺带补齐枚举与结构体这两个值类型成员的设计位置。
public interface ICounter { int Count { get; } void Reset(); }
编译后接口是一个只有元数据没有实现体的类型:一张方法签名列表,加上接口类型自己的方法表。类声明实现接口时,编译器与 CLR 负责检查"类的方法表里能对上契约的每一项"——对不上的编译报错。运行时通过接口变量调用方法时,分派同样走方法表,只是多一步"接口槽位映射":CLR 为每个类维护接口方法到类方法表槽位的映射表,所以接口调用比类虚调用再多一次间接。性能敏感的热路径上这几次间接通常无感,但在千万级循环里可以测出来——知道即可,不必为此扭曲设计。
接口的核心价值是解开"能力"与"家族"的绑定。IDisposable、IComparable<T>、IEnumerable<T> 跨越了完全不相关的类家族:文件、数据库连接、图形资源都能 dispose,数组、列表、集合都能枚举。继承表达"是什么",接口表达"能做什么"——一个类只能属于一个家族,却能挂任意多张能力证书。
public interface IReader { string Read(); } public interface IWriter { string Read { get; } } // 故意同名 public class Storage : IReader, IWriter { string IReader.Read() => "读数据"; // 显式实现:带接口限定 string IWriter.Read => "写游标"; // 只能通过接口访问 }
显式实现(方法名带接口前缀)不出现在类的公共方法表上,只能通过对应接口类型的变量调用。两个用途:一,解决多接口成员撞名(如上);二,把成员"藏"起来强迫调用方走接口——List<T> 对 IEnumerable<T> 的实现里好几个成员都是显式的,就是为了收窄日常可见面。代价是显式成员不能被子类 override(不在公共方法表),也难以单元测试直接调。
一个高频 FAQ:接口能声明属性吗?能。1.4 节讲过属性本质是方法对(get_X 与 set_X),所以接口契约它们毫无障碍——这也是当初说"属性是穿了字段外衣的方法"的又一处印证。
C# 8 允许接口给方法带默认实现:
public interface ILogger { void Log(string msg); void Warn(string msg) => Log("[WARN] " + msg); // 默认实现 }
动机不是"接口变抽象类",而是演化:一个发布多年的接口(想想 IEnumerable)想加成员,一旦是抽象的,全世界所有实现类同时编译失败。默认方法让新增带默认行为不破坏既有实现。代价是新税:接口可以带状态(静态字段)后,菱形继承问题回来了——一个类实现两个接口、两者有同名默认方法时,必须自己显式实现消歧。日常设计的默认态度仍是"接口无实现,抽象类有实现",默认方法留给库作者处理兼容。
| 维度 | 接口 | 抽象类 |
|---|---|---|
| 能否携带状态 | 实例字段不能 | 能 |
| 默认实现 | C# 8 后可以 | 天然可以 |
| 一个类能要几个 | 多个 | 一个 |
| 构造函数 | 无 | 有 |
| 表达关系 | 能做什么 | 是什么 |
| 典型场景 | 能力契约、跨家族解耦 | 家族骨架、模板方法 |
实践里我的倾向:默认先问"这是能力还是家族"。日志、比较、枚举、释放是能力,用接口;报表体系、渲染管线这种有共同字段与流程骨架的家族,用抽象类。两者组合是常态:Stream 抽象类提供骨架,IDisposable 契约独立存在,互不抢戏。
第三章之前的最后两块积木。枚举是命名整数的值类型:
public enum OrderState { Draft = 0, Paid, Shipped, Closed }
元数据层面枚举直接继承 System.Enum,底层默认 int。两个工程要点:给第一个成员显式赋 0(未初始化的枚举字段默认是 0,若 0 无业务含义,调试时会看到莫名值,惯例是加一个 Unknown = 0);枚举与 Flags 特性配合按位组合(值取 1、2、4、8),状态机与权限位集都靠它。结构体第一章已定调:小、不可变、值语义才用——decimal、DateTime、Guid 都是教科书级的 struct 设计,它们"是一个值"且常以百万计出现。
下一节把"行为本身"变成数据:委托如何用类型安全的方式装下一个方法调用。