注:
1.未特别指明时,本文所述内容均为C/C++(引用则为C++)的;
2.未特别指明时,本文的描述、测试和实现,一般基于80x86构架和Win32平台下的VC++6.0的debug模式,但我也会在某些地方强调指出;
3.未特别指明时,我用实体(entity)来泛指平时所说的变量、常量和函数(未特别指明时,本文所说的常量,一般均指用const定义的,而非#define定义的);
4.本文需要对C/C++语言、VC++6.0以及汇编语言有一定程度的了解,因为我不可能太过详细地描述;
5.关于这些内容,还有很多没有说到的,因为全部的内容足够写一本书了;我只选择了部分,并重点描述其实现;
6.我一向不喜欢查资料,所以如果本文有错,一概与其他资料无关;但我会尽量保证本文的严谨性,若仍有无法避免的错误,请不吝指出。假如本文某些观点是和其他资料雷同,请当成我抄袭。
一直以来我都被告知,引用就是一个别名,数组名就是一个常量指针,而字符串就是以'\0'结尾的字符数组,那么事实是否如此呢?
一、指针(pointer):
(一)描述:
指针被存放在内存中,它的值是它指向的内存单元的地址。要足够保存这个地址,它的大小自然不会小于该机器的字长(准确来说,这也和编译器的实现有关:若在32位平台上使用16位的TC,则地址和指针仍是16位的)。
它提供了一种通过*运算符(dereference,解引用)来访问内存单元的方式。
(二)定义和声明:
一般来说,指针的声明就是它的定义(对于变量和常量来说,声明一般都是它的定义。据我所知,只有在声明时使用extern且未在该处初始化,或在类类型的定义中声明类静态数据成员时例外。本文将要描述的其他3者也相同,下文就不再重复这句了)。
作为一个良好的编程风格,指针应被初始化为一个实体的地址或NULL,使用如下格式定义(关于&和*运算符的描述可以见下文):
type* pointer = &entity;
//当type不为void时,entity的类型应是type或type的派生类类型
//这里所说的entity的类型也包括指针,此时type也应包含对应的*表明是指向指针的指针
//当entity有const或volatile属性时,要用const或volatile修饰type
//当entity无const或volatile属性时,可用const修饰type,但不可用volatile修饰
//entity不能是位域(bit field)的成员
type* pointer1 = pointer2; //将上述注释中的entity改为pointer2后,规则同上
type* pointer = new type(parameter);
//parameter需和类的构造函数匹配,有缺省构造函数或内置类型时也可不带参数
//C语言中使用malloc等函数
type* pointer = new type[size];
//若为用户自定义类型时,调用缺省构造函数。若无缺省构造函数则编译错误。
//size是一个size_t型的数,它是一个无符号整型,定义参见<stddef.h>或<cstddef>
type* pointer = NULL;
关于NULL,VC++6.0中的某些C标准头文件有如下定义(如<stdlib.h>和<stdio.h>。再次注意:未特别指明时,下文的实现均指在VC++6.0中):
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
而C++标准头<cstdlib>和<cstdio>只是简单地分别#include了<stdlib.h>和<stdio.h>,并包含在namespace std中。
其他的C++标准头(至少所有和输入输出相关的都是如此)通过几层include,最终也包含了<stdio.h>或<stdlib.h>
另外,指针还可以指向一个函数,这被称为函数指针。
使用如下方式声明一个函数指针:
type (*functionPointer)(parameters); //parameters的数目可以为0或多个,声明和定义时只需写出参数类型即可。
typedef type (*functionPointerType)(parameters); //声明一个函数指针类型
functionPointerType functionPointer; //声明一个type (*)(parameters)类型的函数指针
使用如下方式定义一个函数指针:
type (*functionPointer)(parameters) = functionName; //functionName是一个函数名,类型必须和functionPointer相同(包括返回值类型和每个参数的类型)
type (*functionPointer)(parameters) = &functionName; //和上一句等效
type (*functionPointer)(parameters) = *functionName; //和上一句等效,functionName前可以加任意多个*
type (*functionPointer1)(parameters) = functionPointer2; //functionPointer2是一个函数指针名,类型必须和functionPointer相同
type (*functionPointer1)(parameters) = *functionPointer2; //和上一句等效,functionPointer2前可以加任意多个*
functionPointerType functionPointer = ...; //省略号表一个函数指针、函数名或函数地址,functionPointerType为上面声明的一个函数指针类型
顺便提一下,使用如下方式定义一个指针常量(即该指针的内容为常量,也即该指针一般不能再指向其他的地址):
将定义的左边改为type* const pointer即可。
注意const修饰的是它左边的类型(这里我将指针也当成类型),当它左边没有类型时,才会修饰右边的类型。关于这点,volatile和const是一致的。
下面做个简单的测试,看看指针究竟是什么:
--------------------------------------------------------------------------
#include <iostream>
using namespace std;
//因为经常会用到C++标准库,且每个测试文件的并不相同,所以本文的测试一律用这句代替using声明
int main()
{
bool b = true; //为了在后面方便区别于指针的大小,我选择了bool型
bool* p1 = &b;
bool* p2 = p1;
bool* pn = NULL;
cout << "&b = " << &b <<
"\np1 = " << p1 << "\t&p1 = " << &p1 <<
"\np2 = " << p2 << "\t&p2 = " << &p2 <<
"\npn = " << pn << "\t&pn = " << &pn << endl;
return 0;
}
--------------------------------------------------------------------------
输出:(这是在我同学的电脑上得到的结果,也许和你的不同。下面不再注明)
&b = 0012FF7C
p1 = 0012FF7C &p1 = 0012FF78
p2 = 0012FF7C &p2 = 0012FF74
pn = 00000000 &pn = 0012FF70
--------------------------------------------------------------------------
反汇编可得到如下代码(只提供关键部分,下同):
7: bool b = 0;
004015A8 mov byte ptr [ebp-4],0
//ebp-4的值为0012FF7C(应该分得清16进制和10进制吧,我懒得加0x或h了)。
//因此给b初始化是直接把0放到0012FF7C地址单元的一个byte中。
8: bool* p1 = &b;
004015AC lea eax,[ebp-4]
004015AF mov dword ptr [ebp-8],eax
//ebp-8的值为0012FF78。
//因此给p1初始化是取b的地址0012FF7C,放入0012FF78地址单元的一个dword中。
//由此得知0012FF78就是p1的地址。
//执行完这句后,0012FF78~0012FF7B地址单元分别为7C FF 12 00,即0012FF7C的小端顺序表示。
//可见用实体的地址给指针初始化,指针的内容就是该实体的地址。
9: bool* p2 = p1;
004015B2 mov ecx,dword ptr [ebp-8]
004015B5 mov dword ptr [ebp-0Ch],ecx
//ebp-0Ch的值为0012FF74,这是p2的地址。
//用指针给指针初始化,需要通过dword ptr [ebp-8]得到p1的内容(即b的地址,注意地址是个dword)。
//执行完这句后,0012FF74~0012FF77地址单元分别为7C FF 12 00。
10: bool* pn = NULL;
004015B8 mov dword ptr [ebp-10h],0
//这和给实体初始化是一样的,直接将0放入pn所在地址表示的dword单元中。
--------------------------------------------------------------------------
由上述测试得知,指针的内容就是地址。
注:
1.对汇编不太了解的,也应该可以从测试的输出得出上述结论。反汇编只是为了解释指针的存储原理。
2.由于new返回的是一个指针,所以实际和第9句是类似的,就不再做重复的实验了。
(三)运算:
对一个实体的地址使用*运算符,可以得到该实体的地址对应的内存单元的值(概念上来说可被视为一个引用,一般为该实体本身,可以作为左值(即可以通过名称或地址(而非靠纯粹的计算)来访问的表达式))。这个值的类型与*的操作数类型相同。
对一个非void类型的指针使用*运算符,可以得到该指针指向的内存单元的值(引用)。这个值的类型与该指针的类型相同。注意,若该内存单元是不可读的,则程序会崩溃;若该内存单元是不可写的,却对其赋值,则程序也会崩溃。
对一个实体使用&运算符,可以得到它的地址。这个实体是一个左值,且不能为位域成员。这个地址是一个右值,它不能被赋值或再次使用&运算符。
对一个非const类型指针使用=运算符,并提供一个地址或指针作为右操作数,可以使该指针的值更改为这个地址或指针的值。这里的注意事项同指针的定义和声明。
对一个非const或void类型指针使用++或--运算符,可以让该指针的值增加或减少(sizeof(指针的类型))(我用西文字符的括号表示一个表达式,下同),一般来说会指向下一个或上一个实体(如果指针未越界的话,下同)。
对一个非const或void类型指针使用+或-运算符,并提供一个ptrdiff_t类型的参数(ptrdiff)(可以为负数)作为操作数,可以得到一个该类型的指针。这个指针是一个右值,它的值为((指针的内容) op ((ptrdiff) * sizeof(指针的类型))),op为使用的运算符。一般来说会指向与原实体相距ptrdiff个实体。
对一个非const或void类型指针使用+=或-=运算符,并提供一个ptrdiff_t类型的参数(ptrdiff)作为右操作数,可以让该指针的值增加或减少((ptrdiff) * sizeof(指针的类型)),并且结果可以作为左值。一般来说会指向与原实体相距ptrdiff个实体。
对两个同类型或有派生关系的非void指针使用-运算符,可以得到一个类型为ptrdiff_t的值。这个值即(它们的差值)。
对两个同类型或有派生关系的非void指针使用比较运算符,可以得到一个bool值。比较的方法是拿它们的内容进行比较,相当于无符号整型的比较。
关于ptrdiff_t,<stddef.h>有如下定义(<cstddef>包含了该文件):
#ifndef _PTRDIFF_T_DEFINED
typedef int ptrdiff_t;
#define _PTRDIFF_T_DEFINED
#endif
指针还提供了[]运算符,我将放在下文讲述。
函数指针则提供了*、&和()运算符,前2者实际上没什么用处(下文会提到),后者则是调用函数。
此外,指针还定义了4种标准转型:转换成bool型、转换成void*型、派生类指针转换成基类指针和基类成员指针转换成派生类成员指针。这些内容可以在别处搜索资料,我就不提供了。
另外,编译器会在函数地址、函数指针和函数名之间自动进行转换,但函数名只能转换为一个函数指针常量,即不能指向其他函数。
当后面无参数列表时,被当成函数地址或函数指针使用;否则当成函数的定义、声明或调用。
函数指针隐式转型为函数名的操作被称为decay,下文我们还会遇到另一种decay。
关于函数指针的显式转型我也稍微提一下。函数指针的类型是:(返回值类型) (*)(形参列表)。所以转换的时候要用“reinterpret_cast<((返回值类型) (*)(形参列表))>(函数名、函数地址或函数指针名)”或“((返回值类型) (*)(形参列表))(函数名、函数地址或函数指针名)”(为了清晰的表示,我增加了几个括号,但不影响使用)的形式。注意(*)的括号不能去掉,它表示类型是一个函数指针。
关于函数指针的其他内容,例如与之相关的代理函数(哑函数),以及使用函数指针调用2进制数据代码等内容,我不作介绍,因为这并非本文的主题。
顺便说说输出流的<<运算符对void*类型进行的重载。设p是一个指针,在输出流的<<运算符中,*p、p和&p将分别输出p指向的实体的值(如果这个值能被<<输出)、p的内容和p的地址。
但char*型将被当成字符串输出(它也被重载了),所以要使用(static_cast<void*>(p))的格式输出指针的内容,否则可能导致程序崩溃。
而有volatile属性的指针将被转换成bool型输出。若要像其他指针一样在<<中输出,我只尝试出如下的一种办法(如前文所述,我不喜欢查资料,也许其他资料有更好的办法吧):
cout.fill('0');
cout.width(8);
cout << uppercase << hex << reinterpret_cast<size_t>(p) << endl;
意图就是把指针转换成一个无符号整型(因为指针的内容就是一个无符号整型),设置输出时填充0,位宽为8位,大写,16进制。使用size_t是为了可移植,不过要包含<cstddef>。而进行那些格式设置则需要包含<iomanip>。
当然,你也可以用C的输出函数处理:
print("%p\n", p);
这样无论p是什么指针,都能输出它的值。
此外,对于用new运算符或malloc等函数分配的内存空间,需要对指针使用delete运算符或free等函数释放空间。
关于这里的注意事项,很多资料都说了,我只提几点最重要的:
(1)务必使用和申请时相对应的释放方式
(2)若无法确保该指针不会被再次使用,请将其赋值为NULL
(3)不要再次释放一个已经被释放的空间:一个好办法就是释放空间后,将所有指向该空间的指针赋值为NULL
(4)将值为NULL的指针传递给delete、delete[]或free是安全的
对于指针的运算,我觉得没有必要给出测试用例了。因为在指针的定义和声明中提供的测试中,已经知道指针存储的就是一个地址(类型是无符号的整型),而对它进行的运算就相当于对地址进行运算。
当然,你也可以自己进行测试来验证;如果你会调试的话,还能观察指针内容的变化。
(四)其他:
正如前文所说,指针的内容足够写一本书了,所以我也就不再往下讲述了。
但下文的内容都和指针有关,所以会在各节继续补充相应内容,因为我介绍指针的目的就是为了引出后面的内容。
二、引用(reference)
(一)描述:
引用被存放在内存中,它的值是它指向的实体的地址。(准确来说,引用可能并不指向一个实体,但这一般是编程时需要避免的。)
它提供了一种通过引用名来访问它指向的实体的方式。从更广泛的意义上来说,任何实体的名称都可以被视为一个引用,因为通过该名称可以访问该实体。
(二)定义和声明:
引用必须用一个实体来初始化,使用如下格式来定义一个引用:
type& reference = entity;
这里的注意事项和指针类似,不再重复。
顺便提一下函数引用(我不知道这个名词是否准确,因为孤陋寡闻的我没在其他资料上看到过这个名词):
使用如下方法声明一个函数引用:
type (&functionReference)(parameters);
使用如下方法定义一个函数引用:
type (&functionReference)(parameters) = function; //function可以是函数名、函数地址、函数指针和函数引用的解引用形式(即前面加上*)
注意是函数引用的解引用形式,否则调用时程序可能崩溃。(因为函数引用不会自动转换为函数地址或函数指针。这也许是因为引用是C++对于C新增的内容,虽然尽量让引用变得和指针相似,但仍旧百密一疏。)
其他声明和定义方式以及注意事项同函数指针。
再做几个测试,看看引用究竟是什么:
--------------------------------------------------------------------------
#include <iostream>
using namespace std;
int main()
{
bool b = true;
bool& rb = b;
bool* pb = &b;
rb = false;
*pb = false;
cout << "b = " << b << "\t&b = " << &b <<
"\nrb = " << rb << "\t&rb = " << &rb <<
"\n*pb = " << *pb << "\tpb = " << pb << "\t&pb = " << &pb << endl;
return 0;
}
--------------------------------------------------------------------------
输出:
b = 0 &b = 0012FF7C
rb = 0 &rb = 0012FF7C
*pb = 0 pb = 0012FF7C &pb = 0012FF74
--------------------------------------------------------------------------
看上去引用和被引用的实体似乎是同一个东西,让我们反汇编看看吧:
7: bool b = true;
004017B8 mov byte ptr [ebp-4],1
8: bool& rb = b;
004017BC lea eax,[ebp-4]
004017BF mov dword ptr [ebp-8],eax
9: bool* pb = &b;
004017C2 lea ecx,[ebp-4]
004017C5 mov dword ptr [ebp-0Ch],ecx
10:
11: rb = false;
004017C8 mov edx,dword ptr [ebp-8]
004017CB mov byte ptr [edx],0
12: *pb = false;
004017CE mov eax,dword ptr [ebp-0Ch]
004017D1 mov byte ptr [eax],0
--------------------------------------------------------------------------
有没有发现对于引用和指针的定义和使用,汇编代码都是差不多的呢?
实际上观察rb的地址ebp-8(我得到的是0012FF78),将会看到7C FF 12 00,这就是b的地址的小端顺序表示。
而比较11和12句将会发现,对引用进行操作,就是取引用的内容作为地址,然后对该地址的实体进行操作。这和指针是相同的。
可见引用和一个解引用的指针实质上是相同的。(并不非常准确,下文会说到原因)
--------------------------------------------------------------------------
对于不太了解汇编语言的,可以进行如下测试:
--------------------------------------------------------------------------
#include <iostream>
using namespace std;
class A
{
char a;
};
class B
{
char& b;
};
class C
{
char* c;
};
int main()
{
cout << "sizeof(A) = " << sizeof(A) <<
"\nsizeof(B) = " << sizeof(B) <<
"\nsizeof(C) = " << sizeof(C) << endl;
return 0;
}
--------------------------------------------------------------------------
输出:
sizeof(A) = 1
sizeof(B) = 4
sizeof(C) = 4
--------------------------------------------------------------------------
结果表明:引用的大小和指针的大小是相同的,而与被引用的实体无关。
这也就说明了,引用和被引用的实体是完全不同的。
后面将继续研究这个问题。
(三)运算:
从上面的测试得知,对引用进行的操作,就是对它指向的实体进行操作。所以,引用能做任何被它引用的实体能做的操作,并且得到的结果是相同的(但当它被声明为一个const引用时,不能改变被引用的实体的值)。
这也就说明,无法在C++中直接得到引用的地址,并且也不能给引用重新赋值(即指向另一个实体)。
因此引用的实质就是一个解引用的常量指针。
但是,函数引用却可以作为=运算符的左值,被赋值为另一个函数地址。因此函数引用的实质就是一个解引用的函数指针。
另外,函数引用也可通过&运算符得到自身的地址(而非被引用的函数的地址)。
其他方面,函数引用的使用和函数以及函数指针是差不多的。
(四)其他:
引用和指针最常见的用途是将它们作为函数的参数,这样的调用也被称为传址调用。
在描述传址调用之前,我还要指出,使用引用作为函数的参数,可能会造成二义性。
即一个T类型的实参,将会和3种类型的参数完美匹配:T、T&和T const&。
对于T类型的右值,T和T const&匹配程度一样;对于T类型的左值,T和T&匹配程度一样。
如果定义了2种同名的,且匹配程度一样函数,在调用时就会产生二义性,所以请注意这点。一般对于用户自定义类型,使用T&或T const&较好。
下面进行一个测试,了解传址调用和传值调用的区别:(为便于区别和不产生二义性,我将函数名取为不同的了)
--------------------------------------------------------------------------
#include <iostream>
using namespace std;
void assign1(int a, int b)
{
a = b;
}
void assign2(int& ra, int b)
{
ra = b;
}
void assign3(int* pa, int b)
{
*pa = b;
}
void assign4(int** ppa, int b)
{
**ppa = b;
}
int main()
{
int a = 0;
cout << "a = " << a << endl;
assign1(a, 1);
cout << "a = " << a << endl;
assign2(a, 2);
cout << "a = " << a << endl;
assign3(&a, 3);
cout << "a = " << a << endl;
int* pa = &a;
assign3(pa, 4);
cout << "a = " << a << endl;
int** ppa = &pa;
assign4(ppa, 5);
cout << "a = " << a << endl;
return 0;
}
--------------------------------------------------------------------------
输出:
a = 0
a = 0
a = 2
a = 3
a = 4
a = 5
--------------------------------------------------------------------------
可以看出,只有使用指针和引用才能更改实参的值。(准确来说,实参(指针或引用)并没被更改,更改的是它们指向的实体的值)。
于是我们可以猜测,传递指针或引用时,其实是复制了该指针或引用。
由于这个开销相当于传递一个大小为(sizeof(void*))的数据,所以如果无需更改原来的值,对于内置类型可以直接传递它们。而用户自定义类型则一般不会小于指针和引用的大小,所以后者一般传递引用或指针。
下面反汇编一下证实我们的猜测。
--------------------------------------------------------------------------
30: assign1(a, 1);
004018D8 push 1
004018DA mov ecx,dword ptr [ebp-4]
004018DD push ecx
004018DE call @ILT+400(assign1) (00401195)
004018E3 add esp,8
33: assign2(a, 2);
0040190F push 2
00401911 lea eax,[ebp-4]
00401914 push eax
00401915 call @ILT+580(assign2) (00401249)
0040191A add esp,8
36: assign3(&a, 3);
00401946 push 3
00401948 lea edx,[ebp-4]
0040194B push edx
0040194C call @ILT+460(assign3) (004011d1)
00401951 add esp,8
--------------------------------------------------------------------------
可见使用2个参数的传值调用是直接将实体压栈,而传址调用是将被指向的实体的地址压栈。
对于其他数量的参数的调用,由于代码太多就不提供了,你可以自己测试。我测试时发现,编译器有时会将实参分别压栈,有时会将实参全部保存在通用寄存器中,有时会使用rep movs指令通过edi和esi寄存器保存连续存放的实参。
对于用户自定义类型,传址调用只是将该对象的地址压栈。而传值调用则需要将对象的所有非静态成员变量作为实参;且当对象存在虚函数时,将调用复制构造函数。
指针和引用也可作为函数的返回类型,但一般不要返回一个在函数内部才有效的局部非静态实体,因为返回后该实体就销毁了,引用它只会导致未定义的行为。
另外,指针和引用也是C++实现运行期多态性的基础,这部分内容可以看我以前写的blog。
总之,使用引用一般比使用指针更为直观和安全,如果引用能做到的话,就尽量不要用指针。
另外,还有很多东西也能实现类似指针的功能,如迭代器、智能指针和句柄等,使用它们一般也比直接使用指针方便。
最后提一下Java中的对象,你应该能知道它们都是靠引用来访问的吧。
只不过这个引用比较特别——它可以引用不同的对象(即它不是一个常量指针),也可以引用一个null对象(主要用于初始化和检查引用的有效性)。这也就增强了引用的功能,因为看上去和指针没什么区别了。(我的感觉是,Java中的引用类似于一个句柄。)
不过别忘了,指针可以指向任何东西,甚至是一个任意的无效的地址(这里的无效意为不指向任何实体)。
三、数组(array)
(一)描述:
数组是在内存中一片连续的可存放指定类型和大小的实体的空间。
它提供了一种通过[]运算符来访问它的元素的方式。
注:本文所述的数组指在栈中分配的固定大小的数组,而非在堆中分配的。但2者其实差不多。
(二)定义和声明:
使用如下格式定义一个一维数组:
type array[size]; //size为size_t类型的常量(在编译期即可确定大小)。注意size不能太大,因为一个程序的堆栈是有限的(在VC++6.0上缺省是1MB多)。使用这样的定义,数组中所有的元素都是未初始化的(即值是随机的)。
type array[size] = {element1, element2, ...}; //省略号表后面还有,你可以在这初始化不超过size个的元素,未被显式初始化的元素将被缺省初始化(如int将被初始化为0)。
type array[] = {element1, element2, ...}; //编译器将把你初始化的元素数目,自动设置为数组的大小。
对于字符数组,还有2种初始化方式:
char array[size] = "..."; //省略号表可以为不超过(size - 1)个的字符,因为最后还会自动添加一个'\0'字符。
char array[] = "..."; //编译器将把(引号中的字符数目 + 1),自动设置为数组的大小。
使用如下格式定义一个多维数组:
type array[size1][size2][...]; //省略号表可以有任意多维。
type array[size1][size2][...] = {element1, element2, ...};
type array[size1][size2][...] = {{element1, element2, ...}, {...}, ...}; //每一个{}声明一维的元素。省略号表可以有不超过声明维数的{}。每一维的元素数目不能超过该维的大小。
type array[][size1][size2][...] = {element1, element2, ...}; //编译器将根据你初始化的元素数目,自动确定第1维的大小。下同。
type array[][size1][size2][...] = {{element1, element2, ...}, {...}, ...};
下面做个测试看看编译器是怎么初始化数组的:
注:定义未初始化的数组看不到汇编代码(实际上所有未初始化的变量和常量都如此),所以不测试了。
此处我只测试了几组典型的,且懒得输出了,因为我只关心如何初始化。
--------------------------------------------------------------------------
#include <iostream>
using namespace std;
int main()
{
int a1[][6] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
int a2[][6] = {1};
int a3[][7] = {1};
char c[] = "abcd";
return 0;
}
--------------------------------------------------------------------------
反汇编后得到如下代码:
7: int a1[][6] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
0040126E mov dword ptr [ebp-48h],0
00401275 mov dword ptr [ebp-44h],1
0040127C mov dword ptr [ebp-40h],2
00401283 mov dword ptr [ebp-3Ch],3
0040128A mov dword ptr [ebp-38h],4
00401291 mov dword ptr [ebp-34h],5
00401298 mov dword ptr [ebp-30h],6
0040129F mov dword ptr [ebp-2Ch],7
004012A6 mov dword ptr [ebp-28h],8
004012AD mov dword ptr [ebp-24h],9
004012B4 mov dword ptr [ebp-20h],0Ah
004012BB mov dword ptr [ebp-1Ch],0Bh
004012C2 mov dword ptr [ebp-18h],0Ch
//定义了一个2维数组,初始化了13个字符。
004012C9 xor eax,eax
004012CB mov dword ptr [ebp-14h],eax
004012CE mov dword ptr [ebp-10h],eax
004012D1 mov dword ptr [ebp-0Ch],eax
004012D4 mov dword ptr [ebp-8],eax
004012D7 mov dword ptr [ebp-4],eax
//编译器自动判断第1维大小为3,将后面5个字符初始化为0。
8: int a2[][6] = {1};
004012DA mov dword ptr [ebp-60h],1
004012E1 xor ecx,ecx
004012E3 mov dword ptr [ebp-5Ch],ecx
004012E6 mov dword ptr [ebp-58h],ecx
004012E9 mov dword ptr [ebp-54h],ecx
004012EC mov dword ptr [ebp-50h],ecx
004012EF mov dword ptr [ebp-4Ch],ecx
//编译器自动判断第1维大小为1,将后面5个字符初始化为0。
9: int a3[][7] = {1};
004012F2 mov dword ptr [ebp-7Ch],1
004012F9 mov ecx,6
004012FE xor eax,eax
00401300 lea edi,[ebp-78h]
00401303 rep stos dword ptr [edi]
//编译器自动判断第1维大小为1,将后面6个字符用rep stos指令初始化为0。注意次数被放入ecx,初值则放入eax了。
10: char c[] = "abcd";
00401305 mov edx,dword ptr [string "abcd" (0043101c)]
0040130B mov dword ptr [ebp-84h],edx
00401311 mov al,[string "abcd"+4 (00431020)]
00401316 mov byte ptr [ebp-80h],al
//这里我用了个很极端的例子。string "abcd"是5个字节的(包括结束字符'\0'),而一次最多只能取4个字节(dword),于是必须进行2次赋值。
--------------------------------------------------------------------------
可以看出,未给出第1维大小时,编译器会自动判断第1维大小;并且数组的空间是连续的,初始化也是依次进行初始化的。
另外注意一点,这是在未开启优化的debug模式下进行的测试。
如果是在开启优化的release模式下的话,编译器认为未用到的非volatile元素将被优化掉。如果你只使用了数组的其中几个元素,那么有可能这些元素也没被初始化,而是由编译器直接给出值的。
不过这一般没关系,但你在调试时也许会惊奇地发现,你定义的元素实际上可能根本不存在,你也就无法直观地调试它了。
(三)运算:
数组名在某些情况下可以当成一个指针常量,因此它也可以进行大部分指针能做的运算(除了更改它本身的内容),下面只介绍未介绍的:
array[index1][index2][...](省略号表一共有array的维数个方括号):返回array数组的各维下标分别为index1、index2...的元素的引用。
注意下标必须是整数。要安全的访问数组则要知道,下标应该是[0,该维的大小)间的整数。不过C/C++编译器并不检查下标的值是否越界,你可以给一个等于或超过该维的大小的整数,甚至可以是负整数。这也就是很多C/C++程序溢出的主要原因之一。
正如前面所说,数组名在某些情况下可以当成一个指针常量。其实指针也可以使用[]运算符,且运算规则是相同的。
*(array + index):返回array数组的下标为index的元素的引用。
对于多维数组来说,这个引用相当于一个维数下降了一维的数组。
可以嵌套使用它,即“...*(*(*(array + index1) + index2) + ...)”的形式(省略号表一共最多可以有array的维数个*)。
这和[]的访问方式是相同的,指针也可以这样使用。
最后,因为数组名在某些情况下可以当成一个指针常量,所以不能简单地对数组整体进行赋值(但可以整体初始化)。
下面测试一下编译器是如何通过这2种方式访问数组的元素的(我在其中也演示了如何将一个指针和引用指向多维数组):
--------------------------------------------------------------------------
#include <iostream>
using namespace std;
int main()
{
int a1[4] = {0};
int* p1 = a1;
int a2[4][4] = {0};
int* p21 = a2[0];
int (*p22)[4] = a2;
int (&r21)[4] = a2[0];
int (&r22)[4][4] = a2;
//这种形式是不是很像函数指针和函数引用的声明?
a1[0] = 1;
p1[1] = 2;
*(a1 + 2) = 3;
*(p1 + 3) = 4;
a2[0][0] = 1;
p21[1] = 2;
p22[0][2] = 3;
r21[3] = 4;
r22[1][0] = 5;
*(*(a2 + 1) + 1) = 6;
*(p21 + 6) = 7;
*(*(p22 + 1) +3) = 8;
*(r21 + 8) = 9;
*(*(r22 + 2) + 1) = 10;
(*(a2 + 2))[2] = 11; //注意[]前要用小括号分开。
(*(p22 + 2))[3] = 12;
(*(r22 + 3))[0] = 13;
for (int i = 0; i != 4; ++i)
{
cout << "a1[" << i << "] = " << a1[i] << '\n';
}
cout << '\n';
for (int j = 0; j != 4; ++j)
{
for (int k = 0; k != 4; ++k)
{
cout << "a2[" << j << "][" << k << "] = " << a2[j][k] << '\n';
}
}
return 0;
}--------------------------------------------------------------------------
输出:
a1[0] = 1
a1[1] = 2
a1[2] = 3
a1[3] = 4
a2[0][0] = 1
a2[0][1] = 2
a2[0][2] = 3
a2[0][3] = 4
a2[1][0] = 5
a2[1][1] = 6
a2[1][2] = 7
a2[1][3] = 8
a2[2][0] = 9
a2[2][1] = 10
a2[2][2] = 11
a2[2][3] = 12
a2[3][0] = 13
a2[3][1] = 0
a2[3][2] = 0
a2[3][3] = 0
--------------------------------------------------------------------------
赋值部分的反汇编代码:
17: a1[0] = 1;
004015D1 mov dword ptr [ebp-10h],1
18: p1[1] = 2;
004015D8 mov eax,dword ptr [ebp-14h]
004015DB mov dword ptr [eax+4],2
19: *(a1 + 2) = 3;
004015E2 mov dword ptr [ebp-8],3
20: *(p1 + 3) = 4;
004015E9 mov ecx,dword ptr [ebp-14h]
004015EC mov dword ptr [ecx+0Ch],4
21:
22: a2[0][0] = 1;
004015F3 mov dword ptr [ebp-54h],1
23: p21[1] = 2;
004015FA mov edx,dword ptr [ebp-58h]
004015FD mov dword ptr [edx+4],2
24: p22[0][2] = 3;
00401604 mov eax,dword ptr [ebp-5Ch]
00401607 mov dword ptr [eax+8],3
25: r21[3] = 4;
0040160E mov ecx,dword ptr [ebp-60h]
00401611 mov dword ptr [ecx+0Ch],4
26: r22[1][0] = 5;
00401618 mov edx,dword ptr [ebp-64h]
0040161B mov dword ptr [edx+10h],5
27: *(*(a2 + 1) + 1) = 6;
00401622 mov dword ptr [ebp-40h],6
28: *(p21 + 6) = 7;
00401629 mov eax,dword ptr [ebp-58h]
0040162C mov dword ptr [eax+18h],7
29: *(*(p22 + 1) +3) = 8;
00401633 mov ecx,dword ptr [ebp-5Ch]
00401636 mov dword ptr [ecx+1Ch],8
30: *(r21 + 8) = 9;
0040163D mov edx,dword ptr [ebp-60h]
00401640 mov dword ptr [edx+20h],9
31: *(*(r22 + 2) + 1) = 10;
00401647 mov eax,dword ptr [ebp-64h]
0040164A mov dword ptr [eax+24h],0Ah
32: (*(a2 + 2))[2] = 11;
00401651 mov dword ptr [ebp-2Ch],0Bh
33: (*(p22 + 2))[3] = 12;
00401658 mov ecx,dword ptr [ebp-5Ch]
0040165B mov dword ptr [ecx+2Ch],0Ch
34: (*(r22 + 3))[0] = 13;
00401662 mov edx,dword ptr [ebp-64h]
00401665 mov dword ptr [edx+30h],0Dh
--------------------------------------------------------------------------
可以看出,使用指针和引用操访问数组元素,比使用数组名访问数组元素多一个获得指针或引用指向的地址的步骤。
而使用[]和*运算符的效率是相同的。
--------------------------------------------------------------------------
插播一个花絮:
记得我学习C语言的时候,老师说使用指针要比数组名访问数组元素快,使用*要比[]快,于是要我们尽量用指针和*进行操作。
我于是傻乎乎地全用指针去做老师布置的实验作业,被满屏幕的指针弄得都头晕了。
要是早做了这个测试的话(实际上我是写这篇blog时才第一次做的测试),我才不会去听老师的鬼话了。
(四)其他:
继续回到主题,下面说说数组的类型。
如果采用“type array[size1][size2][...];”的方式声明一个数组,那么这个数组的类型就是“type [size1][size2][...]”。(这里的引号用于分隔,并不是表示字符串类型。下同。)
但是在使用时,它往往会隐式转型为“type (*)[size2][...]”类型,这也叫做decay。(还记得函数指针隐式转型为函数名吗?)
这种行为在给函数传递以数组名作为参数时就会发生,但有一个例外:将它传递给一个模板函数,且该参数为引用类型。
于是这里可能导致出错,因为模板函数需要完全匹配参数类型,而数组的类型是包括它的维数和维的大小在内的。
对于一维数组而言,它们通常decay为“type *”类型,因而可以匹配;但使用引用参数的模板函数则必须匹配数组的维数。
同样的,对于多维数组而言,普通函数也得考虑这个问题。
下面做个测试,看看decay究竟如何发生的:
--------------------------------------------------------------------------
#include <iostream>
using namespace std;
void f1(int*);
void f2(int [10]);
void f3(int (*)[10]);
void f4(int [10][10]);
int main()
{
int a[10];
cout << "The type of \"int a[10]\" is \"" << typeid(a).name() << "\"\n";
cout << "The type of \"int [10]\" is \"" << typeid(int [10]).name() << "\"\n";
cout << "The type of \"void f1(int*)\" is \"" << typeid(f1).name() << "\"\n";
cout << "The type of \"void f2(int [10])\" is \"" << typeid(f2).name() << "\"\n";
cout << "The type of \"void f3(int (*)[10])\" is \"" << typeid(f3).name() << "\"\n";
cout << "The type of \"void f4(int [10][10])\" is \"" << typeid(f4).name() << '\"' << endl;
return 0;
}
--------------------------------------------------------------------------
输出:
The type of "int a[10]" is "int *"
The type of "int [10]" is "int [10]"
The type of "void f1(int*)" is "void (__cdecl*)(int *)"
The type of "void f2(int [10])" is "void (__cdecl*)(int * const)"
The type of "void f3(int (*)[10])" is "void (__cdecl*)(int (*)[10])"
The type of "void f4(int [10][10])" is "void (__cdecl*)(int (* const)[10])"
--------------------------------------------------------------------------
可以看出,所有传递数组的函数类型都decay了。
注:
1.(__cdecl*)是函数的调用方式。
2.函数模板必须在实例化后才能得到其类型,而在VC++6.0中传递数组名给typeid就会decay,所以我不知道有什么好办法显示其类型。
3.但是“将数组名传递给一个模板函数,且该参数为引用类型”时不发生decay这个规则是没错的,可以通过传递2个大小不同的数组的数组名作为参数来验证。
由于数组的不安全性和不方便性,所以如果不是对性能要求非常高的话,C++中一般使用verctor等容器来代替它。
四、字符串(string)
(一)描述:
C风格的字符串是以'\0'结尾的一维字符数组。
它提供了以数组的形式对一串字符进行操作的方式。
注:未特别指明时,本文讨论的字符串均指C风格的。它以char为元素类型,而非以wchar_t为元素类型。也不要和C++中的string类等混淆了。
(二)定义和声明:
由于字符串是一个一维字符数组,而在讲述数组时已经写过如何定义字符数组了,所以这里就不再重复了。
不过还得提一下转义序列,即用\和其他字符组合,表示一个字符。这些可以在很多资料中找到说明,我就也不解释了。
下面来测试一下字符串的类型:
--------------------------------------------------------------------------
#include <iostream>
using namespace std;
int main()
{
char string1[] = "string";
char* string2 = "string";
cout << "The type of \"string\" is " << typeid("string").name() << ".\n";
cout << "The type of string1 is " << typeid(string1).name() << ".\n";
cout << "The type of string2 is " << typeid(string2).name() << ".\n";
return 0;
}
--------------------------------------------------------------------------
输出
The type of "string" is char [7].
The type of string1 is char *.
The type of string2 is char *.
--------------------------------------------------------------------------
很不幸,将数组名和字符指针名传递给typeid时,又发生了decay,因此不得不看反汇编代码:
7: char string1[] = "string";
00401168 mov eax,[string "string" (0043c078)]
0040116D mov dword ptr [ebp-8],eax
00401170 mov cx,word ptr [string "string"+4 (0043c07c)]
00401177 mov word ptr [ebp-4],cx
0040117B mov dl,byte ptr [string "string"+6 (0043c07e)]
00401181 mov byte ptr [ebp-2],dl
//依次将"string"的字符复制到string1的字符数组中
8: char* string2 = "string";
00401184 mov dword ptr [ebp-0Ch],offset string "string" (0043c078)
//将指针指向"string"字符串
--------------------------------------------------------------------------
可以看出,这2种定义方式的并不相同。
还可以看到VC++6.0将"string"字符串的地址都当成固定的了(此处是0043c078),这在其他的编译器中可能不是这样的。
另外,如果选择了优化,该字符指针可能不会被创建。
其实,更简单的方式是用sizeof输出大小,这里就不测试了。
由于字符串可以从char [size]类型decay为char*类型,所以很多地方都可以和字符指针混用,但它们之间仍然是不同的。
下面再测试一下:
--------------------------------------------------------------------------
//string_test1.cpp
#include <iostream>
using namespace std;
extern char string1[];
extern char* string2;
extern char* string3;
extern char string4[];
int main()
{
cout << "string1 : " << string1;
cout << "\nstring2 : " << string2;
cout << "\nstring3 : " << string3;
cout << "\nstring4 : " << string4;
cout << endl;
return 0;
}
--------------------------------------------------------------------------
//string_test2.cpp
char string1[] = "string";
char* string2 = "string";
char string3[] = "string";
char* string4 = "string";
--------------------------------------------------------------------------
我故意将2个文件中的声明更改了,如果你将注释符号去掉的话,就很可能导致程序崩溃了。
下面反汇编看看原因吧:
--------------------------------------------------------------------------
12: cout << "string1 : " << string1;
00401398 push offset string1 (00435dc0)
0040139D push offset string "string1 : " (0043204c)
004013A2 push offset std::cout (00439550)
004013A7 call @ILT+155(std::operator<<) (004010a0)
004013AC add esp,8
004013AF push eax
004013B0 call @ILT+155(std::operator<<) (004010a0)
004013B5 add esp,8
//把string1的地址00435dc0压栈
13: cout << "\nstring2 : " << string2;
004013B8 mov eax,[string2 (00435dc8)]
004013BD push eax
004013BE push offset string "\nstring2 : " (0043203c)
004013C3 push offset std::cout (00439550)
004013C8 call @ILT+155(std::operator<<) (004010a0)
004013CD add esp,8
004013D0 push eax
004013D1 call @ILT+155(std::operator<<) (004010a0)
004013D6 add esp,8
//把string2的内容(即string2指向的地址单元)压栈
14: cout << "\nstring3 : " << string3;
004013D9 mov ecx,dword ptr [string3 (00435dcc)]
004013DF push ecx
004013E0 push offset string "\nstring3 : " (0043202c)
004013E5 push offset std::cout (00439550)
004013EA call @ILT+155(std::operator<<) (004010a0)
004013EF add esp,8
004013F2 push eax
004013F3 call @ILT+155(std::operator<<) (004010a0)
004013F8 add esp,8
//把string3的内容(即string3指向的地址单元)压栈
//但是string3应该是数组名,它指向什么呢?
//答案很可能是不能访问的内存区域
15: cout << "\nstring4 : " << string4;
004013FB push offset string4 (00435dd4)
00401400 push offset string "\nstring4 : " (0043201c)
00401405 push offset std::cout (00439550)
0040140A call @ILT+155(std::operator<<) (004010a0)
0040140F add esp,8
00401412 push eax
00401413 call @ILT+155(std::operator<<) (004010a0)
00401418 add esp,8
//把string4的地址00435dd4压栈
//但是string4应该是指针,将它的地址压栈输出的是什么呢?
//答案很可能是看不懂的乱码
--------------------------------------------------------------------------
可见处理字符串和字符指针时,方式是不同的。编译器是根据每个翻译单元来判断使用什么方式的,所以在各个翻译单元中,声明一定要和定义完全相同。
当然,像这种数据,最好是放在头文件中让其他文件包含。
(三)运算:
因为字符串是一个一维字符数组,所以支持一维数组可以进行的运算。
另外,字符串名也可decay为一个字符指针,所以在它们自己比较大小,是根据指针的运算规则。
但有的编译器会将字符串直接量的地址都看成相同的,你可以通过检验("string" == "string")的值来确定。但这不是标准所规定的。
处理字符串还有很多标准库函数可用,它们大多需要包含<string.h>或<cstring>。
(四)其他:
注意字符串本身并不知道其大小,它是以'\0'判断结束的。所以如果忘记用'\0'结束,则很容易出现问题。这也是很多C/C++程序中出现溢出的主要原因之一。
由于C风格的字符串的不安全性和不方便性,C++中一般使用string类代替。而stringstream也可代替char数组作缓冲区。
--------------------------------------------------------------------------
思考题:
1.根据第一、二节的内容,说明为什么C语言中的标准库函数scanf()需要传递变量的地址,而printf()只需要传递变量的值。
2.根据第一、二节的内容,分析下面的程序片断是否正确(不考虑异常安全性):
int* p = new int;
int& i = *p;
delete &i;
3.根据第三、四节的内容,思考如果数组越界操作了会怎样。验证你的想法并参考关于溢出的资料(貌似很多黑客网站都有介绍)。