MoonBit Pearls Vol.10:prettyprinter:使用函数组合解决结构化数据打印问题

prettyprinter:使用函数组合解决结构化数据打印问题

结构化数据的打印是编程中常见的问题,尤其是在调试和日志记录时。如何展示复杂的数据结构,并能够根据屏幕宽度调整排版?例如,对于一个数组字面量 [a,b,c] , 我们希望在屏幕宽度足够时打印为一行,而在屏幕宽度不足时自动换行并缩进。 传统的解决方案往往依赖于手动处理字符串拼接和维护缩进状态,这样的方式不仅繁琐,而且容易出错。

本篇文章将介绍一种基于函数组合的实用方案------prettyprinter的实现。Prettyprinter 向用户提供了一系列函数, 这些函数能够组合成一个描述了打印方式的Doc原语。然后,根据宽度配置和Doc原语生成最终的字符串。函数组合的思路使得用户能够复用已有的代码,声明式地实现数据结构的打印。

SimpleDoc 原语

我们先定义一个SimpleDoc表示4个最简单的原语,来处理最基本的字符串拼接和换行。

rust 复制代码
enum SimpleDoc {
  Empty
  Line
  Text(String)
  Cat(SimpleDoc, SimpleDoc)
}
  • Empty: 表示空字符串
  • Line:表示换行
  • Text(String): 表示一个不包含换行的文本片段
  • Cat(SimpleDoc, SimpleDoc): 按顺序组合两个 SimpleDoc

按照上面每个原语的定义,我们可以实现一个简单的渲染函数:这个函数使用一个栈来保存待处理的SimpleDoc,逐个将它们转换为字符串。

rust 复制代码
fn SimpleDoc::render(doc : SimpleDoc) -> String {
  let buf = StringBuilder::new()
  let stack = [doc]
  while stack.pop() is Some(doc) {
    match doc {
      Empty => ()
      Line => {
        buf..write_string("\n")
      }
      Text(text) => {
        buf.write_string(text)
      }
      Cat(left, right) =>
        stack..push(right)..push(left)
    }
  }
  buf.to_string()
}

编写测试,可以看到SimpleDoc的表达能力和 String 相当: Empty 相当于 ""Line 相当于 "\n" , Text("a") 相当于 "a"Cat(Text("a"), Text("b")) 相当于 "a" + "b"

rust 复制代码
test "simple doc" {
  let doc : SimpleDoc = Cat(Text("hello"), Cat(Line, Text("world")))
  inspect(
    doc.render(),
    content=(
      #|hello
      #|world
    ),
  )
}

目前它还和String一样无法方便地处理缩进和排版切换。不过,只要再添加三个原语就可以解决这些问题。

ExtendDoc:Nest, Choice, Group

接下来我们在SimpleDoc的基础上,添加三个新的原语Nest、Choice、Group来处理更复杂的打印需求。

rust 复制代码
enum ExtendDoc {
  Empty
  Line
  Text(String)
  Cat(ExtendDoc, ExtendDoc)
  Nest(Int,ExtendDoc)
  Choice(ExtendDoc, ExtendDoc)
  Group(ExtendDoc)
}
  • Nest Nest(Int, ExtendDoc) 用于处理缩进。第一个参数表示缩进的空格数,第二个参数表示内部的 ExtendDoc 。当内部的 ExtendDoc 包含 Line 时,render函数将在打印换行的同时追加相应数量的空格。 Nest 嵌套使用时缩进会累加。

  • Choice Choice(ExtendDoc, ExtendDoc) 保存了两种打印方式。通常第一个参数表示不包含换行更紧凑的布局,第二个参数则是包含 Line 的布局。当render在紧凑模式时,使用第一个布局,否则使用第二个。

  • Group Group(ExtendDoc) 将ExtendDoc分组,并根据 ExtendDoc 的长度和剩余的空间切换打印 ExtendDoc 时的模式。如果剩余空间足够,则在紧凑模式下打印,否则使用包含换行的布局。

计算所需空间

Group的实现需要计算 ExtendDoc 的空间需求,以便决定是否使用紧凑模式。我们可以为 ExtendDoc 添加一个 space() 方法来计算每个布局片段所需的空间。

rust 复制代码
let max_space = 9999

fn ExtendDoc::space(self : Self) -> Int {
  match self {
    Empty => 0
    Line => max_space
    Text(str) => str.length()
    Cat(a, b) => a.space() + b.space()
    Nest(_, a) | Choice(a, _) | Group(a) => a.space()
  }
}

对于 Line , 我们假设它总是需要占用无限大的空间。这样如果 Group 内包含 Line,能够保证render处理内部的 ExtendDoc 时不会进入紧凑模式。

