この記事は「技育祭」というイベントで発表したものです
是非「スライドモード」でご覧ください
みなさん、凶悪な関数はお好きですか?
最近弊社では「ゆめみからの挑戦状」というクイズ企画をTwitter上で行なっています
その企画の中で、こんな問題を出したことがあります
「足し算関数に1行追加して、凶悪にしてください」
元となる足し算関数
const add = (a: number, b: number): number => {
// ここに1行追加して、凶悪な関数にしてください。
return a + b
}
すると、たくさんのエンジニアさん達が
さまざまな凶悪な処理を考えてくれました
いくつか見ていきましょう
const add = (a: number, b: number): number => {
+ if (Date.now() >= Date.parse('2023/01/01')) return 0;
return a + b
}
2023年になると、0
を返すようになる関数です
これは凶悪ですね・・・!
2022年の間はちゃんと足し算をしてくれるので
自動テストなんかもすり抜けてしまいます・・・!
もう一つ見てみましょう
const add = (a: number, b: number): number => {
+ process.env.ENV = "DEV";
return a + b
}
足し算をすると、
環境変数を上書きしてしまう関数です
本番環境なのに、足し算をしたら
開発環境モードの動作に変わってしまうんでしょうか
恐ろしいですね・・・!
エンジニアの皆さんは、
凶悪なことを考えるのが本当にお得意でいらっしゃいます・・・!
このような凶悪なコード、
大喜利の解答として見ている分には面白いんですが
「実際にプロダクトのコードに紛れ込んでしまったら」
と考えると、恐ろしくてたまりません
間違って凶悪な処理を書かないように、
気をつけないといけませんね
でも、気をつけなくても──
これらの凶悪な処理をそもそも書けない──
そんなプログラミング言語もあります
その一つが、純粋関数型言語Elmです
Elmでは「参照透明」な関数しか書けません
参照透明とはなんでしょうか?
まずは先ほどのTypeScriptの足し算関数を見てみましょう
const add = (a: number, b: number): number => {
if (Date.now() >= Date.parse('2023/01/01')) return 0;
return a + b
}
この関数は、例えば3
と5
を渡したら8
を返してくれます
でも、2023年になると正しい答えを返さなくなります
どんな引数を渡しても0
を返すようになってしまいます
現在時刻によって、返す値が変わってしまう関数です
抽象的には「外部の状態に依存する関数」です
3
と5
を入れたら、必ず8
を返してほしいですよね?
2023年になっても、いつまでも8
を返してほしいものです
3
と5
を入れたら、必ず8
を返してくれる
同じ引数を渡したら、必ず同じ結果を返してくれる
その性質を「参照透過性・参照透明性」と言います。
でも、先ほどの凶悪な足し算関数は
日時に依存しているため、参照透明ではありません
参照透明だと、何が嬉しいのでしょうか?
結果が予測しやすい
参照透明な関数は、
同じ引数を渡せば、いつも同じ答えを返します
場面によって返す答えが変わったりしません
なので、挙動が予測しやすく、
自動テストとの相性も良いです
- 3と5を渡したら、8が返ってきたね!
- 1と10を渡したら、11が返ってきたね!
↑こんな風に、自動テストで動作を保証できます
参照透明でない関数は、テストしづらかったりします
先ほどの、日時に依存している関数とかですね
同じ引数を渡しても、
場面によって返ってくる値が変わるためです
ただ、テストできなくもないです
JavaScriptのDateをモック化してくれる
ライブラリなどもあります
もしくは──
2022年 大晦日
ワイ「ついにこの日が来たで・・・!」
ワイ「今、23時30分や・・・」
ワイ「そろそろ年越しテストを開始するで!」
ワイ「npm run test
!」
ワイ「npm run test
!」
ワイ「npm run test
!」
ワイ「npm run test
!」
2023年、到来
ワイ「お、年越した?年越した?」
ワイ「年越したな!」
ワイ「npm run test
!」
ワイ「お、戻り値が変わった!」
ワイ「よっしゃ!テスト成功や!」
「年越しのタイミングで、ちゃんと関数の戻り値が変わったで!」
きっと、一生の思い出に残るテストになると思います
話を戻します
参照透明な関数には、ほかにもメリットがあります
どんなに重い計算でも、引数が分かればメモ化できます
外部の状態を気にしなくてよいので、再利用性も高いです
2つめの凶悪な関数についてはどうでしょうか
const add = (a: number, b: number): number => {
+ process.env.ENV = "DEV";
return a + b
}
計算の結果を返せばいいだけなのに、
外部の状態を変えてしまっています
関数が外部の状態を変えたり、
外部に影響を与えたりすることを「副作用」と呼びます
逆に、副作用がなく参照透明な関数は
「純粋関数」と呼ばれます
純粋関数型言語であるElmでは、皆さんの書くコードが
純粋関数となるように設計されています
同じ引数を渡せば、必ず同じ答えが返ってきます
じゃあ、場面によって答えを変えたい場合はどうすればいいの?
例えば「今日は○月○日です!」という
文字列を生成してくれる──
そんな関数を作りたい場合はどうすればいいの?
そういう関数は、日によって返す値が違うはずじゃん!
純粋関数しか書けなかったら、
そういう関数が作れないじゃん!
そこは、ちょっと不思議な仕組みで可能にします
Elmという言語では、
- 現在の日時を取得する
- その日時を元に「今日は○月○日です!」という文字列を生成して、返す
↑これを、1つの関数の中で行うことができません。
ではどうするのかというと、
- 「現在の日時を取得してくれい!」というお手紙をElmランタイムに渡す
- Elmランタイムから、現在の日時が渡ってくる
- 受け取った日時を元に「今日は○月○日です!」という文字列を生成して、返す
↑このように、どこかで「Elmランタイムにお願いする」フェーズを挟みます
そのため、1つの関数の中で
- 現在の日時を取得!
- その時刻を元に文字列を生成して、返す!
↑これができません
「現在の日時を取得して、メッセージ文を生成して、返す」
という関数が書けないのです
でも、Elmランタイムにお願いして
現在の日時を渡してもらうことはできるので
結果的に「今日は○月○日です!」という
文字列を生成することはできます。
日時に依存するような処理も
純粋関数だけを組み合わせて実現できる──
ちょっと不思議な仕組みです
TypeScriptの場合は、以下のようにすると良いと思います。
// 「日時に応じたメッセージを作ってくれる関数」 を実行
const dateMessage = createDateMessage(Date.now());
関数の中で日時を取得するのではなく、
引数として日時を渡す感じです。
こうすることで関数が参照透明になり、
自動テストがしやすくなります
引数として渡す日時を色々変えたりして
テストをすることができますね
TypeScriptでも、意識すれば関数型っぽく書けそうです
では、乱数を扱う場合はどうでしょうか?
純粋関数だけで、できるのでしょうか?
Elmでは、乱数なども似たような方法で扱います
- 乱数を生成!
- その数値を使って計算し、結果を返す!
↑これを1つの関数の中で行うことができません
どうするかというと、
- 「乱数を作ってくれい!」というお手紙をElmランタイムに渡す
- Elmランタイムから、生成した数値が渡ってくる
- 受け取った数値を元に計算し、値を返す
↑やはり、どこかで「Elmランタイムにお願いする」フェーズを挟みます
そのため「実行するたびに違う数値を返す関数」は
書けないのですが──
ランタイムにお願いして乱数を作ってもらって、
それを受け取って画面に表示する
ということはできます
サイコロを表示するWebページなんかも作れます
参照透明な関数だけを組み合わせて、
乱数を扱えます
Elmでは、他にも凶悪な処理が書きづらくなっています
Elmでは、
const add = (a: number, b: number): number => {
+ process.env.ENV = "DEV";
return a + b
}
↑こういった、勝手に上書きしちゃう関数も書けません
全ての値がイミュータブル(不変)です
再代入はできません
再代入という概念がありません
なので、上書きもできません
「足し算を実行したら、知らん間に環境変数が変わっとった!」
といった副作用も起こりません
このように、純粋関数型言語では──
- 参照透明でない関数
- 副作用を起こす関数
を書けないため、
冒頭で紹介したような凶悪な関数が書けません
これは、言語仕様上そうなっているからです。
「全てを予測可能にしたい!」という思想のもとで
言語が作られているからです
詳しくは「Elm Guide」でググってみてください
とても分かりやすい、日本語版の公式ドキュメントがあります
でも──
TypeScriptでは「再代入」もできますし
「1つの関数の中で、乱数を生成しつつ計算」も
できちゃいますよね?
なので、凶悪な副作用も普通に起こせます
参照透明ではない、
ころころ結果が変わる関数も書けちゃいます
じゃあ「TypeScriptで関数型プログラミング」とか
考えてもしょうがなくない?
いえ、むしろ逆です
TypeScriptでこそ、
関数型プログラミングを意識するメリットがあります
いかに純粋関数とそうでない関数を分離できるか、を
意識することが大事です
さっきのコードのような
「そもそも必要のない処理」を取り除くことも大事です
ところで皆さん
undefined
やNaN
はお好きですか?
TypeScriptでは、
tsconfigでstrict: true
に設定していても
気づかないうちに
undefined
やNaN
が入り込んでしまう場合があります
(2022年10月現在)
すると、予期せぬエラーが発生してしまうことがあります
「User
型の値を扱っているつもりが」
「いつの間にかundefined
になってて」
「ブラウザでエラーが出てもうた!」
そんなことが起きてしまうことがあります
しかも、いちいち気をつけてundefined
やNaN
を
チェックするのも大変ですよね
今日からは Option<T>
という型を使うことにしましょう
この型を実装するのは面倒なので……
今回は fp-ts というライブラリを使います
「ユーザーが入力した文字列を、数値に変換する」
という例を考えてみます
TypeScriptだと
const num: number = Number("5")
または
const num: number = parseInt("5")
こうですね
でも──
const num: number = Number("あああ")
こうすると、変数num
の値はNaN
になってしまいます
しかも、NaN
はnumber
型の一種という扱いなので
コンパイルエラーで気づけません
普通にnumber
型の変数に代入できてしまいます
明らかに例外的な値なのに、です
(2022年10月現在)
そのため──
画面にNaN
って表示してしまったりするミス、
たまに起こります
私もこの間やらかしました
せっかくTypeScriptを使っているので
ブラウザ上でチェックして、
初めて変な値に気づくのではなく
事前にテキストエディタ上で、
コンパイルエラーで気づきたいですよね?
Elmという言語の場合は、
int = String.toInt("5")
↑このようにして文字列を数値に変換します。
でも、直接Int
型の値には変換できません
int = String.toInt("5")
↑この関数の戻り値はMaybe Int
型になります。
文字列から数値に変換する訳なので──
できないこともあります
数値に変換できない例
int = String.toInt("あああ")
なのでMaybe Int
型な訳です
Maybe Int
型の値は、
そのまま計算に使ったりできません
数値に変換できてるかもしれないし──
できていないかもしれない──
そんな「シュレディンガーの猫」みたいな値になります
Maybe Int
型の値を計算などに使いたい場合には
必ず「数値に変換できてなかった場合の処理」を
書かないといけません
例外的な値が発生しそうな処理をする際には
「例外的な値が発生したらどうするか」を書いておかないと
コンパイルエラーが起こって先に進めません
「全てを予測可能にしたい」
「例外的な値も、事前に予測して対処したい」
そんな「関数型っぽさ」がよく現れた型ですね
こちらも、詳しくは「Elm Guide」を読んでみてください
fp-ts の話に戻ります
fp-ts のOption<T>
も、先ほどのMaybe
と同じような型です
「Option」という言葉には
「必須ではない」という意味があります
つまり「存在しないかもしれない値」を型で表現できます
文字列を数値に変換する例を見てみましょう
fp-ts にはfromString
という関数が用意されています
文字列を数値に変換するための関数です
import { fromString } from 'fp-ts-std/Number'
import { Option } from 'fp-ts/Option'
const num: Option<number> = fromString("5")
この関数はOption<number>
型の値を返します
Option<number>
型の値は
Maybe
と同じように、そのまま計算には使えません
fromString
という関数の実装はこんなイメージです
import { Option, none, some } from 'fp-ts/Option'
const fromString = (value: string): Option<number> => {
const num = Number(value)
// NaNになっていないかチェック!
return Number.isNaN(num) ? none : some(num)
}
ちゃんと数値に変換できたかどうか、
NaN
が発生してないかどうか
そこをチェックしてくれます
そして「数値に変換できてなかった場合にはどうするか」を
ちゃんとコードで書かないといけません
そのため「NaN
のまま計算してもうた!」
というミスが起こりません
では"5"
という文字列を
数値に変換する例を見てみましょう
ここでは fp-ts の pipe
という関数を使っていきます
pipe
関数の例
import { pipe } from 'fp-ts/function'
const num: number = pipe(
1,
a => a * 2,
a => a * 2,
a => a * 2,
)
pipe
関数を使うと、例えば
1
という数値に、複数の関数を連続して適用できます。
では"5"
という文字列を数値に変換しつつ
色々な処理を繋げてみましょう
まずは"5"
という文字列を数値に変換してみましょう
文字列から数値に変換・・・?
import { pipe } from 'fp-ts/function'
import { fromString } from 'fp-ts-std/Number'
const num: number = pipe(
"5",
fromString,
)
// -> コンパイルエラー!
このコードは、コンパイルエラーが起こります
文字列から数値への変換は、失敗するかもしれないからです
「数値に変換できなかったらどうするか」を書かないと、
number
型の変数には代入できません
Option<number>
のままでは、
number
型の変数には代入できません
「数値に変換できなかったらどうするか」を明記
import * as O from 'fp-ts/Option'
import { pipe } from 'fp-ts/function'
import { fromString } from 'fp-ts-std/Number'
const num: number = pipe(
"5",
fromString,
+ O.match(
+ () => { throw new Error("不正な値です!") }, // 変換できなかったケース
+ (value) => value, // 変換できたケース
+ )
)
「不正な値のときには例外を投げる」と明記することで、number
型に代入できました
TypeScriptのNumber()
関数だと
戻り値がNaN
かもしれないのに、型としてはnumber
になるため、
そのままNaN
を使った計算をしてしまう恐れがあります。
コンパイルエラーで気づくことができません。
fp-tsの Option<T>
型を使うと
戻り値の型をOption<number>
にできるため、
そのまま計算などができません。
型エラーで事前に気づくことができます。
実行時に初めて
「画面にNaN
て表示されてるやん!」
と気づくのではなく
テキストエディタ上で
「お、Option<number>
やから」
「そのまま計算はできひんのやな」
と気づくことができます
「数値に変換できてなかった場合の処理も」
「ちゃんと書いとかんとな!」
と気づくことができます
また、手動でNaN
チェックするよりも
「他の関数と組み合わせやすい」です
文字列から数値に変換したあと
「さらに二乗する」例を見てみましょう
文字列を数値に変換して、さらに二乗する例
+ // 数値を二乗してくれる関数
+ const square = (value: number): number => value * value
const num: number = pipe(
"5",
fromString, // 文字列から Option<number> に変換!
+ O.map(square), // square関数を使って、値を二乗!
O.match(
() => { throw new Error("不正な値です!") }, // 途中で失敗したケース
(value) => value, // 上手く行ったケース
),
)
square
関数は、数値にしか適用できません
でもO.map()
関数を使うことで、
Option<number>
に対しても適用できるようになります
「存在しないかもしれない数値」を二乗するようなことが
できます・・・!
不思議ですね・・・!
O.map()
関数が、
square
関数を変身させてくれるからです
number
用の関数を、Option<number>
用の関数に
変身させてくれます
これを「関数を持ち上げる」なんて言います
「数値に変換できてないかもしれない値」に対しても
「数値用の関数」を適用できる──
そんな便利な仕組みが用意されているわけですね
でも、まだ先ほどのコードはイマイチです
さらに「Error
が発生する可能性があるかどうか」も
型で扱えるようにして行きます
そのためにEither<A, B>
型を使ってみましょう
これは A
か B
のどちらか一方の値を持つ型です
今回は「number
またはError
」ということを
型で表現してみましょう
「エラーかもしれないし、普通の値かもしれない」
そんな値を返す関数
import { pipe } from 'fp-ts/function'
import * as O from 'fp-ts/Option'
import { Either, left, right } from 'fp-ts/Either'
const optionToEither = <T>(optionValue: O.Option<T>): Either<Error, T> => pipe(
optionValue,
O.match(
() => left(new Error("不正な値です!")),
(value) => right(value),
),
)
Either
も Option
のように
どちらの値をとるかで分岐する必要があります
「エラーだった場合、そうじゃなかった場合」を明記しないと
コンパイルが通りません
では「エラーになるかもしれない」処理を繋げてみましょう
まず、文字列を数値に変換しつつ二乗して
「エラーかもしれない」値を作成
import * as E from 'fp-ts/Either'
const errorOrNum: E.Either<Error, number> = pipe(
"5",
fromString, // string -> Option<number>
optionToEither, // Option<number> -> Either<Error, number>
E.map(square), // Either<Error, number> -> Either<Error, number>
)
ここでもE.map()
を使って
square
関数を変身させています
「エラーかもしれない値」に対しても
「数値用の関数」を適用できています
そして、値に応じたメッセージを作成
const message: string = pipe(
errorOrNum,
E.match(
(left: Error) => left.message, // 途中でエラーが起きたケース
(right: number) => `2 乗した値は ${right} です`, // ちゃんと計算できたケース
),
)
console.log(message)
ここでも、エラーが起きた場合の処理を明記しないと
コンパイルが通りません
このように fp-ts をうまく活用することで
undefined
、NaN
、例外をとるかもしれない値などを
型に落とし込むことができます
型的に矛盾のあるコードを書いている場合は
コンパイルエラーで気づくことができます
「テキストエディタ上で、事前に気づける」という
TypeScriptのメリットを、より強化できました
しかも「存在しないかもしれない値」が発生しても、
その後の計算を繋げることができています
「全てを予測可能にしたい」
「例外的な値も、事前に予測して対処したい」
「しかも、いろんな処理を便利に繋げたい」
そういった関数型の思想を、
TypeScriptにも取り込むことができました
まとめ
なんだかんだ、TypeScriptって良いと思います
JavaScriptに比べて、だんぜん型安全ですし
undefined
絡みのエラーも
けっこう事前検知できるようになっています
何より、ユーザー数が多く
ライブラリやフレームワークも豊富です
Vue も React もあります
なので、TypeScriptを使いたい場面って多いです
「でも、もっと関数型言語の思想も取り入れたい」
そんなときは fp-ts 使ってみてください
※先に関数型言語を少し触ってみてからの方が
分かりやすいかもです
〜完〜
スペシャルサンクス
- 一緒に資料を作ってくれたsiketyan