«
前端的手动内存管理基础入门(一)返璞归真:从引用类型到裸指针

时间:2022-4   


作为一名经常需要在团队中搞跨界合作的前端开发者,我发现许多往往被人们敬而远之的「底层」技能其实并不必学到精通才能应用。只要能以问题导向按需学习,就足以有效地完成工作,取得成果了。像 C、C++ 和 Rust 中的手动内存管理,就是这样的例子。我们完全可以绕开语言的黑魔法,只学习它们对工程而言最必要的特性与最佳实践。这就足够我们开发出它们与 JS 交互时的原生扩展,或调用平台库实现功能了。

因此,本系列文章将以实用和入门角度出发,专注于解释这几门「原生语言」中,与手动内存管理相关的子集。整个主题(暂定)依次包括这些部分:

由于在内存管理方面,JS 和其他常见的 Java、Python 和 Dart 等带 GC 语言的差别并不大。因此熟悉这些语言的同学,也可以很容易地按这篇文章介绍的方式,由浅入深地掌握这项工程技能。另外在前端开发者们常用的 macOS 上,C/C++ 的编译环境更完全是现成的,没有任何折腾的必要。本文中的代码都是无需第三方依赖的单线程简单示例,因此也不必接触复杂的构建配置和多线程心智模型。

作为系列的起点,下面我们将从 JS 返回 C 的世界。有位做 C++ 的朋友提醒我说「裸指针可能是最难的」。但其实个人认为对现在普遍熟悉强类型语言的前端开发者们来说,裸指针反倒很容易通过一些技巧来快速类比掌握。不过鉴于指针的新人杀手性质,本文会尝试提供一条尽可能平滑的学习曲线。相信只要过了这一关,大家读起后面的内容会轻松顺利很多。

本篇介绍可分为如下几个部分:

下面,让我们首先从每位前端开发者都耳熟能详的「基础类型」和「引用类型」说起吧。

你可能已经掌握的 C 子集

当我们探讨「原生」语言中的内存管理时,C 语言是我们很难绕开的话题。它的影响极为深远,以至于很多时候当你熟练地使用其他高级语言编码时,你甚至未必知道自己可能已经(部分地)学会了 C。比如,如果只考虑  double  和  int  这样的基础类型,那么你会发现这时的 C 语言,几乎不可思议地接近 JS 和 Dart。像下面这段用来计算斐波那契数列的经典代码,就同时既是有效的 C,又是有效的 Dart:

int fib(int n) {
  if (n <= 0) return 0;
  else if (n == 1) return 1;
  else return fib(n - 1) + fib(n - 2);
}

上面这个例子中,在发生形如  fib(n - 1)  的函数调用时,调用方  n - 1  表达式的值会被复制一份,传给被调用的函数,这也就是所谓 pass by value 的经典心智模型了。对于整数和浮点数等「小」数据类型,这种设计从 C 时代起,一直在主流工业语言中沿用至今。所以如果你只用 JS 或 Dart 做这类纯粹的算术计算,那么你完全可以(自豪地)认为这时你写的代码就是 C 语言子集的一种方言变体,当然这个子集其实也没多少实用价值就是了。

有些语言会把类型定义放在变量标识符前面,如 C、C++、Java、C# 和 Dart。也有一些则会把类型放在标识符后面,如 TypeScript、Rust 和 Go。像  int x  还是  x: int  这种风格问题本身并没有多少好坏之分,大家只要入乡随俗地习惯就行。

根据小学二年级的前端知识,我们知道在 JS 中,整数和浮点数属于基本类型(primitive values),而对象则属于引用类型。所谓「引用」就像超链接一样,是个指向实际对象的标识符。在 JS 中,当我们把对象作为参数来执行函数调用时,引用会被复制一份传入被调用的函数,而引用所指向的对象本身则不会被拷贝——前端社区里解释这件事的文章早已汗牛充栋,这里就不再赘述了。关键的问题在于,在 C 语言里传递「对象」时又是怎样的呢?

