瞎谈指针与内存

指针是 C/C++ 的重中之重,也是程序员理解软件运作方式的一个重要入口,这次我来扯扯内存中的那点事。

  • 如果你刚刚学了编程,会一些基础的流程控制,但是对指针完全陌生或者感到指针学习非常苦恼,请你仔细看文章的前半部分。
  • 如果你经常使用 Java、JavaScript、Python 等自带垃圾回收机制的语言,我也建议你看看文章的前半部分和中间部分,对内存的理解是程序员的必修课,有助于你对程序进行优化。
  • 如果你是一个 C/C++ 的使用熟手,能够完全理清指针用法和内存模型,请主要看文章后半部分我对 Rust 语言的安利。

看山是山:C/C++ 眼中的内存

指针基础

在讨论内存之前,我们先讲一点关于指针的基础知识,不然接下来你可能会对内存模型中的例子感到有些难懂。当然,如果你懂指针完全可以跳过这一部分,直接看 内存结构 吧。

很多学习 C/C++ 编程的萌新对指针这个玩意又怕又恨。其实指针的概念真的非常简单,只是你可能对那个 * 号和 & 号以及其揉合进一堆数组中的样子感到心里没谱,不急,我们慢慢捋。

另外,强烈建议 使用 Windows 系统的同学装上有“宇宙第一 IDE”之美名的 Visual Studio, 版本当然是越新越好,社区版免费又强大。求求你不要再用老掉牙的 VC6.0++ 了,它甚至会因为兼容性的问题而难以安装到 Windows 10 上,真的过时了,然而国内还有一堆高校的老师在教学时叫学生们装这个二十多年前的 IDE,真的是懒得学新技术的一群老古董。

用 Visual Studio 的话,在接下来我给的示例程序中,你可以先打断点进入调试模式,然后在调试中去菜单里找到 内存窗口(调试 > 窗口 > 内存,选择内存 1,找不到也可以试试 VS 内置的强大的搜索功能),把它开下来看看内存里都塞了些什么玩意,在 内存窗口 的地址栏里搜 &变量名 就能取到对应变量的地址了,真的非常好用。注意内存高地址到低地址的方向,可能你看了一个数值半天看不出名堂,那其实是你看反了,比如内存里一个地址在窗口里显示的是 16 EE,你以为这是十进制的数值 5870,其实这是 60950 ——也就是十六进制的 0xEE16,方向问题而已。数值在计算机里的存储方式在《计算机组成原理》里有讲,这都是应知应会的基本功。

从最简单的开始。考虑一下这个变量在内存中的表现形式:

1
unsigned int a = 3;

无符号整形 unsigned int 在不同操作系统上有着不同的大小,C/C++ 只规定它占用字节数必须大于等于 short 且小于等于 long(因为也有可能在寸土寸金的嵌入式设备上使用 C 进行开发,所以不能规定死了),这里我们就认定它有 4 个字节大吧。同样地,我们假定内存地址也是用 4 个字节表示的。1 个字节就是 8 bit,表示范围为 0 到 255,可以用两个十六进制位表示。在内存栈区里,它实际上可能是这样的(地址是我随便写的,实际情况这个地址就是不确定的,由操作系统自动分配):

  • 地址:0x00114514
  • 数值:0x00000011

这样的 <地址, 数值> 成对地布满了整个内存。程序可以通过这个地址来读取其中的数值。

再来考虑以下这个所谓的指针在内存栈区中的表现形式:

1
2
unsigned int a = 3;
unsigned int* ptr_a = &a;

& 操作符意思就是将变量 a 的地址 0x00114514 取出来,保存到 ptr_a 这个指针的数值中。ptr_a 的在内存栈区中的表现可能是这样的(地址也是随机的,不过事实上会跟前面变量的地址挨得比较近):

  • 地址:0x01919810
  • 数值:0x00114514

没错,即使是指针,它也与那个无符号整数在内存栈区中的表现形式完全一致,它的数值就是变量 a 的地址,这就是所谓的指针 ptr_a 指向了变量 a。你甚至可以对这个指针再次取其指针:

1
2
3
unsigned int a = 3;
unsigned int* ptr_a = &a;
unsigned int** ptr_ptr_a = &ptr_a;

ptr_ptr_a 是一个指向了 ptr_a 的指针,它在内存栈区中的表现可能是这样的:

  • 地址:0x20202020
  • 数值:0x01919810

一看就懂了吧?所以才说指针很简单,因为它就是这么的直白!由于我们假定了操作系统上的内存地址都是用 4 个字节表示的,那么不管是哪个指针,都只需要保存 4 个字节的地址作为数值就够了。但是,考虑以下情况:

1
2
unsigned long b = 6;
unsigned long* ptr_b = &b;

假定 unsigned long 占用了 8 个字节,变量 b 在内存中的表现可能是这样的:

  • 地址:0x40404040 ~ 0x40404047
  • 数值:0x00000000 00000110

诶,为什么我这里写了个 来表示出了地址的范围?之前我说过,每两个十六进制数表示一个字节,也就是 8 bit,一个字节在地址数里表示 1 ,数值一共有 8 个字节,那当然就占了 8 个地址数。其实上面 unsigned int 类型的 a 也是这样占用了 4 个地址数的,只是我没写详细了而已。仔细想想,就是说指针的数值只存储了它指向目标的首个地址,那系统是在读取指针的时候是怎么知道要往指向目标读几位呢?往后读 4 位和往后读 8 位的结果可能是不一样的。答案就是:类型。系统针对不同的变量类型指定了不同的字节数,这样系统只需要保存首地址,再根据变量的类型来确定要读写多少字节。而指针——你仔细看,指针都是有类型的,比如 ptr_a 的类型是 unsigned int* ,系统在对其解除引用时显然就能知道它指向的是一个 unsigned int 类型,那铁定能知道需要读几个字节了。所以说,指针不仅记录了某个变量的实体,更重要的是它还记录了该变量占用了多少个字节。在 C/C++ 中,你可以对指针进行强制转换。考虑以下 C++ 程序:

1
2
3
4
5
6
7
8
9
10
11
12
// 程序清单 1.1
#include <iostream>

int main()
{
unsigned long c = 1145141919810114514;
unsigned long* long_ptr_c = &c;
unsigned int* int_ptr_c = reinterpret_cast<unsigned int*>(long_ptr_c);
std::cout << *long_ptr_c << std::endl;
std::cout << *int_ptr_c << std::endl;
return 0;
}

