Loading... # 从零重建 VisiCalc 电子表格软件 # 一、概述 ## 1. 项目背景 ### A. VisiCalc 的历史意义 VisiCalc 是世界上第一款电子表格软件,由 Dan Bricklin 和 Bob Frankston 于 1979 年创造。这款软件仅用数千行 6502 汇编代码编写,却能在 16KB 内存的机器上运行。它成为 Apple II 的杀手级应用,销量超过 100 万份,将早期的个人电脑转变为真正的商业工具。 ### B. 电子表格的设计理念 电子表格被誉为史上最佳用户体验设计之一。它具有极简的学习曲线,允许用户快速操作数据、描述逻辑、可视化结果,甚至可以用它创作艺术和运行 GameBoy 模拟器。 ### C. 本文目标 从零开始构建一个最小化的 VisiCalc 克隆版本,探索电子表格的核心设计原理。最终实现将包含完整的数据模型、公式求值器和简单的终端用户界面。 ## 2. 核心问题 如何用最少的代码实现一个功能完整的电子表格系统,需要解决以下问题: - 如何表示和存储单元格数据 - 如何解析和计算数学公式 - 如何处理单元格间的依赖关系 - 如何构建高效的用户界面 # 二、系统设计 ## 1. 核心概念 电子表格系统由以下几个基本概念组成: - 单元格(Cell):表格的基本单位,可存储值、公式或标签 - 网格(Grid):单元格的二维集合 - 公式(Formula):引用其他单元格的数学表达式 - 响应式计算:当单元格值变化时自动重新计算相关公式 ## 2. 数据模型设计 ### A. 单元格结构 每个单元格可以处于四种状态之一:空、数值、标签或公式。 ```c #define MAXIN 128 // 单元格输入最大长度 enum { EMPTY, NUM, LABEL, FORMULA }; // 单元格类型 struct cell { int type; // 单元格类型 float val; // 数值 char text[MAXIN]; // 原始用户输入 }; ``` ### B. 网格结构 Excel 有 1,048,576 行和 16,384 列的限制,原始 VisiCalc 有 256 行和 64 列。我们的迷你版本可以使用更小的尺寸: ```c #define NCOL 26 // 最大列数 (A..Z) #define NROW 50 // 最大行数 struct grid { struct cell cells[NCOL][NROW]; }; ``` ## 3. 系统架构 ```mermaid graph TB subgraph 用户界面 UI[TUI 界面] Input[输入处理] end subgraph 核心引擎 Grid[网格数据] Parser[公式解析器] Recalc[重新计算] end subgraph 数据模型 Cell[单元格] Formula[公式] end UI --> Input Input --> Grid Grid --> Parser Parser --> Recalc Recalc --> Grid Grid --> Cell Cell --> Formula ```  # 三、公式求值器 ## 1. 解析器设计 使用递归下降解析器(Recursive Descent Parser)来计算公式。解析器需要能够: - 识别数字和单元格引用 - 处理运算符优先级 - 支持函数调用 - 访问网格中的单元格值 ### A. 解析器结构 ```c struct parser { const char* s; // 公式字符串 int pos; // 当前位置 struct grid* g; // 网格引用 }; ``` ## 2. 语法解析层次 公式解析遵循经典的运算符优先级规则: - 表达式(Expression):由加法和减法连接的项 - 项(Term):由乘法和除法连接的因子 - 因子(Factor):数字、单元格引用、函数调用或括号表达式 ```mermaid graph TD A[表达式 Expression] --> B[项 Term] B --> C[因子 Factor] C --> D[数字 Number] C --> E[单元格引用 CellRef] C --> F[函数调用 Function] C --> G[括号表达式 ParenExpr] B -->|+/-| B A -->|*//| A ```  ## 3. 解析实现 ### A. 因子解析 因子是最基本的语法单位,可以是: - 带符号的一元运算 - 函数调用(以 @ 开头) - 括号表达式 - 数字字面量 - 单元格引用 ```c float primary(struct parser* p) { skipws(p); if (!*p->p) return NAN; // 处理一元加号 if (*p->p == '+') p->p++; // 处理一元减号 if (*p->p == '-') { p->p++; return -primary(p); } // 函数调用 if (*p->p == '@') { p->p++; return func(p); } // 括号表达式 if (*p->p == '(') { p->p++; float v = expr(p); skipws(p); if (*p->p != ')') return NAN; p->p++; return v; } // 数字字面量 if (isdigit(*p->p) || *p->p == '.') return number(p); // 单元格引用 return cellval(p); } ``` ### B. 项解析 处理乘法和除法运算: ```c float term(struct parser* p) { float l = primary(p); for (;;) { skipws(p); char op = *p->p; if (op != '*' && op != '/') break; p->p++; float r = primary(p); if (op == '*') l *= r; else if (r == 0) return NAN; else l /= r; } return l; } ``` ### C. 表达式解析 处理加法和减法运算: ```c float expr(struct parser* p) { float l = term(p); for (;;) { skipws(p); char op = *p->p; if (op != '+' && op != '-') break; p->p++; float r = term(p); l = (op == '+') ? l + r : l - r; } return l; } ``` ## 4. 单元格引用解析 将单元格地址(如 A1、AB123)转换为网格坐标: ```c int ref(const char* s, int* col, int* row) { const char* p = s; // 解析列字母 if (!isalpha(*p)) return 0; *col = toupper(*p++) - 'A'; if (isalpha(*p)) *col = *col * 26 + (toupper(*p++) - 'A'); // 解析行数字 char* end; int n = strtol(p, &end, 10); if (n <= 0 || end == p) return 0; *row = n - 1; return (int)(end - s); } ``` ## 5. 函数支持 支持基本的内置函数: - @SUM:求和函数,支持范围参数(如 @SUM(A1...C3)) - @ABS:绝对值函数 - @INT:取整函数 - @SQRT:平方根函数 # 四、响应式计算 ## 1. 计算依赖问题 电子表格的核心特性是响应式计算:当某个单元格的值发生变化时,所有引用该单元格的公式都应该自动重新计算。 ## 2. VisiCalc 的解决方案 原始 VisiCalc 受限于 16KB 内存,采用了简单而有效的策略: - 每次单元格更新时重新计算整个表格 - 用户可以选择按行优先或按列优先的计算顺序 - 对于复杂依赖,建议用户多次运行手动重新计算命令 ## 3. 自动迭代计算 现代实现可以自动化这个过程,运行多次迭代直到没有新的变化: ```mermaid graph TD A[单元格更新] --> B[开始重新计算] B --> C[遍历所有单元格] C --> D{单元格是否为公式?} D -->|是| E[重新计算公式值] D -->|否| C E --> F{值是否改变?} F -->|是| G[标记已变化] F -->|否| C G --> C C --> H{是否遍历完成?} H -->|否| C H -->|是| I{是否有变化?} I -->|是| B I -->|否| J[完成] ```  ### 实现代码 ```c void recalc(struct grid* g) { for (int pass = 0; pass < 100; pass++) { int changed = 0; for (int r = 0; r < NROW; r++) for (int c = 0; c < NCOL; c++) { struct cell* cl = &g->cells[c][r]; if (cl->type != FORMULA) continue; struct parser p = {cl->text, cl->text, g}; float v = expr(&p); if (v != cl->val) changed = 1; cl->val = v; } if (!changed) break; } } ``` # 五、单元格设置 ## 1. 输入分类 根据用户输入的第一个字符,自动判断单元格类型: - 以 +、-、( 或 @ 开头:公式 - 以数字或小数点开头:数值 - 其他:标签 ```c void setcell(struct grid* g, int c, int r, const char* input) { struct cell* cl = cell(g, c, r); if (!cl) return; // 空输入清空单元格 if (!*input) { *cl = (struct cell){0}; recalc(g); return; } // 保存原始输入 strncpy(cl->text, input, MAXIN - 1); // 分类输入类型 if (input[0] == '+' || input[0] == '-' || input[0] == '(' || input[0] == '@') { cl->type = FORMULA; } else if (isdigit(input[0]) || input[0] == '.') { char* end; double v = strtod(input, &end); cl->type = (*end == '\0') ? NUM : LABEL; if (cl->type == NUM) cl->val = v; } else { cl->type = LABEL; } // 触发重新计算 recalc(g); } ``` ## 2. 测试用例 ```c struct grid g = {0}; // 设置 A1=5, A2=7, A3=11, A4=@SUM(A1...A3) setcell(&g, 0, 0, "5"); setcell(&g, 0, 1, "7"); setcell(&g, 0, 2, "11"); setcell(&g, 0, 3, "+@SUM(A1...A3)"); assert(g.cells[0][3].val == 23.0f); // 修改值,总和应重新计算 setcell(&g, 0, 0, "5"); setcell(&g, 0, 1, "+A1+5"); setcell(&g, 0, 2, "+A2+A1"); assert(g.cells[0][3].val == 5.0f + 10.0f + 15.0f); // 修改 A1,所有公式应重新计算 setcell(&g, 0, 0, "7"); assert(g.cells[0][3].val == 7.0f + 12.0f + 19.0f); ``` # 六、用户界面 ## 1. 界面布局 VisiCalc 的屏幕分为四个区域,垂直排列: ```mermaid graph TB subgraph VisiCalc 界面 S[状态栏: 单元格地址 + 值/公式 + 模式] E[编辑行: 当前输入或单元格内容] H[列标题: A, B, C, ...] G[网格区域: 单元格内容 + 行号] end ```  ## 2. 视口设计 由于网格可能比终端屏幕大,需要实现滚动视口: ```c #define CW 9 // 列显示宽度 #define GW 4 // 行号栏宽度 // 可见的行列数 int vcols(void) { return (COLS - GW) / CW; } int vrows(void) { return LINES - 4; } struct grid { struct cell cells[NCOL][NROW]; int cc, cr; // 光标列、行 int vc, vr; // 视口左上角 }; ``` ## 3. 视口滚动逻辑 当光标移动到屏幕外时,视口自动跟随: ```c // 水平滚动 if (g.cc < g.vc) g.vc = g.cc; if (g.cc >= g.vc + vcols()) g.vc = g.cc - vcols() + 1; // 垂直滚动 if (g.cr < g.vr) g.vr = g.cr; if (g.cr >= g.vr + vrows()) g.vr = g.cr - vrows() + 1; ``` ## 4. 渲染实现 ### A. 状态栏 显示当前单元格地址、值或公式,以及当前模式: ```c // 状态栏:单元格地址 + 值 + 模式指示器 attron(A_BOLD | A_REVERSE); mvprintw(0, 0, " %c%d", 'A' + g->cc, g->cr + 1); if (cur->type == FORMULA) printw(" %s = %.10g", cur->text, cur->val); mvprintw(0, COLS - 6, mode == ENTRY ? "ENTRY" : "READY"); attroff(A_BOLD | A_REVERSE); ``` ### B. 编辑行 显示当前输入或单元格内容: ```c if (mode) mvprintw(1, 0, "> %s_", buf); else if (cur->type != EMPTY) mvprintw(1, 0, " %s", cur->text); ``` ### C. 列标题和网格 绘制列标题(A、B、C...)和网格内容: ```c // 列标题 attron(A_BOLD | A_REVERSE); for (int c = 0; c < vcols(); c++) mvprintw(2, GW + c * CW, "%*c", CW, 'A' + g->vc + c); attroff(A_BOLD | A_REVERSE); // 网格单元格 for (int r = 0; r < vrows() && g->vr + r < NROW; r++) { int row = g->vr + r, y = 3 + r; // 行号栏 attron(A_REVERSE); mvprintw(y, 0, "%*d ", GW - 1, row + 1); attroff(A_REVERSE); // 单元格内容 for (int c = 0; c < vcols() && g->vc + c < NCOL; c++) { int col = g->vc + c; struct cell* cl = cell(g, col, row); // 格式化显示 int is_cur = (col == g->cc && row == g->cr); if (is_cur) attron(A_REVERSE); mvprintw(y, GW + c * CW, "%s", formatted_buf); if (is_cur) attroff(A_REVERSE); } } ``` # 七、输入处理 ## 1. 模态界面设计 VisiCalc 采用模态界面设计,用户处于两种模式之一: - READY 模式:在网格中导航 - ENTRY 模式:编辑单元格内容 ## 2. 特殊字符处理 在 READY 模式下,以下字符有特殊含义: - /:进入命令模式(/B 清空、/Q 退出、/F 格式化) - >:进入跳转模式(输入单元格地址如 B5 并按 Enter) - 其他可打印字符:进入单元格编辑模式 ## 3. 命令模式实现 ```c if (ch == '/') { mvprintw(1, 0, "Command: /"); refresh(); ch = getch(); if (toupper(ch) == 'Q') break; // 退出 if (toupper(ch) == 'B') { // 清空单元格 *cell(&g, g.cc, g.cr) = (struct cell){0}; recalc(&g); } if (toupper(ch) == 'F') { // 格式化 // 格式化命令处理 } } ``` ## 4. 确认和导航 在 ENTRY 模式下: - Enter:确认输入并向下移动 - Tab:确认输入并向右移动 ```c if (ch == 10 && mode == ENTRY) { setcell(&g, g.cc, g.cr, buf); if (g.cr < NROW - 1) g.cr++; mode = READY; } ``` # 八、实现总结 ## 1. 核心功能 通过约 500 行 C 代码,我们实现了: - 完整的单元格数据模型 - 递归下降公式解析器 - 自动迭代重新计算机制 - 模态 TUI 用户界面 - 视口滚动和渲染 ## 2. 未实现功能 完整版本还可以添加: - 文件 I/O - /R 复制命令(批量复制公式) - 更多内置函数 - 移动和删除行列 - 更复杂的格式化选项 ## 3. 设计启示 VisiCalc 的设计历经 47 年仍然有效,证明了其核心设计的优雅和简洁。关键设计原则包括: - 极简的数据模型 - 响应式计算 - 直观的用户界面 - 高效的实现方式 *** ## 参考资料 1. [VisiCalc reconstructed - zserge.com](https://zserge.com/posts/visicalc/) 2. [kalk - VisiClone implementation on GitHub](https://github.com/zserge/kalk) 3. [World smallest office suite](https://zserge.com/posts/awfice/) 最后修改:2026 年 03 月 21 日 © 允许规范转载 赞 如果觉得我的文章对你有用,请随意赞赏