作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
彼得Goodspeed-Niklaus
验证专家 在工程
8 的经验

彼得, 理学士(优异), 是一个专业的Python/Django开发人员谁也写了一个奇特的处理器模拟器在生锈.

专业知识

分享

一开始,有C. 在C语言中,有三种类型的内存分配:静态、自动和动态. 静态变量是嵌入在源文件中的常量, 因为他们已经知道了尺寸,并且永远不会改变, 它们并不都那么有趣. 自动分配可以看作是堆栈分配——在进入词法块时分配空间, 并在该块退出时被释放. 它最重要的特征与此直接相关. 在C99之前,自动分配的变量需要在编译时知道它们的大小. 这意味着任何字符串, 列表, map, 任何由这些衍生出来的结构都必须存在于堆上, 在动态存储器中.

消除垃圾收集器:RAII方式

动态内存由程序员使用四个基本操作显式地分配和释放:malloc, realloc, calloc, 和自由. 前两种方法不执行任何初始化,内存中可能包含无用的东西. 除了free之外,它们都可能失败. 在这种情况下, 它们返回一个空指针, the access of which is undefined behavior; in the best case, 你的程序崩溃了. 在最坏的情况下, 您的程序似乎可以工作一段时间, 在爆炸前处理垃圾数据.

这样做有点痛苦,因为你, 程序员, 独自负责维护一堆不变量,这些不变量在违反时导致程序爆炸. 在访问变量之前必须有一个malloc调用. 在使用变量之前,必须检查malloc是否成功返回. 在执行路径中,每个malloc必须有一个空闲调用. 如果为零,则内存泄漏. 如果超过一个,您的程序就会爆炸. 释放变量后,可能不会再有访问尝试. 让我们看一个实际的例子:

Int main() {
   Char *str = (Char *) malloc(7); 
   toptal“strcpy (str);
   Printf ("char array = \"%s\" @ %u\n", str, str);

   STR = (char *) realloc(STR, 11);
   strcat (str。”.com”);
   Printf ("char array = \"%s\" @ %u\n", str, str);

   免费(str);
   
   返回(0);
}
$ make run
GCC - 0 cc.c
./c
Char * (null终止):total @ 66576
Char * (null终止):total.Com @ 66576

这段代码虽然简单,但已经包含了一个反模式和一个有问题的决策. 在现实生活中, 永远不要将字节计数作为字面量写出来, 而是使用sizeof函数. 类似的, 将char *数组赋值为所需的字符串长度的两倍(比字符串长度多一倍), 考虑到无效终止), 这是一个相当昂贵的手术. 更复杂的程序可能会构造更大的字符串缓冲区, 允许字符串大小增长.

RAII的发明:一个新的希望

至少可以说,所有的手工管理都是不愉快的. 在80年代中期,Bjarne Stroustrup为他的全新语言c++发明了一种新的范式. 他称之为资源获取即初始化, 其基本见解如下:可以指定对象具有构造函数和析构函数,这些构造函数和析构函数将在适当的时候由编译器自动调用, 这提供了一种更方便的方式来管理给定对象所需的内存, 这种技术对于内存以外的资源也很有用.

这意味着上面的例子,在c++中,要清晰得多:

Int main() {
   Std::string STR = Std::string ("toptal");
   std::cout << "string object: " << str << " @ " << &str << "\n";
   
   STR += ".com”;
   std::cout << "string object: " << str << " @ " << &str << "\n";
   
   返回(0);
}
$ g++ -o ex_1 ex_1.cpp && ./ ex_1
字符串对象:total @ 0x5fcaf0
字符串对象:toptal.Com @ 0x5fcaf0

没有手动内存管理! 构造字符串对象, 是否调用了重载的方法, 并且在函数退出时自动销毁. 不幸的是,同样的简单也会导致其他的并发症. 让我们来看一个详细的例子:

vector read_行_from_file(string &file_name) {
	vector 行;
	字符串行;
	
	file_name . h和le(文件名.c_str ());
	而($ $ file_h和le.好() && !$ file_h和le.eof ()) {
		getline ($ $ file_h和le线);
		行.push_back方法(线);
	}
	
	$ file_h和le.close ();
	
	返回行;
}

Int main(Int argc, char* argv[]) {
	//从第一个参数获取文件名
	字符串file_name (argv [1]);
	Int count = read_行_from_file(file_name).大小();
	cout << "File " << file_name << " contains " << count << " 行.";
	
	返回0;
}
$ make CPP && ./ c++ makefile
c++ - 0 c++ c++.cpp
makefile文件包含38行.

这一切看起来都相当简单. 向量线被填充、返回并调用. 然而,被 高效程序员 谁关心性能, 这让我们感到困扰:在返回语句中, 由于在玩的值语义,向量被复制到一个新的向量, 在它被摧毁前不久.

