理论教育 编译器设计之路:指针运算的翻译

编译器设计之路:指针运算的翻译

时间:2023-11-04 理论教育 版权反馈
【摘要】:指针一度被誉为程序设计语言的精髓所在。这里,主要关注指针的一些常用运算的相关语义处理。标准Pascal关于指针及其运算的描述比较模糊,因此,各种版本的编译器对此的理解与处理是不尽相同的。不能深刻理解左值、右值的概念,对于分析指针的运算是非常不利的。在Pascal中,间接访问运算符n通过指针进行间接访问,它与地址运算符@是互逆的。间接访问运算规定操作数必须是指针,其运算结果就是引用这个指针所指向的对象。

编译器设计之路:指针运算的翻译

指针一度被誉为程序设计语言的精髓所在。不过,学术界对于指针的应用一直存在争议。本书不对指针机制本身作过多的评价,因为这是非常困难的。然而,从编译器设计的角度来说,指针却是一个非常不错的语法机制。由于指针的存在,很多原先看似平凡的问题变得丰富多彩,当然,这可能是以实现难度的增加作为代价的。这里,主要关注指针的一些常用运算的相关语义处理。在C语言中,与指针相关的运算主要有“*”和“&”,这是指针的两个基本运算,当然,读者可能还会想到“++”、“一一”等。不过,由于其他运算相对简单,与普通单、双目运算非常类似,因此,没有必要作深入讨论。

标准Pascal关于指针及其运算的描述比较模糊,因此,各种版本的编译器对此的理解与处理是不尽相同的。早期的Pascal编译器并不支持地址运算(“@”),在这种情况下,指针只允许指向运行时刻用户程序动态申请的空间,而不能指向普通的变量。虽然这种处理是以牺牲指针的灵活性为代价的,但是大大降低了实现的复杂度。随着C语言的风靡,C的指针机制逐渐成为了一个标准,许多商用Pascal编译器都竞相效仿,引入了“@”运算符。在正式讨论指针相关运算的实现之前,先简单解释一下左值、右值的概念。

1.左值与右值

在程序设计语言及编译器设计中,左值、右值是一对非常重要的概念。不过,国内的教材关于这一概念的阐述却非常罕见,甚至一些有多年经验的程序员对这个概念都比较模糊。不能深刻理解左值、右值的概念,对于分析指针的运算是非常不利的。实际上,在国外的一些专业书籍中,左值、右值是出现频率极高的两个名词。因此,笔者认为有必要深入了解这两个概念,以便以后阅读一些国外的经典教材。

实际上,通俗地讲,左值就是指可以出现在赋值号左边的值,也就是指那些可以被修改的值。而右值就是指可以出现在赋值号右边的值。从表面上来看,这两个概念的描述似乎是轻描淡写的。不过,其内含却并不简单。注意,左值、右值是讨论表达式的一种分类方法而已,并不意味着左值必定是出现在赋值号左边的。先前,笔者只是强调左值“可以”出现在赋值号左边,但并不是必须出现在赋值号左边的。

在早期,左值、右值的概念的提出就是用于区别一个值是否可以改变。通常,左值是可以改变的,而右值是不能改变的。不过,这种观点并不太准确。在现代C++语言中,左值、右值的概念已经失去了其原有的意义。C++的观点认为左值通常是指可以通过具体的名字或引用来指定的对象。除了左值之外,其余的都可以视为右值。下面,先来看几个例子,见表6-8。

表6-8 表达式左值

978-7-111-32164-4-Chapter06-89.jpg

这里简单说明两点:

(1) const限定词修饰的符号仍然可以视为左值。在这种情况下,试图修改符号的值可能是非法的,但并不影响其成为左值,因为它是可以通过名字引用的。

(2)返回引用的函数也可以视为左值。事实上,除了返回引用之外,其他类型的返回值都不是左值。其实这是很好理解的。如果一个函数的返回值是一个普通对象,那么这个普通对象必定是临时的,当然,也不可能通过一个显式的名字来访问。但如果一个函数返回引用,那么,它的返回值就没有意义了,因为它是另一个名字的别名,通过这个别名,就可以直接访问被别名的对象,因此,返回引用就是左值。

理论上讲,左值是可以转换为右值的,但右值却不一定能转换为左值。那么,讨论左值、右值对于编译器设计又有什么意义呢?在程序设计语言中,左值、右值的限定是非常频繁的,仅以指针处理为例,左值、右值的意义就比较显著。例如,C语言规定&运算符的操作数只能是左值或函数名字。那么,从编译器设计的角度,又如何理解左值问题呢?实际上,在所有变量符号中,只有一类变量符号是不能作左值的,即非间接寻址访问的临时变量。读者一旦体会到这一本质,确定左值与否将变得非常容易。相对于左值而言,右值的问题就非常容易了,并没有太多的理论。至此,笔者已经详细阐述了左值与右值的相关话题,这两个概念比较重要,建议读者仔细推敲。

2.间接访问运算

间接访问运算是一种基本的指针运算。在Pascal中,间接访问运算符n通过指针进行间接访问,它与地址运算符@是互逆的。如果x是一个变量,那么,表达式(@x)n与x是相同的。间接访问运算规定操作数必须是指针,其运算结果就是引用这个指针所指向的对象。间接访问运算的结果是一个左值。

当然,间接访问运算是否安全是由程序员确定的。编译器通常不会静态检测指针是否为空指针或野指针,因这类指针而导致的不安全寻址是由程序员负责的。实际上,Pascal的间接访问运算应用与C语言的*运算符是极其相似的,这里就不再深入讨论了。下面就来看看Neo Pascal的相关实现。

【文法6-6】

978-7-111-32164-4-Chapter06-90.jpg

程序6-16 semantic.cpp

978-7-111-32164-4-Chapter06-91.jpg

978-7-111-32164-4-Chapter06-92.jpg

第3~7行:语义有效性检查。当然,对常量符号取下标也是无意义的。

第8行:获取CurrentVar栈顶元素,该元素即为当前操作数。

第9~13行:间接访问运算符只能应用于指针类型变量。注意,间接访问运算并没有规定其操作数必须为左值,因此,不需要判断当前变量是否为左值。

第14~15行:获取当前变量的类型链的指针。

第16行:将当前变量的m_VarTypeStack栈的栈顶元素弹出,也就是将指针类型的描述信息弹出。(www.daowen.com)

第17行:将指针所指向的基类型压入当前变量的m_VarTypeStack。这里,值得注意的是类型的变化过程,一次间接访问会将当前变量类型由原来的指针类型转换为该指针的基类型。编译器则借助于m_VarTypeStack栈跟踪寻址过程中的类型的变化信息。

第18行:调用semantic053语义子程序,分析当前变量的相关语义,这是一个非常重要的语义动作。在Pascal或C语言中,间接访问运算的结果本身也是一个左值,因此,大多数运算都可以基于其结果进行,当然,也包括许多单目运算。例如,a^[i]、a^.p等形式都是合法的,这与C语言的*运算是类似的。以a“[i]为例,设计者必须注意a[i]与a八[i]的差异。显然,a[i]是相对于a变量首地址的偏移,而a“[i]是相对于指针变量a所指向的空间区域首地址的偏移。换句话说,对于一个完整的操作数而言,间接访问运算符前后两部分并没有直接的联系,只是后者是依赖于前者计算得到的符号内的值而已。因此,这里调用semantic053的目的就是用于分析间接访运算符前面部分信息,即存储于CurrentVar栈中的当前变量的相关信息。然后,这些信息(主符号、偏移等)传递也就到此为止了,并不需要继续向后传递。而间接访问运算符之后部分的相关分析仅仅依赖于对semantic053函数得到的结果操作数的间接寻址访问,以此作为后续分析的基址。

第19~20行:获取semantic053函数所生成的结果操作数,将其赋给临时对象TmpOp,并将Operand栈顶元素(即结果操作数)弹出。

第21~34行:如果TmpOp为间接寻址操作数,则需要特殊处理。实际上,在这种情况下,若试图获得间接访问运算符后部符号的基址,就必须对TmpOp进行二重间接访问寻址。不过,绝大多数目标机只支持一次间接寻址,因此,试图在一个操作数中表示二重间接访问寻址并不是一个令人满意的解决方案。这里,不得不借助于一个临时指针将二重间接访问寻址转化为两次间接寻址的形式,通常,可以生成赋值IR完成这一功能。在生成赋值IR时,应该特别注意各操作数m_bRef属性的设置,即源操作数的m_bRef为真,而目标操作数的m_bRef为假。

第35~40行:设置TmpVar对象的相关属性,并将其压入CurrentVar栈。值得注意的是,实际上,此时的TmpVar-m_bRef属性必定为false,无论原始的状况是true还是false。如果原始的TmpVar.m_bRef为真,则必须由第21~34行生成一行赋值IR,以实现一次间接寻址的过程。然而,第36行再次将TmpVar.m_bRef设置为true的目的是为了表示该操作数是间接访问。

3.地址运算

地址运算是一种基本的指针运算。实际上,标准Pascal并没有对其作非常明确的解释。这主要是因为早期的Pascal语言只允许指针指向用户动态申请的存储空间,而不能是用户变量或参数等。不过,鉴于C及一些商用Pascal编译器的设计,笔者最终采用了“@”符号作为地址运算符,这主要源于Turbo Pascal及Delphi的设计。

通常,地址运算的安全性是由编译器保证的。当然,它的前提就是地址运算的操作数必须是左值。也就是说,只有左值表达式才可以进行取地址。实际上,这并不难理解。例如,&fa+b)是非法表达式的原因就是(a+b)不是左值。而地址运算的结果操作数就是一个地址或者指针。实际上,Pascal的地址运算应用与C语言的&运算符是极其相似的,这里就不再深入讨论了。下面就来看看Neo Pascal的相关实现。

【文法6-7】

978-7-111-32164-4-Chapter06-93.jpg

程序6-17 semantic.cpp

978-7-111-32164-4-Chapter06-94.jpg

978-7-111-32164-4-Chapter06-95.jpg

第4~8行:Operand栈的有效性检查。

第9~10行:将Operand栈顶元素赋给Tmp,并将其弹出。

第11~16行:左值判断。在C、Pascal中,地址运算的操作数必须是左值表达式。关于左值的判断条件,笔者先前已作了说明。

第18~37行:处理对过程取地址的运算。这里必须生成一个指向该过程的指针变量,并生成相关的IR,用于对过程取地址。

第38~58行:实际上,除了过程取地址之外,只可能存在变量取地址的情况,因此并不需要进行额外的判断。对于变量取地址的情况,主要的处理有两部分:

(1)变量的原类型的变换。即在原类型链的链首增加一个指针结点,用于表示地址运算结果操作数的类型是一个指向原类型的指针。

(2)根据实际情况,生成地址运算的IR。

其中,第一部分主要是由第38~58行完成的。同时,这里还需要考虑m_iDetailType栈为空的情况。当然,类型的变化过程也需要在m_iDetailType栈中予以体现。

第59--76行:处理非间接寻址的情况。对于非间接寻址的操作数则需要生成地址运算的IR。注意,变参的情况是需要特殊处理的。虽然变参并不是指针,但是变参空间内存储的却是实参的地址。因此,在处理变参地址运算时,只需要生成赋值IR(ASSIGN)即可。然而,除了变参之外,其他的情况都是需要生成取地址IR(GETADDR)的。当然,值得注意的是,对于间接寻址的情况,由于操作数存储的就是目标对象的地址,因此,只需将操作数的m_bRef设置false即可(即表示非间接寻址)。

第77行:将Rslt操作数压入Operand栈。

至此,关于指针运算的相关语义处理已基本讨论完了。处理指针运算的关键就是理解左值、右值的概念,因为并不是任何操作数都是满足运算需求的。

免责声明:以上内容源自网络,版权归原作者所有,如有侵犯您的原创版权请告知,我们将尽快删除相关内容。

我要反馈