VisiCalc được xây dựng lại
Tin tức chung·Hacker News·1 lượt xem

VisiCalc được xây dựng lại

VisiCalc Reconstructed

AI Summary

Việc tái tạo lại VisiCalc, chương trình bảng tính tiên phong đầu tiên, đã làm nổi bật thiết kế tinh tế và các nguyên tắc nền tảng của nó. Bằng cách xây dựng một bản clone tối giản từ đầu, tác giả đã chứng minh cách một data model đơn giản, formula evaluator và UI có thể tái hiện chức năng cốt lõi, thậm chí hỗ trợ cell references và các phép toán cơ bản. Bài tập này là một bài học quý giá cho các developer, minh họa tác động lâu dài của UX tối giản của VisiCalc và các khái niệm cơ bản về data manipulation làm nền tảng cho các công cụ năng suất hiện đại. Việc tái tạo nhấn mạnh rằng các ứng dụng mạnh mẽ có thể được xây dựng với code gọn gàng, hiệu quả, đề cao tầm quan trọng của core logic và data structures.

VisiCalcBảng tính thống trị thế giới trong gần nửa thế kỷ. Tôi thực sự tin rằng đó là một trong những UX tốt nhất từng được tạo ra. Khá tối giản và dễ học, nó cho phép người dùng thao tác nhanh chóng...

VisiCalc

Bảng tính thống trị thế giới trong gần nửa thế kỷ. Tôi thực sự tin rằng đó là một trong những UX tốt nhất từng được tạo ra. Khá đơn giản và dễ học, nó cho phép người dùng nhanh chóng thao tác với dữ liệu, mô tả logic, trực quan hóa kết quả hoặc thậm chí sáng tạo nghệ thuậtchạy Trò chơi GameBoy.