有趣的是,如果我们坚持写语法最简单的 C,那么你会发现这时的 C 甚至在心智模型上能比 JS 更加简单,简单到你都可以不用考虑「引用」这个常常困扰初学者的概念。实际上,当我们在 C 语言中使用  int a  这样的语法来定义变量时,数据通常会被分配在栈(此处指原生程序的运行时调用栈,即所谓的 The Stack)上。这类变量的内存能做到被优雅地零开销自动管理,因为在离开函数作用域时,在栈上分配的内存都会被自动回收。而对于 C 语言中常用于模拟对象的  struct  结构体来说,默认你也能用这种方式来管理它的内存——也就是完全不用你费心管!没有内存泄漏,没有悬空指针,一切简单明了地自动完成。

让我们举例说明这种「最简单的 C」吧。假设我们想基于 Skia 这样的 2D 图形绘制库,实现自己的 UI 框架。那么这时框架中所建模的最重要的抽象概念,应该就是屏幕上的矩形图层(这里把它称为 Layer)了。基于朴素的 C 结构体语法,我们可以这么建模图层 Layer 的基础结构:

// 导入标准库中的 printf 函数
#include <stdio.h>

// 定义名为 Layer 的结构体,包含 x y w h 四个字段
struct Layer {
  double x;
  double y;
  double w;
  double h;
};

// 输出 layer 的宽高尺寸
void printSize(struct Layer layer) {
  // 使用 layer.xxx 语法来获取字段
  printf("width: %f, height: %f\n", layer.w, layer.h);
}

int main() {
  // 使用字面量语法建立栈上的 layer 实例
  struct Layer a = {0.0, 0.0, 100.0, 100.0};
  printSize(a);
}

不过  struct Layer  这样的类型名有些啰嗦,所以我们常常会用  typedef  语法来简化一下它,像这样:

// 把 struct Layer 定义为 Layer
typedef struct Layer {
  double x;
  double y;
  double w;
  double h;
} Layer;

这样一来,我们就可以用  Layer  这个类型来替代  struct Layer  了:

void printSize(Layer layer) {
  printf("width: %f, height: %f\n", layer.w, layer.h);
}

int main() {
  Layer a = {0.0, 0.0, 100.0, 100.0};
  printSize(a);
}

基于这种语法,你甚至可以在 C 语言中轻松地获得纯粹的,无副作用的的纯函数。它完全通过返回值来输出计算结果,从而彻底避开 JS 函数内部直接 mutate 外部对象的问题:

// 将某个 layer 的尺寸放大两倍
// 不同于 JS,这里传入的 layer 相当于原始值的拷贝
Layer doubleSize(Layer layer) {
  // 这里不会 mutate 原本的结构体数据
  layer.w *= 2.0;
  layer.h *= 2.0;
  return layer;
}

int main() {
  Layer a = {0.0, 0.0, 100.0, 100.0};
  // 如果只调用 doubleSize(a) 而不赋值,是不会修改 a 的
  a = doubleSize(a);
  printSize(a);
}

遗憾的是,上面这种手法虽然看起来简单易懂,但真实的 C 工程项目中经常不会这么做。为什么呢?首先,这种传值形式可能在函数调用之间产生较多冗余的拷贝,影响程序的性能。并且有些资源(例如 GPU 纹理)甚至未必位于内存中,它们更是不可随意拷贝的。这时该怎么办呢?

熟悉指针类型

终于,是时候让我们讨论 C 语言中的「引用类型」了——通过引入「指针」的概念,C 语言赋予了你自由地引用和解引用内存地址的能力。传统上,指针常常让新手感到艰深晦涩。而某些应试教育中人为制造的  int ****p  等脱离实际的问题,更加深了普通人对其的畏惧感。但下面我们将介绍一种简单的技巧,展示如何通过引入一点点写法上的改变,让你即便只有 JS 系语言的使用经验,也能轻松地写出可以通过编译的指针操作代码。

这条技巧说起来其实很容易,那就是把指针当作一种特殊的变量类型即可。你可以在任何类型的后面加一个  * ,获得其相应的指针类型,像这样:

int x1; // 定义出 int 类型的变量 x1
int* x2; // 定义出 int* 类型的变量 x2
Layer layer1; // 定义出 Layer 类型的变量 layer1
Layer* layer2; // 定义出 Layer* 类型的变量 layer2

