今天我們要開始介紹類別(Type Class)型的模組,類別模組內的函數比較少,不同的類別代表不同的數學概念。首先登場的是Eq和Ord兩個關係的類別。
一個輸出值為布林值的函數(也就是Predicate),最常使用的過濾條件之一便是相等。數值(number)、字串(string)和布林值(boolean)的相等是沒有太大的爭議,Typescript也可以使用===符號來判斷是否相等。但是陣列和物件的相等呢?物件和陣列在typescript中都是參照值(也就是物件和陣列的位址),因此即便內容一樣,位址不一樣,使用===符號得到的結果會是false,因此物件和陣列的相等可以透過Eq模組來建立其相等的條件。
Eq介面需要提供一個equals的函數,我們先看Eq介面的定義。
interface Eq<A> {
readonly equals: (first: A, second: A) => boolean
}
再來看一個簡單的應用,我們現在定義二維平面上點的型別Point、EqPoint和一個Point[]
type Point = [number, number]
const EqPoint: Eq<Point> = {
equals: ([x1, y1], [x2, y2]) => x1 === x2 && y1 === y2,
};
const pointsArray: Point[] = [
[1, 2],
[2, 4],
[4, 6],
[3, 7],
];
如果我們直接使用Typescript Array的內建函數includes,
console.log(pointsArray.includes([1, 2])); // false
這個結果可能出乎你意料之外,這是因為陣列的相等是建立在參照位址,而兩個陣列的內容雖然相等,但是卻是不同的位址,所以會被視為不相等,所以得到一個false的結果。現在我們定義一個elem的函數(這是Haskell使用的函數名稱,和typescript的include功用一樣)來判斷一個陣列是否包含一元素,這個函數需要3個參數,我們要提供一個型別A的Eq<A>
實例參數,後面再接著一個型別A元素參數,最後一個參數是型別A陣列。
some 函數需要提供一個Predicate和一個陣列作為參數,如果陣列中任何一個元素代入Predicate傳回真值(truth),則得到的結果便為真。
import { some } from 'fp-ts/ReadonlyArray';
import { pipe } from 'fp-ts/function';
import { Eq } from 'fp-ts/Eq';
const elem =
<A>(E: Eq<A>) =>
(a: A) =>
(as: ReadonlyArray<A>): boolean =>
some((e: A) => E.equals(a, e))(as);
console.log(elem(EqPoint)([1, 2])(pointsArray)); // true
只要我們有定義型別A的Eq<A>
實例,就可以使用這個函數,我們不需要替不同的型別寫個別的elme函數,也就是說「elem函數適用所有屬於Eq類別的型別」,它的HM的型別簽名如下:
elem :: Eq a => a -> [a] -> boolean
符號::和=>之間的Eq a稱作型別限制(Type Constraint),也就是要求型別a必須是Eq類別;由於typescript沒有支援高階類別(HKT),因此在fp-ts的實作中,必須提供一個Eq<A>
的實例。
在EqPoint中,陣列中兩個坐標都是數字的比較,fp-ts提供了struct的Eq實例建構函數,讓我們可以使用直接使用基本型別的Eq函數,例如,原來的EqPoint可以改寫成這樣:
import * as N from 'fp-ts/number';
const EqPoint: Eq<Point> = struct([N.Eq, N.Eq]);
另外,同一型別A也可以賦予不同的Eq<A>
實例,然後適需要提供不同的實例,例如有一個User型別,
import { Eq, struct } from 'fp-ts/Eq';
import { pipe } from 'fp-ts/function';
import * as N from 'fp-ts/number';
import * as S from 'fp-ts/string';
import { some } from 'fp-ts/ReadonlyArray';
type User = {
readonly id: number;
readonly name: string;
};
const EqUser: Eq<User> = struct({
id: N.Eq,
name: S.Eq,
});
const EqUserByName: Eq<User> = {
equals: (u1, u2) => u1.name === u2.name,
};
const users: User[] = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
{ id: 3, name: 'Dove' },
{ id: 4, name: 'Tom' },
{ id: 5, name: 'John' },
];
console.log(EqUser.equals({ id: 1, name: 'John' }, { id: 5, name: 'John' })); // false
console.log(
EqUserByName.equals({ id: 1, name: 'John' }, { id: 5, name: 'John' })
); // true
接下來要介紹的是Ord類別,Ord類別源自於數學的三一律,它保證了任二個數字大小是「大於」、「等於」或「小於」其中一個,也就是兩個數是可「比較」,Ord這個類別介面就是三一律的具體實作,很明顯的,一個Ord類別必然是Eq類別,我們看看fp-ts中Ord的類別介面定義。
滿足三一律的代數結構稱為Total Order,數學上也有Partial Order的討論,fp-ts中和Partial Order相關的模組是Lattice模組。
import { Eq } from 'fp-ts/Eq'
type Ordering = -1 | 0 | 1
interface Ord<A> extends Eq<A> {
readonly compare: (x: A, y: A) => Ordering
}
Ord的類別介面除了有equals函數之外,還要有compare函數,compare函數有二個同型別的參數,前者大於後者回傳1,兩者相等回傳0,前者小於後者回傳-1,底下是一個Ord型別的實例。
const ordNumber: Ord<number> = {
equals: (x, y) => x === y,
compare: (x, y) => (x < y ? -1 : x > y ? 1 : 0)
}
和Eq型別一樣,我們很少自己實作一個型別,通常用fromCompare建構子,從基本型別number、string的Ord.compare得到新的型別的Ord。
const OrdUserById: Ord.Ord<User> = Ord.fromCompare((u1, u2) =>
N.Ord.compare(u1.id, u2.id)
);
console.log(
OrdUserById.compare({ id: 1, name: 'John' }, { id: 5, name: 'John' })
); // -1
接下來我們來完成max和min這兩個適用於任何Ord類別的函數。
const max =
<A>(ord: Ord.Ord<A>) =>
(first: A, second: A): A =>
ord.compare(first, second) === -1 ? second : first;
const min =
<A>(ord: Ord.Ord<A>) =>
(first: A, second: A): A =>
ord.compare(first, second) === 1 ? second : first;
console.log(max(OrdUserById)({ id: 1, name: 'John' }, { id: 5, name: 'John' }));
console.log(min(OrdUserById)({ id: 1, name: 'John' }, { id: 5, name: 'John' }));
Eq和Ord型別建構子除了上面介紹的方法建構外,兩者都提供了一個contramap函數來利用陣列或物件中的某一個註標(index)或性質(property)的型別已經存在的相對應Eq和Ord型別來為陣列或物件來構建新的Eq和Ord,我們用簡單的程式碼來說明contramap的用法。
----- Eq -----
const EqUserById = Eq.contramap((user: User) => user.id)(N.Eq)
console.log(
EqUserById.equals({ id: 1, name: 'John' }, { id: 5, name: 'John' })
); // false
----- Ord -----
const OrdUserByName = Ord.contramap((user: User) => user.name)(S.Ord);
console.log(
OrdUserByName.compare({ id: 1, name: 'Tom' }, { id: 5, name: 'John' })
);
我們來檢視Eq.contramap的用法,它的第一個參數是User -> number的函數,第二參數的型別是Eq<number>
,輸出的型別則是`Eq,所以Eq.contramap型別變化如下:
Eq.contramap :: (User -> number) -> Eq number -> Eq User
而Ord.contramap的型別變化也是類似:
Ord.contramap :: (User -> number) -> Ord number -> Ord User
如果我們將具備contramap函數的型別集合起來,便可稱它為Contravariant類別,我們將Contramap的HM型別簽名
contramap :: Contravariant f => (a -> b) -> f b -> f a
至於為什麼要取名contramap呢?我們比較Functor類別中map函數的型別簽名。
map:: Functor f => (a -> b) -> f a -> f b
map函數的第一個參數是型別a -> 型別b的函數,回傳的是型別 f a -> f b
contramap的第一個參數是型別a -> 型別b的函數,回傳的是型別 f b -> f a
也就是如果兩者的第一個函數參數的輸出和輸入型別一樣,那麼它們的輸出函數的輸入型別和輸出型別會相反,所以一個稱為map,另一個被稱為contramap。
如果從typescript的實例著手,也是看得出這個關係。
const map = <A>(f: a => b) => Option<A> => Option<B>
const contramap = <A>(f: b => a) => Eq<A> => Eq<B>
有實作contramap函數的介面,我們便稱為Cotravariant Functor,而實作map函數是介面則稱為Covariant Functor,通常簡稱為Functor。fp-ts中只有Eq、Ord和Predicate三個模組有提供contramap函數。
今天介紹了類別模組中的Eq和Ord模組,兩者是相關的類別介面,因此模組中提供的方法也很接近。在HM的函數型別簽名中,如果函數的使用要求某個型別必須是特定的類別時,我們要在符號::和=>之間加入型別的類別限制。最後則比較map和contramap兩個方法,它們都會將函數進行轉換,經過map轉換過的函數,輸入型別和輸出型別和原函數一致;而經過contramap轉換的函數,輸入型別和輸出型別和原函數相反。
今天的分享內容到此為止,明天再見。