Skip to content

第12章: トラバーサブル構造(タイプの横断)

これまでのコンテナサーカスで、私たちは手なずけた凶暴なファンクターを自在に操り、思いのままの操作を実行させてきました。関数適用による結果収集で複数の危険な副作用を同時に操る技に目を見張り、コンテナが合体して消滅する結合の瞬間に驚嘆し、副作用の領域で効果を合成する様子を見守り、さらには自然の摂理を超えて型を変換する冒険も経験しました。

次なるマジックはトラバーサルです。空中ブランコ乗りが値を保持したまま型を飛び越えるように、大観覧車のゴンドラのように効果を再編成します。コンテナが曲芸師の手足のように絡み合った時、このインタフェースで解きほぐしましょう。異なる順序付けで多様な効果を観察します。パンタロンとスライド笛を手に、さあ始めましょう。

型と型の交響曲

不思議の世界へようこそ:

js
// readFile :: FileName -> Task Error String

// firstWords :: String -> String
const firstWords = compose(intercalate(' '), take(3), split(' '));

// tldr :: FileName -> Task Error String
const tldr = compose(map(firstWords), readFile);

map(tldr, ['file1', 'file2']);
// [Task('hail the monarchy'), Task('smash the patriarchy')]

ここでは複数のファイルを読み取って役に立たないタスクの配列を得ています。各タスクをどう処理すべきか? [Task Error String]ではなくTask Error [String]に型を変換できれば、非同期処理に適した単一の未来値で全結果を保持できます。

最後にもう一つの難題を提示します:

js
// getAttribute :: String -> Node -> Maybe String
// $ :: Selector -> IO Node

// getControlNode :: Selector -> IO (Maybe (IO Node))
const getControlNode = compose(map(map($)), map(getAttribute('aria-controls')), $);

結合を切望するIOたちを見てください。joinでダンスさせたいところですが、Maybeがプロムナードの監視人のように邪魔をしています。型の位置を入れ替えてIO (Maybe Node)に簡素化しましょう。

型の風水術

Traversableインタフェースはsequencetraverseという二つの華麗な関数で構成されます。

sequenceを使った型再編成の例:

js
sequence(List.of, Maybe.of(['the facts'])); // [Just('the facts')]
sequence(Task.of, new Map({ a: Task.of(1), b: Task.of(2) })); // Task(Map({ a: 1, b: 2 }))
sequence(IO.of, Either.of(IO.of('buckle my shoe'))); // IO(Right('buckle my shoe'))
sequence(Either.of, [Either.of('wing')]); // Right(['wing'])
sequence(Task.of, left('wing')); // Task(Left('wing'))

内部のファンクターが外側に出て入れ替わる様子がわかります。sequenceは引数の型に厳格で、そのシグネチャは次の通りです:

js
// sequence :: (Traversable t, Applicative f) => (a -> f a) -> t (f a) -> f (t a)
const sequence = curry((of, x) => x.sequence(of));

第2引数はTraversableに包まれたApplicativeである必要があります(実際にはよくあるケースです)。型t (f a)f (t a)に変換されます。第1引数は型推論の補助用で、特にLeftのような型変換に必要な場合に使用します。

Either型の実装例を見てみましょう:

js
class Right extends Either {
  // ...
  sequence(of) {
    return this.$value.map(Either.of);
  }
}

ファンクター(実際にはアプリカティブ・ファンクター)の場合、コンストラクターをmapで適用することで型を飛び越えられます。

Leftの場合などマッピングが無効なケースではof引数を使用します。

js
class Left extends Either {
  // ...
  sequence(of) {
    return of(this);
  }
}

ApplicativePointed Functorを前提とするためofが必要です。型推論可能な言語では明示的な指定は不要です。

効果のカタログ

型の順序は意味を変化させます。[Maybe a]は部分的な成功を許容し、Maybe [a]は全か無かの状況を表します。Either Error (Task Error a)はクライアント側検証、Task Error (Either Error a)はサーバー側検証を表現します。

js
// fromPredicate :: (a -> Bool) -> a -> Either e a

// partition :: (a -> Bool) -> [a] -> [Either e a]
const partition = f => map(fromPredicate(f));

// validate :: (a -> Bool) -> [a] -> Either e [a]
const validate = f => traverse(Either.of, fromPredicate(f));

maptraverseで異なる動作を実現します。partitionは述語関数に基づき左右に分割し、validateは最初の失敗か完全な成功を返します。

List.traverseによるvalidate実装を見てみましょう:

js
traverse(of, fn) {
    return this.$value.reduce(
      (f, a) => fn(a).map(b => bs => bs.concat(b)).ap(f),
      of(new List([])),
    );
  }