为了便于理解,本文把指针类型以外的类型统称为「普通类型」。对所谓「普通类型」更准确的定义其实是「值类型」。C 语言中的值类型既包含整数和布尔值这样的基础类型,也包括了  struct  和  enum  这样的复合类型,在 C++ 中还包括了  class总之如果不使用指针,那么 C 语言并不像 JS 那样「对象就是引用类型」。更详细的相关定义可参见 Wikipedia

上面这种写法,看起来(至少在个人眼里)很贴近前端在使用 TypeScript 等强类型语言时的思维模型。但要注意的是,它其实和「经典」的 C 编程风格是有所不同的。传统上,C 语言代码中习惯使用  int *p  的风格来定义指针变量。其理由很简单,如下所示:

int* p, q; // 这里的 q 是 int 类型,不是 int* 类型!
int *p, *q; // 改成这样就不会写错

不过,个人认为使用  int* p  的风格,更有助于我们将指针概念融入大家现在所习惯的类型系统,降低一些学习成本。例如在谷歌 Chromium 项目中,就使用了这样的编码风格。基于这种风格,不论是整数、浮点数还是结构体,如果它们默认的类型是  Type t ,那么  Type* t  就是其相应的指针类型。不管在哪种情况下,你定义出的变量名始终是  t  而不是  *t

按照这个「把指针当作一种变量类型」的理解方式,你会发现指针变量如果(十分鲁莽地)不考虑安全性问题,它们在使用时的心智负担实际上非常小。具备指针类型的变量同样可以被重新赋值,也可以作为函数参数自由传递。像这样:

// 这个函数接收 Type* 类型的变量,这在 C 中非常常见
void renderLayer(Layer* layer) {
  // ...
}

int main() {
  // Layer* 类型变量可以这样简单地定义出来
  Layer* a;
  // Layer* 类型变量也可以用函数来创建,先忽略这里的细节
  Layer* b = makeLayer(0.0, 0.0, 100.0, 100.0);
  // Layer* 类型变量之间可以自由地互相赋值
  a = b;
  // Layer* 类型变量也可以作为函数参数传递
  renderLayer(a);
}

看起来是不是也很简单呢?不过,指针类型和普通类型显然还是有所不同的。回到 Layer 的例子, Layer*  类型的指针变量在使用时相比于  Layer  类型的普通变量,其区别简单看来只有这么两条:

体现这两条区别的例子是这样的:

void doubleSize(Layer* layer) {
  // 通过指针变量,可以直接 mutate 原本的结构体数据
  layer->w *= 2.0;
  layer->h *= 2.0;
  printSize(layer);
}

int main() {
  // 先忽略创建 Layer* 类型变量时的细节
  Layer* a = makeLayer(0.0, 0.0, 100.0, 100.0);
  // 因为传递的是引用,这里不再需要函数返回值了
  doubleSize(a);
}

在这个例子中,通过引入指针,我们使用 C 的方式已经发生了变化,能以引用类型的心智模型来编写逻辑了。不过,指针变量和普通变量之间并不直接兼容。这也就是说,需要  Layer*  变量的地方不能传入  Layer  变量,反之亦然。所幸它们之间可以简单地进行双向转换,其语法是这样的:

所谓的引用和解引用,其实很类似 Vue 3.0 中的  ref  和  unref 。这种转换的编写本身并不需要额外的条件,只要类型匹配就能通过编译。其相应的例子类似这样:

void printSize1(Layer layer) {
  printf("width: %f, height: %f\n", layer.w, layer.h);
}

void printSize2(Layer* layer) {
  printf("width: %f, height: %f\n", layer->w, layer->h);
}

int main() {
  Layer* a = makeLayer(0.0, 0.0, 100.0, 100.0);
  Layer b = {0.0, 0.0, 100.0, 100.0};
  printSize1(*a); // 将 Layer* 转为 Layer
  printSize2(&amp;b); // 将 Layer 转为 Layer*
}

为指针分配内存

了解指针类型与普通类型之间的转换后,擅长活学活用的同学可能立刻就能想到,我们是不是可以像下面这样简单直接地实现  makeLayer  函数呢?

Layer* wronglyMakeLayer() {
  Layer layer = {0.0, 0.0, 100.0, 100.0};
  return &amp;layer;
}