实现 ExtendDoc::render

我们在SimpleDoc::render的基础上实现 ExtendDoc::render 。 render在打印完一个子结构后,继续打印后续的结构需要退回到原先的缩进层级,因此需要在stack中额外保存每个待打印的ExtendDoc的两个状态:缩进和是否在紧凑模式。我们还需要维护了一个在render过程中更新的 column 变量,表示当前行的已经使用的字符数,以计算当前行所剩的空间。另外,函数增加了额外的width参数,表示每行的最大宽度限制。

rust 复制代码
fn ExtendDoc::render(doc : ExtendDoc, width~ : Int = 80) -> String {
  let buf = StringBuilder::new()
  let stack = [(0, false, doc)] // 默认不缩进,非紧凑模式
  let mut column = 0
  while stack.pop() is Some((indent, fit, doc)) {
    match doc {
      Empty => ()
      Line => {
        buf..write_string("\n")
        // 在换行后打印需要的缩进
        for _ in 0..<indent {
          buf.write_string(" ")
        }
        // 重置当前行的字符数
        column = indent
      }
      Text(text) => {
        buf.write_string(text)
        // 更新当前行的字符数
        column += text.length()
      }
      Cat(left, right) =>
        stack..push((indent, fit, right))..push((indent, fit, left))
      Nest(n, doc) => stack..push((indent + n, fit, doc)) // 增加缩进
      Choice(a, b) =>
        stack.push(if fit { (indent, fit, a) } else { (indent, fit, b) })
      Group(doc) => {
        // 如果已经在紧凑模式下,直接使用紧凑布局。如果不在紧凑模式下,但是要打印的内容可以放入当前行,则进入紧凑模式。
        let fit = fit || column + doc.space() <= width
        stack.push((indent, fit, doc))
      }
    }
  }
  buf.to_string()
}

下面我们尝试用 ExtendDoc 描述一个 (expr) ,并在不同的宽度配置下打印它:

rust 复制代码
let softline : ExtendDoc = Choice(Empty, Line)

impl Add for ExtendDoc with op_add(a, b) {
  Cat(a, b)
}

test "tuple" {
  let tuple : ExtendDoc = Group(
    Text("(") + Nest(2, softline + Text("expr")) + softline + Text(")"),
  )
  inspect(tuple.render(width=40), content="(expr)")
  inspect(
    tuple.render(width=5),
    content=(
      #|(
      #|  expr
      #|)
    ),
  )
}

我们先通过组合EmptyLine的方式定义了一个在紧凑模式下不换行的 softline 。render默认以非紧凑模式开始打印,所以我们需要用 Group 将整个表达式包裹起来。这样在宽度足够时,整个表达式会打印为一行,而在宽度不足时会自动换行并缩进。为了减少嵌套的括号,改善可读性,这里给 ExtendDoc 重载了 + 运算符。

组合函数

在prettyprinter的实践中,用户更多地会使用在 ExtendDoc 原语基础之上组合出的函数------例如之前使用过的 softline 。下面将介绍一些实用的函数,帮助我们解决结构化打印的问题。

softline & softbreak

rust 复制代码
let softbreak : ExtendDoc = Choice(Text(" "), Line)

softline 类似,不同的是在紧凑模式下它会加入额外的空格。注意在同一层 Group 中,每个 Choice 都会一致选择紧凑或非紧凑模式。

rust 复制代码
let abc : ExtendDoc = Text("abc")

let def : ExtendDoc = Text("def")

let ghi : ExtendDoc = Text("ghi")

test "softbreak" {
  let doc : ExtendDoc = Group(abc + softbreak + def + softbreak + ghi)
  inspect(doc.render(width=20), content="abc def ghi")
  inspect(
    doc.render(width=10),
    content=(
      #|abc
      #|def
      #|ghi
    ),
  )
}

autoline & autobreak

rust 复制代码
let autoline : ExtendDoc = Group(softline)

let autobreak : ExtendDoc = Group(softbreak)

autolineautobreak 实现一种类似于文字编辑器的排版:尽可能多地将内容放进一行内,溢出则换行。

rust 复制代码
test {
  let doc : ExtendDoc = Group(
    abc + autobreak + def + autobreak + ghi,
  )
  inspect(doc.render(width=10), content="abc def ghi")
  inspect(
    doc.render(width=5),
    content=(
      #|abc def
      #|ghi
    ),
  )
  inspect(
    doc.render(width=3),
    content=(
      #|abc
      #|def
      #|ghi
    ),
  )
}

sepby

rust 复制代码
fn sepby(xs : Array[ExtendDoc], sep : ExtendDoc) -> ExtendDoc {
  match xs {
    [] => Empty
    [x, .. xs] => xs.fold(init=x, (a, b) => a + sep + b)
  }
}

