7

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

 3 years ago
source link: https://zhuanlan.zhihu.com/p/356214452
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

雪碧 | github.com/doodlewind

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

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

  • 返璞归真:从引用类型到裸指针——在 GC 的庇护下,我们对 JS 中引用类型(如对象)占用的内存可以放心地一无所知。那么如果想自己手动管理一段内存空间,这时最朴素最简单最经典的做法,是怎样的呢?令许多计算机专业新生「闻风丧胆」的指针,理解起来真的比 JS 困难很多吗?
  • 新瓶旧酒:面向对象的资源管理——原始的 C 语言是面向过程的,并不易于维护。在智能指针到来前,C++ 已为此投入了大量工作。我们是否能借此以更为声明式的代码和思想来管理资源呢?这时最常见的坑又是什么样的呢?
  • 半自动化:引用计数与智能指针——无 GC 的内存管理只能完全手动吗?是时候接触现代 C++ 中的智能指针了。理想情况下只要利用好这套基础设施来写 C++,其开发效率未必会差 JS 太多(当然理想和现实是有差距的)。
  • 触类旁通:进入 Rust 时代——最后,很多人在光有 JS 背景去硬啃 Rust 时,可能会遇到不少显得奇怪的语言特性。但有了上面的铺垫后,你或许就可以「秒懂」Rust 为什么要这么设计了。你会看到只要理解了原理,要把内存管理的经验在 C++ 和 Rust 之间复用,其实并非难事。

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

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

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

  • 你可能已经掌握的 C 子集
  • 熟悉指针类型
  • 为指针分配内存
  • 指针、数组与字符串
  • 指针与 C++ 中的引用

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

你可能已经掌握的 C 子集

当我们探讨「原生」语言中的内存管理时,C 语言是我们很难绕开的话题。它的影响极为深远,以至于很多时候当你熟练地使用其他高级语言编码时,你甚至未必知道自己可能已经(部分地)学会了 C。比如,如果只考虑 doubleint 这样的基础类型,那么你会发现这时的 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 语言中的值类型既包含整数和布尔值这样的基础类型,也包括了 structenum 这样的复合类型,在 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 类型的普通变量,其区别简单看来只有这么两条:

  • 指针类型变量,在函数调用之间传递的是引用的拷贝。
  • 指针类型变量,需要用形如 obj->x 的语法替代 obj.x 来存取值。

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

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 变量,反之亦然。所幸它们之间可以简单地进行双向转换,其语法是这样的:

  • 通过 & 操作符,你可以把 Layer 类型转换成 Layer* 类型,亦即所谓的引用(reference)。
  • 通过 * 操作符,你可以把 Layer* 类型转换为 Layer 类型,亦即所谓的解引用(dereference)。

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

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(&b); // 将 Layer 转为 Layer*
}

为指针分配内存

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

Layer* wronglyMakeLayer() {
  Layer layer = {0.0, 0.0, 100.0, 100.0};
  return &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》中有很精彩的论述,推荐感兴趣的同学阅读。

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

  • 使用了未初始化的指针,即所谓野指针(wild pointer)——相当于链接生成后还没准备好内容就点开,于是页面一片空白。
  • 使用了已经被释放的指针,即所谓悬空指针(dangling pointer)——相当于原页面被删除但链接却留着,于是一点开链接就是 404。
  • 内存泄漏——相当于链接被遗忘了,于是某个页面虽然已经没有价值,却没有被及时删除掉。

如何感受指针的危险呢?在 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];

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

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 中字符串可能对应的几种内存分配位置:

  • 栈上:对于 "hello"这样赋值给 char[] 的字符串,它和 int a[] = {1, 2, 3} 一样,整个数组内的数据都是分配在栈上的。
  • 堆上:对于 malloc 动态分配出的字符串空间,自然分配在堆上。如果想动态改变这类字符串的长度,还可以通过 realloc 来实现。不妨将此留作习题(狗头)。
  • 常量区上:对于 "world" 这样直接赋值给 char* 的字符串字面量,一般在程序运行过程中始终位于固定的内存空间,因此不需要操心它的释放。但注意指针 c 本身也是个函数内的局部变量,因此它分配在栈上。c 本身可以通过栈的内存管理来自动销毁,但它指向的东西则不行——其他指针也是这么个道理。

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

指针与 C++ 中的引用

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • 引用必须定义时就初始化,杜绝了 int& x; 这样会导致野指针的写法。
  • 引用所绑定的对象是固定的。比如 Layer& b = a 之后,b = x 的意思实际上就是 a = x
  • 引用不允许赋值为 NULL

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

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

  • C 语言离普通前端开发者其实并没有那么遥远,你很可能早已掌握了它的一个子集。
  • C 最朴素的玩法甚至可以比 JS 更简单,同样能全自动且安全地管理内存。
  • 指针相当于需要手动管理内存的引用类型变量。通过一点技巧,同样很容易用 C 写出合法(能通过编译)的指针操作代码,但别忘了安全问题。
  • C 的数组可视为指针的语法糖,而字符串相当于 char*char[]
  • C++ 增加了引用的概念。它和指针同属引用类型但更加安全易用,可在适合时作为替代。

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


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

版权声明:本文不允许未授权转载。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK