news 2026/9/7 4:53:42

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

作者头像

张小明

前端开发工程师

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

如果你只在 Vim 里用宏做过“批量加注释”或“把单词统一替换”,那你可能还没有真正见过宏编程的杀伤力。

这篇文章要完成一个看起来像行为艺术、实际上非常训练底层功夫的任务:不装任何插件,不写 Python 脚本,只用 Vim 的宏寄存器,从零实现一个康威生命游戏(Conway's Game of Life)。

先说结论:宏(macro)在 Vim 生态里被严重低估。它不只是“录一次按键然后回放”的偷懒工具;寄存器的本质是一段可递归调用的击键程序。寄存器里存的是字符序列,字符序列本身又是程序,这正是“文本即程序”的极佳体现。

这篇文章会带你完成三件事:第一,理解宏和寄存器如何构成 Vim 的“函数与变量”;第二,学会用三行窗口和影子缓冲区解决同步更新问题;第三,拿到一份可以直接复制运行的 Vim 脚本,在终端里跑出一个能移动、能繁衍的二维生命场。

写这个主题还有一个更实际的原因:当你真的把宏用到这种程度,再回到日常的批量文本处理里,你会对“当前位置、上下文行、缓冲区边界”这三样东西格外敏感。很多微妙的编辑 bug,其实都出在这三件事上。

最终代码会尽量保持 vi 风格的编辑命令,但需要说明的是,规则判断和计数部分用到了 Vim 扩展能力。文章里会对“能兼容到什么程度”给出一个清晰边界。

1. 这篇文章真正要解决的问题

生命游戏的规则人人都能看懂:二维网格上每个细胞有生或死两种状态,下一帧的状态由它周围八个邻居的状态决定。规则可以浓缩成一句口诀:

  • 活细胞:邻居数为 2 或 3 时继续活,否则死亡;
  • 死细胞:邻居数正好为 3 时复活;
  • 其余情况保持死亡。

规则简单到小学生都能理解,但真要在一个文本编辑器里跑起来,会遇到三个非常现实的问题。

第一个问题是循环。Vim 的宏本身可以重复执行,问题是“执行多少次、什么时候停”。如果只做 20 代,那我们可以用20@a;如果要做动态演示,就必须让宏能判断“是否还有下一行”。宏里没有现成的 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][j+1]再算邻居时就已经读到新值了。正确做法是先把所有下一帧结果存进一个新数组,全部算完后再一次性写回。

这个道理放在 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 nowrap

nocompatible保证我们使用的是 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 行,可以用:

10@a

这就是宏最基础的用法:录制一段操作,批量回放。

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"

打开 Vim:

vim -u NONE rule90.txt :source rule90.vim 15@r

每条新行都用上一行的左右邻居生成,所以宏@r每执行一次,就增加一代。15 次执行后,文件末尾会出现一条清晰的分形纹理。

这个热身告诉我们两件事:

  • 宏可以当作迭代器使用,每次执行追加一行;
  • “用上一行生成下一行”其实就是二维生命游戏在高度上的投影。

5. 核心设计:用三重窗口统计二维邻居

进入二维之前,先把最难的两个设计点讲清楚。

5.1 为什么不能边算边写

如果按照“从上到下逐行更新”的方式直接写回,那么当处理第 3 行时,第 2 行已经被改成了新状态。第 3 行用来统计邻居的数据,就不再是“上一代的第 2 行”,而是“下一代已经更新过的第 2 行”。

这会导致感染式传播:一个细胞的更新结果立刻影响下一个细胞,下一个细胞又影响下下个,最后整个场的演化规律完全偏离康威生命游戏。

解决办法是影子缓冲区:先把所有行的计算结果缓存在一个数组里,全部算完之后再一次性写回缓冲区。文本编辑器的setline()是事后操作,计算永远只基于快照。

5.2 数据格式与边界

我们要处理的文本是一个矩形,每行由01组成,长度完全一致。1代表活细胞,0代表死细胞。

生命游戏要求无限网格,真实文本缓冲区却有边界。处理办法很朴素:在四周补一圈0

  • 左右边界:每行首尾各加两个0
  • 上下边界:文件顶部和底部各插入一行全0

为什么左右各加两个0而不是一个?因为计算一个细胞的邻居时,需要读取它左边一格和右边一格。如果只留一个0,那么最左侧真实细胞在读取“左邻居”时正好读到边界0;但如果你再往左看,比如统计窗口的中心落在边界0上,它还需要读取更左边一格。留两个0最简单,能保证任何合法计算位置都不会越界。

这个工作由LifeInit()函数完成。

5.3 三行窗口的统计逻辑

假设当前要计算的是第i行第c列那个细胞,那么它的八邻居分布如下:

上一行: 左 中 右 当前行: 左 中心 右 下一行: 左 中 右

在 Vim 里,我们可以一次读三行:prevcurrnext。对于第c列,三次strpart()分别取出c-1cc+1三列,这样三行一共取出 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)\r"

6.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需要更大场时先在四周多补几圈 0

9. 最佳实践与工程建议

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录制即可。宏编程的核心从来不是一次录对,而是你愿不愿意把寄存器当作一张草稿纸,反复涂改,直到写出能稳定复现的程序。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 4:53:42

用Python仿真理解调制解调:从AM、FM到BPSK误码率

通信技术发展至今已经超过一百年。从最早的有线电报&#xff0c;到后来的无线电广播&#xff0c;再到今天的 5G 移动通信和卫星互联网&#xff0c;设备形态和协议栈换了无数代&#xff0c;但通信系统要解决的核心问题始终只有一个&#xff1a;让信息可靠、高效地穿越信道&#…

作者头像 李华
网站建设 2026/9/7 4:52:21

猫抓cat-catch:3步装好并抓到第一个资源

猫抓cat-catch&#xff1a;3步装好并抓到第一个资源 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 想存下网页上的视频&#xff0c;右键菜单里却只…

作者头像 李华
网站建设 2026/9/7 4:47:28

Python期末复习模拟卷:核心考点与易错题解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 4:47:02

从原始行情到实盘策略:机器学习交易代码库完整指南

从原始行情到实盘策略&#xff1a;机器学习交易代码库完整指南 【免费下载链接】machine-learning-for-trading Code for Machine Learning for Trading, 3rd edition — from data sourcing to live execution. 项目地址: https://gitcode.com/GitHub_Trending/ma/machine-l…

作者头像 李华
网站建设 2026/9/7 4:46:41

Agent开发实战:从Skills设计到腾讯云部署的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 4:45:12

HeaderEditor实战:文件头修复与ROM刷机包配置改动的完整指南

简介&#xff1a;HeaderEditor 5.0.0.41-V2 是一款面向 Chrome 浏览器的请求头编辑与管理插件&#xff0c;适合需要自定义 User-Agent、Referer 等 HTTP 头信息的前端开发者、接口调试人员及网页数据采集者使用。压缩包内仅含 2 个文件&#xff0c;分别为 crx 插件安装包和 jso…

作者头像 李华