它的输出:

1
2
3
$ g++ main.cpp && ./a.out 
1145141919810114514
1135662034

很显然,变量 c 在内存中的二进制数值被截断了,你可以用计算器等工具算算看是怎么截断的,相信你会对你的发现很开心。C/C++ 非常接近于底层,可以直接对内存中的数值进行修改,也能够对指针的类型进行强制转换,所以用起来十分灵活。只要你知晓原理,它自然是把趁手的法宝。C 语言因此其实是一种 弱类型 的语言,类型之间的转换非常好懂——数值不变而改变系统对变量类型的认知就行,而不像 Java 那样的 强类型 语言,转换一个整形包装类对象必须要调用各种成员方法——毕竟 Java 讲究的就是简单安全,这是必要的,也是它成功的重要原因之一。当然,涉及到自动转换的时候你得时刻保持清醒的头脑,这才是重中之重。

好了,指针基础就讲到这里,我们接下来看看让指针大展身手的内存吧。

内存结构

在 C/C++ 眼中,内存分成了五个部分:全局 / 静态区、常量区、代码区、栈区、堆区

内存的模样

在讨论五大区之前,我们应该先理清楚内存是什么。内存通常指 RAM,由于 CPU 运算极快,硬盘输入输出速度相对极低,所以内存作为一个暂存数据的设备出现了:它存放需要执行的程序代码和各种由程序读入的数据,并通过流程控制不断地将数据交付给 CPU 运算。程序如何找到内存中存放的数据——比如说电子表格(它现在被 Excel 程序读入到内存中,并通过显示器输出到了你的眼中)中一个框框里保存的你的期末考试成绩呢?

显然,用编址可以解决这个问题:内存被分为若干个单元,每个单元就像是临时租用的房子,房门上贴着门牌号(内存地址),房子里住着临时搬进来的数据(注意临时性)。有的数据比较短,比如说它有 8 bit ,也就是一个字节那么大(但不能再小了),可以直接塞进一个单元。而有的数据很长,比如它有 64 bit,可能就要切开来塞进 8 个单元中。系统会根据你编写的程序以及其记录的地址将数据存入内存或从内存中取出,以达到运算的目的。而且有了地址,我们也就可以用指针来寻址,以此高效操作内存。

当操作系统读入程序时候会给程序分配一段虚拟内存,C/C++ 程序拿到这段内存就会将其划作五大区,而每个区里存放的每个数据理论上都可以被地址索引到。我们需要合理地分配管理这些数据,因此这五个区的划分根据主要是数据的类型、生存周期以及可执行程序文件加载到内存的流程。我们来一起看看吧。

全局 / 静态区

存放了全局变量和静态变量。这块内存区域在加载后大小是固定的,直到程序运行结束才释放。全局变量和静态变量的生命周期因此也就是整个程序。静态变量由高层抽象管理,全局静态变量的作用域在一个程序文件中,而局部静态变量的作用域则是在一个函数中。虽然完全不一样,但是这两种变量都是划分于同一个区之内的。

常量区

顾名思义,这里主要存放字符串常量等用 const 修饰的常量。这块内存区域在加载后,大小固定且内容不可更改。考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 程序清单 1.2
#include <iostream>

int main()
{
char* str = "hello, world";
std::cout << "address = " << &str << std::endl;
std::cout << "str = " << str << std::endl;
std::cout << "str[5] = " << str[5] << std::endl;
++str[5];
std::cout << "new str = " << str[5] << std::endl;
return 0;
}

这个是很基础的程序,是初学者都会碰到的坑。我们来看看编译并执行后会输出什么:

1
2
3
4
5
6
7
8
9
$ g++ main.cpp && ./a.out 
main.cpp: In function ‘int main()’:
main.cpp:7:20: warning: ISO C++ forbids converting a string constant to ‘char*’ [-Wwrite-strings]
7 | char *str = "hello, world";
| ^~~~~~~~~~~~~~
address = 0x7ffd5364f6c0
str = hello, world
str[5] = ,
段错误

可以看见编译器给了一个强制转换的警告,说明 "hello, world" 字面量的类型应该是 const char*,而不是 char*。由于这个字符串存放于常量区,它就是不能变的,所以后面程序执行的时候输出了 段错误 并直接退出程序了。很多情况下我们需要可变的字符串,它们需要由栈区或者堆区来存放,这个后面会说。另外,如果程序中在很多地方都有 "hello, world" 的话,常量区只会存在其一份副本,这样可以剩下很多空间。

代码区

程序编译后的代码会在内存中形成一个副本,C/C++ 中十分灵活的 函数指针 就指向这里的各个函数代码,不过函数内部的各种变量在程序运行的时候又会分配到其他地方去(比如栈区),这个后面也会细说。代码区并没有什么特别的,因此不做过多讲解了。

关于程序文件

以上三个区都是在编译时就能决定结构的内存区域,所以他们都是直接从可执行程序文件中加载的,也都不能更改其分配到的内存空间大小。程序清单1.2 中编译好的可执行程序文件里有很多东西,用 size 命令浏览一下它:

1
2
3
$ size a.out
text data bss dec hex filename
2923 704 280 3907 f43 a.out

主要有这样几个区域:

  • .text段:这里就是程序运行的二进制代码,也就是代码区中代码副本的源。另外 size 命令笼统地将 .text段.rodata段 归到一起了(用 readelf -S 可以看得更详细)。对于常量来说,有的常量经过优化后硬编码进了 .text段,成为代码的一部分;还有的常量,比如全局常量 const int ten = 10,未优化的局部常量 const int nine = 9 和字符串常量 "hello, world" 等就会被放在 .rodata段,像 程序清单1.2 示范的那样只作读取用。用 hexdump 截取其可执行文件,局部内容如下,这些字符串都会被加载进常量区:
1
2
3
4
00002000: 01 00 02 00 00 68 65 6C 6C 6F 2C 20 77 6F 72 6C    .....hello,.worl
00002010: 64 00 61 64 64 72 65 73 73 20 3D 20 00 73 74 72 d.address.=..str
00002020: 20 20 20 20 20 3D 20 00 73 74 72 5B 35 5D 20 20 .....=..str[5]..
00002030: 3D 20 00 6E 65 77 20 73 74 72 20 3D 20 00 00 00 =..new.str.=....
  • .data段:用于存放程序中已初始化的全局变量(往往非零)和静态变量。程序开始运行时读入内存的全局 / 静态区,大小不能变。

  • .bss段:用于存放程序中未初始化的各种全局变量(初值为零也可能会放到这里)和静态变量。程序开始运行时读入内存的全局 / 静态区,数值全归为零,其大小也是不能变的。所以 .bss段 其实就是提示运行时系统该分配多大的固定内存空间的,起到一个占坑的作用。

