1979 年,Dan Bricklin 与 Bob Frankston 在一台仅有 16KB 内存的 Apple II 上推出了 VisiCalc,这是世界上第一款电子表格软件。它的出现重新定义了商业计算的交互范式,也让「_what-if」分析从手算走向了程序化。近期,开发者 Sergey Zaitsev(社区昵称 zserge)发布了一个 VisiCalc 重建项目,通过从零构建一个极简的电子表格引擎,还原了那个年代在极端资源约束下的工程决策细节。这个项目不仅是对历史的致敬,更为当代系统工程师提供了关于表达式解析、依赖追踪与内存布局的珍贵参考样本。
核心数据结构:单元格与网格的紧凑表达
在现代电子表格中,我们习惯于用对象或类来存储单元格,习惯性地为每个单元格分配数十甚至上百字节的内存。然而在 1979 年的 Apple II 环境下,16KB 到 32KB 已经是整机可用的全部内存,操作系统与用户程序必须共享这片空间。VisiCalc 的做法是将每个单元格设计为固定长度的存储块,避免了动态分配带来的碎片化风险。重建项目中复现了这一思路,采用如下结构体定义单元格:
enum { EMPTY, NUM, LABEL, FORMULA };
struct cell {
int type;
float val;
char text[MAXIN]; // raw user input, MAXIN=128
};
这个结构体的核心在于 text 字段直接存储用户输入的原始字符串,而不预先解析为某种中间表示。类型标签 type 占 4 字节,浮点值 val 占 4 字节,text 数组占 128 字节 —— 总计约 136 字节。在当时的设计中,实际实现可能更紧凑,但这个数量级已经说明了一个关键原则:对于极低内存环境,将解析延迟到使用时再做是更优的选择。值得注意的是,项目作者在 Hacker News 的讨论中指出,原始 VisiCalc 为了进一步压缩空间,甚至使用了变长记录与 0xff 标记来区分公式单元格的末尾,这种「一个字节的魔法」在当时的调试中甚至导致了一些边缘情况下的 bug。
网格本身被组织为 struct grid { struct cell cells[NCOL][NROW]; },其中 NCOL 为 26(对应 A 到 Z 列),NROW 为 50 行。这意味着一个完整的表格最多占用 26×50×136 ≈ 176800 字节,约合 172KB—— 这在当时的内存条件下显然不可直接整体加载。因此,原始 VisiCalc 采用了行向量与单元格块从两端相向生长的策略:当行向量从低地址向高地址延伸、单元格块从高地址向低地址延伸时,一旦两者相遇,程序就需要在原位进行重组。这个细节在重建项目中通过简化模拟得到了呈现,也让读者能够体会到「没有内存管理库的时代」程序员必须面对的权衡。
表达式解析:递归下降与延迟计算
表达式解析是电子表格引擎的技术核心。VisiCalc 的公式语法包含了数字、单元格引用(如 A1、BB12)、运算符(+、-、*、/、^)以及内建函数(@SUM、@ABS、@MAX 等)。重建项目实现了一个经典的递归下降解析器,分为 primary(处理数字与单元格引用)、term(处理乘除与乘方)、expr(处理加减)三个层级,这与现代编译器教材中的示例代码高度一致,但在实现细节上做了面向嵌入式场景的裁剪。
一个关键的设计决策体现在单元格引用的解析函数 ref 中。它需要同时处理单字母列号(A 到 Z)与双字母列号(AA 到 ZZ),并返回该引用在原始字符串中占据的字符数。这一细节看似 trivial,实际上直接影响用户体验:用户输入 =B2+10 时,解析器必须准确识别 B2 为一个独立的 token,而非将 BB2 误认为单元格引用。重建项目的实现返回了「消耗的字符数」,这使得解析器可以自然地推进指针位置,实现简洁的流式解析。
在表达式求值层面,项目采用了延迟计算策略:公式单元格仅在需要显示或被依赖单元格请求时才计算其值。这种策略在现代响应式系统中被称为「惰性求值」,但在 1979 年的 VisiCalc 中,它更多地是出于内存与计算量的双重约束。重建项目进一步将这一思路显式化:在 recalc 函数中,程序会最多遍历所有公式单元格 100 轮,每一轮都重新解析并计算每个公式,直到某一轮遍历中所有单元格的值都未发生变化。这个简单的迭代不动点算法在大多数真实使用场景下能在 3 到 5 轮内收敛,其时间复杂度在最坏情况下为 O (passes × cells × formula_length),对于当时几百个单元格的表格规模而言完全可接受。
依赖追踪的工程权衡:图还是遍历
在 Hacker News 的讨论中,一个话题引发了激烈争论:现代电子表格引擎是否必须维护完整的依赖图?项目作者在博客中写道「维护依赖图往往是一种过度设计」,这一说法遭到了多位资深开发者的反驳。原始 VisiCalc 实际上根本没有维护依赖图 —— 它仅支持按行优先或列优先的顺序进行一次性重算,如果用户修改了一个被其他公式依赖的单元格,需要手动触发重新计算才能看到正确结果。
这个细节揭示了资源受限环境下的典型工程哲学:用近似正确替代精确完备。Bob Frankston 本人在讨论中回忆道:「我们意识到普通电子表格是简单的,无论按行还是按列计算,错误通常会立即显现。后期电子表格宣传的『自然顺序重算』是一个重大特性,但在 Apple II 上,我们做出了正确的权衡。」这种做法在当代开发者看来可能是不可接受的 —— 想象一下在一个循环依赖的表格中(A1 引用 B2,B2 引用 A1),仅执行一次遍历会导致错误的计算结果。然而在 1979 年的硬件限制下,这已经是能够交付的最优解。
对比当代实现,若要支持「自然顺序」—— 即根据单元格间的实际依赖关系决定计算顺序 —— 则必须维护一张有向无环图(DAG),并在图上进行拓扑排序。这个数据结构的内存开销对于当时的 16KB 机器而言是难以承受的:假设每个单元格最多有 4 个依赖关系,仅依赖关系表就需要约 26×50×4×2 ≈ 10KB 的额外存储,加上图遍历的指针开销,几乎要消耗掉可用内存的三分之一。而在现代环境下,一个包含数十万单元格的表格维护完整依赖图的代价已经微乎其微 —— 这也解释了为什么 Excel 在 1985 年 Macintosh 版本中就引入了依赖图,而 VisiCalc 在其整个生命周期中都未能实现这一特性。
面向现代实现的参考参数
尽管硬件环境已截然不同,VisiCalc 重建项目中的若干参数与策略对于构建现代轻量级电子表格引擎仍具有直接参考价值。首先是重算迭代上限:项目设置为 100 轮,对于绝大多数 DAG 结构,这个阈值足够保证收敛,同时在出现循环引用时可以作为安全阀防止无限循环。在生产级系统中,建议将此参数与「最大重算深度」监控指标绑定,当实际迭代次数接近阈值时触发告警,因为这通常意味着表格结构存在问题。
其次是单元格存储的内存预算。假设一个现代应用需要支持 100 万个单元格,每个单元格分配 64 字节,总计约 64MB—— 这在移动设备上仍然是完全可行的。关键在于避免为每个单元格预分配过大的缓冲区,特别是在处理富文本或国际化字符串时更应谨慎。一种可行的策略是采用字典映射(sparse array),仅在实际写入时才为单元格分配存储。
第三是表达式缓存策略。VisiCalc 的原始设计在每次重算时都重新解析公式字符串,这显然是一种时间换空间的 trade-off。在现代实现中,可以为每个公式单元格缓存解析后的 AST(抽象语法树),仅在公式本身被修改时才重新解析。这个优化可以将重算性能提升一个数量级,尤其是对于包含数千个公式的大型表格。
最后,对于需要极低延迟交互的场景(如在 Web 前端实时编辑),可以考虑采用「脏标记」机制:仅标记哪些单元格受到影响,而不是每次修改都触发全表重算。结合依赖图的增量更新算法,可以将平均重算成本从 O (cells) 降低到 O (affected_cells × 依赖深度),这对用户体验的提升是显著的。
VisiCalc 重建项目的重要性不仅在于还原了一段技术史,更在于它提供了一面镜子,让当代工程师得以审视自己在资源充裕环境下是否无意中做了过度设计。当我们抱怨现代框架「过于复杂」时,不妨回顾 1979 年的那些程序员如何在 16KB 内存中创造出影响整个商业世界的软件 —— 或许能找到回归工程本质的路径。
资料来源:VisiCalc Reconstructed 项目博客(zserge.com/posts/visicalc/)与 Hacker News 讨论(news.ycombinator.com/item?id=47410871)