用Vim宏实现康威生命游戏:从录制到命令解释器
有段时间我特别喜欢在终端里折腾一些看起来没什么用、但做起来很上头的事。有一天盯着 Vim 的宏录制功能我冒出一个念头如果只用q录制、回放这一套最基本的东西不碰 vimscript不装插件能不能把康威生命游戏跑起来这个想法被同事评价为“纯粹是闲的”。但真开始录之后我发现它比想象中更能训练对编辑器的理解。标题里的“手元”按我自己的理解就是一份亲手操作、边做边解说的记录。这里不会给你一大段直接复制就能跑的完整宏代码因为完整版本往往很长而且不同终端、不同棋盘尺寸都会影响具体按键。但我会把宏为什么能实现生命游戏、录制时最该注意什么、遇到问题怎么查讲得尽量清楚。先把观点放在前面用 vim 宏实现生命游戏本质上不是在一个文本编辑器里硬塞一个游戏引擎而是把一组有限状态演化规则翻译成按键序列。难点从来不是规则本身而是状态管理、边界处理和宏的终止条件。1. 宏编程的本质从“录操作”到“命令解释器”1.1 宏不是自动按键机而是可组合的命令流很多人第一次接触宏会把它理解成“批量重复按键”。这个理解不算错但会低估它能做的事。宏录制机制的本质是你把一串按键写入一个命名寄存器之后随时可以按名字回放。qa表示把后续操作录制进寄存器a再次按q结束a表示把寄存器a里保存的按键序列执行一遍。这里的关键不是“录”而是“可组合”。宏里面可以继续调用另一个宏可以包含查找命令也可以包含替换命令。如果愿意你甚至可以让宏在执行到某个查找命令时因为找不到目标而自动中断。这就让宏具备了条件判断的雏形找到就继续找不到就停。再加上a在宏内部自我调用就构成了递归。于是你会发现宏并不是只能做简单替换它其实是一套运行在编辑器里的命令解释器。我每次给别人演示宏的时候都会用一个例子在两个文件里分别有一批格式相似的行手动改需要重复十几步。录一个宏切到下一个文件按一次a再往下一个文件去执行。整个过程不是“播放录像”而是“调用函数”。一旦你把这个心智模型建立起来宏就不再是抄近路的小技巧而是一个正式的工具。1.2 为什么宏能表达计算为什么宏能做到这些因为它的运行依赖三个要素。第一缓冲区文本本身是存储既有棋盘状态就写在文件里不需要额外数据结构。第二查找命令是判断/xxx能定位到目标搜索失败则中断这个失败机制在宏里可以被当作一个隐含的条件分支。第三寄存器是临时变量宏内容存在寄存器里不同寄存器之间的调用可以传递控制流。我们通常说某个环境“图灵完备”意思是只要给它足够的时间它能表达任何可计算过程。宏编程在这个层面上很接近图灵完备有存储、有判断、有循环。它不像 Python、C 那样容易读但表达能力确实存在。理解这一点之后再看生命游戏就会觉得顺理成章。当然这里说的“计算”不是指宏适合写算法题。宏里的循环没有明确的循环变量判断也得靠命令是否失败来间接表达所以它的表达能力很原始阅读和维护成本很高。但这种原始恰恰能用来做思维训练当你只能用最少的机制表达流程时你会更清楚流程的哪一部分是真正的核心。1.3 生命游戏为什么适合放进宏里康威生命游戏的规则不需要重复太多在一个二维网格里每个格子只有活或死两种状态对每一个格子统计周围 8 个邻居中活细胞的数量活细胞在邻居数为 2 或 3 时继续存活否则死亡死细胞在邻居数恰好为 3 时变为活细胞。这个规则看起来需要大量数值计算但如果把网格写成一个字符矩阵比如用O表示活用.表示死那么每一代演化本质上就是一次文本形态变换。宏做得最好的事情恰恰就是按固定规则反复处理文本。你只需要把“计算邻居数”和“决定下一代状态”这两步翻译成若干条 vim 命令然后把这一轮命令录进宏里剩下的事就是不断回放。这也是为什么这个实验虽然不能证明 vim 比编程语言强却能证明宏的抽象能力被大多数人低估了。另一个角度看生命游戏之所以经典是因为它用极其简单的局部规则涌现出复杂整体行为。宏录制也一样单个按键动作毫无智能但组合成宏之后它能持续执行一个完整的演化策略。两者在概念上是同构的小规则 重复迭代 复杂结果。2. 最小可运行流程先把循环跑起来再谈规则2.1 准备一个纯文本棋盘先准备环境。打开 Linux 终端里的 vim 或 vi新建一个文件把棋盘用一个固定宽度的文本矩阵表示。字符选择建议统一用.表示死细胞O表示活细胞。不要混用空格和制表符因为后面做替换命令时分隔符越少越不容易出错。棋盘宽度先设小一点比如 6 列、6 行方便观察每一轮变化。如果想严格兼容传统 vi建议在录制时优先使用那些几十年来都稳定的基础键0、$、j、k、h、l、:s、/、?、q、、G。vim 里的一些扩展命令在传统 vi 环境里未必可用所以对这个演示来说用基础键更稳。2.2 先录一个最简单的宏在正式录生命游戏之前先录一个最简宏建立循环的手感。把光标放在第一行依次按下面的键qa x j q这段宏的意思是开始录制到寄存器a删除当前光标所在字符光标移到下一行结束录制。之后执行a就会删除当前行的一个字符执行10a就会连续删除 10 个字符前提是文件里有那么多内容。这个例子虽然简单但它证明了宏回放不是机械地重放按键而是每次都基于新的光标位置产生新结果。常用操作可以整理成一张表操作按键说明开始录制qa把后续按键写入寄存器 a结束录制q停止录制回放一次a执行寄存器 a 里的按键回放多次10a连续执行 10 次查看宏内容:reg a检查录制结果追加录制qA在寄存器 a 原有内容后追加按键更贴近状态变换的一个例子是先手动把当前行里的某种字符替换成另一种字符。比如:s/O/x/g然后j下移。确认这组操作符合预期后再按qa把同样的操作录进去。这能避免录制时反复修改按键。不要小看这一步很多宏出问题不是因为规则复杂而是因为录制者自己也没想清楚每一步会产生什么结果。2.3 手动验证一遍再开始录制很多初学者一上来就想把完整的生命游戏规则录进宏结果录到一半按错一个键就得全部重来。我建议的顺序是先在命令行模式下手动执行一遍这一轮应该做的替换操作确认输出结果和预期一致然后把同样的操作录进宏。这里的核心原则是“先能手动跑通再考虑自动化”。一条命令在单次执行时都不稳定录进宏之后只会更不稳定。录制宏时建议遵守这三个原则先手动执行再录制。每次只加一个小功能。回放一次后立即检查缓冲区。这样做看起来很慢但实际最稳。生命游戏的宏问题不在录制本身而在于你很难判断“是这一步替换错了还是上一轮状态已经被污染了”。手动验证能帮你把问题隔离在更小的范围内。2.4 生命游戏宏的骨架结构把完整规则放进来宏的骨架大致是这样的宏 a 的执行流程示意 1. 定位到棋盘左上角 2. 对当前行执行一组状态变换替换命令 3. 移动到下一行 4. 如果还有下一行则继续否则结束其中第 2 步在完整实现里会被展开成很多细节要同时考虑当前格子的旧状态、周围 8 个方向和邻居计数。这不是宏本身做不到而是按键序列会变得非常长。实践里更常见的做法是把“每一步替换”从简单规则开始验证再逐步叠加。先让棋盘上某一种局部模式能正确变化再扩展到全盘。3. 真正的坑新旧状态、边界和光标位置3.1 最优先解决“同时更新”生命游戏有一个容易被新手忽视的性质所有格子是同时更新。上一轮棋盘是快照下一轮状态完全基于快照计算。如果在宏里一边扫描一边直接改原字符改到后面的格子时它周围的邻居可能已经被改成新一代了结果自然是错的。所以宏里必须安排一个快照区。常见做法是把当前棋盘复制到文件末尾所有计算都以快照为准完成后再用结果覆盖真正的棋盘区域。这一步看起来增加了很多命令但没有它整个宏就是不可靠的。不要一上来就把100a拉满。先用1a观察一次确认宏做了你预期的事再逐步增加循环次数。宏能不能自己停下来是这个实验里另一个有趣的问题。最直接的方式是使用计数回放100a让宏连续执行 100 次。但这要求你知道 100 次迭代大概够用而且如果中间出错很难定位。另一种方式是递归宏在宏的最后放一个a。这会让宏执行完一轮后再次调用自己。如果宏内部某条查找命令找不到目标命令会报错连锁反应会让宏停止。这个机制很有用但也有风险一旦终止条件没有被触发宏就会一直执行下去只能靠编辑器报错或手动中断。3.2 边界细胞和棋盘扩展棋盘边缘是第二个大坑。规则里每个格子都有 8 个邻居但棋盘第一行的上面没有行最后一列的右面没有列。如果直接按内部格子的方式处理边缘替换命令会默认缺省的邻居不存在也就是死亡。结果棋盘每迭代一轮边缘都会出现预料之外的收缩。更麻烦的是生命游戏里有名的滑翔机一旦移动到棋盘边缘就可能消失。解决思路是先给棋盘外围垫一圈全 0 的“哨兵行和哨兵列”宏只处理内部区域这样边缘格子也能正常计算邻居数。如果目标是观察滑翔机这类会持续移动的结构就还要预留扩展空间。棋盘不能卡死否则结构撞到边界就断了。预留多少取决于你想跑多少代这里没有统一答案只能在使用时提前想清楚。3.3 光标位置决定宏能否重复宏录制时记录的是按键不记录当时的绝对文件位置。回放时每个移动命令都会从当前光标位置开始重新解释。因此一个宏要能重复使用最好在宏开头先做一个明确的定位动作把光标放到一个确定的起点。如果只在宏结束时不放回原位第二次执行通常就会错位。一个常用的经验是每次执行完一轮把光标放回棋盘左上角这样下一轮可以以同样的前提开始。如果目标是兼容传统 vi定位时优先考虑0和G这类基础命令而不是 vim 的gg扩展。不同环境下gg是否能使用并不一致为了少踩坑建议使用兼容性更广的写法。单次执行正确不代表宏能稳定复用。宏能重复的关键是每次执行结束后都能回到一个确定的起点。3.4 宏跑飞后的排查链路宏跑飞的时候先不要急着改规则。按下面的顺序排查看现象是完全没有变化、状态错乱还是一直停不下来看输入棋盘字符是否统一是否有隐藏空格或制表符编码是否正常看光标宏录制的起点和结束位置在哪里有没有来回跳行看寄存器用:reg a查看宏实际录了什么里面是不是混进了移动、缩进、误按的键。看边界第一行、最后一行、棋盘外的哨兵区域有没有被宏误操作。看兼容性如果换到另一台机器的 vi 上跑有没有依赖某个只在 vim 里存在的命令。排查时最有效的方法是把10a改成1a把连续播放改成单步执行。每执行一次宏就观察一次缓冲区变化很快就能定位到是哪一条命令出了问题。4. 能带走什么宏的边界与选择判断4.1 宏本身也是一套迷你语言跑完这个实验最直接的收获不是掌握了某个冷门命令而是重新理解了宏的表达能力。宏里可以出现自我调用可以依靠查找失败退出可以在不同寄存器之间互相调用。这意味着它并不是一个简单的按键匣子而是一套受限制但完整的迷你语言。用过一次之后日常写 vim 命令时会更关注命令是否“可重复执行”这条移动命令是否会因光标位置不同产生不同结果这条替换命令的作用范围是否被精确限定。这个意识对写脚本、写自动化流程同样有迁移价值。可以把这个实验当成一个通用的三步法先最小化、再扩展、最后清理。第一步只处理一个单元格或一行单元格确认状态变换正确第二步扩展到一个棋盘加入邻居计数和边界处理第三步把快照区、临时标记、输出目录都清干净让宏可以重复执行。这套方法不只适用于宏也适用于任何自动化流程。4.2 什么时候用宏什么时候写脚本那是不是以后能用宏解决的问题都用宏不是。宏适合的是格式统一、逻辑简单、需要交互式观察效果的编辑任务。一旦任务需要大量参数、复杂分支、异常恢复或者以后还要维护修改脚本语言会是更稳的选择。可以按这个表格快速判断维度宏脚本任务规模临时、局部、多文件同构编辑批量、长期、需要自动化编排参数与分支弱不支持清晰传参强函数、变量、异常处理都可用可见性录制后不直观难阅读代码本身可读、可测稳定性依赖光标位置和当前缓冲区状态逻辑相对独立容易恢复典型场景手头 10 个文件做同一种小改解析日志、批量转换、持续集成宏也常被用来做跨文件批量操作但任务复杂度一旦上升比如需要按日期判断、需要计算差值宏就会很快变得难以维护。这不是宏能力不够而是它的设计目的本来就不是面向大型程序。4.3 这个演示适合谁不适合谁这个演示适合谁适合对 vim 有基本了解、想进一步理解命令组合的开发者也适合想给新人讲清楚“编辑器也能编程”的老师。它不适合谁不适合想找一个生产级生命游戏实现的人也不适合把 vim 当成 IDE 替代品、不打算深入命令模式的用户。另外如果只是想在 Linux 服务器上快速做一个好玩的项目用 Python 或 awk 肯定会更容易。宏实现生命游戏的价值不在效率而在约束条件下思考问题的过程。宏的价值不在“快”而在把一次临时操作沉淀成可复用的命令组合。长期来看值得关注的是 vim/vi 在现代开发环境中的角色。宏编程不会让你变成更快的程序员但它会改变你使用编辑器的方式从“每次手动敲命令”变成“先构建一批可复用的命令组合”。这种思维对写 shell、写脚本、做自动化都有帮助。最后回到标题里的“手元”。如果有一天你也在 Linux 终端里打开 vi想起这篇文章我建议你先别急着复刻一个完整的生命游戏。先录一个最简单的宏让它删除一个字符、跳到下一行再录一个宏让它做一次状态替换。等这两步都顺手了再往里面叠加相邻计数、边界哨兵、快照清理。你会发现真正有收获的不是最后那张动态棋盘而是你被迫去想的那些东西状态不能互相污染边缘需要特殊处理光标位置必须稳定。这些东西在任何编程环境里都是同样重要的。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →