理解变量、数据类型、引用和内存

先看几个结论:

  1. 变量是用来存储数据的,存储方式有两种:直接存储数据,存储引用从而间接存储数据
  2. 变量保存数据时,有时候还需要指定数据的类型,解释器或编译器将根据数据类型来划分合适的内存空间
  3. 变量有时候不仅仅只保存数据,还保存一些额外但必要的元数据信息

下面将分别解释这些结论。

结论一

变量是用来存储数据的,存储方式有两种:直接存储数据,存储引用从而间接存储数据

例如,x=3表示将数据3保存到变量x中。它实际的过程是分配一个内存空间,该内存空间存放数值3,然后变量x就可以直接或间接地使用这段内存空间中的数据3。对编程人员来说,变量x现在保存了数值3,也用来代表内存中的这个3。但这里需要区分直接和间接这段内存空间的含义。

直接使用这段内存空间,意味着变量x就是保存了数据3的那段内存空间。这应该容易理解,往一个盒子里丢数据3,这个盒子的名称叫做x。更准确一点来说,变量x本身就是这段内存空间的地址。对于编译型语言来说,通常会在编译期间将名为x的变量替换成这段内存空间的地址,编译之后的程序中就已经没有了名为x的东西,毕竟x这样的字母只是面向编程人员的一个友好名称。

**间接使用这段内存空间,意味着变量x保存的是指向数据3那段内存空间的一个引用(有时候也称为指针)**。需要注意的是,指针自身就是地址值(这里不考虑胖指针),如0x3c2a5f,这个地址指向保存了实际数据3的内存空间。因为地址本身也是数据(一个有特殊意义的十六进制数据),它占用一个机器字长的大小(32位机器的字长4字节,64位机器的字长8字节),所以指针也需要4字节或8字节的内存空间来存放。因此,变量x保存的是一个指向数据3那段内存空间的地址,而不是直接保存数据3。对于编译型语言来说,编译期间会将名为x的变量替换为保存了指针的那段内存空间地址。

通常来说,变量直接保存数据时,这段内存被分配在栈(Stack)上,而变量保存引用时,保存数据的那段内存分配在堆(Heap)上,保存指针的那段内存分配在栈上

如图:

不同编程语言采用不同的方式,有的编程语言的变量完全通过引用来间接使用内存数据(比如Perl),有的编程语言则直接和间接两种方式混用:对于简单数据类型的数据,采用直接的方式,对于复杂数据类型的数据,采用间接的方式。稍后解释结论三时会对这一点再做深入描述。

结论二

变量保存数据时,有时候还需要指定数据的类型,解释器或编译器将根据数据类型来划分合适的内存空间

例如,变量x要存放数值123,变量y要存放字符串"123",它们所需的内存空间大小是不一样的,变量x可能只需要一个字节就能存放数值123,而变量y则至少需要三个字节才能存放字符串"123"

因此,一些语言要求在编写代码时明确指定变量的数据类型,以便编译器知道如何为该变量划分内存,以及如何往这段内存中存放该类型的数据。

注意,数据类型是变量而非数据的属性,但有时候会在代码中直接使用数据的字面量(即直接硬编码的数据)而不是将数据保存到变量中。例如,下面是两条Rust代码:

1
2
let x: i32 = 33;  // 数值33按照4字节数据的方式保存到内存中
let y = 33_i32; // 使用数值33的字面量,指定数值的类型是i32

总之,编译器要求在编译期间,就能够确认数据写进内存时是如何保存的,它也会知道从某块内存读取数据后应该如何解析这份数据。例如,对于保存为指针类型的数据0xabcdef,编译器知道应该将它解读为指针,而不是当作一个普通的十六进制整数来使用。

另外,为变量指定数据类型还有其他功能,其中功能之一是禁止该变量存储数据类型所不允许的数据。比如字符串数据不允许存放到数值类型的变量中。

结论三

变量有时候不仅仅只保存数据,还保存一些额外但必要的元数据信息

变量可以保存各种各样数据类型的数据,每种数据类型都有自己的内存布局方式,因此编译器知道如何存、取各种类型的数据。

但是,有些数据比较简单,它们可以直接保存在变量中;而有些数据则比较复杂,它们由多个简单数据按照一定的规则组成,对于这种复杂数据,需要使用指向它们的指针外加额外的元数据才能表示出来,也就是说,变量中保存指向这些数据的指针,可能还额外保存一些元数据。

例如,数值3,它就是简简单单的一个数据,可以直接存放到内存中。

而数组则是一种相对更复杂的数据类型,编译器需要为数组划分多个内存空间来存放可能的多份数据。但编译器应该为数组变量划分多少内存呢?实际上,当使用数组类型的变量时,需要为数组指定要存放的数据类型,这样才能知道每个元素占用的空间,还要指定数组的长度,也就是数组最多能保存多少份数据,只有知道这两项信息,编译器才知道该为数组变量划分多少空间的内存(即数组的长度乘以单份数据的大小)。

