Skip to content
This repository has been archived by the owner on Aug 31, 2022. It is now read-only.

Latest commit

 

History

History
170 lines (118 loc) · 11.9 KB

S-Expressions.md

File metadata and controls

170 lines (118 loc) · 11.9 KB

第零九章 • S-表达式

Lisp 列表

Lisp 程序代码与数据的形式完全相同,这使得它非常强大,能完成许多其他语言不能完成的事情。为了拥有这个强大的特性,我们需要将求值过程分为读取并存储输入、对输入进行求值两个过程。

本章结束后,程序的运行结果会和上一章的有轻微的不同。这是因为我们会花时间去更改程序内部的工作方式。在软件开发中,这被叫做重构。重构可能对于当前的程序运行结果并没有太大的影响,但因为工作方式的优化,会使我们在后面的开发中更加省心。

为了存储输入,我们需要创建一个内部列表结构,能够递归地表示数字、操作符号以及其他的列表。在 Lisp 中,这个结构被称为 S-表达式(Symbolic Expression)。我们将扩展 lval 结构来表示它。S-表达式求值也是典型的 Lisp 式过程:首先取列表第一个元素为操作符,然后遍历所有剩下的元素,将它们作为操作数。

有了 S-表达式,我们才算真正迈进了 Lisp 的大门。

指针

在 C 语言中,要表示列表,就必须正确的使用指针。C 语言中的指针一直如洪水猛兽般存在。虽然概念上非常简单,但是用起来却变幻多端,神秘莫测,这使得指针看上去比实际要可怕得多。幸运的是,在本书中我们只会用一些指针在 C 语言中最常规的用法。

我们之所以需要指针,主要是由 C 语言中函数的工作方式决定的。C 语言函数的参数全部是通过值传递的。也就是说,传递给函数的实际是实参的拷贝。对于 intlongchar等系统类型以及用户自定义的结构体都是成立的。这种方式适用于绝大多数情况,但也会偶尔出现问题。

一种常见的情况是,如果我们有一个巨大结构体需要作为参数传递,则每次调用函数,就会对实参进行一次拷贝,这无疑是对性能和内存的浪费。

另外一个问题是,结构体的大小终究是有限的,无论多大,也只能是个固定的大小。而如果我们想向函数传递一组数据,而且数据的总数还是不固定的,结构体就明显的无能为力了。

为了解决这个问题,C 语言的开发者们想出了一个聪明的办法。他们把内存想象成一个巨大的字节数组,每个字节都可以拥有一个全局的索引值。这有点像门牌号:第一个字节索引为 0,第二个字节索引为 1,等等。

在这种情况下,计算机中的所有数据,包括当前运行的程序中的结构体、变量都有相应的索引值与其对应(数据的开始字节的索引作为整个数据的索引)。所以,除了将数据本身拷贝到函数参数,我们还可以只拷贝数据的索引值。在函数内部则可以根据索引值找到需要的数据本身(译者注:我们将这个索引值称为地址,存储地址的变量称为指针)。使用指针,函数可以修改指定位置的内存而无需拷贝。除此之外,指针还可以做其他很多事情。

因为计算机内存的大小是固定的,表示一个地址所需要的字节数也是固定的。但是地址指向的内存的字节数是可以变化的。这就意味着,我们可以创建一个大小可变的数据结构,并将其指针传入函数,对其进行读取及修改。

所以,所谓的指针也仅仅是一个数字而已。是内存中的一块数据的开始字节的索引值。指针的类型用来提示程序员和编译器指针指向的是一块什么样的数据,占多少个字节等。

指针类型是在现有类型的后面加一个星号组成,我们之前已经见过一些指针的示例了,如:mpc_parser_t*mpc_ast_t* 以及 char*

要创建指针,我们就需要获取数据的地址。C 语言提供了取地址符(&)来获取某个数据的地址。在前面的章节中,我们也曾传给过 mpc_parse 函数一个指针,以便其能将输出放到我们声明的 mpc_result_t 变量中。