不幸的是,虽然这段代码能通过编译,但它却犯了个严重的错误。我们把函数内部局部变量所在的内存地址传了出去,而这份分配在栈上的内存空间,在函数返回后就会直接被回收!因此即便能通过编译并有时「凑巧」能正常工作,这段代码也是有问题的,会收到现代编译器的警告。

那么,到底该如何合法地创建出指针类型的复杂结构呢?这常常需要我们手动申请和销毁内存。C 函数自动管理的内存一般分配在栈上,而这类动态分配的内存则位于堆(heap)上。让我们来看看这个过程涉及到哪些 API 吧。

注意,指针既可以指向栈上的内存,也可以指向堆上的内存。栈上的变量只要还没被回收,创建指向它的指针来使用是完全合法的。但对于需要在函数返回后继续长期存在的内存,就必须手动在堆上申请了。

我们知道,要在 C 和 C++ 中使用库,需要引入相应的  .h  头文件。像  stdio.h  就包含了  printf  函数。对于内存分配,我们可以使用标准库  stdlib.h  中的  malloc  函数。只要为它传入所需的内存大小,就可以获得一个指向这段空间的  void*  类型指针了。可以认为  void  相当于 TypeScript 中的  any ,因此  void*  也就是能指向任意类型数据的指针。C 语言中的类型转换规则较为宽松,这个  void*  类型变量可以被直接赋值给任意的指针类型,并需要用  free  函数销毁。至于  malloc  所需的具体字节尺寸数字,则可以通过  sizeof  关键字在编译期计算出来。像这样:

#include <stdlib.h> // 导入用于手动管理内存的库函数

Layer* makeLayer(double x, double y, double w, double h) {
  // 分配 Layer 所需尺寸的内存空间
  Layer* layer = malloc(sizeof(Layer));
  layer->x = x;
  layer->y = y;
  layer->w = w;
  layer->h = h;
  return layer;
}

void test() {
  Layer* a = makeLayer(0.0, 0.0, 100.0, 100.0);
  // ...
  free(a); // 用完记得 free 掉
}

除了  malloc  以外,C 标准中还有能将分配到的空间清零的  calloc  函数,以及能就地调节原指针指向空间大小的  realloc  函数。它们返回的都是  void*  类型的指针,可以用  free  销毁。并且在 C 里,我们还可以通过  TypeB* b = (TypeB*)a  这样的强制类型转换语法,把指向某段内存空间的指针映射到任意类型。但不论如何转换类型,对于指向某段内存地址的指针,其在  malloc / calloc / realloc  时所分配的内存空间,都能正确地被  free  掉。对此有种简单易懂的理解,就是认为  free  既然接受的是  void*  类型,它到底要释放多少内存空间显然与特定类型无关。不过这背后更具体的原理,则在《Operating Systems: Three Easy Pieces》中有很精彩的论述,推荐感兴趣的同学阅读。

当然,如果觉得指针只要学会了  malloc  和  free  就能用好,那就太小看它了。C 中原始的裸指针机制,非常容易带来一些棘手的问题。前面我们说到过,所谓「引用」就像 URL 超链接一样,是个指向实际内容的标识符。而在没有 GC 当保姆的时候,这种标识符机制所产生的问题,和我们日常上网使用超链接时遇到的很像。比如这么几种:

如何感受指针的危险呢?在 JS 中,我们常用一个对象是否为  null  来判断它是否存在。这种「天经地义」的效果在 C 中是不成立的。虽然 C 中有  NULL  宏,但一旦出现悬空指针和野指针,它们都可以通过  NULL  检查!比如这样:

void renderLayer(Layer* layer) {
  // 常见的防御判断对悬空指针无效!
  if (layer == NULL) return;
  // ...
}

int main() {
  Layer* a = makeLayer(0.0, 0.0, 100.0, 100.0);
  free(a);
  // 这时的 a 不为 NULL,它是个悬空指针
  renderLayer(a);
}