总结一下,可执行程序文件中的 .text段 对应内存中的代码区,而 .rodata段 对应内存中的常量区(但也有常量在 .text段),剩下的 .data段.bss段 则对应内存中的全局 / 静态区。

另外,编译器在编译时往往会将 .text段.rodata段 优化掉,而基本不会动全局的东西。那些未使用的非静态局部变量就是没用的,因而可以不编译;而全局变量和静态变量有可能被其他程序模块调用,所以必须完整地编译出来。

在阅读完上面的内容之后,你可以来看看这个程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 程序清单 1.3
// GNU/Linux及g++环境下编译
#include <iostream>

int main()
{
int a = 20; // 1
const int b = a; // 2
int *ptr = (int *)&b; // 通过指针访问的方式,
*ptr += 100; // 试图强行改变b的数值!
std::cout << b << std::endl;
return 0;
}

结果是 120,指针访问成功地改变了局部 “常量”b 中的数值。如果我们把注释中标为 1 的那一行删掉,然后将第注释中标为 2 的那一行改成 const int b = 20,你会发现输出结果是没有改变过的 20。仔细思考一下,这是怎么一回事呢?

改变之前的 程序清单1.3 中,系统还是给 b 分配了一块可变的栈区内存,因此可以成功改变它的值。而改过之后的 程序清单1.3 中,笔者在调试的时候发现 b 跟改变程序之前一样拿到了一块可变的栈区内存,但是一到访问它时就只能读到数值 20。应该是编译器会照常给局部常量分配栈区内存,但会对读取操作做优化,使其强行指向常量区。另外,如果这个 b 定义在全局且初始化为 20,运行时便会报错,估计就是因为编译器不会对全局变量做上述的优化。其实无论这个结果如何,都不应该去试图强行改变 const 修饰的常量,标准里并没有对这种行为作出详细规定,不同的编译器可能得到不同的编译结果。

栈区

这个是非常重要的区域,几乎所有的局部变量都会被放在这里。栈区是系统根据变量的类型自动分配释放的一块连续的内存空间,各个值之间几乎相邻(不同的操作系统下,数据之间的间隔也可能不一样)。其分配空间的方式类似于数据结构里的栈,不过这个栈区是可以随机访问的。既然栈区的各个数据的是连续的,系统必须还要知道这些变量有几个字节才能实现全自动的分配和释放,所以在这里分配到的字符串(char 数组)的内容虽然可以改变,但其内存空间大小不能改。

如果你像这样在程序中的 int main() 函数里定义了一个局部变量:

1
int a = 3;

那么这个程序运行到这里的时候,系统会先检查数值声明,知道有个变量叫 a,它的类型是有符号整形,所以它占用了 sizeof(int) 这么大的空间。于是系统在栈区为它分配了 sizeof(int) 这么大的空间,这个空间可能由 1 或多个内存单元组合而成,里面存放了数值 3。当运行到 int main() 函数末尾时,系统还记得变量 a 及其类型,可以很安全地自动将它释放掉(实质上是移动栈顶指针)。

另外,关于函数与栈区的关系,虽然函数本身的代码保存在代码区,但是函数的参数、返回值、其内部的变量也都跟上面的例子类似地保存在栈里。C/C++ 的编译器拿到了你声明的函数(只需要声明就行,通常写在头文件里),就能根据参数和返回值的类型自动决定需要在栈中分配多少空间(函数参数名可以不写而类型必写也就是这个原因),这样你只需要在开头声明一下就能在后面调用这个函数了,定义也可以写在其他文件里。当调用到这个函数的时候,系统转到函数定义的地方(代码区),再去为函数内部定义的局部变量分配栈区内存。考虑这个程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 程序清单 1.4
#include <iostream>

void change(int); // 1

int main()
{
int i = 8; // 2
change(i); // 3
std::cout << i << std::endl; // 4
return 0;
}

void change(int num)
{
num += 100;
}

这也是一个非常基础的程序。输出的结果是 8,说明变量 i 的值貌似并没有改变。我们来看看这个程序在运行时做了什么:

  1. 声明了 void change(int) 这个函数,这样系统就知道了这个函数的参数长啥样,需要分配多少字节的栈区内存来保存它的返回值和参数。
  2. 进入定义的 int main() 函数中,定义了有符号整形变量 i 并把数值 8 赋给它。
  3. 重点来了,调用函数 void change(i),系统首先查查看之前你声明的函数,知道它的返回值是 void,不需要 int main() 函数内部 分配返回值的栈区内存。然后把变量 i 赋给函数的形参,接着转到 void change(int num) 函数的定义处。注意,变量 i 在函数 int main() 中,实参赋给形参时,系统为形参 num 开辟了一块 属于函数 void change(int num) 的栈区内存,你在函数内部对这个 num 再怎么操作也不会影响到 i,因为它们压根就是两个不同的变量。
  4. 函数运算完毕,向控制台打印 i 的值,结果当然没变。

如果你真的想要通过一个函数来改变栈区中的某个变量的数值,你可以取出其指针作为函数的参数,函数拿到指针就可以对里面的数值为所欲为了。当然也可以用引用,这里不讨论。

接着谈谈字符串的事,考虑这个程序:

1
2
3
4
5
6
7
8
9
10
// 程序清单 1.5
#include <iostream>

int main()
{
char str[] = "hello, world"; // str 的类型是 char[13]
str[3] = 'A';
std::cout << "new str = " << str << std::endl;
return 0;
}

直接看输出:

1
2
$ g++ main.cpp && ./a.out 
new str = helAo, world

能够成功地改掉 str 中的值,yattaze!

它对字符串变量 str 的定义和 程序清单 1.2 有点不大一样,就是那个表示指针的 * 变成了表示数组的 []。因为变量的不同,编译器对它们做了不一样的事:如果是指针 const char*,就把常量的首地址交给 str;如果是数组 char[],在栈上开辟了 13 个 sizeof(char) 长度的空间,把 'h''e'、……、'l''d''\0' 依次存放到这段空间中,然后把这段空间的首地址交给 str。栈区中的数据是可以更改的,所以这次没有报错。

