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

Blog 2. MoonBit 实现外部迭代器 [blog/iterator/external]

目前 MoonBit 社区有两个外部迭代器的实现, Yu-zh/iteratorFlammeShadow/iter

本文提供一个由浅入深的教程,来帮助读者学习外部迭代器。

2.1. 内部迭代器和外部迭代器 [blog/iterator/internal-vs-external]

  • 内部迭代器 迭代过程是不可分割的。
  • 外部迭代器 迭代过程是可分割的。

Moonbit 标准库的 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 迭代过程可以切分,而不是一个整体。

比如 eacheachi 这样的内部迭代器的方法,只能一下子迭代全部元素

读者可以再一次看外部迭代器的性质 [blog/iterator/internal-vs-external]

  • 内部迭代器 迭代过程是不可分割的。
  • 外部迭代器 迭代过程是可分割的。

Moonbit 标准库的 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))
  }
}

这里测试一下,不可变外部迭代器和 preorder 的一致性

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),
  )
}

2.5. 参考资料 [blog/iterator/reference]

Why does the callback for Iter::new() need to return IterResult?

external iterator

internal iterator

zip n^2 complexity

Proposal: peekable iterator and pattern matching on iterators

Blog 3. MoonBit 实现内部迭代器 [blog/iterator/internal]

本文实现的内部迭代器参考 OCaml iter

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"),]
  )
}

为了在迭代过程中停止循环,MoonBit 引入了 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

MoonBit 标准库中的 Iter::take, Iter::drop, Iter::flat_map … 这些方法都是装饰器,用来修饰原先 iter 的行为。

我们来看 coreIter::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]

  • 内部迭代器 迭代过程是不可分割的。
  • 外部迭代器 迭代过程是可分割的。

Moonbit 标准库的 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

Blog 4. Derive Iteration from Recursion [blog/defunctionalize]

递归和迭代是编程中两种基础的重复执行模式,它们在实现方式上有所不同,但计算能力上是等价的。 递归通过函数调用自身来分解问题,通常表达简洁,符合数学 (结构) 归纳法的思维模式;而迭代则借助循环结构 (如 forwhile) 逐步更新状态,直接控制计算流程。然揆诸实现,则各具长短:

  • 递归代码短小清晰,适合解决分治问题 (如树的遍历、动态规划),但可能导致栈溢出 (stack overflow) 和较高的函数调用开销,尤其是深度递归时效率降低。
  • 迭代虽然执行更快,节省栈空间,但需要开发者显式管理状态 (如循环变量、临时存储、甚至需要手动维护一个栈),逻辑可能不如递归直观。

既然两者各有优劣,是否存在一种通用方法,能够自动或半自动地将递归逻辑转换为等价的迭代实现?答案是肯定的,而这正是本文的核心概念——去函数化 (defunctionalization),即将递归的函数调用关系转换为显式的栈管理结构,从而在保持正确性的同时提升运行效率。

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]

面对这个看似棘手的控制流问题,我们稍作停顿。 考虑引入“延续” (continuation) 这一关键概念——其本质是对程序剩余计算过程的抽象建模。 具体而言,对于表达式 $1 + 2 * 3 + 4$ 的求值过程:当计算 $2 * 3$ 时,其延续即为带洞表达式 $1 + \square + 4$ 所表征的后续计算。 形式化地,我们可以将延续定义为高阶函数 $\lambda x. 1 + x + 4$, 该函数接收当前计算结果并执行剩余计算过程。

延续传递形式 (Continuation-Passing Style,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 })
}

通过严格的 CPS 变换,程序的控制流获得了显式的过程化表征。 基于此,我们可进一步实施延续的去函数化,即将高阶函数表示转化为数据结构表示。 观察到延续包含两种形态:递归处理函数 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)
}

为实现彻底的命令式转换,我们先将尾递归形式改写为显式循环结构。 通过 MoonBit 的 loop 语法,控制流的跳转关系得到直观呈现:

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]

$$ \text{CPS} \to \text{Defunctionalization} \to \text{Inlining} \to \text{Tail Call Elimination} $$

