编译器静态类型检查“复杂”表达式时,常用的程序是什么?


23

注意:在标题中使用“复杂”时,是指该表达式具有许多运算符和操作数。表达式本身并不复杂。


我最近一直在研究一个简单的x86-64汇编程序。我已经完成了编译器的主要前端-词法分析器和解析器-现在可以生成程序的抽象语法树表示形式。由于我的语言将是静态类型的,因此我现在进入下一阶段:类型检查源代码。但是,我遇到了一个问题,而自己却无法合理解决。

考虑以下示例:

我的编译器的解析器已阅读以下代码行:

int a = 1 + 2 - 3 * 4 - 5

并将其转换为以下AST:

       =
     /   \
  a(int)  \
           -
         /   \
        -     5
      /   \
     +     *
    / \   / \
   1   2 3   4

现在,它必须键入检查AST。首先检查=操作员的类型。首先检查操作员的左侧。可以看到该变量a被声明为整数。因此,它现在必须验证右侧表达式为整数。

我了解如果表达式只是一个值(例如1或),该怎么办'a'?但是,对于具有多个值和操作数的表达式(一个复杂的表达式)(例如上述表达式),该如何处理呢?为了正确确定表达式的值,似乎类型检查器实际上必须执行表达式本身并记录结果。但这显然似乎破坏了将编译和执行阶段分开的目的。

我想可以做到的唯一另一种方法是递归检查AST中每个子表达式的叶子,并验证所有叶子的类型都与期望的运算符类型匹配。因此,从=运算符开始,类型检查器将扫描左侧的所有AST并验证叶子都是整数。然后,它将对子表达式中的每个运算符重复此操作。

我曾尝试在《龙书》的副本中研究该主题,但似乎没有涉及太多细节,只是重申了我已经知道的内容。

当编译器对具有多个运算符和操作数的表达式进行类型检查时,通常使用的方法是什么?我上面提到的任何方法都使用吗?如果没有,那么方法是什么,它们将如何工作?


8
有一种显而易见的简单方法可以检查表达式的类型。您最好告诉我们是什么使您称之为“令人讨厌”。
gnasher729

12
常用的方法是“第二种方法”:编译器从其子表达式的类型推断出复杂表达式的类型。这是指称语义的要点,也是当今创建的大多数类型系统的要点。
Joker_vD

5
两种方法可能会产生不同的行为:自上而下的方法double a = 7/2 将尝试将右侧解释为double,因此将尝试将分子和分母解释为double并在需要时进行转换;结果a = 3.5。自下而上的将执行整数除法,并且仅在最后一步(赋值)时进行转换,因此a = 3.0
哈根·冯·埃岑

3
请注意,您的AST图片并不符合您的表情,int a = 1 + 2 - 3 * 4 - 5而是int a = 5 - ((4*3) - (1+2))
Basile Starynkevitch

22
您可以对类型而不是值“执行”表达式;例如int + int成为int

Answers:


14

递归就是答案,但是您在执行操作之前先进入每个子树:

int a = 1 + 2 - 3 * 4 - 5

以树的形式:

(assign (a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

通过先走左手,然后走右手,然后在推断出操作数的类型后立即对运算符进行处理,即可推断出类型:

(assign*(a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

->进入lhs

(assign (a*) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

->推断aa众所周知是int。现在回到assign节点:

(assign (int:a)*(sub (sub (add (1) (2)) (mul (3) (4))) (5))

->下降到rhs,然后下降到内部运算符的lhs,直到我们遇到有趣的东西

(assign (int:a) (sub*(sub (add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub*(add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add*(1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add (1*) (2)) (mul (3) (4))) (5))

->推断的类型1,即int,并返回到父级

(assign (int:a) (sub (sub (add (int:1)*(2)) (mul (3) (4))) (5))

->进入RHS

(assign (int:a) (sub (sub (add (int:1) (2*)) (mul (3) (4))) (5))

->推断的类型2,即int,并返回到父级

(assign (int:a) (sub (sub (add (int:1) (int:2)*) (mul (3) (4))) (5))

->推断的类型add(int, int),即int,并返回到父级

(assign (int:a) (sub (sub (int:add (int:1) (int:2))*(mul (3) (4))) (5))

->下降到RHS

(assign (int:a) (sub (sub (int:add (int:1) (int:2)) (mul*(3) (4))) (5))

等等,直到最终

(assign (int:a) (int:sub (int:sub (int:add (int:1) (int:2)) (int:mul (int:3) (int:4))) (int:5))*

赋值本身是否也是带有类型的表达式取决于您的语言。

重要的要点:确定树中任何运算符节点的类型,您只需查看其直接子代,这些子代需要已经为其分配了类型。


43

当编译器对具有许多运算符和操作数的表达式进行类型检查时,通常使用的方法是什么。

阅读有关类型系统类型推断以及使用统一的Hindley-Milner类型系统的Wikipage。另请阅读有关指称语义操作语义的信息

在以下情况下,类型检查可以更简单:

  • 所有您喜欢的变量a都用类型明确声明。这就像C或Pascal或C ++ 98,但不像C ++ 11具有与的某种类型推断auto
  • 所有文字值(例如12或)'c'都具有固有类型:int文字始终具有type int,而字符文字始终具有type char…。
  • 函数和运算符不会重载,例如,+运算符始终为type (int, int) -> int。C对运算符具有重载(+适用于有符号和无符号整数类型以及双精度),但没有函数重载。

在这些约束下,自底向上递归AST类型修饰算法就足够了(这仅在乎类型,而不在乎具体的值,因此是一种编译时方法):

  • 对于每个作用域,您都保留一个表,其中包含所有可见变量的类型(称为环境)。声明之后int a,您可以将条目添加a: int到表中。

  • 叶子的键入是简单的递归基本情况:像这样的文字类型1是已知的,而像这样的变量类型a可以在环境中查找。

  • 要根据先前计算的(嵌套子表达式)操作数的类型键入带有一些运算符和操作数的表达式,我们对操作数使用递归(因此我们首先键入这些子表达式)并遵循与该运算符相关的键入规则。

因此,在您的示例中, 4 * 3and 1 + 2被键入,int因为4312之前已被键入,int并且您的键入规则说2的总和或乘积int是an int,以此类推(4 * 3) - (1 + 2)

然后阅读皮尔斯的《类型和编程语言》一书。我建议学习一点Ocamlλ微积分

对于更动态的类型化语言(类似Lisp),请阅读Queinnec的Lisp In Small Pieces

另请阅读Scott的《编程语言实用》一书

顺便说一句,您不能拥有与语言无关的类型代码,因为类型系统是语言语义中不可或缺的一部分。


2
C ++ 11如何auto不那么简单?没有它,您必须找出右侧的类型,然后查看左侧是否与该类型匹配或转换。只要auto您弄清楚右侧的类型,就可以完成。
nwp

3
@nwp C ++ auto,C#var和Go :=变量定义的一般概念非常简单:在定义的右侧进行类型检查。结果类型是左侧变量的类型。但是魔鬼在细节上。例如,C ++定义可以是自引用的,因此您可以引用在rhs上声明的变量,例如int i = f(&i)。如果i推断出的类型,则上述算法将失败:您需要知道i推断出的类型i。相反,您需要使用类型变量进行完整的HM风格类型推断。
阿蒙(Amon)

13

在C语言中(坦率地说,是大多数基于C的静态类型语言),每个运算符都可以看作是函数调用的语法糖。

因此,您的表达式可以重写为:

int a{operator-(operator-(operator+(1,2),operator*(3,4)),5)};

然后将执行重载解析,并确定每个函数都是(int, int)(const int&, const int&)类型的。

这种方式使类型解析易于理解和遵循,并且(更重要的是)易于实现。有关类型的信息仅以一种方式流动(从内部表达式向外)。

这就是为什么double x = 1/2;会产生x == 0,因为1/2作为一个int表达式。


6
对于C几乎是正确的,在C中+不会像函数调用那样处理(因为它doubleint操作数与操作数的键入方式不同)
Basile Starynkevitch

2
@BasileStarynkevitch:它实现像一系列的重载函数:operator+(int,int)operator+(double,double)operator+(char*,size_t)等解析器只是要跟踪哪一个选择。
Mooing Duck

3
@aschepler没有人暗示在源极和规格级,C 实际上已重载函数或操作员功能

1
当然不是。只是指出,在C解析器的情况下,“函数调用”是您还需要处理的其他事情,实际上与此处所述的“运算符作为函数调用”没有太多共同之处。实际上,在C中找出类型比比找出类型f(a,b)容易得多a+b
aschepler'7

2
任何合理的C编译器都有多个阶段。在前端附近(在预处理器之后),您确实找到了解析器,该解析器建立了AST。在这里,很明显,运算符不是函数调用。但是在代码生成中,您不再关心创建AST节点的语言构造。节点本身的属性确定如何处理该节点。特别是+可能是函数调用-这通常发生在具有模拟浮点数学运算的平台上。使用仿真FP数学的决定发生在代码生成中。之前不需要AST差异。
MSalters

6

关注您的算法,尝试将其更改为自下而上。您知道类型pf的变量和常量。用结果类型标记带有运算符的节点。让叶子确定操作员的类型,这也与您的想法相反。


6

实际上,这很容易,只要您认为+它是多种功能而不是单个概念即可。

    int operator=(int)
     /   \
  a(int)  \
        int operator-(int,int)
         /                  \
    int operator-(int,int)    5
         /              \
int operator+(int,int) int operator*(int,int)
    / \                      / \
   1   2                    3   4

在右侧的解析阶段,解析器检索1,知道是an int,然后解析+,并将其存储为“未解析的函数名”,然后解析2,知道是an int,然后将其返回堆栈。+现在,函数节点知道两种参数类型,因此可以解析+into int operator+(int, int),因此现在知道该子表达式的类型,并且解析器以一种很快乐的方式继续进行。

如您所见,树完全构建后,每个节点(包括函数调用)都知道其类型。这很关键,因为它允许函数返回与其参数不同的类型。

char* ptr = itoa(3);

这里的树是:

    char* itoa(int)
     /           \
  ptr(char*)      3

4

类型检查的基础不是编译器做什么,而是语言定义的。

在C语言中,每个操作数都有一个类型。“ abc”的类型为“ const char数组”。1具有类型“ int”。1L的类型为“ long”。如果x和y是表达式,则存在有关x + y类型的规则,依此类推。因此,编译器显然必须遵循该语言的规则。

在像Swift这样的现代语言上,规则要复杂得多。有些情况很简单,例如C语言。在其他情况下,编译器将看到一个表达式,并事先被告知该表达式应具有的类型,然后根据该表达式确定子表达式的类型。如果x和y是不同类型的变量,并且分配了相同的表达式,则该表达式可能以不同的方式求值。例如,分配12 *(2/3)会将8.0分配给Double,将0分配给Int。在某些情况下,编译器知道两种类型是相关的,并指出了它们基于什么类型。

迅捷的例子:

var x: Double
var y: Int

x = 12 * (2 / 3)
y = 12 * (2 / 3)

print (x, y)

打印“ 8.0,0”。

在分配中x = 12 *(2/3):左侧为Double类型,因此右侧必须为Double类型。返回“ Double”的“ *”运算符只有一个重载,即Double * Double-> Double。因此12必须具有Double类型以及2 /3。12支持“ IntegerLiteralConvertible”协议。Double的初始化程序采用类型为“ IntegerLiteralConvertible”的参数,因此将12转换为Double。2/3必须具有Double类型。返回“ Double”的“ /”运算符只有一个重载,即Double / Double-> Double。2和3转换为Double。2/3的结果是0.6666666。12 *(2/3)的结果是8.0。8.0被分配给x。

在分配y = 12 *(2/3)中,左侧的y具有类型Int,因此右侧的类型必须具有Int类型,因此将12、2、3转换为Int,结果为2/3 = 0,12 *(2/3)= 0。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.