Mọi chuyện bắt đầu vào năm 1979 khi Dan Bricklin và Bob Frankston tạo ra VisiCalc, phần mềm bảng tính đầu tiên. Với vài nghìn dòng lắp ráp 6502 viết tay, VisiCalc có thể chạy thành công trên các máy RAM 16K. Nó nhanh chóng trở thành một “ứng dụng sát thủ” của Apple] [, bán được hơn 1 triệu bản và biến những chiếc máy tính cá nhân đời đầu thành công cụ kinh doanh nghiêm túc.

Tôi nghĩ đây sẽ là một bài tập thú vị khi cố gắng xây dựng lại bản sao VisiCalc tối thiểu từ đầu. Tất cả những gì chúng ta cần là một mô hình dữ liệu, bộ đánh giá công thức và một giao diện người dùng đơn giản để hiển thị các ô. Cuối cùng, chúng ta sẽ có kết quả như thế này:

kalk

Ô

Giống như hầu hết mọi thứ trong cuộc sống, bảng tính được tạo thành từ các ô. Mỗi ô có thể chứa một giá trị, một công thức hoặc để trống. Giá trị có thể là số hoặc văn bản. Công thức là các biểu thức toán học cơ bản có thể tham chiếu đến các ô khác. Tất cả các bạn đều biết điều đó từ Excel, nhưng tiền tố trong công thức VisiCalc thường là + thay vì =, ví dụ +A1+A2*B1 là một công thức, trong khi A1 là một giá trị văn bản.

#define MAXIN 128 // độ dài ô nhập tối đa
enum { EMPTY, NUM, LABEL,  CÔNG THỨC }; // loại ô
cấu trúc ô {
  int loại;
  float val;
  char text[MAXIN]; // thông tin đầu vào thô của người dùng
};

Điều này là đủ để thể hiện các ô trong bảng tính của chúng tôi. Bản thân bảng tính là một mạng lưới các ô. Excel có giới hạn 1.048.576 hàng và 16.384 cột, VisiCalc có 256 hàng và 64 cột. Chúng ta có thể bắt đầu với quy mô nhỏ hơn nữa:

#define NCOL 26 // số cột tối đa (A..Z)
#define NROW 50 // số hàng tối đa
cấu trúc lưới {
  cấu trúc ô ô[NCOL][NROW];
};

Công thức

Bây giờ chúng ta cần triển khai trình đánh giá công thức. Chúng ta có thể sử dụng trình phân tích cú pháp gốc đệ quy đơn giản để tính toán công thức một cách nhanh chóng. Vì các công thức có thể chứa các tham chiếu nên trình phân tích cú pháp phải nhận biết được lưới và có thể tìm nạp các giá trị từ lưới đó.

struct trình phân tích cú pháp {
  const char* s;
  int pos;
  cấu trúc lưới* g;
};

Chúng ta bắt đầu bằng hàm cấp cao nhất expr để đánh giá một biểu thức hoàn chỉnh. Nó gọi thuật ngữ để đánh giá các thuật ngữ, từ đó gọi yếu tố để đánh giá các yếu tố. Một thừa số có thể là một số, một tham chiếu ô hoặc một biểu thức trong ngoặc đơn.

// bỏ qua các ký tự khoảng trắng
void bỏ qua(struct parser* p) { cho (; isspace(*p->p);  p->p++); 
// phân tích tham chiếu ô như A1, AA12, v.v.
int ref(const char* s, int* col, int* row) { ...  // phân tích số
float số(struct parser* p) { ... 
// phân tích tham chiếu ô và trả về giá trị của nó
tĩnh float cellval(struct trình phân tích cú pháp p) { ... 
// gọi hàm phân tích cú pháp như @SUM(A1...B5) hoặc @ABS(-A1)
float func(struct parser* p) { ... 
// phân tích biểu thức chính (số, tham chiếu ô, lệnh gọi hàm, biểu thức trong ngoặc đơn)
thả nổi chính(struct trình phân tích cú pháp* p) { ...  
// thuật ngữ phân tích (yếu tố [*|/ yếu tố]*)
float thuật ngữ(struct parser* p) { ... 
 // phân tích biểu thức (thuật ngữ [+|- thuật ngữ]*)
float expr(struct parser* p) { ... 

Chúng tôi bắt đầu với cấu trúc phân tích cú pháp từ trên xuống cổ điển: các biểu thức cấp cao nhất được phân tích cú pháp dưới dạng các thuật ngữ được phân tách bằng + hoặc -, các thuật ngữ được phân tích cú pháp dưới dạng các yếu tố được phân tách bằng * hoặc / và các yếu tố được phân tích cú pháp dưới dạng nguyên thủy, chẳng hạn như số, tham chiếu ô, lệnh gọi hàm hoặc biểu thức trong ngoặc đơn:

float chính(struct parser* p) {
  bỏ qua(p);
  if (!*p ->p) return NAN;
  if (*p->p == '+') p->p++;
  if (*p->p == '-') {
    p->p;
    trả về -chính(p);
  
  if (*p->p == '@') {
    p->p; trở lại func(p);
  
  if (*p->p == '(') {
    p->p;
    float v = expr(p); bỏ qua(p);
    if (*p->p != ')') return NAN;
    p->p;
    trở lại v;
  
  nếu ( isdigit(*p->p) || *p->p == '.') return số(p);
  return ô(p);

thả nổi thời hạn (struct trình phân tích cú pháp* p) {
  float l = chính(p);
  cho (;;) {
    bỏ qua(p);
    char op = *p-> p;
    if (op != '*' && op != '/') break;
    p->p;
    float r = chính(p);
    nếu  (op == '*')
      l *= r;
    else if (r == 0)
      trở lại NAN;
    khác
      l /= r;
  
  trở lại l ;

float expr(struct parser* p) {
  float l = term(p);
  cho (;;) {
    bỏ qua(p);
    char op = *p->p;
    if (op != '+' && op != '-') break;
    p->p;
    float r = term (p);
    l = (op == '+') ? l + r : l - r;
  
  trở lại l;

Chúng tôi sử dụng NAN để chỉ ra các lỗi lan truyền dễ dàng thông qua các phép tính dấu phẩy động - hầu hết mọi thao tác trên NAN đều dẫn đến NAN. Tham chiếu ô được phân tích cú pháp bằng cách sử dụng một hàm đơn giản để chuyển đổi các chữ cái trong cột thành số và chữ số hàng thành số. Đối với lưới giới hạn của chúng tôi, chúng tôi có thể sử dụng sscanf(s, "%c%d", col, row) nhưng chúng tôi cũng có thể phân tích cú pháp chính xác để hỗ trợ nhiều cột và hàng hơn, chẳng hạn như “AB123”:

int ref(const char* s, int* col, int* row) { char* end;
  const char* p = s;
  if (!isalpha(*p)) trở lại 0;
  *col = toupper(* p++) - 'A';
  if (isalpha(*p)) *col = *col * 26 + (toupper(*p++) -  'A');
  int n = strtol(p, &end, 10);
  if (n <= 0 || end == p) return 0;
   hàng = n - 1;
  return (int)(end - s);

Phân tích số thì đơn giản, nhưng phân tích hàm thì phức tạp hơn một chút. Chúng ta cần hỗ trợ cả các hàm đối số đơn như @ABS(-A1) và các hàm phạm vi như @SUM(A1...C3). Bạn có thể kiểm tra nguồn cuối cùng để biết cách thực hiện. Tôi sẽ chỉ hỗ trợ @SUM, @ABS, @INT, @SQRT trong bài đăng này, nhưng việc thêm nhiều hàm hơn sẽ không quá khó.

Sau khi triển khai trình phân tích cú pháp, giờ đây chúng ta có thể đánh giá các công thức trong các ô:

struct grid g;
struct trình phân tích cú pháp p = { .g  = &g };
// A1 := 42
g.ô[0][0].val = 42; g.ô[0][0].loại = NUMBER ;
// A2 := 123
g.ô[0][1].val = 123; g.ô[0][1].loại = SỐ;
p. s = p.p = "+A1+A2*4";
float n = expr(&p); // n = 534

Có phải vậy không?

Việc có bộ đánh giá biểu thức mang lại chức năng cốt lõi cho bảng tính, nhưng điều đó là chưa đủ do tính chất phản ứng của các phép tính. Một ô có thể chứa công thức tham chiếu đến các ô khác và khi các ô đó thay đổi, công thức đó sẽ được đánh giá lại.

Một cách để thực hiện việc này là theo dõi tất cả các phần phụ thuộc giữa các ô và kích hoạt cập nhật khi cần thiết. Việc duy trì biểu đồ phụ thuộc sẽ cung cấp cho chúng tôi những cập nhật hiệu quả nhất nhưng nó thường là quá mức cần thiết đối với một bảng tính.

VisiCalc đã giúp nó hoạt động trên các máy RAM 16K bằng một thủ thuật đơn giản hơn. Mỗi lần cập nhật ô, nó sẽ đánh giá lại toàn bộ bảng tính. Người dùng có thể tự do lựa chọn thứ tự đánh giá theo hàng hoặc theo cột. Hướng dẫn sử dụng VisiCalc cho biết rằng trên các bảng tính lớn trên các máy tính vinh quang từ quá trình tính toán lại trước đây có thể mất vài giây. Đó là lý do tại sao VisiCalc cung cấp lệnh tính toán lại thủ công và đề xuất chạy lệnh này một vài lần cho đến khi tất cả các phần phụ thuộc được giải quyết.

Chúng tôi có đủ khả năng tự động hóa lệnh này, chạy đánh giá trong một vài lần lặp lại cho đến khi không phát hiện thấy thay đổi mới nào. Mặc dù đơn giản nhưng đây là một cách khá hiệu quả đối với hầu hết các bảng tính thực tế.

void recalc(struct grid* g) {
  for (int vượt qua = 0; vượt qua < 100; vượt qua ++) {
    int đã thay đổi = 0;
    for (int r = 0; r < NROW; r++)
      for (int c = 0 ; c < NCOL; c++) {
        struct ô* cl = &g->ô[c][r];
        if (cl-> !=  CÔNG THỨC) tiếp tục;
        struct trình phân tích cú pháp p = {cl->text, cl->text, g};
        float v = expr( &p);
        if (v != cl->val) đã thay đổi = 1;
        cl->val = v;
      
    if (!đã thay đổi) ngắt;
  

Chúng tôi chỉ sử dụng thứ tự đánh giá theo hàng, vốn là mặc định trong VisiCalc, nhưng việc thực hiện theo từng cột cũng dễ dàng như vậy.

Giờ đây, chúng tôi có thể thêm hàm setter để cập nhật giá trị ô và kích hoạt tính toán lại:

void setcell(struct lưới* g , int c, int r, const char* input) {
  struct ô* cl = ô(g, c, r);
  nếu (! cl) trở lại;
  if (!*đầu vào) {
    *cl = (cấu trúc ô){0};
    recalc(g);
    trở lại;
  
  strncpy (cl->văn bản, đầu vào, MAXIN - 1);
  if (đầu vào[0] == '+' || đầu vào[0] == '-' || đầu vào [0] == '(' || đầu vào[0] == '@') {
    cl->loại =  CÔNG THỨC;
   else if (isdigit(đầu vào[ 0]) || đầu vào[0] == '.') {
    char* end;
    double v = sttrtod(đầu vào, &end);
    cl-> loại = (*end == '\0') ? NUM : LABEL;
    if (cl->loại == NUM) cl->val = v;
    khác {
    cl->loại = LABEL;
  
  recalc(g);

Giờ đây, việc kiểm tra mô hình dữ liệu bảng tính của chúng tôi trở nên đơn giản và dễ đọc:

struct grid g = {0}; // đặt 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)");
xác nhận(g.ô[0][ 3].giá trị == 23.0f);
// thay đổi giá trị, tổng sẽ được tính lại
setcell(&g, 0, 0, "5");
setcell(&g, 0,  1, "+A1+5");
setcell(&g, 0, 2, "+A2+A1");
xác nhận(g.ô[0][3].val == 5,0f + 10,0f + 15,0f);
// thay đổi A1, tất cả các công thức sẽ được tính lại
setcell(&g, 0, 0, "7");
xác nhận(g.ô[0][ 3].val == 7.0f + 12.0f + 19.0f);

Sau khi tính toán bảng tính hoạt động, giờ đây chúng tôi có thể xây dựng một số giao diện người dùng cơ bản cho nó.

Lời nguyền

Xây dựng TUI có lẽ là phần ít thách thức nhất nhưng bổ ích nhất trong dự án này. Chúng ta có thể sử dụng thư viện ncurses cổ điển để tạo một giao diện đơn giản cho phép chúng ta điều hướng qua các ô, chỉnh sửa và hiển thị giá trị của chúng.

Điều đầu tiên cần quyết định là chúng ta đang vẽ chính xác những gì. Màn hình của VisiCalc có bốn vùng riêng biệt được xếp theo chiều dọc:

  • Thanh trạng thái: địa chỉ ô hiện tại và giá trị hoặc công thức của nó.
  • Chỉnh sửa dòng: nội dung bạn đang nhập ngay bây giờ.
  • Tiêu đề cột: A, B, C, …, AA, AB, AC, …
  • Lưới: chính các ô có rãnh ghi số hàng ở bên trái.

Không phải mọi ô đều vừa với màn hình. Lưới của chúng tôi là 26×50, nhưng thiết bị đầu cuối thông thường có thể là 80×24. Chúng ta cần một khung nhìn - một cửa sổ trượt trên lưới cuộn theo con trỏ. VisiCalc cũng làm như vậy, chúng tôi chỉ cần một vài điều chỉnh cho lưới:

#define CW 9 // chiều rộng hiển thị cột
#define GW 4 // số hàng chiều rộng rãnh

// hiển thị hàng và cột
int vcols( void) { return (COLS - GW) / CW; 
int vrows(void) { return LINES - 4; 
cấu trúc lưới { cấu trúc ô ô[NCOL][NROW];
  int cc, cr; // cột con trỏ, hàng con trỏ
 int vc, vr; // góc trên bên trái của khung nhìn
};

Khi con trỏ di chuyển ra khỏi màn hình, chế độ xem sẽ như sau:

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;

Kết xuất thực tế hơi dài dòng nhưng tuyến tính. Thanh trạng thái hiển thị địa chỉ ô hiện tại và giá trị hoặc công thức của nó. Nó cũng hiển thị chế độ hiện tại — giống như VisiCalc sẽ hiển thị “SẴN SÀNG” khi chờ đầu vào và “NHẬP” khi bạn đang nhập một công thức:

enum { SẴN SÀNG, ENTRY, GOTO };
tĩnh void vẽ(cấu trúc lưới* g,  int mode, const char* buf) {
  xóa();
  // Thanh trạng thái: địa chỉ ô + giá trị + chỉ báo chế độ
 attron(A_bol | A_REVERSE);
  mvprintw(0, 0 , " %c%d", 'A' + g->cc, g->cr + 1);
  if (cur->loại ==  CÔNG THỨC)
    printw(" %s = %.10g", cur->văn bản, cur->val);
  mvprintw(0, COLS - 6, chế độ == ENTRY ? "ENTRY" : "SẴN SÀNG");
  tiêu điểm( A_ BÓNG | A_REVERSE);
  // Chỉnh sửa dòng: hiển thị nội dung đang được nhập hoặc nội dung ô hiện tại
 if (chế độ)
    mvprintw(1, 0, "> %s_", buf);
  else if ( hiện tại->loại != EMPTY)
    mvprintw(1, 0, " %s", cur->văn bản);

Sau đó là tiêu đề cột và ô lưới. Đối với mỗi ô hiển thị, chúng tôi định dạng giá trị của nó: nhãn căn trái, số căn phải, lỗi hiển thị là “ERROR”. Ô hiện tại được đánh dấu bằng video đảo ngược:

 // Tiêu đề cột
 attron(A_bol | A_REVERSE);
  for (int c = 0 ; c < vcols(); c++)
    mvprintw(2, GW + c * CW, "%*c", CW, 'A' + g->vc +  c);
  attroff(A_bol | A_REVERSE);
  // Ô lưới
 for (int r = 0; r < vrows() && g->vr +  r < NROW; r++) {
    int row = g->vr + r, y = 3 + r;
    // Máng xối số hàng
 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 ô* cl = ô(g, col, row);
      // ... định dạng cl->val vào bộ đệm hiển thị ...
 int is_cur = (col == g->cc && row == g->cr);
      if (is_cur) attron( A_REVERSE);
      mvprintw(y, GW + c * CW, "%s", fb);
      if (is_cur) attroff(A_REVERSE);
    
  

Không có gì lạ mắt cả. Các số nguyên được hiển thị không có số thập phân, số float có hai chữ số thập phân, nhãn được căn trái. VisiCalc cũng có các lệnh định dạng — bạn có thể đặt các ô hiển thị dưới dạng tiền tệ ($) hoặc căn trái (L). Chúng tôi cũng hỗ trợ điều này: lệnh /F cho phép bạn chọn định dạng cho ô hiện tại.

Đầu vào

Vòng lặp chính là nơi mọi thứ kết hợp với nhau. VisiCalc có giao diện dạng thức: bạn đang điều hướng lưới hoặc nhập vào một ô.

Chỉ có ba ký tự đầu tiên đặc biệt trong chế độ SẴN SÀNG:

  • / để vào chế độ lệnh (kiểu VisiCalc: /B để trống, /Q để thoát, /F để định dạng).
  • > để vào chế độ goto (nhập a địa chỉ ô như B5 và nhấn Enter).
  • bất kỳ thứ gì khác — chuyển sang chế độ chỉnh sửa ô.

Khi ở chế độ chỉnh sửa, setcell sẽ quyết định nội dung bạn nhập: nếu nó bắt đầu bằng +, -, ( hoặc @ thì đó là một công thức. Nếu phân tích cú pháp dưới dạng số thì đó là một số. Mọi thứ khác đều là nhãn.

Để nhập văn bản nhãn đặc biệt như /// hoặc >xin chào bạn có thể gói nó trong dấu ngoặc kép: "///". Chúng tôi loại bỏ các trích dẫn ngoài cùng trước khi lưu trữ:

if (ch == '/') {
  // chế độ lệnh: /B trống, /Q thoát, định dạng /F
 else if (ch == '>') {
  // chế độ goto: gõ địa chỉ ô, nhấn Enter
  else if (ch >= 32 && ch < 127) {
  chế độ = NHẬP;
  buf[0] = ch; buf[1 ] = '\0'; len = 1;

Vậy đó. Bất kỳ ký tự có thể in nào đều bắt đầu nhập ô. Hàm setcell xử lý việc phân loại.

if (ch == '/') {
  mvprintw(1, 0, "Lệnh: /");
  làm mới();
  ch = getch();
  nếu (toupper(ch) == 'Q') break;
  if (toupper(ch) == 'B') {
    *ô(&g, g. cc, g.cr) = (struct cell){0};
    recalc(&g);
  

Khi người dùng nhấn Enter, chúng tôi xác nhận chỉnh sửa và di chuyển xuống dưới. Tab xác nhận và di chuyển sang phải. Điều này làm cho việc nhập dữ liệu giống như điền vào một bảng trong Excel:

if (ch == 10 && chế độ == ENTRY) {
  setcell(&g, g.cc, g.cr , buf);
  if (g.cr < NROW - 1) g.cr++;
  chế độ = SẴN SÀNG;

Toàn bộ vòng lặp chính là một for(;;), một getch(), biến chế độ và bộ đệm ký tự. Khoảng 150 dòng cho tất cả xử lý hiển thị và đầu vào kết hợp.

Bạn có thể kiểm tra VisiCalc mini tại đây - https://Gist.github.com/zserge/9781e3855002f4559ae20c16823aaa6b

Những gì còn lại

Khá nhiều. Chúng tôi không có I/O tệp, không có lệnh /R (Sao chép) để sao chép công thức trên các phạm vi, chúng tôi có thể thêm nhiều hàm và toán tử hơn, làm cho lưới lớn hơn, thêm lệnh để kiểm soát độ rộng cột hoặc khóa hàng/cột. Các lệnh phức tạp trên các phạm vi, chẳng hạn như di chuyển hoặc sao chép cũng bị thiếu và yêu cầu điều chỉnh công thức khi di chuyển các ô.

Nhưng bản chất vẫn ở đó: ô, công thức, tham chiếu, tính toán lại và TUI theo phương thức, dưới 500 dòng C.

Thật ngạc nhiên là bốn mươi bảy năm sau khi VisiCalc lần đầu tiên được tạo ra, mọi bảng tính vẫn hoạt động theo cùng một cách. Ô, công thức, tính toán lại, lưới. Hãy thử tự tạo một cái hoặc xem cách triển khai lại VisiCalc hoàn chỉnh hơn trên Github - https://github.com/zserge/kalk

Tôi hy vọng bạn thích bài viết này. Bạn có thể theo dõi – và đóng góp – trên Github, Mastodon, Twitter hoặc đăng ký qua rss.

Ngày 15 tháng 3 năm 2026

Xem thêm: Bộ ứng dụng văn phòng nhỏ nhất thế giớinhiều hơn nữa.

Tác giả: ingve

#discussion