文档开发者中心
我的

重新定义C语言,让C语言达到和RUST一样的安全性(二)

1 所有权

2 所有权借用

2.1 借用

前面代码中提到过这样一个问题:我们必须将s2返回给调用函数交还所有权,以便在调用函数能继续s1。下面演示如何定义并使用一个(新的)borrow函数,1的内它可以使用s容但不获取其所有权:


void main( )

{

int i1 = 5; // i1 进入作用域

char *s1 = (char *)malloc(xxx); // s1 进入作用域

borrow( i1, s1 ); // i1 是值复制,不涉及所有权

// s1 的值借用到函数里 ...

…… // s1继续有效,可继续使用

}// s1 离开作用域,检查器会自动释放,程序员无需处理。

void borrow( int i2, char const * volatile s2 )

{

// i2进入作用域

// s2仅仅是一个借用,在作用域范围内可以使用其内容值。

}


沿用C语言的volatile修饰符来表示一个借用(是否对编译器有其它影响?)。另外在C语言中,const *表示常量指针,指针指向内容为只读,在此表示为只读借用。用虚线表示借用关系,如下图所示:

很显然,如果在borrow函数中,尝试给s2申请新的空间,或者尝试调用free函数释放s2,检查器都会强烈提示错误。当然,检查器自己也不会在s2的作用域结束时去释放s2。因为s2并没有内存空间的所有权。借用(borrowing),正如现实生活中,如果一个人拥有房产,你可以从他那里租借来住,但你并不拥有所有权。


void borrow( int i2, char const * volatile s2 )

{

// i2进入作用域

// s2仅仅是一个借用,在作用域范围内可以使用其内容值。

s2 = (char *)malloc(xxx); // 错误,借用不能申请自己的空间

free( s2 ); // 错误,借用没有自己的空间可释放

} // 作用域结束自动归还。


2.2 归还


作用域结束,自动归还是很容易理解的事。除此之外,程序员还可以主动归还:


void borrow( int i2, char const * volatile s2 )

{

// i2进入作用域

s2 = NULL; // 主动归还

}


通过赋值到NULL是一种主动归还方式,可以成为显式归还。还有另外一种隐式主动归还:


void borrow( int i2, char const * volatile s2 )

{

// i2进入作用域

char *s4 = (char *)malloc(xxx);

s2 = s4; // s2向s4发起了借用,隐式的归还了原先的借用。

}


2.3 可写借用


进一步,如果在borrow函数中,改变s2所指向的内容,检查器和编译器也会提示错误:


void borrow( int i2, char const * volatile s2 )

{

// i2进入作用域

// s2仅仅是一个借用,在作用域范围内可以使用其内容值。

s2[0] = 'a'; // 检查器提示错误,s2指向的内容不能改变。

}


C程序员很容易发现问题,是因为我们在借用时加了const修饰符,如果想修改s2指向空间的值。只要去掉这个const就可以了。我们把这种借用方式叫做可写借用。


void borrow( int i2, char * volatile s2 )

{

// i2进入作用域

// s2仅仅是一个借用,在作用域范围内可以使用其内容值。

s2[0] = 'a'; // 可写借用,写成功。

}


2.4 可写借用限制


不过对于可写借用,有一个很大的限制:在特定作用域中的特定数据有且只有一个可写借用。这些代码会失败:


char *s1 = (char *)malloc(xxx); // s1 具有所有权

char * volatile s2 = s1; // 可写借用

char * volatile s3 = s1; // 再次可写借用,检查器报错


进一步讲,有了可写借用后,也不允许同时存在其它只读借用:


char *s1 = (char *)malloc(xxx); // s1 具有所有权

char const * volatile s2 = s1; // 只读借用,OK

char * volatile s3 = s1; // 可写借用,检查器报错

char const * volatile s4 = s1; // 再次只读借用,OK


不过,这个限制可考虑通过配置关闭,因为大部分语言中变量任何时候都是可变的。意思是说在检查器可以有选项可以选择:允许多次可写出借或者同时允许一个可写和多个只读出借。但这个限制的好处是让TC和Rust一样可以在编译前/时就避免数据竞争。数据竞争(data race)类似于竞态条件,它可由这三个行为造成:


  • 两个或更多指针同时访问同一数据。

  • 至少有一个指针被用来写入数据。

  • 没有同步数据访问的机制。


数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;TC和 Rust避免了这种情况的发生,因为它们甚至不会编译存在数据竞争的代码!


一如既往,可以使用大括号来创建一个新的作用域,以允许拥有多个可变借用,只要不是同时拥有:


char *s1 = (char *)malloc(xxx); // s1 具有所有权