如果把失效的地址拿来解引用,很容易导致程序的运行时崩溃。诸如此类的各种问题使得指针虽然容易通过编译,准确地用好它却很难——不过作为手动内存管理的元祖级特性,现在我们至少已经知道该怎么理解和编写它了。并且对前端开发者而言,就算还没有指针的实际工程经验,只要借助对其概念的理解,C 的许多重要语言特性一下就会显得很简单了。

指针、数组与字符串

在经典的谭书等 C 语言教材中,是先讲字符串和数组,再讲指针的。如果把 C 作为第一门编程入门语言,这个安排有其合理性。但本文并未遵循这条路线,因为只要我们先从 JS 的引用类型出发理解了 C 的指针类型,很容易继续把 C 的数组当作一种指针来理解,进而理解字符串的结构

首先,指针是可以和整数之间做加减运算的——对具备「对象是引用类型,数字是基本类型」经验的前端同学来说,这恐怕有点毁三观。毕竟基于 JS 的思维模式, obj + 1  难道不应该是…… "[object Object]1"  吗?但在 C 语言里,这是极为重要的指针运算。假设我们为某种类型的 N 个实例分配了一整段内存空间,而这段空间的起始位置又有个指针。那么我们就可以用这种方式,指向其中的某个实例:

// 在堆上分配足够容纳 5 个 Layer 的内存空间
Layer* p = calloc(5, sizeof(Layer));

// 可以直接解引用出第一个 Layer
Layer first = *p;

// 或获得指向第二个 Layer 的指针
Layer* secondA = p + 1;

// 也可以这样解引用出第二个 Layer
Layer secondB = *(p + 1);

但是,上面  *(p + 1)  这种写法实在太不语义化了。为此 C 设计了数组语法,可以给指针操作披上一层壳:

// 在栈上分配 5 个 Layer 实例
Layer a, b, c, d, e;
Layer layers[] = {a, b, c, d, e};

// 这比 *(p + 1) 直观多了吧
Layer second = layers[1];

可以看出, *(ptr + offset)  等价于  ptr[offset] ,而这个  offset  每次  +1  时对应的字节数,正是  ptr  所指类型的  sizeof  大小。比如假设  sizeof(Layer)  的结果是 32,那么  layers[2]  就等价于偏移  2 * 32  个字节的内存地址——这种简单明了的对应关系,其实也是 C 语言从零开始计算数组下标的原因之一。除了  []  以外,前面提到的  ->  运算符也有这样的语法糖性质, obj->x  实际上就等价于  (*obj).x

对于装载任意类型数据的 C 数组,你都可以创建出指向它的指针。如果数组的类型是  Type[] ,那么相应指针的类型就是  Type* 。并且,数组和指针都能用  []  运算符来取下标。像这样:

// int[] 类型的数组
int arr[] = {0, 1, 2};
// int* 类型的指针,指向数组起始位置
int* p = arr;

// 下面这两行代码是等价的
int tmp1 = arr[1];
int tmp2 = p[1];

别忘了  int  和  Layer  都属于值类型,所以我们也能轻松照猫画虎地写出这样的代码:

Layer a, b, c;
// Layer[] 类型的数组
Layer layers[] = {a, b, c};
// Layer* 类型的指针,指向数组起始位置
Layer* p = layers;

// 下面这两行代码也是等价的
Layer tmp1 = layers[0];
Layer tmp2 = p[0];

更进一步地,数组里可不只能装普通类型,也能装指针类型:

Layer* a = malloc(sizeof(Layer));
Layer* b = malloc(sizeof(Layer));

// 数组中的每项都是 Layer* 类型
Layer* layers[] = {a, b};
// 等效的指针形式,指向数组起始位置
Layer** p = layers; 

Layer* first = layers[0]; // 等价于 p[0]
first == a; // true

// 记得销毁 malloc 出的对象
free(a);
free(b);

Layer**  这个类型可能有些费解,它的字面意义是  Ptr<Ptr<Layer>> ,在实践中既可以兼容  Ptr<Array<Layer>> ,也可以兼容  Array<Ptr<Layer>> ,我们这里使用的是后者。不要再纠结于传统 C 语言教程中所谓「数组指针」和「指针数组」这种含糊的概念了。直接从类型的角度理解它们,会准确而可靠得多