让我们对这一系列精妙的程序转换过程进行系统性的理论总结。 整个改造工程可分解为四个阶段:

  • 第 Ⅰ 阶段:控制流显式化 (CPS 变换): 通过引入延续传递形式,我们将原本隐含在语言运行时中的执行上下文显式提升为可操作的一等公民。 这一关键转换使递归调用点的控制流转移过程浮出水面,为后续的机械化改造奠定了基础。

  • 第 Ⅱ 阶段:去函数化: 基于 Reynolds 提出的去函数化理论, 我们将高阶的延续函数降维为具体的代数数据类型 TreeCont。 这一转换揭示了延续本质上是对调用栈的结构化建模, 其中 Next 构造子对应栈帧压入操作,对其模式匹配则是出栈, Return 则表征栈空状态。 通过此步骤,动态的函数调用关系被静态的数据结构所替代。

  • 第 Ⅲ 阶段:内联与尾递归化: 通过将辅助函数 run_cont 内联到主处理流程, 我们消除了函数间的相互递归调用,形成严格的尾递归形式。 此时程序的执行流已呈现近线性结构, 每个函数调用点的上下文关系完全由传入的延续对象所决定, 这为尾调用优化提供了理想的先决条件。

  • 第 Ⅳ 阶段:迭代式转换: 最终阶段将尾递归结构转换为基于可变状态和循环命令的迭代实现。 这一转换过程严格对应现代编译器对尾调用的优化策略: 将递归调用点改写为带状态更新的循环跳转,将延续对象转换为显式的栈数据结构。 值得注意的是,从 TreeCont 到命令式栈的转换验证了理论计算机科学中“程序即数据”的观点。

“去函数化”的确是精妙的程序转换技术, 若读者在看完本文仍意犹未尽, 希望能够深入了解其背后的理论基础, 推荐阅读 John Reynolds 关于去函数化的经典文章, 以及 Olivier Danvy 很多相关的研究工作。

Blog 5. The Abstraction of Scientific Computing in LunaFlow [blog/lunaflow]

LunaFlow 是一个基于 MoonBit 的科学计算框架, 旨在为用户提供高效、灵活的计算能力。 该框架在设计理念上有许多独到之处, 尤其体现在数据抽象与泛型算法的实现。 然而遗憾的是,未有技术文档详尽阐释这些核心设计背后的理论依据与实践权衡。

作为 LunaFlow 的架构者之一,笔者撰写本文的目的在于: 其一,系统性地剖析 LunaFlow 的顶层设计哲学,揭示其主要设计原则与实现细节。 其二,通过具体用例的实证分析,展示如何利用该框架构建高效的数值计算流程。 希望读者不仅能够领悟到此种抽象设计在工程实践中的精妙之处, 还能将这种范式迁移至自身的项目开发之中,提升代码的表达力与泛用性。

5.1. Levels of Abstraction [blog/lunaflow/layers]

LunaFlow 采用分层模块化架构, 通过严格的抽象层级将系统组件进行逻辑划分。 其架构可组织为树形结构: 位于根基位置的是 Luna-Generic 核心模块, 该模块借助 MoonBit 的 trait 系统实现了对基础数学结构的代码描述。 在此基础上,作为首要叶子节点的 Luna-Utils 模块承担了通用工具函数的实现, 而诸如 ComplexQuaternion 等基础数据结构包则构成了更为细化的叶节点。

Modules

高阶数学工具包被置于抽象体系的更上层级, 当前主力模块包括 Calculus NumericalLinear AlgebraPolynomial 等, 这些模块通过显式的依赖关系调用底层服务,展现出清晰的依赖倒置原则(Dependence Inversion Principle)。 尤为精妙的是,LunaFlow 的架构设计允许用户在 Luna-Generic 的抽象框架下扩展自定义数据结构, 这些用户定义类型可无缝融入现有类型系统,并与上层模块达成双向互操作。 自然地,这引出一个关键性问题:如何构建具有数学严谨性的通用数据抽象?

5.2. Mathematical Structures In Luna-Generic [blog/lunaflow/generic]