{

char * volatile s2 = s1; // 可写借用

}

char * volatile s3 = s1; // 再次可写借用,因为s2已经失效


另外,需要特别注意的是,默认情况下,有了可写借用后,拥有所有权的变量自身也变得不可访问,既不能写也不能读。如果只是只读借用,原变量还可以读,只是不能写。


char *s1 = (char *)malloc(xxx);// s1 具有所有权

char * volatile s4 = s1; // 可写借用

s1[0] = 'a'; // 检查器报错,s1不可写访问

printf( 's1=%s', s1 ); // 检查器报错,s1不可读访问


这仅仅是一个为了说明问题的示例,如果实际情况中出现这种代码,也不用太担心,检查器会在s4离开作用域后恢复s1的可访问性,然后在s1离开作用域后释放空间。


总之,在默认限制规则下,我们也不能在拥有只读借用的同时拥有可写借用。只读借用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个只读借用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。


尽管这些限制有时使人沮丧,但请牢记这是TC检查器和Rust编译器在提前指出一个潜在的bug(在编译前/时而不是在运行时)并精准显示问题所在。这样你就不必去跟踪为何数据并不是你想象中的那样。


2.5 悬垂指针(Dangling pointer)

在C这样具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,TC检查器和Rust中编译器确保永远也不会变成悬垂状态:情况一:对于拥有所有权的变量来说,检查器会严格按照作用域来确保数据绝对有效性;情况二:对于借用者来说,检查器会确保出借者不会在其归还数据之前离开作用域,不离开作用域,就不会被释放,也保证了数据的绝对有效。


情况二描述的这种机制实际上是一个我们还未介绍的新功能,叫:生命周期(lifetimes)。后面章节会详细介绍生命周期。现在,我们先来看一个例子,检查器如何检查这种错误:


char * volatile dangle( )

{

char *s1 = (char *)malloc(xxx);// s1 具有所有权

char * volatile s2 = s1; // s2 借用s1

return s2;

// s1离开作用域后释放,S2成为悬垂指针,检查报错并拒绝编译。

}

因为 s1 是在被调用函数内创建的,当该函数的代码执行完毕后,s1将被释放。我们尝试返回它的借用,指向一个无效的空间,这可不对!TC检查器不会允许我们这么做。当然,C语言是可以的,这些C语言的不安全性正是TC要优化的地方。


这里的解决方法是直接返回s1:


char * no_dangle( )

{

char *s1 = (char *)malloc(xxx);// s1 具有所有权

return s1; // s1 所有权返回,运行正常。

}


这样就没有任何错误了。所有权被移动出去,所以没有空间会被释放。


2.6 借用的默认规则

让我们概括一下之前对借用的讨论:


  • 同一时刻,可有0个或多个只读借用,但不能同时有任何可写借用;

  • 同一时刻,在没有只读借用时,允许有一个可写借用;

  • 在只读借用归还前不可写访问拥有所有权的源变量;

  • 在可写借用归还前不可写或读访问拥有所有权的源变量;

  • 借用在离开作用域后归还,也可以主动归还;

  • 在借用期内,所有者保证不会释放/转移这块内存。

2.7 永久所有权的借用

对于具有永久产权的基本数据类型的借用,也遵循上述默认的借用规则。先看一下整形数据的例子:


int i = 0;

int j = i; // 值复制

int *k = &i; // 错误,尝试转移永久产权

int * volatile l = &i; // 正确,可写借用

int const* volatile l = &i; // 正确,不可写借用


下面,继续看一下永久所有权数组的例子


int i[10];

int *j = i; // 错误,尝试转移永久产权

int * volatile k = i; // 正确,可写借用

int const* volatile k = i; // 正确,不可写借用


在函数调用时也是一样的规则,但函数返回时有些不一样。前面在讲非永久所有权借用时也没有提及函数返回一个借用时的情况,因为这涉及到下面一个技术点。


2.8 转借

Alice给Bob借了一笔钱,Bob转身把这笔钱又借给了Carol,这就产生了转借行为。把借来的东西再转借出去,这会产生让人非常头痛的三角债问题。按我们生活中的智慧,我们需要避免这种转借行为。但在TC和RUST语言中,必须允许转借行为存在。为了避免三角债务带来的烦恼,我们把转借在逻辑上可以理解是一种代理行为。因此,虽然Carol向Bob借用,但Carol的债主并不是Bob,还是Alice,是最终的所有权拥有者。这也很容易理解,因为Bob并不拥有所有权,所以他没有东西可供借出,他只能是一个代理人,帮助Alice做借出动作。


下面看一个例子:


char *Alice = (char *)malloc(xxx); // s1 具有所有权

char const *volatile Bob = Alice;// 只读借用,此时Alice无法写了

char const *volatile Carol = Bob;// 转借,相当于A同时出借给B和C


根据前面所述规则,一个变量可以同时有多个只读借用,所以上面操作没有问题。现在问题来了,如果Bob是一个可写借用呢?按照规则,如果存在可写借用,那有且只能有一个,其它不论是否可写,都不能再借出。但在这里的转借场景中,情况有所不同,即便Bob是可写借用,它仍然可以继续转借。但只能是可写转借,不能只读转借,看一个例子:


char *Alice = (char *)malloc(xxx); // s1 具有所有权

char * volatile Bob = Alice; // 可写借用,此时Alice无法读写

char const *volatile Carol = Bob;// 只读转借,错误操作

char * volatile Carol = Bob; // 可写转借,此时Bob也无法读写


可写借用来的东西为什么不能只读转借?这也很容易理解,如果允许只读转借,那Bob就变成了只读访问,这是没问题的,问题是Alice前面已经变成了完全不可访问,此处要不要变成只读访问呢?如果在继续转借下去,可能会引起连锁反应,所以,我们规定这种情况下只允许可写转借,只影响转借者本身的权限。


2.9 转借的应用

转借在实际编码中是必须的,比如在前面提到的函数返回时。来看一个例子:


void main( )

{

char *s1 = (char *)malloc(xxx); // s1 进入作用域

char *volatile s3 = borrow( s1 ); // s1 的值借用到函数里 ...

…… // 得到一个新借用者s3

}

char * volatile borrow( char * volatile s2 )

{

……; // s2 处理中

return s2+10; // 返回一个s2的一个偏移值,例如+10。

}


另外一个常用场景是函数的多层调用:


void borrow1( char * volatile s2 )

{

……; // s2 处理中

borrow2( s2 ); // 继续转借给borrow2处理。

}

void borrow2( char * volatile s3 )

{

……; // s3 处理中

}

需要说明下,在第一个例子中返回了一个偏移值,我们对这种部分借用有一个专用名字叫“Slice借用”。


2.10 结构体的借用

对于没有复杂数据类型成员的结构体,可以认为就是单块空间借用,规则完全如前所述。对于有复杂数据成员的结构体,借用就会复杂很多,特别是有多层复杂数据成员堆叠嵌套时。但还是前面的话,理解了所有权的本质是和一个地址相关联就不难理解结构体带来的复杂性了。下面还是以STAFF_T结构体为例来解释。


typedef struct staff

{

char name[10];

int age;

char *addr;

}STAFF_T;


2.10.1 整体借用


实例化staff_a:


STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

staff_a->addr = (char *)malloc(100);


定义一个整体借用:


STAFF_T * volatile staff_b = staff_a;


此时,staff_a已经可写出借给了staff_b,按照出借规则,staff_a处于读写都不允许的状态,也不能够再出借给其它人。


这个时候新的问题来了,staff_b借用了staff_a,对staff_a的空间没有所有权,无法去释放,但这个借用是一个可写借用,表示对staff_a的成员内容有可写权限。也就是说,我们可以写成员staff_a->addr,这相当于我们神奇的获得了所有权。就是原本staff_a->addr所指向的空间的所有权转移到了staff_b->addr上,确确实实是转移行为,而不是借用关系。从下面示意图中可以看出,指向该空间的箭头是一个实箭头:



这和我们的直觉可能有所不同,但这样的设计确实不会影响安全性,此时staff_a处于无法访问状态,自然无法对addr行驶所有权,检查器会自动完成检查确认工作。在实际编程中,这样的使用场景很多,比如处理一个链表上进进出出的节点,处理者只是借用了链表头,但它需要对下层空间拥有所有权才能进行链表操作。


这实际上也不难理解,所有权实际上仅和直接指向的内存空间有关。


2.10.2 Slice借用


结构体除了整体出借外,可以可以只借出部分成员。可以把一个结构体看做一棵树,叶子的借用就是slice借用。


实例化staff_a:


STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));

staff_a->addr = (char *)malloc(100);


定义一个slice借用:


char * volatile addr1 = staff_a->addr;


此时,staff_a的一个成员的空间可写出借给了addr1,是一种slice借用,对这个叶子来说,往下又是一个整体,遵循整体借用规则。下图为一个示意图,r1被借用后,从r1指向内容r2往下又是一个整体,继续遵循整体借用规则。


现在问题来了,r1北向和东西向情况如何呢?


