指针的安全性隐患及其应对方案

指针的安全性隐患及其应对方案

引用《c++程序语言设计》 郑莉 董渊 编著

指针这样一种底层机制,虽然为程序带来了很大的灵活性,但灵活意味着较少的约束,因此对指针的不慎使用,常常会带来一些安全性问题。把这些可能发生的安全性问题分为3大类,加以分析,并指出应对方案。

1a71fb1eb335a4694c4a978998a157b0

1.地址安全性

我们通常使用的变量,其地址是由编译器分配的,引用变量时,编译器会使用适当的地址,由编译器保证所引用的地址是分配给这个变量的有效地址,而不会访问到不允许访问的地址,也不会访问到其他变量的地址。而使用指针时,指针所存储的地址是由程序在运行时确定的。如果程序没有给指针赋予确定的有效地址,就会造成地址安全性隐患。

如果一个指针未赋初值就被使用,就会造成地址安全性隐患。而且,一个具有动态生存期的普通变量如果不赋初值就被使用,程序的结果将是不确定的,同样会造成地址安全性隐患,在这一点上,指针并没有特殊性。

造成地址安全性隐患的另一个原因是指针的算术运算。首先,**指针算术运算的用途,一定要限制在通过指向数组中某个元素的指针,得到指向同一个数组中另一个元素的指针。**指针算术运算的其他用法,都会得到不确定的结果。

即使把指针的算术运算限制在这一用途之内,仍然存在地址安全性隐患,最典型的问题就是数组下标越界。无论是大小固定的静态数组,还是用new分配的动态数组,访问数组中除了第一个元素外的其他元素,都要通过对首地址指针进行算术运算。算术运算后得到的地址,如果超出了数组的空间范围,就会发生错误。这类错误往往不易查出,例如,由于对数组进行下标越界的访问而导致b变量的值不慎被改写,则一般只能直接观察到b变量值的异常,而如果想找出这一异常是由对a的不当访问造成的,则需费一番周折了。

解决这一安全隐患的办法是,尽量不直接通过指针来使用数组,而是使用封装的数组(像是vector),这样使得数组下标越界的错误很容易被检测出。即使直接使用数组,应在访问数组元素前检查数组下标。

2.类型安全性

对于普通变量来说,由于每个变量都有明确的类型,每个变量又都有明确的地址,因此编译器保证只把每段内存单元中存储的数据当作同一种类型来处理。例如存进去的时候是用浮点数的格式,读出来并参加运算时,也一定被当成浮点数。唯一的例外是联合体。而使用指针时,由于指针允许做类型的隐含或显式转换,安全问题就出现了。

基本数据类型和类类型也都有类型转换的情况,为什么没有类型安全性问题呢?那是因为它们所做的转换是基于内容的转换。例如:

int i=2;
float x=static cast<float>(i);

整型的2和双精度浮点型的2,在内存中是由不同的二进制序列表示的。不过,在执行static cast<float)>(i) 这一操作时,编译器会生成目标代码,将i的整型的二进制表示,转换成浮点型的二进制表示,这种转换叫做基于内容的转换。但是有指针参加的转换,情况就不大一样了,例如:

int i=2;
float p=reinterpret cast<float *(&i);

reinterpret_cast 是和 static_cast 并列的一种类型转换操作符,它可以将一种类型的指针转换为另一种类型的指针,这里把 int 类型的 &i 转换为float ×类型。这个转换是怎么进行的呢?无论是int类型的指针,还是float类型的指针,存储的都是一个地址,它们的区别只是相应地址中的数据被解释为不同类型而已。因此,这里的类型转换,无外乎就是把 &i 得到的地址值直接作为转换结果,并为这一结果赋予float类型。这种转换的结果就是,p作为浮点型指针,却指向了整型变量i。如果通过 p 访问整型变量 i ,所执行的操作只能是针对浮点型的,这能不出问题吗?