另外,'\0' 是一个特殊的字符,C/C++ 里把它看作字符串结束的标志。它其实就是 数字0 ,相同字节长度的 数字0'\0' 在内存里长一个样,只是不同的数值类型对它有不同的解释:前者是数字类型,而后者是字符类型。由于 C/C++ 是弱类型的,你可以对它自由但是不安全地操作(取决于你的编程水平)。你可以通过对指针的操作把这个玩意儿改成其他字符,不过假如在使用输出、复制等操作的时候,程序只要没读到它就会一直操作下去,虽然内存里到处都是 '\0',但是这样仍然很危险,它可能有不符合我们预期的行为。比如说你在复制一个字符串的时候,不写 '\0' 就会触发函数的越界访问,进而导致目标字符串也越界或者直接爆掉。

这个例子中的声明中可以是 str[],编译器会根据后面的字符串字面量自行决定大小,str 的类型也就是固定的 char[13]。或者在 [] 中写一个比 13 大的数字也是可以的,那会分配到更多的栈区内存,假如我们需要对 str 存放数值进行更改,只要不超过这个数字就行。再考虑以下程序:

1
2
3
4
char str[] = "hi"; // str 的类型是 char[3]。
str[2] = 'A';
str[3] = '\0';
std::cout << str << std::endl;

你很有可能得到符合预期的输出 hiA ,但是这样做是 极其错误 的!因为它使用了根本不属于它的一块空间来存放那个 '\0',俗称 越界访问。假如你更狂野一些,把很多的字符拼接在 str 的后面,这甚至会改掉另一个存储于栈区上的变量中的数据——要知道栈区里大家都是邻居,虽然 C/C++ 允许你打砸抢,但是这么做显然是不对的,你把别人扔出去并且还霸占了人家的屋子,属实带恶人。更可怕的是接下来的运算中,系统还把这个被霸占了的内存空间看作是之前的那个类型,其内在数据却被你改变了,这会带来不可估量的后果。

假如说我们写一个程序来接收控制台的输入,那么字符串变量究竟该设为多少?我的输入可能有很长很长,也可能只是一个简单的单词,分配在栈区中的定长字符串没法满足这样的需求,或者说需要一些麻烦的处理才能解决这个问题。有没有办法动态地改变字符串分配到的内存长度呢?有,那就是需要申请才能使用,并且一定要在使用完后释放的堆区。

堆区

堆区与栈区不同,堆区是可以在运行的时候动态分配的内存区域,其中的内存经过 malloc() 函数(C 风格)或者使用 new 关键字(C++ 风格)等方式来手动开辟,分配完成后将这块内存的首地址值赋给一个指针,这个指针变量一般保存在栈区上。堆区分配的内存不像栈区那样连续,可能是东一块、西一块的,毕竟内存可以变长或者变短,程序编译的时候完全不知道自己能占用多大的空间。假如动态的内存像栈区那样紧邻,那数据肯定要经常挪窝,很显然这样做的开销是很大的。那么区分栈区和堆区的原因就是:全是栈区没法动态扩充或缩小内存空间,而全是堆区内存的使用率又太低,所以要分开并结合它们来存放适用于不同场景的数据。另外,内存堆区与数据结构的堆是两码事,不要混淆了。

有借有换,再借不难——堆区上的内存空间在开辟分配过后必须要被使用,使用完了必须要释放。对于栈区而言,各个变量的生存周期都很好懂,系统完全知道什么时候去分配或释放空间。但是对于堆区,系统无法知道你什么时候会开辟一块堆区内存,也不知道你什么时候就不需要再用到它们。如果一旦内存满了就瞎给你释放内存的话,那你肯定会丢失有用的数据的。所以程序员必须手动调用 free() 函数(C 风格)或者使用 delete 关键字以及 RAII机制(C++ 风格)来释放内存。假如你申请分配了内存空间却从不释放,那么你的计算机很快就吃不消了,它会被满是垃圾的内存拖垮。Windows 系统的任务管理器会一片红,而 GNU/Linux 系统则会直接杀了这个进程——肯定都是你不愿看到的情况。

申请内存或许不难,但是释放内存可是真的有些恐怖,因为有着 内存泄漏 以及 二次释放 等在 C/C++ 中一直都很让人头疼的问题。

  • 内存泄漏 其实就是上一段说的有借无还,即使你是个有一定经验的 C/C++ 开发者,你也不能完全保证你能释放每一处你申请的内存,这个真的是个管理难题。比如说你把申请和释放的语句写在了两个不同的函数中,然后指针作为函数参数传进来,你不能保证在头脑不清醒的情况下能完全做到一一配对。假如你开发了一个 web 服务器,这种软件的特点就是要稳定且一直保持运行状态。而一旦有内存泄漏的 bug,你的服务器会占用越来越多的内存,直到系统爆掉或进程被杀,那会带来非常恐怖的损失。
  • 还有 二次释放,比如你在函数内部的开头申请内存并用一个指针保存它,在结尾逐一释放掉这个内存以保证这个指针只在函数内部有效。这样看似很合理,也实质上就是上面说的 RAII机制 ,但是万一你要对这个指针做一些赋值操作(这是很常见的),把它赋给其他指针——那完了,如果函数的调用者对你的实现完全不知情,而你又没把这些操作封装好,他很有可能对那个被赋值了的指针释放内存,再加上你的释放就会造成 二次释放。运行时的系统是不可能在发现 二次释放 后还保持沉默的,道理很简单:释放后的内存空间并不属于这个程序,万一有其他程序申请了相同一块内存,系统可能会因为 二次释放 而错误地释放掉新申请的内存,这显然会造成恐怖的后果。现在你很幸运,在某次运行中没有让其他程序申请到这块内存,却让系统发现了你在这里会对同一块内存释放两次,你说它能保持沉默吗?

至于 RAII机制,这是 C++ 内部实现了的一种机制,如果你没听说过,推荐你去搜搜看。简单来说,就是 C++ 的面向对象设计中,在一个对象的生命周期结束时(比如函数内定义,碰到函数结尾时),系统会自动调用这个对象的析构函数,所以我们可以把 delete 关键字语句放在这里,并把 new 关键字放在创建变量时的构造函数中,以实现对对象申请内存的自动管理,这样可以让程序简洁得多。不过你依然得时刻保持头脑清醒,考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 程序清单 1.6
#include <iostream>

