高级语言中的单词——5种类型的token

930 阅读21分钟

《高级语言中的单词——5种类型的token》原文链接,阅读体验更佳

特别感谢李文塔工程师,这篇文章中借鉴了《Go语言核心编程》中第一章的许多内容,其实在写这篇文章的时候我卡了很长一段时间,也重新写了很多遍,但是总觉得思路不对,这本书让我茅塞顿开。

上篇文章《编程语言的自举之路——从机器码到高级语言》中提到过,现在高级程序设计语言已经有几百种了,每一门编程语言所面向的问题领域都是不同的,那么每一门语言也就都具有自己的特性,它们的语法形式也是千差万别。但是,不同高级语言最终都是需要转换成计算机可以运行的二进制机器码,那么它们之间必然存在着很多的共性。

我们将高级语言的语法进行抽象概括,剔除表现形式的差异,就形成了一个个表达语义的语言特性,有些特性是某个或者某些语言特有的,但是绝大多数的语言特性是很多高级语言所共有的。

对于任何计算机语言来说,必定是“用规定的文法,去表达特定语义,最终操作运行时的”一个过程。

----- 程劭非(winter),前手机淘宝前端负责人

winter老师的这句话已经高度概括了高级语言代码生命周期的基本脉络。

我们在使用一门语言的时候,直接面对的就是其语法,回想一下我们在学习英文的时候是怎么学习的,首先我们会先学习26个英文字母,认识了字母之后我们会学习由字母组成的单词,之后就是学习基本的语法知识,比如主谓宾、主系表,然后我们就可以根据语法,用我们所掌握的单词造短语和句子了;掌握了短语和句子之后,我们会学习基本的文法知识,什么代词的运用啊、标点符号的使用呀,联系上下文呀,最后我们就可以把多个短语和句子组成一个片段,每个片段描述一部分内容,多个片段组合起来就能形成一篇完整的文章了。

因为大多数高级程序设计语言都是以英文为载体的,所以我们学习一门高级语言的过程和学习英文的过程是类似的。

我们在学习一门高级语言的时候,首先也是需要认识26个英文字母,这个过程就不必多说了;接下来,我们需要认识高级语言中的单词,英文中的单词有不同的词性,比如名词、动词等等,高级程序设计语言中的单词根据作用的不同也有不同的分类,而在编译原理领域中我们称高级语言中的单词为token,**token是构成源程序的基本不可再分割的单元。高级程序设计语言在分析源程序时的第一步就是把源程序分割为一个个独立的token,这个过程就称为词法分析,这个过程类似于我们在阅读英文文章的时候把文章分隔成一个个单词。**我们这里先不用过多地纠结编译的过程,后面我们会有文章对编译进行简单的介绍。

类比英文中的单词词性的分类,所有高级语言中的token总共就包含以下几类:

  • 分隔符

  • 关键字和保留字

  • 标识符

  • 操作符

  • 字面值

大家可以多找几段高级语言的代码来分析以下,看看是不是所有的token都可以归入到上面所介绍的五种类型里面,而每一类的token都有它们的作用,下面我们就来分别介绍一下。

分隔符

首先是分隔符,编译器是怎么分割token的呢?分隔符在其中起到了非常关键的作用。回想一下我们阅读英文文章的时候,我们是根据什么来分隔单词的呢?大部分时候我们都是根据空格来作为单词与单词之间的界限的;而我们又是如何分隔句子的呢?可以用句号、问号等标点符号来作为句子与句子之间的界限,而高级语言中最常见的分隔符有空格、换行符、分号、大括号等等,我们可以利用空格来分隔token、用换行符或者分号来分隔语句、用大括号或者是缩进(数量相等的空格或者是制表符)来分隔代码块(复合语句)。

操作符也是一种特殊的分隔符,因为操作符是一些特殊的字符,它们和26个英文字母是有明显区别的,在我们平时阅读一些特殊的英文文章特别是科学文献的时候,应该会有比较明显的体会,比如我们在阅读1+1=2的时候,虽然数字和符号之间没有任何的空格,但是我们仍然可以读懂其中的含义。编译器在进行词法分析的时候也能够做类似的处理。

除了操作符之外的分隔符,在编译器识别完token之后通常会被直接丢弃,因为它们不具备实际的意义。

标识符

编程语言的标识符是用来标识变量自定义类型函数等实体的符号名称,我们在后面想要使用这个实体的话只需要用这个实体的名字引用它就可以了。不同类型的标识符具有不同的作用,当标识符代表的是一个变量和函数的时候,它代表的其实是一个内存地址,我们使用变量进行内存访问、使用函数进行子程序的调用;而当标识符作为一个自定义数据类型的时候,它将在编译的时候为编译器提供组织内存的元数据信息。

