优化Lua字符串字面量以节省400字节

Hacker News Top 工具

摘要

一篇技术博客文章,探讨在ComputerCraft程序环境下,通过选择最优表示方式(引号字符串与原始字符串)来减小Lua字符串字面量大小的方法,实现了400字节的节省。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/16 19:53

# 优化 Lua 字符串字面量以节省 400 字节 来源:https://purplesyringa.moe/blog/guest/optimizing-lua-string-literals-to-save-400-bytes/ 2026年7月2日 客座文章 (https://sylfn.codeberg.page/26w27a-optimizing-lua-string-literals/) 这是一篇由 Yuki (https://codeberg.org/sylfn/) 撰写的客座文章,介绍我们在共享的 ComputerCraft 爱好者项目 (https://github.com/purplesyringa/computercraft-programs/) 中用于 Lua 代码压缩的一些技巧。我之前写过我们如何为此目的适配 bzip2 (https://purplesyringa.moe/blog/an-ode-to-bzip/);这篇故事是同一背景下的一篇续作。 如果你想阅读更多她的文章,这里有 Atom 订阅源 (https://sylfn.codeberg.page/atom.xml) 和博客 (https://sylfn.codeberg.page/)。 ### 背景 我和我的女朋友为 CC: Tweaked (https://github.com/purplesyringa/computercraft-programs) 创建了一堆很酷的程序。CC:T 的程序是用 Lua 编写的,而游戏内的计算机磁盘空间有限。未调整的限制是每台计算机(或海龟)1 MB 的磁盘空间,以及每张软盘 125 KB 的磁盘空间。 我们的程序远远超出了这个限制。在撰写本文时,仓库中所有 lua 代码及其文档的连接后大小略大于 440 KB,这无法放入一张软盘,而且实在太大。 不过,我们有一个解决大小问题的方案,那就是压缩。我们的数据主要是 Lua 代码和文本,而有一些压缩算法 (https://purplesyringa.moe/blog/an-ode-to-bzip/) 在这方面是(在输出大小和解压速度方面)最佳。 我们的目标之一是创建一个自解压存档,形式为单个文件,这样安装只需在 shell 中输入: `` wget https://cc.purplesyringa.moe/initrd.lua startup.lua `` ### 替代? 这一壮举需要将数据与解压器合并。最笨的方法是将数据序列化为 Lua 字面量,但这有一些问题。序列化 `nil`、`true`、`false`、数字和列表是极其简单的。困难的部分是表和字符串。 表有两种主要的键表示方法:基于标识符和基于表达式。这两个表是等价的: `` { key = 10 } { ["key"] = 10 } `` 第一种使用基于标识符的键,这只能是有效的标识符,然后被转换为字符串;第二种是通配变体。我没有测试过是否值得到处使用基于表达式的键,但我感觉不值得。 替代的最终 Boss 是字符串。 这能有多难呢? ### 字符串是困难的 有一个梗是关于 YAML 中有几十种创建字符串的方法,以及它如何让不熟悉的人感到困惑。这种情况有点像这样。 Lua 字符串(特别是 Cobalt(CC 中使用的 Lua 运行时)中的字符串)是字节字符串,类似于 Python 中的 `b"meow"` 但更糟,或者像 Rust 中的 `b"meow"` 但更好。有三种主要方式编写字符串: 1. 单引号,单行:`'meow'`。反斜杠转义有效。 2. 双引号,单行:`"meow"`。反斜杠转义有效。 3. 原始字符串,可多行:`[[meow]]` 和 `[=[this [[is]] a string]=]`。反斜杠被视为普通字符。 带引号的字符串很酷。如果字符串不包含任何换行或特殊字符,那么带引号的表示法显然是最好的。这可能是你编写大多数正常字符串的方式。然而,原始字符串…… ### 原始字符串 原始字符串以 `[=[`(任意数量的 `=`)开始,以 `]=]`(相同数量的 `=`)结束,并且其内容不能包含起始符或结束符。这使得 `[=[meow [=[ mrrp]=]` 是一个无效字符串,但 `[[meow [=[ mrrp]]` 是有效的。 有人可能会想:“好吧,让我们找到 `\[=\+\[|\]=\+\]` 正则表达式的最长匹配,然后比它们多一个 = 号”,但这在 `[=[==[` 上会失败。 有人可能会想:“好吧,让我们通过下一个字符来过滤 `[\[\]]=*` 的匹配,然后比它们多一个 = 号”,这基本上能行……大部分时候。 但这里还有细微差别。 ### 细微差别??? 表键中的原始字符串是特殊的,需要空格,就像 C++11 之前的 C++ 模板: `` { [ [[meow]]] = 10 } `` 这是最容易处理的细微差别之一,但还有更多。 什么是换行符?有人甚至可能说它是一个序列。Cobalt 在原始字符串中将换行序列(CRLF、LFCR、单个 CR、单个 LF)折叠成单个 LF,这就像对你的 PNG 运行 `dos2unix` 并期望它不会损坏一样。 而 Cobalt 还会做一件事。如果字符串以换行符开头,该换行符会被丢弃。只有第一个换行符受到影响,所以这两条都是 true: `` print("meow" == [[ meow]]) print("\nmeow" == [[ meow]]) `` ### 处理 CR 所以我们终究还是需要转义。但原始字符串之所以称为原始,是因为它们不以任何特殊方式解释反斜杠!我们该如何转义? 我们有一个工具,叫做 `gsub` (https://www.lua.org/manual/5.2/manual.html#pdf-string.gsub)。 如果字符串中有一个未使用的字符(对于文本文件,大约是 `\0`),那么我们可以将所有 CR 替换为该字符,然后使用 gsub,就像这样: `` print("\r\r\r" == ([[RRR]]):gsub('R','\r')) `` 注意,因为我们在普通字符串以及作为 `gsub` 的参数中使用该字符,所以某些字符被禁止用作“转义”字符,即: - CR 本身 - `\`,正常的转义字符 - 任何 `^$()%.\[\]*+-?`,这些都是特殊的模式字符 - 单引号(我们在 Rust 端使用它作为字符串边界,使代码更易读) 简单的情况已经用完了,我们现在有一个包含各种字节的字节序列,甚至包括 CR,而且所有这些都需要保留。 处理这个问题的一种方法是同时替换 CR 和我们的转义字符(例如 `E`)为其他东西,例如: - `Er` 表示 CR - `E]` 表示转义字符本身 这种解决方案有效,但如果字符串具有近乎均匀的字节分布,则字符串的大小会增加大约 1%。 还有另一种方法。我们可以将“它实际上是 CR 吗”的信息带外存储,然后通过 `gsub` 应用: `` ([[string]]):gsub('E', (function(i) return function() i = i + 1 return bit32.btest( ('V'):byte(math.floor(i/7)), 2^(i%7) ) and '\r' end end)(6)) `` 其中 `E` 是转义字符,`V` 是位串。 这大约一百字节(为可读性未压缩)充满了技巧: - 只访问 `V` 的低 7 位,这样我们总能设置最高位,避免撞上特殊字符。 - 使用 `and '\r'` 将转义替换为 CR,因为如果 `gsub` 回调返回 `nil` 或 `false`,`gsub` 不会执行替换。 - `function(i)` 创建了一个局部变量,在调用点初始化为 `6`。 - `i = 6` 是初始状态,以补偿 Lua 中基于 1 的索引。 这几乎将 CR 开销减半,因为现在 CR/转义用大约 9 位编码,而不是 16 位。 可能有进一步简化此过程的方法,如果你知道,请告诉我。 ### 额外:Rust 视角 还有一个技巧: `` core::iter::repeat(0..7).flatten() `` 创建一个迭代器,生成 `0, 1, ..., 7, 0, 1, ..., 7, 0, 1, ...`,即我们需要填充的位的索引。这比使用 `% 7` 和 `.enumerate()` 感觉更简洁。 完整的字符串序列化代码可以在这里访问 (https://github.com/purplesyringa/computercraft-programs/blob/df3551a486e239f7761312ba8989560229ddd241/initrd-ng/initrd-core/src/ser.rs#L102)。

相似文章

Zig ELF 二进制文件代码高尔夫 (2025)

Lobsters Hottest

深入技术探讨如何缩小 Zig ELF 二进制文件的大小,从 2180K 缩减至 500 字节以下,通过去除调试信息、切换到 ReleaseSmall 以及使用 freestanding 目标。