Skip to content

第11章: 再び自然変換、その本質

日常のコード実装において自然変換(Natural Transformation)の実用性について議論します。これは圏論の支柱となる概念であり、コードの論証や再構成に数学を適用する際に不可欠なものです。ただし私の視野の限界から不完全な説明となる可能性があることをお断りしておきます。それでは始めましょう。

ネストの呪い

ここで取り上げるのは型のネスト(入れ子構造)の問題です。近い将来親になる人々が執拗に整理整備する生物学的衝動ではなく(しかし後の章で明らかになるように類似点があります)、複数の異なる型が新生児を抱えるように値を取り囲んでいる状態を指します。

js
Right(Maybe('b'));

IO(Task(IO(1000)));

[Identity('bee thousand')];

これまでの例では巧妙に回避していましたが、実践では型が絡まったヘッドホンコードのように複雑に交錯します。型を体系化しないまま進めると、コードの可読性は猫カフェの風変わりな客のように大幅に低下します。

状況喜劇

js
// getValue :: Selector -> Task Error (Maybe String)
// postComment :: String -> Task Error Comment
// validate :: String -> Either ValidationError String

// saveComment :: () -> Task Error (Maybe (Either ValidationError (Task Error Comment)))
const saveComment = compose(
  map(map(map(postComment))),
  map(map(validate)),
  getValue('#comment'),
);

型シグネチャが悲鳴を上げる複雑なコードを見てみましょう。getValue('#comment')は要素からテキストを取得するTask Error (Maybe String)を返します。その後TaskMaybeを三階層にわたってmapし、validateを通過させるとTask Error (Maybe (Either ValidationError String))という構造になります。この結果をpostComment処理に渡すためにさらにmapを重ねます。

恐ろしい抽象型の寄せ集めです。このような問題には複数の解決策がありますが、本章では自然変換による型の均質化に焦点を当てます。

自然なるもの

自然変換とは「関手間の射」であり、型シグネチャは(Functor f, Functor g) => f a → g aです。重要なのは関手の中身を覗かずに構造変換を行う点で、次の条件を満たす必要があります:nt(x.map(f)) ≡ nt(x).map(f)

natural transformation diagram

コードでは:

js
// nt :: (Functor f, Functor g) => f a -> g a
compose(map(f), nt) === compose(nt, map(f));

この図式とコードは自然変換とmapの順序を入れ替えても結果が一致することを示しています。これは自由定理から導かれますが、自然変換は型操作に限定されません。

原則に基づく型変換

プログラマに馴染み深いStringからBooleanへの変換とは異なり、代数的コンテナを操作します。例えば:

具体的な例を見てみましょう:

js
// idToMaybe :: Identity a -> Maybe a
const idToMaybe = x => Maybe.of(x.$value);

// idToIO :: Identity a -> IO a
const idToIO = x => IO.of(x.$value);

// eitherToTask :: Either a b -> Task a b
const eitherToTask = either(Task.rejected, Task.of);

// ioToTask :: IO a -> Task () a
const ioToTask = x => new Task((reject, resolve) => resolve(x.unsafePerform()));

// maybeToTask :: Maybe a -> Task () a
const maybeToTask = x => (x.isNothing ? Task.rejected() : Task.of(x.$value));

// arrayToMaybe :: [a] -> Maybe a
const arrayToMaybe = x => Maybe.of(x[0]);

共通点は関手間の変換です。構造変換中に情報を失っても、map操作が値を失わない限り問題ありません。これが核心です:変換後もmapは定義通り動作し続けなければなりません。

作用の変換と捉えることもできます。ioToTaskは同期処理を非同期に変換し、arrayToMaybeは非決定性を可能的失敗に変換します。JavaScriptでは非同期を同期に変換するtaskToIOは書けません。これは「超自然的変換」となります(※原語の'supernatural'との語呂合わせ)。

機能羨望

ListsortByのような機能を使う場合、自然変換を用いて対象型に変換すればmapの整合性を保証できます。

js
// arrayToList :: [a] -> List a
const arrayToList = List.of;

const doListyThings = compose(sortBy(h), filter(g), arrayToList, map(f));
const doListyThings_ = compose(sortBy(h), filter(g), map(f), arrayToList); // law applied