对于C语言来说,数组变量保存且只保存指向数组第一个内存空间的指针,没有保存任何其他元数据,因此在C中操作数组实际上是在操作指针。而其他相比于C更高级或更现代的语言,数组变量除了保存指针外,通常还保存一些额外的元数据,比如数组当前的长度(即当前保存了多少个元素)、为数组划分的内存容量(即最多能保存的元素个数)。

再比如,面向对象语言中的对象,是更为复杂的数据类型,它除了需要保存对象的实际数据,还需要保存很多必要的元数据信息。

因此,参考下图:

解释了上面三个结论后,对变量应该有了一个比较深入的理解。那么什么是变量?上图中栈里的x或arr是变量,从图可知,变量和它想要保存的实际数据可能是分开存放的。再回顾一下,变量用来存储数据,但可能直接存储实际数据,也可能间接存储实际数据。

原地修改和不可原地修改

当修改变量数据或为变量重新赋值时,不同语言的处理方式不同。

以下面这段伪代码为例:

1
2
a = 3;
a = 4;

上例的变量a,原来在内存地址0x123处保存数值3,后来重新赋值为4时,不同语言对这里的处理不同:

  • 允许原地修改内存的语言:仍然在0x123内存位置处保存新值4,这时变量a仍然代表这段内存
  • 不允许原地修改内存的语言:重新申请一个新的内存空间0x456用来存放新值4,并修改变量a使其指向0x456,这时原内存空间0x123可能已经没有任何用处,它将会等待被回收

因此,对于变量来说,这两种处理方式导致不一样的行为:允许原地修改时,保持变量的地址不发生变化;不允许原地修改时,变量的地址会发生变化。

一般只考虑简单数据类型是否允许原地修改的问题,比如数值、字符串(但注意,有些语言字符串是很复杂的数据类型)、布尔值等。对于那些容器类型,它们一定是允许原地修改的,因为它们是引用类型,可以包含多份简单数据类型,修改容器内部的元素并不会让容器自身的地址发生变化。例如:

1
2
3
4
5
6
# 修改容器内元素,arr自身地址不改变
# 但被修改的那个元素,如果它是简单数据类型,
# 且是允许原地修改的,则其地址不变
# 如果不允许原地修改,则这个元素引用的地址发生改变
arr = [11, 22, 33];
arr[1] = 2222;

按引用拷贝和按值拷贝

看下面一段伪代码:将变量a赋值给变量b,或者调用函数f时,将变量a传递给f的参数b。

1
2
3
4
5
6
7
8
# 将变量a赋值给变量b
a = 33;
b = a;

# 调用f()时,将变量a传递给参数b
function f(b){...}
a = 44;
f(a);

如上,当将变量传递给另一个变量时,有两种传递方式:

  • 按值拷贝:拷贝变量a所保存的实际数据值拷贝一份,赋值给变量b,使得变量a和b的值相同,但是变量a和变量b指向的内存地址不同
  • 按引用拷贝(传递指针):拷贝变量a的引用,赋值给变量b,使得变量a和b都指向内存中的同一个数据,它们的值自然也是相同的

这两种拷贝方式如下图:

按值拷贝时,修改变量a不会影响影响变量b,它们指向的内存地址是相互独立的。

1
2
3
4
# 如果是按值拷贝,修改a后,b不会发生改变
a = 3;
b = a;
a = 4; # 修改a=4,但b仍然为3

按引用拷贝时,修改变量a时有可能会影响变量b。之所以是可能会影响,这是因为有些语言的有些数据是允许原地修改的,有些语言则是不允许原地修改的。

显然,应该避免按引用拷贝且允许原地修改的情况,因为这会导致变量的数据不一致性。比如原来b=3,修改a=4之后,b也被修改为4,但这期间却从未操作过变量b。

另外,对于容器类型的数据(比如数组、对象、集合、hash等),一般情况下它们是引用类型的数据,只修改其中的元素时,无论是否允许原地修改,都不会影响容器自身,因为容器自身的内存位置并未改变。

最后,对于按引用拷贝和按值拷贝,有如下通用但不一定适合所有语言的结论

  • 支持指针操作的语言,默认采用按值拷贝,因为编程人员可以手动传递指针来实现按引用拷贝的方式
  • 不支持指针操作的语言,默认采用按引用拷贝,因为编程人员无法手动传递引用,只能由解释器在内部来传递引用
  • 对于那些数据直接保存在变量中的数据类型(即变量保存的不是引用或指针),它们通常采用按值拷贝的方式,因为这类数据通常保存在栈中,直接拷贝效率也很高
  • 有些语言,某些数据值在整个程序运行期间只有一份,不会在内存中创建第二份同值数据,这类数据没有按值拷贝和按引用拷贝的概念。比如Python中的小整数,Ruby中的整数