从 JavaScript 到 TypeScript - 接口

前面讲 泛型 的时候,提到了接口。和泛型同样,接口也是目前 JavaScript 中并不存在的语法。html

因为泛型语法老是附加在类或函数语法中,因此从 TypeScript 转译成 JavaScript 以后,至少还存在类和函数(只是去掉了泛型定义,相似 Java 泛型的类型擦除)。然而,若是在某个 .ts 文件中只定义了接口,转译后的 .js 文件将是一个空文件——接口被彻底“擦除”了。程序员

那么,TypeScript 中为何要出现接口语法?而对于没接触过强类型语法的 JSer 来讲,接口究竟是个什么东西?typescript

什么是接口

现实生活中咱们会遇到这么一个问题:出国旅游以前,每每须要了解目的地的电源插座的状况:编程

  1. 是什么形状,是三插仍是双插,是平插仍是圆插?小程序

  2. 若是形状相同,电压多少,110V 仍是 220V 或者 380V?segmentfault

  3. 直流电仍是交流电?设计模式

你们都知道,国内的电源插头常见的有两种,三平插(好比多数笔记本电脑电源插头)和双平插(好比多数手机电源插头),家用电压都是 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 是否是更合理一些?

后记

无论什么语言,接口的主要目的是为了在供应者和消费者以前建立一个契约,其意义更倾向于设计而非程序自己,因此接口在各类设计模式中应用很是普遍。不要为了接口而接口,在设计须要的时候使用它。对复杂的应用来讲,定义一套好的接口颇有必要,可是对于一些小程序来讲,彷佛并没有必要。


相关阅读


关注做者的公众号“边城客栈” →

相关文章
相关标签/搜索