程序设计语言中抽象机制的一个根本目标, 在于对行为模式进行精确的形式化描述并实现有效的代码复用。 在此语境下,代数结构因其严谨的数学内涵, 恰好为这类抽象提供了坚实的理论基础。 MoonBit 语言设计的 trait 系统,通过精妙的类型约束与组合机制, 构建了一套完整的代数结构表达体系。 该系统允许开发者以类型安全的方式,将半群(Semigroup)到半环(Semiring),乃至更复杂的环(Ring)等代数结构进行层级化建模。

5.2.1. Semiring [blog/lunaflow/semiring]

在抽象代数中,一个半环(Semiring)是一个集合 $R$, 配备了两个二元运算:加法 $+$ 和乘法 $*$,且满足下面的性质:

  • $(R, +, 0)$ 是一个交换群(Commutative Group),即满足结合律、交换律和存在单位元。
  • $(R, *, 1)$ 是一个幺半群(Monoid),即满足结合律和存在单位元。

除此之外,还满足下面两条性质:

  • 分配律:$a * (b + c) = a * b + a * c$ 和 $(a + b) * c = a * c + b * c$。
  • $0 * a = 0$ 和 $a * 0 = 0$,其中 $0$ 是加法的单位元。

从数学定义出发,半环结构实际上包含两个相互关联的代数组成部分: 其一是具有交换性质的加法幺半群, 其二是乘法幺半群。 在 MoonBit 的类型系统中, 这两个基本结构分别对应着 trait AddMonoidMulMonoid 的实现 (其中 AddMonoid 隐含地要求加法运算满足交换律这一数学性质):

trait AddMonoid: Add + Zero {}
trait MulMonoid: Mul + One {}
trait Semiring: AddMonoid + MulMonoid {}

需要特别说明的是, 当前 MoonBit 的 trait 机制尚无法在类型层面强制保证代数公理的成立 —— 包括但不限于结合律、分配律等基本性质的正确性。 这种限制本质上源于类型系统的表达能力边界: 若要严格验证代数公理,必须借助依赖类型等高级类型理论工具, 而这与 MoonBit 作为工业级语言的设计目标有所偏离。 对这一话题感兴趣的读者,可进一步研读 LeanCoq 等定理证明器的相关文献, 这些语言可通过精密的类型机制表达更为复杂的数学约束。 下面是一个在 Lean 中定义半群的示例:

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

从软件工程的角度审视,代数结构的层级化抽象完美体现了关注点分离(Separation of Concerns)的设计哲学。 数学上,群(Group)在幺半群基础上引入逆元概念, 而环(Ring)又要求加法构成交换群。 这些递进关系在 MoonBit 中表现为 trait 的组合: 每个新特征只需声明其扩展的代数运算,无需重复定义底层运算。这种设计不仅消除了代码冗余, 更重要的是建立了数学理论与程序实现之间的严格对应, 使得抽象层次之间的关系如抽丝剥茧般清晰可见。

5.3. Validating Constraints on Algebraic Structures [blog/lunaflow/testing]

对于代数结构公理的验证,我们并非束手无策。 虽然从完备性角度无法穷尽所有可能的验证场景, 但通过精心构建的测试用例集合, 我们能够对公理在特定实例上的有效性进行系统化验证。 借助 QuickCheck 这一强大的属性测试框架, 我们得以实现从抽象代数公理转化为可执行规范(Executable Specifications)的范式转变。这一过程中,代数结构的基本公理恰成为指导属性编写的理论基石,配合自动生成的测试数据,能够对类型实现进行统计学上的验证。

5.3.1. How QuickCheck Works? [blog/lunaflow/quickcheck]

简单来说,QuickCheck 的本质是通过随机采样对程序行为进行统计验证。 用户可以制定待测试程序具有的属性, 通常可以视为是 (T) -> Bool 的函数, 其中返回值 Bool 表示程序是否满足该属性。 随后通过生成器(Generator)来创建随机数据, 并将其传递给属性函数,并验证返回值是否为 true。 (默认采用的是类型导向采样:即通过 Arbitrary trait 将类型与数据生成规则绑定) 若属性函数在所有随机数据上均返回 true, 则可以认为该属性成立。 否则,可认为该属性不成立,并且找到了一个反例, QuickCheck 接着尝试缩小反例的大小, 以便更好地理解问题的根源。

