前面讲 泛型 的时候,提到了接口。和泛型一样,接口也是目前 JavaScript 中并不存在的语法。
由于泛型语法总是附加在类或函数语法中,所以从 TypeScript 转译成 JavaScript 之后,至少还存在类和函数(只是去掉了泛型定义,类似 Java 泛型的类型擦除)。然而,如果在某个 .ts
文件中只定义了接口,转译后的 .js
文件将是一个空文件——接口被完全“擦除”了。
那么,TypeScript 中为什么要出现接口语法?而对于没接触过强类型语法的 JSer 来说,接口到底是个什么东西?
什么是接口
现实生活中我们会遇到这么一个问题:出国旅游之前,往往需要了解目的地的电源插座的情况:
是什么形状,是三插还是双插,是平插还是圆插?
如果形状相同,电压多少,110V 还是 220V 或者 380V?
直流电还是交流电?
大家都知道,国内的电源插头常见的有两种,三平插(比如多数笔记本电脑电源插头)和双平插(比如多数手机电源插头),家用电压都是 220V。但是近年来电子产品与国际接轨,电源适配器和充电器一般都支持 100~220V 电压。
那么上面就出现了两类标准,一类是插座的标准,另一类是插头的标准。如果这两类标准一样,我们就可以提包上路,不用担心到地方后手机充不上电,电脑找不到合适电源的问题。但是,如果标准不一样,就必须去买个转换插头,甚至是带变压功能的转换插头。
这里提到的转换插头在软件开发中属于“适配器模式”,这里不深研。我们要研究的是插座和插头的标准。插座就是留在墙上的接口,它有自身的标准,而插头为了能使用这个插座,就必须符合它的标准,换句话说,得匹配接口。工业上这像插座这样的标准必须成文、审批、公布并执行,而编程上的接口也类似,需要定义接口、类型检查(编译器)、公布文档,实现接口。
所以回到 TypeScript,我们以关键字 interface
,用类似于 class
声明的语法在定义接口 (还记得声明类型一文中提到的类成员声明吗)。所以一个接口看起来可能是这样的
interface INamedLogable {
name: string;
log(...args: any[]);
}
通过实例讲接口
假设我们的业务中有这样一部分 JavaScript 代码
function doWith(logger) {
console.log(`[Logger] ${logger.name}`);
logger.log("begin to do");
// ...
logger.log("all done");
}
doWith({
name: "jsLogger",
log(...args) {
console.log(...args);
}
})
翻译成 TypeScript
我们还不懂接口,所以先定义一个类,包含 name
属性和 log()
方法。有了这个类就可以在 doWith()
和其它定义中使用它来进行类型约束(检查)。
class JsLogger {
name: string;
constructor(name: string) {
this.name = name;
}
log(...args: any[]) {
console.log(...args);
}
}
然后定义 doWith
:
function doWith(logger: JsLogger) {
console.log(`[Logger] ${logger.name}`);
logger.log("begin to do");
// ...
logger.log("all done");
}
调用示例:
const logger = new JsLogger("jsLogger");
doWith(logger);
给 log() 方法加点料
上面的示例中,输出的日志只有日志内容本身,但是我们希望能在日志信息每行前面缀上日志名称,比如像这样的输出
[jsLogger] begin to do
所以我们从 JsLogger
继承出来一个 PoweredJsLogger
来用:
class PoweredJsLogger extends JsLogger {
log(...args: any[]) {
console.log(`[${this.name}]`, ...args);
}
}
const logger = new PoweredJsLogger("jsLogger");
doWith(logger);
换个第三方 Logger
甚至我们可以换个第三方 Logger,与 JsLogger
毫无关系,但成员定义相同
function doWith(logger: JsLogger) {
console.log(`[Logger] ${logger.name}`);
logger.log("begin to do");
// ...
logger.log("all done");
}
const logger = new AnotherLogger("oops");
doWith(logger);
你以为它会报错?没有,它转译正常,运行正常,输出
[Logger] oops
[Another(oops)] begin to do
[Another(oops)] all done
看到这个结果,Java 和 C# 程序员要抓狂了。不过 JSer 觉得这没什么啊,我们平时经常这么干。
从类 (class) 声明接口
理论上来说,接口是一个抽象概念,类是一个更具体的抽象概念——是的,类不是实体 (instance),从类产生的对象才是实体。一般情况下,我们的设计过程是从具体到抽象,但开发(编程)过程正好相反,是从抽象到具体。所以一般在开发过程中都是先定义接口,再定义实现这个接口的类。
当然有例外,我相信多数开发者会有相反的体验,尤其是一边设计一边开发的时候:先根据业务需要定义类,再从这个类抽象出接口,定义接口并声明之前的类实现这个接口。如果接口元素(比如:方法)发生变化,往往也是先在类中实现,再进行抽象补充到接口定义中。这种情况下我们多么希望能直接从类生成接口……当然有工具可以实现这个过程,但多数语言本身并不支持——别再问我原因,刚才已经讲过了。
不过 TypeScript 带来了不一样的体验,我们可以从类声明接口,比如这样
interface ILogger extends JsLogger {
// 还可以补充其它接口元素
}
这里定义的 ILogger
和最前面定义的 INamedLogable
具有相同的接口元素,是一样的效果。
为什么 TypeScript 支持这种反向的定义……也许真的只是为了方便。但是对于大型应用开发来说,这并不见得是件好事。如果以后因为某些原因需要为 JsLogger
添加公共方法,那就悲剧了——所有实现了 ILogger
接口的类都得实现这个新加的方法。也许以后某个版本的 TypeScript 会处理这个问题,至少现在 Java 已经找到办法了,这就是 Java 8 带来的默认方法,而且 C# 马上也要实现这一特性了 。
回到上面的问题
现在回到上面的问题,为什么向 doWith()
传入 AnotherLogger
对象毫不违和,甚至连个警告都没有。
前面我们已经提到了“鸭子辨型法”,对于 doWith(logger: JsLogger)
来说,它需要的并不真的是 JsLogger
,而是 interface extends JsLogger {}
。只要传入的这参数符合这个接口约束,方法体内的任何语句都不会产生语法错误,语法上绝对没有问题。因此,传入 AnotherLogger
不会有问题,它所隐含的接口定义完全符合 ILogger
接口的定义。
然而,语义上也许会有些问题,这也是我作为一个十多年经验的静态语言使用者所不能完全理解的。有可能这是 TypeScript 为了适应动态的 JavaScript 所做出的让步,也有可能这是 TypeScript 特意引入的特性。我对多数动态语言和函数式语言并不了解,但我相信,这肯定不是 TypeScript 首创。
TypeScript 接口详述
上面大量的内容只是为了将大家通过 class
的定义引入到对 interface
的了解。但是接口到底该怎么定义?
常规接口
常规接口的定义和类的定义几乎没有区别,上面已经存在例子,归纳起来需要注意几点:
使用
interface
关键字;接口名称一般按规范前缀
I
;-
接口中不包含实现
不对成员变量赋初始值
没有构造函数
没有方法体
而对接口的实现可以通过 implemnets
关键字,比如
class MyLogger implements INamedLogable {
name: string;
log(...args: any[]) {
console.log(...args);
}
}
这是显式地实现,还有隐式的。
const myLogger: INamedLogable = {
name: "my-loader",
log(...args: any[]) {
console.log(...args);
}
};
另外,在所有声明接口类型的地方传值或赋值,TypeScript 会通过对接口元素一一对比来对传入的对象进行检查。
函数类型接口
曾经我们定义一个函数类型,是使用 type
关键字,以类似 Lambda 的语法来定义。比如需要定义一个参数是 number
,返回值是 string
的函数类型:
// 声明类型
type NumberToStringFunc = (n: number) => string;
// 定义符合这个类型的 hex
const hex: NumberToStringFunc = n => n.toString(16);
现在可以用接口语法来定义
// tslint:disable-next-line:interface-name
interface NumberToStringFunc {
(n: number): string;
}
const hex: NumberToStringFunc = n => n.toString(16);
这种定义方式和 Java 8 的函数式接口语法类似,而且由于它表示一个函数类型,所以一般不会前缀 I
,而是后缀 Func
(有参) 或者 Action
(无参)。不过 TSLint 可不吃这一套,所以这里通过注释关闭了 TSLint 对该接口的命名检查。
这样的接口不能由类实现。上例中的 hex
是直接通过一个 Lambda 实现的。它还可以通过函数、函数表达式来实现。另外,它可以扩展为混合类型的接口。
混合类型接口
JSer 们应该经常会用到一种技巧,定义一个函数,再为这个函数赋值某些属性——这没毛病,JavaScript 的函数本身就是对象,而 JavaScript 的对象可以动态修改。最常见的例子应该就是 jQuery 和 Lodash 了。
这样的类型在 TypeScript 中就通过混合类型接口来定义,这次直接引用官方文档的示例:
interface Counter {
(start: number): string;
interval: number;
reset(): void;
}
function getCounter(): Counter {
let counter = <Counter>function (start: number) { };
counter.interval = 123;
counter.reset = function () { };
return counter;
}
let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;
接口继承
前面我们提到可以从类声明接口,其语法采用 extends
关键字,所以说成是继承也并无不可。
另外,接口还可以继承自其它接口,比如
interface INewLogger: ILogger {
suplier: string;
}
接口还允许从多个接口继承,比如上面提到的 INamedLogable
可以拆分一下
interface INamed {
name: string;
}
interface ILogable {
log(...args: any[]);
}
interface INamedLogable extends INamed, ILogable {}
这样定义 INamedLogable
是不是更合理一些?
后记
不管什么语言,接口的主要目的是为了在供应者和消费者之前创建一个契约,其意义更倾向于设计而非程序本身,所以接口在各种设计模式中应用非常广泛。不要为了接口而接口,在设计需要的时候使用它。对复杂的应用来说,定义一套好的接口很有必要,但是对于一些小程序来说,似乎并无必要。
相关阅读
关注作者的公众号“边城客栈” →
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。