C 中的数组非常简单,它纯粹只是一段连续的内存空间,并不携带长度信息。在将数组作为函数参数传递时,一般还要单独将其长度作为参数传递。这时候它们对外的 API 形式也都很接近:

// 接收数组为参数的函数
void printArr(int arr[], int len) {
  arr[len - 1]; // 数组的最后一项
}

// 等价的指针形式,虽可用但语义化程度较差
void printPtr(int* arr, int len) {
  arr[len - 1]; // 数组的最后一项
}

为什么  int arr[]  好像总能转换成  int* arr  来使用呢?实际上在需要指针类型的地方,C 会将数组类型自动退化(decay)为指针类型。因此  int[]  和  int*  虽然类型不同,但需要  int*  的地方总可以传入  int[]

既然  int[]  和  int*  的兼容性这么好, char[]  和  char*  自然也不例外——然后我们就可以很容易地理解 C 中的「字符串」了。C 中从来没有  string  类型,只有用来表示单个字节的  char  类型。于是,由一串  char  类型字符数据组成的数组,就构成了「真 · 字符串」。假设我们要为 Layer 增加  name  字段,就可以直接这么写:

typedef struct Layer {
  double x;
  double y;
  double w;
  double h;
  char name[50]; // 预留 50 字节的位置
} Layer;

或者把  char[]  换成不受长度限制的  char*  类型:

typedef struct Layer {
  // ...
  char* name;
} Layer;

这两种形式都可以这么初始化:

Layer a;
Layer b = {0.0, 0.0, 0.0, 0.0, "hello"};

但  char[]  有个地方需要注意,那就是它不能被重新赋值。比如这样:

#include <string.h>

typedef struct Layer {
  // ...
  char name[50];
} Layer;

int main() {
  Layer layer;
  layer.name = "world"; // 但这是无法通过编译的
  strcpy(layer.name, "world"); // 要换成这样
}

相比之下, char*  就没有这种限制:

typedef struct Layer {
  // ...
  char* name;
} Layer;

int main() {
  Layer layer;
  layer.name = "world"; // 这样可以通过编译
  strcpy(layer.name, "world"); // 这样也可以
}

除了赋值时的区别外,C 中的字符串也不能靠  ==  来比较。 ==  只能用于比较指针和算术类型。如果直接比较两个  char[]  数组,这时虽然可以通过编译,但 C 会将数组退化为指针来进行比较,其结果并不是我们想要的。因此不管是  char[]  还是  char*  类型的字符串,其通用的比较方式都是使用  string.h  标准库中的  strcmp  函数。

另外,前面的结构体  Layer  类型也不能用  ==  来比较,只有  Layer*  可以。不妨想想这是为什么,又该怎么办呢?

我们已经发现,字符串既可以是  char[]  类型,也可以是  char*  类型。这里存在一个容易混淆之处,亦即字符串的存储位置。像下面的三行代码虽然同属  char  家族,但它们运行时所处的内存区域却各不相同:

int main () {
  char a[] = "hello"; // 分配在栈上
  char* b = malloc(10); // 分配在堆上
  char* c = "world"; // 指针 c 在栈上,"world" 在常量区
}

上面的例子,反映出了 C 中字符串可能对应的几种内存分配位置:

在这里,我们可以再次感受到 C 的抽象层次之低。作为入门性质的文章,这里不会继续深入相关的 OS 和编译产物细节。不过至少目前来看,这个例子可以告诉我们,为什么有些地方传来的字符串需要手动销毁,有些则不需要了。

指针与 C++ 中的引用

上文已经覆盖了对指针常用知识的基本介绍。在传递引用方面,C 为我们提供的特性实质上也就只有它了。但对于「引用类型」这个概念,最后值得简单一提的还有 C++ 中的引用(reference)语言特性。

C++ 中的引用和 C 的指针,都可以认为是(相对于值类型的)引用类型。本文中除非特别提及,所谓「引用类型」都指广义上的通用概念,而不是 C++ 中的这项具体语言特性。

经过上面的论述,我们已经知道对于  Layer  这个类型,存在着  Layer*  和  Layer[]  这两种引用类型了。C++ 中加入了一种新的类型,那就是  Layer&amp; 。像这样:

Layer a;
Layer&amp; b = a;  // 创建一个 Layer&amp; 类型变量