简单来说,QuickCheck 是一种基于随机采样对程序行为进行统计验证的测试框架。其核心工作机制可解构为以下几个的骤:

  1. 测试者形式化地定义待验证程序的属性,通常表示为一个谓词函数,其类型签名为 (T) -> Bool。其中,布尔返回值直观地表征当前输入数据是否满足预期性质。
  2. 框架通过预定义的生成器(Generator)构造符合类型约束的随机测试数据。这里涉及一个关键的技术实现细节:默认情况下,系统采用类型导向的采样策略,即通过 Arbitrary 这个 trait 将具体数据类型与其对应的随机生成规则进行约束绑定,从而实现类型安全的测试用例自动化生成。
  3. 当测试引擎获得随机生成的测试数据后,会将其作为参数传递给前述属性函数进行求值验证。倘若所有随机样本都能使属性函数返回 true,则表明属性成立。反之,若发现存在反例,框架不仅会精准定位违规用例,还会使用收缩算法,通过逐步缩小反例的大小,最终呈现最精简的反例形态,以提高诊断问题的效率与精确度。

在 QuickCheck 测试系统中,属性构造居于核心地位。 对于代数结构而言,其公理体系天然具备可检验性特质: 我们可以通过将数学公理直接映射为测试属性,在具体实现中建立严格的验证机制。 以 Linear-Algebra 代码库中的典型案例进行说明: 当定义表示向量的 Vector[T] 类型及其加法运算 op_add 时, 作为向量空间的基本要求,加法运算必须严格满足交换律与结合律。 具体而言,对于任意选择的向量 $\vec{u}, \vec{v}, \vec{w} \in V$, 必须满足以下数学约束:

$$ \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*} $$

在实现层面,我们可将其转化为如下测试验证(注:此处假定 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)
        }
    })
}

这种将数学公理机械性地转化为可执行验证机制的范式, 使得 LunaFlow 得以利用数学上的公理体系, 为实现的正确性提供强有力的测试保障。

5.4. Real-world Usage of LunaFlow (Polynomial) [blog/lunaflow/instances]

5.5. Future of LunaFlow [blog/lunaflow/future]

Blog 6. You could have invented Fenwick trees [blog/fenwick]

本文译自 Brent Yorgey 的 Functional Pearl 论文 You could have invented Fenwick trees,并做了一些解释补充、错误修正,以及将原文的 Haskell 代码都翻译到了 MoonBit 代码。

芬威克树(Fenwick trees),亦称二叉索引树(binary indexed trees),是一种精巧的数据结构,旨在解决这样一个问题:如何在维护一个数值序列的同时,支持在亚线性时间内完成更新操作与区间查询。其实现简洁高效,然亦颇为费解,多由一些针对索引的、不甚直观的位运算构成。本文将从线段树(segment trees)入手——这是一种更为直接、易于验证的纯函数式解法——并运用等式推理,阐释芬威克树的实现乃是一种优化变体,此过程将借助一个 MoonBit 嵌入式领域特定语言(EDSL)来处理无限二进制补码数。

Blog 7. 用 MoonBit 实现 LRU 缓存算法 [blog/lrualgorithm]

7.1. LRU 缓存简介 [blog/lrualgorithm/whats]

大家好!今天我想和大家分享如何用 MoonBit 语言实现一个 LRU(Least Recently Used,最近最少使用)缓存算法。无论你是刚接触 MoonBit,还是想了解 LRU 算法的实现细节,这篇文章都会带你深入了解这个经典而实用的算法。

什么是 LRU 缓存?

LRU 缓存是一种常见的缓存淘汰策略,它基于一个简单的假设:最近访问过的数据在不久的将来很可能再次被访问。因此,当缓存空间不足需要淘汰数据时,应该优先淘汰最长时间未被访问的数据。

一个日常生活的例子

