☰
language-ext 中的 Lens 详解:以不可变方式读取与更新嵌套结构
2026/9/29 3:16:55 网站建设 项目流程
  • 后端

【免费下载链接】language-ext

C# pure functional programming framework - come and get declarative!

项目地址:https://gitcode.com/gh_mirrors/la/language-ext
点击查看免费下载

本指南聚焦 language-ext(C# 纯函数式编程框架)核心模块 LanguageExt.Core/Lens/README.md 所定义的 Lens 抽象:在不可变数据结构中"向内查看(look inside)并随后更新(update)"某个值,同时通过返回新版本结构来彻底规避突变。读完本文,你将掌握Lens<A, B>的构造与组合、lens函数与|操作符的用法、与 Map/Arr/Lst 等不可变集合itemLens 的协作,以及从 Lens 到 Prism 的转换,能够直接在项目中写出纯函数式的嵌套更新代码。

一、Lens 解决什么问题

在不可变编程模型中,嵌套结构(如Book → Editor → Car → Mileage)的更新令人头痛:没有指针可改,只能层层重建外层对象,代码会迅速退化为大量样板。Lens 正是为此而生。原文档 LanguageExt.Core/Lens/README.md 给出两句话的准确定义:

Lenses allow you to look inside a structure to access its value and then subsequently update that value. But because we don't want to do mutation, the updaters create new versions of the underlying structure.

拆解为三个要点:

  1. 查看(access):Lens 提供Get,能从外层结构A中取出内层值B;
  2. 更新(update):Lens 提供Set,能把新的内层值B写回外层结构A;
  3. 不突变(no mutation):Set/Update返回的是外层结构的新版本,原结构保持不变——这与 language-ext 中所有不可变集合(Map、Arr、Lst等)的持久化语义完全一致。

需要强调的是,这里的"更新"是函数式的:写回操作实际构造了一条"旧结构 → 新结构"的纯函数管道,原对象不会被修改,因此天然适合多线程共享与函数式组合。

二、核心类型 Lens〈A, B〉:Get 与 SetF 的二元结构

Lens 的底层实现位于 LanguageExt.Core/Lens/LensAB.cs,它是一个readonly struct,只携带两个字段:

public readonly struct Lens<A, B> { public readonly Func<A, B> Get; // 从 A 中取出 B public readonly Func<B, Func<A, A>> SetF; // 给定新值 B,返回 A -> A 的更新函数 public A Set(B value, A cont) => SetF(value)(cont); public static Lens<A, B> New(Func<A, B> Get, Func<B, Func<A, A>> Set) => new(Get, Set); public Func<A, A> Update(Func<B, B> f) => a => Set(f(Get(a)), a); public A Update(Func<B, B> f, A value) => Set(f(Get(value)), value); }

这里的类型签名值得细读:

  • Get : A -> B——读取方向;
  • SetF : B -> A -> A——柯里化的写入方向:先接收新值B,返回一个A → A的转换函数,调用该函数时传入旧结构、产出新结构。这种柯里化设计正是 Lens 能被"顺序组合"(见第四节)的关键;
  • Update(Func<B, B> f)——就地变换(modify):先Get出内层值,用f变换,再Set回去。这是比"取出→计算→写回"三步更原子、更优雅的 API。

从源码结构看,Set与Update都是Get+SetF的组合,因此只要正确构造New(Get, Set),四条操作天然成立。任何满足Set(v, a)后再Get能还原v的 Lens 即为"well-behaved"(行为良好的双向变换),这也是该类型注释 "well-behaved bidirectional transformations" 的由来。

三、构造 Lens 的常用工厂函数

大多数情况下不必手写New,LanguageExt.Core/Lens/Lens.cs 提供了现成的工厂:

工厂函数签名语义
identity<A>()Lens<A, A>恒等透镜,Get原样返回,Set整体替换
fst<A, B>()Lens<(A, B), A>二元组/三元组/四元组的第一个元素
snd<A, B>()Lens<(A, B), B>第二个元素
thrd<A, B, C>()Lens<(A, B, C), C>第三个元素
cond<A, B>(pred, Then, Else)Lens<A, B>按谓词动态选择两个 Lens 之一
tuple(First, Second)Lens<(A,B), (C,D)>将两个 Lens 合并作用于二元组
enumMap(la)Lens<IEnumerable<A>, IEnumerable<B>>把作用于单个元素A的 Lens 提升到整个序列

以fst为例,其实现展示了"查看与更新必须对称"的写法:

public static Lens<(A, B), A> fst<A, B>() => Lens<(A, B), A>.New( Get: ab => ab.Item1, Set: a => ab => (a, ab.Item2));

Set构造的新二元组保留原组的第二个元素,只替换第一个元素——这正是"创建新版本、不碰旧结构"的具体体现。cond则适合"同一结构中按条件走不同字段路径"的场景:Get/Set前先用pred(a)决定采用Then还是Else透镜。

四、顺序组合:lens 函数与 | 操作符

单个 Lens 只能访问一层,真实业务往往需要穿透多层嵌套。language-ext 提供了两条等价的组合途径。

4.1 Prelude 的 lens 函数

LanguageExt.Core/Lens/Prelude.Lens.cs 中lens提供了从 2 个到 10 个 Lens 的重载,逐一顺序组合。以三参版本为例:

public static Lens<A, D> lens<A, B, C, D>(Lens<A, B> la, Lens<B, C> lb, Lens<C, D> lc) => Lens<A, D>.New( Get: a => lc.Get(lb.Get(la.Get(a))), // 沿路径层层向下取 Set: v => la.Update(lb.Update(lc.SetF(v)))); // 从最内层开始层层向上重建

注意Set的组装顺序与Get完全相反:Set先把新值交给最内层lc,产生C→C的更新,再被lb.Update提升为B→B,最后被la.Update提升为A→A。Update在此充当"把内层变换逐级外提"的粘合剂,这正是第二节柯里化SetF设计的意义。

4.2 中缀操作符 |

LanguageExt.Core/Lens/Lens.Operators.cs 为Lens<A, B>定义了|操作符,语义与lens相同,写作更接近管道风格:

public static Lens<A, C> operator |(Lens<A, B> lhs, Lens<B, C> rhs) => Lens<A, C>.New( Get: a => rhs.Get(lhs.Get(a)), Set: v => lhs.Update(rhs.SetF(v)));

于是Book.editor | Editor.car | Car.mileage与lens(Book.editor, Editor.car, Car.mileage)完全等价,可按团队风格选用。

五、与不可变集合协作:item 系列 Lens

Lens 的实用价值很大一部分来自集合索引。language-ext 为各不可变集合内置了itemLens,使其可以直接作为组合链的中间环节:

  • Map.cs:Map<K, V>.item(key)与Map<K, V>.itemOrNone(key);
  • Arr.cs:Arr<A>.item(index);
  • Lst.cs:Lst<A>.item(index);
  • 同理可查HashMap、TrackingHashMap等类型中的同名工厂。

Map的实现最典型——它把"按键取值"封装为可组合的透镜:

public static Lens<Map<K, V>, V> item(K key) => Lens<Map<K, V>, V>.New( Get: la => la[key], Set: a => la => la.AddOrUpdate(key, a)); // 返回新 Map,原 Map 不变

itemOrNone则返回Lens<Map<K, V>, Option<V>>:Get用Find得到Option,Set时若为Some(x)则AddOrUpdate,若为None则Remove——这把"键可能不存在"的细节也封装进了透镜语义,组合链可放心使用。

六、测试用例验证:从 Record 嵌套结构看完整用法

LanguageExt.Tests/LensTests.cs 提供了 Lens 在Record类型上的完整演示。需要说明的是,该文件当前整体处于注释状态,文件头标注 "TODO: Restore when we have SourceGen",即等待源代码生成器恢复后启用;但它完整展示了[WithLens]特性配合Record派生类生成字段级 Lens 的设想用法,与 LanguageExt.Core/Record 的生成器设计一致,因此其代码仍具权威参考价值。

6.1 多层嵌套的读取与更新

定义Car → Editor → Book三层 Record(均标注[WithLens]以便生成Car.mileage、Editor.car、Book.editor等静态 Lens):

var car = new Car("Maserati", "Ghibli", 20000); var editor = new Editor("Joe Bloggs", 50000, car); var book = new Book("Editors of the World", "Sarah Bloggs", editor); var bookEditorCarMileage = lens(Book.editor, Editor.car, Car.mileage); var mileage = bookEditorCarMileage.Get(book); // 读取:20000 var book2 = bookEditorCarMileage.Set(25000, book);// 更新:返回新 Book Assert.True(book2.Editor.Car.Mileage == 25000); // 原 book 及其 editor、car 均未被修改

Get沿Book → Editor → Car → Mileage下行取值;Set则从最内层Mileage开始,逐级生成新的Car、Editor、Book,最终book2与原book共享未被改动的部分,这是持久化结构天然带来的结构共享(structural sharing)收益。

6.2 集合元素级更新

测试还演示了 Lens 与Map的组合——按预约 id 更新某个预约的状态:

var person = new Person("Paul", "Louth", Map( (1, new Appt(1, DateTime.Parse("1/1/2010"), ApptState.NotArrived)), (2, new Appt(2, DateTime.Parse("2/1/2010"), ApptState.NotArrived)), (3, new Appt(3, DateTime.Parse("3/1/2010"), ApptState.NotArrived)))); Lens<Person, ApptState> arrive(int id) => lens(Person.appts, Map<int, Appt>.item(id), Appt.state); var person2 = arrive(2).Set(ApptState.Arrived, person); Assert.True(person2.Appts[2].State == ApptState.Arrived);

组合链Person.appts → Map.item(2) → Appt.state将"人 → 预约表 → 指定预约 → 状态"四层访问压缩为一条声明式管道,这正是第五节itemLens 的实战价值。

七、从 Lens 到 Prism:处理可能缺失的值

当目标值可能不存在时,Lens 升级为 Prism。姊妹文档 LanguageExt.Core/Prism/README.md 说明了两者的唯一区别:Prism 的 getter 返回可选值(Func<A, Option<B>>),因此Get失败时更新会安全地落到原值。

  • PrismAB.cs 提供两种构造:Prism<A, B>.New(Lens<A, B>)与Prism<A, B>.New(Lens<A, Option<B>>),并定义了从Lens到Prism的隐式转换;
  • Prelude_Prism.cs 提供prism(la)辅助函数,以及与lens结构对称的 2~10 个 Prism 顺序组合重载(Get使用Bind串联 Option,Set使用pa.Update/pb.Update提升);
  • Lens.cs 还提供扩展方法ToPrism(),可一键把Lens<A, B>或Lens<A, Option<B>>转成 Prism。

典型用法:Map<int, Appt>.itemOrNone(5)得到Lens<Map<int, Appt>, Option<Appt>>,经ToPrism()或prism(...)转为Prism<Map<int, Appt>, Appt>后,Get返回Option<Appt>,Update在键不存在时直接返回原 Map(参见 PrismAB.cs 中Update的IfNone(a)兜底)。

八、适用范围与使用建议

结合 LensTests.cs、Lens.cs 与集合item实现,可以总结出以下实践准则:

  • 读取深度嵌套的不可变数据时,lens(...)/|组合链比逐层foreach/属性访问更声明式、更易复用;
  • 更新深层字段时,优先使用Set或Update(f),让 Lens 替你完成整条路径的重建,避免手写多层with表达式;
  • 值可能缺失时改用 Prism(itemOrNone+ToPrism),把 Option 处理并入透镜语义;
  • Record 类型可借助[WithLens]特性(见 LanguageExt.Tests/LensTests.cs)生成字段级 Lens,配合Record<T>使用;
  • 所有操作均返回新结构,原对象保持不可变,可安全用于并发场景与缓存共享。

Lens 是 language-ext "不可变优先"设计哲学在数据访问层的落点:它把"查看 + 更新"封装为一对对称函数,让嵌套结构的读写变得可组合、可复用、无副作用。结合lens组合函数、|操作符、集合item工厂与 Prism 转换,你可以用极少的样板代码完成任意深度的持久化更新,这正是纯函数式编程在 C# 中落地的重要一步。

  • 后端

【免费下载链接】language-ext

C# pure functional programming framework - come and get declarative!

项目地址:https://gitcode.com/gh_mirrors/la/language-ext
点击查看免费下载
上一篇:KMS_VL_ALL_AIO:Windows和Office一键激活终极解决方案
下一篇:如何免费创建专业吉他谱:TuxGuitar开源吉他编辑器完整指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询