或许不少人只要一看到带着  &amp;  和  *  的类型就头疼。不过这里有条好消息,那就是  Layer&amp;  类型可以当做普通的  Layer  类型来用!像这样:

void printSize(Layer layer) {
  printf("width: %f, height: %f\n", layer.w, layer.h);
}

int main() {
  Layer a;
  Layer&amp; b = a;

  // Layer 和 Layer&amp; 的存取值语法一样
  a.w = 100.0;
  b.w = 200.0;

  // 作为参数传递时的用法也一样
  printSize(a);
  printSize(b);
}

这么看来,这种类型有什么存在的意义呢?它的名字已经告诉了我们答案,那就是「引用」能力了。如果我们写的是朴素的  Layer b = a  而非  Layer&amp; b = a ,那么这里的  a  和  b  是两个独立的实例,对  b  的修改不会影响  a 。而对于  Layer&amp;  类型的  b  来说,对它的修改也会直接影响到  a 。这个差异应该很容易理解,这里就不展开赘述了。

C++ 引用特性的一种常见用途,是优化函数传参时的拷贝开销。刚才我们用来接收  Layer  的函数是这样的:

// 接收普通的值类型,会产生拷贝
void printSize(Layer layer) {
  // ...
}

每次调用这个函数时,传入的 Layer 数据都会被完整地拷贝一份。为此,我们可以把输入参数的类型从  Layer  换成  Layer&amp; ——由于  Layer  和  Layer&amp;  之间的良好兼容性,这个改动应该不会报错。于是我们只要加一个  &amp;  就能优化掉拷贝开销,其他地方还是和原来一样该怎么写怎么写,属于躺着就能拿到的性能优化:

// 接收引用类型,能避免拷贝,但 layer 可能被 mutate
void printSize(Layer&amp; layer) {
  // ...
}

但这时候,代码的潜在限制就会发生改变了。比如,我们现在可以通过修改  layer.x  来改变原有的数据,这就产生了代码语义上的差异。为此,我们可以继续用  const  修饰符来禁止修改它。这样一来,我们就得到了在许多 C++ 代码库中非常常见的这种函数:

// 接收常量引用类型,它不仅能避免拷贝,layer 还不会被 mutate
void printSize(const Layer&amp; layer) {
  // ...
}

当作为函数形参时, Layer  类型和  const Layer&amp;  类型的使用效果几乎是一致的:不用担心传入的变量被修改,也都能用  layer.x  的语法来读取值。并且相比于传递朴素的值类型,传递  const  引用可以避免拷贝——所以,下次在 C++ 函数参数列表里见到  const Type&amp;  类型的时候不要慌,它想表达的就是个更好的  Type  类型而已。

为了解决指针的常见问题,引用做出了几条限制:

引用算是相当容易应用的 C++ 特性。只要你在朴素的 C 代码库里用上它,那么你就可以说自己写的已经不再是 C,而是「C++ 的子集」了……后面我们也会继续按这种思路,按需地引入 C++ 中的一些重要概念。

到这里,与 C 系语言中「引用类型」相关的介绍就到此为止了。总结如下:

依靠本文目前介绍的 C 特性的表达力,已经足够高手用清晰的代码开发出强大的软件。比如哪怕只基于本文涉及的这么一点东西,你在阅读 Fabrice Bellard 的 QuickJS 引擎源码时,恐怕都已经不会受到多少语言特性上的困扰了。但 C 本身作为「最简单的高级语言」,其特性实在还是太少。它的继任者 C++ 做出了很多努力,便于我们靠更高层面的心智模型来开发大型项目。在下一篇文章中,我们会继续从 JS 背景开发者的视角出发,介绍在披上「面向对象」外衣时,原生语言中的内存管理是什么样的。


本系列文章计划在「前端随想录」专栏和 GitHub 上的 gui-engineering-101 仓库上连载并维护。这个《写给前端的 GUI 工程基础入门》电子书项目才刚刚启动,旨在分享一系列务实的工程技能入门介绍,例如上一个主题就是「写给前端的原生开发基础入门」。限于个人能力,文中介绍难免存在偏差和错漏,衷心希望大家批评指正(和顺便 star)。