想象你的书桌上只能放 5 本书。每次你需要一本新书,但书桌已满时,你会把哪本书放回书架?最合理的选择是把最久没看的那本书放回去。这就是 LRU 策略的直观体现。

技术应用场景

LRU 缓存在计算机系统中有广泛的应用:

  • 操作系统的页面置换:当物理内存不足时,系统需要决定将哪些页面从内存换出到磁盘
  • 数据库的缓存管理:数据库会在内存中缓存经常访问的数据块,提高读写性能
  • Web 服务器的缓存:缓存热门的网页、图片等资源,减少服务器负载
  • 浏览器的缓存机制:存储最近浏览的网页,加快重复访问的速度
  • CDN 内容分发网络:根据访问频率调整内容缓存策略

LRU 缓存的实际例子

假设我们有一个容量为 3 的 LRU 缓存,初始为空。现在执行以下操作:

  1. put(1, "一") → 缓存现状:[(1, “一”)]
  2. put(2, "二") → 缓存现状:[(2, “二”), (1, “一”)]
  3. put(3, "三") → 缓存现状:[(3, “三”), (2, “二”), (1, “一”)]
  4. get(1) → 返回 “一”,缓存现状:[(1, “一”), (3, “三”), (2, “二”)] (注意 1 被移到了最前面)
  5. put(4, "四") → 缓存已满,淘汰最久未使用的 (2, “二”),缓存现状:[(4, “四”), (1, “一”), (3, “三”)]
  6. get(2) → 返回 None (不存在)

通过这个例子,我们可以看到 LRU 缓存如何维护“最近使用“的顺序,以及如何在缓存满时进行淘汰。

LRU 缓存的核心操作

一个标准的 LRU 缓存需要支持以下两个核心操作:

  1. get(key): 获取缓存中键对应的值

    • 如果键存在,返回对应的值,并将该键值对移动到“最近使用“的位置
    • 如果键不存在,返回特殊值(如 None)
  2. put(key, value): 向缓存中插入或更新键值对

    • 如果键已存在,更新值,并将该键值对移动到“最近使用“的位置
    • 如果键不存在,创建新的键值对,放到“最近使用“的位置
    • 如果缓存已满,先删除“最久未使用“的键值对,再添加新的键值对

这两个操作的时间复杂度都应该是 O(1),这就需要我们精心设计数据结构。

为什么需要特殊的数据结构?

实现一个高效的 LRU 缓存面临两个主要挑战:

  1. 快速查找:我们需要 O(1) 时间确定一个键是否存在于缓存中
  2. 顺序维护:我们需要跟踪所有缓存项的使用顺序,以便知道哪个是“最久未使用“的

如果只用数组,查找需要 O(n) 时间;如果只用哈希表,无法跟踪使用顺序。因此,我们需要结合两种数据结构。

实现思路:哈希表 + 双向链表

LRU 缓存的经典实现方式是结合使用:

  • 哈希表:提供 O(1) 的查找能力,键映射到链表中的节点
  • 双向链表:维护数据的访问顺序,最近使用的在链表头,最久未使用的在链表尾

这种组合让我们能够同时满足快速查找和顺序维护的需求。

接下来,我们将用 MoonBit 语言一步步实现这个数据结构,从基础的节点定义,到完整的 LRU 缓存功能。

7.2. 定义基础数据结构 [blog/lrualgorithm/define]

在实现 LRU 缓存之前,我们首先需要定义一些基础的数据结构。正如前面所说,我们需要结合哈希表和双向链表来实现高效的 LRU 缓存。

节点结构

我们先定义双向链表的节点结构。每个节点需要存储键值对,并且包含指向前一个和后一个节点的引用:

// 定义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 }
}

在这个节点结构中有几个有趣的 MoonBit 特性值得我们关注:

  • 泛型参数 [K, V]:这使得我们的 LRU 缓存可以适用于任何类型的键和值,非常灵活。

  • 可变字段 mutvalueprenext 都被标记为 mut,意味着它们可以在创建后修改。这对于我们需要频繁调整链表结构的 LRU 缓存来说是必须的。

  • 可选类型 ?prenext 是可选类型,表示它们可能为 None。这样处理链表两端的节点会更加自然。

  • 简单构造函数new_node 函数帮助我们创建一个新节点,初始状态下前驱和后继都是 None

