Skip to content

Latest commit

 

History

History

pointer-references

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

指针与引用

在前面的章节中,我们已经介绍了与基本的数据类型和控制流相关的语言特性,它们看起来工作得也很好。那么为什么我们还需要引入指针这个概念呢?让我们从上一节最后提及的调用栈说起吧…

指针与取址

很多 C 语言的初学者对于理解指针的概念感到吃力,但如果你按照我们从类型系统出发的方式来理解,那么你就能够使用同样的思维模型来轻松理解指针了。我们已经知道 int 类型变量的值是个整数、char 类型变量的值是个 ASCII 码、float 类型变量的值是个浮点数。而指针也是一种数据类型,这个类型变量的值是个内存地址

作为一种类型,指针可以使用 *& 符号被声明并赋值:

int x = 123;
int *p = &x;

printf("%d %d\n", x, &p);

我们可以这样声明一个指向整型变量 x 的指针 p。注意这里的语法有一个稍微反直觉的地方:C 的语法中,p 的类型初看起来更接近 Int<Pointer>,但实际上其类型应当理解为 Pointer<Int>。并且 * 符号虽然和 p 紧贴,但其实它并不是变量名的一部分。

你可以把 * 理解为将 Type 类型封装在 Pointer 指针变量的操作符,它可以让你得到一个 Pointer<Type> 类型的指针变量。这样我们就能把数据的地址装在指针里了。在使用的时候,我们需要相应地使用 & 符号来将数据从指针里拿出来,即所谓的解引用。这就是指针的基本用法了。

然而既然我们已经有了完善的控制流和模块化的函数,为什么还需要引入这种并不和数据直接相关的类型呢?在我们之前的介绍中,C 的语法其实都只是在汇编上加了一层壳。而指针也不例外:它直接对应于汇编中非常常见的取地址操作。

取地址操作在 C 程序中有什么意义呢?将数据当做内存地址的操作一定程度上模糊了数据和代码段之间的界限,但在将程序和数据一视同仁的冯诺依曼体系结构中,这其实是一种司空见惯的手法。例如,在之前的章节中我们已经介绍过,函数调用时参数会放在调用栈上。但这就带来了一个问题:不管参数是几个简单的 int 还是复杂庞大的数据格式,在发生函数调用时都需要把这些参数全部复制一份到栈帧上。而冯诺依曼体系结构的瓶颈就在于内存的读写速度远远跟不上 CPU,故而这个复制的开销经常是难以容忍的。如果将传入函数的参数从值变成传指针,那么我们只需要复制一份指向数据的内存地址即可,从而避免了大量数据重复性读写的开销。

引用传递

在上面我们已经提及,我们可以通过在调用栈上复制指针的方式,来减少函数调用时参数传递的开销。这种方式称之为 Pass by Reference 引用传递,一直到当今的编程语言中都非常常见。

但你可能会有疑问,高级的编程语言里不是没有指针了吗?这个概念确实已经在 JavaScript 等语言里被淡化了,但你仍然可以抓住指针的小尾巴。比如,我们可以在 pass-by-ref.js 示例里观察到 JavaScript 中修改函数参数时行为的不一致性。首先让我们考察这个函数:

function setX (obj) {
  obj.x = 1
}

const a = { x: 0 }
setX(a)

console.log(a.x) // 1

这个 setX 函数能够将外部的变量 a 属性修改掉(即所谓的副作用)。但下面的这个函数则不能达到预期的效果:

function setNull (obj) {
  obj = null
}

const b = { x: 0 }
setNull(b)

console.log(b) // { x: 0 }

为什么同样是传入函数的参数 obj,修改其属性能影响外部的变量,但将其置为 null 则不生效呢?在这方面,JavaScript 和 C 语言一样,是将函数参数通过调用栈传递的。而引用类型所传入的参数就是一个指针,故而通过指针去存取对象属性就能够对“外部”变量生效,但将指针本身置为 null 是不影响对象本身的。理解了这一点,就能够理解 Pass by Reference 引用传递的机制了。