细节 reinterpret_cast不仅可以在不同类型对象的指针之间转换,还可以在不同类型函数的指针之间、不同类数据成员的指针之间、不同类函数成员的指针之间、不同类型的引用之间相互转换。reinterpret_cast的转换过程,在C++标准中未明确规定,会因编译环境而异。C++ 标准只保证用 reinterpret_cast 操作符将A类型的p转换为B类型q,再用reinterpret_cast操作符将B类型的q转换为A类型的r后,应当有(p==r)成立。

reinterpret_cast所做的转换,一般只用于帮助实现一些非常底层的操作,在绝大多数情况下,用reinterpret_cast在不同类型的指针之间转换的行为,都是应当避免的。C++之所以要将reinterpret_cast 所能执行的转换操作和static_cast分开,就是因为reinterpret_cast具有很大的危险性和不确定性,而static_cast基本上是安全的和确定的。但是,static_cast 也并非绝对安全。即使不用reinterpret_cast,类型安全性仍然存在,这是因为有void指针的存在。任何类型的指针都可以隐含地转换为void指针,例如:

int i=2;
void vp=&i

这两条语句本身没有安全性问题,因为void指针在语义上就是所指向对象内容的数据类型不确定的指针,因此它可以指向任何类型的对象。然而,通过void指针不能对它所指向的对象进行任何操作,在执行操作前,还需先将void指针转换为具体类型的指针。C++不允许void指针到具体类型指针的转换隐含地发生,这种转换需要显式地进行,但是无须借助于reinterpret_cast 操作符,用static_cast操作符即可。例如:

int p=static cast<int *(vp);

细节 C语言允许void指针隐含地转换为其他任何类型的指针,而C++规定这种情况只能显式转换,这是C++与C相比的一个安全之处。

如果用static_cast将void指针转换为指针原来的类型,那么这是一种安全的转换,否则仍然是不安全的。例如:

float p2=static cast<float *>(vp);

与reinterpret_cast相同的问题又发生了。因此,static_cast也不是绝对安全的,在对void指针使用static_cast时如使用不当就会有不安全的情况出现。因此,void指针要慎用。有很多从标准C继承而来的函数会使用void指针作为参数和返回值,例如将一段内存空间设为一个固定值(memset)、比较两段内存(memcmp)、复制一段内存空间(memcpy)、动态分配一段内存空间(malloc)、释放动态分配的内存空间(free)等,这些操作都是不管具体的数据类型,把不同类型的数据当作无差别的二进制序列。其中,动态内存管理的函数(malloc,free等)已经可以被C++的new,delete关键字全面替代,而直接内存操作的函数(memset,.memcmp,memcpy等) 只能针对对象的二进制表示进行处理,不符合面向对象的要求,一般不用,至多对一些基本数据类型的数组使用。

void指针的另一个用途在于,有时 一个指针可能会指向不同类型的对象,void指针只起一定的传递作用,最终使用该指针时,还需要根据情况将指针还原为它原先的类型。不过,这样的需求,很多都可以用继承、多态(将在第7章和第8章介绍)的方式加以处理。如果实在无法处理,那么一定要保证用static_cast操作符将void指针转换为正确的类型,而不能是其他类型,这样就能够保证指针类型的安全性。

总结起来,保证指针类型安全性的办法有以下几种。

  • 除非非常特殊的底层用途,reinterpret cast不要用。
  • 继承标准C的涉及ⅴoid指针的函数,一般不要用,至多对一些基本数据类型及其数组使用。
  • 如果一定需要用void指针,那么用static cast将void指针转换为具体类型的指针时,一定要转换为最初的类型(即当初转换到该void指针的指针类型)。

提示 在这里,C++标准中几种分工明确的类型转换操作符static_cast,reinterpret_cast等,与旧风格的类型转换形式相比,其优势就显示了出来。·无论是相对安全的static cast,还是不安全的reinterpret_cast,都使用相同的转换形式,安全和不安全的行为会变得不易区分。

3.堆对象的管理