class A
{
private:
int *arr;
public:
A()
{
std::cout << "A()" << std::endl;
arr = new int[10];
}
~A()
{
std::cout << "~A()" << std::endl;
delete arr;
}
};

void func(A obj)
{
std::cout << "void func(A obj)" << std::endl;
}

int main()
{
A a;
func(a);
return 0;
}

看看输出吧:

1
2
3
4
5
6
7
$ g++ main.cpp && ./a.out
A()
void func(A obj)
~A()
~A()
free(): double free detected in tcache 2
已放弃

我们成功地看到了系统因为二次释放问题而放弃了治疗。

仔细看一下,我们在 int main() 函数里构建了一个对象 a,也就是在构造函数 A()new 了一块堆区内存,然后把它的首地址赋给私有成员变量 arr。析构函数 ~A() 则负责 在对象 a 的生命周期结束时 自动将其申请的内存 delete 掉,这就是上述的 RAII机制。但是我们发现析构函数被调用了两次,究竟发生了什么?

在构造完对象后我们调用了一次函数 void func(A a),坏就坏在这个函数的参数上。当这个函数被调用时,系统给这个函数的形参 obj 开辟了一块栈区内存,然后把实参 a 存储在栈中的数据复制了一份到其中,所以形参 obj 这个对象也有一个具有同样数值的私有成员变量 arr(记住这个成员变量保存在栈上),也就是说实参 a 和形参 obj 的成员 arr 都指向同一块内存。重点来了,形参 obj 的生命周期到这个函数的结尾就结束了,这时候系统会自动调用其析构函数,也就是把对象 a 申请出来的那块堆内存给释放掉了。再回到 int main() 函数中的结尾,系统在调用 a 的析构函数时发现问题不对劲,却只能对第二次内存释放表示无能狂怒,最后放弃治疗。

实际上上述的栈区内存复制叫做 浅复制,而连带堆区内存复制叫做 深复制。全家桶级的做法是写个函数 A clone(const &a) 来实现 深复制,然后重写复制构造函数 A(const A &a) 实现参数赋值等情况下的的 深复制,最后重载符号 = 来使你的赋值操作更简单。

那其实这个也是有简单且更合理的解决方法的,那就是把函数 void func(A obj) 写成这样:

1
void func(A *obj);

调用的时候实参取指针 &a 就行。这样写,传到函数内部的只是一个指针,而指针的生命周期结束时是不会触发 RAII机制 的。而且最重要的是节省了栈内存复制带来的开销,这就是指针的好处。还有,在函数内部不需要改变参数内容的时候,应该尽量添上 const 关键词来修饰,例如这么写:

1
void func(const A *obj);

这样声明的话,函数调用者就能知道传进去的对象是只读的,完全不用担心会出什么岔子。而且你在定义函数的具体内容的时候,一旦修改了对象的值就有可能被编译器警告或直接不通过,大大减少了出 bug 的机会。在你只需要读取对象内容的时候应尽量使用 const 关键字。

还可以用引用(C++ 限定)来优雅地解决这个问题,函数声明就会是这样:

1
void func(const A &obj);

引用实质上是指针的封装,其生命周期完全依赖于实参,因而不会有 二次释放 的问题。调用这个函数的时候直接把实参 a 传进去就好,既方便又美观,这也是 C++ 推荐的做法,因为 C++ 不建议直接操作裸指针。

堆区说得差不多了,让我们再回到字符串的问题上吧。考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 程序清单 1.7
#include <iostream>

void print_str(const std::string *str);

int main()
{
std::string str = "hello";
// 在字符串后追加字符串。
str.append(", ").append("world");
print_str(&str);
// 输入一个新的字符串。
std::cout << "Please input a new string: " << std::flush;
std::getline(std::cin, str);
print_str(&str);
return 0;
}

void print_str(const std::string *str)
{
std::cout << "len = " << str->length() << std::endl;
std::cout << "str = " << *str << std::endl;
}

这个程序里使用了 C++ 标准库中的 std::string,这个就是全世界最顶尖的秃子们经过反复磋商后给你封装好的 难用的 字符串类型。函数 void print_str(const std::string *str) 很显然就是用来向控制台打印字符串的长度及其内容的。我们来看看输出:

1
2
3
4
5
6
$ g++ main.cpp && ./a.out
len = 12
str = hello, world
Please input a new string: El Psy Congroo
len = 14
str = El Psy Congroo

std::string 的内部是使用堆区内存来实现的,所以可以动态地向这个字符串的后面追加新的字符串,它的 length 也会随之增减。所以,配合标准库中的 std::geline() 函数就可以将输入流的内容放进变量 str 中,这样无论你从控制台输入一行多长的字符串都行。另外,std::getline() 第二个参数接收的是 std::string 的基础类的引用,这样就不需要考虑二次释放的问题了。std::string 的使用还是要注意的,因为官方重载了它的各种赋值和复制运算,所以虽然不容易出现 二次释放,但是使用不小心就会创造出很多字符串的副本从而降低运行效率,这往往与 C/C++ 的高效率应用场景相违背。

内存结构相关的基础知识到这里也就结束了,现在该看看带有垃圾回收机制的语言是怎么看待内存的了。


看山不是山:内存管理与高层抽象的矛盾

垃圾回收机制的出现主要是来解决程序员需要手动释放堆区内存这个切实而又麻烦的问题的,事实上任何平台、任何语言写出来的程序都需要处理内存管理问题。C++ 中,在 C++11标准推出智能指针之前(当然 Boost 库推出更早一点),罕有漂亮的设计模式能完美解决这个问题的(那之后其实也不能算有)。其实智能指针里也就 unique_ptr 开销小且真正安全。那应对这样的窘境,很多新兴语言被设计出来了,它们基本上都有一个共识:不要像 C/C++ 那样触碰太多底层的东西,而是做高层抽象,利用抽象出的平台来托管内存。

渐向高层的抽象

稍微有点计算机常识的都应该知道,现代的计算机本质上还是图灵口中的那个处理长而有限的纸带的呆瓜。这个纸带其实就是 0 和 1 的组合,也就是所谓的机器语言。机器很乐意读写机器语言,但是这对于人来说实在太费神了,于是有些秃子针对不同的平台以及架构将机器语言里的一些固定组合抽象成了一些指令,这就是第一层抽象出的汇编语言。

