TypeScriptで学ぶ古典的ADT - Maybe
本記事はこの記事の続きです。Maybe
を改めて紹介します。
代数的データ型をコードで表現するために次のコード群を使用します。
type Coproduct<T> = {
[K in keyof T]: Record<"type", K> & T[K];
}[keyof T];
type Individual<TCoproduct extends Record<"type", keyof any>, Tag> = Extract<
TCoproduct,
Record<"type", Tag>
>;
type Match<TCoproduct extends Record<"type", keyof any>, Tag> = Omit<
Individual<TCoproduct, Tag>,
"type"
>;
function match<TCoproduct extends Record<"type", keyof any>, TOut>(
value: TCoproduct,
patterns: {
[K in TCoproduct["type"]]: (param: Match<TCoproduct, K>) => TOut;
},
): TOut {
const tag: TCoproduct["type"] = value.type;
return patterns[tag](value as any);
}
-
Coproduct<T>
は代数的データ型の「または」の型を表すためのヘルパーです。実装はtype
というタグをつけたオブジェクトの型のユニオン[1]です。 -
Individual<TCoproduct, Tag>
は「または」の型の個別の型を表すためのヘルパーです。 -
match(value, patterns)
は「または」の型に対するパターンマッチングを行うためのヘルパーです。
Maybeの無い世界
Maybe<T>
は「型T
の値がある」または「値が何らかの理由で無い」ことを表す値の型です。
例えば、Array.prototype.indexOfを見てみます。
このメソッドは指定された要素が配列の中にある場合はそのインデックスを返しますが、見つからなければ-1
を返します。
const arr = ["apple", "banana", "cherry"];
const indexOfBanana: number = arr.indexOf("banana"); // 1(0から数えて1番目)
const indexOfZucchini: number = arr.indexOf("zucchini"); // -1(存在しない)
さて、ここでメソッドの返り値を受けた変数indexOfBanana: number
に注目しましょう。
この値が-1
かどうかをif
文などで条件分岐するのはプログラムを書く人の責務です。
const arrHasBanana: boolean = indexOfBanana >= 0;
if (arrHasBanana) {
console.log(`${indexOfBanana + 1}番目にbananaがあります。`);
} else {
console.log(`bananaがありませんでした。`);
}
もう1つ例を挙げましょう。
parseInt関数は文字列を数値に変換しますが、できなければNaN
を返します。
// 第2引数の 10 は十進法を表す
const parse42: number = parseInt("42", 10); // 42
const parseabc: number = parseInt("abc", 10); // NaN
パーズに成功したかどうかをif
文などで条件分岐するのはプログラムを書く人の責務です。
※NaN: number
です。
// parseSomething: number(※NaNの場合もあり得る)
if (!Number.isNaN(parseSomething)) {
// 成功
} else {
// 失敗
}
このように、プログラムを書く人が暗黙の裡にやらなければならないことが発生します。
これらの処理に漏れがあるとそれはバグを引き起こす要因となります。
Maybe<T>の定義
そこで、Maybe<T>
を定義します。
type Maybe<T> = Coproduct<{
Just: { value: T };
Nothing: {};
}>;
これは次のコードと同じです。
(詳細は前回の記事を参照してください。)
type Maybe<T> =
| { type: "Just"; value: T }
| { type: "Nothing" };
つまり、Maybe<T>
型の値は、Just
というタグかNothing
というタグがついており、
前者の場合は更にT
型の値をvalue
に持ちます。
Maybe<T>
型の値を作成する方法は、単にそのようなオブジェクトを作成すればOKです。
const just42 = { type: "Just", value: 42 } as Maybe<number>;
これを行うためにヘルパー関数を用意しましょう。
※本記事では敢えてCamelCaseで命名します。
function Just<T>(value: T): Maybe<T> {
return { type: "Just", value };
}
const Nothing: Maybe<never> = { type: "Nothing" };
次のように使うことができます。
const just42 = Just(42); // { type: "Just", value: 42 }
Maybe<T>を返す関数
さて、先ほどのArray.prototype.indexOf
をラップしてMaybe<number>
を返す関数を作成してみます。
配列の中に所望の要素があれば成功と見なし、Just
というタグを付けてその要素のインデックスを返します。
要素が無ければ失敗と見なし、Nothing
というタグを付けた空のオブジェクトを返します。
function indexOfArray<T>(item: T, arr: T[]): Maybe<number> {
const index = arr.indexOf(item);
if (index >= 0) {
return Just(index);
} else {
return Nothing;
}
}
先ほどの例でこの関数を使ってみましょう。
const arr = ["apple", "banana", "cherry"];
const maybeIndexOfBanana = indexOfArray("banana", arr); // Just(1)
const maybeIndexOfZucchini = indexOfArray("zucchini", arr); // Nothing
さて、ここで関数の返り値maybeIndexOfBanana: Maybe<number>
に注目しましょう。
この型はMaybe<number>
なので、number
型の値がある場合とない場合があり得ることが分かります。
次に、作成したMaybe<T>
型の値を使ってみましょう。
「または」の型を使う際は、冒頭で定義したヘルパー関数match
を使います。
match(maybeIndexOfBanana, {
Just: ({ value }) => {
// Justのとき、すなわち要素があったとき
// valueにインデックスが格納されている
console.log(`${value + 1}番目にbananaがあります。`);
},
Nothing: (_) => {
// Nothingのとき、すなわち要素が無かったとき
console.log(`bananaがありませんでした。`);
},
});
maybeIndexOfBanana
から直接value
を取り出すことはできません。
これは、Nothing
である場合があり得るからです。
したがって、インデックスを知るには必ずmatch
などを用いて場合分けを行う必要があります。
match
を使う恩恵として、場合分けに漏れがあるとコンパイルエラーとなります。
例えば、配列の中に要素が無かった場合の処理をうっかり忘れてしまったとします。
match(maybeIndexOfBanana, {
Just: ({ value }) => {
console.log(`${value + 1}番目にBananaがあります。`);
},
});
これはコンパイルエラーとなります。
場合分け漏れによるコンパイルエラー
赤枠で囲んだところに漏れている場合が表示されます。
したがって、次のコードのように自力で場合分けをするような処理はアンチパターンです。
// 👎 NG
if (maybeIndexOfBanana.type === "Just") {
const indexOfBanana = maybeIndexOfBanana.value
// ...
}
// maybeIndexOfBanana.type === "Nothing"の場合の
// 処理漏れを引き起こす可能性があるためNG
// matchを使うのが良い
これでは敢えてMaybe<T>
型を導入する利点がありません。
if
文による場合分けは避け、代わりにmatch
を使うようにしましょう。
ここまでの話を踏まえて、Maybe<T>
を導入する意義をまとめます。
Maybe<T>
を使うと、その中の値value: T
にアクセスするために場合分けが強制されます。
その場合分けをmatch
を使って行うことで場合分けの処理漏れを防ぐことができます。
(練習問題)
Array.prototype.indexOf
のときと同様に、parseInt
をラップしてMaybe<number>
を返す関数safeParseInt
を作成してください。基数は10
とします。
declare function safeParseInt(str: string): Maybe<number>;
解答例`safeParseInt`
function safeParseInt(str: string): Maybe<number> {
const result = parseInt(str, 10);
if (!Number.isNaN(result)) {
return Just(result);
} else {
return Nothing;
}
}
型安全性の追及
さて、safeParseInt
を使うとパーズ結果としてnumber
ではなくMaybe<number>
を返すため、
パーズに成功したか失敗したかという場合分けをする際にmatch
を使うことで
漏れなく処理できるということが分かりました。
しかし、Maybe<number>
の中身は依然としてnumber
型であるので、
型だけ見ると「NaN
が含まれるかもしれない」という情報が含まれています。
実際にはNaN
にならないように条件分岐しているので、これは不満です。
NaN
値を取り得ないことをどうしても強調したい場合、
新しい型を定義することで実現できます。
/**
* (NaNやInfinityでない)有限な数値を表す型(のつもり)
*/
type FiniteNumber = number & { FiniteNumber: never }
FiniteNumber
型はFiniteNumber
というタグを付けたnumber
です。
したがって、TypeScriptコンパイラはFiniteNumber
であればnumber
でもあるということを認識できます。
また、FiniteNumber
であるためには少なくともnumber
でなければならないということも認識できます。
const bool = true as FiniteNumber; // numberでないのでコンパイルエラー
const str = "42" as FiniteNumber; // 同じくコンパイルエラー
const num = 42 as FiniteNumber; // これはOK
const fourtySeven = num + 5; // FiniteNumberはnumberでもあるのでOK
しかし、FiniteNumber
型を「(NaNやInfinityでない)有限な数値を表す型にしたい」という
人間の気持ちまでは汲み取ってくれないため、FiniteNumber
型をそのような型に保つのは
プログラムを書く人の責任[2]となります。
const fourtyTwo = 42 as FiniteNumber; // これはOK
const evilNumber = Infinity as FiniteNumber; // 本来NGだがコンパイルできてしまう😈
つまり、FiniteNumber
へのキャストは細心の注意を払う必要があります。
今回はnumber
型の値が有限かどうかをNumber.isFinite
関数で判定し、
有限であればJust
、そうでなければNothing
のタグを付けて返す関数を作成します。
function safeFinite(n: number): Maybe<FiniteNumber> {
if (Number.isFinite(n)) {
// nは有限なのでキャストしても良い
return Just(n as FiniteNumber);
} else {
return Nothing;
}
}
試しにFiniteNumber
の絶対値を取得する関数を書いてみましょう。
引数をFiniteNumber
に限定することにより、返り値の範囲も狭めることができます。
function finiteAbs(n: FiniteNumber): FiniteNumber {
// Math.abs(NaN)はNaNを返し、Math.abs(Infinity)はInifinityを返すので
// 本来Math.absの返り値の型としてはnumberが相応しいです
//
// しかし、NaNやInfinityはFiniteNumber型には含まれない(ことになっている)ので
// Math.absの引数にNaNやInfinityが入ることはありません
//
// 以上により、FiniteNumber値の絶対値をFiniteNumberにキャストしても良い
return Math.abs(n) as FiniteNumber;
}
それでは使ってみましょう。
finiteAbs(NaN); // コンパイルエラー
finiteAbs(-42) // 残念ながらこれもコンパイルエラー
const maybeFinite = safeFinite(-42);
match(maybeFinite, {
Just: ({ value }) => {
// safeFiniteを通過した値のみ引数に使うことができる
console.log(finiteAbs(value)); // 42
},
Nothing: (_) => {
console.log("数値が正しくありません。");
},
});
このように、finiteAbs(-42)
もコンパイルエラーとして弾かれてしまう[3]のが玉に瑕ですが、
その引き換えとして型に追加情報を含ませることができます。
文字列からFiniteNumber
型へのパーズ関数parseFiniteInt
とparseFiniteFloat
を書いてみます。
function parseFiniteInt(str: string): Maybe<FiniteNumber> {
const result = parseInt(str, 10);
return safeFinite(result);
}
function parseFiniteFloat(str: string): Maybe<FiniteNumber> {
const result = parseFloat(str);
return safeFinite(result);
}
ドメインロジックの記述
先ほどの例のようにわざわざFiniteNumber
という型を定義して、
多少の不便を負ってでも型安全性を重視したいというのは稀でしょう。
しかし、ドメインロジックを潔癖に保っておくことはしばしば有用です。
例えば、年齢Age
を考えます。
年齢を表すために使う型は素朴に考えるとnumber
ですが、
number
型はNaN
、-100
、3.14
、100058
などの様々な値を許します。
このそれぞれに歳
をつけてみましょう。
NaN歳
、-100歳
、3.14歳
、100058歳
等々、異様です。
年齢が整数でなかったり、マイナスであったり、150
を越える場合は失敗扱いにしたいとします。
number
型の数値が今述べた基準で年齢として妥当かどうかを判定する関数を書いてみます。
function isValidAsAge(num: number): boolean {
return 0 <= num && num <= 150;
}
典型的なコードは次のようにif
文で分岐して、年齢として妥当な場合の処理と
年齢として妥当ではない場合の処理を記述することです。
// rawInput: number
// 例えばJSONから拾ってきた数値であったり、ユーザー入力から取得してきた数値
if (isValidAsAge(rawInput)) {
// OK
// 正常処理
} else {
// 年齢として妥当ではない
// 例外的な処理
}
ここで、場合分け漏れによりrawInput
をそのまま使ってしまうと不具合の原因になります。
そこで、先ほどのトリックを使い、Age
型を新たに定義します。
type Age = number & { Age: never };
そして、Age
型の値を作成するための関数を定義します。
function createAge(num: number): Maybe<Age> {
if (isValidAsAge(num)) {
return Just(num as Age);
} else {
return Nothing;
}
}
これにより、match
によるコーディングが可能になります。
// rawInput: number
// 例えばJSONから拾ってきた数値であったり、ユーザー入力から取得してきた数値
const maybeAge = createAge(rawInput);
match(maybeAge, {
Just: ({ value }) => {
// OK
// 正常処理 value: Age
},
Nothing: (_) => {
// 年齢として妥当ではない
// 例外的な処理
},
});
また、Age
を要求する箇所ではnumber
と区別されるため、これにより堅牢性が増します。
例えばAge
型の値を引数にとる関数を作成してみます。
function toStringAge(age: Age): string {
return `あなたは${age}歳です。`;
}
createAge
により検証を通過した値でなければこの関数の引数に使うことができないため、
裏を返せばtoStringAge
を使えば妥当な年齢のみ文字列化できることになります。
toStringAge(100000000); // コンパイルエラー
const maybeAge = createAge(100000000);
match(maybeAge, {
Just: ({ value }) => {
// value: Age
// 検証を通った数値のはず
console.log(toStringAge(value));
},
Nothing: (_) => {
console.log(`処理に失敗しました。`);
}
})
これにより、Age
型を定義する前は「年齢を扱う際は必ずisValidAsAge
を使って検証する」という暗黙のルールがありましたが、
Age
型を定義した後は「年齢を使う際はcreateAge
を使ってAge
型の値を生成する」という暗黙のルールに変わりました。
前者の暗黙のルールはプログラムを書く人が忘れずに守るよう努力しなければなりませんが、
後者の暗黙のルールはTypeScriptコンパイラによる型チェック機能があるため、
プログラムを書く人が意図的にAge
やany
へのキャストを行わない限りルールが破られることはありません。
Maybe<T>のヘルパー関数
Maybe<T>
型の値を作成するためのJust
とNothing
および、
Maybe<T>
型の値を分解して使用するためのmatch
がMaybe<T>
を扱う上での基本的パーツです。
これらだけでもMaybe<T>
型を扱うことができますが、もっと便利に使えるようにヘルパー関数を作成していきます。
最終的にはmatch
を直接使うことなしに[4]ほとんどのコードを書けるようになります。
型Tの値と述語からMaybe<T>の値を作成する
Maybe<T>
を使う際は、undefined
やNaN
などの特別な値をNothing
扱いにしたい場合が多いです。
T
型の値を既に持っていて、その値を何らかの条件でJust
かNothing
かを振り分けてMaybe<T>
にするというのは頻出パターンです。
T
型の値がJust
かNothing
かを表すようなコードは、T
からboolean
への関数で表すことができます。
これは述語と呼ばれることもあります。
Just
のとき、すなわち値があるときtrue
を返し、Nothing
のときにfalse
を返すような述語からMaybe<T>
を作成する関数fromPredicate
を作成します。
function fromPredicate<T>(pred: (value: T) => boolean, value: T): Maybe<T> {
if (pred(value)) {
return Just(value);
} else {
return Nothing;
}
}
pred の返り値 |
fromPredicate の返り値のタグ |
---|---|
true |
"Just" |
false |
"Nothing" |
これを用いると、先ほど作成したindexOfArray
関数は次のようにも実装できます。
const indexOfArray = <T>(item: T, arr: T[]): Maybe<number> =>
fromPredicate((index) => index >= 0, arr.indexOf(item));
このように、fromPredicate
を使うと、ヘルパー関数であるfromPredicate
の中に
if
文を閉じ込めることができます。(広義のカプセル化)
(練習問題)
関数safeParseInt
を、fromPredicate
ヘルパーを用いて再実装してください。
declare function safeParseInt(str: string): Maybe<number>;
解答例`safeParseInt`
const safeParseInt = (str: string) =>
fromPredicate((int) => !Number.isNaN(int), str);
Maybe<T>の値と述語からタグを付け直す
先ほどはT
型の値からMaybe<T>
型の値を作成しました。
では、既にMaybe<T>
型の値を持っていた場合はどうでしょう?
例えば、maybeAge: Maybe<Age>
を持っているとしましょう。
// rawInput: number
// 例えばJSONから拾ってきた数値であったり、ユーザー入力から取得してきた数値
const maybeAge: Maybe<Age> = createAge(rawInput);
年齢が60歳以上のときのみ正常処理を行い、
もともとの数値が年齢として正しくなかったり、年齢としては妥当であっても20歳未満であったりした場合には同じ処理を行うとします。
match(maybeAge, {
Just: ({ value }) => {
if (value >= 60) {
// 正常処理
console.log(`あなたは${value}歳です`);
} else {
console.log(`シルバー会員は60歳からです`); // 同じ処理
}
},
Nothing: (_) => {
console.log(`シルバー会員は60歳からです`); // 同じ処理
},
});
ここで、Maybe<T>
と述語からMaybe<T>
を作成するヘルパー関数filterMaybe
を作成してみます。
function filterMaybe<T>(
pred: (value: T) => boolean,
mvalue: Maybe<T>,
): Maybe<T> {
return match(mvalue, {
Just: ({ value }) => fromPredicate(pred, value),
Nothing: (_) => Nothing,
});
}
引数のMaybe<T>
がJust
のときはその中身の値が述語pred
をtrue
にすることを確かめ、Just
のままにします。
ただし、述語pred
を満たさない場合は値を捨ててNothing
にします。
引数のMaybe<T>
が元々Nothing
のときはそのままNothing
にします。
mvalue のタグ |
pred の返り値 |
filterMaybe の返り値のタグ |
---|---|---|
"Just" |
true |
"Just" |
"Just" |
false |
"Nothing" |
"Nothing" |
true |
"Nothing" |
"Nothing" |
false |
"Nothing" |
これは述語pred
を使ってMaybe<T>
をふるいにかけているようなイメージです。
述語pred
の結果に応じてJust
やNothing
のタグを付け直します。
ここでfromPredicate
とfilterMaybe
の型を比べてみましょう。
fromPredicate: <T>(pred: (value: T) => boolean, value: T ) => Maybe<T>
filterMaybe : <T>(pred: (value: T) => boolean, mvalue: Maybe<T>) => Maybe<T>
判定に使う値が生の値value: T
であるか、既にMaybe<_>
で包まれた値mvalue: Maybe<T>
であるかだけの違いであることが分かります。
さて、filterMaybe
関数を使うと先ほどの処理は次のように実装できます。
const maybe60AndOver = filterMaybe((age) => age >= 60, maybeAge);
match(maybe60AndOver, {
Just: ({ value }) => {
// 正常処理
console.log(`あなたは${value}歳です`);
},
Nothing: (_) => {
// その他の処理
console.log(`シルバー会員は60歳からです`);
},
});
例外的な処理の記述が1箇所にまとまりました。
このように、なるべくmatch
を直接使うタイミングを遅らせることでコードが簡潔になります。
Maybe<T>の値とデフォルト値からTの値を得る
null合体演算子??
と同等のことをMaybe<T>
でもできるようにしましょう。
function greeting(name?: string | null): string {
return `${name ?? "名無し"}さんこんにちは`;
}
そこで、defaultValue
関数を定義します。
function defaultValue<T>(value: T, mt: Maybe<T>): T {
return match(mt, {
Just: ({ value }) => value,
Nothing: (_) => value,
});
}
function greeting(name: Maybe<string>): string {
return `${defaultValue("名無し", name)}さんこんにちは`;
}
あるいは必要なときだけ規定値が計算されるようにするために、
関数を引数として受け取るバージョンもあると良いかもしれません。
function defaultWith<T>(thunk: () => T, mt: Maybe<T>): T {
return match(mt, {
Just: ({ value }) => value,
Nothing: (_) => thunk(),
});
}
Maybe<T>の値をMaybe<U>の値に変換する
年齢でChild
、Regular
、Silver
の3階級に分けるとします。
- 20歳未満は
Child
とする - 60歳以上は
Silver
とする - それ以外は
Regular
とする
type AgeKind = "Child" | "Regular" | "Silver"
Age
からAgeKind
への関数を書くことができます。
function toAgeKind(age: Age): AgeKind {
if (age < 20) return "Child";
if (age >= 60) return "Silver";
return "Regular";
}
しかし、ここまでで何度か繰り返した通り、Age
を作るためには
createAge
を通らなければならないため、Age
を直接持っている場面はほとんどありません。
その代わりにMaybe<Age>
値を持っていることがほとんどです。
そこで、引数の型Age
と返り値の型AgeKind
の両方をMaybe
で包んだ関数を考えます。
このようにそれぞれの型をMaybe
等の同じ型で包むことを「リフトする」と呼ぶことがあります。
function toAgeKindLifted(age: Maybe<Age>): Maybe<AgeKind> {
// ...
}
中身も実装してしまいます。
リフトされた関数toAgeKindLifted
は、リフト前の関数toAgeKind
を使って実装できます。
function toAgeKindLifted(age: Maybe<Age>): Maybe<AgeKind> {
return match(age, {
Just: ({ value }) => Just(toAgeKind(value)),
Nothing: (_) => Nothing,
});
}
どのような関数でも使えるように、mapMaybe
というヘルパー関数を定義します。
function mapMaybe<T, U>(f: (t: T) => U, mt: Maybe<T>): Maybe<U> {
return match(mt, {
Just: ({ value }) => Just(f(value)),
Nothing: (_) => Nothing,
});
}
これにより、toAgeKindLifted
は次のように実装できます。
const toAgeKindLifted = (age: Maybe<Age>) => mapMaybe(toAgeKind, age);
(練習問題)
mt
のタグとmapMaybe
の返り値のタグの関係を表にしてください。
(解答例)
mt のタグ |
mapMaybe の返り値のタグ |
---|---|
"Just" |
"Just" |
"Nothing" |
"Nothing" |
(練習問題)
filterMaybe
の節で次の記述をしました。
const maybe60AndOver = filterMaybe((age) => age >= 60, maybeAge);
match(maybe60AndOver, {
Just: ({ value }) => {
// 正常処理
console.log(`あなたは${value}歳です`);
},
Nothing: (_) => {
// その他の処理
console.log(`シルバー会員は60歳からです`);
},
});
この実装を、これまで作ってきたヘルパー関数を駆使してmatch
を直接使わない実装にリファクタリングしてください。
(解答例)
const maybe60AndOver = filterMaybe((age) => age >= 60, maybeAge);
const maybeStr = mapMaybe((age) => `あなたは${value}歳です`, maybe60AndOver);
const message = defaultValue(`シルバー会員は60歳からです`, maybeStr);
console.log(message);
match
を直接使うタイミングをなるべく遅らせることでコードが簡潔になります。
今回の例ではヘルパー関数が充実しているため、そもそもmatch
が不要になりました。
複数のMaybe<_>値からMaybe<U>の値を作成する
mapMaybe
では1引数の関数をリフトすることができました。
では、2引数、3引数ではどうでしょうか?
例えば、文字列str
の長さが指定の長さlen
より長いときにtrue
を返す関数isLonger
を考えます。
function isLonger(str: string, len: number): boolean {
return str.length > len;
}
既にMaybe<string>
値とMaybe<number>
値を持っており、
どちらかがNothing
であればNothing
を返し、
どちらもJust
であればisLonger
を適用した結果を返したいとします。
function isLongerLifted(
mstr: Maybe<string>,
mlen: Maybe<number>,
): Maybe<boolean> {
//
}
実装してみましょう。実装の方法は色々あります。
例えば、mstr
とmlen
を地道に展開してみます。
function isLongerLifted(
mstr: Maybe<string>,
mlen: Maybe<number>,
): Maybe<boolean> {
return match(mstr, {
Just: (mstr) =>
match(mlen, {
Just: (mlen) => Just(isLonger(mstr.value, mlen.value)),
Nothing: (_) => Nothing,
}),
Nothing: (_) => Nothing,
});
}
もう少し楽にできる方法を考えましょう。
まず、通常の関数適用を考えます。
T
型の値に、T
からU
への関数f
を適用してU
型の値を得ます。
function apply<T, U>(f: (t: T) => U, t: T): U {
return f(t);
}
次に、これをリフトします。
function applyMaybe<T, U>(mf: Maybe<(t: T) => U>, mt: Maybe<T>): Maybe<U> {
return match(mf, {
Just: ({ value /* value: (t: T) => U */ }) => mapMaybe(value, mt),
Nothing: (_) => Nothing,
});
}
mf: Maybe<(t: T) => U>
とmt: Maybe<T>
のどちらかがNothing
の場合はNothing
を返し、
どちらもJust
の場合は関数適用の結果にJust
タグを付けて返します。
mf のタグ |
mt のタグ |
applyMaybe の返り値のタグ |
---|---|---|
"Just" |
"Just" |
"Just" |
"Just" |
"Nothing" |
"Nothing" |
"Nothing" |
"Just" |
"Nothing" |
"Nothing" |
"Nothing" |
"Nothing" |
mapMaybe
とapplyMaybe
を比べてみましょう。
mapMaybe : ( f: (t: T) => U , mt: Maybe<T>): Maybe<U>
applyMaybe: (mf: Maybe<(t: T) => U>, mt: Maybe<T>): Maybe<U>
関数がMaybe<_>
で包まれているかどうかの違いだけであることが分かります。
これらを用いて、先ほどのisLongerLifted
関数は次のように実装できます。
function isLongerLifted(
mstr: Maybe<string>,
mlen: Maybe<number>,
): Maybe<boolean> {
// まず、関数`isLonger`の引数を1つずつ分解します
const f = (str: string) => (len: number) => isLonger(str, len);
// これにより1引数の関数になるので`mapMaybe`を使うことができます
const mf: Maybe<(len: number) => boolean> = mapMaybe(f, mstr);
// Maybe<関数>に対しては`mapApply`を使うことができます
return applyMaybe(mf, mlen);
}
このように、まずは関数を1引数の関数に分解して[5]から、
mapMaybe
とapplyMaybe
を組み合わせて使うことにより、
match
を直接使うことなく2引数の関数をリフトできます。
汎用的に使えるように、f: (t1: T1, t2: T2) => U
をリフトする関数を作っておきましょう。
function map2Maybe<T1, T2, U>(
f: (t1: T1, t2: T2) => U,
mt1: Maybe<T1>,
mt2: Maybe<T2>,
): Maybe<U> {
const f2 = (t1: T1) => (t2: T2) => f(t1, t2); // 実質2引数関数
const mf1 = mapMaybe(f2, mt1); // 1引数関数(リフト)
return applyMaybe(mf, mt2);
}
これを用いるとisLongerLifted
を次のように実装できます。
const isLongerLifted = (mstr: Maybe<string>, mlen: Maybe<number>) =>
map2Maybe(isLonger, mstr, mlen);
(練習問題)
mt1
のタグ、mt2
のタグ、map2Maybe
の返り値のタグの関係性を表にしてください。
(解答例)
mt1 のタグ |
mt2 のタグ |
map2Maybe の返り値のタグ |
---|---|---|
"Just" |
"Just" |
"Just" |
"Just" |
"Nothing" |
"Nothing" |
"Nothing" |
"Just" |
"Nothing" |
"Nothing" |
"Nothing" |
"Nothing" |
では3引数の場合はどうでしょうか?
function map3Maybe<T1, T2, T3, U>(
f: (t1: T1, t2: T2, t3: T3) => U,
mt1: Maybe<T1>,
mt2: Maybe<T2>,
mt3: Maybe<T3>,
): Maybe<U> {
const f3 = (t1: T1) => (t2: T2) => (t3: T3) => f(t1, t2, t3); // 実質3引数関数
const mf2 = mapMaybe(f3, mt1); // 2引数関数(リフト)
const mf1 = applyMaybe(mf2, mt2); // 1引数関数(リフト)
return applyMaybe(mf1, mt3);
}
2引数の場合と同様に、まず関数の引数を1つずつに分解し、初回だけmapMaybe
、
あとはapplyMaybe
を繰り返し適用する[6]ことで機械的に実装することができます。
例えば、3つの文字列を整数としてパーズし、その和を計算してタグJust
を付けます。
どれか1つでもパーズに失敗した場合はNothing
とします。
const ma = safeParseInt("1");
const mb = safeParseInt("2");
const mc = safeParseInt("3");
const msum = map3Maybe((a, b, c) => a + b + c, ma, mb, mc); // Just(6)
3引数の関数と同じ要領で、n
引数の関数をリフトすることができます。
(練習問題)
2つの文字列(整数であるべき)を引数にとり、それらで割り算をしてその結果を返す関数divide
を作成してください。
割り算の結果は整数である必要はありません。
パーズにはsafeParseInt
を使用し、パーズに失敗した場合はNothing
を返します。
ゼロ除算時も同様にNothing
を返します。
declare function divide(top: string, bottom: string): Maybe<number>;
※top
÷ bottom
を計算してください。
解答例`divide`
function divide(top: string, bottom: string): Maybe<number> {
const mtop = safeParseInt(top);
const mbottom = safeParseInt(bottom);
const mbottomNonZero = filterMaybe((n) => n !== 0, mbottom);
return map2Maybe((t, b) => t / b, mtop, mbottomNonZero);
}
(練習問題)
4引数の関数をリフトするmap4Maybe
関数を作成してください。
declare function map4Maybe<T1, T2, T3, T4, U>(
f: (t1: T1, t2: T2, t3: T3, t4: T4) => U,
mt1: Maybe<T1>,
mt2: Maybe<T2>,
mt3: Maybe<T3>,
mt4: Maybe<T4>,
): Maybe<U>;
解答例`map4Maybe`
function map4Maybe<T1, T2, T3, T4, U>(
f: (t1: T1, t2: T2, t3: T3, t4: T4) => U,
mt1: Maybe<T1>,
mt2: Maybe<T2>,
mt3: Maybe<T3>,
mt4: Maybe<T4>,
): Maybe<U> {
const f4 = (t1: T1) => (t2: T2) => (t3: T3) => (t4: T4) => f(t1, t2, t3, t4);
const mf3 = mapMaybe(f4, mt1);
const mf2 = applyMaybe(mf3, mt2);
const mf1 = applyMaybe(mf2, mt3);
return applyMaybe(mf1, mt4);
}
Maybe<T>とその中の値からMaybe<U>を作成する
map2Maybe
やmap3Maybe
などでは、複数のMaybe<_>
をその引数に要求します。
つまり、これらのヘルパー関数を使う際には、既に複数のMaybe<_>
値を持っていなければなりません。
しかし、Maybe<_>
の中身を使って新しい処理を順次行いたい場合があります。
例えば、文字列を整数にパーズし、それが妥当な年齢かどうかを確認します。
パーズに失敗したり、整数であってもそれが妥当な値ではなかったりした場合にはNothing
とします。
これは2つの処理に分かれています。
- 文字列を整数にパーズする
(rawInput: string) => Maybe<number>
- その整数を検証し、年齢として扱う
(int: number) => Maybe<Age>
このような処理は、パーズ結果(整数)が無ければ妥当な年齢かどうかの判定ができないため、
map2Maybe
を使うことはできません。
したがって、次のように書くことになります。
function parseAge(str: string): Maybe<Age> {
return match(safeParseInt(str), {
Just: ({ value }) => createAge(value),
Nothing: (_) => Nothing,
});
}
そこで、ヘルパー関数bindMaybe
を定義します。
function bindMaybe<T, U>(mt: Maybe<T>, f: (t: T) => Maybe<U>): Maybe<U> {
return match(mt, {
Just: ({ value }) => f(value),
Nothing: (_) => Nothing,
});
}
これにより、parseAge
を次のように実装できます。
const parseAge = (str: string) => bindMaybe(safeParseInt(str), createAge);
bindMaybe
はこれまでに定義してきたヘルパー関数の中でも
かなり器用な関数であり、様々なことをすることができます。
(練習問題)
Just
とbindMaybe
を用いてmapMaybe
を再実装してください。
(解答例)`mapMaybe`
const mapMaybe = <T, U>(f: (t: T) => U, mt: Maybe<T>) =>
bindMaybe(mt, (t) => Just(f(t)));
(練習問題)
Just
とbindMaybe
を用いてapplyMaybe
を再実装してください。
(解答例)`applyMaybe`
const applyMaybe = <T, U>(mf: Maybe<(t: T) => U>, mt: Maybe<T>): Maybe<U> =>
bindMaybe(mf, (f) => bindMaybe(mt, (t) => Just(f(t))));
Maybe<T>の方言
今回はMaybe
、Just
、Nothing
という言葉を採用しましたが、
それぞれOption
、Some
、None
という言葉を採用する流派もあります。
apply
の代わりにap
という言葉を採用する流派もあります。
bind
の代わりにchain
やandThen
という言葉を採用する流派もあります。
関数の引数の順番が異なる場合がありますが、処理自体の本質は変わりません。
まとめ
今回は古典的ADTの1つとしてMaybe<T>
を定義し、その周辺の典型的なヘルパー関数を定義しました。
もし本記事が参考になりましたら、いいねあるいはコメントよろしくお願いします。
-
プログラムを書く人の責任が無くなることはありません。「変数名が実体に合っていること」もその内の1つです。例えば
indexOfBanana
という名前の変数を「"banana"
のインデックスを表す変数にしたい」という人間の気持ちはconst indexOfBanana = arr.indexOf("zucchini");
などの記述によって踏み躙られてしまいます。 ↩︎ -
したがって、
finiteAbs(-42 as FiniteNumber)
のように書かなければなりません。-42
がFiniteNumber
であることを確認するのはプログラムを書く人の責任です。 ↩︎ -
match
を使うと常に"Just"
と"Nothing"
の場合分けを明示的に記述しなければならず、そのメリットにもかかわらず時に煩雑になります。そこで、「"Nothing"
のときはそのまま"Nothing"
を返す」という処理を暗黙的に行うヘルパー関数を作成します。 ↩︎ -
このように
map
とapply
を主軸にコードを書くことをApplicativeスタイルと呼ぶことがあります。特に、ユーザーが自由に演算子を定義できる言語で使われます。 ↩︎
Discussion