一.純粋関数
純粋関数とは副作用のない関数(a function that has no side effects)のことで、いくつかの利点があります:
-
参照透過性(referential transparency)
-
コードの推論可能性(reason about your code)
P.S. 参照透過性については、[基礎構文_Haskell ノート 1](/articles/基礎構文-haskell ノート 1/#articleHeader1) を参照
副作用ゼロ(side effects)が鍵ですが、いくつかの副作用は避けられずかつ極めて重要です。例えば:
-
出力:Console への表示、プリンターへの送信、データベースへの書き込みなど
-
入力:入力デバイスからのユーザー入力の取得、ネットワークリクエスト情報など
では、純粋関数を推奨する関数型プログラミングはこれらのシーンにどのように対応するのでしょうか?2 つの解法があります:
-
依存性注入
-
Effect Functor
二.依存性注入
We take any impurities in our code, and shove them into function parameters. Then we can treat them as some other function's responsibility.
簡潔に言えば、不純な部分をパラメータとして取り除くことです
例えば:
// logSomething :: String -> String
function logSomething(something) {
const dt = new Date().toISOString();
console.log(`${dt}: ${something}`);
return something;
}
logSomething 関数には 2 つの不純な要素があり、Date と console はこっそりと取得する外部状態なので、同じ入力(something)��対して、必ずしも同じ出力結果になるとは限りません(log 動作および出力内容がどちらも不確定)。したがって、同じ入力が常に同じ出力に対応するようにするために、この欺瞞的な手段を採用します:
// logSomething: Date -> (String -> *) -> String -> *
function logSomething(d, log, something) {
const dt = d.toISOString();
return log(`${dt}: ${something}`);
}
このようにすれば、同じ入力に対応する同じ出力を実現できます:
const something = "Curiouser and curiouser!"
const d = new Date();
const log = console.log.bind(console);
logSomething(d, log, something);
これは本当に愚かに見えますが、独善其身は意味がないように見えます。実際には、私たちはいくつかのことを行いました:
-
不純な部分を剥離した
-
それらを押しやり、コアコードから遠ざけた(
logSomethingの外に取得) -
logSomethingを純粋にした(動作が予測可能)
意義は不確実性(unpredictability)を制御することにあります:
-
範囲の縮小:不確実性をより小さな関数(
log)内に移動 -
集中管理:繰り返し範囲を縮小し、不確実性を端(アプリケーション入口など)に押しやれば、不確実性をコアコードから遠ざけ、コアコードの動作を予測可能に保証できます
So we end up with a thin shell of impure code that wraps around a well-tested, predictable core.
P.S. このようにすることはテストにも有利で、この不純な薄殻を交換するだけで、コアコードをシミュレートされたテスト環境で実行でき、実行環境全体をシミュレートする必要がありません
しかし、このパラメータ化された依存性注入方式は完璧ではなく、その欠点は:
-
メソッドシグネチャが長い:例えば
app(document, console, fetch, store, config, ga, (new Date()), Math.random) -
引数伝達チェーンが長い:例えば React でトップレベルコンポーネントからあるリーフコンポーネントまで
propsをリレーして伝達
長いメソッドシグネチャの利点は、どの不純なものに呼び出し依存するかを明確に示すことですが、逐層引数を伝達するのは確かに面倒です
三.遅延関数(Lazy Function)
もう一つの副作用を制御する思路は、副作用を生む部分を保護することです(地下鉄の防爆球に入れる)、この防護殻を持って演算に参加し、結果が必要になる時まで殻を開けて値を取得しません
例えば:
// fZero :: () -> Number
function fZero() {
console.log('発射核弾頭');
// Code to launch nuclear missiles goes here
return 0;
}
明らかに、fZero は純粋関数ではなく、極めて大きな副作用があります(核弾頭を発射します)。安全のために、この危険な操作を防爆球に包みます:
// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
return fZero;
}
次にこの球を自由に操作しても核弾頭が発射されることはありません:
const zeroFunc = returnZeroFunc();
roll(zeroFunc);
knock(zeroFunc);
しかし、returnZeroFunc は依然として純粋関数ではありません(外部の fZero に依存)、依存関係を取り込みましょう:
// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
function fZero() {
console.log('Launching nuclear missiles');
// Code to launch nuclear missiles goes here
return 0;
}
return fZero;
}
結果を直接返さず、期待される結果を返すことができる関数を返します([thunk](/articles/惰性 io から話す-haskell ノート 6/#articleHeader3) のような意味)、これに類推:
// fIncrement :: (() -> Number) -> (() -> Number)
function fIncrement(f) {
return () => f() + 1;
}
const fOne = fIncrement(fZero);
const fTwo = fIncrement(fOne);
const fThree = fIncrement(fTwo);
// And so on…
私たちはいくつかの特殊な数値を定義し、これらの数値に対する任意の操作(伝達、計算など)は安全で副作用がありません:
// fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fMultiply(a, b) {
return () => a() * b();
}
// fPow :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fPow(a, b) {
return () => Math.pow(a(), b());
}
// fSqrt :: (() -> Number) -> (() -> Number)
function fSqrt(x) {
return () => Math.sqrt(x());
}
const fFour = fPow(fTwo, fTwo);
const fEight = fMultiply(fFour, fTwo);
const fTwentySeven = fPow(fThree, fThree);
const fNine = fSqrt(fTwentySeven);
// No console log or thermonuclear war. Jolly good show!
これらの操作は公式変換に相当し、最終的な代数計算時のみ才能真正に副作用を生みます。副作用を沈殿させ、依存性注入の方案は副作用を浮かび上がらせるようなもので、两种方式とも副作用を分離し、不確実性を制御する目的を達成できます
しかし、数値の定義が変わったため(数値から数値を返す関数に)、加、減、乗、除……数値に基づく算術演算の一整套を再定義しなければならず、これは本当に愚かで、より良い方法がありますか?
四.Effect Functor
至此、私たちは数値を数値を返す関数にマッピングし、数値演算をこの特殊な数値を操作できる関数にマッピングしました。ちょっと待って、マッピング、防爆球、包装、包まれたものを操作……何を思い出しましたか?
そうです、[Functor](/articles/深く typeclass を探る-haskell ノート 4/#articleHeader5) です:
-- Haskell
class Functor f where
fmap :: (a -> b) -> f a -> f b
fmap が定義する動作はまさにコンテナ内の内容(値)にマッピングを行い、完了後にコンテナに戻すことです
这不就是惰性関数方案で切実に欲しいものですか?
JS で実装试试看、まずコンテナタイプ(Effect)を作成:
// Effect :: Function -> Effect
function Effect(f) {
return {
get: () => f
}
}
コンテナがあれば箱詰め/箱開け操作が可能です:
// 副作用を含むメソッド
function fZero() {
console.log('some side effects...');
return 0;
}
// 箱詰め、fZero を Effect に包む
const eZero = Effect(fZero);
// 箱開け、Effect から fZero を取り出す
eZero.get();
-- Haskell 中のに対応
-- 箱詰め
let justZero = Just (\x -> 0)
-- 箱開け
let (Just fZero) = justZero in fZero
次に fmap を実装:
// fmap :: ((a -> b), Effect a) -> Effect b
function fmap(g, ef) {
let f = ef.get();
return Effect(x => g(f(x)));
}
// test
let eOne = fmap(x => x + 1, Effect(fZero));
eOne.get()(); // 1
またはより地道(関数シグネチャが一致)な curried バージョン:
const curry = f => arg => f.length > 1 ? curry(f.bind(null, arg)) : f(arg);
// fmap :: (a -> b) -> Effect a -> Effect b
fmap = curry(fmap);
// test
let eOne = fmap(x => x + 1)(Effect(fZero));
eOne.get()(); // 1
Effect を実行する get()() は少し冗長に見えるので、簡素化し、同時に fmap も取り込み、これらをより JS の味に合わせます:
// Effect :: Function -> Effect
function Effect(f) {
return {
get: () => f,
run: x => f(x),
map(g) {
return Effect(x => g(f(x)));
}
}
}
试玩一下:
const increment = x => x + 1;
const double = x => x * 2;
const cube = x => x ** 3;
// (0 + 1) * 2 ^ 3
const calculations = Effect(fZero)
.map(increment)
.map(double)
.map(cube)
calculations.run(); // 8
この一連の map 演算はすべて副作用を含まず、最後に run() して初めて fZero の副作用を引き起こします。这正是惰性関数方案の意義:副作用を砂のように最後に沈殿させ、上層の水を純粋透明に保証します
P.S. 上記で実装した Effect は実際に関数 Functor に相当し、[関数に対するマッピング操作は実際に関数合成](/articles/functor と applicative-haskell ノート 7/#articleHeader3) です:
-- Haskell
instance Functor ((->) r) where
fmap = (.)
(.) :: (b -> c) -> (a -> b) -> a -> c
(.) f g = \x -> f (g x)
// 即ち
map(g) {
return Effect(x => g(f(x)));
}
したがって重要な点は関数合成(compose)にあります:
// 特殊値
const fZero = x => 0;
// 普通関数
const double = x => x + x;
// 直接 double fZero は不可
// Functor fmap 概念を導入
const compose = (f, g) => x => g(f(x));
// double を変更せず、double fZero を実現
compose(fZero, double)();
// (0 + 1) * 2 ^ 3
// compose(compose(compose(fZero, increment), double), cube)();
五.まとめ
依存性注入でも Effect Functor 方案でも、副作用を処理する原則はそれがもたらす不確実性を一定範囲内に制限し、他の部分が純粋な特性を保持できるようにすることです
至る所に副作用が混在するアプリケーションを砂が混じった水のコップだと見なすなら、两种方式の違いは水を清くする思路が異なります:
-
依存性注入:砂を浮かび上がらせて最上層にさせ、不純な薄殻を形成し、下方の水を純粋に保持
-
Effect Functor:砂をカップの底に沈殿させ、上方の水を澄清透明に
確かに、副作用はまだ存在し、消除されたわけではありません。しかし、類似の方式を通じて大部分のコードが純粋な特性を保持し、純粋関数がもたらす確定的な利点(think less)を享受できます:
You can be confident that the only thing affecting their behaviour are the inputs passed to it. And this narrows down the number of things you need to consider. In other words, it allows you to think less.
コメントはまだありません