Skip to content

第十一章:再探自然变换

我们即将在具体编码场景中探讨*自然变换(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)。后续操作通过map穿透TaskMaybe将文本传给validate函数,后者返回Either ValidationError String。最终嵌套层级达到Task Error (Maybe (Either ValidationError String))并传递给postComment

这种抽象类型的大杂烩如同抽象表现主义画作般混杂——波洛克式的多态泼溅、蒙德里安式的模块化构成。解决方案包含类型组合、join操作、同构转换等多种方式,本章重点是通过自然变换实现类型同质化。

自然本源

自然变换是"函子(Functor)之间的态射",定义为类型签名(Functor f, Functor g) => f a -> g a的函数。其核心特征在于不涉及容器内容本身的操作,正如密封档案的格式转换。形式化定义需满足:map(f)∘nt ≡ nt∘map(f)

natural transformation diagram

代码表达为:

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

图表与代码传递相同语义:自然变换后的映射与映射后的自然变换结果等价。这一特性源于自由定理的扩展,且自然变换的应用不限于类型转换。

规范化类型转换

与常规类型转换不同,自然变换作用于代数结构的容器转换。例如idToMaybeIdentity转为MaybeeitherToTaskEither提升到异步执行上下文。

典型示例如下:

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操作的延续性。

可将其视为运算效果的转换。ioToTask实现同步转异步,arrayToMaybe从多值转为潜在失败操作。需要特别注意的是,在JavaScript中无法实现异步到同步的taskToIO转换——这种违反自然规律的转换超越了我们的能力范畴。

功能适配

若需在List类型上实现sortBy等特性,自然变换提供了安全的类型转换通道,确保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方法。

通过将map(f)前置至自然变换左侧(如doListyThings_所示),还能优化操作流程,实现运算融合。

同构关系

当类型间存在无损双向转换时,称为同构(Isomorphism)。例如promiseToTasktaskToPromise共同证明了PromiseTask的同构性。与之相对,arrayToMaybe由于信息丢失无法构成同构。

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;

通过listToArray的转换也显示列表与数组的同构特性。虽然这些属于自然变换,但其深层力量在于揭示类型间的结构等价性。

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')

虽然本章重点讨论类型转换,需要强调的是,同构概念在范畴论中具有极其重要的地位。但接下来让我们回归主题。

扩展定义域

自然变换的应用远不仅限于类型转换:

示例包括:列表反转reverse :: [a] -> [a];首项提取head :: [a] -> Maybe a;数值运算(mod 5) :: Int -> Int

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

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

head :: [a] -> a

of :: a -> f a

自然变换定律同样适用于这些函数。例如head可视为[a] -> Identity a,基于aIdentity a的同构性可简化定律验证(这再次验证同构概念的普遍性)。

解套方案

回到多层嵌套的示例代码。通过插入chain(maybeToTask)chain(eitherToTask)两个自然变换,将各层类型统一为Task后执行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'),
);

通过函子转换将异构类型同质化,类似在窗台设置防鸟装置阻止鸽子筑巢。这种结构转换策略有效保持了代码的线性与清晰。正如光之城的名言:"Mieux vaut prévenir que guérir"(预防胜于治疗)。

核心总结

自然变换本质是函子间的转换函数。作为范畴论的核心概念,在抽象编程实践中具有重要作用。通过类型转换既保持组合运算的完整性,又解决了类型嵌套问题。实际应用中往往将多样类型统一为效果最显著的函子(通常选择Task)。

类型管理如同雕琢玉器,需要持续打磨方能显其光华。虽然隐式效应更加难以掌控,但我们正在这场正义之战中稳步前行。在应对更复杂的类型聚合前,我们还需要精进更多工具技艺。下一章我们将通过*可遍历性(Traversable)*实现类型重组。

第十二章:遍历之石

练习题

开始练习!

编写将Either b a转换为Maybe 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;