Typescript 中的类型类 (1)
在 TypeScript 中实现一个简单的类型类

类型类在静态类型函数式变成中经常被用与代替interface去实现多态(编写可以用于多个不同类型的代码)。Fantasy-land是一个通过标准JS实现的算术数据类型库,它就是用interface来实现代码重用的。而Fp-ts是另外一个包括了受Fantasy-land启发的算术数据类型库,区别是它是使用类型类来构建的。类型类拓展性更好,并且更适合某些特定的类型结构: 用interface构造就会显得非常复杂, 而且会有一些冗长和笨重。

举个例子,我们需要创建一个函数:接受一个传入的值,把它转换为一个字符串,并且打印出来。假设我们不能直接调用JSON.stringify,并且我们需要每个对象都能有自己独有的字符串序列化方式。

如果使用接口,代码应该是这样:

interface Printable {
  print(): string
}

function logValue(value: Printable) {
  console.log(value.print())
}

这段代码运行起来没问题,但是随之而来的会有一些限制。首先,它只能被用于已经遵循了Printable接口的值。例如,为了让它可以用在一个string值上,你需要把这个值包装成一个适配器对象:

const name = "Paul"

logValue(new StringPrintable(name))

这样做的效果其实也不差,但是假设我们需要设计两个interface,那我们就需要一个特殊的适配器,这个适配器会同时实现这两个interface,而且问题会随着我们添加interface变得更加严重。

const name = "Paul"

logValue(new StringPrintableAndComparable(name))

在Typescript里,我们可以使用接口对类型类进行编码,但是将其值和实现的方法分开。我们可以创造一个分开的值和第二个值去实现这个接口,代替原本直接通过原始值实现这个接口。无论什么时候我们需要为一个值写一个函数去实现类型类,我们只需要我们的函数有两个参数:原始值和类型类的实例。

举个例子,我们通过类型类来创建同一个logValue函数。我们的Printable接口现在变成了一个类型类,并且它需要可以在任何值身上都生效,所以我们引入了一个类型参数,A(这个类型和Printable的定义无关,所以我们可以简单的定义一下)。我们的Print函数从一个无参数函数变成了一个传入类型A的值作为参数的函数,并且返回一个字符串。

type Printable<A> = {
  print(value: A): string
}

我们的logValue函数修改为传入两个值:我们正要转换的原始值,还有一个Printable类型类的实例(里边会有对应的转换逻辑)。我们可以用Printable实例中的print方法,传入我们的原始值:

function logValue<T>(t: T, stringable: Printable<T>) {
  console.log(stringable.print(t))
}

这里,logValue就可以对任何类型的值都生效了。我们也不需要去把一个值包裹成一个适配器,并且如果要增加功能,只需要单纯地增加类型类的参数即可:

declare function logValue<T>(t: T, stringable: Printable<T>, equal: Equal<T>)

然后让我们再进一步看一下Equal类型类。在写Javascript代码的时候,经常会有比较两个值的时候。多数的库或者框架都会参考strict equality comparison algorithm来比较判断两个值是否相等。比如,在Javascript中Set就是通过这个算法来判断相等的,默认情况下,一个React中的PureComponent用来比较props也是基于这个算法,reselect库也是通过该算法类判断参数是否相同。一般来说这是没问题的,但是其他的一些时候我们也需要更精准地控制判断两个值是否相等这件事的粒度。

如果我们想要通过接口来解决,写出来的代码应该看起来是这样:

interface Equal {
  equals(other: Equal): boolean
}

然后是一些简单地实现:

class User implements Equal {
  constructor(private id: number) {}
  equals(user: User) {
    return user.id === this.id
  }
}

class Point implements Equal {
  constructor(private x: number, private y: number) {}
  equals(point: Point) {
    return this.x === point.x && this.y === point.y
  }
}

可惜这个实现不是那么的准确。比如,我有两个都实现Equal的不同实例,我只要想办法让类型检查通过就能比较它们:

const user: Equal = new User(1)
const point: Equal = new Point(15, 30)

user.equals(point) // 类型检查通过, 但是逻辑上说不通

为了让它们的类型更加安全,我们可以使用类型类来建模这个Equal接口。我们创建一个有两个参数的方法:要操作的原始值和一个用于比较的附加参数,用这个来取代原来只有一个参数的方法:

type Equal<A> = {
  equals(left: A, right: A)
}