LRU 缓存结构

接下来,我们来定义 LRU 缓存的主体结构:

// LRU缓存结构体
struct LRUCache[K, V] {
  capacity : Int // 容量
  dummy : Node[K, V] // 哑节点,用于标记双向链表的头尾
  key_to_node : Map[K, Node[K, V]] // 键到节点的映射
}

这个结构包含三个关键字段:

  • capacity:定义了缓存可以存储的最大键值对数量。

  • dummy:一个特殊的哑元节点,不存储实际的键值对,而是用来简化链表操作。使用哑元节点是一个常见的编程技巧,它可以帮助我们避免处理空链表和边界条件的特殊情况。

  • key_to_node:一个从键到节点的映射表,使我们能够在 O(1) 时间内通过键找到对应的节点。

为什么使用哑元节点?

哑元节点的使用是一个很巧妙的设计。我们将在链表中使用一个环形结构,其中哑元节点同时充当链表的“头“和“尾“:

  • dummy.next 指向链表中最近使用的节点(实际的第一个节点)
  • dummy.pre 指向链表中最久未使用的节点(实际的最后一个节点)

这样的设计使得我们在处理节点插入和删除时,不必担心空链表的特殊情况,代码逻辑会更加统一和清晰。

通过这两个基础数据结构,我们已经为实现一个高效的 LRU 缓存打下了基础。在下一部分,我们将开始实现 LRU 缓存的构造函数和核心操作。

7.3. 构造函数实现 [blog/lrualgorithm/construction]

现在我们已经定义好了基础数据结构,接下来需要实现 LRU 缓存的构造函数。构造函数是任何数据结构的起点,它负责正确初始化内部状态。

构造函数的设计

在设计 LRU 缓存的构造函数时,我们需要考虑:

  • 初始化缓存的最大容量
  • 创建并设置哑元节点
  • 初始化空的键值映射表

让我们看看如何用 MoonBit 实现这个构造函数:

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_kdefault_v 来初始化哑元节点。这是 MoonBit 类型系统的要求,即使哑元节点不用于存储实际数据,我们也需要为它提供合法的值。

  • 自引用环:我们将哑元节点的 nextpre 都指向自身,形成一个空的环形链表。这是一种巧妙的技巧,确保我们在链表为空时,不会遇到空指针问题。

  • 返回结构体实例:使用了 MoonBit 的结构体字面量语法 { capacity, dummy, key_to_node: Map::new() } 创建并返回了 LRUCache 的实例。

为什么要形成环形结构?

你可能注意到了,我们将哑元节点的前驱和后继都指向了自身,形成了一个环。这样做的好处是:

  • 不需要处理空链表的边界情况
  • 插入第一个节点和之后的节点使用相同的逻辑
  • 删除最后一个节点和之前的节点使用相同的逻辑

在空链表中,哑元节点既是“头“也是“尾“。当我们添加实际节点时,它们会被插入到这个环中,而哑元节点始终保持在原位,帮助我们定位链表的逻辑起点和终点。

示意图说明

初始状态下,我们的链表结构如下:

dummy ⟷ dummy (自己指向自己)

添加一个节点 A 后:

dummy ⟷ A ⟷ dummy

添加另一个节点 B(放在最近使用的位置)后:

dummy ⟷ B ⟷ A ⟷ dummy

在这个结构中,dummy.next 指向最近使用的节点(B),而 dummy.pre 指向最久未使用的节点(A)。

这样的设计为我们后面实现 getput 等核心操作奠定了基础。下一步,我们将实现这些核心操作以完成 LRU 缓存的功能。

7.4. 核心操作 - get 和 put [blog/lrualgorithm/core]

接下来我们要实现 LRU 缓存的两个核心操作:getput。这两个操作是 LRU 缓存的精髓所在,也是最常用的接口。

