1 所有权
所有运行的程序都必须管理其使用计算机内存的方式。一些语言中具有GC(Garbage Collection)机制,在程序运行时不断地寻找不再使用的内存(典型代表Java);在另一些语言中,程序员必须亲自分配和释放内存(典型代表C/C++),容易出错,不安全。一旦出现问题,调试工作巨大,让人沮丧;还有一些语言采用了ARC(Automatic Reference Counting)的机制(典型代表Object C和C++智能指针),据说ARC比传统GC机制性能好。Rust 则选择了另外一种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。在运行时,所有权系统的任何功能都不会减慢程序。Rust号称是安全语言,其核心功能就是“所有权(ownership)”。
一些公司大量产品都采用C语言开发,不太可能全部迁移至C++或OC,迁移到Rust更加不可能,Rust特别有意设计的和C不一样的另类语法,对许多软件工程师来说,学习成本就非常大,更别提实际迁移工作量了。
因此,吸收Rust的所有权思想并引入到C语言中提升C语言的安全性是一件非常有意义的事,在工具链上需要通过开发简单的插件来达到和RUST编译器一样的检查功能,我们姑且把这个插件叫做检查器。
我们也姑且把这种C语言称为TC(可信的C?随便取的,主要为了方便区分和后文描述)。
1.1 栈(Stack)与堆(Heap)
在很多语言(比如Java和OC)中,你并不需要经常考虑到栈与堆。不过在像 C/Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的抉择。
栈和堆都是代码在运行时可供使用的内存,但是它们的结构不同。栈以放入值的顺序存储值并以相反顺序取出值。这也被称作 后进先出(last in, first out)。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做 进栈(pushing onto the stack),而移出数据叫做 出栈(popping off the stack)。
栈的操作是十分快速的,这主要是得益于它存取数据的方式:因为数据存取的位置总是在栈顶而不需要寻找一个位置存放或读取数据。另一个让操作栈快速的属性是,栈中的所有数据都必须占用已知且固定的大小。
在编译时大小未知或大小可能变化的数据,要改为存储在堆上。堆是缺乏组织的:当向堆放入数据时,你要请求一定大小的空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的 指针(pointer)。这个过程称作 在堆上分配内存(allocating on the heap),有时简称为 “分配”(allocating)。将数据推入栈中并不被认为是分配。因为指针的大小是已知并且固定的,你可以将指针存储在栈上,不过当需要实际数据时,必须访问指针。
想象一下去餐馆就座吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。
当你的代码调用一个函数时,传递给函数的值(包括可能指向堆上数据的指针)如果超出允许的寄存器用量时会被压入栈中,函数的局部变量也会被压入栈中。当函数结束时,这些值被移出栈,这是由操作系统自动管理的,程序员并不需要关心。但操作系统对堆上数据并不会自动清理,因此,跟踪哪部分代码正在使用堆上的哪些数据,最大限度的减少堆上的重复数据的数量,以及清理堆上不再使用的数据确保不会耗尽空间,这些问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过明白了所有权的存在就是为了管理堆数据,能够帮助解释为什么所有权要以这种方式工作。
1.2 所有权规则
首先,让我们看一下所有权的规则。当我们通过举例说明时,请谨记这些规则:
1)TC中的每一个值都有一个被称为其所有者(owner)的变量。
2)值有且只有一个所有者。
3)当所有者(变量)离开作用域,这个值将被丢弃。
1.3 变量作用域
变量的作用域(scope)是一个项(item)在程序中有效的范围。假设有这样一个变量:
int i = 0;
跟其他编程语言类似,C和TC一样,作用域由该变量最近的一组{}来圈定。
{ // i 在这里无效, 它尚未声明
int i = 0; // 从此处起,i 是有效的
// 使用 i
} // 此作用域已结束,i 不再有效
换句话说,这里有两个重要的时间点:
当 i 进入作用域 时,它就是有效的。
这一直持续到它 离开作用域 为止。
1.4 复杂类型
前面介绍的基本数据类型是存储在栈上的并且当离开作用域时被移出栈,这是很容易理解的已有机制,不是所有权要解决的核心问题。现在我们需要寻找一个存储在堆上的复杂数据来探索TC是如何知道该在何时清理数据的。在C语言中,数据类型比较简单,所谓复杂类型就是指字符串、结构体和联合体。
1.5 内存与分配
就字符串常量来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串并不占用栈或堆的空间。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变,用放在栈上的局部数组来存放也是不安全的,可能会导致栈空间的耗尽。
对于字符串类型,为了支持一个可变,可增长的文本片段,如在需要容纳一个不确定的外部输入时,一般需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:
必须在运行时向操作系统请求堆内存。
需要一个当我们处理完字符串时将内存返回给操作系统的方法。
C语言调用malloc库函数来申请堆空间,然后要识别出不再使用的内存并调用free库函数显式释放,这是程序员的责任,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,一样会引发异常。这些都可能会引起严重的安全问题。
TC采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面是作用域的例子,例子中计划使用一个堆空间来存放字符串,而不是一个基本变量:
{ // str 在这里无效, 它尚未声明
char *str = (char *)malloc(XXX);
// 如申请空间成功,从此处起,str是有效的
// 使用str
} // 此作用域已结束,str被检查器自动释放
这是一个将str需要的内存返回给操作系统的很自然的位置:当 str 离开作用域的时候。TC的检查器为我们调用一个特殊的函数,这个函数就是free,在结尾的 } 处自动添加free调用。如果程序员已经手工调用free函数,检查器会给出提示,不会再重复释放。不建议程序员手工调用free函数。
注意:在 C++ 中,这种 item 在生命周期结束时释放资源的模式有时被称作 资源获取即初始化(Resource Acquisition Is Initialization (RAII))。如果你使用过 RAII 模式的话,应该对此做法并不陌生。
这个模式对编写TC代码的方式有着深远的影响。现在它看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。现在让我们探索一些这样的场景。
1.6 所有权转移
先让我们看一个使用基本类型的例子。
int x = 5;
int y = x;
将变量 x 的整数值赋给 y,我们都很清楚这是在干什么:“将x初始化为 5,接着将 x 的值拷贝到 y”。现在有了两个变量,x 和 y,都等于 5。这也正是事实上发生了的,因为整数是有已知固定大小的简单值,所以这两个 5 被放入了栈中。
现在来看这个例子的字符串版本:
char *s1 = (char *)malloc(XXX);
char *s2 = s1;
这看起来与上面的代码非常类似,如果不了解C语言,我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个 s1 的拷贝,并将其地址赋值给 s2 。C程序员都知道,事实上并不是这样,而是s2和s1都指向了同一个堆空间。如下图所示:
这种“一房多卖”的情况在C语言中是一件让人非常头痛的事,比如出现多次释放,或者释放后使用的问题。
TC语言并不会改成上述假想的那样,为S2重新申请一块空间,并将数据从S1指向的空间拷贝过来。程序员都知道内存的拷贝是一件很影响性能的事。TC语言为了确保内存安全,这种场景下,TC会认为 s1 不再有效,检查器会自动添加s1=NULL这样的语句。看看在 s2 被创建之后尝试使用 s1 会发生什么。这段代码不能运行:
char *s1 = (char *)malloc(XXX);
char *s2 = s1;
……
printf( 's1=%s!', s1 );
检查器会强烈提示错误,并拒绝进行下一步编译运行动作,否则你的程序可能会奔溃,因为 s1 已经是一个空指针。
如果你在其他语言中听说过术语 浅拷贝(shallow copy)和 深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 TC 同时使第一个变量无效了,这个操作被称为 移动(move),而不是任何一种拷贝。上面的例子可以解读为 s1 被 移动 到了 s2 中。如下图所示:
这样就解决了我们前面讲的“一房多卖”的问题!因为只有 s2 是有效的,当s2离开作用域,检查器会添加自动释放的语句,完毕。
C程序员都知道如果需要复制一个副本,需要再次另外申请空间,并用memcpy此类的函数来进行数据复制。安全专家们都知道memcpy是一个非常危险的操作,但它的安全性并不是“所有权”要解决问题的范畴。
1.7 所有权遗弃
转移接收了其它所有权,原先的所有权自动遗弃,毕竟我们是不允许脚踏两只船的。从空间被释放的时间点来看,这一做法和ARC机制非常类似。
char *s1 = (char *)malloc(XXX);
char *s2 = (char *)malloc(XXX);
s2 = s1;
//s2原有空间被遗弃,检查器会去释放它,s1的空间转给s2,s1置空
程序运行情况如下图所示:
1.8 永久所有权
前面已经讲到所有权是为了解决堆上分配问题而做的优化,但这并不意味着所有权概念仅用于堆上空间。比如位于栈上、位于代码段和数据段的基本数据类型(含数组),这些空间的所有权就是其变量,但和堆空间不同的是,这些变量对该空间拥有永久所有权,永久所有权不能转移给其它变量,也不能丢弃。
char *str = 'Hello, TC!'; // 错误语法,检查器会报错
char str[] = 'Hello, TC!'; // 正确语法,永久所有权
char str[XXX]; // 正确语法,永久所有权
char *str1 = str; // 错误,永久所有权不能转移
C程序员可能会对最后一句提出严重的抗议,但这确实是TC语言所不同的地方,永久所有权不能被转移,但在后面章节可以看到,它可以被借用。因此,需要完成正常的功能是没有问题的,只是更安全而已。
1.9 所有权与函数
将基本类型的形参传递给函数实参时,在语义上与给变量赋值相似,是一种值的传递。但对于复杂类型指针向函数传递时可能会产生所有权移动,就像赋值语句一样。下面例子使用注释展示变量何时进入和离开作用域:
void main( )
{
int i1 = 5; // i1 进入作用域
char *s1 = (char *)malloc(xxx); // s1 进入作用域
takes_ownership( i1, s1 ); // i1 的值复制给函数里
// s1 的值移动到函数里 ...
// 所以到这里,s1不再有效
// 但可继续使用 i1
}// s1 的空间已被移走,检查器会自动置空,程序员无需关心释放问题。
void takes_ownership( int i2, char *s2 )
{
// i2,s2 进入作用域
} //s2 移出作用域,检查器自动释放s2空间。i2在栈上,无需任何操作。
当尝试在调用 takes_ownership 后使用 s1 时,检查器会强烈提示错误,并拒绝进行下一步编译运行动作,否则运行起来系统可能会奔溃。
1.10 返回值与作用域
在某些场景下,调用一个函数对s1进行处理后,我还需要继续处理s1,但s1已经失去空间所有权,这怎么办呢?方法是让函数再将所有权返回。
void main()
{
char *s1 = (char *)malloc(xxx); // s1 进入作用域
……
s1 = takes_and_gives_back(s1); // s1 被移动到
// takes_and_gives_back 中,
// 它也将返回值移给 s1
……继续使用s1……
} // 这里, s1 移出作用域并被检查器释放
char * takes_and_gives_back( char *s2 )
{
// s2 进入作用域
……
return s2; // 返回所有权给调用者
}// s2所有权已经转移,检查器在这里不需要做特殊处理
每次都传进去再返回来就有点烦人,除此之外,我们也可能想返回函数体中产生的一些数据,就无法返回所有权了,C语言只允许返回一个值的。
我们不想这么啰嗦和形式化,那能否要函数使用一个值但不获取所有权呢?请参见下一章“所有权借用”。
1.11 结构体的所有权
1.11.1 所有权的包含关系
前面大部分内容都是以字符串为例介绍的,但在复杂数据类型中,还有结构体struct和联合体union两种,以struct为例来介绍其所有权问题。先看一个例子:
typedef struct staff
{
char name[10];
int age;
}STAFF_T;
我们定义了一个新的struct型的数据类型STAFF_T,然后实例化一个staff数据:
STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));
staff_a拥有该示例的所有权,遵循上述所有权的相关规定,可以遗弃、转移和出借。这个例子比较简单,因为struct的成员全部是基本数据类型,如果struct含有复杂数据类型情况会如何呢?继续看复杂一点的例子,加入addr成员长短差异较大,程序员采用动态分配内存的方法:
typedef struct staff
{
char name[10];
int age;
char *addr;
}STAFF_T;
实例化(所有举例代码仅为说明问题,不考虑异常问题):
STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));
staff_a->addr = (char *)malloc(100);
这个时候再内存中存在两块内存,示意图如下:
这个时候新的情况出现了,前面说第一块内存的所有权是staff_a的,那第二块内存的所有权属于谁呢?我们认为第二块内存的所有权属于staff_a的成员addr,并不属于staff_a,虽然他们存在紧密关系,这是特别要注意的地方。
当staff_a被释放或遗弃时,如果addr也有空间存在,会被一并自动释放,无需程序员手工处理。但能够释放的前提条件是其所有权(第二章介绍)没有被借用。
总之,理解了所有权的本质是和一个地址相关联,就不难理解结构体相关的所有权知识了。
1.11.2 复杂数据成员所有权的转移
在结构体含有复杂数据类型成员时,所有权转移会比较复杂一点,主要是需要考虑其包含的下一级所有权是否存在。下面还是以STAFF_T结构体为例来解释。
typedef struct staff
{
char name[10];
int age;
char *addr;
}STAFF_T;
实例化staff_a:
STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));
staff_a->addr = (char *)malloc(100);
定义一个staff_b,并将staff_a的所有权转移过来:
STAFF_T *staff_b = staff_a;
这种从根struct转移所有权的行为是一种整体转移。注意,在整体转移过程中,其复杂数据类型成员的所有权并没有转移,其所有权还在原先的地址上。下面示意图可以清晰看出,红圈所在的地址(所有权)并没有发生改变。
除此之外,还可以部分转移,即只转移其复杂数据类型成员的所有权。实例化staff_a:
STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));
staff_a->addr = (char *)malloc(100);
定义一个addr1,并将staff_a->addr的所有权转移过来:
char *addr1 = staff_a->addr;
如果有多层次的复杂数据的嵌套,原理也是一样。
未完待续……
_____________________________________
BY:HW-LGP






































苏公安网备 32011402010933号