最后,为了获取指针所指向的地址的数据值(称为解引用),我们需要在指针左边使用 * 操作符。要获取结构体指针的某个字段,需要使用 -> 操作符,而不是 .,这你在第七章已经见过了。

栈(Stack)和堆(Heap)

前面说过,我们可以把内存简单粗暴地想象成一个巨大的字节数组。事实上,它被更加合理地划分成了两部分,即

有些人可能已经听说过一些关于堆和栈的神秘传说,例如“栈从上往下增长,而堆则是从下往上”,或是“栈的数量很多,但堆只有一个”云云。其实这些事情都是无关紧要的。在 C 语言中,处理好栈和堆确实是件麻烦的事情,但这并不代表它们很神秘。实际上,它们只是内存中的两块不同的区域,分别用来完成不同的任务而已。

栈(The Stack)

栈是程序赖以生存的地方,所有的临时变量和数据结构都保存于其中,供你读取及编辑。每次调用一个新的函数,就会有一块新的栈区压入,并在其中存放函数内的临时变量、传入的实参的拷贝以及其它的一些信息。当函数运行完毕,这块栈区就会被弹出并回收,供其他函数使用。

我喜欢把栈想象成一个建筑工地。每次需要干点新事情的时候,我们就圈出一块地方来,放工具、原料,并在这里工作。如果需要的话,我们也可以到工地的其他地方,甚至是离开工地。但是我们所有的工作都是在自己的地方完成的。一旦工作完成,我们就把工作成果转移到新的地方,并把现在工作的地方清理干净。

堆(The Heap)

堆占据另一部分内存,主要用来存放长生命周期期的数据。堆中的数据必须手动申请和释放。申请内存使用 malloc 函数。这个函数接受一个数字作为要申请的字节数,返回申请好的内存块的指针。

当使用完毕申请的内存,我们还需要将其释放,只要将 malloc 函数返回的指针传给 free 函数即可。

堆比栈的使用难度要大一些,因为它要求程序员手动调用 free 函数释放内存,而且还要正确调用。如果不释放,程序就有可能不断申请新的内存,而不释放旧的,导致内存越用越多。这也被称为内存泄漏。避免这种情况发生的一个简单有效的办法就是,针对每一个 malloc 函数调用,都有且只有一个 free 函数与之对应。这某种程度上就能保证程序能正确处理堆内存的使用。

我把堆想象成一个自助存储仓库,我们使用 malloc 函数申请存储空间。我们可以在自主存储仓库和建筑工地之间自由存取。它非常适合用来存放大件的偶尔才用一次的物件。唯一的问题就是在用完之后要记得使用 free 函数将空间归还。

解析表达式

因为现在我们考虑的是 S-表达式,而不是之前的波兰表达式了,我们需要更新一下语法分析器。S-表达式的语法非常简单。只是小括号之间包含一组表达式而已。而这些表达式可以是数字、操作符或是其他的 S-表达式。只需修改一下之前写的就可以了。另外,我们还需把 operator 规则重命名为 symbol。为之后添加更多的操作符以及变量、函数等做准备。

mpc_parser_t* Number = mpc_new("number");
mpc_parser_t* Symbol = mpc_new("symbol");
mpc_parser_t* Sexpr  = mpc_new("sexpr");
mpc_parser_t* Expr   = mpc_new("expr");
mpc_parser_t* Lispy  = mpc_new("lispy");

mpca_lang(MPCA_LANG_DEFAULT,
  "                                          \
    number : /-?[0-9]+/ ;                    \
    symbol : '+' | '-' | '*' | '/' ;         \
    sexpr  : '(' <expr>* ')' ;               \
    expr   : <number> | <symbol> | <sexpr> ; \
    lispy  : /^/ <expr>* /$/ ;               \
  ",
  Number, Symbol, Sexpr, Expr, Lispy);

同时,还要记得在退出之前做好清理工作:

mpc_cleanup(5, Number, Symbol, Sexpr, Expr, Lispy);