不同的语言中,所拥有的程序实体的类型是不一样的,比如JavaScript作为一门基于原型的面向对象语言,就不支持任何形式的自定义类型(虽然ES6提供了基于类的面向对象的语法糖——class关键字,但其本质上也是基于原型的);而Java是一门完全面向对象的语言,它就没有函数(虽然类中可以有方法,但是函数不是Java的一等公民1)。

但是标识符作为我们代码中最灵活的部分(因为标识符本质上是一个名称,而这个名称是由开发者自己定义的),它构成了我们程序上下文的主体,而在编译的最后一个阶段——语义分析中,主要的工作就是对标识符进行符号替换。

关于标识符,我们需要了解的内容还是挺多的,其中最关键的就是标识符的作用域,这里我们就不展开讨论了,在后面介绍变量的文章中会以变量为例来讨论一下标识符的作用域。

很多语言中,不同类型标识符可以重复而不会造成名称冲突

在很多的编程语言中,对于不同类型的代码实体的声明上下文和使用上下文是不同的,这个时候,编译器或者解释器依据实体类型+标识符可以唯一确定一个实体,在这种情况下,不同类型的实体的标识符是可以重复的。

比如在Java中,一个类中的成员属性和成员方法可以重名,但是不会造成名称冲突,如下Java代码所示:

public class Test {
	private String foo; //foo这个成员属性和foo这个成员函数重名了,
            //但是它们的声明上下文和使用上下文不同,所以不会对编译器造成二义性。
	private String foo() {
		return "";
	}
}

标识符的命名规则

任何语言标识符的命名都要遵循一定的规则,而几乎所有的语言对标识符的命名限制都是类似的,以下是摘自《疯狂java讲义 第四版》P48中的Java标识符的命名规则:

  1. 标识符可以由字母、数字、下划线、$组成,其中数字不能打头
  2. 标识符不能是Java的关键字和保留字
  3. 标识符不能包含空格
  4. 标识符只能包含美元符号($),而不能包含诸如@、#等其他特殊符号(@、#等专门在Java编译器自动生成的代码中使用,以进行区分)

以上的规则中的不能是语言的关键字和保留字、不能有一些特殊的字符以及不能包含空白字符都比较容易理解,但是为什么标识符不能以数字打头呢?这一点,在下文介绍完字面值之后再给出解释。

关键字和保留字

关键字是指语言的设计者保留的具有特定语法含义的字符序列,每个关键字都有自己独特的用途和语法含义,我们只能按照高级程序设计语言的语法规定来使用它们。关键字是一门高级程序设计语言的语法的重要组成部分,同时也是我们为语言的编译器或解释器提供元数据信息的主要手段。

关键字都能为语言的编译器提供哪些方面的元数据信息呢?大体有以下几个方面:

  • 源代码组织

    我们在使用高级语言编写代码的时候,通常不是把所有的代码都写在同一个文件里面的,为了更好的组织代码结构,我们通常会把代码拆分为多个文件,对代码进行模块划分,这样既有利于代码的阅读,也有利于后续对代码的维护。

    在我们把源代码划分为多个模块之后,势必会涉及到不同模块之间代码的相互引用,这个时候就需要关键字来为编译器提供不同模块之间相互引用的元数据信息了,比如Java语言中的packageimport关键字。

  • 声明程序实体

    我们上文提到标识符是程序实体的名称,它具有不同的类型,代表的可能是一个变量、一个函数、一个自定义类型,有的语言中甚至有更多类型的程序实体,比如模块等等,那么我们该如何告知编译器我们的标识符是一种什么类型的实体呢?同时这些程序实体又有什么样的特殊的性质呢?答案就是通过语言提供给我们的关键字,如下Java代码:

    public class Person {
        private String name;
        private final String sex;
        private int age;
        
        public Person(String sex) {
            this.sex = sex;
        }
        
        public String getName() {
            return name;
        }
        
        public void setName(String name) {
            this.name = name;
        }
        // getter and setter
    }
    

    上面的Java代码中我们使用class关键字来声明一个自定义类型Person,表明Person这个标识符代表的是一个类;private final String sex;这行代码中表明sex是一个String类型的变量,其中的final表明sex是一个只能被赋值一次的变量,也就是我们常说的常量。

  • 控制程序执行流程

    1996年,计算机科学家Bohrm和Jacopini证明了:任何简单或复杂的算法,都可以由顺序结构、选择结构和循环结构这三种基本控制结构组合而成。所以这三种结构就被称为程序设计的三种基本控制结构

    而在高级语言中,这三种基本控制结构也是由关键字来提供的,比如Java语言,使用ifswitch关键字提供分支结构,使用forwhiledo while来提供循环结构,其他的高级语言中也都有类似的机制。

  • 提供其他元数据信息

    除了上面提到的最基本的三种作用之外,很多高级语言的关键字还提供了更加丰富的元数据信息。

    比如C语言中的预处理指令#define,我们也可以认为这是一种关键字,但是其不是提供给编译器的,而是提供给预处理器的;

    再比如在Go语言中,go具有并发语义、defer具有延迟执行的语义,我们可以认为这样的关键字具有流程控制的作用,但是其实它还提供了更多的语义,和传统的流程控制是不太一样的。

