Blog [blog]
Blog [blog]
二叉树的定义 先序遍历一棵树 我们把 下面的部分是用来测试系统栈版本和手动控制栈版本的一致性。Blog 1. MoonBit 实现树的先序遍历 [blog/iterator/preorder-traversal]
priv enum Tree[A] {
Nil
Node(A, Tree[A], Tree[A])
}
fn Tree::preorder[A](self : Tree[A], f : (A) -> Unit) -> Unit {
fn dfs(root) {
match root {
Nil => ()
Node(x, left, right) => {
f(x)
dfs(left)
dfs(right)
}
}
}
dfs(self)
}
Tree::preorder
改写为手动控制栈的过程 Tree::preorder_stack
fn Tree::preorder_stack[A](self : Tree[A], f : (A) -> Unit) -> Unit {
let stack = Array::new(capacity=4096)
stack.push(self)
while not(stack.is_empty()) {
let root = stack.unsafe_pop()
match root {
Nil => ()
Node(x, left, right) => {
f(x)
stack.push(right) // 先进后出, 先被压入后处理
stack.push(left) // 先处理左节点
}
}
}
}
fn Tree::from_n(n : Int) -> Tree[Int] {
let mut i = 0
fn dfs() {
if i < n {
let x = i
i += 1
let res = Node(x, dfs(), dfs())
res
} else {
Nil
}
}
dfs()
}
fn test_preorder(root : Tree[Int]) -> Unit! {
let b1 = StringBuilder::new()
let b2 = StringBuilder::new()
root.preorder(fn(x) { b1.write_object(x) })
root.preorder_stack(fn(x) { b2.write_object(x) })
assert_eq!(b1.to_string(), b2.to_string())
}
test "preorder/preorder_stack" {
let t1 = Node(
1,
Node(2, Nil, Nil),
Node(3, Node(4, Nil, Nil), Node(5, Nil, Nil)),
)
let mut sum = 0
t1.preorder(fn(x) { sum += x })
inspect!(sum, content="15")
let t2 = Tree::from_n(15)
test_preorder!(t2)
}
目前 MoonBit 社区有两个外部迭代器的实现,
Yu-zh/iterator 和
FlammeShadow/iter。 本文提供一个由浅入深的教程,来帮助读者学习外部迭代器。 Moonbit 标准库的 所以无法实现 也无法实现 不可变外部迭代器可以很自然的实现 可变外部迭代器可以实现 不可变外部迭代器没有内部可变状态可以迭代多次。 可变外部迭代器只能迭代一次,虽然无法实现 调用 可变外部迭代器,可以不断获取下一个元素的值,内部有一个可变的状态。 比如 Moonbit 标准库的 所以无法实现 也无法实现 不可变外部迭代器可以很自然的实现 可变外部迭代器可以实现 我们可以实现 接下来我们来实现不可变外部迭代器 不可变外部迭代器可以返回第一个元素和剩余的迭代过程 这里从 测试一下迭代过程 二叉树的定义 先序遍历一棵树 我们把 下面的部分是用来测试系统栈版本和手动控制栈版本的一致性。 这里使用不可变单向链表作为栈,每次返回一个 可变外部迭代器并不会保存,只能运行一次 不可变外部迭代器可以运行多次,但是需要额外保存迭代上下文 这里测试一下,不可变外部迭代器和 preorder 的一致性 接下来我们来实现 Blog 2. MoonBit 实现外部迭代器 [blog/iterator/external]
2.1. 内部迭代器和外部迭代器 [blog/iterator/internal-vs-external]
Iter[A]
是内部迭代器,
没有办法把iter
切分为(first : A?)
和 (rest : Iter[A])
。fn uncons[A](xs : Iter[A]) -> (A, Iter[A])?
这样的接口。fn next[A](self : Iter[A]) -> A?
这样的接口。test "split internal iterator" {
let xs = [1,2,3,4,5]
let iter = xs.iter().drop(4)
// 这里的 drop 只是修饰器,改变迭代的行为
inspect!(xs.iter().last(), content="Some(5)")
// 但是迭代过程还是 xs.iter()
inspect!(iter.head(), content="Some(5)")
// xs.iter().last() 仍然需要迭代5次
}
uncons
。next
。 2.2. 不可变外部迭代器和可变外部迭代器 [blog/iterator/immut-vs-mut]
uncons
, 但是迭代每个元素的过程仍是可分割的。next
方法后,可变外部迭代器的内部状态改变,变成剩余的迭代了。 2.3. 可变外部迭代器 [blog/iterator/mut-exiter]
priv type ExIter[A] () -> A?
fn ExIter::next[A](self : ExIter[A]) -> A? {
(self._)()
}
fn ExIter::from_array[A](xs : Array[A]) -> ExIter[A] {
let mut i = 0 // i 是内部可变状态
fn() {
if i < xs.length() {
let res = xs[i]
i = i + 1
Some(res)
} else {
None
}
}
}
test {
let xs = [1, 2, 3, 4, 5]
let iter = ExIter::from_array(xs)
let mut sum = 0
loop iter.next() {
None => ()
Some(x) => {
sum += x
continue iter.next()
}
}
inspect!(sum, content="15")
}
ExIter::from_tree
是从 Tree::Tree::preorder_stack
改编而来,
loop
变成 if
迭代过程可以切分,而不是一个整体。each
和eachi
这样的内部迭代器的方法,只能一下子迭代全部元素读者可以再一次看外部迭代器的性质 [blog/iterator/internal-vs-external]
Iter[A]
是内部迭代器,
没有办法把iter
切分为(first : A?)
和 (rest : Iter[A])
。fn uncons[A](xs : Iter[A]) -> (A, Iter[A])?
这样的接口。fn next[A](self : Iter[A]) -> A?
这样的接口。test "split internal iterator" {
let xs = [1,2,3,4,5]
let iter = xs.iter().drop(4)
// 这里的 drop 只是修饰器,改变迭代的行为
inspect!(xs.iter().last(), content="Some(5)")
// 但是迭代过程还是 xs.iter()
inspect!(iter.head(), content="Some(5)")
// xs.iter().last() 仍然需要迭代5次
}
uncons
。next
。fn ExIter::from_tree[A](root : Tree[A]) -> ExIter[A] {
let stack = Array::new(capacity=4096) // stack 是内部可变状态
stack.push(root)
fn() {
if not(stack.is_empty()) {
let root = stack.unsafe_pop()
match root {
Nil => None
Node(x, left, right) => {
stack.push(right)
stack.push(left)
Some(x)
}
}
} else {
None
}
}
}
zipWith
来同时迭代两个迭代器,
但是和不可变迭代器比,ExIter
只能迭代一次,迭代后就使用完所有的元素。
这个和文件流的概念很相似。fn ExIter::zipWith[A,B,C](self : ExIter[A], other : ExIter[B], f : (A,B) -> C) -> ExIter[C] {
let xs = self
let ys = other
fn () {
match (xs.next(),ys.next() ) {
(Some(x),Some(y)) => {
Some(f(x,y))
}
(_,_) => None
}
}
}
test "ExIter::zipWith" {
let xs = ["apple", "orange", "watermetlon"]
let ys = Tree::from_n(5)
let xs = ExIter::from_array(xs)
let ys = ExIter::from_tree(ys)
let zs = xs.zipWith(ys,fn (x,y) { (x,y)})
let b1 = StringBuilder::new()
loop zs.next() {
None => ()
Some(x) => {
b1.write_string("\{x},")
continue zs.next()
}
}
inspect!(b1, content=
#|("apple", 0),("orange", 1),("watermetlon", 2),
)
}
test "ExIter::from_tree" {
let t2 = Tree::from_n(15)
let iter = ExIter::from_tree(t2)
let b1 = StringBuilder::new()
loop iter.next() {
None => ()
Some(x) => {
b1.write_string("\{x},")
continue iter.next()
}
}
inspect!(b1, content="0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,")
let b2 = StringBuilder::new()
t2.preorder(fn(x) { b2.write_string("\{x},") })
inspect!(b2, content="0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,")
}
2.4. 不可变外部迭代器 [blog/iterator/immut-exiter]
priv type ImmutExIter[A] () -> (A,ImmutExIter[A])?
fn ImmutExIter::uncons[A](self : ImmutExIter[A]) -> (A,ImmutExIter[A])? {
(self._)()
}
Array[A]
构造不可变外部迭代器fn ImmutExIter::from_array[A](xs : Array[A]) -> ImmutExIter[A] {
fn aux(i) -> (A,ImmutExIter[A])? {
if (i < xs.length()) {
Some((xs[i],fn () { aux(i + 1) } ) )
} else {
None
}
}
fn () {
aux(0)
}
}
test "ImmutExIter::from_array" {
let xs = [1,2,3,4,5]
let iter = ImmutExIter::from_array(xs)
let buf = StringBuilder::new()
loop iter.uncons() {
None => ()
Some((x,xs)) => {
buf.write_object(x)
continue xs.uncons()
}
}
inspect!(buf, content="12345")
}
2.4.1. 实现树的不可变外部迭代器 [blog/iterator/immut-exiter-tree]
Blog 2.4.1.1. 前置知识 [blog/iterator/preorder-traversal]
priv enum Tree[A] {
Nil
Node(A, Tree[A], Tree[A])
}
fn Tree::preorder[A](self : Tree[A], f : (A) -> Unit) -> Unit {
fn dfs(root) {
match root {
Nil => ()
Node(x, left, right) => {
f(x)
dfs(left)
dfs(right)
}
}
}
dfs(self)
}
Tree::preorder
改写为手动控制栈的过程 Tree::preorder_stack
fn Tree::preorder_stack[A](self : Tree[A], f : (A) -> Unit) -> Unit {
let stack = Array::new(capacity=4096)
stack.push(self)
while not(stack.is_empty()) {
let root = stack.unsafe_pop()
match root {
Nil => ()
Node(x, left, right) => {
f(x)
stack.push(right) // 先进后出, 先被压入后处理
stack.push(left) // 先处理左节点
}
}
}
}
fn Tree::from_n(n : Int) -> Tree[Int] {
let mut i = 0
fn dfs() {
if i < n {
let x = i
i += 1
let res = Node(x, dfs(), dfs())
res
} else {
Nil
}
}
dfs()
}
fn test_preorder(root : Tree[Int]) -> Unit! {
let b1 = StringBuilder::new()
let b2 = StringBuilder::new()
root.preorder(fn(x) { b1.write_object(x) })
root.preorder_stack(fn(x) { b2.write_object(x) })
assert_eq!(b1.to_string(), b2.to_string())
}
test "preorder/preorder_stack" {
let t1 = Node(
1,
Node(2, Nil, Nil),
Node(3, Node(4, Nil, Nil), Node(5, Nil, Nil)),
)
let mut sum = 0
t1.preorder(fn(x) { sum += x })
inspect!(sum, content="15")
let t2 = Tree::from_n(15)
test_preorder!(t2)
}
2.4.1.2. 正文 [blog/iterator/immut-exiter-tree-content]
ImmutExIter[A]
需要保存整个迭代上下文,
typealias Stack[A] = @immut/list.T[A]
fn ImmutExIter::from_tree[A](root : Tree[A]) -> ImmutExIter[A] {
fn aux(stack : Stack[_]) -> (A,ImmutExIter[A])? {
match stack {
Nil => None
Cons(root,rest_stack) => { // pop root from stack
match root {
Nil => None
Node(x,left,right) => {
let stack = Stack::Cons(left,Stack::Cons(right,rest_stack))
// push right into stack
// push left into stack
Some((x,fn () { aux(stack)}))
}
}
}
}
}
fn () {
aux(@immut/list.singleton(root))
}
}
test "ImmutExIter::from_tree" {
let t2 = Tree::from_n(15)
let iter = ImmutExIter::from_tree(t2)
let b1 = StringBuilder::new()
loop iter.uncons() {
None => ()
Some((x,rest)) => {
b1.write_string("\{x},")
continue rest.uncons()
}
}
inspect!(b1, content="0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,")
let b2 = StringBuilder::new()
t2.preorder(fn(x) { b2.write_string("\{x},") })
inspect!(b2, content="0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,")
}
zipWith
,这里和普通的List[A]
的 zipWith
别无二致fn ImmutExIter::zipWith[A,B,C](self : ImmutExIter[A], other : ImmutExIter[B], f : (A,B) -> C) -> ImmutExIter[C] {
let xs = self
let ys = other
match (xs.uncons(),ys.uncons()) {
(Some((x,xs)),Some((y,ys))) => {
fn () { Some((f(x,y), ImmutExIter::zipWith(xs,ys,f)))}
}
(_,_) => {
fn () { None }
}
}
}
test {
let xs = ["apple", "orange", "watermetlon"]
let ys = Tree::from_n(5)
let xs = ImmutExIter::from_array(xs)
let ys = ImmutExIter::from_tree(ys)
let zs = xs.zipWith(ys,fn (x,y) { (x,y)})
let b1 = StringBuilder::new()
loop zs.uncons() {
None => ()
Some((x,rest)) => {
b1.write_string("\{x},")
continue rest.uncons()
}
}
inspect!(b1, content=
#|("apple", 0),("orange", 1),("watermetlon", 2),
)
}
本文实现的内部迭代器参考 OCaml iter。 这里的 为了在迭代过程中停止循环,MoonBit 引入了 我们比较 把 MoonBit 标准库中的 我们来看 Moonbit 标准库的 所以无法实现 也无法实现 不可变外部迭代器可以很自然的实现 可变外部迭代器可以实现Blog 3. MoonBit 实现内部迭代器 [blog/iterator/internal]
priv type InIter[A] ((A) -> Unit) -> Unit
fn InIter::run[A](self : InIter[A], f : (A) -> Unit) -> Unit {
(self._)(f)
}
InIter::from_array
相当于 OCaml
的柯里化。fn InIter::from_array[A](xs : Array[A]) -> InIter[A] {
fn(k) { xs.each(k) }
}
with_index
是装饰器,装饰 iter
默认的行为。fn InIter::with_index[A](self : InIter[A]) -> InIter[(Int, A)] {
let mut i = 0
fn(k) {
self.run(fn(x) {
k((i, x))
i += 1
})
}
}
impl[A : Show] Show for InIter[A] with output(self, logger) {
logger.write_string("[")
self.run(fn(x) {
logger.write_object(x)
logger.write_string(",")
})
logger.write_string("]")
}
test {
let xs = ["apple", "orange", "strawberry"]
let iter = InIter::from_array(xs).with_index()
inspect!(iter.to_string(), content=#|[(0, "apple"),(1, "orange"),(2, "strawberry"),]
)
}
IterResult
.yield : (A) -> IterResult
这个回调函数可以控制循环是停止还是继续。type InIter[A] ((A) -> Unit) -> Unit
type Iter[A] ((A) -> IterResult) -> IterResult
Iter[A]
和 InIter[A]
的定义,Unit
替换成 IterResult
之后,就和 MoonBit 内建的内部迭代器定义一样了
InIter[A]
返回 Unit
和 回调函数返回 Unit
, 相当于一直返回 IterResult::IterContinue
。Iter::take
, Iter::drop
, Iter::flat_map
… 这些方法都是装饰器,用来修饰原先 iter
的行为。core
的 Iter::take
的实现,
这个 Iter::take
可以用来解释, yield_
如何控制循环的状态。// ref: https://github.com/moonbitlang/core/blob/main/builtin/iter.mbt
pub fn Iter::take[T](self : Iter[T], n : Int) -> Iter[T] {
// [..take(10,seq), next] would continue
// even if seq has less than 10 elements
// but `for x in [..take(10,seq), next ] { break }` would stop
//
fn(yield_) {
let mut i = 0
let mut r = IterContinue
self.just_run(fn {
a =>
if i < n {
if yield_(a) == IterContinue {
// yield_ 这个回调函数,有循环的控制权
i = i + 1
IterContinue
} else {
r = IterEnd
IterEnd // 这里 yield 让整个循环退出了
}
} else {
IterEnd // i < n, 停止循环
}
})
r
}
}
Iter::drop
的实现,可以解释为什么内部迭代器是不可分割的。// ref: https://github.com/moonbitlang/core/blob/main/builtin/iter.mbt
pub fn Iter::drop[T](self : Iter[T], n : Int) -> Iter[T] {
fn(yield_) {
let mut i = 0
self.run(fn(a) {
if i < n {
i = i + 1
IterContinue
// 这里跳过 n 个元素, 但是这个 Iter::drop 只是装饰器,
// 让 self 这个迭代器,不对前 n 个元素,调用 yield_ 而已
// 即使drop 之后,i 仍然会递增 n
} else {
yield_(a)
}
})
}
}
test "split internal iterator" {
let xs = [1,2,3,4,5]
let iter = xs.iter().drop(4)
// 这里的 drop 只是修饰器,改变迭代的行为
inspect!(xs.iter().last(), content="Some(5)")
// 但是迭代过程还是 xs.iter()
inspect!(iter.head(), content="Some(5)")
// xs.iter().last() 仍然需要迭代 5 次
// 上面的源码解析,解释了为什么仍然需要迭代 5 次
}
Iter::drop
之后,还需要递增内部状态的 i
, 所以说内部迭代器是不可分割的 3.1. 内部迭代器和外部迭代器 [blog/iterator/internal-vs-external]
Iter[A]
是内部迭代器,
没有办法把iter
切分为(first : A?)
和 (rest : Iter[A])
。fn uncons[A](xs : Iter[A]) -> (A, Iter[A])?
这样的接口。fn next[A](self : Iter[A]) -> A?
这样的接口。test "split internal iterator" {
let xs = [1,2,3,4,5]
let iter = xs.iter().drop(4)
// 这里的 drop 只是修饰器,改变迭代的行为
inspect!(xs.iter().last(), content="Some(5)")
// 但是迭代过程还是 xs.iter()
inspect!(iter.head(), content="Some(5)")
// xs.iter().last() 仍然需要迭代5次
}
uncons
。next
。
递归和迭代是编程中两种基础的重复执行模式,它们在实现方式上有所不同,但计算能力上是等价的。
递归通过函数调用自身来分解问题,通常表达简洁,符合数学 (结构) 归纳法的思维模式;而迭代则借助循环结构 (如 既然两者各有优劣,是否存在一种通用方法,能够自动或半自动地将递归逻辑转换为等价的迭代实现?答案是肯定的,而这正是本文的核心概念——去函数化 (defunctionalization),即将递归的函数调用关系转换为显式的栈管理结构,从而在保持正确性的同时提升运行效率。 让我们以循序渐进的方式展开论述。
作为切入点,不妨考察一个具有典型性的基础案例:设想需要实现 此处 最直观的解决思路莫过于穷举法——将所有可能用到的谓词函数进行枚举编码。以下示例展示了针对整数的几种基本谓词: 借此定义,可重构出经过去函数化处理的过滤函数: 然而此方案存在明显局限性。
诚然,高阶谓词函数的集合实为不可数无穷集,这使得完备枚举在理论上即不可行。
但通过代数数据类型的参数化特性,我们可获得某种程度上的扩展能力。
例如将 更富启发性的是引入复合逻辑结构。
通过增加 经过这般层层抽象,我们所构建的 但必须指出,这种基于代数数据类型的方案存在固有的封闭性缺陷——每新增一个枚举成员都需要修改所有相关的模式匹配逻辑 (本案例中的 通过前文的讨论,读者应对去函数化的核心思想建立了基本认知。
本节将运用该技术解决更具挑战性的问题——二叉树遍历优化。首先给出二叉树的规范定义: 考虑基础的前序遍历实现: 在该函数的设计实现中,我们采用递归算法对树形数据结构进行系统性遍历,
这种方法虽能确保每个节点都受到函数 面对这个看似棘手的控制流问题,我们稍作停顿。
考虑引入“延续” (continuation) 这一关键概念——其本质是对程序剩余计算过程的抽象建模。
具体而言,对于表达式 $1 + 2 * 3 + 4$ 的求值过程:当计算 $2 * 3$ 时,其延续即为带洞表达式 $1 + \square + 4$ 所表征的后续计算。
形式化地,我们可以将延续定义为高阶函数 $\lambda x. 1 + x + 4$,
该函数接收当前计算结果并执行剩余计算过程。 延续传递形式 (Continuation-Passing Style,CPS) 的核心范式在于:
所有函数均不直接返回结果,而是通过将中间值传递给延续函数来实现控制流转移。
以树的前序遍历为例,当第一个 通过严格的 CPS 变换,程序的控制流获得了显式的过程化表征。
基于此,我们可进一步实施延续的去函数化,即将高阶函数表示转化为数据结构表示。
观察到延续包含两种形态:递归处理函数 重构后的实现将函数调用转化为对延续结构的模式匹配,引入辅助函数 为实现彻底的命令式转换,我们先将尾递归形式改写为显式循环结构。
通过 MoonBit 的 loop 语法,控制流的跳转关系得到直观呈现: 此时,向传统循环的转换已水到渠成。通过引入可变状态变量,我们获得完全命令式的实现: 经仔细分析可见,延续结构 这项系统性的转换工程清晰展现了从高阶函数到数据结构,
从递归到迭代,从隐式控制流到显式状态管理的完整范式迁移路径。 $$ \text{CPS} \to \text{Defunctionalization} \to \text{Inlining} \to \text{Tail Call Elimination} $$ 让我们对这一系列精妙的程序转换过程进行系统性的理论总结。
整个改造工程可分解为四个阶段: 第 Ⅰ 阶段:控制流显式化 (CPS 变换):
通过引入延续传递形式,我们将原本隐含在语言运行时中的执行上下文显式提升为可操作的一等公民。
这一关键转换使递归调用点的控制流转移过程浮出水面,为后续的机械化改造奠定了基础。 第 Ⅱ 阶段:去函数化:
基于 Reynolds 提出的去函数化理论,
我们将高阶的延续函数降维为具体的代数数据类型 第 Ⅲ 阶段:内联与尾递归化:
通过将辅助函数 第 Ⅳ 阶段:迭代式转换:
最终阶段将尾递归结构转换为基于可变状态和循环命令的迭代实现。
这一转换过程严格对应现代编译器对尾调用的优化策略:
将递归调用点改写为带状态更新的循环跳转,将延续对象转换为显式的栈数据结构。
值得注意的是,从 “去函数化”的确是精妙的程序转换技术,
若读者在看完本文仍意犹未尽,
希望能够深入了解其背后的理论基础,
推荐阅读 John Reynolds 关于去函数化的经典文章,
以及 Olivier Danvy 很多相关的研究工作。Blog 4. Derive Iteration from Recursion [blog/defunctionalize]
for
、while
) 逐步更新状态,直接控制计算流程。然揆诸实现,则各具长短:
4.1. Defunctionalization: A Taste on Filter DSL [blog/defunctionalize/filter]
filter
函数,该函数接收列表与谓词函数作为输入,返回满足谓词条件的所有元素构成的新列表。
采用递归策略可达成如下实现:fn filter[T](xs : List[T], pred : (T) -> Bool) -> List[T] {
match xs {
Nil => Nil
Cons(x, xs) if pred(x) => Cons(x, filter(xs, pred))
Cons(_, xs) => filter(xs, pred)
}
}
pred
作为高阶函数的特性引发了一个本质性问题:在底层编译场景中 (如将 MoonBit 编译至 C 语言时),函数无法以常规数据结构的形式进行存储与传递。换言之,在目标语言层面,一等函数的直接表示存在根本性限制。enum Filter {
IsEven
IsOdd
IsPositive
IsNegative
}
fn run(self : Filter, data : Int) -> Bool {
match self {
IsEven => data % 2 == 0
IsOdd => data % 2 != 0
IsPositive => data > 0
IsNegative => data < 0
}
}
fn filter_defunct(xs : List[Int], pred : Filter) -> List[Int] {
match xs {
Nil => Nil
Cons(x, xs) if run(pred, x) => Cons(x, filter_defunct(xs, pred))
Cons(_, xs) => filter_defunct(xs, pred)
}
}
IsNegative
推广为带参数的 IsLessThan
:enum FilterExtend {
//...
IsLessThan(Int)
}
fn run_extend(self : FilterExtend, data : Int) -> Bool {
match self {
IsLessThan(n) => data < n
//...
}
}
And
等逻辑连接词,可实现谓词函数的组合运算:enum FilterExtendCompose {
//...
And(FilterExtend, FilterExtend)
}
fn run_extend_compose(self : FilterExtendCompose, data : Int) -> Bool {
match self {
And(f1, f2) => run_extend(f1, data) && run_extend(f2, data)
//...
}
}
Filter
类型本质上已演变为一种领域特定语言 (Domain-Specific Language)。
建议感兴趣的读者可进一步探索其 Parser 与 Pretty Printer 的实现路径,以完善该 DSL 的序列化能力。run
函数)。
这与高阶函数与生俱来的开放性形成鲜明对比:后者允许在不修改现有代码的前提下自由扩展新函数。 4.2. Traverse a Tree [blog/defunctionalize/tree]
enum Tree[T] {
Leaf(T)
Node(T, Tree[T], Tree[T])
}
fn pre_order[T : Show](tree : Tree[T], f : (T) -> Unit) -> Unit {
match tree {
Leaf(x) => f(x)
Node(x, left, right) => {
f(x)
pre_order(left, f)
pre_order(right, f)
}
}
}
f
的精确,
且严格遵循前序遍历(pre-order traversal)的既定顺序,但其递归范式存在显著的效率瓶颈。
具体而言,由于该递归过程并非尾递归(tail recursion)的优化形态,
导致现代编译器的尾调用优化(TCO)机制无法将其自动转换为等价的迭代形式,
这种特性必然造成调用栈的持续累积,进而影响程序的执行效能。
有鉴于此,我们亟需运用前文阐述的「去函数化」(defunctionalization)这一程序变换技术来突破此局限。 4.3. Continuation-Passing Style Transformation [blog/defunctionalize/cps]
pre_order(left, f)
调用执行时,
其延续正是 fn (_) { pre_order(right, f) }
表征的右子树遍历过程。
我们通过扩展函数签名引入延续参数,重构后的实现显式地将计算结果注入到延续中:fn pre_order_cps[T : Show](self : Tree[T], f : (T) -> Unit) -> Unit {
fn go {
Leaf(x), k => k(f(x))
Node(x, left, right), k => {
f(x)
go(left, fn { _ => go(right, k) })
}
}
go(self, fn { x => x })
}
go(tree, cont)
与恒等函数 fn { x => x }
,我们将其编码为代数数据类型:enum TreeCont[T] {
Return
Next(Tree[T], TreeCont[T])
}
run_cont
实现之:fn pre_order_cps_defunct[T : Show](self : Tree[T], f : (T) -> Unit) -> Unit {
fn run_cont {
Next(tree, k) => go(tree, k)
Return => ()
}
fn go {
Leaf(x), k => {
f(x)
run_cont(k)
}
Node(x, left, right), k => {
f(x)
go(left, Next(right, k))
}
}
go(self, Return)
}
fn pre_order_cps_defunct_loop[T : Show](
self : Tree[T],
f : (T) -> Unit
) -> Unit {
loop self, Return {
Leaf(x), k => {
f(x)
match k {
Next(tree, k) => continue tree, k
Return => ()
}
}
Node(x, left, right), k => {
f(x)
continue left, Next(right, k)
}
}
}
fn pre_order_loop[T : Show](self : Tree[T], f : (T) -> Unit) -> Unit {
let mut k = Return
let mut t = self
while true {
match t {
Leaf(x) => {
f(x)
match k {
Next(tree, next) => {
k = next
t = tree
}
Return => break
}
}
Node(x, left, right) => {
f(x)
k = Next(right, k)
t = left
}
}
}
}
TreeCont
实质上模拟了栈式存储结构:Next(right, k)
对应入栈操作,
而模式匹配 Next(tree, next)
则对应出栈操作。
这一洞察使我们能直接给出基于显式栈的实现:fn pre_order_loop_stack[T : Show](self : Tree[T], f : (T) -> Unit) -> Unit {
let k = []
let mut t = self
while true {
match t {
Leaf(x) => {
f(x)
match k.pop() {
Some(tree) => t = tree
None => break
}
}
Node(x, left, right) => {
f(x)
k.push(right)
t = left
}
}
}
}
4.4. Review and Summary [blog/defunctionalize/review]
TreeCont
。
这一转换揭示了延续本质上是对调用栈的结构化建模,
其中 Next
构造子对应栈帧压入操作,对其模式匹配则是出栈,
Return
则表征栈空状态。
通过此步骤,动态的函数调用关系被静态的数据结构所替代。run_cont
内联到主处理流程,
我们消除了函数间的相互递归调用,形成严格的尾递归形式。
此时程序的执行流已呈现近线性结构,
每个函数调用点的上下文关系完全由传入的延续对象所决定,
这为尾调用优化提供了理想的先决条件。TreeCont
到命令式栈的转换验证了理论计算机科学中“程序即数据”的观点。
LunaFlow 是一个基于 MoonBit 的科学计算框架,
旨在为用户提供高效、灵活的计算能力。
该框架在设计理念上有许多独到之处,
尤其体现在数据抽象与泛型算法的实现。
然而遗憾的是,未有技术文档详尽阐释这些核心设计背后的理论依据与实践权衡。 作为 LunaFlow 的架构者之一,笔者撰写本文的目的在于:
其一,系统性地剖析 LunaFlow 的顶层设计哲学,揭示其主要设计原则与实现细节。
其二,通过具体用例的实证分析,展示如何利用该框架构建高效的数值计算流程。
希望读者不仅能够领悟到此种抽象设计在工程实践中的精妙之处,
还能将这种范式迁移至自身的项目开发之中,提升代码的表达力与泛用性。 LunaFlow 采用分层模块化架构,
通过严格的抽象层级将系统组件进行逻辑划分。
其架构可组织为树形结构:
位于根基位置的是 Luna-Generic 核心模块,
该模块借助 MoonBit 的 trait 系统实现了对基础数学结构的代码描述。
在此基础上,作为首要叶子节点的 Luna-Utils 模块承担了通用工具函数的实现,
而诸如 Complex 与 Quaternion
等基础数据结构包则构成了更为细化的叶节点。 高阶数学工具包被置于抽象体系的更上层级,
当前主力模块包括 Calculus Numerical、
Linear Algebra 及 Polynomial 等,
这些模块通过显式的依赖关系调用底层服务,展现出清晰的依赖倒置原则(Dependence Inversion Principle)。
尤为精妙的是,LunaFlow 的架构设计允许用户在 程序设计语言中抽象机制的一个根本目标,
在于对行为模式进行精确的形式化描述并实现有效的代码复用。
在此语境下,代数结构因其严谨的数学内涵,
恰好为这类抽象提供了坚实的理论基础。
MoonBit 语言设计的 trait 系统,通过精妙的类型约束与组合机制,
构建了一套完整的代数结构表达体系。
该系统允许开发者以类型安全的方式,将半群(Semigroup)到半环(Semiring),乃至更复杂的环(Ring)等代数结构进行层级化建模。 在抽象代数中,一个半环(Semiring)是一个集合 $R$,
配备了两个二元运算:加法 $+$ 和乘法 $*$,且满足下面的性质: 除此之外,还满足下面两条性质: 从数学定义出发,半环结构实际上包含两个相互关联的代数组成部分:
其一是具有交换性质的加法幺半群,
其二是乘法幺半群。
在 MoonBit 的类型系统中,
这两个基本结构分别对应着 trait 需要特别说明的是,
当前 MoonBit 的 trait 机制尚无法在类型层面强制保证代数公理的成立 ——
包括但不限于结合律、分配律等基本性质的正确性。
这种限制本质上源于类型系统的表达能力边界:
若要严格验证代数公理,必须借助依赖类型等高级类型理论工具,
而这与 MoonBit 作为工业级语言的设计目标有所偏离。
对这一话题感兴趣的读者,可进一步研读 Lean 或 Coq 等定理证明器的相关文献,
这些语言可通过精密的类型机制表达更为复杂的数学约束。
下面是一个在 Lean 中定义半群的示例: 此类抽象机制的核心价值在于实现真正基于代数性质的通用编程范式。
譬如,撰写矩阵乘法算法时,开发者只需约束类型参数满足 从软件工程的角度审视,代数结构的层级化抽象完美体现了关注点分离(Separation of Concerns)的设计哲学。
数学上,群(Group)在幺半群基础上引入逆元概念,
而环(Ring)又要求加法构成交换群。
这些递进关系在 MoonBit 中表现为 trait 的组合:
每个新特征只需声明其扩展的代数运算,无需重复定义底层运算。这种设计不仅消除了代码冗余,
更重要的是建立了数学理论与程序实现之间的严格对应,
使得抽象层次之间的关系如抽丝剥茧般清晰可见。 对于代数结构公理的验证,我们并非束手无策。
虽然从完备性角度无法穷尽所有可能的验证场景,
但通过精心构建的测试用例集合,
我们能够对公理在特定实例上的有效性进行系统化验证。
借助 QuickCheck 这一强大的属性测试框架,
我们得以实现从抽象代数公理转化为可执行规范(Executable Specifications)的范式转变。这一过程中,代数结构的基本公理恰成为指导属性编写的理论基石,配合自动生成的测试数据,能够对类型实现进行统计学上的验证。 简单来说,QuickCheck 的本质是通过随机采样对程序行为进行统计验证。
用户可以制定待测试程序具有的属性,
通常可以视为是 简单来说,QuickCheck 是一种基于随机采样对程序行为进行统计验证的测试框架。其核心工作机制可解构为以下几个的骤: 在 QuickCheck 测试系统中,属性构造居于核心地位。
对于代数结构而言,其公理体系天然具备可检验性特质:
我们可以通过将数学公理直接映射为测试属性,在具体实现中建立严格的验证机制。
以 Linear-Algebra 代码库中的典型案例进行说明:
当定义表示向量的 $$
\begin{align*}
\vec{u} + \vec{v} &= \vec{v} + \vec{u} \quad \text{(交换律)} \\
\vec{u} + (\vec{v} + \vec{w}) &= (\vec{u} + \vec{v}) + \vec{w} \quad \text{(结合律)}
\end{align*}
$$ 在实现层面,我们可将其转化为如下测试验证(注:此处假定 这种将数学公理机械性地转化为可执行验证机制的范式,
使得 LunaFlow 得以利用数学上的公理体系,
为实现的正确性提供强有力的测试保障。Blog 5. The Abstraction of Scientific Computing in LunaFlow [blog/lunaflow]
5.1. Levels of Abstraction [blog/lunaflow/layers]
Luna-Generic
的抽象框架下扩展自定义数据结构,
这些用户定义类型可无缝融入现有类型系统,并与上层模块达成双向互操作。
自然地,这引出一个关键性问题:如何构建具有数学严谨性的通用数据抽象? 5.2. Mathematical Structures In Luna-Generic [blog/lunaflow/generic]
5.2.1. Semiring [blog/lunaflow/semiring]
AddMonoid
与 MulMonoid
的实现
(其中 AddMonoid
隐含地要求加法运算满足交换律这一数学性质):trait AddMonoid: Add + Zero {}
trait MulMonoid: Mul + One {}
trait Semiring: AddMonoid + MulMonoid {}
/-- A semigroup is a type with an associative `(*)`. -/
class Semigroup (G : Type u) extends Mul G where
/-- Multiplication is associative -/
protected mul_assoc : ∀ a b c : G, a * b * c = a * (b * c)
Semiring
特征,
即可安全地调用加法与乘法运算。无论具体实例化为整数环、
布尔半环还是其他自定义代数结构,
编译器都能在类型检查阶段确保所有运算的合法性与完备性。
这种设计在保障类型安全的同时,达到了代码复用的最大化。
如 Luna-Poly
的实现中,多项式结构被 A
参数化,
并通过 Semiring
特征约束其元素类型,
任意实现了 Semiring
特征的类型都可以作为多项式的系数类型,
从而可以调用多项式的加法与乘法运算:impl[A : Eq + Semiring] Mul for Polynomial[A] with op_mul(xs, ys)
impl[A : Eq + Semiring] Add for Polynomial[A] with op_add(xs, ys)
5.3. Validating Constraints on Algebraic Structures [blog/lunaflow/testing]
5.3.1. How QuickCheck Works? [blog/lunaflow/quickcheck]
(T) -> Bool
的函数,
其中返回值 Bool
表示程序是否满足该属性。
随后通过生成器(Generator)来创建随机数据,
并将其传递给属性函数,并验证返回值是否为 true
。
(默认采用的是类型导向采样:即通过 Arbitrary
trait 将类型与数据生成规则绑定)
若属性函数在所有随机数据上均返回 true
,
则可以认为该属性成立。
否则,可认为该属性不成立,并且找到了一个反例,
QuickCheck 接着尝试缩小反例的大小,
以便更好地理解问题的根源。
(T) -> Bool
。其中,布尔返回值直观地表征当前输入数据是否满足预期性质。Arbitrary
这个 trait 将具体数据类型与其对应的随机生成规则进行约束绑定,从而实现类型安全的测试用例自动化生成。true
,则表明属性成立。反之,若发现存在反例,框架不仅会精准定位违规用例,还会使用收缩算法,通过逐步缩小反例的大小,最终呈现最精简的反例形态,以提高诊断问题的效率与精确度。Vector[T]
类型及其加法运算 op_add
时,
作为向量空间的基本要求,加法运算必须严格满足交换律与结合律。
具体而言,对于任意选择的向量 $\vec{u}, \vec{v}, \vec{w} \in V$,
必须满足以下数学约束:Vector
类型已正确实现 Arbitrary
trait):test "algebraic laws" {
fn prop(a : Vector[Int], b) { a + b == b + a }
fn prop(a : Vector[Int], b, c) { a + (b + c) == (a + b) + c }
quick_check_fn!(fn {
((a : Vector[Int]), (b : Vector[Int])) => {
guard a.length() == b.length() else { true }
prop(a, b)
}
})
quick_check_fn!(fn {
((a : Vector[Int]), (b : Vector[Int]), (c : Vector[Int])) => {
guard a.length() == b.length() && b.length() == c.length() else { true }
prop(a, b, c)
}
})
}
5.4. Real-world Usage of LunaFlow (Polynomial) [blog/lunaflow/instances]
5.5. Future of LunaFlow [blog/lunaflow/future]
本文译自 Brent Yorgey 的 Functional Pearl 论文 You could have invented Fenwick trees,并做了一些解释补充、错误修正,以及将原文的 Haskell 代码都翻译到了 MoonBit 代码。 芬威克树(Fenwick trees),亦称二叉索引树(binary indexed trees),是一种精巧的数据结构,旨在解决这样一个问题:如何在维护一个数值序列的同时,支持在亚线性时间内完成更新操作与区间查询。其实现简洁高效,然亦颇为费解,多由一些针对索引的、不甚直观的位运算构成。本文将从线段树(segment trees)入手——这是一种更为直接、易于验证的纯函数式解法——并运用等式推理,阐释芬威克树的实现乃是一种优化变体,此过程将借助一个 MoonBit 嵌入式领域特定语言(EDSL)来处理无限二进制补码数。
Blog 6. You could have invented Fenwick trees [blog/fenwick]
大家好!今天我想和大家分享如何用 MoonBit 语言实现一个 LRU(Least Recently Used,最近最少使用)缓存算法。无论你是刚接触 MoonBit,还是想了解 LRU 算法的实现细节,这篇文章都会带你深入了解这个经典而实用的算法。 什么是 LRU 缓存? LRU 缓存是一种常见的缓存淘汰策略,它基于一个简单的假设:最近访问过的数据在不久的将来很可能再次被访问。因此,当缓存空间不足需要淘汰数据时,应该优先淘汰最长时间未被访问的数据。 一个日常生活的例子 想象你的书桌上只能放 5 本书。每次你需要一本新书,但书桌已满时,你会把哪本书放回书架?最合理的选择是把最久没看的那本书放回去。这就是 LRU 策略的直观体现。 技术应用场景 LRU 缓存在计算机系统中有广泛的应用: LRU 缓存的实际例子 假设我们有一个容量为 3 的 LRU 缓存,初始为空。现在执行以下操作: 通过这个例子,我们可以看到 LRU 缓存如何维护“最近使用“的顺序,以及如何在缓存满时进行淘汰。 LRU 缓存的核心操作 一个标准的 LRU 缓存需要支持以下两个核心操作: get(key): 获取缓存中键对应的值 put(key, value): 向缓存中插入或更新键值对 这两个操作的时间复杂度都应该是 O(1),这就需要我们精心设计数据结构。 为什么需要特殊的数据结构? 实现一个高效的 LRU 缓存面临两个主要挑战: 如果只用数组,查找需要 O(n) 时间;如果只用哈希表,无法跟踪使用顺序。因此,我们需要结合两种数据结构。 实现思路:哈希表 + 双向链表 LRU 缓存的经典实现方式是结合使用: 这种组合让我们能够同时满足快速查找和顺序维护的需求。 接下来,我们将用 MoonBit 语言一步步实现这个数据结构,从基础的节点定义,到完整的 LRU 缓存功能。 在实现 LRU 缓存之前,我们首先需要定义一些基础的数据结构。正如前面所说,我们需要结合哈希表和双向链表来实现高效的 LRU 缓存。 节点结构 我们先定义双向链表的节点结构。每个节点需要存储键值对,并且包含指向前一个和后一个节点的引用: 在这个节点结构中有几个有趣的 MoonBit 特性值得我们关注: 泛型参数 可变字段 可选类型 简单构造函数: LRU 缓存结构 接下来,我们来定义 LRU 缓存的主体结构: 这个结构包含三个关键字段: capacity:定义了缓存可以存储的最大键值对数量。 dummy:一个特殊的哑元节点,不存储实际的键值对,而是用来简化链表操作。使用哑元节点是一个常见的编程技巧,它可以帮助我们避免处理空链表和边界条件的特殊情况。 key_to_node:一个从键到节点的映射表,使我们能够在 O(1) 时间内通过键找到对应的节点。 为什么使用哑元节点? 哑元节点的使用是一个很巧妙的设计。我们将在链表中使用一个环形结构,其中哑元节点同时充当链表的“头“和“尾“: 这样的设计使得我们在处理节点插入和删除时,不必担心空链表的特殊情况,代码逻辑会更加统一和清晰。 通过这两个基础数据结构,我们已经为实现一个高效的 LRU 缓存打下了基础。在下一部分,我们将开始实现 LRU 缓存的构造函数和核心操作。 现在我们已经定义好了基础数据结构,接下来需要实现 LRU 缓存的构造函数。构造函数是任何数据结构的起点,它负责正确初始化内部状态。 构造函数的设计 在设计 LRU 缓存的构造函数时,我们需要考虑: 让我们看看如何用 MoonBit 实现这个构造函数: 这段代码做了几件重要的事情: 泛型约束:注意 默认值参数:我们需要 自引用环:我们将哑元节点的 返回结构体实例:使用了 MoonBit 的结构体字面量语法 为什么要形成环形结构? 你可能注意到了,我们将哑元节点的前驱和后继都指向了自身,形成了一个环。这样做的好处是: 在空链表中,哑元节点既是“头“也是“尾“。当我们添加实际节点时,它们会被插入到这个环中,而哑元节点始终保持在原位,帮助我们定位链表的逻辑起点和终点。 示意图说明 初始状态下,我们的链表结构如下: 添加一个节点 A 后: 添加另一个节点 B(放在最近使用的位置)后: 在这个结构中, 这样的设计为我们后面实现 接下来我们要实现 LRU 缓存的两个核心操作: get 方法实现 这个实现看起来很简单,但它内部调用了 这里我们用到了两个辅助方法 put 方法实现 检查键是否已存在: 创建新节点: 添加到链表和哈希表: 检查容量并可能淘汰: LRU 算法的核心思想 通过这两个方法,我们可以看到 LRU 算法的核心思想: 这种设计确保了我们总是保留最有可能再次被访问的数据,而丢弃最不可能再次被访问的数据,从而最大化缓存的命中率。 在下一部分,我们将详细介绍 在上一部分中,我们实现了 LRU 缓存的核心操作 push_front 方法实现 这个方法的逻辑虽然只有几行,但需要仔细理解: 通过这四步,我们完成了在链表头部插入新节点的操作。注意我们使用了 remove 方法实现 这个方法的实现非常直观: 通过这两步,节点就从链表中“断开“了,不再是链表的一部分。 为什么这两个操作如此重要? 这两个看似简单的操作是整个 LRU 缓存算法的关键: 通过这两个基本操作,我们能够维护一个按照“最近使用“到“最久未使用“排序的双向链表,从而实现 LRU 缓存的核心功能。 双向链表操作的细节 在处理双向链表时,容易出现的错误是指针操作顺序不当导致链表断裂或形成环。我们的实现避免了这些问题: 这种实现方式确保了链表操作的安全性和正确性。 使用哑元节点的优势 回顾一下我们设计中使用的哑元节点(dummy node),它带来了以下优势: 有了这些辅助操作,我们的 LRU 缓存算法就更加完整了。接下来,我们会添加一些额外的实用方法,让我们的 LRU 缓存更加易用。 我们已经实现了 LRU 缓存的核心功能,包括基本数据结构、构造函数、核心操作 获取缓存大小 首先,我们实现一个方法来获取当前缓存中的元素数量: 这个方法非常简单,直接返回哈希表的大小,因为哈希表中的每一项都对应缓存中的一个元素。 检查缓存是否为空 接下来,我们添加一个方法来检查缓存是否为空: 这个方法检查哈希表是否为空,从而判断整个缓存是否为空。 检查键是否存在 最后,我们实现一个方法来检查缓存中是否存在某个键: 这个方法直接利用哈希表的 这些辅助方法的意义 虽然这些方法看起来很简单,但它们对于实际使用 LRU 缓存非常有价值: 这些辅助方法体现了良好的 API 设计原则 —— 简单、一致且易于使用。通过提供这些方法,我们的 LRU 缓存实现更加健壮和用户友好。 实际应用示例 这些辅助方法在实际应用中非常有用,例如: 这些方法虽然简单,但它们大大增强了 LRU 缓存的实用性。通过这些辅助方法,我们的 LRU 缓存实现已经相当完整和实用。 终于到了我们 LRU 缓存实现之旅的终点。这一路走来,我们从基本概念开始,一步步构建了一个完整的 LRU 缓存。说实话,当我第一次接触 LRU 算法时,就被它简单而优雅的设计所吸引。在实现过程中,我不仅体会到了算法的精妙之处,也感受到了 MoonBit 语言的表达力。 回顾整个实现过程,我最满意的是数据结构的选择。哈希表和双向链表的组合虽然是经典方案,但在 MoonBit 中实现时,语言本身的特性让代码显得格外简洁。特别是哑元节点的使用,解决了链表操作中的边界情况,让代码逻辑更加一致。这种小技巧看似简单,却能大幅简化实现,减少潜在的错误。 在编写过程中,我发现 MoonBit 的类型系统非常适合这类数据结构的实现。泛型让我们的缓存可以适用于各种类型的键值对,而可选类型(Option 类型)则优雅地处理了可能不存在的值的情况。与其他语言相比,不必担心空指针异常是一种解脱,让我可以更专注于算法本身的逻辑。 对我而言,编写 说到实际应用,这种 LRU 缓存在日常开发中其实非常实用。我曾在一个网页应用中使用类似的缓存来存储频繁访问的数据,显著提升了应用响应速度。见过太多项目因为缺乏合理的缓存策略而性能低下,一个好的 LRU 实现往往能起到事半功倍的效果。 当然,这个实现还有改进空间。比如添加线程安全支持,或者引入基于时间的过期策略。在实际项目中,我会考虑根据具体需求扩展这些功能。不过,目前的实现已经涵盖了 LRU 缓存的核心功能,足以应对大多数场景。 我最大的收获可能是深入理解了算法与数据结构如何相互配合。LRU 缓存的优雅之处在于它巧妙地结合了两种数据结构,各取所长,达到了理想的性能。这种思路启发我在面对其他问题时,不要局限于单一的数据结构,而是思考如何组合现有工具来获得最佳解决方案。 最后,希望这个 LRU 缓存的实现过程对你有所帮助。无论你是在学习 MoonBit 语言,还是想深入了解缓存算法,我都希望这篇文章能给你一些启发。编程的乐趣不仅在于解决问题,还在于创造优雅的解决方案。在这个意义上,LRU 缓存是一个小巧而完美的例子。 如果你有任何问题或改进建议,欢迎在 Github 留言讨论。毕竟,代码和思想总是在交流中不断完善的。Blog 7. 用 MoonBit 实现 LRU 缓存算法 [blog/lrualgorithm]
7.1. LRU 缓存简介 [blog/lrualgorithm/whats]
put(1, "一")
→ 缓存现状:[(1, “一”)]put(2, "二")
→ 缓存现状:[(2, “二”), (1, “一”)]put(3, "三")
→ 缓存现状:[(3, “三”), (2, “二”), (1, “一”)]get(1)
→ 返回 “一”,缓存现状:[(1, “一”), (3, “三”), (2, “二”)] (注意 1 被移到了最前面)put(4, "四")
→ 缓存已满,淘汰最久未使用的 (2, “二”),缓存现状:[(4, “四”), (1, “一”), (3, “三”)]get(2)
→ 返回 None (不存在)
7.2. 定义基础数据结构 [blog/lrualgorithm/define]
// 定义Node结构体,用于双向链表
struct Node[K, V] {
key : K
mut value : V
mut pre : Node[K, V]? // 前驱节点
mut next : Node[K, V]? // 后继节点
}
// Node构造函数
fn new_node[K, V](k : K, v : V) -> Node[K, V] {
{ key: k, value: v, pre: None, next: None }
}
[K, V]
:这使得我们的 LRU 缓存可以适用于任何类型的键和值,非常灵活。mut
:value
、pre
和 next
都被标记为 mut
,意味着它们可以在创建后修改。这对于我们需要频繁调整链表结构的 LRU 缓存来说是必须的。?
:pre
和 next
是可选类型,表示它们可能为 None
。这样处理链表两端的节点会更加自然。new_node
函数帮助我们创建一个新节点,初始状态下前驱和后继都是 None
。// LRU缓存结构体
struct LRUCache[K, V] {
capacity : Int // 容量
dummy : Node[K, V] // 哑节点,用于标记双向链表的头尾
key_to_node : Map[K, Node[K, V]] // 键到节点的映射
}
dummy.next
指向链表中最近使用的节点(实际的第一个节点)dummy.pre
指向链表中最久未使用的节点(实际的最后一个节点) 7.3. 构造函数实现 [blog/lrualgorithm/construction]
fn new[K : Hash + Eq, V](
capacity : Int,
default_k : K,
default_v : V
) -> LRUCache[K, V] {
// 创建一个哑元节点,使用提供的默认值
let dummy = new_node(default_k, default_v)
// 初始化dummy节点,指向自身形成环
dummy.next = Some(dummy)
dummy.pre = Some(dummy)
{ capacity, dummy, key_to_node: Map::new() }
}
K : Hash + Eq
这个约束,它表明我们的键类型必须支持哈希操作和相等性比较。这是因为我们要用键来创建哈希映射,所以键必须是可哈希和可比较的。default_k
和 default_v
来初始化哑元节点。这是 MoonBit 类型系统的要求,即使哑元节点不用于存储实际数据,我们也需要为它提供合法的值。next
和 pre
都指向自身,形成一个空的环形链表。这是一种巧妙的技巧,确保我们在链表为空时,不会遇到空指针问题。{ capacity, dummy, key_to_node: Map::new() }
创建并返回了 LRUCache 的实例。
dummy ⟷ dummy (自己指向自己)
dummy ⟷ A ⟷ dummy
dummy ⟷ B ⟷ A ⟷ dummy
dummy.next
指向最近使用的节点(B),而 dummy.pre
指向最久未使用的节点(A)。get
和 put
等核心操作奠定了基础。下一步,我们将实现这些核心操作以完成 LRU 缓存的功能。 7.4. 核心操作 - get 和 put [blog/lrualgorithm/core]
get
和 put
。这两个操作是 LRU 缓存的精髓所在,也是最常用的接口。get
方法用于从缓存中获取某个键对应的值。如果键存在,它还需要将对应的节点移到“最近使用“的位置:fn get[K : Hash + Eq, V](self : LRUCache[K, V], key : K) -> V? {
let node = get_node(self, key)
match node {
Some(n) => Some(n.value)
None => None
}
}
get_node
方法,这个方法不仅查找节点,还负责调整节点位置。下面是 get_node
的实现:fn get_node[K : Hash + Eq, V](self : LRUCache[K, V], key : K) -> Node[K, V]? {
if self.key_to_node.contains(key) {
if self.key_to_node.get(key) is Some(node) {
// 将节点移到链表前端
remove(self, node)
push_front(self, node)
return Some(node)
}
}
None
}
get_node
方法做了几件重要的事:
remove
和 push_front
(后面会详细介绍),它们负责链表的具体操作。put
方法用于向缓存中添加或更新键值对:fn put[K : Hash + Eq, V](self : LRUCache[K, V], key : K, value : V) -> Unit {
// 如果键已存在,更新值并移到链表前端
let existing = get_node(self, key)
if existing is Some(node) {
node.value = value
return
}
// 创建新节点
let node = new_node(key, value)
// 将新节点加入链表前端
push_front(self, node)
self.key_to_node.set(key, node)
// 如果超出容量,删除最久未使用的节点
if self.key_to_node.size() > self.capacity {
if self.dummy.pre is Some(last_node) {
self.key_to_node.remove(last_node.key)
remove(self, last_node)
}
}
}
put
方法的逻辑分为几个部分:
get_node
方法)get_node
已经将节点移到了链表前端
dummy.pre
)指向的节点
push_front
和 remove
这两个关键的辅助方法,它们是实现 LRU 缓存链表操作的基础。 7.5. 辅助操作 - push_front 和 remove [blog/lrualgorithm/auxiliary]
get
和 put
,但它们都依赖于两个关键的辅助方法:push_front
和 remove
。这两个方法负责维护双向链表的结构,是整个 LRU 算法能够正常工作的基础。push_front
方法用于将一个节点插入到链表的最前端(即最近使用的位置):fn push_front[K, V](self : LRUCache[K, V], node : Node[K, V]) -> Unit {
node.next = self.dummy.next
node.pre = Some(self.dummy)
if node.pre is Some(pre) {
pre.next = Some(node)
}
if node.next is Some(next) {
next.pre = Some(node)
}
}
next
指向原来的第一个节点(即 dummy.next
)pre
指向哑元节点next
指向新节点pre
指向新节点is Some
模式匹配来安全地处理可选值,这是 MoonBit 处理空值的一种优雅方式。remove
方法用于从链表中删除一个节点:fn remove[K, V](self : LRUCache[K, V], node : Node[K, V]) -> Unit {
if node.pre is Some(pre) {
pre.next = node.next
}
if node.next is Some(next) {
next.pre = node.pre
}
}
next
指向节点的下一个节点pre
指向节点的前一个节点
push_front
确保最近访问的项总是位于链表的前端remove
用于两个场景:
push_front
中,我们先设置新节点的指针,再更新相邻节点的指针remove
中,我们直接更新相邻节点的指针,跳过要删除的节点
7.6. 实用辅助方法 [blog/lrualgorithm/auxiliary-other]
get
/put
以及链表操作的辅助方法。为了让我们的 LRU 缓存更加完整和易用,现在我们来添加一些实用的辅助方法。fn size[K, V](self : LRUCache[K, V]) -> Int {
self.key_to_node.size()
}
fn is_empty[K, V](self : LRUCache[K, V]) -> Bool {
self.key_to_node.size() == 0
}
fn contains[K : Hash + Eq, V](self : LRUCache[K, V], key : K) -> Bool {
self.key_to_node.contains(key)
}
contains
方法,快速判断一个键是否在缓存中。
is_empty()
比 size() == 0
更直观key_to_node
哈希表// 检查键是否存在,而不获取值(避免改变使用顺序)
if contains(cache, "key") {
// 键存在,可以进行一些操作
}
// 缓存为空时执行特定逻辑
if is_empty(cache) {
// 缓存为空,可以执行初始化或其他操作
}
// 获取缓存使用情况
let usage_percentage = size(cache) * 100 / cache.capacity
7.7. 总结与思考 [blog/lrualgorithm/summary]
get
和 put
方法是最有趣的部分。这两个方法看似简单,却包含了 LRU 算法的核心思想:每次访问都要更新使用顺序,确保最近使用的项目保留在缓存中。当我看到这些方法能够正确工作时,那种成就感是难以形容的。