arrayToList[a]List aに変換するだけで、自由にsortByを使用できます。

doListyThings_で示されるように、自然変換の左側にmap(f)を移動することで操作の最適化や融合が容易になります。

同型のJavaScript

情報を失わず双方向変換可能な場合を同型(isomorphism)と呼びます。具体的には相互変換可能な自然変換が存在する時、二つの型は同型です:

js
// promiseToTask :: Promise a b -> Task a b
const promiseToTask = x => new Task((reject, resolve) => x.then(resolve).catch(reject));

// taskToPromise :: Task a b -> Promise a b
const taskToPromise = x => new Promise((resolve, reject) => x.fork(reject, resolve));

const x = Promise.resolve('ring');
taskToPromise(promiseToTask(x)) === x;

const y = Task.of('rabbit');
promiseToTask(taskToPromise(y)) === y;

以上によりPromiseTaskは同型です。arrayToMaybeは情報を失うため同型ではありませんが、自然変換ではあります。重要なのは変換後のmapが整合することです。

js
// maybeToArray :: Maybe a -> [a]
const maybeToArray = x => (x.isNothing ? [] : [x.$value]);

// arrayToMaybe :: [a] -> Maybe a
const arrayToMaybe = x => Maybe.of(x[0]);

const x = ['elvis costello', 'the attractions'];

// not isomorphic
maybeToArray(arrayToMaybe(x)); // ['elvis costello']

// but is a natural transformation
compose(arrayToMaybe, map(replace('elvis', 'lou')))(x); // Just('lou costello')
// ==
compose(map(replace('elvis', 'lou')), arrayToMaybe)(x); // Just('lou costello')

ここで同型について触れましたが、この概念は極めて強力で普遍的なものです。本章の本題から外れるので深入りしません。

より広い定義

自然変換は型変換に限定されません:

他の例を見てみましょう:

hs
reverse :: [a] -> [a]

join :: (Monad m) => m (m a) -> m a

head :: [a] -> a

of :: a -> f a

自然変換の法則はこれらの関数にも適用されます。"head :: [a] → Identity a"と見做すことで、"a"と"Identity a"が同型であることを活用しながら法則を検証できます。

一つの解決策

問題の型シグネチャに戻りましょう。自然変換を用いて各々の型を均質化し、join可能な状態に変換します。

js
// getValue :: Selector -> Task Error (Maybe String)
// postComment :: String -> Task Error Comment
// validate :: String -> Either ValidationError String

// saveComment :: () -> Task Error Comment
const saveComment = compose(
  chain(postComment),
  chain(eitherToTask),
  map(validate),
  chain(maybeToTask),
  getValue('#comment'),
);

chain(maybeToTask)chain(eitherToTask)を追加することで、関手を均質化してネストを解消します。パリの格言「予防は治療に勝る」の通り、根本原因への対処が重要です。

まとめ

自然変換は関手そのものへの操作であり、圏論の中核概念です。実用では最も影響力の大きい関手(通常Task)への均質化が多用されます。型変換により異なる効果を実現しつつ、合成可能性を保証できます。

型の継続的な整理は、それらを具現化した代償です。さらなる型融合のためには追加の道具が必要です。次章ではTraversableを用いた型再編成を扱います。

第12章: 石を辿る

練習問題

練習開始!

Either b aMaybe aへ変換する自然変換を実装せよ // eitherToMaybe :: Either b a -> Maybe a const eitherToMaybe = undefined;


js
// eitherToTask :: Either a b -> Task a b
const eitherToTask = either(Task.rejected, Task.of);

練習開始!

eitherToTaskを使用してfindNameByIdのネストしたEither構造を除去せよ // findNameById :: Number -> Task Error (Either Error User) const findNameById = compose(map(map(prop('name'))), findUserById);


以下の関数が利用可能です:

hs
split :: String -> String -> [String]
intercalate :: String -> [String] -> String

練習開始!

Stringと[Char]の間の同型変換を実装せよ // strToList :: String -> [Char] const strToList = undefined; // listToStr :: [Char] -> String const listToStr = undefined;