我们上文对高级语言的关键字进行了一个简单的介绍,实际上要给关键字进行分类是一件非常困难的事情,因为单个关键字所具有的语义可能就是多样的,这和程序的上下文也有一定的关系。我上文中对关键字的解释肯定是不完整甚至是不恰当的,如果你有更好的想法欢迎随时和我交流(laomst@163.com)。

虽然我们不能完美的介绍高级语言的关键字,但是我们能够比较自然的推断出高级语言的关键字是其语义的重要载体,一部分关键字是单纯提供给编译器或者解释器的元数据信息,而还有一部分则会被编译器或者解释器映射成指令。

保留字

保留字是一类比较特殊的关键字,是为了语言后续的升级所预留的,在当前的语言版本中,保留字还不具备任何特殊的语义,但是在后续的语言升级过程中,保留字可能会被提升为关键字。所以我们在为标识符命名的时候,也不能随意使用保留字。

注意语言内置的标识符

一门语言往往会为用户提供一个标准库,库里面定义了许多的实体,有类型、函数等等,而且有的语言还会有一些内置的实体,这些实体也是具有名字的,个人认为在对待语言的标准库中和语言内置的标识符时,应该把它们看做一种特殊的关键字,我们在自己定义标识符的时候,尽量避免使用这些语言标准库中提供的或者是内置的标识符,以免对代码的可读性造成干扰。

而且在一些早期的语言中,内置的数据类型就是关键字,比如C/C++和Java,而且他们还兼具引导声明的作用,如下Java代码:

int a = 1;
public int add(int a, int b) {
 return a + b;
}

上面的Java代码中,我们使用int来声明变量,同时用int来声明方法的返回值,在Java中,int不仅是一个内置的数据类型,而且具有引导声明的作用。

字面值

**字面值是编程语言中一种非常重要的机制,因为它是我们向代码中传递数据的最终方式。**这一点,在我们在后面介绍变量和值的文章中会有更详细的介绍。

在高级语言中,存在三种最基本的字面值形式(不同语言所具有的字面值形式是不同的,但是大多数的语言都支持下面三种形式的字面值):

  • 字符串:因为代码本身就是字符串,通常会使用界定符比如""来区分是字面值还是程序代码
  • 数字:其实就是具有特定格式的字符串(只有数字字符,数字分隔符,进制前缀和类型后缀),因为其格式特殊,所以不需要界定符
  • 逻辑值:通常也称为布尔值,true或者false,大部分程序设计语言中都把true和false作为关键字,但是在比较古老的编程语言中(例如C语言),没有专门代表逻辑值的数据类型,而是使用整数代表逻辑值,0代表false,所有的非0整数都代表true。

字面值这个概念是非常重要但是比较容易被忽略,我们可以随便找一段高级程序设计语言的源代码分析一下,我们会发现程序中所有的数据结构中所存储的数据的最终来源都是某个字面值,只不过有可能嵌套的比较深。也就是说,字面值其实是程序中数据输入的最终来源。

标识符为什么不能以数字打头

介绍完了字面值,我们现在可以讨论一下为什么标识符不能以数字打头了。

上面在介绍字面值的时候提到,数字字面值没有界定符,也就是说数字字面值是直接嵌入到代码中的,编译器对数字字面值的识别仅仅是“这是一个代表数字的字符串”。假如说,标识符的命名规则没有数字不能打头这一说,那么像a=1+b这句代码中,1是一个标识符还是一个数字字面值?

同时就算是限定标识符不能是纯数字字符串也是行不通的,就拿java来说,123L这是一个长整型数字字面值还是一个标识符呢?

说到这里我又忍不住提到Java中的整型数字字面值,Java中的整型数字字面值支持四种进制:

  • 二进制 以 0b 或者 0B 打头
  • 八进制 以 0 打头
  • 十进制 默认进制,但是需要注意不要以0打头,这一点需要特别注意
  • 十六进制 以 0x 或者 0X 打头

