在程序中表示程序非常简单
假设您想创建一种能够改变世界的全新编程语言。或者您想要自动格式化以正确地缩进。或者您可能想为现有语言编写一个预处理器,以将一些缺失的功能融入其中。
不管是什么,编写将其他程序作为数据进行操作的程序有很多原因,第一步是弄清楚如何在程序中表示该数据。
我在本文中的目标是解释最标准的方法之一(抽象语法树)并展示它们实际上如何非常容易理解和实现。
本文有很多C语言的代码示例。
我们定义什么样的语言?
让我们从一个简单的例子开始:一种带有变量、数字和加法的语言。请注意,该语言没有语句,只有计算结果为简单值的表达式。这意味着我们将能够表示 0
、 1+1
、 x+y
甚至 a+(b+1)
等表达式。
我们将为每种类型的表达式使用不同的数据类型。这些数据类型需要存储足够的信息来编码表达式的行为,而不是完整的文本表示。
也就是说,我们将仅存储执行此代码所需的信息,但不足以重建相应的字符串。
这是一个设计决定。如果您正在编写代码格式化程序,您还需要存储此类信息!
让我们从整数 字面值 开始。我们需要什么样的信息?yes。我们只需要存储文字的实际值,因为这足以对此类表达式的行为进行编码。
那么,最直接的表示可能是这样的:
c
struct int_literal {
int value;
};
这对于我们的目的来说完全足够了(事实上,这种表示可以让你走得很远,甚至是一个完整的编译器)。
对于 变量,假设它的名字足以代表它。我们可以存储更多信息,例如它是否是全局变量、指向其声明的指针(如果我们的语言有这样的东西)等等,但我们在这里尽量保持简单。
c
struct variable {
char* name;
};
现在,最后一块拼图是加法。表示 其他两个表达式的总和。我们需要存储什么?嗯,正是另外两个表达方式。
这是我们遇到一些问题的时候。加法可以使用任何类型的表达式作为其操作数。它可以是一个文字加一个文字、一个变量加一个变量、两者的组合,或者更重要的是,它可以有其他添加作为操作数。
为了处理这个问题,我们需要一些可以表示任何类型表达式的数据类型。既然我们现在还没有,那就假装我们有,然后尝试创造它。
c
struct addition {
struct expression* left_term;
struct expression* right_term;
};
这往往是大多数人感到困惑的一点。我们希望加法有两个字段,这两个字段可以是各种不同的类型,具体取决于运行时提供的数据(也许我们可以说这些字段是多态的),并且字段的类型有时会是加法本身(也许我们可以说数据是递归的)。
为了实现这个所谓的 struct expression
,我们需要设计一个可以容纳 struct int_literal
、 struct variable
或 struct addition
.
这看起来似乎不可能,但让我们看看这实际上是如何很容易做到的,即使在 C 中也是如此。在 C 中做到这一点的一个巧妙方法是使用标记联合(tagged union)。
Unions in C
C 中的联合(union)只是一块足够大的内存块,可以存储一组数据类型中的任何一种。例如,如果我们有一个 10 字节大的类型 A
和一个 15 字节大的类型 B
,那么这两种类型的并集将是 15 字节。
这个想法是,这种类型的对象有时会保存 A
类型的值,有时会保存 B
类型的值。让我们看一个人为的例子:
c
// 12 bytes
struct a {
char data[12];
};
// 8 bytes in a 32-bit machine, 16 bytes in a 64-bit machine
struct b {
void* x;
void* y;
};
// sizeof(union c) is the greater of sizeof(struct a) and sizeof(struct b)
// 12 bytes in a 32-bit machine, 16 bytes in a 64-bit machine
union c {
struct a as_a;
struct b as_b;
};
Tagged Unions
尽管 union c
类型的对象有时会保存 struct a
,有时会保存 struct b
,但无法在运行时查询它保存的是哪一个。为此,我们添加一个辅助标签值,用于存储此信息。
c
union c {
struct a as_a;
struct b as_b;
};
enum c_tag { C_A, C_B };
struct tagged_c {
enum c_tag tag;
union c data;
};
构造这些值很简单,但有点冗长。
c
struct tagged_c make_a(void* x, void* y) {
struct tagged_c result;
result.tag = C_A;
result.data.as_a.x = x;
result.data.as_a.y = y;
return result;
}
struct tagged_c make_b(char* data) {
// ... analogous code ...
}
然后,我们可以通过查看标签来编写处理 As 或 Bs 的代码:
c
void process(struct tagged_c* data) {
switch(data->tag) {
case C_A:
process_a(data->data.as_a);
break;
case C_B:
break;
process_b(data->data.as_b);
}
}
回到表达式
这些知识可以直接应用于表达式。我们只需要创建一个可以容纳三种表达式类型中任何一种的tagged union。
c
enum expression_tag {
EXPRESSION_INT_LITERAL,
EXPRESSION_VARIABLE,
EXPRESSION_ADDITION,
};
struct expression {
enum expression_tag tag;
union {
struct int_literal as_int_literal;
struct variable as_variable;
struct addition as_addition;
};
};
这里我使用了一些语法糖来避免声明单独的联合类型。
这还允许我们直接访问
as_xxx
字段,而不是像以前那样使用中间data
字段。
为了方便构建这些值,我建议编写一些如下所示的辅组函数:
c
struct expression* addition(struct expression* left_term, struct expression* right_term) {
struct expression* result = malloc(sizeof(struct expression));
result->tag = EXPRESSION_ADDITION;
result->as_addition.left_term = left_term;
result->as_addition.right_term = right_term;
return result;
}
struct expression* int_literal(int value) { /* analogous code */ }
struct expression* variable(char* name) { /* analogous code */ }
然后,我们就可以自由创建表达式了:
c
// 0
struct expression* example1 = int_literal(0);
// 1+1
struct expression* example2 = addition(int_literal(1), int_literal(1));
// x+y
struct expression* example3 = addition(variable("x"), variable("y"));
// a+(b+1)
struct expression* example4 = addition(variable("a"), addition(variable("b"), int_literal(1)));
我们如何使用这些表达方式?
正如前面提到的,这些表达式是递归数据类型。这意味着在处理表达式时使用递归通常是最方便的。
例如,让我们编写一个简单的表达式打印方法。
c
void print(struct expression* e) {
switch (e->tag) {
case EXPRESSION_INT_LITERAL:
printf("%d", e->as_int_literal.value);
break;
case EXPRESSION_VARIABLE:
printf("%s", e->as_variable.name);
break;
case EXPRESSION_ADDITION:
printf("(");
print(e->as_addition.left_term);
printf("+");
print(e->as_addition.right_term);
printf(")");
break;
}
}
虽然这可能需要一段时间才能习惯,但必须注意的是,AST 上的大多数递归都是相当机械的。它通常以"进行一些预处理,对所有子表达式进行递归,进行一些后处理"的形式出现,并且代码最终非常统一,因此即使没有完全理解递归,也可以很快地掌握它。
这能扩展吗?
这个基本设计可以走得很远。创建新类型的表达式并将它们添加到tagged union中并不困难:
c
struct anonymous_function {
// array of parameters
int parameter_count;
char** parameters;
struct expression* body;
};
struct function_call {
struct expression* target;
// array of arguments
int argument_count;
struct expression** arguments;
};
enum expression_tag {
// .. old tags ..
EXPRESSION_FUNCTION_CALL,
EXPRESSION_ANONYMOUS_FUNCTION,
};
struct expression {
enum expression_tag tag;
union {
// .. old types ..
struct anonymous_function as_anonymous_function;
struct function_call as_function_call;
};
};
我们可以轻松扩展任何使用标记联合的函数,就像之前的打印方法一样:
c
void print(struct expression* e) {
switch (e->tag) {
// .. old cases ..
case EXPRESSION_ANONYMOUS_FUNCTION:
printf("fun (");
for (int i = 0; i < e->as_anonymous_function.parameter_count; ++i) {
if (i > 0) printf(", ");
print(e->as_anonymous_function.parameters[i]);
}
printf(") => ");
print(e->as_anonymous_function.body);
break;
case EXPRESSION_FUNCTION_CALL:
print(e->as_function_call.target);
printf("(");
for (int i = 0; i < e->as_function_call.argument_count; ++i) {
if (i > 0) printf(", ");
print(e->as_function_call.arguments[i]);
}
printf(")");
break;
}
}
结论
尽管程序具有本质上递归的不太常见的特征,但这可以以机械方式简单地处理。在我看来,这意味着在程序中表示程序并不比任何其他数据建模任务更困难。
我希望这个博客能够鼓励您编写操作程序的程序!
[EN] Representing Programs Within Programs is Surprisingly Easy