资讯动态

用Vim宏编程从零实现康威生命游戏:寄存器与文本缓冲区的极致实践

发布时间:2026/9/7 4:53:48 来源:尧图企业网站定制
如果你只在 Vim 里用宏做过“批量加注释”或“把单词统一替换”那你可能还没有真正见过宏编程的杀伤力。这篇文章要完成一个看起来像行为艺术、实际上非常训练底层功夫的任务不装任何插件不写 Python 脚本只用 Vim 的宏寄存器从零实现一个康威生命游戏Conways Game of Life。先说结论宏macro在 Vim 生态里被严重低估。它不只是“录一次按键然后回放”的偷懒工具寄存器的本质是一段可递归调用的击键程序。寄存器里存的是字符序列字符序列本身又是程序这正是“文本即程序”的极佳体现。这篇文章会带你完成三件事第一理解宏和寄存器如何构成 Vim 的“函数与变量”第二学会用三行窗口和影子缓冲区解决同步更新问题第三拿到一份可以直接复制运行的 Vim 脚本在终端里跑出一个能移动、能繁衍的二维生命场。写这个主题还有一个更实际的原因当你真的把宏用到这种程度再回到日常的批量文本处理里你会对“当前位置、上下文行、缓冲区边界”这三样东西格外敏感。很多微妙的编辑 bug其实都出在这三件事上。最终代码会尽量保持 vi 风格的编辑命令但需要说明的是规则判断和计数部分用到了 Vim 扩展能力。文章里会对“能兼容到什么程度”给出一个清晰边界。1. 这篇文章真正要解决的问题生命游戏的规则人人都能看懂二维网格上每个细胞有生或死两种状态下一帧的状态由它周围八个邻居的状态决定。规则可以浓缩成一句口诀活细胞邻居数为 2 或 3 时继续活否则死亡死细胞邻居数正好为 3 时复活其余情况保持死亡。规则简单到小学生都能理解但真要在一个文本编辑器里跑起来会遇到三个非常现实的问题。第一个问题是循环。Vim 的宏本身可以重复执行问题是“执行多少次、什么时候停”。如果只做 20 代那我们可以用20a如果要做动态演示就必须让宏能判断“是否还有下一行”。宏里没有现成的 if只有“命令失败就停止”这一条隐藏规则。很多初学者写的递归宏停不下来就是因为没用好这条规则。第二个问题是同步更新。生命游戏要求所有细胞在同一时刻发生变化而 Vim 的编辑命令是一行一行顺序执行的。如果边计算边写回上一行改完之后下一行在统计邻居时读到的就是“新状态”结果整个迭代就变成了异步更新滑翔机会跑得乱七八糟。这是实现里最容易踩、也最容易忽视的坑。第三个问题是邻居统计。宏本身只会移动光标、删除字符、粘贴文本它不擅长算术。要在一个二维平面里数出八个邻居的数量我们必须把“数数”这件事转化成一串可以重复回放的文本操作。三个问题合在一起其实就是一个完整的“宏编程实战”循环控制、状态缓存、批量计算。正因为生命游戏足够小、足够规则它才特别适合用来做这种思维训练。2. 生命游戏规则与宏编程的基础对应2.1 生命游戏到底在算什么很多教程会把生命游戏包装成“人工智能”“复杂系统”但抛开情怀它本质上是一个二维数组的迭代每一轮读一遍旧数组算出一个新数组再把新数组覆盖回去。这和我们写普通业务代码没有什么区别唯一的特殊点在于“同步”。如果你写的是for i in range(rows): for j in range(cols): game[i][j] next_state(game, i, j)那一定会出错因为在更新game[i][j]之后相邻的game[i][j1]再算邻居时就已经读到新值了。正确做法是先把所有下一帧结果存进一个新数组全部算完后再一次性写回。这个道理放在 Vim 里完全一样文本缓冲区就是数组getline()就是读数组setline()就是写数组。实现的关键不是“会不会 Vim 命令”而是“愿不愿意把文本当成内存来思考”。2.2 宏、寄存器和递归Vim 的宏录制非常简单按q加一个字母开始录制再按一次q结束录制a回放寄存器a里的内容。但很多人没有意识到寄存器a里保存的并不是什么特殊对象而是一段普通字符而已。所以你可以做这样几件看起来“作弊”的事情用:reg a查看宏内容用ap把宏内容粘贴到文档里把粘贴出来的文本修改好之后再用ay$存回寄存器直接用:let a ... 往寄存器里写一段程序。这就是“宏编程”和“录制宏”的分水岭录制宏是按操作记录宏编程是把寄存器当作函数体来书写。一旦接受了这个设定Vim 就不再只是编辑器而是一个极简但完整的计算环境。2.3 一张对应表把生命游戏的术语映射到 Vim 世界整件事就突然清晰了生命游戏概念Vim 宏编程对应物二维细胞网格文本缓冲区活细胞字符1死细胞字符0一次同步演化调用一次宏或函数八邻居统计三行窗口扫描规则 B3/S23(n 3) || (cur 1 n 2)无限网格近似四周补 0 边界连续多代循环调用宏有了这张表后面的代码就不再是魔法而是一步一步可解释的工程实现。3. 环境准备与前置条件这个项目不需要安装任何插件只需要一个能跑 Vim 的 Linux 环境。macOS 自带的 Vim、Windows 下的 WSL 里的 Vim 也都可以。推荐使用 Vim 8.0 以上版本或者 Neovim。本文的脚本主要依赖 Vimscript 的表达式替换、range()、strpart()、getline()/setline()这些能力它们在现代 Vim 里都是内置的。具体版本请以本机vim --version输出为准本文重点演示通用思路。为了不让个人配置干扰运行建议用一个干净环境vim -u NONE life.txt这样不会有插件干扰也不会因为set compatible导致命令行为变化。另外建议在启动 Vim 后先执行两条命令:set nocompatible :set nowrapnocompatible保证我们使用的是 Vim 增强能力nowrap避免长行折行影响观感。先说明兼容边界传统 vi 的宏机制和 Vim 本质相同都是击键序列。但传统 vi 没有\表达式替换也没有 Vimscript 的函数和循环。所以“兼容 vi”在这里更准确的理解是“命令风格尽量使用 vi 已有基础命令规则判断部分使用 Vim 扩展能力”。想在纯 vi 里完整跑生命游戏并不是不行但需要把计数和判断拆成大量文本替换工程量大得多本文不展开。4. 热身先把宏当成程序来理解4.1 一次最普通的宏录制先做一个最朴素的例子给当前文件每一行的开头插入一个#注释符。录制过程qa I#Esc j q解释一下qa开始录制到寄存器aI#Esc在行首插入#j跳到下一行q结束录制。然后在全文件范围执行这个宏:%normal! a:%normal! a的意思是对每一行执行一次宏a。这里宏里的j并不会导致行混乱因为normal!在执行前会先定位到目标行。如果只想处理前 10 行可以用10a这就是宏最基础的用法录制一段操作批量回放。4.2 让宏读取状态数字批量自增宏的进阶用法是在宏里嵌入 Ex 命令让宏能够读取和计算。下面的宏会把当前缓冲区里所有数字都加 1:let r :%s/\\d\\/\\submatch(0)1/g\r然后回放r这里的重点有两个双引号字符串里的\r表示“回车”这是让宏回放时真正执行 Ex 命令的关键\submatch(0)1是 Vim 替换表达式它会在替换时动态计算新值。如果你用单引号写:%s/...\r那么寄存器里存的就是反斜杠和字母r而不是回车宏回放时会报错。这是宏编程里非常经典的低级错误。4.3 降维练习用宏跑一维细胞自动机二维生命游戏的前置热身我建议先做一维细胞自动机。一维情况下每个细胞只有左右两个邻居规则更少实现更短但已经包含了“用上一行生成下一行”的核心思想。以规则 90 为例下一代细胞 左上邻居 XOR 右上邻居。初始只有一行每次用最后一行生成新行追加到文件末尾。这正好可以用宏反复执行生成一张类似谢尔宾斯基三角形的分形图。先准备一个初始文件rule90.txt里面只有一行0000000000100000000000然后写一个函数文件rule90.vim 文件路径rule90.vim function! Rule90Next() abort let prev getline(line($)) let len strlen(prev) let out 0 for c in range(1, len - 2) let left str2nr(strpart(prev, c - 1, 1)) let right str2nr(strpart(prev, c 1, 1)) let out . (left ! right) ? 1 : 0 endfor call append(line($), out . 0) endfunction let r :call Rule90Next()\r打开 Vimvim -u NONE rule90.txt :source rule90.vim 15r每条新行都用上一行的左右邻居生成所以宏r每执行一次就增加一代。15 次执行后文件末尾会出现一条清晰的分形纹理。这个热身告诉我们两件事宏可以当作迭代器使用每次执行追加一行“用上一行生成下一行”其实就是二维生命游戏在高度上的投影。5. 核心设计用三重窗口统计二维邻居进入二维之前先把最难的两个设计点讲清楚。5.1 为什么不能边算边写如果按照“从上到下逐行更新”的方式直接写回那么当处理第 3 行时第 2 行已经被改成了新状态。第 3 行用来统计邻居的数据就不再是“上一代的第 2 行”而是“下一代已经更新过的第 2 行”。这会导致感染式传播一个细胞的更新结果立刻影响下一个细胞下一个细胞又影响下下个最后整个场的演化规律完全偏离康威生命游戏。解决办法是影子缓冲区先把所有行的计算结果缓存在一个数组里全部算完之后再一次性写回缓冲区。文本编辑器的setline()是事后操作计算永远只基于快照。5.2 数据格式与边界我们要处理的文本是一个矩形每行由0和1组成长度完全一致。1代表活细胞0代表死细胞。生命游戏要求无限网格真实文本缓冲区却有边界。处理办法很朴素在四周补一圈0。左右边界每行首尾各加两个0上下边界文件顶部和底部各插入一行全0。为什么左右各加两个0而不是一个因为计算一个细胞的邻居时需要读取它左边一格和右边一格。如果只留一个0那么最左侧真实细胞在读取“左邻居”时正好读到边界0但如果你再往左看比如统计窗口的中心落在边界0上它还需要读取更左边一格。留两个0最简单能保证任何合法计算位置都不会越界。这个工作由LifeInit()函数完成。5.3 三行窗口的统计逻辑假设当前要计算的是第i行第c列那个细胞那么它的八邻居分布如下上一行 左 中 右 当前行 左 中心 右 下一行 左 中 右在 Vim 里我们可以一次读三行prev、curr、next。对于第c列三次strpart()分别取出c-1、c、c1三列这样三行一共取出 9 个字符。这 9 个字符里包含了中心细胞自身和它的 8 个邻居。把 9 个数字相加再减掉中心细胞就得到了邻居数for row in [prev, curr, next] let n str2nr(strpart(row, c - 1, 1)) let n str2nr(strpart(row, c, 1)) let n str2nr(strpart(row, c 1, 1)) endfor let n - str2nr(strpart(curr, c, 1))然后套用规则let cur str2nr(strpart(curr, c, 1)) let out . (n 3 || (cur 1 n 2)) ? 1 : 0这个表达式对应生命游戏规则死细胞周围正好 3 个邻居时复活活细胞周围 2 或 3 个邻居时继续存活。最后把计算得到的一整行out前后补上左右边界再写入结果数组。等所有行都算完再一次写回。整个过程的时间复杂度是O(行数 × 列数 × 9)。Vimscript 是解释执行的所以不建议跑特别大的场100×100 以内用于演示和教学没有问题。6. 完整实现可复制的 Vim 生命游戏脚本6.1 函数文件 life.vim下面是一份可以直接保存的完整脚本文件名为life.vim。 文件路径life.vim 使用步骤 vim -u NONE life.txt :source life.vim :call LifeInit() :call LifeN(20) 函数列表 LifeInit() 给当前 0/1 矩形加 0 边界 LifeStep() 执行一代同步演化 LifeN(n) 连续执行 n 代 LifeShow() 把 0/1 改成 ./便于肉眼观察会破坏数值 function! LifeInit() abort %s/^/00/ %s/$/00/ let width strlen(getline(1)) let border repeat(0, width) call append(0, border) call append(line($), border) endfunction function! LifeStep() abort let first 1 let last line($) - 1 let results [] for lnum in range(first 1, last) let prev getline(lnum - 1) let curr getline(lnum) let next getline(lnum 1) let len strlen(curr) if len 5 continue endif let out for c in range(2, len - 3) let n 0 for row in [prev, curr, next] let n str2nr(strpart(row, c - 1, 1)) let n str2nr(strpart(row, c, 1)) let n str2nr(strpart(row, c 1, 1)) endfor let n - str2nr(strpart(curr, c, 1)) let cur str2nr(strpart(curr, c, 1)) let out . (n 3 || (cur 1 n 2)) ? 1 : 0 endfor call add(results, 00 . out . 00) endfor for idx in range(len(results)) call setline(first 1 idx, results[idx]) endfor endfunction function! LifeN(n) abort for i in range(1, a:n) call LifeStep() endfor endfunction function! LifeShow() abort %s/0/./g %s/1/*/g endfunction 把常用操作注册到宏寄存器 let s :call LifeStep()\r let g :call LifeN(20)\r6.2 把函数封装成宏寄存器脚本末尾的两行很关键let s :call LifeStep()\r let g :call LifeN(20)\r执行:source life.vim之后寄存器s里就存了:call LifeStep()加一个回车。回放s就是执行一代回放g就是连续执行 20 代。这就是宏编程里非常理想的接口设计复杂逻辑放在函数里容易调试对外只暴露一个寄存器作为“快捷键”。你甚至可以用qA往宏里追加其他操作比如“先重置到初始场再跑 30 代”。需要特别注意的是写入宏寄存器一定要用双引号字符串保证\r被解释成回车。如果写成单引号寄存器里存的就是字面量\r回放时不会触发命令执行。6.3 准备初始数据一个滑翔机经典生命游戏里最著名的图案是滑翔机Glider它会在网格里持续移动。创建一个life.txt内容如下0000000000 0000000000 0000001000 0000010000 0000111000 0000000000 0000000000 0000000000 0000000000 0000000000这个图案由三个活细胞组成一个斜线加上一个由三个活细胞组成的三角整体会向斜下方移动。它足够简单又足够敏感是验证实现是否正确的理想测试用例。6.4 运行步骤在终端执行vim -u NONE life.txt进入 Vim 后依次执行:source life.vim :call LifeInit() :call LifeN(20)如果一切正常缓冲区里的 0/1 矩阵会从原来的滑翔机图案逐渐变化并在多代之后依然保持清晰的移动轨迹。7. 运行结果与效果验证执行完:call LifeN(20)后你可能发现满屏的 0 和 1 并不直观。这时候可以用LifeShow()把数字换成字符:call LifeShow()执行后文件里的0会变成.1会变成*生命场一下子就有了轮廓。滑翔机会以*的形式在.的海洋里移动。要验证实现是否正确有两种方式第一种是观察滑翔机。滑翔机在周期迭代中不会消亡也不会原地静止而是整体平移。如果跑 20 代后图案彻底消失或变形大概率是同步更新出了问题。第二种是构造稳定图案做对照。一个最简单的对照组是两个相邻的活细胞它们在任何时刻邻居数都不会达到 3绝大多数情况下会直接消亡。另一个常用图案是“三连块”比如111它是不动的振荡器代代保持原状。你可以把life.txt改成这个图案再跑LifeStep()看看输出是否保持一致。如果运行失败第一步先检查:reg s和:reg g如果寄存器内容为空说明life.vim没有成功 source如果寄存器里出现了字面量\r说明字符串引号用错了如果函数没定义会提示E117: Unknown function先检查函数名拼写。8. 常见问题与排查思路问题现象可能原因排查方式解决方案s或g没有反应寄存器为空或 life.vim 未 source输入:reg s查看寄存器内容重新:source life.vim宏回放时报错或每次只执行一行寄存器里的\r变成了字面量:reg g查看是否出现^M或\r用双引号字符串重写let g ...\r图案迭代几代后全是 0更新变成异步或边界缺失先用稳定图案111测试确认结果先存results数组最后一次性写回图形移动方向错误列偏移计算不对把中间n值:echom n打印出来检查strpart(row, c - 1, 1)三列是否覆盖左中右重复执行LifeInit()后边界越来越多初始化被重复追加查看文件首尾行数用:e!重新载入原始文件再初始化场太大跑得非常慢Vimscript 解释执行循环开销高统计文件行数和列数演示规模控制在 100×100 以内凸出来的活动细胞撞到边界后消失实际网格不是真正无限观察边界行是否全为 0需要更大场时先在四周多补几圈 09. 最佳实践与工程建议9.1 用函数组织逻辑用宏做接口纯录制宏很难维护尤其是几十步的长宏一旦中途录错一个按键就得全部重录。更好的模式是把复杂逻辑写成具名函数宏只负责一行调用。这样代码可读、可调试、可在版本控制里管理。9.2 善用寄存器的大小写追加录制宏时小写字母qa会覆盖寄存器a大写字母qA会追加到寄存器a末尾。这个特性在搭建“一键流程”时很好用。比如先录一个初始命令再用qA追加一步最后组成完整的宏链。调试完的宏也可以用sp粘贴到文档里人工检查每个字符。9.3 运行前先备份宏会批量修改当前缓冲区一旦出错手动撤销大范围修改很痛苦。安全操作顺序是:write life.bak :call LifeInit() :call LifeN(10)如果结果不对可以:e!重新加载原始文件重来。9.4 显示和计算分离LifeShow()会把0/1替换成./*这对人眼友好但会破坏下一次迭代所需的数值。建议只有在最终展示时才调用如果还想继续迭代可以先:write当前结果再另存一个展示副本。9.5 把规则参数化康威生命游戏只是众多细胞自动机中的一种。把LifeStep()里的规则判断提取成一个独立函数function! Rule(cur, n) abort return (a:n 3 || (a:cur 1 a:n 2)) ? 1 : 0 endfunction然后调整LifeStep()调用Rule()就可以很方便地切换到其他规则例如 B2/S 之类的变体。宏本身不变只改规则函数即可。9.6 性能边界在 Vimscript 里每一代都要对每个细胞做 9 次strpart()和 1 次字符串拼接。100×100 的场大约要处理 1 万个细胞执行一代需要一定时间连续跑几十代勉强可以接受。更大的场建议换 Python、C 或专门的仿真工具Vim 宏在这里更适合作为教学和思维训练而不是性能仿真平台。10. 总结宏编程的边界与延伸这篇文章真正讲清楚了三件事寄存器是 Vim 的函数单元文本缓冲区是内存影子缓冲区是解决同步更新问题的通用手段。这三件事比“用 Vim 跑生命游戏”本身更有迁移价值。如果你照着做了下一步可以尝试这些小练习把life.txt换成其他初始图案比如振荡器、滑翔机枪观察不同图案的行为修改LifeStep()里的规则表达式跑一跑 B2/S 或者 B3/S 以外的规则用qA追加宏做一个“初始化 跑 50 代 显示”的一键宏把三行窗口的统计思想迁移到日常编辑中例如批量处理表格中的上下文感知替换。最后提醒一句宏录制时如果中途出错了别慌。按Esc中断用u撤销误操作然后重新qa录制即可。宏编程的核心从来不是一次录对而是你愿不愿意把寄存器当作一张草稿纸反复涂改直到写出能稳定复现的程序。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价