汇编仍然不便,因为它是非结构化的,东一坨西一坨的地址跳转能把可怜的猴子们整疯,所以再往后出现了结构化的语言:C 语言。C 可以说是真正的里程碑,它将一些固定的汇编流程再次抽象,结构化成循环、条件转移等控制语句,使得流程控制简单且符合人类的思维习惯,深深影响了后来的语言。C 的最大优点就是贴合机器的底层运作,而且提供给开发人员的规则很简明,可谓是一把贝爷的小刀——小刀人人都会耍两下吧,但问题在于,你不是贝爷你也耍不出大名堂来。由于没有面向对象、泛型等语言特性,写 C 的猴子们要用宏等语言提供的工具手动写大量的重复代码,仍然在摧残着猴子们的身心健康。于是,伟大而蛋疼的先驱 C++ 诞生了。

C++ 为了解决 C 里的重复劳动问题,将相似结构的数据编集成类,使用继承等面向对象的机制处理代码复用问题。泛型模板更不用说了,C++ 里的模板本质上就是文本替换,即先把占位类型写在声明和调用中,等编译的时候再把实际的类填进去(这也就是模板必须写在头文件里的原因)。仔细想想,其实这里又是做了一层抽象,面向对象将时间万物抽象成类,同类型的物品有着相似结构的一捆数据,这其实非常符合人类观察有限特征的思维习惯。但是 C++ 到底是个命运多舛的先驱者,它在尝试使用新的技术时被包袱绊了大跟头。C 里有很多的特性并不适合抽象,而 C++ 却坚持要向上兼容 C 的代码以提高支持率,因此获得了很多 C 遗留下来的包袱(在 C 中却不是问题)。C++ 为了修补这些问题花了很大的功夫,结果却是一堆细枝末节、不符合人类思维逻辑的莫名其妙的“语言特性”。这说明它的抽象还不到位,还不能用更加通用的规则来处理更多的情况,所以诸如 Python 和 Java 这样更高层的语言流行起来了。

Python 以解释器为运行环境,在系统和代码之间分出了一个层次;Java 则为了可移植性,同样地抽象了操作系统底层接口而设计出了 Java 虚拟机。这种分层设计对于猴子们来说是爽得不得了,一份代码可以到处跑,但是一旦切实地跑在机器上就会占用大量的 CPU 和内存资源——猴子和机器总得死一个。Python 和 Java 都封装了底层的指针,不允许猴子直接操作它们。针对堆内存管理的问题,它们都各自使用了不同技术的垃圾回收机制。Python 的垃圾回收机制是为对象添加引用计数,一个对象自诞生起计数为 1,对象传递时计数递增,销毁时计数递减,当计数为 0 时说明没有任何代码需要再使用它,它占用的内存被标记,由垃圾收集器分代释放。如果你分析过 C++ 里 shared_ptr 的实现原理,你会恍然大悟这种设计基本上就是一码事。Python 的垃圾回收和 shared_ptr 都是还算不错的设计,但是都无法避免计数带来的开销,而且必须解决循环引用和多线程原子计数的问题。Python 完全封装了对象还好(也付出了惨痛的运行时代价),C++ 就必须自己单独处理异常。至于 Java,它的虚拟机平台维护对象的引用状态,放任对象在内存中滋生,只要对象不再被引用就说明它没用了而定期进行垃圾回收。类似地,为了解决循环引用的问题,Java 查看一个对象是否为根部可达而决定是否释放它。总之垃圾回收就是在后台放一个时不时来工房里看看的狗管理,看你做完事之后一个小时没动静了就直接把你踢出去。如果能让工人们做完事之后自觉退出,而不是再摸整整一小时的鱼的话,那就是不需要狗管理的,这显然不是件容易事。

再对比 C++ 和 Java,C++ 奉行的是零成本抽象,模板的设计归根结底就是大量文本的替换,这样即使会有代码膨胀的弊病,运行时开销也不至于太高;而 Java 同样作为支持面向对象和泛型的语言,它的思路有所不同,不仅是在运行系统层面做了抽象,而且在语言设计上抽象成了十足的面向对象:所有的对象都继承自 Object 这个类,凡事无论大小,哪怕是纯过程的算法也必须包装成类。这样设计非常有哲学意义,因为无论是泛型还函数式编程这些高级思想都是用继承基类来实现的语法糖。对比泛型设计,C++ 可以在模板中直接调用某个类的某个成员函数或者是操作符,只有实现了或是重载了这些函数或操作符的类才能通过编译(文本替换嘛);Java 的泛型是“伪泛型”,在 JDK5 没有泛型之前你可以将需要泛化的类直接写成 Object 类,传进来的对象由于必定继承自 Object ,可以在语言特性上进行“类型擦除”,最后在调用接口处手动强制转换为原来的类型即可实现 Java 的泛型编程。而正式出现泛型机制后只是不再需要在调用处手写强转了而已,因此这彻头彻尾是用继承实现的语法糖。C++ 中的函数式是编译层面上将 lambda 表达式做类型包装并重载其括弧运算符,其实在那之前用 C 的函数指针也能做到,只是将函数包装得像个对象而已;Java 里则是接口和回调的配合,你需要填写一块接口的匿名实现类供其他代码调用,Java 把这个调用像模像样地写成了箭头表达式,其实也是继承实现的语法糖。Java 的抽象使得一切都能用继承解决,非常地具有普适性,就是维护很多类的代价太庞大了。

抽象与平衡点理论

看到这里你应该大概对我屡次提到的“抽象”有点数了:它贴近人类的思维习惯,并且忽略我们不想看到的东西。越高层的抽象越简明且越能被人接受,而且能涵盖更加广阔的图景,越底层的则越能被机器接受。抽象是有代价的,那就是对机器更加不友好。机器不可能直接看得懂你给它传输的一张图片,所以中间要有不同层级来进行加工。每一次在抽象的时候都要使得本层能够总结下层里的一些共性的东西,用更简单的策略组合来应对更多的情况。类似地,TCP/IP 四层网络模型也遵循着抽象的理念。