表达式结构

首先,我们需要让 lval 能够存储 S-表达式。这意味着我们还要能存储符号(Symbols)和数字。我们向枚举中添加两个新的类型。LVAL_SYM 表示操作符类型,例如 + 等,LVAL_SEXPR 表示 S-表达式。

enum { LVAL_ERR, LVAL_NUM, LVAL_SYM, LVAL_SEXPR };

S-表达式是一个可变长度的列表。在本章的开头已经提到,我们不能创建可变长度的结构体,所以只能使用指针来表示它。我们为 lval 结构体创建一个 cell 字段,指向一个存放 lval* 列表的区域。所以 cell 的类型就应该是 lval**。指向 lval* 的指针。我们还需要知道 cell 列表中的元素个数,所以我创建了 count 字段。

我们使用字符串来表示符号(Symbols),另外我们还增加了另一个字符串用来存储错误信息。也就是说现在 lval 可以存储更加具体的错误信息了,而不只是一个错误代码,这使得我们的错误报告系统更加灵活好用。我们也可以删除掉之前写的错误枚举了。升级过后的 lval 结构体如下所示:

typedef struct lval {
  int type;
  long num;
  /* Error and Symbol types have some string data */
  char* err;
  char* sym;
  /* Count and Pointer to a list of "lval*" */
  int count;
  struct lval** cell;
} lval;

有指向指向指针的指针的指针吗?

这里有个古老的笑话,说是可以根据 C 程序员的程序中指针后面的星星数(*)作为其水平的评分。

初级水平的人写的程序可能只会用到像 char*、奇怪的 int* 等指针,所以他们被称为一星程序员。而大多数中级的程序员则会用到诸如 lval** 这类的二级指针,所以他们被称为二星程序员。但三级指针就真的很少见了,你可能会在一些伟大的作品中见到,这些代码的妙处凡夫俗子自然也是体会不到的。果真如此,三星程序员真是极大的赞誉了。 但据我所知,还没有人用到过四级指针。

构造函数和析构函数

我们可以重写 lval 的构造函数,使其返回 lval 的指针,而不是其本身。这样做会使得对 lval 变量进行跟踪更加简单。为此,我们需要用到 malloc 库函数以及 sizeof 操作符为 lval 结构体在堆上申请足够大的内存区域,然后使用 -> 操作符填充结构体中的相关字段。

当我们构造一个 lval 时,它的某些指针字段可能会包含其他的在堆上申请的内存,所以我们应该小心行事。当某个 `lval` 完成使命之后,我们不仅需要删除它本身所指向的堆内存,还要删除它的字段所指向的堆内存。

/* Construct a pointer to a new Number lval */ 
lval* lval_num(long x) {
  lval* v = malloc(sizeof(lval));
  v->type = LVAL_NUM;
  v->num = x;
  return v;
}
/* Construct a pointer to a new Error lval */ 
lval* lval_err(char* m) {
  lval* v = malloc(sizeof(lval));
  v->type = LVAL_ERR;
  v->err = malloc(strlen(m) + 1);
  strcpy(v->err, m);
  return v;
}
/* Construct a pointer to a new Symbol lval */ 
lval* lval_sym(char* s) {
  lval* v = malloc(sizeof(lval));
  v->type = LVAL_SYM;
  v->sym = malloc(strlen(s) + 1);
  strcpy(v->sym, s);
  return v;
}
/* A pointer to a new empty Sexpr lval */
lval* lval_sexpr(void) {
  lval* v = malloc(sizeof(lval));
  v->type = LVAL_SEXPR;
  v->count = 0;
  v->cell = NULL;
  return v;
}

NULL 是什么?

NULL 是一个指向内存地址 0 的特殊常量。按照惯例,它通常被用来表示空值或无数据。在上面的代码中,我们使用 NULL 来表示虽然我们有一个数据指针,但它目前还没有指向任何内容。在本书的后续章节中你讲经常性地遇到这个特殊的常量,所以,请眼熟它。