iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png
今天我們要開始介紹類別(Type Class)型的模組,類別模組內的函數比較少,不同的類別代表不同的數學概念。首先登場的是Eq和Ord兩個關係的類別。

Eq

一個輸出值為布林值的函數(也就是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這個類別介面就是三一律的具體實作,很明顯的,一個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' }));

Contramap

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轉換的函數,輸入型別和輸出型別和原函數相反。

今天的分享內容到此為止,明天再見。


上一篇
Day 20. fp-ts綜合練習
下一篇
Day 22. ADT-Algebraic Data Type
系列文
數學老師學函數式程式設計 - 以fp-ts啟航22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言