因为这两个参数都是固定地同一个类型,所以它们之后不会再混淆了。要使用这个类型类,我们可以创建一个函数,它的作用是传入一个数组并且移除重复的项。你可能会说直接通过数组创建一个Set就可以了,但是Set使用的严格比较算法可能会让结果和我们想的有出入:

const users = [{
  id: 1, name: "Bob"
}, {
  id: 2, name: "Susan"
}, {
  id: 1, name: "Bob"
}]

console.log(new Set(users))

会打印出:

Set {
  { id: 1, name: 'Bob' },
  { id: 2, name: 'Susan' },
  { id: 1, name: 'Bob' } }

不对啊!我们想要移除重复的项目,但是Set只能基于引用相等来做出判断。所以我们接下来用Equal类型类来写一个函数正确地实现这个功能。

为了使用配合User使用类型类,我们只需要构造一个Equal<User>的实例:

/** 当它们的id相等时,这两个users相等 */
const userEq: Equal<User> = {
  equals(l, r) {
    return l.id === r.id
  }
}

然后用它来执行removeDupes

const users = [{
  id: 1, name: "Bob"
}, {
  id: 2, name: "Susan"
}, {
  id: 1, name: "Bob"
}]

console.log(removeDupes(users, userEq))

这个会打印出:

[ { id: 1, name: 'Bob' }, { id: 2, name: 'Susan' } ]

现在应该是我们想要的结果了。

类型类的思考

当使用类型类构建多态函数的过程中给我们带来了一些思考。与其限制所有实现某个接口子类的参数,我们只需要引入一个类型变量,并且为需要的功能添加类型类即可,比如:

原本重点在使用Talker的子类:

function speak(talker: Talker): string { ... }

类型类允许任何是Talker类型类实例的值:

function speak<T>(talker: T, tc: Talker<T>): string { ... }

当使用通过类型类构建的库时,我们不需要考虑“这个值是否已经实现了X接口”,而是更多地去思考“这个值有没有一个对应的类型类实例?”

创建类型类实例

刚才我们写的那个Equal在fp-ts中是Eq。Fp-ts同时也包括了一个叫做uniq的函数(在库的Array模块里),等同于我们写的removeDupes函数。让我们来试一下fp-ts库里这些函数来实现刚才的功能。

首先创建一个Eq的实例,我们只需要创建一个值"遵循"Eq接口。假设我们有一个用来表示2d坐标系中坐标的元组类型(一个X值和一个Y值),我们创建一个坐标列表:

type Point = [number, number]

const points: Point[] = [
	[1, 2],
	[6, 7],
	[1, 2]
]

我们再进行下一步之前,首先要把重复的值移除。我们将会分两步完成:

  1. Point构建一个Eq类型类的实例。
  2. 配合uniq函数使用这个Eq实例。

先创建一个类型为Eq<Point>的值。我们只需要实现一个方法:equal(a: Point, b: Point) => boolean:

import { Eq } from 'fp-ts/lib/Eq'

const pointEq: Eq<Point> = {
  equals(a: Point, b: Point) {
    return a[0] === b[0] && a[1] === b[1]
  }
}

uniq函数是柯里化的(不像我们自己写的removeDupes),并且先传入一个Eq实例:

function uniq<A>(E: Eq<A>): (as: Array<A>) => Array<A>

uniq的返回值是一个入参是一个数组的函数,然后内部通过Eq实例中的比较方法过滤数组,返回过滤后的结果。

import { uniq } from 'fp-ts/lib/Array'

const filterPoints = uniq(pointEq) // 返回一个用于过滤数组的函数

filterPoints(points) // 返回过滤后的结果

类型类的组合

不停地为一些简单数据类型比如组合数字实现类型类很快就会让人烦躁起来。Fp-ts提供了很多有用的工具函数,让我们可以通过组合简单的函数来构建复杂的类型类。比如,如果已经有了一个用于比较数字的Eq实例,然后在Eq里其实也有了一个函数可以帮我们实现元组的比较。让我们来对比一下这些工具函数的组合跟我们手写函数的区别:

import { Eq, getTupleEq, eqNumber } from 'fp-ts/lib/Eq'

const pointEq: Eq<Point> = getTupleEq(eqNumber, eqNumber)

两个元组只有当它们的内容相等时才相等,所以你没法用一个类型类实例就适配所有的元组(因为元组内可以有很多不同类型的值),getTupleEq“解决"了这个问题


Last modified on 2021-02-12