リストをreduceで処理します。(f, a) => fn(a).map(b => bs => bs.concat(b)).ap(f)という多少複雑な関数を使用します。

  1. reduce :: [a] -> (f -> a -> f) -> f -> fのシグネチャを思い出してください。第1引数は$valueのドット記法から得られます。

  2. 初期値of(new List([]))Either e [a]型です。最終結果もこの型になります。

  3. fnにはfromPredicate(f) :: a -> Either e aが使用されます。

  4. Rightの場合、関数適用で配列に要素を追加します。結果はEither e ([a] -> [a])型になります。

  5. ap(f)でアキュムレーターに適用します。Either e [a]型を保ちながら処理が進行します。

List.traverseの6行実装は、アプリカティブ・ファンクターの抽象化により汎用的なコードを実現しています。

型たちの舞踏会

最初の例を再検証して整理しましょう:

js
// readFile :: FileName -> Task Error String

// firstWords :: String -> String
const firstWords = compose(intercalate(' '), take(3), split(' '));

// tldr :: FileName -> Task Error String
const tldr = compose(map(firstWords), readFile);

traverse(Task.of, tldr, ['file1', 'file2']);
// Task(['hail the monarchy', 'smash the patriarchy']);

traverseを使うことで非同期タスクを協調処理できます。Promise.all()に似ていますが、特定の型に依存しない汎用実装です。

最後の例を整理します(クロージャーではなく):

js
// getAttribute :: String -> Node -> Maybe String
// $ :: Selector -> IO Node

// getControlNode :: Selector -> IO (Maybe Node)
const getControlNode = compose(chain(traverse(IO.of, $)), map(getAttribute('aria-controls')), $);

map(map($))の代わりにchain(traverse(IO.of, $))を使い、chainで2つのIOをマップして平坦化します。

規則なき秩序

これらの法則は有用なコード保証を提供します。優れた設計とは、可能性を絞り込み適切な答えへ導く制約を課す試みです。

法則のないインタフェースは単なる間接化です。数学的構造と同様、健全性のために特性を公開する必要があります。

さあ、法則を詳細に検証しましょう。

恒等律

js
const identity1 = compose(sequence(Identity.of), map(Identity.of));
const identity2 = Identity.of;

// test it out with Right
identity1(Either.of('stuff'));
// Identity(Right('stuff'))

identity2(Either.of('stuff'));
// Identity(Right('stuff'))

Identityを反転させても結果が変わらない法則です。圏論では自然変換が射となり、Identityは恒等射として機能します。

合成律

js
const comp1 = compose(sequence(Compose.of), map(Compose.of));
const comp2 = (Fof, Gof) => compose(Compose.of, map(sequence(Gof)), sequence(Fof));


// Test it out with some types we have lying around
comp1(Identity(Right([true])));
// Compose(Right([Identity(true)]))

comp2(Either.of, Array)(Identity(Right([true])));
// Compose(Right([Identity(true)]))

ファンクター合成の順序変更が結果に影響しないことを保証します。QuickCheck等のライブラリで検証可能です。

この法則からトラバーサル融合の最適化が可能になります。

自然性

js
const natLaw1 = (of, nt) => compose(nt, sequence(of));
const natLaw2 = (of, nt) => compose(sequence(of), map(nt));

// test with a random natural transformation and our friendly Identity/Right functors.

// maybeToEither :: Maybe a -> Either () a
const maybeToEither = x => (x.$value ? new Right(x.$value) : new Left());

natLaw1(Maybe.of, maybeToEither)(Identity.of(Maybe.of('barlow one')));
// Right(Identity('barlow one'))

natLaw2(Either.of, maybeToEither)(Identity.of(Maybe.of('barlow one')));
// Right(Identity('barlow one'))

自然変換の適用順序が結果に影響しないことを規定します。

自然性の帰結として:

js
traverse(A.of, A.of) === A.of;

パフォーマンス面で有利な性質を得られます。

総括

Traversableはテレキネティックな型操作を可能にする強力なインタフェースです。次章では関数型プログラミングの要モノイドを探求します。

練習問題

以下の要素を考慮してください:

js
// httpGet :: Route -> Task Error JSON

// routes :: Map Route Route
const routes = new Map({ '/': '/', '/about': '/about' });

練習開始!

トラバーサブルを使いgetJsonsの型シグネチャを Map Route Route → Task Error (Map Route JSON)に変更せよ // getJsons :: Map Route Route -> Map Route (Task Error JSON) const getJsons = map(httpGet);


次の検証関数を定義します:

js
// validate :: Player -> Either String Player
const validate = player => (player.name ? Either.of(player) : left('must have name'));

練習開始!

トラバーサブルとvalidateを使い、startGameを更新して全プレイヤーが有効の場合のみ開始せよ // startGame :: [Player] -> [Either Error String] const startGame = compose(map(map(always('game started!'))), map(validate));


ファイルシステムヘルパーに関する問題:

js
// readfile :: String -> String -> Task Error String
// readdir :: String -> Task Error [String]

練習開始!

トラバーサブルでネストしたTaskとMaybeを再編成・平坦化せよ // readFirst :: String -> Task Error (Maybe (Task Error String)) const readFirst = compose(map(map(readfile('utf-8'))), map(safeHead), readdir);