因此,抽象设计是内存管理必须要去思考的哲学,可要设计得好可并不容易。事实上例如,我自己在做 Java GUI 以及 Java 服务器开发的时候经常对资源分配头疼不已,且并没有什么真正有效的调优手段——JVM 全托管了,基本不给你机会的,顶多给你两个启动参数意思一下。开发 Linux 内核的林纳斯大神因此看到 Java 直接大呼可怖,对于他来说还是拿小刀抠长城比较好,至少每个细节都是可控的,小刀在他手里是大道至简的神器,啄就完事了。但不是每个人都是大神,可见合理的抽象既要使得代码对猴子友好,也能对机器友好。我们必须找到一个平衡点来协调这个友好度的倾向,这个平衡点是因人、因目的而异的。有些数理研究者逻辑思维好,但是不想去一天到晚整那个内存释放的东西,而需要快速将算法变现,所以 Python 一直到现在热度也没消减。有些猴子也懒得针对不同硬件做各种调优,而想把开发重心放在业务逻辑以及架构设计上,所以他们选择了 Java。Linux 内核开发不易选用 C++,由于其规则不如 C 简明,千人万风格,团队协调时接耦的成本很高,因此难以适合于这种重量级的开源项目——黑客众只想把重心放在技术上,他们才懒得做什么管理事务呢,难用的干脆不用好了。虽说 C++ 自诞生以来设计上就存在包袱和缺陷,但其在各种领域生根发芽,现在仍无法被替代,原因也有不少:一是它好歹抽象比 C 高,开发起来相对轻松一点(劈里啪啦敲得爽,调试火葬场);二是它生态成熟,军事、银行、桌面软件等等,无论哪里都能看到它的身影,直接推翻这些高楼大厦并在短时间内另起炉灶简直不可能;三是它能直接管理内存,这是其他大多数语言无法做到的,而这个对于调优来说太重要了,尤其是必须充分利用硬件资源的游戏以及服务器软件,用 Python 或者 Java 这些有垃圾回收器的语言来实现注定就不靠谱。

所以说各种语言的平衡点各有千秋,没有千秋的都消逝在历史长河中了(也不过几十年)。好的工匠应该应对不同的状况选用不同的工具,起子往往用来拧螺丝,斧头适合砍木柴,没有哪一种工具能轻松地应对所有情况的。虽然 C++ 最有成为万能工具的资格,但它的缺陷实在太多了。当然,工具也是要升级换代的,老一套迟早要被设计更好的新工具替代。下面我从设计角度谈谈一个年轻而有潜力的语言——Rust,如果有谁能替代 C++,那就非它莫属了。


看山还是山:不错的平衡点

Rust 程序设计语言

Rust 是 Mozilla 基金会研发的一门系统级编程语言,Rust 编译器是在 MIT License 和 Apache License 2.0 双重协议声明下的免费开源软件。它吸收了很多现代化的设计理念,比如函数式编程、泛型编程。作为配套的管理器 Cargo 对标 Python 的 pip、JavaScript 的 Node.js,以及 Java 的 Gradle 等构建工具集(可怜的 C/C++ 到现在都没有个统一的包管理器)。“系统级”强调了它能像 C/C++ 那样运行时低开销,也就是说它一定没有可怖的垃圾回收,且能在合理的内存控制下运作。它把设计的重点放在了编译检查上,以逻辑证明过的设计将绝大多数的运行时问题(比如内存泄漏)在编译期就直接踢出门外,也就是说具有运行时潜在风险的代码甚至往往不能通过编译。下面来看看它的一些优秀设计点。

所有权模型

C++ 开发必须要关注一个很不错的智能指针 unique_ptr,它对裸指针进行了一层简单包装,禁用了赋值和复制等操作,也就是不允许猴子们把它瞎传到十万八千里之外,而只提供部分接口来限制其生命期。传递时,它将自身独占的指针解放,然后标记自己空空如也,即使析构了也不会释放任何内存,此时其他接管指针所有权的 unique_ptr 通过其解放出的裸指针来构造自身,形成了新的独占。这样,就保证了无论何时 unique_ptr 都至多独占一个指向堆内存的指针,避免了二次释放和内存泄漏的问题。没有计数器,它的开销就真的非常低,性能上贴合原生裸指针,这种抽象的设计是很安全的。

Rust 的设计里直接大胆地将所有结构体都强怼成类似于 unique_ptr 模型,无论是在堆区还是栈区,每个结构体的名字都天生与所有权结合。假如说你在一个块中创建了一个结构体,在执行两条语句后它作为实参传递到了一个函数中,那么在这条函数调用语句后你绝不能再在当前块中使用这个结构体的名字,因为所有权转移出去了,作为实参的名字内部已经空空如也。现在那结构体的内存的所有权在函数的形参手上,假如函数结束时并没有将其作为返回值返回,也就是将所有权移交回调用函数的块中的话,那么形参独占的内存就会被 RAII 机制释放,这相当于 unique_ptr 的析构。当然这样必须转移才能使用是非常蛋疼的,总不能每次调用都得返回一堆自己还要用到的参数吧,于是 Rust 仿照 C++,语法上也支持类似 C++ 的引用设计,包装了指针传递。Rust 把安全放在第一位,它的引用因此带来了生命周期和参数配平的新问题,但配合解引用强制转换机制,整体上句法还是十分美观的。

有了所有权,复制和移动等语义都会变得非常清晰,这里就举闭包的例子好了。简单来说,闭包就是一个携带数据的函数,它的数据往往由 lambda 表达式根据上下文捕获而得。只要使用了参数中不存在的名字,闭包就会把上下文中能找到名字捕获进其中,然后就可以将闭包传递给其他函数或线程。Rust 的闭包可以用 move 关键字修饰,明确地表明被捕获的变量所有权已被移到闭包中,同级块中后续部分就不能使用这个名字,在语法层面上杜绝了数据竞争等难以排查的问题。C++ 的 lambda 表达式默认是捕获复制体,也可以捕获引用(非常不安全),却到 C++14 才支持移动语义。而 Java 之流只能捕获引用,你想象不到闭包执行完了之后局部变量竟然还存在是多么恐怖的事。另外,Rust 可以利用迭代器和链式调用映射函数将动态数组转换成 HashMap 这样的数据结构,此时移动语义可以清晰的表明原结构已被消费,高效、安全且不模棱两可。

默认不可变设计

