fp-ts Store 模块完全指南:用 Comonad 理解带焦点的函数式状态
【免费下载链接】fp-tsFunctional programming in TypeScript项目地址: https://gitcode.com/gh_mirrors/fp/fp-ts
导读
Store是 fp-ts 中基于Comonad(余单子)思想实现的经典数据结构:它将「一个可读取当前状态的函数peek」与「一个当前焦点位置pos」打包在一起,从而在纯函数式代码中表达"带焦点的上下文"。本文以 docs/modules/Store.ts.md 为主线,结合 src/Store.ts 的源码实现与 test/Store.ts 的测试用例,系统讲解Store的模型、全部实用函数(seek/seeks/peeks/experiment等)、Comonad 实例,以及它与Comonad、Extend、Functor等抽象之间的关系。读完本文,你将能够熟练创建Store、使用管道式 API 完成位置移动与局部取值,并理解余单子编程的基本模式。
一、Store 是什么:模型与核心直觉
1.1 Store 接口定义
Store<S, A>在 fp-ts 中定义于 src/Store.ts,类型参数含义如下:
S:position(位置)类型,即状态空间的坐标类型;A:value(取值)类型,即从位置读取到的值类型。
export interface Store<S, A> { readonly peek: (s: S) => A readonly pos: S }接口只有两个字段:
| 字段 | 类型 | 含义 |
|---|---|---|
peek | (s: S) => A | 从任意位置s读取对应的值(只读查询函数) |
pos | S | 当前焦点位置(当前"光标"所在的坐标) |
Store的核心直觉是:它像一枚"镜头"或"游标"——peek描述了整个世界(从任意坐标到值的映射),pos标记了你当前注视着的位置。这使它天然适合表达光标文本编辑器(位置 + 缓冲区读取)、棋盘/网格(坐标 + 格子内容查询)等带焦点的场景。
1.2 创建一个 Store
由于Store只是一个普通接口,直接构造对象字面量即可。测试用例 test/Store.ts 展示了典型构造方式——用S.size(来自 src/string.ts 的size: (s: string) => number)作为peek,即"读取任意字符串的长度":
import * as _ from 'fp-ts/Store' import * as S from 'fp-ts/string' // peek 表示"从任意字符串位置读到其长度",pos 表示当前焦点在 'a' 处 const wa: _.Store<string, number> = { peek: S.size, pos: 'a' }此时extract(wa)会执行wa.peek(wa.pos),即读取'a'的长度,得到1。
1.3 取回当前值:extract
extract是 Comonad 的"对偶于 Monad 的return"的关键操作,签名见文档与源码 src/Store.ts:
export declare const extract: <E, A>(wa: Store<E, A>) => A实现极其简单:(wa) => wa.peek(wa.pos)——对当前位置执行 peek,取出焦点处的值。它不改变 Store 本身,是纯读取。
二、位置操作三件套:seek / seeks / peeks
这三者是Store使用频率最高的工具函数,文档中分别标注为"Reposition the focus..."与"Extract a value from a position which depends on the current position"。
2.1 seek:把焦点移动到指定位置
export declare function seek<S>(s: S): <A>(wa: Store<S, A>) => Store<S, A>源码实现(src/Store.ts):
return (wa) => ({ peek: wa.peek, pos: s })seek保留原有peek,仅把pos替换为目标位置s。例如测试 test/Store.ts 中,焦点从'a'移到'aa'后extract结果为2('aa'的长度)。
2.2 seeks:根据当前位置计算新焦点
export declare function seeks<S>(f: Endomorphism<S>): <A>(wa: Store<S, A>) => Store<S, A>实现(src/Store.ts):
return (wa) => ({ peek: wa.peek, pos: f(wa.pos) })其中f: Endomorphism<S>即(s: S) => S的自同态函数(定义见 src/Endomorphism.ts)。seeks先对当前焦点施加f得到新位置,再更新pos。测试用例中seeks((s) => s + 'a')把'a'变为'aa',extract得到2(test/Store.ts)。
seekvsseeks的区分:seek接收具体的坐标值;seeks接收坐标变换函数。后者常用于"相对移动",例如光标右移一位可写为seeks((x) => x + 1)。
2.3 peeks:在不改变焦点的情况下窥探其他位置
export declare function peeks<S>(f: Endomorphism<S>): <A>(wa: Store<S, A>) => A实现(src/Store.ts):
return (wa) => wa.peek(f(wa.pos))与seeks不同,peeks不改变pos,而是基于当前焦点计算出目标位置后直接peek取值,返回A而非新的Store。它适合"偷看"相邻位置的值而不移动光标。测试中peeks((s) => s + 'a')返回2(test/Store.ts),且原 Store 的焦点保持不变。
三、批量探测:experiment
experiment是peeks的批量版本,文档描述为"Extract a collection of values from positions which depend on the current position"。
3.1 签名与多重重载
experiment针对不同 Kind 结构提供了 5 个重载(文档与 src/Store.ts 完全一致),核心逻辑只有一行:
return (f) => (wa) => F.map(f(wa.pos), (s) => wa.peek(s))它接收两个参数并柯里化:
- 一个
Functor实例F(支持Functor3/Functor3C/Functor2/Functor2C/Functor1/FunctorHKT各档); - 一个函数
f: (s: S) => F<S>,它根据当前焦点位置产生一批候选位置(包在某个函子容器中,如数组、Option、ReadonlyArray等)。
然后对每个候选位置执行wa.peek,最终得到F<A>。
3.2 实战示例
测试 test/Store.ts 展示了与ReadonlyArray函子配合的用法:
pipe( wa, // Store<string, number>,peek = S.size,pos = 'a' _.experiment(RA.Functor)((s) => [s, s + 'a']) // 候选位置:['a', 'aa'] ) // 结果:[1, 2],即 [size('a'), size('aa')]也可以换用Option、IO等任意 Functor,experiment会自动按对应容器聚合结果。这是"以当前焦点为中心批量探测邻域"的通用工具。
四、Comonad 视角:extend 与 duplicate
Store是 fp-ts 中余单子的代表性实例,理解extend/duplicate是掌握它的关键。
4.1 extend:把"整片上下文"的计算推广到每个位置
export declare const extend: <E, A, B>(f: (wa: Store<E, A>) => B) => (wa: Store<E, A>) => Store<E, B>实现(src/Store.ts):
export const extend = (f) => (wa) => ({ peek: (s) => f({ peek: wa.peek, pos: s }), pos: wa.pos })extend接收一个"消费整个 Store 得出一个值"的函数f: (wa: Store<E, A>) => B,返回新的 Store:新 Store 的peek会在任意位置s处临时把焦点设为s,再调用f计算该位置上的"局部结果";pos保持原焦点不变。这正是对偶于bind(chain)的"上下文传播"操作——它是后面会讲到的细胞自动机、平滑滤波等"每个点由邻域决定"算法的基石。
测试中的用法(test/Store.ts):
pipe( wa, _.extend((wa) => _.extract( pipe( wa, _.map((n) => n + 1) ) ) ) )4.2 duplicate:制造"带游标的光标"
export declare const duplicate: <E, A>(wa: Store<E, A>) => Store<E, Store<E, A>>源码实现极其简洁(src/Store.ts):
export const duplicate = /*#__PURE__*/ extend(identity)duplicate就是extend(identity)——把内部每个位置上"临时焦点化的 Store"作为值,得到一个Store<E, Store<E, A>>。测试验证了extract(extract(duplicate(wa))) === 1(test/Store.ts),这恰好印证了余单子定律extract ∘ duplicate = id在该实例上的成立。
三者关系:
extend(f) = map(f) ∘ duplicate,这是 Extend 公理的一部分,duplicate因此可以被视为extend的"通用化"版本。
五、映射操作:map 与 flap
5.1 map:把函数提升进 Store 上下文
map是标准 Functor 操作,文档说明:"mapcan be used to turn functions(a: A) => Binto functions(fa: F<A>) => F<B>"。签名:
export declare const map: <A, B>(f: (a: A) => B) => <E>(fa: Store<E, A>) => Store<E, B>实现(src/Store.ts)保持pos不变,把f复合进peek:
export const map = (f) => (fa) => ({ peek: (s) => f(fa.peek(s)), pos: fa.pos })5.2 flap:把"容器内的函数"应用到固定值上
export declare const flap: <A>(a: A) => <E, B>(fab: Store<E, (a: A) => B>) => Store<E, B>flap(v2.10.0 加入)把值a: A应用到容器内承载的函数上。fp-ts 中flap的通用实现定义于 src/Functor.ts:
return (a) => (fab) => F.map(fab, (f) => f(a))而Store.flap是通过flap_(Functor)从Functor实例派生的(src/Store.ts),/*#__PURE__*/标记使其可被打包器做常量折叠。当你手里有一个Store<E, (a: A) => B>并想把它应用到固定参数a上时,flap比手写map((f) => f(a))更直观。
六、类型体操:URI、类型 lambda 与实例
6.1 URI 与类型 lambda 注册
Store是二元类型构造子(两个类型参数S、A),通过URI常量接入 HKT 系统(src/Store.ts):
export const URI = 'Store' export type URI = typeof URI declare module './HKT' { interface URItoKind2<E, A> { readonly [URI]: Store<E, A> } }URI常量:运行时标识,值为字符串'Store';URI类型别名:把字符串字面量类型'Store'暴露给类型层;URItoKind2的declare module增强:告诉 fp-ts 的 HKT 查找表"'Store'对应Store<E, A>",这是experiment等泛型函数能对Store做类型推断的前提。
6.2 Functor 与 Comonad 实例
自 v2.7.0 起,fp-ts 提供小而具体的实例(src/Store.ts):
export const Functor: Functor2<URI> = { URI, map: _map } export const Comonad: Comonad2<URI> = { URI, map: _map, extend: _extend, extract }其中_map/_extend是/* istanbul ignore next */标记的"非柯里化"适配层(src/Store.ts),把柯里化 API 转成实例所需的二元签名。
抽象层级关系如下(对应 src/Comonad.ts 与 src/Extend.ts 的接口定义):
Comonad2<W> extends Extend2<W> extends Functor2<W> ├── map : 来自 Functor ├── extend : 来自 Extend └── extract: Comonad 新增(唯一"取出"操作)Comonad2接口要求extract: <E, A>(wa: Kind2<W, E, A>) => A(src/Comonad.ts),正好与Store的extract对应。
七、zone of death:被废弃的 store 实例
文档最后一部分是"zone of death"(废弃区),store自 v2.0.0 起存在、现已标记@deprecated:
export declare const store: Comonad2<'Store'>废弃原因(源码注释 src/Store.ts 与文档一致):应改用小而具体的实例。例如某个函数需要Comonad时,传入S.Comonad而不是S.store(其中S来自import S from 'fp-ts/Store')。这符合 fp-ts v2 中期以来的 API 演进方针——用Functor、Comonad等最小实例按需组合,避免一次性传递"超大实例"。新代码应直接使用Comonad。
八、实例与定律验证:从测试看正确性
test/Store.ts 使用describe/it组织测试,通过pipe管道式调用覆盖了全部核心 API:
| 被测函数 | 测试位置 | 关键断言 |
|---|---|---|
map | test/Store.ts | extract(map(n => n+1)(wa)) === 2 |
extend | test/Store.ts | 邻域求值后再extract得到2 |
duplicate | test/Store.ts | extract(extract(duplicate(wa))) === 1(余单子定律) |
seek | test/Store.ts | 焦点移到'aa'后extract === 2 |
seeks | test/Store.ts | 变换(s) => s + 'a'后extract === 2 |
peeks | test/Store.ts | 不移动焦点,窥探'aa'得2 |
experiment | test/Store.ts | 与RA.Functor配合得[1, 2] |
这些用例同时验证了余单子的关键定律:
- extract 左单位律:
extract ∘ duplicate = id(duplicate测试); - map/extend 与 extract 的相容性:
extract ∘ map(f) = f ∘ extract; - extend 的复合:
extend(f) = map(f) ∘ duplicate。
借助pipe(定义于 src/function.ts),Store的 API 全部采用"数据在管道尾部"的柯里化形式,可读性良好。
九、完整实战:用 Store 模拟一个带光标的小文本缓冲区
综合以上 API,构造一个"字符串长度缓冲区 + 光标"的完整示例:
import * as _ from 'fp-ts/Store' import { pipe } from 'fp-ts/function' import * as RA from 'fp-ts/ReadonlyArray' import * as S from 'fp-ts/string' // 1. 创建:光标在 'abc' 开头 const buffer: _.Store<string, number> = { peek: S.size, pos: 'a' } // 2. extract:读取当前焦点处的长度 pipe(buffer, _.extract) // 1 // 3. seek:直接跳到 'hello' pipe(buffer, _.seek('hello'), _.extract) // 5 // 4. seeks:相对移动,向后追加一个字符 pipe(buffer, _.seeks((s) => s + '!'), _.extract) // 2 // 5. peeks:不移动光标,窥探右侧一个位置 pipe(buffer, _.peeks((s) => s + '!')) // 2(buffer.pos 仍为 'a') // 6. experiment:批量探测当前位置及其邻域 pipe(buffer, _.experiment(RA.Functor)((s) => [s, s + 'a', s + 'aa'])) // [1, 2, 3] // 7. map:把读取结果变换后仍保持焦点 pipe(buffer, _.map((n) => `length=${n}`), _.extract) // 'length=1' // 8. extend:在每个位置计算"当前位置与其下一个位置长度之和" const sumWithNext = pipe( buffer, _.extend((wa) => _.extract(wa) + _.peeks((s) => s + 'a')(wa)) ) pipe(sumWithNext, _.extract) // 1 + 2 = 3该示例完整串联了文档列出的全部核心函数(extract、seek、seeks、peeks、experiment、map、extend),可作为日常使用Store的速查样板。
十、何时使用 Store:适用场景小结
结合文档内容与源码结构,Store适合以下场景:
- 带焦点的只读上下文:编辑器光标、地图/棋盘坐标、表格选中单元格等"当前位置 + 全局查询能力"的组合;
- 局部计算需要依赖邻域:一维/二维元胞自动机、图像卷积、平滑滤波——
extend让"每个点的值由周围环境决定"得以纯函数式表达; - 与 Comonad 抽象协同:需要
extract/extend/duplicate三件套参与泛型组合的算法(如 Conway 生命游戏可以建模为Store<Cell, Cell>上的迭代); - 只想"偷看"不改状态:
peeks/experiment提供无副作用的位置探测。
需要注意:Store是纯只读结构,seek/seeks产生的是新的 Store 而非原地修改;若需要可写状态,应配合其他状态管理模块使用。
附录:API 速查表
| 分类 | 名称 | 签名要点 | 版本 |
|---|---|---|---|
| Extract | extract | (wa: Store<E, A>) => A,读取当前焦点值 | v2.6.2 |
| instances | Comonad | Comonad2<'Store'>,含 map/extend/extract | v2.7.0 |
| instances | Functor | Functor2<'Store'>,仅含 map | v2.7.0 |
| mapping | flap | 把值应用到容器内函数 | v2.10.0 |
| mapping | map | 在保持pos的同时变换peek结果 | v2.0.0 |
| model | Store | { peek: (s: S) => A; pos: S } | v2.0.0 |
| type lambdas | URI/URI | 字符串'Store'及 HKT 注册 | v2.0.0 |
| utils | duplicate | extend(identity),产生Store<E, Store<E, A>> | v2.0.0 |
| utils | experiment | 基于当前位置批量探测并聚合 | v2.0.0 |
| utils | extend | 将"上下文 → 值"的计算推广到每个位置 | v2.0.0 |
| utils | peeks | 不移动焦点窥探目标位置 | v2.0.0 |
| utils | seek | 焦点移到指定位置 | v2.0.0 |
| utils | seeks | 依据变换函数重定位焦点 | v2.0.0 |
| zone of death | ~~store~~ | 已废弃,改用Comonad等小实例 | v2.0.0 |
版本说明:以上版本号均取自当前仓库 src/Store.ts 与 docs/modules/Store.ts.md 中的
@since标注。
延伸阅读
- Comonad 抽象定义:了解
Comonad2等接口的层级关系; - Extend 抽象定义:
extend运算的形式化约束; - Functor 模块:
flap的通用实现(src/Functor.ts); - Endomorphism 定义:
seeks/peeks依赖的(s: S) => S类型; - Store 源码 与 Store 测试:本文全部结论的实现与验证依据。
【免费下载链接】fp-tsFunctional programming in TypeScript项目地址: https://gitcode.com/gh_mirrors/fp/fp-ts
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考