ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

vim宏编程实现生命游戏:用按键序列推演元胞自动机

vim宏编程实现生命游戏:用按键序列推演元胞自动机 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 邻居的活细胞数。具体按键录制到寄存器aqa ms 用标记 s 记住当前格位置 hk1yl 左上角 l2yl 正上 l3yl 右上 jhh4yl 正左 l5yl 当前格中心 l6yl 正右 jhh7yl 左下 l8yl 正下 l9yl 右下 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 hk1yl l2yl l3yl jhh4yl l5yl l6yl jhh7yl l8yl l9yl 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 hk1yl l2yl l3yl jhh4yl l5yl l6yl jhh7yl l8yl l9yl 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跑几个格子确认每跑一次光标会向右移动一格然后用10b或20b连续跑一行观察新棋盘区是不是逐格写上了新状态。如果你熟悉 vim 的:for循环也可以在一代结束后用:for i in range(1, 8) : normal b :endfor但这就属于 vimscript 了不是纯录制宏。兼容边界后面会再提。4.4 连续推演合并、清空、再来一轮一代写完新棋盘里就是下一代的状态。这时候要把新棋盘变成下一轮的旧棋盘方法是删掉旧棋盘区域再把当前棋盘复制一份放到下方并清空作为接下来的新棋盘。示例命令 删掉上面的旧棋盘 :1,b:M delete 把当前棋盘复制一份到文件末尾 :1,$ t $ 把复制出来的下半部分全部清空 :b:M1,$ s/././g执行完后文件又变回“上面旧棋盘、下面空棋盘”的布局可以继续跑下一代。把这三行和跑一整代的操作合并到宏d里qd ...一整代的执行... :1,b:M delete :1,$ t $ :b:M1,$ s/././g q之后想看多少代就执行5d、10d或者用反复跑。跑之前建议先: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 里常用的这些能力归属如下能力经典 vinvibusybox vivimq录制宏不支持不支持视版本支持回放寄存器不支持视版本视版本支持寄存器 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 宏编程案例了。
返回列表