get 方法实现

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 方法做了几件重要的事:

  1. 检查键是否存在于哈希表中
  2. 如果存在,获取对应的节点
  3. 将节点从当前位置移除
  4. 将节点插入到链表前端(表示最近使用)
  5. 返回找到的节点

这里我们用到了两个辅助方法 removepush_front(后面会详细介绍),它们负责链表的具体操作。

put 方法实现

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 方法的逻辑分为几个部分:

  1. 检查键是否已存在

    • 如果存在,直接更新值并调整位置(通过前面的 get_node 方法)
    • 这里我们只需要修改值,因为 get_node 已经将节点移到了链表前端
  2. 创建新节点

    • 如果键不存在,则创建一个新的节点存储键值对
  3. 添加到链表和哈希表

    • 将新节点插入到链表前端
    • 在哈希表中添加键到节点的映射
  4. 检查容量并可能淘汰

    • 如果缓存大小超过了容量,需要淘汰最久未使用的项
    • 最久未使用的项就是链表尾部(dummy.pre)指向的节点
    • 从哈希表和链表中都删除这个节点

LRU 算法的核心思想

通过这两个方法,我们可以看到 LRU 算法的核心思想:

  • 每次访问(get)或更新(put)一个键,都将它移到“最近使用“位置
  • 当需要淘汰时,移除“最久未使用“位置的键

这种设计确保了我们总是保留最有可能再次被访问的数据,而丢弃最不可能再次被访问的数据,从而最大化缓存的命中率。

在下一部分,我们将详细介绍 push_frontremove 这两个关键的辅助方法,它们是实现 LRU 缓存链表操作的基础。

7.5. 辅助操作 - push_front 和 remove [blog/lrualgorithm/auxiliary]

在上一部分中,我们实现了 LRU 缓存的核心操作 getput,但它们都依赖于两个关键的辅助方法:push_frontremove。这两个方法负责维护双向链表的结构,是整个 LRU 算法能够正常工作的基础。

push_front 方法实现

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

这个方法的逻辑虽然只有几行,但需要仔细理解:

  1. 将新节点的 next 指向原来的第一个节点(即 dummy.next
  2. 将新节点的 pre 指向哑元节点
  3. 更新哑元节点的 next 指向新节点
  4. 更新原第一个节点的 pre 指向新节点

通过这四步,我们完成了在链表头部插入新节点的操作。注意我们使用了 is Some 模式匹配来安全地处理可选值,这是 MoonBit 处理空值的一种优雅方式。

remove 方法实现

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
  }
}

这个方法的实现非常直观:

  1. 将节点的前一个节点的 next 指向节点的下一个节点
  2. 将节点的下一个节点的 pre 指向节点的前一个节点

通过这两步,节点就从链表中“断开“了,不再是链表的一部分。

为什么这两个操作如此重要?

这两个看似简单的操作是整个 LRU 缓存算法的关键:

  • push_front 确保最近访问的项总是位于链表的前端
  • remove 用于两个场景:
    1. 将节点从当前位置移除,然后重新插入到链表前端(更新使用顺序)
    2. 淘汰最久未使用的节点(当缓存超出容量时)

通过这两个基本操作,我们能够维护一个按照“最近使用“到“最久未使用“排序的双向链表,从而实现 LRU 缓存的核心功能。

双向链表操作的细节

在处理双向链表时,容易出现的错误是指针操作顺序不当导致链表断裂或形成环。我们的实现避免了这些问题:

  • push_front 中,我们先设置新节点的指针,再更新相邻节点的指针
  • remove 中,我们直接更新相邻节点的指针,跳过要删除的节点

这种实现方式确保了链表操作的安全性和正确性。

使用哑元节点的优势

回顾一下我们设计中使用的哑元节点(dummy node),它带来了以下优势:

  • 简化了链表为空的处理
  • 统一了节点插入和删除的逻辑
  • 提供了稳定的“头“和“尾“引用点

有了这些辅助操作,我们的 LRU 缓存算法就更加完整了。接下来,我们会添加一些额外的实用方法,让我们的 LRU 缓存更加易用。

7.6. 实用辅助方法 [blog/lrualgorithm/auxiliary-other]