第一次接触 Rust 的时候你肯定会奇怪为什么定义的变量默认都是不可变的,貌似十分地反直觉,甚至有些反人类,其实它只是反经验罢了。一般来说,程序中的量无非就变量和常量。但是经过统计,很多我们定义出的变量都是不需要改变值的,也就是说它们只需要只读即可,即可设为“不可变变量”或是“常量”(可统称为“不可变量”),“变量”的“变”字突出它只有在运行时才能知道结果。与其写下满大街的 const 或是 final 关键字,不如默认将变量设计为不可变,这样十分方便 Rust 的编译器进行调优。另外,真正的常量往往都是编译期就能计算出并且内联进代码内部的(即 .text段)。对比几种语言,Rust 里可以用 const 关键字来表示这样的常量;C 语言可用宏定义来实现内联常量(全是坑);而 C++,由于宏定义不好处理复合类型,它一开始就想让 const 关键字替代宏定义,设计出的结果是 const 关键字修饰的量既可能表示成内联常量,也有可能表示成不可变变量,尔后又弄了一个 constexpr 关键字加以区分,这样的抽象设计极大地增加了学习成本(不过现在 C++ 甚至整出了 constexpr new 等一堆编译期黑魔法,你说神奇不神奇)。所以在这一方面,Rust 的简洁设计是可圈可点的。

除此之外,不可变量在函数式编程中有着重要的意义。我们一般在程序中用一个可变变量来表示某种状态,它往往占有一个内存空间,通过某种过程改变这个内存空间的值就可以切换状态了。而函数式思想中的函数不依赖于这样的状态,它往往用返回的新的不可变量,也就是用新的内存空间及其值来替代掉原来的状态,以此来达到切换状态的效果。函数式编程在过去几十年里一直不是主流的编程范式,因为它会浪费大量的内存。但是现在它非常适合于网络编程,因为不可变量不会在你眼皮子底下改变它的值,所以也就不需要加锁,直接从源头上干掉了某些让人掉光头发的死锁问题。所以,默认不可变也是 Rust 中体现并发安全的一个方面。

异常的处理

异常这个设计可以说是非常糟糕的玩意,C++ 和 Java 里都有。看起来好像不错,用 trycatch 包住代码块就行了,但是变量与代码块之间的层次安排恶心得不得了,还必须得大量浪费运行时资源来捕获它,因此 Google 的某些 C++ 项目中直接禁用了异常。函数可能会运行失败,出现的错误一般来说分两种,一种是你“网线一拔,恩怨去他妈”的硬件问题,这是在软件里再怎么处理也恢复不了的,属于不可恢复的机器异常(Java 称之为 Error);而另一种可以恢复的、逻辑上的异常就可以通过内层抛出异常对象以及外层捕获的机制,使得程序能够在外部调用层作出相应的处理。异常机制这么设计,说白了就是早年设计函数的返回值时返回值最多只能有一个且只能有一种类型,所以要想让函数多一种异常退出的状态,那就得请出满天飞的 throw 关键字和各种异常对象了。假如像 Python 那样能够返回元组的话,元组的一个元素放对应返回值类型的结果,另一个放表明函数运行状态的常量整数,函数调用方只有在整数为零时才取出返回的结果,其他情况下看整数对应什么异常(列一个常量整数列表),全部这么设计的话就彻底不需要捕获机制了。事实上 C/C++ 和 Java 都可以这么做,比如定义一个泛型 struct Result,有着上述的两个公有成员即可,开销不会太大,毕竟绝大多数代码是不需要处理异常的。但是由于历史和生态问题,它们 trycatch 的包袱已经扔不掉了。由此,Rust 作为新生语言不惧而立,它支持泛型的枚举,故在标准库里规定了一对表示结果的枚举,如下:

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

因为是枚举,函数里将它作为返回值时,运行成功返回 Ok(T),调用者可以将 T 类型返回值取出继续处理代码逻辑;失败时则返回 Err(E) ,可以方便地取出 E 类型的对象作为错误标识,以此采取不同的处理措施来清理现场。此外,Rust 还使用语言特性的 match 关键字来进行函数式映射,可以直接把枚举的两种结果用箭头映射到代码块上,非常符合人类的思维逻辑,这比 if else 控制块要美观得多。

另外,由于强制将可能出现异常的返回结果进行包装,加上 Rust 强到吓人的编译期所有权逻辑检查和 Option 枚举类型,Rust 语言自始至终都不需要出现 null 关键字,完全杜绝了 Java 猴们深痛恶绝的空指针问题。

如何学习 Rust

Rust 对标的是 C/C++ 这种可以进行系统级开发的语言,它的抽象平衡点偏向于机器,因此有些设计会显得反直觉,学习前要有被编译器血虐的准备。说实在的,作为程序员是必须要去了解机器内部运作机制的,不然和满大街三十天出师的XX语言工程师有什么区别呢?Rust 提供了很好的基础设施,在不让你手操指针的情况下就能做到内存安全且高效的编程。即使你在现实工作中完全用不到 Rust,去想想为什么它的某些语法会如此设计,往往也能收获不少。如果你在修习完 C/C++ 之后来看看 Rust,你一定会惊呼其中设计的美妙。

所以说,如果你很感兴趣且很有闲工夫,建议在有编程基础后去看看官方的 The Book,这是入门指定书目。Rust 是一门新兴语言,不应该存在任何包袱,所以你要注意它随时都可能抛掉不合理的设计。目前 Cargo 提供的库也不算很全,只能说不妨自己加入其中,成为开源世界的贡献者吧。更何况 Rust 的社区设施以及包管理设计得都很不错,做好了发布一下,如果能帮助到其他人,那成就感能飞到天上。


最后的总结

由于最近时间比较紧,我自己也在不断学习中,所以写这篇文章前前后后跨了一个多月。顺便考虑到文章的结构完整性,我也就不脸厚地拆成三篇文章了,低调地偷偷更新一下。

文章第一部分着重在原理层面剖析内存分布,其实有了这个基础,你会发现很多语言中的一些古怪的特性都可以解释了。第二部分讨论了抽象与内存的关系,算是我浅浅地学习了好几门语言之后总结出的一些哲学层面的的东西。前两部分里一些概念性的东西作为第三部分的铺垫,先加深读者对内存的理解,再引出 Rust 中一些我十分欣赏却有些反直觉的设计,意在它们都是有据可循的。尔后其实全是在安利 Rust 语言,反而有些脱离主题的内存问题了,所以我没占用太大的篇幅来继续讲它。

其实我还有一些东西想讲,比如同样的逻辑是写法多好还是写法少好,还有强弱类型以及自动类型推导的发展趋势等等,这些又是不小的话题了,改天再说吧。


今年的夏天根本不热,兴许是洪水作的妖吧。这是第五片星星。