发现了没有,各种进制还是以数字打头,也就是说在Java中只要遇到以数字打头的token,Java的编译器就可以认为这是一个数字字面值了,同时使用数字字面值的规则对其进行语法检查,标识符不能以数字打头这个规则也在一定程度上提高了编译的效率和代码的可读性。

操作符

操作符就是语言所使用的符号集合,一般来说操作符代表的是计算类的指令,我们会使用操作符来构建表达式,来完成计算任务。大多数的操作符会被编译成转换成计算类的指令。

按照功能分类,操作符可以做如下分类:

  • 算术运算符

  • 位运算符

  • 赋值运算符

  • 赋值复核运算符(+=这一类的)

  • 比较运算符

  • 逻辑运算符

  • 括号(可以用来改变表达式的计算顺序,也可以用来进行函数调用)

  • 自增自减操作

  • 其他运算符,比如元素数组元素访问[],子元素访问.->等等

按照操作数的数量,操作符可以做如下分类:

  • 单目运算符
  • 双目运算符
  • 三目运算符

操作符的主要作用是用来构成表达式的,运算符有两个非常重要的特性,即优先级和结合性,这两个特性决定了表达式的计算。

  • 优先级

    我们在数学中进行四则运算的时候都知道一个计算式中要先算乘除后算加减,这个规则就是数学中四则运算的优先级规则,高级语言中的运算符的运算规则跟这个是类似的,具有更高优先级的运算符会优先占用操作数组成一个表达式进行计算产生一个值。

    在所有语言中运算符的优先级规则都是非常相似的,一般来说操作数越少的操作数优先级越高,而且赋值运算符的优先级比较靠后。

  • 结合性

    优先级并不能完全解决表达式的计算顺序问题,在表达式中出现连续的多个优先级相同的运算符的时候,该以什么样的顺序进行计算呢?

    高级语言中表达式的结合性就是用来解决连续多个相同优先级的运算符的计算顺序的。

    结合性有两种,即左结合性和右结合性。具有左结合性的计算顺序是从左到右,右结合性的计算顺序是从右到左。

总之,我们在看一个表达式的计算顺序的时候,要先看运算符的优先级、优先级相同的情况下再看其结合性。因为复杂的表达式在计算的时候,优先级高的运算符先消耗掉他的操作数之后产生一个值然后带入原来的表达式的位置,以此进行复杂表达式的简化,简化到一定程度之后,可能剩下的所有的操作符的优先级都是一样的了,这个时候就只能依赖结合性来进行后续的计算了。

比如如下的两个Java表达式:

int a = 1 + 2 - 3 + 4int b;
int c;
int d;
int e = b = c = d = a;

1 + 2 - 3 + 4这个表达式中只有加减操作,加减的优先级是一样的,其结合性是左结合性,那么这个表达式就会从左到右依次计算;首先是最左侧的+占用两个操作数12产生一个新值3,这样原先的表达式简化为3-3+4,然后依次进行计算。

e = b = c = d = a这个表达式中只有赋值操作,赋值操作的结合性是右结合性,所有他会先计算a的值,然后计算 d = a的值,依次从右到左计算。

其实在实际的编程中,不推荐过分依赖语言的运算符优先级规则,我们也没有必要去专门记忆运算符的优先级,大多数情况下我们应该使用()去主动控制表达式的计算顺序。

其实我们不难发现,目数不同的操作数的优先级肯定是不一样的,目数不同的操作数如果具有相同的优先级,那么就有可能出现永远无法结束的表达式。

总结

上面我们介绍了高级程序设计语言中的基本语法单元——token,在高级语言中,token是不可再分的,它就相当于英文中的单词,我们通过使用高级语言规定的token集,并基于高级语言制定的使用token集的规则,就可以编写对应高级语言的代码,然后由高级语言的编译器转换成可以直接运行的机器码或者是由高级语言的解释器来解释运行。

下一篇文章中我们就来介绍一下高级语言中比token更高一级的语言结构——表达式和语句。上文中我们介绍token的时候提到一门高级语言中的所有的token都可以归类到上述五类token中,但是,到了更高级的抽象层次上之后,不同语言之间的差异就会越来越大。

感谢你耐心读完。本人深知技术水平和表达能力有限,如果文中有什么地方不合理或者你有其他不同的思考和看法,欢迎随时和我进行讨论(laomst@163.com)。

Footnotes

  1. 在编程语言中,一等公民是指可以直接赋值给变量、可以作为函数的参数并且可以作为函数的返回值的程序元素。