计算机世界有一个常识——所有的数据和指令必须经由内存才能进入CPU的寄存器进而被CPU使用,那么我们程序操作的主战场就是内存,内存操作也就顺理成章成为了程序中最高频的操作。
为了节目的效果,我们先来看一段8086平台下的汇编代码:
1assume cs:code
2code segment
3 dw 0123h, 0456h, 0789h, 0abch, 0defh, 0fedh, 0cbah, 0987h
4 start: mov bx, 0
5 mov ax, 0
6
7 mov cx, 8
8 s: add ax, cs:[bx]
9 add bx, 2
10 loop s
11
12 mov ax, 4c00h
13 int 21h
14code ends
15end start
在说明上面汇编代码的功能之前,我们先来介绍一下loop指令,loop指令的格式是loop label,CPU在执行loop指令的时候要进行两步操作——1.将cx寄存器中的值减一,2.判断cx寄存器中的值,不为0则转至标号处执行程序,如果为0则不跳转,继续向下执行。
上面那段代码代码的功能就是计算0123h, 0456h, 0789h, 0abch, 0defh, 0fedh, 0cbah, 0987h这8个16进制数的和,为了正确加载数据,上面的代码中使用了寄存器间接寻址的寻址方式,即将寄存器bx中的值作为变址使用,而且我们在上面存储数据的时候使用了dw关键字,其指出其后的每一个数据的长度为两个字节,汇编语言的编译器会负责帮我们组织数据。
上面的代码中使用纯粹的偏移量来进行内存访问,非常不方便,我们也可以使用标号的方式来进行内存的访问,如下汇编代码:
1assume cs:code, ds:data
2data segment
3 ary db 10, 20, 30, 40, 50
4 ty db 20
5data ends
6code segment
7start: mov cx, (ty-ary)
8 ...
9code ends
10end start
上面的代码中,我们在数据段中使用了标号 ary和ty,而在代码段中使用表达式ty-ary来计算ary所代表的数组(暂且认为它就是一个数组的头指针吧)的长度。而在实际汇编的时候,这个表达式会被汇编程序计算,最终其实就等价于5-0,也就是5.
通过上面的汇编代码,我们可以知道,汇编语言中可以有表达式,但是汇编语言中的表达式完全是静态的,是由汇编程序计算的,而程序所有的运行时行为都是由指令体现出来的。实际上汇编语言中所有的标号都是一种伪指令,它们代表的都是某个地址值而已,更准确的说是一个地址的偏移量,使用标号的目的是使得我们更加方便的引用一个地址值,而且这个值是在静态阶段已知的,它只是具有辅助作用,并不直接参与内存访问。
上面的程序中我们使用了伪指令dw来告诉汇编程序我们的数据占用多大的内存空间,而我们从内存中读取数据的时候,要读取多大的内存空间呢?这一部分信息隐藏在了指令的目标寄存器中,比如上面的add ax cs:[bx]
这行代码的目标寄存器是ax,是16位的寄存器,我们要从地址cs:[bx]开始读取16位也就是两个字节,如果我们把目标寄存器换成ah,那么就成了读取8位也就是一个字节了。
可以看出,我们在使用汇编语言进行内存访问的时候,数据应该以怎样的步长读取,读取到的数据可以进行什么样的操作,语言本身能提供给我们的元数据信息非常有限,而且几乎没有对我们进行任何的限制。我们所能依靠的只有代码注释和自己的大脑,这样的编程方式就像走钢丝一样,稍不留神就会坠入万丈深渊。
左值和数据存储区域
编程界流传着这样一句话——机器生汇编,汇编生B,B生C,C生万物。在高级程序设计语言领域中,很多的高级语言都深受C语言的影响,而且很多语言的自举都是从C语言开始的,而我这个系列的文章正是谈自己对于高级程序设计语言的理解,所以很多的故事都是从C语言开始讲起的。
关于自举,我们后面的文章会有介绍。
在C语言中,用于存储值的数据存储区域统称为数据对象,但是考虑到数据对象这个术语可能会和面向对象编程中的对象混淆,所以我们这里采用数据存储区域这个术语。
那么,在汇编语言中的内存访问操作在C语言中相对应的就是对数据存储区域的访问。
在C语言中,对某个数据存储区域的引用被统称为左值(lvalue),意思是它可以出现在赋值操作的左侧(可以是赋值操作的目标操作数),这是很好理解的,因为赋值操作的目的是把值存储在某个数据存储区域上,这就意味着,只有一个数据存储区域的引用也就是左值可以作为赋值操作的目标操作数,(左值(lvalue)中的l就是来自于此)
其实左值的概念是适用于所有的高级程序设计语言的,而且,在高级语言中访问某个数据存储区域的唯一方式就是使用左值,除此之外,别无他法。
也就是说,左值和数据存储区域存在如下的关系:
我们在代码中使用左值来引用(代表)某个数据存储区域,我们可以简单地认为一个左值指向某个数据存储区域,它代表的是其引用的数据存储区域的首地址。
左值的两种操作——LHS查询和RHS查询
我们知道,访存操作一共就两种,要么读、要么写;对应的,我们对左值的操作其实一共就两种——分别是LHS(Left Hand Side)查询和RHS(Right Hand Side)查询。
"L"和"R"的含义,它们分别代表左侧和右侧。什么东西的左侧和右侧呢?是一个赋值操作的左侧和右侧。
LHS和RHS的含义是“赋值操作的左侧或右侧”并不一定意味着就是“=
赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,比如对函数的形式参数的赋值。因此在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。
当变量出现在赋值操作的左侧的时候,编译器或者解释器对其执行LHS查询,这个时候我们并不关心这个变量所引用的数据存储区域中的值是什么,而是想要找到这个数据存储区域,并将赋值操作的源值放到这个数据存储区域中(对其进行赋值操作),所以说,LHS查询对应内存访问中的Store(写)操作。如下图所示:
在进行LHS查询的时候,lvalue就代表其所引用的数据存储区域这个容器,赋值操作的源会直接被写入这个数据存储区域中,语言的编译器或者解释器负责实现底层的写内存操作。
RHS查询对应的是内存访问中的load(mov/读)操作,这个时候我们不关心这个变量所引用的数据存储区域,而只是想得到这个数据存储区域中所存储的值。从这个角度来说,RHS并不是真正意义上的“赋值操作的右侧”,更准确地说应该是非左侧。如下js代码:
1let obj = {
2 say: () => {
3 console.log('Hello World')
4 }
5}
6
7obj.say()上面的
obj.say()
这一行代码中,obj并不是赋值操作的目标,也就是说它出现的位置是赋值操作的非左侧,那么这个时候js解释器就会对obj
进行RHS查询。如下图:在进行LHS查询的时候,lvalue就代表其所引用的数据存储区域中所存储的值,这时候我们相当于直接使用值,语言的编译器或者解释器负责实现底层的读内存操作。
总而言之,RHS查询关心的是变量所引用的数据存储区域中的值,它对应的是内存的读操作,而LHS查询关心的是变量所引用的数据存储区域,其对应的是内存的写操作。
左值是类型化的存储区域
上文,我们介绍了在高级语言中我们通过左值来定位一个数据存储区域,对左值进行RHS查询和LHS查询分别对应对数据存储区域的读操作和写操作。
但是如何进行读和写?应该读或者写多大的区域?我们把数据读出来之后又应该怎么使用呢?和汇编不同的是,高级语言对这方面进行了抽象,提供了数据类型的概念,数据类型提供了一个值需要占用多大的内存空间、一个值可以进行哪些操作这些元数据信息。语言的编译器或者解释器也正是根据数据类型配合左值来帮我们处理底层的访存操作。
也就是说,数据类型不仅代表了内存空间的大小,同时我们在编码的时候可以施加于其上的操作也受数据类型的限制(根据语言的不同,这种限制有可能是在编译时,有可能是在运行时或者这两个阶段都存在),而且不同的类型具有不同的语义,这样一个内存空间中数据的作用就能在一定程度上通过源代码感知到,实现了一定程序上的自文档化。
但是在数据类型这个地方,产生了两种分化,一种是静态数据类型,一种是动态数据类型。对于静态数据类型而言,左值引用的数据存储区域在语言的编译阶段就能够确认,而对于动态类型来说,则需要在运行时进行查找。
静态类型的语言在编译时左值所引用的数据存储区域中所存储的值的数据类型就是已知的,而对于动态类型的语言来说,左值所引用的数据存储区域中的值必须在运行时才能确定。但是,对于一个值来说,它一定是有一个明确且唯一的类型的。
语言的类型系统也是构成一门语言的世界观的语言核心特性,在下一篇文章中我会详细介绍语言的类型系统,这里也就不做过多介绍了。
变量实体——左值的诞生
在大多数的文献中并不区分左值和变量这两个概念,认为这两者是等价的。但是,在这篇文章中我们需要一个概念来指明某个变量是我们在编写代码的时候所声明的‘变量’这种程序实体,所以这里我们引入一个概念——变量实体,用来指代我们在源代码中所声明的‘变量’这种程序实体。如下js代码:
1let obj = {
2 a: 1,
3 b: 2,
4 c: 3
5}
上面代码中的obj
是我们声明的一个变量实体,而且是上面代码中唯一的一个变量。obj.a
、obj.b
、obj.c
是变量,但是它并不是我们声明的,其身份是对象obj
的一个属性。
在高级语言中,获得一个左值的最终方式是声明一个变量实体。
左值树
为什么上文如此强调变量实体
这个概念呢?那是因为我们对左值的使用都是从我们声明的某个变量实体开始的,而且在引入自定义数据结构之后,自定义结构中的属性也是一个左值,而最终所有的左值会构成一个逻辑上的树形结构。
如下的C语言代码:
1#include
2
3typedef struct {
4 char province[32];
5 char city[32];
6 char area[32];
7 char address[128];
8} Location;
9
10typedef struct {
11 char name[32];
12 char header[32];
13 Location location;
14} School;
15
16typedef struct {
17 int age;
18 char name[32];
19 School school;
20} Student;
21
22
23int main() {
24 Student laomst = {
25 .age=24,
26 .name="laomst",
27 .school={
28 .name="qknd",
29 .location={
30 "shandong",
31 "qingdao",
32 "chengyang",
33 "changchenglu 700 hao"
34 },
35 .header="songxiyun"
36 }
37 };
38 printf("%s", laomst.school.location.address);
39 printf("\n");
40 Student *laomst_ptr = &laomst;
41 printf("%s", laomst_ptr->school.location.address);
42}
上面的代码中我们声明了一个名为laomst,类型为Student的变量实体,而其实这个变量实体拉起了如下的一个左值的树形结构:
这棵树中的每一个节点都是一个左值,而我们声明的laomst这个变量实体正是这个左值树的根节点。
现在,我们来总结一下:
左值就是变量,因为左值引用了一个用来存储数据的数据存储区域,而其中存储的值是可以改变的。
复合数据结构类型的左值代表了一个左值树,而这个树的根最终就是我们在代码中声明的某个变量实体。
我强调变量实体这个概念的原因就是其代表的是某个左值树的根节点,我们在程序中获得左值的唯一方式是声明一个变量实体,同时,我们对于某个左值的访问也是从一个变量实体(左值树的根节点)开始的,比如上面代码中的printf("%s", laomst.school.location.address);
这行代码,我们想要访问上图树中最右下角的address节点,也是从根laomst开始一层层走下去的。
数据结构是逻辑上的概念
上文中我们给出了laomst这个左值的内存结构的逻辑图,其实在真正存储的时候使用的是一段连续的内存空间,其物理图如下所示:
但是这并不影响其逻辑上是一棵树。当然,使用连续内存存储的根本原因是C语言是静态数据类型的,如果是动态类型的语言,可能就真的是一个树形结构了。
其实数据结构更多的是一种逻辑上的数据组织,而同一个数据结构在物理上可能有多种存储方式,比如顺序表可以使用数组存储也可以使用链表存储、完全二叉树也经常使用数组进行存储、图可以使用邻接矩阵(二维数组)也可使用邻接表(链表方式)进行存储…
不同的物理存储方式可能决定不同的逻辑特性,毕竟逻辑是基于物理的;但是根据空间局部性原理来说,使用连续的内存来存储数据在时间上可能会更加高效一些。
我后面也会有数据结构和算法相关的文章,其中我们详细介绍我对数据结构和算法的理解。
简单名和限定名
我们再次回顾一下在【左值树】小节中的代码中的最后四行,如下:
1 printf("%s", laomst.school.location.address);
2 printf("\n");
3 Student *laomst_ptr = &laomst;
4 printf("%s", laomst_ptr->school.location.address);
我们访问address这个左值的时候使用了一系列的标识符,而不是直接使用address这个标识符。上面代码中的laomst.school.location.address
其实也是一个标识符,是一个名称,其引用一个代码实体,而我们称这样的名称为限定名。而laomst_ptr->school.location.address
中的形式有所不同,laomst_ptr
是一个指针类型的变量,使用它访问其内部的属性需要使用->
操作符,但是不管形式如何,只要涉及左值树中子节点的访问,我们使用的都是限定名称。
与限定名对应的是简单名,laomst.school.location.address
中的laomst就是一个简单名,什么是简单名和限定名呢?
简单名
简单名就是指我们在声明一个代码实体的时候分配给代码实体的标识符,如下Java代码:
1public class Test {
2 private String foo;
3 public String foo() {
4 return "";
5 }
6}上面的代码中存在三个简单名,分别是类名
Test
、类的成员变量foo
和类的成员方法foo
。限定名
限定名就是指,我们在引用一个代码实体的时候,不能使用它的简单名,而是必须用其所属的那个代码实体的简单名对其进行限制,最典型的场景就是我们访问自定义类型的内部属性的时候,需要用自定义类型的变量名限定其内部属性的简单名,如下Java代码所示:
1public class Person {
2 private String name;
3 public Person(String name) {
4 this.name = name;
5 }
6 public String getName() {
7 return name;
8 }
9}
10
11public class Test {
12 public static void main(String[] args) {
13 Person p = new Person("Tom");
14 String pname = p.getName();
15 }
16}上述代码中的
String pname = p.getName()
在访问Person类型的变量p的getName方法的时候,不是直接使用getName这个简单名,而是使用p对getName这个简单名进行了限定,在面向对象的术语中,称我们向p这个Person类型的对象发送了一个消息。
对于变量来说,不难发现,简单名对应的其实就是一个左值树的根,也就是我们声明的一个变量实体。
需要注意的是,上面的类名
Person
也是一个简单名,但是这里我们只讨论变量,不讨论其他类型的程序实体,其他类型的程序实体需要在具体语言中具体分析。
引出简单名和限定名这两个概念是为了方便接下来对变量作用域的介绍。
变量的作用域
我们讨论作用域的目的是编译器或者解释器是如何搜索也代码实体的,它是在什么地方搜索我们所声明的代码实体来构建程序的上下文的呢?
答案就是——编译器或解释器是在作用域中搜索我们的代码实体的。
在计算机编程中,作用域是使得名称绑定(一个标识符和代码实体(例如变量)的绑定关系)有效的一块区域:在这个区域中,名称可以用来引用代码实体。在程序的其他区域中,相同的名称可能引用不同的代码实体(在不同的作用域中名称可能具有不同的绑定),也有可能压根不引用任何实体(在这个作用域中没有关于这个名称的绑定关系)。
绑定的范围也称为代码实体的可见性,我们一定要注意的是,标识符的背后一定对应了一个代码实体,我们要讨论的是代码实体的可见性,而不是这个名称的可见性。
对应于代码上,作用域是程序的一部分,它本身就是或者它可以作为一组绑定的范围。想要给出作用域的一个精确定义是比较困难的,但是我们在编码时作用域在很大程度上对应于块、函数或文件,具体取决于语言和代码实体的类型。
作用域也用来指所有可见的实体的集合,或在程序的一部分或程序中给定的点有效的名称,更正确的说法为上下文或环境。
----- 维基百科
对于一个程序实体,如果我们能够通过简单名对其进行引用,那么我们就称当前的程序上下文处在该程序实体的作用域内,而对于限定名所引用的程序实体的绑定,可能会涉及其他的规则,这里我们不做深入的讨论,因为对于所有程序实体的引用都是从一个简单名开始的。
同样的,为了让内容尽量普适,我们这里只介绍变量实体的作用域,而且是基于运行时调用栈来进行分析的,对于其他类型的实体还是到具体语言中具体分析。
作用域共有两种主要的工作模型:静态作用域和动态作用域,这两种作用域的不同之处在于其对“程序的一部分”的理解方式是不太一样的。
静态作用域(词法作用域)
静态作用域是被大多数编程语言所采用的作用域规则,这时候“程序的一部分”指的是“源代码的一部分(是一个文本区域)”。这时候“程序的一部分”是一个文本属性,由语言实现独立于运行时调用栈,使得这种名称匹配可以只通过分析静态文本就可以实现。因此,静态作用域又被称为“词法作用域”。
也就是说,在具有词法作用域的语言中,名称解析取决于源代码中的位置和词法上下文中的位置,静态范围允许程序员将参数、变量、常量、类型、函数等对象引用作为简单的名称替换进行推理。这使得制作模块化代码变得更加容易,因为可以隔离地理解本地命名结构。
词法作用域中对名称的解析可以在编译时确定,也被称为早期绑定。
另外,即使都是使用词法作用域的工作方式,不同语言的作用域规则也是有差别的,比如C、JavaScript等语言允许局部变量有“死区”(在子作用域中允许声明和父作用域中同名的变量,并且子作用域中的变量声明会覆盖父作用域中的变量声明);而在Java语言中,大多数情况下是不允许在子作用域中声明和父作用域中同名的变量的。而且在有的语言中允许在函数中定义函数(支持闭包特性),而有的则不允许。
大多数词法作用域语言的词法作用域规则都受到了ALGOL语言的影响,遵循层级嵌套的模型,我们以一段JavaScript代码为例,进行简要的讨论(该例摘自《你不知道的JavaScript 上卷》):
在这个例子中有三个逐级嵌套的作用域。为了帮助理解,可以将它们想象成几个逐级包含的气泡。
气泡1包含着整个全局作用域,其中只有一个标识符:foo
。
气泡2包含着foo
所创建的作用域,其中有三个标识符:a
、bar
和b
。
气泡3包含着bar
所创建的作用域,其中只有一个标识符:c
。
作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的。
动态作用域
目前来说,只有少部分的语言使用动态作用域规则,比如Bash脚本、Lisp语言和一些模板语言。这时候“程序的一部分”是指“部分运行时间(执行期间的时间段)”。
对于动态作用域,全局标识符是指与最新环境关联的标识符。从技术上讲,这意味着每个标识符都有一个全局绑定堆栈。引入具有名称的本地变量会将绑定推送到全局堆栈(可能为空),当控制流离开作用域时,该变量将弹出该堆栈。这在编译时无法完成,因为绑定堆栈仅在运行时存在,这就是为什么这种类型的范围范围称为动态范围。
也就是说,在具有动态作用域的语言中,名称解析取决于遇到由执行上下文或调用上下文确定的名称时的程序状态,动态范围迫使程序员预测调用模块代码的所有可能的动态上下文,分析起来会比较困难。
动态作用域的名称解析通常只能在运行时确定,因此称为后期绑定(动态绑定)。
使用C语言中的预编译指令模拟动态作用域
在现代语言中,预处理器的宏扩展是事实上动态作用域的一个关键示例。例如C语言中的宏编译指令
#define
,它提供给编译之前运行的预编译器使用,只用来转换源代码,而不解析名称,当时当其定义的宏被展开到源代码中之后,其中的名称被解析,就像动态范围一样。如下C语言代码:
1#define ADD_A(x) x + a
2
3void add_one(int *x) {
4const int a = 1;
5*x = ADD_A(*x); // 当宏被展开的时候,这行代码变成了 *x = *x + a;
6}上面的宏中访问了一个表示符
a
,而在*x = ADD_A(*x)
这行代码被展开之后就变为了*x = *x + a
,可以看出其访问到的正是局部变量a,这时候对a的访问就和调用ADD_A(x)这个宏的地方有关系了,这和动态作用域的行为是非常相似的。同时,我们也可以看出,对于拥有动态作用域的语言来说,对一个子程序的定义需要依赖使用它的上下文,这对提高程序的模块化是非常不利的。
常量
上文中我们聊完了变量(左值),这篇文章中就顺带着介绍一下常量吧。
什么是常量呢?常量应该具有如下的特征:
常量具有值
常量是只读的,即我们只能读取其值,而不能修改这个值
常量的值在编译期就是可以确定的
如果要完全符合上面的特征,我们发现只有字面值才能被称为常量。
但是,有时候我们又需要一种这样的变量:
它的值在运行时才能被计算出来
在它的整个生命周期中,只能被赋值一次,也就是说其被初始化之后就不能再作为左值使用了。
这种机制需要语言提供支持,例如C语言中的const变量、Java中的final变量等等。对于这样的变量,其本质上还是一个左值,但是其符合我们上面的约束——初始化后不能再进行赋值操作,所以我们称之为不可变左值。
总的来说,语言中的常量包括两种类型,一种是字面值,这是真正意义上的常量,另一种是不可变左值,不可变左值需要语言提供支持。
不可变左值
不可变左值还涉及很多的细节,比如一些不可变左值的值在编译阶段可能就是可以计算的,这个时候语言的编译器可能会对其进行
宏编译
,比如Java中的常量变量,对于这些细节问题,我们到了具体语言中再具体情况具体分析吧。
总结
在这篇文章中我们简单介绍了高级语言如何使用数据类型和变量来帮我们进行访存操作,而数据类型有动、静之分,同时也有其他的维度来描述一门语言的类型系统的特征,我们在这篇文章中并没有对这部份内容进行讨论,下一篇文章《类型系统》就是对这部份内容的补充。
这篇文章中的主角应该是左值
,有左必有右,而我们这篇文章中并没有对右值进行讨论,在下下篇文章《函数、表达式和语句》中将会补充对右值的讨论。
同时,这篇文章比较长,感谢你耐心读完。本人深知技术水平和表达能力有限,如果文中有什么地方不合理或者你有其他不同的思考和看法,欢迎随时和我进行讨论(laomst@163.com)。