最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 写给前端的手动内存管理基础入门(一)返璞归真:从引用类型到裸指针

    正文概述 掘金(doodlewind)   2021-03-11   459

    作为一名经常需要在团队中搞跨界合作的前端开发者,我发现许多往往被人们敬而远之的「底层」技能其实并不必学到精通才能应用。只要能以问题导向按需学习,就足以有效地完成工作,取得成果了。像 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 语言子集的一种方言变体,当然这个子集其实也没多少实用价值就是了。

    根据小学二年级的前端知识,我们知道在 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
    

    上面这种写法,看起来(至少在个人眼里)很贴近前端在使用 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 里,我们还可以通过 TypeA* a = (TypeB*)b 这样的强制类型转换语法,把指向某段内存空间的指针映射到任意类型。但不论如何转换类型,对于指向某段内存地址的指针,其在 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 函数。

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

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

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

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

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

    指针与 C++ 中的引用

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

    经过上面的论述,我们已经知道对于 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)。

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


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

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元