vim 宏编程实现生命游戏,听着像炫技,但正因为它要把“规则”压缩成“按键序列”,才最适合把宏的运作机制一次讲透。生命游戏(Game of Life)是在二维格子上,每个格子要么生要么死,下一轮状态完全由周围 8 个格子的状态决定;到了 vim 里,这个元胞自动机可以不用写脚本、不用装插件,只靠q录制宏、寄存器暂存、替换命令回放,一步一步推演出来。这篇文章适合已经把 vim 常用命令上手、但一直没弄清楚宏怎么设计的人,也适合在 Linux 终端里想找点有趣练习的运维朋友。先给结论:小棋盘上的单格推进宏完全能跑通,但“所有格子必须同时更新”这个规则,恰恰是宏方案里最容易绕错的地方,后面会专门拆开讲。
1. 生命游戏在 vim 里,本质是“用按键序列做全盘替换”
1.1 棋盘可以看成一个不换行的字符串矩阵
用 vim 处理生命游戏,第一步是把二维格子装进 vim 的缓冲区。
我常用的表示方式很简单:每个格子一个字符,活着的格子写O,死掉的格子写.,一行就是一个棋盘行,行与行之间用回车分离。这样整个棋盘在 vim 眼里就是一个矩形文本块,所有移动、替换、寄存器操作都能直接作用上去。
另一种表示是1和0。好处是计数时可以借数字运算,坏处是肉眼检查状态不够直观。练习阶段我建议用O和.,方便人眼比对;如果后面要配合外部命令统计,再转成1/0。
vim 编辑器常用命令里,宏是最容易被忽略的一块。多数人知道dd删行、yy复制、:%s全局替换,但很少把宏当成一种“自动化手段”来用。生命游戏正好是一个反向例子:它逼着你把“移动、抓取、计算、写回”全部拆成宏能执行的步骤。
1.2 一步代际,就是一次“按规则改写整块文本”
生命游戏的规则很固定:每个格子数周围 8 个邻居里有多少个活细胞。活着的格子,邻居数是 2 或 3 就继续活,否则死;死掉的格子,邻居数正好是 3 就复活。
放到 vim 里,“一代”就等于:遍历棋盘的每个格子,统计它的 8 邻居,然后把当前格子改成新状态。遍历可以用宏来做,统计邻居可以用寄存器和替换来做,改格子可以用r命令或替换命令来做。
所以整件事不是玄学,而是三个问题:怎么表示棋盘、怎么数邻居、怎么保证所有格子用的是同一份旧状态。
1.3 真正的难点只有两个
第一个难点是邻居统计。vim 不是编程语言,没有二维数组,也没有直接的“相邻元素”概念,只能用光标移动、yank、寄存器这些基础能力去“抓”周围的字符。
第二个难点是同步更新。如果按顺序从左到右、从上到下处理格子,先处理的格子已经改了棋盘,后处理的格子统计邻居时就会读到“这一轮刚改过”的新值,整个演化就会乱掉。这一点很多第一次写的人都会踩,后面单独用一节讲。
2. 先准备棋盘:字符表示、边框和对齐
2.1 环境与最小文件
我在 Linux 终端里跑这个项目时,用的是发行版自带的 vim,没有装任何插件,也没有额外 vimrc。环境越干净,出问题时越容易定位。标题里的“手元”说的是把按键过程一帧一帧拆开看,所以我下面写的都是可以直接照做的命令序列,不是概念描述。
准备一个文件,比如life.txt,先放一个小图案。不要一上来就放几十行的图案,先从 5×5 或 8×8 开始,跑通逻辑再放大。
比如最简单的“三连横”:
... OOO ...这是生命游戏里最经典的震荡子 blinker,两代循环一次,非常适合验证宏有没有写对。
2.2 给棋盘加边框
生命游戏里,边缘格子也要有邻居。最省事的办法是给整个棋盘加一圈死细胞边框,这样统计邻居时,任何格子周围总能取到 8 个位置,不用担心光标在边界时移动错位。
加边框的 vim 命令:
" 左右各补一个 .(死细胞) :%s/^/\./ :%s/$/\./ " 上下各补一行:复制第一行和最后一行,再把整行替换成 . 1GyyP :s/././g Gyy p :s/././g最后两条:s/././g会把整行所有字符都替换成.。这里容易误解的是:替换串里面的.是字面量,不是通配符,所以结果就是一行全是点的死细胞行。
注意,这一步最容易出问题的是行宽不一致。vim 的yy会复制整行,如果原棋盘每行末尾有多余空格,复制出来的边框行就和其它行对不齐。我习惯在加边框前先执行一次:
:%s/\s\+$//把行尾空格清掉,保证每行宽度一致。
2.3 开始之前,先把这些选项调好
宏录制最怕两个干扰:自动换行和搜索高亮。移动光标时如果触发自动换行,邻居位置就会全错;搜索高亮残留也会干扰检查。
我一般会先执行:
:set nowrap :set nohlsearch :set lazyredrawlazyredraw让宏跑的时候少刷屏,速度会快不少,尤其是后面连续跑几十代的时候。
还要确认文件末尾没有多余空行。双棋盘方案里我经常用行数除以 2 来算棋盘高度,如果末尾多了一个空行,行数就错了,代际推进会整体错位。
3. 单格宏:9 个寄存器抓邻居,一条表达式定生死
3.1 把“数 8 个邻居”拆成按键
先做最小单元:一个宏,处理光标下的一个格子。它能跑通,整盘方案才有意义。
方法很直接:从当前格子出发,把周围 3×3 共 9 个字符分别 yank 到寄存器 1 到 9,中间那个是当前格子本身。然后统计这 9 个字符里O的数量,再减去当前格自身,就是 8 邻居的活细胞数。
具体按键录制到寄存器a:
qa ms " 用标记 s 记住当前格位置 hk"1yl " 左上角 l"2yl " 正上 l"3yl " 右上 jhh"4yl " 正左 l"5yl " 当前格(中心) l"6yl " 正右 jhh"7yl " 左下 l"8yl " 正下 l"9yl " 右下 `s " 回到标记位置 q录制完后,把光标放到任意格子上执行@a,宏会走完一圈再回来。先在小棋盘上调,故意把光标放在格子中间,观察 yank 到的寄存器内容对不对。
检查寄存器内容用:
:reg 1 2 3 4 5 6 7 8 9如果哪个寄存器和预期位置不对,多半是某一步h、j、l的方向或次数错了。宏是相对光标位置录制的,只要初始光标在某个格子上,就能重复使用。
这里提一个容易踩的细节:标记名我用了s,只是为了顺手。如果你觉得s和替换命令太像,容易混,可以换成mq。关键是进入宏和退出宏时用同一个标记名,不要中途覆盖。
3.2 邻居计数和规则判定,用一行表达式完成
统计邻居数量,我用 vim 表达式里的substitute配合strlen:
:let n = strlen(substitute(@1.@2.@3.@4.@6.@7.@8.@9, '[^O]', '', 'g'))这里把寄存器 1、2、3、4、6、7、8、9 拼成一个字符串,把所有不是O的字符删掉,剩下的长度就是活邻居数量。注意我特意没有拼寄存器 5,因为寄存器 5 是当前格子自己,不算邻居。
然后按生命游戏规则算新状态。规则可以写成一条表达式:
:let @r = ((@5 == 'O' && n == 2) || n == 3) ? 'O' : '.'解释一下:新状态是活,只有两种情况。第一,当前格活着且邻居数是 2;第二,邻居数是 3。这个写法把“出生”和“存活”合并到了一起,因为邻居数为 3 时,不管当前是死是活,下一代都活。
3.3 把结果写回格子
vim 里想只替换光标下的一个字符,可以用特殊锚点\%#,它匹配光标当前位置:
:silent s/\%#./\=@r/\=表示把替换内容当作表达式计算结果来用,这里就是寄存器r里的新字符。silent是为了不让宏运行时报一堆替换信息。
把这一句也加进宏。完整的单格宏大致长这样:
qa ms hk"1yl l"2yl l"3yl jhh"4yl l"5yl l"6yl jhh"7yl l"8yl l"9yl `s :let n = strlen(substitute(@1.@2.@3.@4.@6.@7.@8.@9, '[^O]', '', 'g')) :let @r = ((@5 == 'O' && n == 2) || n == 3) ? 'O' : '.' :silent s/\%#./\=@r/ q注意,这一步到这里仍然只是单格更新,不是完整一代。真正能用来推演生命游戏,必须解决同步更新问题。
3.4 为什么一定要先跑通单格宏
我见过很多人上来就写一个整盘宏,结果跑出来一团乱,根本不知道是邻居数错了、范围选错了,还是规则写反了。
先把单格宏跑通,等于给后面所有步骤打了底。验证方法很简单:放一个已知的 3×3 小区块,手算某个格子的邻居数,然后跑@a,看结果对不对。这一步没问题,再进入双棋盘设计。
4. 同步更新才是关键:从单格宏升级到双棋盘代际推进
4.1 原地替换为什么一定会错
生命游戏要求“所有格子同时更新”。意思是在统计这一轮的邻居时,所有格子都必须停留在上一轮的状态。
如果在同一个棋盘里按顺序一个一个改,比如从左上角往右跑宏,当你处理第二个格子时,它左边那个格子已经被改过了。被改过的格子可能从O变成了.,也可能从.变成了O,于是第二个格子统计到的邻居数已经包含了这一轮产生的新状态。
我最早调试时用 blinker 做测试,前几代看着正常,多跑几代就出现完全不符合规则的图案。一开始以为是规则写错,后来把每一代的状态都打出来对比,才发现是同步更新没做。
处理办法不复杂:准备两份棋盘。一份保存上一轮状态,一份专门写这一轮结果。统计时只读旧棋盘,写结果只写新棋盘。
4.2 双棋盘布局:上面旧盘,下面新盘
最简单的双棋盘不一定要开两个窗口,可以放在同一个缓冲区里:上面是旧棋盘,下面是新棋盘,行数相同,列数相同。
假设棋盘高度是 M,让新棋盘区域放在旧棋盘正下方。录一个宏b,作用是把当前格子的邻居统计完,然后把新状态写到下方对应位置。
" 先设置棋盘高度参数,假设当前文件正好是 2M 行 :let b:M = line('$') / 2b:M是 vim 的缓冲区局部变量。宏里要从当前行往下跳 M 行,才能到新棋盘对应的那一行。
qb ms hk"1yl l"2yl l"3yl jhh"4yl l"5yl l"6yl jhh"7yl l"8yl l"9yl `s :let n = strlen(substitute(@1.@2.@3.@4.@6.@7.@8.@9, '[^O]', '', 'g')) :let @r = ((@5 == 'O' && n == 2) || n == 3) ? 'O' : '.' :let @w = line('.') + b:M :call cursor(@w, col('.')) :exec "normal r" . @r `s l q和单格宏相比,区别集中在这三行:
:let @w = line('.') + b:M :call cursor(@w, col('.')) :exec "normal r" . @r意思是:取当前行号,加上 M,跳到旧棋盘正下方的同一列,用r命令把新状态写进去。宏结尾再回到原格子,向右移一格,方便下一次执行。
这里要提醒一点:b:M必须在录制宏之前设置好,而且文件里不能有额外空行。如果文件是 2M 行,line('$') / 2才是 M;否则偏差一行,所有写入位置都会错。
4.3 把“跑一整代”也录成宏
单格宏b只处理一个格子。一整代要处理旧棋盘内部的所有格子。
最简单的方式是手工重复执行@b,但棋盘稍大就太慢。可以再录一个外层宏c,负责在某一行内连续调用@b,处理完一行就换行。
qc @b ... q这里不建议在宏里写死次数。一个更实用的操作是,先用@b跑几个格子,确认每跑一次光标会向右移动一格;然后用10@b或20@b连续跑一行,观察新棋盘区是不是逐格写上了新状态。
如果你熟悉 vim 的:for循环,也可以在一代结束后用:
:for i in range(1, 8) : normal @b :endfor但这就属于 vimscript 了,不是纯录制宏。兼容边界后面会再提。
4.4 连续推演:合并、清空、再来一轮
一代写完,新棋盘里就是下一代的状态。这时候要把新棋盘变成下一轮的旧棋盘,方法是删掉旧棋盘区域,再把当前棋盘复制一份放到下方并清空,作为接下来的新棋盘。
示例命令:
" 删掉上面的旧棋盘 :1,b:M delete " 把当前棋盘复制一份到文件末尾 :1,$ t $ " 把复制出来的下半部分全部清空 :b:M+1,$ s/././g执行完后,文件又变回“上面旧棋盘、下面空棋盘”的布局,可以继续跑下一代。
把这三行和跑一整代的操作合并到宏d里:
qd ...一整代的执行... :1,b:M delete :1,$ t $ :b:M+1,$ s/././g q之后想看多少代,就执行5@d、10@d,或者用@@反复跑。跑之前建议先:set lazyredraw。
一个小建议:每次跑完一代,先停在旧棋盘左上角,确认当前棋盘确实是你期望的新状态,再继续。连续跑 20 代之前,先一代一代跑 5 次,手动检查,比跑完再回头排查容易得多。
5. 规则、参数与扩展:换规则、跑大棋盘、做可视化
5.1 把规则变成参数
前面用的规则是 B3/S23,也就是生命游戏最经典的规则:邻居数为 3 时出生,邻居数为 2 或 3 时存活。
想换成其它元胞自动机规则,只需要改判断表达式。比如 HighLife 规则是 B36/S23,意思是邻居数为 3 或 6 时出生。表达式可以改成:
:let @r = ((@5 == 'O' && (n == 2 || n == 3)) || n == 3 || n == 6) ? 'O' : '.'这类改动在宏方案里成本很低,因为所有逻辑都集中在一条:let表达式里。对初学者来说,这也是理解“规则编码”的好机会:先手写一遍规则的真值表,再翻译成表达式。
5.2 大棋盘和性能问题
单格宏最明显的短板是慢。每处理一个格子,要移动光标、yank 9 个字符、做字符串统计、写回,一次宏调用涉及一大堆操作。棋盘如果是 50×50,一代就有 2500 个格子,连续跑几十代,速度非常感人。
处理大棋盘我一般分两步。第一步,还是用宏方案在小棋盘上把逻辑调对;第二步,把整代的计算交给更合适的方式,比如:
- 用 vim 的
\=替换配合表达式,对整行一次算完; - 把棋盘导出成文本,交给
awk处理,算完再导回 vim; - 如果只是看结果,可以直接在终端里写一个
awk脚本,把 vim 当作文本查看器。
举个例子,导出当前棋盘可以用:
:w! board.txt外部处理完再读回来查看。宏方案的优势从来不是性能,而是不需要额外依赖、理解直观、现场就能推演。
5.3 把每一代保存下来
连续跑代际时,如果不做处理,屏幕上只有最后一代的状态。想回看每一代,可以在每代结束后把棋盘保存到独立文件里。
:w! gen.txt想看得更接近动画,可以在每代执行前加两行:
:redraw :sleep 200msleep 200m表示暂停 200 毫秒。这样宏在连续跑代际时,屏幕会像动画一样一帧一帧刷新。如果你在录制外层宏时把这两行也录进去,回放时会很有现场感。
如果想把多代合成一张长图,可以把每个世代保存成独立文件,再用paste或其它工具拼接。这属于后处理,和 vim 本身关系不大。
6. 兼容 vi?先分清哪些是 vim 特性,哪些是通用 ex 命令
6.1 宏录制本来就不是经典 vi 的能力
项目标题里写“兼容 vi”,这个点值得认真说。
严格讲,经典 vi(比如 Solaris vi)没有 vim 里的q录制宏机制。q录制、@回放这套交互是 vim 对 vi 的增强。nvi 和 busybox vi 的情况也各不相同,有些版本支持部分寄存器执行,有些版本不支持。以你自己目标环境实测为准,不要默认“vi 就等于 vim 的简易版”。
vim 里常用的这些能力,归属如下:
| 能力 | 经典 vi | nvi | busybox vi | vim |
|---|---|---|---|---|
q录制宏 | 不支持 | 不支持 | 视版本 | 支持 |
@回放寄存器 | 不支持 | 视版本 | 视版本 | 支持 |
| 寄存器 yank/put | 支持 | 支持 | 支持 | 支持 |
:let表达式 | 不支持 | 不支持 | 不支持 | 支持 |
\=替换表达式 | 不支持 | 不支持 | 不支持 | 支持 |
\%#光标锚点 | 不支持 | 不支持 | 不支持 | 支持 |
:!外部命令 | 支持 | 支持 | 部分 | 支持 |
这张表想说明的是:这套宏方案里的“灵魂”部分(表达式统计、光标锚点、表达式替换)都是 vim 特性。如果说“兼容 vi”是指 Solaris vi 那种原生 vi,完整复现不太现实。
6.2 经典 vi 能做什么,不能做什么
经典 vi 能做的部分主要是:
- 用
:map定义键映射,实现简单宏; - 用寄存器做 yank/put;
- 用
:!调用外部命令处理文本。
但它做不到表达式计算,做不到\%#这种光标锚点,也没有\=替换。所以纯 vi 环境下,比较现实的做法是:把棋盘导出,用awk算好下一代,再导回来看。vim 在这条链路里只是展示和输入工具,不是计算引擎。
6.3 务实的“兼容”建议
如果你真的需要在受限环境里演示,我的建议是:
- 先在本机 vim 里把宏方案跑熟;
- 到了受限环境,先确认它到底是 nvi、busybox vi 还是 vim 的兼容模式;
- 不支持宏的环境,就改用“vim 编辑 + 外部命令计算”的组合;
- 不要把“兼容 vi”理解为同一套按键到处能跑,要理解为设计思路不依赖 vim 插件,核心流程可以在不同编辑器中迁移。
7. 常见报错与排查顺序
7.1 现象对照表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
执行@a后没有任何变化 | 计数器算错,规则表达式永远为假 | 先:reg 1 2 3 4 5 6 7 8 9检查寄存器内容 |
| 格子被改成错误字符 | 邻居统计错位,寄存器抓错位置 | 检查 yank 序列里的方向,特别是jhh组合 |
| 越跑越乱,图案不符合生命游戏 | 同步更新问题,同盘原地替换 | 改用双棋盘方案 |
| 光标在边界时宏错位 | 没有加边框,或行宽不一致 | 确认外围有一圈.,确认每行等宽 |
出现E486: 找不到模式 | 光标位置不在目标行 | 确认宏录制时光标确实在棋盘格子上 |
nvi 下:let报错 | nvi 不支持表达式 | 改外部命令方案 |
| 连续跑多代后行数变多 | 每一代复制了新棋盘但没清理 | 检查:1,b:M delete是否执行 |
7.2 我自己的排查顺序
遇到宏跑错,不要先怀疑规则表达式,按顺序查:
- 查寄存器内容,确认 9 个寄存器抓到的都是预期位置的字符;
- 查棋盘形状,是不是每行等宽、边界够不够;
- 查光标位置,执行
@a前光标是否准确落在某个格子上; - 查同步更新,是不是把新状态写回了旧棋盘;
- 最后再查规则表达式本身。
大多数“宏完全没反应”的情况,其实不是表达式问题,是寄存器没抓到内容,或者光标在宏执行中被带到了奇怪的地方。
7.3 收尾提示
vim 宏编程做生命游戏,真正值钱的地方不在“能跑”,而在跑的过程中被迫把问题拆解清楚:状态怎么表示、邻居怎么取、新状态怎么写、同步怎么做。这套拆解能力放到任何自动化脚本、任何代码生成任务里都一样有用。
如果只是学习,我建议把目标定成“小棋盘上连续推演十几代不出错”就够了。不用急着优化成几十毫秒跑一代,宏方案最舒服的体验是:能在任何一台装有 vim 的 Linux 机器上现场演示,不装插件,不输脚本。把单格宏、双棋盘、代际合并这三步吃透,已经是一个很扎实的 vim 宏编程案例了。