用Vim宏驱动康威生命游戏:从录制到递归的完整实践
发布时间:2026/9/7 9:01:44 作者:尧图编辑部 阅读量:1,286

很多 Vim 教程在讲宏的时候都停留在“录一段按键然后a批量重放”这个层面。这种用法当然有用但它远远低估了宏的能力。宏本质上是一段可以录制、重放、递归、组合的按键序列它不只能帮你补括号、插分号甚至可以成为一个计算引擎的调度器。今天这篇“手元解说”就用康威生命游戏Conways Game of Life作为实验场把 Vim 宏做到一个有趣的位置在文本网格上演化一代代细胞所有迭代都通过宏来驱动。这篇文章不是要教你“用 Vim 玩游戏”而是借生命游戏讲清楚三件事vim 宏的录制与重放原理、宏的跨行批量应用方式、以及带终止条件的递归宏怎么写。如果你以前只会q和看完之后会对“宏编程”有完全不同的认识如果你已经会一点 Vimscript也能从“宏做调度、脚本做计算”的折中方案里获得工程层面的启发。文章还会回答一个很多 Linux 初学者关心的问题用 Vim 处理文本批处理任务时什么时候应该选宏什么时候应该写脚本两者如何配合。全程基于纯文本操作不依赖任何插件核心示例在 Vim / Neovim 中可以完整运行基础按键尽量保持与传统 vi 兼容。1. 这篇文章真正要解决的问题很多人学 vim 宏学到“录制、重放”就停了遇到复杂场景还是不知道怎么用。比如录好的宏怎么对连续多行执行宏能不能调用自己实现递归宏在处理过程中失败了应该从哪里排查什么时候适合用宏什么时候应该直接写脚本这些问题单独看都不难但没有一个实际案例把它们串起来理解就总是浮在表面。康威生命游戏恰恰是一个非常适合用来串场的问题。它规则简单但计算过程绕不开“邻居计数”“边界判断”“批量更新”这几件事正好可以拆成“Vim 文本操作”和“少量脚本计算”两部分让宏在最擅长的调度环节发挥作用。读完这篇文章你会得到四样东西一个能直接在 Vim 里跑起来的生命游戏宏方案。对 vim 宏三种用法的完整理解普通重放、跨行批量重放、带终止条件的递归宏。宏和 Vimscript 配合使用的分工思路。一套适合在生产环境里安全操作宏的习惯。2. 基础概念vim 宏与生命游戏规则2.1 vim 宏到底是什么vim 宏的本质是把一段按键序列保存到寄存器里之后可以随时重放。基本用法在普通模式下按q加一个寄存器字母开始录制。比如qa表示录制到寄存器a。录制期间的每一次按键包括普通模式命令、插入模式输入、Ex 命令都会被记下来。再按一次q结束录制。使用a重放寄存器a中的宏。使用重放最近一次使用的宏。宏还可以被递归调用也就是在宏内部再执行a。这个特性非常强大如果使用不当也很危险后面讲递归宏时会专门演示。2.2 vi 与 vim 的宏兼容性传统 vi 时代就有宏的概念不同 vi 实现的录音方式略有差异但“把命令序列保存下来再批量执行”的思路是一致的。本文示例主要基于 Vim / Neovim因为实现完整生命游戏需要一个邻居计数函数这是 Vimscript 的领域。文中第 4 节的“加边界宏”是纯键盘宏不依赖 Vimscript在多数传统 vi 里也能录出来。这里必须说清楚一个边界纯 vi 没有类似getline()的函数也没有表达式寄存器所以如果要在纯 vi 里跑完整二维生命游戏需要另想办法。本文采用“宏调度 Vimscript 计算”的混合方案这是工程上最现实的做法。2.3 康威生命游戏的四条规则生命游戏是一个“零玩家游戏”所有演化由初始状态决定。细胞只有两种状态存活或死亡。当前细胞下一代的生死由它周围 8 个邻居中活细胞的数量决定。当前状态活邻居数量下一代状态说明存活0 或 1死亡孤独死亡存活2 或 3存活正常延续存活4 到 8死亡过度拥挤死亡3存活繁殖死亡0,1,2,4,5,6,7,8死亡维持死亡注意邻居是中心细胞周围一层 8 个位置包括上、下、左、右、左上、右上、左下、右下。边界处理时通常把网格外想象成死亡细胞或者给网格外圈补一圈死细胞边界。2.4 为什么文本网格可以做元胞自动机生命游戏的核心数据结构是二维网格。Vim 缓冲区本身就是文本行组成的二维矩阵一个字符就是一个格子。用.表示死亡细胞用*表示存活细胞我们就能把网格直接画在 Vim 里。..... ..*.. ..*.. ..*.. .....每一行是网格的一行每一列由字符下标决定。宏的任务就是对这个文本矩阵做“一代更新”。3. 环境准备与前置条件本文演示环境为 Linux 终端下的 Vim 8 或 Neovim。如果你用的是嵌入式 Linux、服务器 Linux或者 Windows 下的 WSL / Git Bash操作方式完全相同。准备一个空白文件life.txt后续所有网格都写在这个文件里验证。为了让显示效果稳定建议在 Vim 里执行set nocompatible set nowrap set numbernocompatible关闭 Vi 兼容模式让 Vim 充分发挥能力。nowrap防止长行自动折行保持网格视觉对齐。number显示行号方便调试时定位行列。也可以用命令行快速生成一个 8x8 网格cat life.txt EOF ........ ........ ........ ........ ........ ........ ........ ........ EOF4. 先热手用纯文本宏理解宏的工作机制在进入生命游戏之前先做两个纯键盘宏热身。它们不依赖任何脚本只使用最基础的 normal 模式命令可以帮助你建立对宏的直觉。4.1 示例给网格加一圈边界点给life.txt的每一行左右各补一个.。这是一个典型的“对多行做同一操作”的场景。先录制宏# 操作步骤 1. 将光标移到第一行。 2. 按 qa 开始录制到寄存器 a。 3. 按 0 移动到行首。 4. 按 i. 插入一个点按 Esc。 5. 按 $ 移动到行尾。 6. 按 a. 在行尾追加一个点按 Esc。 7. 按 j 移动到下一行。 8. 按 q 结束录制。宏内容用简写表示就是0i.Esc$a.Escj录制完成后对剩余行批量执行# 如果网格有 8 行光标已经在第 2 行只需要再处理 7 行 7a如果今天网格行数不固定也可以用选中区域的方式:%normal a这条命令对文件每一行执行一次宏a所有行都会在左右两侧加上.。4.2 宏的批量执行方式总结方式命令适用场景重复执行 N 次Na连续区域批量处理对选中行执行:,normal a只处理可视模式选中的行对全文件执行:%normal a全量处理重放上次宏快速继续上一次操作4.3 一个常见小坑宏在最后一行会失败如果你录的宏以j结尾当它执行到文件最后一行时j会失败Vim 会认为宏执行失败并终止。这个特性看似麻烦但也可以利用它来控制宏的终止条件。比如上面的宏录制后如果不想再执行到空行可以在文件末尾临时补一行空行或者使用:%normal a时接受“最后一行停止”的行为。理解“宏失败即终止”后面看递归宏会更容易。5. 生命游戏的核心算法拆解5.1 为什么不能只靠纯按键宏算 8 个邻居如果你尝试用纯按键宏计算一个细胞周围 8 个位置中的活细胞数量很快会发现按键宏非常不适合做条件判断和计数。虽然可以用搜索匹配来模拟if但每一条分支都要录成宏片段遇到 512 种 3x3 组合时宏会膨胀到不可维护。所以这里的工程折中是用 Vimscript 写一个邻居计数函数负责最复杂的数学计算用宏负责调度和批量迭代。这也是真实场景中推荐的用法——宏不是万能的它擅长批量执行和流程控制不适合做大规模数值逻辑。5.2 完整实现邻居计数函数下面的代码保存为life.vim作用和注释都写在里面。 文件路径life.vim 计算 (row, col) 位置周围 8 个邻居中活细胞*的数量 row、col 均从 0 开始 function! CountLiveNeighbors(lines, row, col) let live 0 for dr in [-1, 0, 1] for dc in [-1, 0, 1] if dr 0 dc 0 continue endif let nr a:row dr let nc a:col dc if nr 0 || nr len(a:lines) continue endif let line a:lines[nr] if nc 0 || nc strlen(line) continue endif if line[nc] * let live 1 endif endfor endfor return live endfunction这个函数接收lines数组和当前细胞坐标循环遍历dr和dc的 8 个组合。越界位置直接跳过等价于把网格外视为死亡细胞。5.3 完整实现GameOfLife 主函数 执行一代 Conway 生命游戏 function! GameOfLife() let lines getline(1, $) let newLines [] for row in range(len(lines)) let oldLine lines[row] let newLine for col in range(strlen(oldLine)) let live CountLiveNeighbors(lines, row, col) let cell oldLine[col] if cell * if live 2 || live 3 let newLine . * else let newLine . . endif else if live 3 let newLine . * else let newLine . . endif endif endfor call add(newLines, newLine) endfor call setline(1, newLines) endfunction逻辑并不复杂先读取当前缓冲区所有行然后对每个字符判断邻居数量和自身状态生成一行新文本最后用setline一次性替换整个缓冲区。这里有一个使用前提网格必须是等宽的矩形。如果有的行长、有的行短col取值和邻居判断都会出问题。所以生命游戏开始前最好先确认每行长度一致。5.4 把函数绑定成宏在 Vim 中打开life.txt执行:source life.vim接下来录制一个执行一代的宏。这一步很关键宏就是你把整个:call GameOfLife()命令录进寄存器。操作步骤1. 按 qg 开始录制到寄存器 g。 2. 输入 :call GameOfLife() 3. 按 Enter 执行。 4. 按 q 结束录制。如果你不想手动录也可以用命令把宏写进寄存器:let g :call GameOfLife()\r这样普通模式下按一次g就执行一代。想连续执行 5 代按5g。6. 用宏驱动生命游戏迭代6.1 准备三个经典测试图案先用life.vim里的函数测试几个经典图案。打开一个新缓冲区输入下面的图案然后:source life.vim。Block 静物这个图案在生命周期内保持不变.... .**. .**. ....Blinker 振荡器这个图案会在“横线”和“竖线”之间来回切换。初始状态使用竖线..... ..*.. ..*.. ..*.. .....Glider 滑翔机这个图案会整体向右下方向移动是生命游戏最著名的图案........ ...**... ....*... ...*.... ........ ........ ........ ........6.2 执行一代并查看变化把光标放在 Block 图案所在缓冲区按g执行一代。执行后图案保持原样说明静物特征正常。对 Blinker 执行一代应该看到竖线变横线..... ..... .***. ..... .....再执行一代又变回竖线。6.3 连续执行 N 代如果想让宏连续执行 10 代最简单的办法是用:for循环配合execute normal g:for i in range(1, 10) | execute normal g | endfor这条命令会调用 Vimscript 的:for循环执行 10 次宏g。它比10g更直观因为循环次数可以动态修改比如:let n 20 :for i in range(1, n) | execute normal g | endfor6.4 递归宏让宏自己调用自己vim 宏支持递归调用。录制一个宏r让它每执行一次就调用一次r可以实现无限迭代q r :call GameOfLife()Enter r q但直接执行r会无限循环基本停不下来。更可靠的做法是加一个计数器寄存器只在计数器大于 0 时递归。先设置剩余迭代次数:let c 20再把递归宏注册进去:let r :if c 0\r:let c c - 1\r:call GameOfLife()\r:execute normal r\rendif\r执行r这个宏会执行 20 代后自动停止。原理是每次进入宏先判断c是否大于 0如果大于 0 就递减计数、执行一代、再次调用自身当计数归零时整个条件块被跳过递归自然终止。递归宏非常强大但也容易出错。执行前建议先保存文件或者打开undo功能万一宏失控至少可以用u撤销或者按Ctrl-C中断。6.5 做一个可以暂停的动画宏如果你不满足于看结果可以做一个简单的“动画”。录制一个宏a内容是执行一代、刷新屏幕、暂停 200 毫秒:let a :call GameOfLife()\r:redraw\r:sleep 200m\r然后运行 30 代:for i in range(1, 30) | execute normal a | endfor你会看到 Vim 窗口里的网格像动画一样自动更新。这个例子很适合在演示或者布道 Vim 时使用因为它直观地展示了“宏 简单脚本”能带来的效果。7. 运行结果与效果验证7.1 Block 验证Block 图案执行任意多代都应该保持不变。如果第一代就变了说明邻居计数或规则判断有问题。7.2 Blinker 验证初始状态..... ..*.. ..*.. ..*.. .....执行一代后..... ..... .***. ..... .....再执行一代后又回到竖线。两代一个周期这是振荡器的典型特征。7.3 Glider 验证Glider 图案最有趣的地方是会移动。初始图案位置大约在网格左上区域运行几代后整个图案会向右下方移动。由于不同方向初始图案略有差异这里不准备精确到某一代的输出读者运行后观察整体位置变化即可。如果观察到 Glider 朝右下方向移动说明整条链路已经通了。7.4 如何快速判断宏是否正常判断是否正常用已知图案比用随机图案更可靠。用 Block 测试不变。用 Blinker 测试两代周期。用 Glider 测试整体位移。此外可以观察活细胞数量。生命游戏的种群数量会随规则变化但不会毫无规律地爆炸或消失。如果运行后发现所有细胞全部消失优先检查两件事网格是否等宽边界处理是否正确。8. 常见问题与排查思路问题现象可能原因排查方式解决方案宏只执行一代就停止宏以j结束最后一行往下移动失败检查光标是否停在最后一行文件末尾补空行或改用:%normal g边缘细胞不更新边界越界判断错误单独在一个 4x4 网格上测试边界细胞检查CountLiveNeighbors中的越界判断执行后所有细胞消失每行宽度不一致用:%s/.*/len(trim(...))或手动strlen检查把每行补充为等宽或重新准备网格递归宏停不下来宏内部无条件调用自身观察 CPU 和缓冲区变化按Ctrl-C中断改用带计数器递归宏或:for循环在 vi 里无法执行:call老式 vi 没有 Vimscript 函数查看vim --version使用 Vim/Neovim或把计算逻辑放到外部脚本宏执行后没有变化忘记:source life.vim执行:function GameOfLife看是否存在先加载 life.vim再执行宏动画宏卡顿网格过大或sleep时间过长减小网格或调整sleep毫秒数缩短sleep 200m为sleep 50m9. 最佳实践与工程建议9.1 录制宏之前先想清楚步骤宏录制过程如果中途出错最稳妥的做法是重新录制而不是继续修补。动手之前用纸笔把每一步列出来画清光标路径再开始录。比如先移到行首、插入字符、移动下一行这就是一个很清晰的循环体。9.2 宏的命名与保存宏寄存器只有 26 个字母容易混淆。建议固定一套命名规则a通用编辑宏。g生命游戏专用执行一代。r递归宏。c计数器寄存器。如果想让宏永久生效可以把它写进vimrclet g :call GameOfLife()\r也可以把整段宏导出成脚本需要时再加载。9.3 什么时候该用宏什么时候该写脚本这是一个容易被忽视的经验问题。宏适合做“重复性文本操作”比如多行注释、批量加前缀、多行插入编号。如果逻辑中有大量条件分支、循环嵌套、数值计算宏就会变得既长又难维护这时候应该写 Vimscript、Python 或其他脚本然后让 Vim 调用结果。比如本文的生命游戏计算部分用 Vimscript 函数调度部分用宏。这种分工才是工程上的正确姿势。9.4 操作重要文本前先备份无论宏看起来多可靠只要作用于重要文件先执行:set undofile或先另存一份备份能少走很多弯路。尤其在服务器 Linux 环境中直接改生产配置前养成备份习惯比任何技巧都重要。9.5 性能建议生命游戏在大网格上计算量会很大Vimscript 不是性能最好的实现。如果网格超过几百行建议把计算放到 Python 或 awk 等外部工具只把最终结果导回 Vim。Vim 在这里的角色是“编辑器和展示器”而不是高性能计算引擎。10. 总结与后续学习方向这篇文章用康威生命游戏把 vim 宏的三层能力串了起来第一层是录制与重放第二层是跨行批量执行第三层是带终止条件的递归宏。同时通过一个可运行的life.vim函数说明了宏和 Vimscript 的分工边界。下一步可以尝试的方向很多把 Block、Blinker、Glider 换成更大规模的随机图案观察生命游戏演化。尝试实现 Rule 30、Rule 90 等一维元胞自动机这些规则更简单纯键盘宏也能跑起来。如果你用的是 Neovim可以考虑把整个生命游戏逻辑用 Lua 重写再把宏作为调用入口体验会更现代。宏编程不是一段高不可攀的技巧它本质上就是“把你的手动操作变成可重放的命令序列”。当你能用宏驱动一个完整的计算模型时对 Vim、对文本处理的理解都会进入新的层次。建议把这篇文章里的命令实际操作一遍亲眼看一次 Glider 在缓冲区里向右下方移动那种感觉比读十篇教程都直观。