我们已经实现了 LRU 缓存的核心功能,包括基本数据结构、构造函数、核心操作 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 方法,快速判断一个键是否在缓存中。

这些辅助方法的意义

虽然这些方法看起来很简单,但它们对于实际使用 LRU 缓存非常有价值:

  • 提高可读性:使用 is_empty()size() == 0 更直观
  • 封装实现细节:用户不需要知道内部使用了 key_to_node 哈希表
  • 接口完整性:提供了一套完整的接口,满足各种使用场景

这些辅助方法体现了良好的 API 设计原则 —— 简单、一致且易于使用。通过提供这些方法,我们的 LRU 缓存实现更加健壮和用户友好。

实际应用示例

这些辅助方法在实际应用中非常有用,例如:

// 检查键是否存在,而不获取值(避免改变使用顺序)
if contains(cache, "key") {
  // 键存在,可以进行一些操作
}

// 缓存为空时执行特定逻辑
if is_empty(cache) {
  // 缓存为空,可以执行初始化或其他操作
}

// 获取缓存使用情况
let usage_percentage = size(cache) * 100 / cache.capacity

这些方法虽然简单,但它们大大增强了 LRU 缓存的实用性。通过这些辅助方法,我们的 LRU 缓存实现已经相当完整和实用。

7.7. 总结与思考 [blog/lrualgorithm/summary]

终于到了我们 LRU 缓存实现之旅的终点。这一路走来,我们从基本概念开始,一步步构建了一个完整的 LRU 缓存。说实话,当我第一次接触 LRU 算法时,就被它简单而优雅的设计所吸引。在实现过程中,我不仅体会到了算法的精妙之处,也感受到了 MoonBit 语言的表达力。

回顾整个实现过程,我最满意的是数据结构的选择。哈希表和双向链表的组合虽然是经典方案,但在 MoonBit 中实现时,语言本身的特性让代码显得格外简洁。特别是哑元节点的使用,解决了链表操作中的边界情况,让代码逻辑更加一致。这种小技巧看似简单,却能大幅简化实现,减少潜在的错误。

在编写过程中,我发现 MoonBit 的类型系统非常适合这类数据结构的实现。泛型让我们的缓存可以适用于各种类型的键值对,而可选类型(Option 类型)则优雅地处理了可能不存在的值的情况。与其他语言相比,不必担心空指针异常是一种解脱,让我可以更专注于算法本身的逻辑。

对我而言,编写 getput 方法是最有趣的部分。这两个方法看似简单,却包含了 LRU 算法的核心思想:每次访问都要更新使用顺序,确保最近使用的项目保留在缓存中。当我看到这些方法能够正确工作时,那种成就感是难以形容的。

说到实际应用,这种 LRU 缓存在日常开发中其实非常实用。我曾在一个网页应用中使用类似的缓存来存储频繁访问的数据,显著提升了应用响应速度。见过太多项目因为缺乏合理的缓存策略而性能低下,一个好的 LRU 实现往往能起到事半功倍的效果。

当然,这个实现还有改进空间。比如添加线程安全支持,或者引入基于时间的过期策略。在实际项目中,我会考虑根据具体需求扩展这些功能。不过,目前的实现已经涵盖了 LRU 缓存的核心功能,足以应对大多数场景。

我最大的收获可能是深入理解了算法与数据结构如何相互配合。LRU 缓存的优雅之处在于它巧妙地结合了两种数据结构,各取所长,达到了理想的性能。这种思路启发我在面对其他问题时,不要局限于单一的数据结构,而是思考如何组合现有工具来获得最佳解决方案。

最后,希望这个 LRU 缓存的实现过程对你有所帮助。无论你是在学习 MoonBit 语言,还是想深入了解缓存算法,我都希望这篇文章能给你一些启发。编程的乐趣不仅在于解决问题,还在于创造优雅的解决方案。在这个意义上,LRU 缓存是一个小巧而完美的例子。

如果你有任何问题或改进建议,欢迎在 Github 留言讨论。毕竟,代码和思想总是在交流中不断完善的。