先看东西向兄弟成员,因为兄弟成员和r1没有任何包含关系,因此,r1的借用对兄弟成员的状态没有任何影响,兄弟成员可以继续借出(所有)、转移或释放(非永久所有权的兄弟成员)。


再看r1的北向,因为r1被其北向r0所包含,所以当r1被借出后、归还前,r0不允许被释放,如果r0还有北向,也以此类推,一直到结构体根部。这实际上是后面章节要讲的结构体的生命周期问题。但北向的借用和转移都不影响。


2.10.3 出借期增益


实例化staff_a:


STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));


定义一个整体借用:


STAFF_T * volatile staff_b = staff_a;


出借后,由借入方代理申请了新的空间:


staff_b->addr = (char *)malloc(100);


当staff_b归还给staff_a后,staff_a的成员就多了一块产权,这可以看做是staff_a在出借期间的增益。这并不难理解。


2.10.4 借用者成员

结构体中含有借用者成员,修改一下STAFF_T的定义:


typedef struct staff

{

char name[10];

int age;

char * volatile addr; // 这是一个借用者

}STAFF_T;


实例化staff_a:


STAFF_T *staff_a = (STAFF_T *)malloc(sizeof(STAFF_T));


定义一个整体借用:


STAFF_T * volatile staff_b = staff_a;


出借后,由借入方代理进行了成员的借用:


staff_b->addr = addr1;


因为,staff_b是一个可写借用,所以,上面代理为成员进行借用是允许的。反之,如果是只读借用,上面的操作将会被检查器报错。


在staff_b归还后,staff_a释放时,会自动归还借用来的成员空间。不过,如果不是十分必要这样做,建议staff_b在归还自身前先主动代理归还成员空间,这有利于简化生命周期管理。


2.11 高维数据的借用

C语言最容易迷惑人的是高维指针,实际上前一节的结构体含有复杂数据类型成员时,也是一种高维指针。只是每一个维度都显式静态定义,更容易理解。在纯粹的高维指针中,中间维度是动态关联,中间关系就没有那么直观。先看一个例子:


char *s1 = (char *)malloc(xxx);

char **s2 = &s1; // 检查器报错,&s1是永久所有权,无法转移

char ** volatile s2 = &s1;//借用成功


需要注意的是,这个借用是一个高维借用,用示意图表示如下:



可见,s2借用的并不是s1所指向的空间,而是s1本身,即&s1指向的空间,只是没有显式定义&s1,实际地址中并不存在这个位置。根据前面所有权知识,我们知道&s1是一个永久所有权,所以,它只能被借用,但不能被转移。


另外,和结构体借用类似,s2借用到s1后,可以拥有s1指向空间的所有权,因为s1的位置没有发生任何改变,而且s2是一个可写借用,所以s2可以主动去释放、转移、遗弃s1所指向的空间,例如:


*s2 = NULL; // s1指向空间被遗弃


但在s2归还时,不会自动释放s1指向空间,因为s1还存在着。


未完待续……

————————————————————

BY:HW-LGP

点赞
收藏
回复
5
分享
举报
浏览855 发布于2019-10-29 06:17未知归属地
全部评论
最多点赞
最新发布
最早发布

此题,何解?


5楼回复于2019-10-30 14:44 未知归属地

不错哦,感谢分享


4楼回复于2019-10-30 14:23 未知归属地

关联阅读:重新定义C语言,让C语言达到和RUST一样的安全性(三)

3楼编辑于2019-10-29 06:41 未知归属地

关联阅读:重新定义C语言,让C语言达到和RUST一样的安全性(一)

2楼回复于2019-10-29 06:20 未知归属地

本章讲述的是所有权借用的问题

1楼回复于2019-10-29 06:20 未知归属地
写回答
新增插入模板功能
一键使用模板,快速填写内容,轻松发帖~
知道了
  • 为了保障您的信息安全,请勿上传您的敏感个人信息(如您的密码等信息)和您的敏感资产信息(如关键源代码、签名私钥、调试安装包、业务日志等信息),且您需自行承担由此产生的信息泄露等安全风险。
  • 如您发布的内容为转载内容,请注明内容来源。

我要发帖子

目录 收起
1 所有权
2 所有权借用
2.1 借用
2.2 归还
2.3 可写借用
2.4 可写借用限制
2.5 悬垂指针(Dangling pointer)
2.6 借用的默认规则
2.7 永久所有权的借用
2.8 转借
2.9 转借的应用
2.10 结构体的借用
2.10.1 整体借用
2.10.2 Slice借用
2.10.3 出借期增益
2.10.4 借用者成员
2.11 高维数据的借用
了解社区公约,与您携手共创和谐专业的开发者社区。