通常使用的局部变量,在运行栈上分配空间,空间分配和释放的过程是由编译器生成的代码控制的,一个函数返回后相应的空间会自行释放;而静态生存期变量,其空间的分配是由连接器完成的,它们占用的空间大小始终是固定的,在运行过程中无须释放。然而,用new在程序运行时动态创建的堆对象,则必须由程序用delete显式删除。如果动态生成的对象不再需要使用也不用delete删除,会使得这部分空间始终不能被其他对象利用,造成内存资源的泄漏。

“用new创建的对象,必须用delete删除”这一原则,虽然说起来很简单,但在复杂的程序中,实践起来却没那么容易。如果一个堆对象的指针,只被一个对象直接访问,而不会传递给其他对象,那么就比较容易处理针。

然而,情况并非都如此简单,有时常常会出现一个堆对象的指针被传递给多个对象的情况,这时,在什么时候、由哪个对象负责删除该堆对象,就成了问题。
对于这一问题,最关键的是明确每个堆对象的归属问题,也就是说,一个堆对象应当由哪个对象、哪个函数负责删除。一般来说,最理想的情况是,一个堆对象是由哪个类的成员函数创建的,就在这个类的成员函数中被删除。如果遵循这一原则,则堆对象的建立和删除都只是一个类的局部问题,不涉及其他类,因此问题简单许多。

然而,有时确实需要在不同类之间转移堆对象的归属。例如,如果一个函数需要返回一个对象,为了避免复制构造函数因传递返回值被调用(因为大对象的复制构造会有较大开销),可以在函数内用new建立该对象,再将该对象的地址返回,但这就要求调用这个函数的类确保这个返回的堆对象最后被删除。每当遇到这种情况,都应当在函数的注释中明确指出,函数的调用者应当负责删除函数所返回的堆对象。这实际上是类的对外接口约定的一部分,不过能否正确履行不由编译器来检查,而需完全由编程者来保证。

解决动态对象的管理问题,也可以借助于共享指针。共享指针是一种具有指针行为的特殊的类,它会在指向一个堆对象的所有指针都不再有效时,自动将其删除。虽然使用共享指针要付出一定的效率代价,但安全性很好,容易使用。共享指针是Bo0st库的一部分。

4. const_cast的应用

旧风格的类型转换操作符,可以用static_cast,reinterpret_cast和const_cast三者之一或其中两者的组合加以描述。static_cast已经介绍得很多,它用
来进行比较安全的、基于内容的数据类型转换,而reinterpret_cast也介绍了,它是一种底层的、具有很大危险性和不确定性的数据类型转换。还剩下一种类型转换操作符const_cast尚未介绍,本节将对它及其用途进行简单介绍。

通俗地说,const_cast可以用来将数据类型中的const属性去除。它可以将常指针转换为普通指针,将常引用转换为普通引用。例如下面的代码:

void foo (const int cp){
  int p=const cast<int *>(cp);
  (*p)++;
}

该代码使用const__cast将cp类型中的const去除,将常指针cp转换为普通指针p,然后通过p修改它所指向的变量。这是一个逻辑有些混乱的程序,因为函数foo通过将参数cp声明为常指针,承诺不会通过cp修改任何变量的值,而它却出尔反尔,最终还是改变了cp所指向的内容。

注意 const_cast 只用于将常指针转换为普通指针,将常引用转换为普通引用,而不用来将常对象转换为普通对象,因为这是没有意义的。因为对象(而非引用)的转换会生成对象的副本,而使用常对象本来就可以直接生成普通对象的副本。例如:

const int i=5;
int j=i;

这里把常量 i 赋给变量 j 是无须任何转换的。不过常对象可以用const_cast转换为普通引用,这是因为从对象到引用的转换是隐含的,常对象可以隐含地转换为常引用,而常引用又可用const_cast转换为普通引用。

可见,const_cast很容易被滥用,破坏对数据的保护,因此它是不安全的,所以相对安全的static_cast不具备去除const的功能。不过,虽然const_cast是不安全的,但并不意未若它是一无是处的,在其固定场合适当地使用它,可以是安全的。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值