sepby会在ExtendDoc之间插入分隔符sep

rust 复制代码
let comma : ExtendDoc = Text(",")
test {
  let layout = Group(sepby([abc, def, ghi], comma + softbreak))
  inspect(layout.render(width=40), content="abc, def, ghi")
  inspect(
    layout.render(width=10),
    content=(
      #|abc,
      #|def,
      #|ghi

    ),
  )
}

surround

rust 复制代码
fn surround(m : ExtendDoc, l : ExtendDoc, r : ExtendDoc) -> ExtendDoc {
  l + m + r
}

surround 用于在 ExtendDoc 的两侧添加括号或其他分隔符。

rust 复制代码
test {
  inspect(surround(abc, Text("("), Text(")")).render(), content="(abc)")
}

打印Json

利用上面定义的函数,我们可以实现一个打印Json的函数。这个函数将递归地处理Json的每个元素,生成相应的布局。

rust 复制代码
fn pretty(x : Json) -> ExtendDoc {
  fn comma_list(xs, l, r) {
    (Nest(2, softline + sepby(xs, comma + softbreak)) + softline)
    |> surround(l, r)
    |> Group
  }

  match x {
    Array(elems) => {
      let elems = elems.iter().map(pretty).collect()
      comma_list(elems, Text("["), Text("]"))
    }
    Object(pairs) => {
      let pairs = pairs
        .iter()
        .map(p => Group(Text(p.0.escape()) + Text(": ") + pretty(p.1)))
        .collect()
      comma_list(pairs, Text("{"), Text("}"))
    }
    String(s) => Text(s.escape())
    Number(i) => Text(i.to_string())
    False => Text("false")
    True => Text("true")
    Null => Text("null")
  }
}

可以看到在不同的打印宽度下,Json的排版会自动调整。

rust 复制代码
test {
  let json : Json = {
    "key1": "string",
    "key2": [12345, 67890],
    "key3": [
      { "field1": 1, "field2": 2 },
      { "field1": 1, "field2": 2 },
      { "field1": [1, 2], "field2": 2 },
    ],
  }
  inspect(
    pretty(json).render(width=80),
    content=(
      #|{
      #|  "key1": "string",
      #|  "key2": [12345, 67890],
      #|  "key3": [
      #|    {"field1": 1, "field2": 2},
      #|    {"field1": 1, "field2": 2},
      #|    {"field1": [1, 2], "field2": 2}
      #|  ]
      #|}
    ),
  )
  inspect(
    pretty(json).render(width=30),
    content=(
      #|{
      #|  "key1": "string",
      #|  "key2": [12345, 67890],
      #|  "key3": [
      #|    {"field1": 1, "field2": 2},
      #|    {"field1": 1, "field2": 2},
      #|    {
      #|      "field1": [1, 2],
      #|      "field2": 2
      #|    }
      #|  ]
      #|}
    ),
  )
  inspect(
    pretty(json).render(width=20),
    content=(
      #|{
      #|  "key1": "string",
      #|  "key2": [
      #|    12345,
      #|    67890
      #|  ],
      #|  "key3": [
      #|    {
      #|      "field1": 1,
      #|      "field2": 2
      #|    },
      #|    {
      #|      "field1": 1,
      #|      "field2": 2
      #|    },
      #|    {
      #|      "field1": [
      #|        1,
      #|        2
      #|      ],
      #|      "field2": 2
      #|    }
      #|  ]
      #|}
    ),
  )
}

总结

本文介绍了如何简单实现一个prettyprinter,使用函数组合的方式来处理结构化数据的打印。通过定义一系列原语和组合函数,我们可以灵活地控制打印格式,并根据屏幕宽度自动调整布局。

当前的实现还可以进一步优化,例如通过记忆化space的计算来提高性能。ExtendDoc::render函数可以增加一个ribbon参数,分别统计当前行的空格和其他文本字数,并且在Group的紧凑模式判断中增加额外的条件,来控制每行的信息密度。另外,还可以增加更多的原语来实现悬挂缩进、最小换行数量等功能。对于更多的设计和实现细节感兴趣的读者,可以参考A prettier printer - Philip Wadler以及Haskell、OCaml等语言的prettyprinter实现。

相关推荐
牛奔38 分钟前
Go 如何避免频繁抢占?
开发语言·后端·golang
想用offer打牌6 小时前
MCP (Model Context Protocol) 技术理解 - 第二篇
后端·aigc·mcp
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
KYGALYX7 小时前
服务异步通信
开发语言·后端·微服务·ruby
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
爬山算法8 小时前
Hibernate(90)如何在故障注入测试中使用Hibernate?
java·后端·hibernate
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端