这在现代c++中不再是严格正确的. c++ 11引入了move语义的概念, 在这种状态下,源被保留在一个有效的(这样它仍然可以被适当地销毁)但未指定的状态. 对于编译器来说,返回调用是一种非常容易优化以移动语义的情况, 因为它知道源头将被摧毁在任何进一步的访问之前. 然而, 这个示例的目的是演示为什么人们在80年代末和90年代初发明了一大堆垃圾收集语言, 在那个时候,c++的移动语义是不可用的.

对于大数据,这可能会很昂贵. 我们来优化一下,返回一个指针. 有一些语法变化,但其他方面都是相同的代码:

实际上, Vector是一个值句柄:一个相对较小的结构,包含指向堆上项的指针. 严格地说,简单地返回向量不是问题. 如果返回的是一个大数组,那么这个示例的效果会更好. 因为试图将文件读入预分配的数组是没有意义的, 我们用向量代替. 请假装这是一个不切实际的大数据结构.

vector * read_行_from_file(string &file_name) {
	vector * 行;
	字符串行;
	
	file_name . h和le(文件名.c_str ());
	而($ $ file_h和le.好() && !$ file_h和le.eof ()) {
		getline ($ $ file_h和le线);
		行->push_back方法(线);
	}
	
	$ file_h和le.close ();
	
	返回行;
}
$ make CPP && ./ c++ makefile
c++ - 0 c++ c++.cpp
分段故障(堆芯)

哎哟! 现在行是一个指针, 我们可以看到,自动变量的工作原理与宣传的一样:vector在离开作用域时被销毁, 使指针指向堆栈中的向前位置. 分段错误只是试图访问非法内存, 这是我们应该预料到的. 仍然, 我们希望以某种方式从函数中获取文件的行, 最自然的事情就是将变量从堆栈中移到堆中. 这是用new关键字完成的. 我们可以简单地编辑文件中的一行,在其中定义行:

vector * 行 = new vector;
$ make CPP && ./ c++ makefile
c++ - 0 c++ c++.cpp
makefile文件包含38行.

不幸的是,尽管这看起来很完美,但它仍然有一个缺陷:它会泄漏内存. 在c++中, pointers to the heap must be manually deleted after they are no longer needed; if not, 一旦最后一个指针超出作用域,该内存将不可用, 并且直到操作系统在进程结束时管理它才会恢复. 习惯的现代c++会在这里使用unique_ptr,它实现了期望的行为. 当指针脱离作用域时,它删除所指向的对象. 然而,这种行为直到c++ 11才成为语言的一部分.

在这个例子中,这可以很容易地修复:

vector * read_行_from_file(string &file_name) {
	vector * 行 = new vector;
	字符串行;
	
	file_name . h和le(文件名.c_str ());
	而($ $ file_h和le.好() && !$ file_h和le.eof ()) {
		getline ($ $ file_h和le线);
		行->push_back方法(线);
	}
	
	$ file_h和le.close ();
	
	返回行;
}

Int main(Int argc, char* argv[]) {
	//从第一个参数获取文件名
	字符串file_name (argv [1]);
	vector * file_行 = read_行_from_file(file_name);
	int count = file_行->大小();
	删除file_行;
	cout << "File " << file_name << " contains " << count << " 行.";
	
	返回0;
}

不幸的是, 随着程序扩展到玩具规模之外, 很快,判断指针应该在何时何地删除就变得更加困难了. 当一个函数返回一个指针时,你现在是否拥有它? 当你用完它后,你应该自己删除它吗, 或者它属于某个数据结构,稍后会被立即释放? 如果在某个方面出错,就会导致内存泄漏, 如果在另一个数据结构中出错,你就会破坏有问题的数据结构,也可能破坏其他数据结构, 因为它们试图解引用现在不再有效的指针.

“进垃圾收集器,飞行员!”

垃圾收集器并不是一项新技术. 它们是John McCarthy在1959年为Lisp发明的. 随着1980年Smalltalk-80的出现,垃圾收集开始成为主流. 然而, 1990年至2000年是该技术的真正繁荣时期, 发行了大量的语言版本, 所有这些都使用了这样或那样的垃圾收集:Haskell, Python, Lua, Java, JavaScriptRuby、OCaml和c#是其中最著名的.

什么是垃圾收集? 简而言之,它是一组用于自动化手动内存管理的技术. 它通常作为具有手动内存管理的语言(如C和c++)的库使用, 但它更常用于需要它的语言中. The great advantage is that 程序员 simply doesn’t need to think about memory; it’s all abstracted away. 例如,与上面的文件读取代码等价的Python代码如下:

def read_行_from_file (file_name):
	台词= []
	使用open(file_name)作为fp: 
		For在fp中的行:
			行.追加(线)
	返回行
	
