この記事は限界開発鯖 Advent Calendar 2022 の 8 日目です. タイトルは釣りです.
はじめに
みなさん, TypeScript で関数型プログラミングしてますか? TypeScript で関数型プログラミングするなら fp-ts というライブラリが存在していますが, これは純粋関数型ライクでどうにも恩恵が得にくくなっています. というのも, TypeScript は
- 関数が参照透過であることを保証できない
- 数学的に厳密な型付けが期待できない
- 高階型などが簡単に表現できない
など純粋関数型の思想がそこまでそぐわないのです. また fp-ts は型情報のタグとして文字列を利用しているため, バンドルした際のスクリプトサイズの増加や乱用によるメモリの圧迫が気になります.
そこでもっと小さくて実用的な関数型プログラミングライブラリが欲しくなりました. ざっと探した感じでは見当たらなかったので, 高階型などの表現を勉強するついでに作ってみました.
前提知識
以下の知識があることを前提に書いています.
- JavaScript の基礎知識
- カリー化したアロー関数がわかる
-
Symbol
を知っている
- TypeScript の
interface
やtype
を使ったことがある
基本設計
先の問題点から, 以下の 3 点を実現することを目指します.
- とにかくシンプルでそのまま使える
- バイナリサイズが小さい
- 型付けが強力
小さいことがコンセプトなので, ライブラリの名前は mini-fn としました.
高階型とは
このライブラリではちょくちょく 高階型 (あるいは高カインド型) というものが登場しますので, それを先に解説しておきます.
まず, TypeScript には型引数という機能があります. そのコードの利用者が任意の型を入れられるように, <T>
のよう書いて型を受け取るものです.
// function の場合
function hoge<T>(foo: T): { foo: T; } {
return { foo };
}
// アロー関数の場合
const hoge = <T>(foo: T): { foo: T; } => ({ foo });
hoge(2); // { foo: 2 }
hoge("bar"); // { foo: "bar" }
hoge([35, 42, "π"]); // { foo: [35, 42, "π"] }
さて, ここで「配列の先頭要素を取り出す, なければ指定されたフォールバックの値を返す」関数を作ってみましょう. 任意の型 T
に対してそれを格納する配列を受け取ろう関数として組むと, このようになります.
<T>(array: T[], fallback: T): T => (
0 in array ? array[0] : fallback
);
// あるいは
<T>(array: Array<T>, fallback: T): T => (
0 in array ? array[0] : fallback
);
このコードを, 配列だけでなく任意の「型引数を 1 つ受け取る型」にも応用したいわけです. そんな型は表現できるのでしょうか?
// ここの `A` を外から自由に指定できるコードにしたい
<T>(array: A<T>, fallback: T): T => (/* ... */);
どうやらそのまま魔法のようにやることは無理そうです. 他の言語 (Haskell など) では言語機能でサポートされたりしていますが, TypeScript では 2022 年現在で特にサポートされていなさそうです.
辞書方式の高階型
そこで, TypeScript の 2 つの性質を利用して, この高階型を再現していきます.
1 つ目は interface
のマージ です. TypeScript では, 同名の interface
を複数記述するとその中身が勝手に結合されます. これは window
の型定義を改造したりするのにも役立ちます.
interface Hoge {
foo: "foo";
}
interface Hoge {
bar: "bar";
}
const x: Hoge = {
foo: "foo",
bar: "bar",
} /* satisifies Hoge */;
2 つ目は 添字アクセスした型 (Indexed Access Type) です. オブジェクトの型に対して, 添字アクセスする文法でその中にあるフィールドの型にアクセスできます.
interface Hoge {
foo: "foo";
bar: "bar";
}
const x: Hoge["foo"] = "bar" /* satisfies Hoge["foo"] */;
これらを用いて, HktDictA1
をこのように定義します. これは「型引数を 1 つ受け取る型」を 新しく登録する ときに使います.
export interface HktDictA1<A1> {}
さらに, それぞれのキーの型と, それに「型引数を適用した型」を取得するための型を用意しておきます.
export type HktKeyA1 = keyof HktDictA1<unknown>;
export type GetHktA1<S, A1> = S extends HktKeyA1 ? HktDictA1<A1>[S] : never;
さて, この辞書に新しい「型引数を 1 つ受け取る型」を登録するときは専用の Symbol
を用意します. 「型引数を 1 つ受け取る型」の代わりに, これを特定する Symbol
をやり取りすることで高階型を実現するのです. HktDictA1
を宣言し, その Symbol
がキーになるように型を登録すれば ok です. HktDictA1
本体は hkt.ts
にあるので, declare module
を使ってマージされるように宣言します.
export type Identity<T> = T;
declare const identityNominal: unique symbol;
export type IdentityHktKey = typeof identityNominal;
declare module "./hkt" {
interface HktDictA1<A1> {
[identityNominal]: Identity<A1>;
}
}
ここで declare const ...: unique symbol
構文を使えば, 実際に Symbol
のオブジェクトとその変数を作成せずに架空の Symbol
とその型を利用できます. これにより 実行時には型情報のためのデータのやり取りが起こらない ため, バンドルした際のファイルサイズの削減などが期待できます.
この辞書から「型引数を 1 つ受け取る型」を取り出すときは, そのキーである Symbol
の型と適用したい型を用意して GetHktA1
に渡せばよいです. これで, 任意の「型引数を 1 つ受け取る型」を適切な型が付いた状態で受け取ることができます!
<T, S extends HktKeyA1>(array: GetHktA1<S, T>, fallback: T): T => (/* ... */);
もちろん, このままだと型引数をもっと受け取るような型には対応できません. 今のところこのライブラリでは, 型引数を 1 つ受け取る場合の HktDictA1
から型引数を 4 つ受け取る場合の HktDictA4
まで (とそれぞれの HktKeyA2
や GetHktA3
など) を手書きして用意しています. もっといい案がございましたらご教授ください.
型クラス
このライブラリでは, 処理を簡潔に書くための機能に溢れた型や関数群を提供しています. それらの間には, 同じように動作する関数が同じ名前で提供してあります. しかし, 特定の型ではなく任意の「その機能を持っている型」を受け取りたいこともあります. そのために, クラスの共通部分を interface
にして取り出すのと同じような感じで, 関数の共通部分を 型クラス として定義しています. 後で紹介するいくつかの機能では, 型クラスに対応した実装である 型インスタンス を提供していることがあります.
さまざまな型クラスを用意してありますが, なかでも基本的かつ便利でよく使うものをいくつかご紹介します.
同値関係
type-class/eq.ts
では, これ と それ が 同じかどうか という考えをより厳密に定めた型クラスとして PartialEq
と Eq
を提供しています. Rust の標準ライブラリにある同名のものとほとんど同じですが, 一応解説します.
PartialEq<A, B>
型クラスは, A
と B
の 2 つの引数を受け取って boolean
を返す関数 eq
が提供されていることを要求します. さらに, eq
が以下の条件を満たすことも要求します.
- 対称律:
PartialEq<A, B>
とPartialEq<B, A>
が常に同じ結果になる. - 推移律:
PartialEq<A, B>
とPartialEq<B, C>
の結果が同じであれば, 常にPartialEq<A, C>
もそれと同じ結果になる.
export interface PartialEq<Lhs, Rhs> {
eq(l: Lhs, r: Rhs): boolean;
}
この条件は すべてのパターンで成り立つ ことを要求しているため, 機械的に検査することは不可能です. PartialEq
のオブジェクトを作成するコードを書いたプログラマー自身が, 論理的に正しく実装できていることを確かめる必要があります.
Eq
型クラスは, PartialEq
の条件に 加えて 次の条件も要求します.
- 反射律:
Eq<A, A>
の引数 2 つに同じ値を渡すと常にtrue
を返す.
こちらもプログラマー自身が, 論理的に正しく実装できていることを確かめる必要があります.
反例として, PartialEq<number, number>
を通常の等価比較 ===
で実装した場合, これはそのままだと Eq<number, number>
にできません.
const numEq: PartialEq<number, number> = ({
eq: (l: number, r: number) => l === r,
});
なぜなら浮動小数点数の仕様上, NaN === NaN
のように同じ値でも false
を返してしまう組み合わせが存在するからです.
モノイド
type-class/monoid.ts
では, モノイドという 機能のあつまり を定義しています. これは次の 2 つから構成されていて,
- 同じ型の値 2 つを合成する演算
combine
- ↑ で合成しても影響しない値
identity
以下の条件を満たすことを要求します.
- 結合律: 任意の
x
,y
,z
に対して,combine(combine(x, y), z)
とcombine(x, combine(y, z))
が同じ結果になる. - 単位元の存在: 任意の
x
に対して,combine(x, identity)
とcombine(identity, x)
がx
そのものと同じ結果になる.
export interface Monoid<T> {
combine(l: T, r: T): T;
readonly identity: T;
}
例えば, 以下のような「足し算」や「掛け算」や「最大値を取る」や「最小値を取る」といった様々な演算をモノイドとして定義できます. 実際にモノイドの定義を満たしているかどうか, 確かめてみてください.
const addMonad = (): Monoid<number> => ({
combine: (l, r) => l + r,
identity: 0,
});
const mulMonad = (): Monoid<number> => ({
combine: (l, r) => l * r,
identity: 1,
});
const minMonoid = (): Monoid<number> => ({
combine: (l, r) => Math.min(l, r),
identity: Number.POSITIVE_INFINITY,
});
const maxMonoid = (): Monoid<number> => ({
combine: (l, r) => Math.max(l, r),
identity: Number.NEGATIVE_INFINITY,
});
機能自体はすごくシンプルですが, 「モノイドならなんでも行ける」アルゴリズムは意外と多いのです. ぜひ関数やクラスの実装に使ってみてください.
関手
こちらは, 高階型に対する型クラスとなります. Functor1
は, 高階型を特定するシンボルの型 S
に対して,
-
T1
を適用した型:GetHktA1<S, T1>
-
T1
をU1
へ変換する関数:(t: T1) => U1
の 2 つから GetHktA1<S, U1>
を作り出す実装を要求します.
export interface Functor1<S extends HktKeyA1> {
map<T1, U1>(fn: (t: T1) => U1): (t: GetHktA1<S, T1>) => GetHktA1<S, U1>;
}
イメージとしては, S
という名前の箱に入っている T1
を, 関数 fn: (t: T1) => U1
を使って U1
に変換する感じです. map
くんは,
-
S
の箱に入っているT1
を取り出す -
fn
にT1
を渡してU1
をゲットする -
S
の箱に詰め直す
という役割を担っています.
fn
T1 ---> U1
|- S -| |- S -|
| | map(fn) | |
| T1 | ----> | U1 |
| | | |
|------| |------|
例えば, Array
や Promise
について以下のような関手の実装を作成できます.
const arrayFunctor: Functor1<arrayHktKey> = {
map: <T1, U1>(fn: (t: T1) => U1) => (t: T1[]): U1[] =>
t.map(fn),
}:
const promiseFunctor: Functor1<promiseHktKey> = {
map: <T1, U1>(fn: (t: T1) => U1) => (t: Promise<T1>): Promise<U1> =>
t.then(fn),
};
FlatMap
まず, flat
(または flatten
) という操作がたまに使われます. これは「型引数を 1 つ受け取る型」が二重になっているもの (Array<Array<number>>
や Promise<Promise<string>>
など) を 平らにならしてネストを解消する (Array<number>
や Promise<string>
にする) ものです.
この flat
と先ほどの関手の map
を組み合わせると, かなり色々な処理を表現できます.
// 数のリストの要素それぞれの隣にそれを 2 乗した数を置く.
[1, 4, 2, 3, 5, 2, 3] // Array<number>
.map((x) => [x, x ** 2]) // Array<Array<number>>
.flat(); // Array<number>
// [1, 1, 4, 16, 2, 4, 3, 9, 5, 25, 2, 4, 3, 9]
// 数のリストから偶数だけを取り除く.
[1, 4, 2, 3, 5, 2, 3] // Array<number>
.map((x) => x % 2 == 0 ? [] : [x]) // Array<Array<number>>, 偶数は空のリストに
.flat();
// [1, 3, 5, 3]
// リスト内の文字列ごとに, それを空白で分割して別々の要素にちぎり分ける
["3rd eye", "Cosmic Mind", "Phantom Ensemble"] // Array<string>
.map((x) => x.split(" ")) // Array<Array<string>>
.flat(); // Array<string>
// ["3rd", "eye", "Cosmic", "Mind", "Phantom", "Ensemble"]
いくつかのデータ構造やオブジェクトでは, もっと効率的に「map
してから flatten
する」処理を実装できることがあります. この処理には flatMap
という名前が付いています. 少し前置きが長くなりましたが, FlatMap
はこの処理を定義するための型クラスとなります.
export interface FlatMap1<S extends HktKeyA1> {
flatMap<T1, U1>(a: (t: T1) => GetHktA1<S, U1>): (t: GetHktA1<S, T1>) => GetHktA1<S, U1>;
}
Array
にはちゃんと flatMap
メソッドが提供されていますし, Promise
の then
は map
と flatMap
両方の働きをします. なのでこれらの FlatMap
実装は以下のようになります.
const arrayFunctor: FlatMap1<arrayHktKey> = {
flatMap: <T1, U1>(a: (t: T1) => U1[]) => (t: T1[]): U1[] =>
t.flatMap(a),
}:
const promiseFunctor: FlatMap1<promiseHktKey> = {
flatMap: <T1, U1>(a: (t: T1) => Promise<U1>) => (t: Promise<T1>): Promise<U1> =>
t.then(a),
};
データ構造
このライブラリでは, 様々な汎用のデータ構造も提供しています. 先の型クラスに対する実装を提供しているものもあります. やはり全てのものは紹介しきれませんが, 日常的に便利そうなものに絞って取り上げます.
Cat
この猫ちゃんは, エサを与える (feed
する) とそれに影響された分身を作ります. 定義そのものはシンプルな interface
です. なお Cat
という名前の由来は, cat
コマンドと同じく conCATenate の略語です.
export interface Cat<T> {
readonly value: T;
feed<U>(fn: (t: T) => U): Cat<U>;
}
これを使うことで, あるオブジェクトを加工する一連の処理を メソッドチェーンで 記述できます. 後で紹介する API を使うときにも, 非常に便利です. fp-ts のような可変長引数のヘルパーで関数結合しまくる手順が不要で, どれだけつなげても型がしっかり付きます.
import { Cat } from "mini-fn";
const result = Cat.cat(-3)
.feed((x) => x ** 2)
.feed((x) => x.toString())
.value;
console.log(result); // "9"
処理途中の値を覗くために, Cat.inspect
というものも提供しています. これに console.log
などのロギング関数を適用した Cat.log
なども用意してあります.
import { Cat } from "mini-fn";
const result = Cat.cat(-3)
.feed(Cat.inspect((value) => console.dir({ value })))
.feed((x) => x ** 2)
.feed(Cat.log)
.feed((x) => x.toString())
.feed(Cat.info)
.value;
以降の API の紹介でも, これをふんだんに利用します. ぜひご活用ください.
Option
Option<T>
は
- 型
T
の値が存在するパターンの型:Some<T>
, - 値が存在しないパターンの型:
None
,
の直和型として定義しています. これらの型は 1 つめの要素にタグの Symbol
を格納したタプルになっていて, "Some"
のような文字列をタグとして格納するよりも経済的です.
const someSymbol = Symbol("OptionSome");
export type Some<T> = readonly [typeof someSymbol, T];
export const some = <T>(v: T): Some<T> => [someSymbol, v];
const noneSymbol = Symbol("OptionNone");
export type None = readonly [typeof noneSymbol];
export const none = (): None => [noneSymbol];
export type Option<T> = None | Some<T>;
Option
には以下のような使いみちがあり, 非常に便利です. T | undefined
にすべきか, それとも T | null
にすべきか, なんて悩まされる必要はありません.
- 変数の遅延初期化
- 引数に突っ込める値によっては正しく計算できない場合の, 関数の戻り値
- ただ存在しないことを 1 種類のエラーとして示すとき
- オブジェクト内の任意のフィールド
- :
もちろんこのままだと取り出したり加工するのが大変ですから, 専用の関数をたくさん提供しています. その中でも重要なものをいくつかお見せします.
型ガード
Option
が実際にどちらかの型であることを確かめるための型ガード関数です. TypeScript がコードの分岐で型を確定させるのに必要です.
export const isSome = <T>(opt: Option<T>): opt is Some<T> =>
opt[0] === someSymbol;
export const isNone = <T>(opt: Option<T>): opt is None =>
opt[0] === noneSymbol;
and
/or
2 つの Option
から &&
や ||
のようにどちらかの Option
を得るための関数です. andThen
は flatMap
と同じ働きをします.
/// `optA` が `Some` なら `optB` を返す. それ以外は `None` を返す.
export const and:
<U>(optB: Option<U>) => <T>(optA: Option<T>) => Option<U>;
/// `optA` が `Some` ならその中身を `optB` に転送する. それ以外は `None` を返す.
export const andThen:
<T, U>(optB: (t: T) => Option<U>) => (optA: Option<T>) => Option<U>;
/// `optA` が `None` なら `optB` を返す. それ以外は `optA` を返す.
export const or:
<T>(optB: Option<T>) => (optA: Option<T>) => Option<T>;
/// `optA` が `None` なら `optB()` を返す. それ以外は `optA` を返す.
export const orElse:
<T>(optB: () => Option<T>) => (optA: Option<T>) => Option<T>;
引数の順番が逆やんけ と感じるかもしれませんが, これは Cat
でたくさんつなげていくための処置です. 一般的に, 関数型の書き方では 関心の対象 (this
相当にしたいもの) を一番最後に置く ようにします. こうすることで関数どうしの結合が簡単になります.
Cat.cat(Option.none())
.feed(Option.and(Option.some("foo")))
.value; // None
Cat.cat(Option.some(2))
.ffeed(Option.and(Option.some("foo")))
.value; // Some<string>
Cat.cat(Option.none())
.feed(Option.or(Option.some("foo")))
.value; // Some<string>
Cat.cat(Option.some(2))
.ffeed(Option.or(Option.some("foo")))
.value; // Some<number>
unwrapOr
フォールバックの値を用意すれば, Some
と None
のどちらかをこちらで判定せずに値を取り出せます. unwrapOrElse
はフォールバックの値が必要なときだけその関数を実行します.
export const unwrapOr:
<T>(init: T) => (opt: Option<T>) => T;
export const unwrapOrElse:
<T>(fn: () => T) => (opt: Option<T>) => T;
Cat.cat(Option.some("car"))
.feed(Option.unwrapOr("bike"))
.value; // "car"
Cat.cat(Option.none())
.feed(Option.unwrapOr("bike"))
.value; // "bike"
Cat.cat(Option.some(4))
.feed(Option.unwrapOrElse(() => 2 ** 7))
.value; // 4
Cat.cat(Option.none())
.feed(Option.unwrapOrElse(() => 2 ** 7))
.value; // 128
map
値が Some
なら, 渡された関数を使って中に格納されている値を変換します. 予想外の挙動になることがありますので, 元の値に変更を加えるような関数を渡すことは避けてください.
export const map:
<T, U>(f: (t: T) => U) =>
(opt: Option<T>) => Option<U>;
Cat.cat(Option.some("Hello, world!"))
.feed(Option.map((str) => str.length))
.value; // Some(13)
なお, これと unwrapOr
系の関数をくっつけた mapOr
や mapOrElse
も提供しています.
Result
Option
ではなにかが存在するかしないかだけを表していました. しかし, より具体的にどんな失敗が起きたのかを知って対処したいこともあります. いわゆるエラーハンドリングを行うために, Result<T>
は
- 正常な値:
Ok<T>
, - 対処できるエラー:
Err<E>
,
の直和型として定義しています. なお, ok
と err
の戻り値型を Ok
や Err
ではなく Result
にしてあります. こうしないと, 間違った型引数の Result
へ型変換されることがあるからです.
const okSymbol = Symbol("ResultOk");
export type Ok<T> = readonly [typeof okSymbol, T];
export const ok = <E, T>(v: T): Result<E, T> => [okSymbol, v];
const errSymbol = Symbol("ResultErr");
export type Err<E> = readonly [typeof errSymbol, E];
export const err = <E, T>(e: E): Result<E, T> => [errSymbol, e];
export type Result<E, T> = Err<E> | Ok<T>
型引数の順序が <E, T>
になっているのは, 型クラスを実装するときの型引数の適用順序の都合によるものです. カリー化した引数の順序と同じように, 主要なものが一番最後にある方が都合がいい のです.
型ガード
Option
とおなじく, 実際にどちらかの型であることを確かめるための型ガード関数です.
export const isOk = <E, T>(res: Result<E, T>): res is Ok<T> =>
res[0] === okSymbol;
export const isErr = <E, T>(res: Result<E, T>): res is Err<E> =>
res[0] === errSymbol;
map
/mapErr
Option
の map
と同じように, Ok
や Err
それぞれの場合において, 中の値を加工する関数 map
や mapErr
を用意しています.
export const map:
<T, U>(fn: (t: T) => U) =>
<E>(res: Result<E, T>) => Result<E, U>;
export const mapErr:
<E, F>(fn: (t: E) => F) =>
<T>(res: Result<E, T>) => Result<F, T>;
and
/or
2 つの Result
から &&
や ||
のようにどちらかの Result
を得るための関数です. andThen
は flatMap
と同じ働きをします.
/// `resA` が `Ok` なら `resB` を返す. それ以外は `resA` (これは必ず `Err`) を返す.
export const and:
<U, E>(resB: Result<E, U>) =>
<T>(resA: Result<E, T>) => Result<E, U>;
/// `resA` が Ok` ならその中身を `fn` に転送する. それ以外は `resA` (これは必ず `Err`) を返す.
export const andThen:
<T, U, E>(fn: (t: T) => Result<E, U>) =>
(resA: Result<E, T>) => Result<E, U>;
/// `resA` が `Err` なら `resB` を返す. それ以外は `resA` (これは必ず `Ok`) を返す.
export const or:
<E, T>(resB: Result<E, T>) =>
(resA: Result<E, T>) => Result<E, T>;
/// `resA` が `Err` ならその中身を `fn` に転送する. それ以外は `resA` (これは必ず `Ok`) を返す.
export const orElse:
<E, T, F>(fn: (error: E) => Result<F, T>) =>
(resA: Result<E, T>) => Result<F, T>;
either
/unwrapOr
フォールバックの処理を用意すれば, こちらで Ok
か Err
かの判定をせずに値を取り出せます.
/// `Ok` なら `f` を, `Err` なら `g` を使うことで, どちらの場合であっても値を取り出す.
export const either:
<E, R>(g: (e: E) => R) =>
<T>(f: (t: T) => R) =>
(res: Result<E, T>) => R;
/// `Ok` と `Err` に同じ型が格納されるとき, どちらなのかを無視してその値を取り出す.
export const mergeOkErr: <T>(res: Result<T, T>) => T;
/// `Ok` ならその中身を, それ以外は `init` を返す.
export const unwrapOr:
<T>(init: T) =>
<E>(res: Result<E, T>) => T;
/// `Ok` ならその中身を, それ以外は `fn()` を返す.
export const unwrapOrElse:
<T>(fn: () => T) =>
<E>(res: Result<E, T>) => T
Option
との相互変換
Result
を Option
に変換したり, その逆をしたりすることもできます.
/// `Result` が `Ok` なら, それを取り出して `Some` にする. それ以外は `None` を返す.
export const optionOk:
<E, T>(res: Result<E, T>) => Option<T>;
/// `Result` が `Err` なら, それを取り出して `Some` にする. それ以外は `None` を返す.
export const optionErr:
<E, T>(res: Result<E, T>) => Option<E>;
/// `Option` が `Some` なら, それを取り出して `Ok` にする. それ以外は `err` が入った `Err` を返す.
export const okOr:
<E>(err: E) =>
<T>(opt: Option<T>) => Result<E, T>;
/// `Option` が `Some` なら, それを取り出して `Ok` にする. それ以外は `err()` が入った `Err` を返す.
export const okOrElse:
<E>(err: () => E) =>
<T>(opt: Option<T>) => Result<E, T>;
Result
と Option
は, しばしば一緒に使われます. そこで Option<Result<E, T>>
や Result<E, Option<T>>
のようなものを相互変換する関数も提供してあります.
/// `Result<E, Option<T>>` を `Option<Result<E, T>>` に変換する.
/// - `Ok(None)` -> `None`
/// - `Ok(Some(value))` -> `Some(Ok(value))`
/// - `Err(error)` -> `Some(Err(error))`
export const resOptToOptRes:
<E, T>(resOpt: Result<E, Option<T>>) => Option<Result<E, T>>;
/// `Option<Result<E, T>>` を `Result<E, Option<T>>` に変換する.
/// - `None` -> `Ok(None)`
/// - `Some(Ok(value))` -> `Ok(Some(value))`
/// - `Some(Err(error))` -> `Err(error)`
export const optResToResOpt:
<E, T>(optRes: Option<Result<E, T>>,) => Result<E, Option<T>>;
State
まず State
の動機づけとして, 疑似乱数を使った処理を 副作用なしで 作ってみます. 疑似乱数の生成には 前の擬似乱数の状態 が必要で, それをあげたら 生成した疑似乱数 と 新しい擬似乱数の状態 が返ってくるものとします.
interface RngState { /* ... */ }
const genRandom: (prevState: RngState): => readonly [number, RngState];
これを愚直に運用しようとすると, なかなか大変なことになります. genRandom
を使う処理はすべて, RngState
を受け取ってはそれを突っ込んで, 戻り値の状態も返す必要があります. とても手で書いていられません.
const rollFourTimesAndSum = (state: RngState): readonly [number, RngState] => {
const [num1, state1] = genRandom(state);
const [num2, state2] = genRandom(state1);
const [num3, state3] = genRandom(state2);
const [num4, state4] = genRandom(state3);
return [num1 + num2 + num3 + num4, state4];
};
State
は, こういった状態付き計算を簡潔に書くためのものです. State
の定義はこのようになっていて, 状態付き計算をする関数 (あなたが作成する関数) そのものを意味します. (注意として, 型引数が <S, A>
なのに対して戻り値の順番は [A, S]
です.)
export interface State<S, A> {
(state: S): [A, S];
}
これをどう使うのかと言うと, 「状態付き計算がしたいなら状態を受け取って返す」のではなく, 「State
を作って/合成して返す」ようにするのです. やっていることとしてはなにも変わっていませんが, State
には便利な関数が用意してあります. これらを使って, 簡単に State
を加工したり State
どうしを結合したりできます!
State を使ってみる
先に使用例を見せます. Xorshift 疑似乱数生成器を State<number, number>
として作成し, 乱数を 3 つ生成して取り出してみます.
const xorShiftRng =
(): State.State<number, number> =>
(state: number): [number, number] => {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
return [state, state];
};
const seed = 1423523;
const bound = Monad.bindT(State.monad)(xorShiftRng);
const results = Cat.cat(Monad.begin<State.StateHktKey, number>(State.monad))
.feed(bound("result1"))
.feed(bound("result2"))
.feed(bound("result3"))
.feed(State.evaluateState)
.value(seed);
console.dir(results);
// {
// result1: 1463707459,
// result2: -519004248,
// result3: -1370047078,
// }
State の作り方と基本操作
State
の引数 state
は文字通り, 状態付き計算に使う前の状態です. 戻り値は [生成した新しい値, 次の状態]
というタプル (TypeScript では配列と同じ) です. Xorshift 疑似乱数では生成した乱数そのものが状態なので, 両方に同じ値を入れた [state, state]
になっています.
const xorShiftRng =
(): State.State<number, number> =>
(state: number): [number, number] => {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
return [state, state];
};
こんな単純な定義ですので, State
をそのまま関数として呼び出せます. しかし, この使い方は少々もったいないです.
const rng = xorShiftRng();
console.log(rng(1423523)); // [1463707459, 1463707459]
mapState/withState
上の乱数生成器はかなりバラバラな値を生成するので, そのままだと使いづらいです. せめて絶対値を取って 0 以上の値に加工したいところです. State
の出力を加工するために, State.mapState
が用意されています. この加工処理は, 元となる State
の計算後に行われます.
const nonNegRng =
(): State.State<number, number> =>
State.mapState(
([state]: [number, number]): [number, number] =>
[Math.abs(state), state]
)(xorShiftRng());
逆に, State.withState
を使えば State
の計算前に処理を割り込ませて状態を書き換えることもできます. ここでは乱数の正負を反転させてから計算させてみます.
const invertRng =
(): State.State<number, number> =>
State.withState((x: number): number => -x)(xorShiftRng());
put/get
ここまでは既存の State
を加工していただけですが, そもそも状態を自由に書き換えるようなことをしても許されます. 前後に処理を自由に割り込ませられますからね. もはや, 値を取り出したり入れたりする魔法のように使うこともできます.
State.put
は, 指定の値を格納した State
を作成します.
const put = State.put(2);
State.get
は, 格納されている状態を返す State
を作成します.
const getter = State.get<number>(); // 型引数は推論できないため必須
これらを Cat
で組み合わせると, 変数が無いのにどこかから値を取り出しては入れるようなコードが完成します. 実際には State
がやっているのですが, モナドがこれを覆い隠しています.
// 足し算だけで 20 倍する処理
// x20 = x10 + x10
// x10 = x8 + x2
// x8 = x4 + x4
// x4 = x2 + x2
// x2 = x1 + x1
const twentyTimes = (x: number): number =>
Cat.cat(State.put(x + x))
.feed(State.flatMap(State.get<number>))
.feed(State.map((x2: number) => x2 + x2))
.feed(State.map((x4: number) => x4 + x4))
.feed(
State.flatMap((x8: number) =>
State.map<number, number, number>((x2: number) => x8 + x2)(State.get<number>()),
),
)
.feed(State.map((x10: number) => x10 + x10))
.value(0 /* これは初期状態 */)[0];
evaluate/execute
さて State
を実際に動かしてみると, 実行した後に [0]
や [1]
で生成した値や状態を取り出すことになります. パッと見だと何をしているか分かりにくいマジックナンバーになるので, 実行したら生成した値/最後の状態が出力されるようにできる関数 State.evaluateState
/State.executeState
をそれぞれ用意しています. 先の例の最後の 2 行はこれでもよいわけです.
// ...
.feed(State.map((x10: number) => x10 + x10))
.feed(State.evaluateState)
.value(0 /* これは初期状態 */);
begin と bindT
State
の最初の乱数を 3 つ作る例で, Monad.begin
と Monad.bindT
が出てきました. これは複数の計算結果それぞれを変数に割り当てるようなコードを書くためのものです. 20 倍する処理のように取り出す時に割と苦労しますからね.
Monad.begin
では, 空のオブジェクト {}
を格納したモナドを作成します.
// ... ここに大量のオーバーロード
export function begin<S extends symbol>(m: Monad<S>): Hkt<S, object> {
return m.pure({});
}
Monad.bindT
はこのオブジェクトに, 計算結果を指定のキーへ格納する関数を作ります.
// オブジェクトの型 `A` にキー `NK` で値 `B` のフィールドを追加する
export type Append<A extends object, NK extends PropertyKey, B> = A & {
readonly [K in keyof A | NK]: K extends keyof A ? A[K] : B;
};
export function bindT<S>(
m: Monad1<S>,
): <B>(
f: () => GetHktA1<S, B>, // 紐付けたい関数
) => <NK extends PropertyKey>(
name: NK, // 割り当てたいキー
) => <A extends object>(ma: GetHktA1<S, A>) => GetHktA1<S, Append<A, NK, B>>;
// ... ほか大量のオーバーロードと本体の実装
そして, この bindT
で生成した関数をつなげるだけで複数の計算をどんどん変数に割り当てられるのです. モナドの強みが発揮されている使い方の 1 つです.
Cat.cat(Monad.begin<State.StateHktKey, number>(State.monad))
.feed(bound("result1"))
.feed(bound("result2"))
.feed(bound("result3"));
// `State<number, { result1: number; result2: number; result3: number; }>`
Identity
Identity
は, 特殊で何も余分な構造を持たない型です. もちろんモナドも用意されています.
declare const identityNominal: unique symbol;
export type IdentityHktKey = typeof identityNominal;
export type Identity<T> = T;
こんなものが何の役に立つんだと思うでしょうが, モナド変換子 において使いみちがあります. モナド変換子というのは, モナドを格納したりモナドを返す関数だったりする型のことです. 例えば, 先程の State
のモナド変換子版である StateT
(大文字の T は変換子, transformer の略) は以下のように定義されています.
export interface StateT<S, M, A> {
(state: S): GetHktA1<M, [A, S]>;
}
この StateT
の M
に Identity
の高階型辞書のキー IdentityHktKey
を入れれば, State
が得られます. GetHktA1<Identity.IdentityHktKey, T>
は T
と同じですからね. 実際に, mini-fn での State
は正確には以下のように定義してあります.
export type State<S, A> = StateT<S, Identity.IdentityHktKey, A>;
その他のデータ構造たち
この辺りで紹介は終わりにしますが, 実装してあるものの既存の素晴らしい解説があるため話題に挙げるほどでもないデータ構造を列挙しておきます.
- ユーティリティ関数
- 恒等関数
id
- 定数関数
constant
- 任意の型を返すと見なせるが例外を投げる関数
absurd
- 関数合成
compose
- 引数順序の反転
flip
- 任意個の引数の関数をカリー化する
curry
- 恒等関数
- 再帰的凍結オブジェクト
Frozen
- タプル (正確にはペア)
Tuple
- 無限長リストあるいはイテレータ
List
- Free モナド
Free
- Reader モナド
Reader
- Write モナド
Writer
- 継続モナド
Cont
- さまざまなモナドの拡張
MonadPromise
,MonadState
,MonadCont
, ... - :
使用にあたって
Option
や Result
を使う程度なら, 好きなところで使い始められるでしょう. 実行結果として取り出したり map
や andThen
を使って加工したりすれば, 強力な機能とともに確実なハンドリングが期待できます.
State
などの重厚かつ結合を前提としたモナドを使う場合は, プロジェクト全体に影響を及ぼします. MonadPromise
を用いて任意のモナドを Promise
に昇格させることで既存のコードと混ぜることもできますし, アプリの中核となるロジック全域にモナドを浸透させることもできます. 慎重に検討してご採用ください.
npm パッケージはこちらです. https://www.npmjs.com/package/@mikuroxina/mini-fn
今後の展望
現状は, 関手やモナドの型を受け取る際にその実装テーブルを受け取るようになっています. 高階型のキーから実装テーブルを辞書引きするようなことができればよいのですが, 今のところは上手い実装を思いつきません.
また, 名前空間で import
/export
したつくりになっているので Tree-Shaking (未使用のコードを除去する最適化) がうまく動かないと思われます. しかし代わりに大量の import
が必要になるような開発体験は避けたいので, 再 export
のコードをもう少し親切にするなど, より最小限のバイナリを作る工夫をしたいところです.
今のところテストも全然足りないので, 需要がある機能から優先して単体テストを用意しておきたいです.
感想
なんだか英文を直訳したようなカタコトな記事なってしまいました. 慣れない敬語で長文を書くものじゃないですね.