第7章 C++世界的奇人异事
在武侠小说中,初入武林的毛头小子总是要遇到几位奇人,发生几件异事,经过高人的指点,经历一番磨炼,方能武功精进,从新手成长为高手。在C++世界,同样有诸多的奇人异事。在C++世界中游历学习的我们,是否也同样期望着遇到几位奇人,经历几件异事,而后从一个C++新手成长为C++高手呢?
武林中的奇人异事可遇而不可求,但是C++世界中的奇人异事却可以为你一一引见。
7.1 一切指针都是纸老虎:彻底理解指针
C++世界中什么最难?指针!C++世界中什么最强?指针!
指针作为C++世界中一种特殊的访问数据的方式,因为其使用方式的灵活而使得它在C++世界中显得威力无比;然而,也正是因为它的灵活,使得它成为初学者最难掌握的C++技能。它就像一只吊睛白额“大老虎”,虽然威力无比但是难以掌握控制,用好了可以方便高效地解决问题,但如果使用不当却又很可能给程序带来灾难性的后果。今天就来打倒指针这只“纸老虎”,彻底掌握控制指针。
7.1.1 指针的运算
从本质上讲,指针也是一种数据,只不过这种数据有点特殊而已。我们通常所见的数据就是各种数值数据文字数据等,而指针所表示的是内存地址数据。既然是数据,那么自然就涉及到了数据的运算。像普通数据一样,指针也可以参与部分运算,包括算术运算、关系运算和赋值运算,而我们最常用的就是指针的算术加减运算。
如果指针的值是某个内存位置的地址值,那么我们就说指针指向这个内存位置。而指针的加减运算,实际上是让指针的指向发生偏转,指向另外的内存位置。通过这种指针的偏转,可以灵活地访问到该指针起始位置附近的内存。如果这种偏移是在某个范围内连续发生的话,则可以通过指针访问到某一连续内存区域的数据。例如,在3.6节中介绍过数组,数组名实际上就是数组数据所在内存区域的首地址,表示数组在内存中的起始位置。可以通过把首地址赋值给指针,然后对该指针进行加减运算,使指针发生偏转指向数组中的其他元素,从而遍历整个数组。例如:
int nArray[] = { , , }; // 定义一个数组
int* pIndex = nArray; // 将数组的起始地址赋值给指针pIndex
cout<<"指针指向的地址是:"<<pIndex<<endl; // 输出指针指向的地址
cout<<"指针所指向的数据的值是:"<<*pIndex<<endl; // 输出这个位置上的数据 pIndex++; // 对指针进行加运算,使其指向数组中的下一个值
cout<<"指针指向的地址是:"<<pIndex<<endl; // 输出指针指向的地址
cout<<"指针所指向的数据的值是:"<<*pIndex<<endl; // 输出数据
这段程序执行后,可以得到这样的输出:
指针指向的地址是:0016FA38
指针所指向的数据的值是:1
指针指向的地址是:0016FA3C
指针所指向的数据的值是:2
从输出结果中可以看到,pIndex指针初始指向的地址是0016FA38,也就是nArray这个数组的首地址。换句话说,也就是pIndex指向的是数组中的第一个数据,所以输出“*pIndex”的值是1。而在对指针进行加1运算后,指针指向的地址变为0016FA3C,它向地址增大的方向偏移了4个字节,指向了数组中的第二个数据,输出“*pIndex”的值自然也就变成了2。
这里大家肯定会奇怪,对指针进行的是加1的运算,怎么指针指向的地址却增加了4个单位?这是因为指针的加减运算跟它所指向的数据的真正数据类型相关,指针加1或者减1,会使指针指向的地址增加或者减少一个对应的数据类型的字节数。比如以上代码中的pIndex指针,它可以指向的是int类型的数据,所以它的加1运算就使地址增加了4个字节,也就是一个int类型数的字节数。同样的道理,对于可以指向char类型数据的char*类型指针,加1会使指针偏移1个字节;而对于可以指向double类型数据的double*类型指针,加2会使指针偏移16(8*2)个字节。指针偏转流程如图7-1所示。
图7-1 指针运算引起的指针10:43:4010:43:41偏转
除了指针的加减算术运算之外,常用到的还有指针的关系运算。指针的关系运算通常用“==”或“!=”来判断两个相同类型的指针是否相等,也就是判断它们是否指向同一地址上的同一数据,以此作为条件或循环结构中的条件判断语句。例如:
int nArray[] = { , , }; // 定义一个数组
int* pIndex = nArray; // 将数组的起始地址赋值给指针pIndex
int* pEnd = nArray + ; // 计算数组的结束地址并赋值给pEnd
while( pIndex != pEnd ) // 在while的条件语句中判断两个指针是否相等,
// 也就是判断当前指针是否已经偏转到结束地址
{
cout<<*pIndex<<endl; // 输出当前指针指向的数据
// 对指针进行加1 运算,
// 使其偏移到下一个内存位置,指向数组中的下一个数据
++pIndex;
}
在以上这段代码中,利用表示数组当前位置的指针pIndex跟表示结束位置的指针pEnd进行相等与否的比较,如果不相等,则意味着pIndex尚未偏移到数组的结束位置,循环可以继续对pIndex进行加1运算,使其偏移至下一个位置指向数组中的下一个元素;如果相等,则意味着pIndex正好偏移到数组的结束位置,while循环已经遍历了整个数组,循环可以结束。
另外,指针变量也常和nullptr关键字进行相等比较,来判断指针是否已经被初识化而指向正确的内存位置,也就是判断这个指针是否有效。虽然我们提倡在定义指针的同时就完成对它的初始化,可有时在定义指针的时候,并没有合适的初始值可以赋给它,但如果让它保持最开始的随机值,又会产生不可预见的结果。在这种情况下,我们会在定义这个指针的同时将这个指针赋值为nullptr,表示这个指针还没有被初始化,处于不可用的状态。而等到合适的时候,再将真正有意义的值赋值给它来完成这个指针的初始化,这时指针的值将不再是nullptr,也就意味着这个指针处于可用的状态。所以,将nullptr跟某个指针进行相等比较,是判断这个指针是否可用的常用手段。下面是一个典型的例子:
int* pInt; // 定义一个指针,这时的指针是一个随机值,指向随机的一个内存地址
// 将指针赋值为nullptr,表示指针还没有合适的值,处于不可用的状态
pInt = nullptr; //… int nArray[] = {};
pInt = nArray; // 将数组首地址赋值给指针
if( nullptr != pInt ) // 判断指针是否已经完成初始化处于可用状态
{
// 指针可用,开始使用指针访问它指向的数据
}
因为通过指针可以直接访问它所指向的内存,所以对尚未初始化的指针的访问,有可能带带来非常严重的后果。而将指针与nullptr进行相等比较,可以有效地避免指针的非法访问。虽然在业务逻辑上这不是必须的,但这样做可以让我们的程序更加健壮,所以这也是一条非常好的编程经验。
7.1.2 灵活的void类型和void类型指针
C++是一种强类型的语言,其中的变量都有自己的数据类型,保存着与之相应类型的数据。比如,一个int类型的变量可以保存数值1,而不能保存数值1.1,它需要一个与之相应的double类型的变量来保存。相应数据类型的变量保存相应的数据,本来相安无事过的好好的。但是,在C++世界中却出现了一个异类,那就是void类型。从本质上讲,void类型并不是一个真正的数据类型,我们并不能定义一个void类型的变量。void更多的是体现一种抽象,在程序中,void类型更多的是用于“修饰”和“限制”一个函数。例如,如果一个函数没有返回值,则可用void作为这个函数的返回值类型,代替具体的返回值数据类型;如果一个函数没有形式参数列表,也可用void作为其形式参数,表示这个函数不需要任何参数。
跟void类型对函数的“修饰”作用不同,void类型指针作为指向抽象数据的指针,它可以成为两个具有特定类型的指针之间相互转换的桥梁。众所周知,在用一个指针对另一个指针进行赋值时,如果两个指针的类型相同,那么可以直接在这两个指针之间进行赋值;如果两个指针的类型不同,则必须使用强制类型转换,把赋值操作符右边的指针类型转换为左边的指针类型,然后才能进行赋值。例如:
int* pInt; // 指向整型数的指针
float* pFloat; // 指向浮点数的指针
pInt = pFloat; // 直接赋值会产生编译错误
pInt = (int*)pFloat; // 强制类型转换后进行赋值
但是,当使用void类型指针时,就没有类型转换的麻烦。void类型指针显得八面玲珑,任何其他类型的指针都可以直接赋值给void类型指针,例如:
void* pVoid; // void类型指针
pVoid = pInt; // 任何其他类型的指针都可以直接赋值给void类型指针
pVoid = pFloat;
虽然任何类型的指针都可以直接赋值给void类型指针,但这并不意味着void类型指针也可以直接赋值给其他类型的指针。要完成这个赋值,必须经过强制类型转换,让“无类型”变成“有类型”。例如:
pInt = (int*)pVoid; // 通过强制类型转换,将void类型指针转换成int类型指针
pFloat = (float*)pVoid; // 通过强制类型转换,将void类型指针转换成float类型指针
虽然通过强制类型转换,void类型指针可以在其他类型指针之间*转换,但是,这种转换应当遵循一定的规则,void类型指针所转换成的其他类型,必须与它所指向的数据的真实类型相符。比如把int类型指针赋值给void类型指针,那么这个void类型指针指向的就是int类型数据,这时如果再把这个void类型指针强制转换成double类型指针并通过它访问它所指向的数据,那么很可能得到错误的结果。因为void类型指针对它所指向的内存数据类型并没有要求,所以它可以用来代表任何类型的指针,如果函数可以接受任何类型的指针,那么应该将其参数声明为void类型指针。例如内存复制函数:
void* memcpy(void* dest, const void* src, size_t len);
在这里,任何类型的指针都可以作为参数传入memcpy()函数中,这也真实地体现了内存操作函数的意义,因为它操作的对象仅仅是一片内存,而不论这片内存上的数据是什么数据类型。如果memcpy()函数的参数类型不是void类型指针,而是char类型指针或者其他类型指针,那么在使用其他类型的指针作为参数调用memcpy()函数时,就需要进行指针类型的转换以适应它对参数类型的要求,纠缠于具体的数据类型,这样的memcpy()函数明显不是一个“纯粹的、脱离低级趣味的”内存复制函数。
最佳实践:11:06:42指针类型的转换
虽然指针类型的转换可能会带来一些不可预料的麻烦,但在某些特殊情况下,例如,需要将某个指针转换成函数参数所要求的指针类型,以达到调用这个函数的目的时,指针类型的转换就成为一种必需。
在C++中,可以使用C风格的强制类型转换进行指针类型的转换。其形式非常简单,只需要在指针前的小括号内指明新的指针类型,就可以将指针转换成新的类型。例如:
int* pInt; // int*类型指针
float* pFloat = (float*)pInt; // 强制类型转换成float*类型指针
在这里,我们通过在int类型指针pInt之前加上“(float*)”而将其强制转换成了一个float类型指针。虽然这种强制类型转换的方式比较直接,但是却显得非常粗鲁。因为它允许我们在任何类型之间进行转换,而不管这种转换是否合理。另外,这种方式在程序语句中很难识别,代码阅读者可能会忽略类型转换的语句。
为了克服C风格类型转换的这些缺点,C++引进了新的类型转换操作符static_cast。在C风格类型转换中,我们使用如下的方式进行类型转换:
(类型说明符)表达式
现在,使用static_cast应该写成这样:
static_cast<类型说明符>(表达式)
其中,表达式是已有的旧数据类型的数据,而类型说明符就是要转换成的新数据类型。在使用上,static_cast的用法与C风格的类型转换的用法相似。例如,两个int类型的变量相除时,为了让结果是比较精确的小数形式,我们需要用类型转换将其中一个变量转换为double类型。如果用C风格的类型转换,可以这样写:
int nVal1 = ;
int nVal2 = ;
double fRes = ((double)nVal1)/nVal2;
如果用static_cast进行类型转换,则应该这样写:
double fRes = static_cast<double>(nVal1)/nVal2;
使用C++风格的类型转换,不论是对代码阅读者还是对程序都很容易识别。我们应该在代码中尽量避免进行类型转换,但如果类型转换无可避免,那么使用C++风格的类型转换在一定程度上既可增加代码的可读性,也是对类型转换损失的一种补偿。