如果__name__ == '__main__':
	导入系统
	File_name = sys.argv [1]
	Count = len(read_行_from_file(file_name))
	文件{}包含{}行.".格式(file_name计数)
$ python3.py makefile
makefile文件包含38行.

行数组在第一次赋值时形成,并且返回时不复制到调用作用域. 它在超出该作用域后的某个时间被垃圾收集器清理, 时间是不确定的. 一个有趣的注意事项是,在Python中,非内存资源的RAII不是惯用的. 这是允许的,我们可以简单地写 p = open(file_name) 而不是使用 块,然后让GC进行清理. 但是推荐的模式是尽可能使用上下文管理器,以便可以在确定的时间释放它们.

虽然抽象内存管理很好,但这是有代价的. 在引用计数垃圾收集, 所有变量赋值和作用域退出都获得了更新引用的小代价. 在标记和扫描系统中, 在不可预测的时间间隔内,所有程序的执行都会停止,而GC会清理内存. 这通常被称为“停止世界事件”. 像Python这样使用这两种系统的实现遭受了两种惩罚. 这些问题降低了垃圾收集语言在性能至关重要的情况下的适用性, 或者实时应用程序是必要的. 即使在这些玩具程序上,也可以看到实际的性能损失:

$ make CPP && time ./ c++ makefile
c++ - 0 c++ c++.cpp
makefile文件包含38行.
真正的0 m0.016s
用户0 m0.000s
sys 0 m0.015s

$ time python3.py makefile
makefile文件包含38行.

真正的0 m0.041s
用户0 m0.015s
sys 0 m0.015s

Python版本所需的实时时间几乎是c++版本的三倍. 虽然并不是所有的差异都可以归因于垃圾收集,但它仍然是相当大的.

所有权:《RAII Awakens

这就结束了吗? 所有的编程语言都必须在性能和易于编程之间做出选择吗? No! 编程语言研究继续进行, 我们开始看到下一代语言范式的第一批实现. 特别有趣的是 叫做生锈的语言, 它承诺像python一样的人体工程学和c一样的速度,同时制作悬空指针, 空指针之类的不可能——它们不会编译. 它怎么能这么说呢?

允许这些令人印象深刻的索赔的核心技术被称为借用检查器, 在编译时运行的静态检查器, 拒绝可能导致这些问题的代码. 然而,在深入讨论其含义之前,我们需要讨论先决条件.

所有权

回想一下我们在c++中对指针的讨论, 我们谈到了所有权的概念, 粗略地说就是“谁负责删除这个变量?.生锈规范并强化了这一概念. 每个变量绑定都拥有它所绑定的资源的所有权, 借用检查器确保只有一个绑定拥有资源的全部所有权. 的代码片段如下 生锈的书,不会编译:

设v = vec![1, 2, 3];
设v2 = v;
println!("v[0] is: {}", v[0]);
错误:使用移动值:' v '
println!("v[0] is: {}", v[0]);
                        ^

默认情况下,生锈中的赋值具有move语义——它们转移所有权. 可以为类型提供复制语义, 对于数字原语,这已经完成了, 但这是不寻常的. 因此, 从第三行代码开始, V2拥有这个向量,它不能再被当作v来访问. 为什么这个有用?? 当每个资源只有一个所有者时, 它也有一个脱离范围的时刻, 哪些可以在编译时确定. 这意味着生锈可以实现RAII的承诺, 根据资源的作用域确定地初始化和销毁资源, 无需使用垃圾收集器或要求程序员手动释放任何东西.

将此与引用计数垃圾收集进行比较. 在RC实现中, 所有指针至少有两条信息:所指向的对象, 以及对该对象的引用次数. 当该计数达到0时,对象被销毁. 这使指针的内存需求增加了一倍,并增加了它的使用成本, 因为计数是自动递增的, 递减, 和检查. 生锈的所有权系统提供了同样的保证, 当对象的引用用完时会自动销毁, 但它这样做没有任何运行时成本. 分析每个对象的所有权,并在编译时插入析构调用.

借款

如果移动语义是传递数据的唯一方式, 函数返回类型会变得非常复杂, 非常快. 如果你想写一个用两个向量生成一个整数的函数, 之后没有消灭带菌者吗, 您必须将它们包含在返回值中. 虽然这在技术上是可行的,但使用起来很糟糕:

fn foo (v1: Vec, v2: Vec) -> (Vec, Vec, i32) {
    //对v1和v2进行操作

    //返回所有权和函数的结果
    (v1, v2, 42)
}

设v1 = vec![1, 2, 3];
设v2 = vec![1, 2, 3];

Let (v1, v2, answer) = foo(v1, v2);

相反,生锈有借用的概念. 你可以这样写相同的函数, 它会借用这些向量的参考, 在函数结束时将其返回给所有者:

fn foo (v1: &Vec, v2: &Vec) -> i32 {
    //做某事
    42
}

设v1 = vec![1, 2, 3];
设v2 = vec![1, 2, 3];

让答案= foo()&v1, &v2);

V1和v2在fn foo返回后将它们的所有权返回到原始作用域, 退出作用域并在包含它的作用域退出时自动销毁.

这里值得一提的是,借贷是有限制的, 由编译时的借用检查器强制执行, 这一 生锈的书 非常简洁地说:

任何借阅的期限不得超过借阅人的期限. 第二,你可以有这两种借款中的一种,但不能同时有两种:

一个或多个参考文献(&T)到一个资源

只有一个可变引用(&狗T)

这一点值得注意,因为它构成了生锈防止数据争用的一个关键方面. 通过在编译时防止对给定资源的多个可变访问, 它保证不能在结果不确定的情况下编写代码,因为它取决于哪个线程首先到达资源. 这可以防止诸如迭代器失效和free之后使用之类的问题.

实用的借用检查器

现在我们已经了解了生锈的一些特性, 让我们看看如何实现我们之前看到的相同的文件行计数器:

fn read_行_from_file (file_name: &str) -> io::Result> {
	//默认情况下生锈中的变量是不可变的. mut关键字允许对它们进行变异.
	let mut 行 = Vec::new();
	let mut 缓冲 = String::new();
	
	if let Ok(mut fp) = OpenOptions::new().阅读(真正的).打开(file_name) {
		//只有当文件被成功打开时,我们才进入这个块.
		// This is one way to unwrap the Result type 生锈 uses instead of exceptions.
		
		// fp.read_to_string可以返回Err. 的尝试! 宏传递这样的错误 
		//向上通过调用堆栈,或者以其他方式继续.
		试一试!(fp.read_to_string (&傻瓜缓冲区));
		行=缓冲区.split(“\ n”).地图(| |年代.to_string ()).收集();
	}
	
	Ok(线)
}

Fn main() {
	//从第一个参数获取文件名.
	//注意args().nth() produces an Option. 为了得到实际的参数,我们使用
	/ / .expect()函数,如果n()返回None,则对给定的消息进行恐慌处理; 
	//表示至少没有那么多参数. 与c++相比,c++
	//当参数不够时产生分段错误,或者Python引发IndexError.
	//在生锈中,错误情况*必须*考虑在内.
	让file_name = env::args().n (1).这个程序至少需要一个参数!");
	if let Ok (file_行) = read_行_from_file(&file_name) {
		println!文件{}包含{}行., file_name, file_行.len ());
	} else {
		// read_行_from_file返回一个错误
		println!("无法读取文件{}",file_name);
	}
}

超出源代码中已经注释的项目, 遍历和跟踪各种变量的生命周期是值得的. file_namefile_行 last until the end of main(); their destructors are called at that time 与out extra cost, 使用与c++的自动变量相同的机制. 当调用 read_行_from_file, file_name 是否在其持续时间内不变地借给该函数. 在 read_行_from_file, 缓冲 以同样的方式,当它超出范围时被破坏. 而另一方面,则持续存在并成功返回 main. 为什么?

首先要注意的是,生锈是一种基于表达式的语言, 返回的呼叫一开始可能看起来不像. 如果函数的最后一行省略了末尾的分号,那么该表达式就是返回值. 第二件事是对返回值进行特殊处理. 假定它们的寿命至少与函数调用者一样长. 最后要注意的是,由于涉及移动语义,没有必要的副本来变形 Ok(线)Ok (file_行),编译器只是使变量指向适当的内存位.

“只有在最后你才会意识到RAII的真正力量.”

手动内存管理是一场噩梦,自编译器发明以来,程序员一直在设法避免它. RAII是一种很有前途的模式, 但是在c++中很弱,因为如果没有一些奇怪的解决方法,它就不能用于堆分配的对象. 因此, 垃圾收集语言在90年代出现了爆炸式增长, 旨在使程序员的生活更愉快,甚至以牺牲性能为代价.

然而,这并不是语言设计的最终结论. 通过使用新的和强有力的所有权和借用的概念, 生锈 manages to merge the scope-basing of RAII patterns 与 the memory security of garbage-collection; all 与out ever requiring a garbage collector to stop the world, 同时做出了其他语言中没有的安全保证. 这是系统编程的未来. 毕竟,”人无完人,但编译器永远不会忘记.

聘请Toptal这方面的专家.
现在雇佣
彼得Goodspeed-Niklaus

彼得Goodspeed-Niklaus

验证专家 在工程
8 的经验

德国巴伐利亚州的维尔茨堡

2015年10月19日成为会员

作者简介

彼得, 理学士(优异), 是一个专业的Python/Django开发人员谁也写了一个奇特的处理器模拟器在生锈.

作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

专业知识

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® 社区.