细粒度C++:指针、数组、引用

第6章 指针、数组、引用

本文作者:黄邦勇帅(原名:黄勇),QQ:42444472 (读者意见可发至QQ)
本系列文章是对《C++语法详解》的增补版本,涵盖C++20的内容,本文参考ISO/IEC 14882 第6版(2020-12)。

本文是粗稿以后有更改的可能性且由于本人能力有限,文中难免有错漏之处,望广大读者指出更正,不胜感激
本文为原创文章,转载请注明出处,并注明转载自“黄邦勇帅(原名:黄勇)”,本文作者拥有完全版权


1、指向cv void或指向对象类型的指针被称为对象指针类型(object pointer type)。指向函数的指针被称为函数指针类型。指向T类型对象的指针称为“指向T的指针”。
2、本章的对象是指的“具有某种类型的一个或多个连续的内存单元”,也就是说,对象由多个内存单元组成,并且是连续的且有某种类型。注:内存空间也是由多个内存单元组成,但内存空间不需要有类型也不一定必须连续。C++规定“可用的内存由一个或多个连续的字节序列组成”。因此,若未特殊说明,本章均假设组成字节的位是连续的,组成对象的内存单元也是连续的(即对象是连续的)。
3、对象类型指的是描述对象的类型,这意味着对象类型可以根据类型确定内存空间大小,这些类型包括整型、浮点型、字符型等,注意,函数类型和不完整类型都是无法确定内存单元大小的,因此都不是对象类型。本章只讲解对象指针类型,注意,对象指针类型包含指向void的指针。函数指针本章不作讲解。
4、本章所指的左值和右值是指的传统意义上的左值和右值,主要用于区分内存空间和值。若是C++11之后的值类别,使用英文进行表述,如lvalue表示C++11之后的值类别“左值”,而不是传统意义上的左值。
5、指针两要素:指针类型和指针指向何处。大多数读者只关心指针指向何处,而忽略了指针类型的重要性,本文将对指针这两方面的内容都加以分析。
6、本章需要具有较强的复杂类型分析能力,请认真阅读第4章。

6.1 指针

6.1.1 指针与内存

一、地址值、地址类型、地址变量、地址常量的引入
1、为弄清楚指针的具体含义,有必要引入地址类型、地址变量等概念,注意,这些概念都不是C++中的概念。

2、地址值和内存单元

  1. 计算机中的内存,一般都是以字节为单位进行划分的,也就是说计算机的内存的最小单位是1个字节(通常是8位)。
  2. 内存单元:一个内存单元占据1个字节(8位)的内存。也就是说,内存单元是计算机的内存的最小单元。
  3. 地址值:每个内存单元都会使用一个唯一的值来标识,这个值就是地址值,地址值通常被简称为地址。有了地址值之后,我们便可以使用地址值来访问内存。地址值通常是一个整数,通常使用16进制表示。地址值与内存单元一一对应,因此,可以使用地址值来指示一个内存单元,所以,在计算机中通常使用“地址”一词来表示内存单元。

3、地址中的值
地址中的值是存储在内存单元中的值,地址值是标识地址的,注意二者的区别。比如,假设某内存单元的地址值为0x0012FF60,在该内存单元中存储的值为10,则可以描述为“地址0x0012FF60中的值为10”,其实这是说的“内存单元0x0012FF60存储的值为10”

4、地址类型
就像浮点数拥有浮点类型一样,地址值也应拥有一种数据类型,本文把地址值拥有的类型称为“地址类型(注意:C++没有这种类型)”。地址值虽然使用整数来表示,但它的类型不是整数类型,因此当内存中存储的数据为地址值时,它的类型是地址类型而非整型。

5、读者可能认为地址类型的值是一个整数值不好理解,其实这就好比一个整数值既可以是浮点类型也可以是整数类型一样,程序能区分出一个整数值按什么类型进行存取,比如将一个整数值赋给浮点型变量,则这个整数值会被按照浮点型的格式在内存中进行存储。

6、地址类型变量(地址变量)
引入地址类型之后,就可以引入对应的地址类型变量了(注意:C++没有这种变量),这就好比整型是一种类型,整型变量是表示整型的变量,整型变量存储的值是整数值一样。

7、地址常量
整数常量,如2、3等,就是地址常量,但在C++中对地址常量的使用是有限制的,地址常量通常使用16进制的形式表示,如0x00001234等。因此一个整数值既是整数常量又是地址常量还可能是浮点常量,如,3既可以表示整数、也可以表示地址、同时还可能是浮点数,具体是什么类型需从上下文区分。

二、指针类型、指针变量、指针值、指针常量的引入

1、在C++中没有地址类型变量、地址类型、地址值、地址常量的说法,取而代之的是指针类型变量(指针变量)、指针类型、指针值、指针常量。也就是说在C++中指针类型就是指的地址类型,指针变量就是指的地址变量,因此,在C++中通常把“指针”和“地址”混合使用。

2、指针类型是一种类型,它与整型、浮点型相同,都是类型中的一种,因为指针类型是类型所以就决定了以下一些性质

  1. 因为是类型所以就有与这一类型相关的变量和值,就像整型类型拥有相关的整型变量和整型值一样,因此就有了关于指针类型的变量(简称为指针变量或指针)和指针类型的值(简称为指针值)。
  2. 指针类型决定了分配内存的长度(或称为大小),一般在32位机器上指针类型占据内存的大小为4字节,在64位机器上为8字节。
  3. 指针类型决定了分配的这段内存中应保存何种类型的数据,在类型为指针类型的这段内存中保存的数据是地址类型的值(简称地址值),这个值可能以10进制,2进制或16进制的整数形式表式,在计算机中一般以16进制形式表示地址值,当然也可以以10进制形式表示地址值,注意:虽然地址值是一个整数值,但是他的类型是地址类型的,而不是整型类型,这就好比整数“2”若在内存中保存为浮点型,但这个数并不是一个整数类型相似。

3、指针类型与指针指向的类型
需要特别注意指针类型与指针指向的类型是不同的,最直接的影响就是,指针类型的大小与指针所指向的类型的大小没有关系。比如

示例6.X:指针类型与指针指向的类型
//假设指针类型的大小为4字节,double为8字节,char为1字节
double *p;		//指针p的大小为4字节,但p指向的对象的大小为8字节
char *p1;		//指针p的大小仍为4字节,但p指向的对象的大小为1字节
void *p2;		//指针p2的大小仍为4字节,但p2指向的对象的大小不能确定

4、注意,对象类型是能根据其类型确定内存的大小的,而函数类型和不完整类型则不能,本章不对指向函数类型的指针进行讲解。

三、指针变量怎样存储地址值

1、指针类型的大小
指针类型的大小与地址值的取值范围有关系,对于32位机器,可以标识232 = 4 294 967 296个内存单元,一个内存单元为1字节(即1B),因此,对于32位机器可以标识“4 294 967 296 B = 4GB”的内存空间。因此,对于32位机器,地址值应是4个字节(32位)的二进制整数(一般使用16进制书写)。因此,指针类型在32位机器上的大小通常是4字节(32位),在64位机器上一般为8个字节(64位)。总之,指针类型的大小依机器而定。若未特殊说明,本文均假设指针类型的大小为4字节。

2、指针变量用于存储对象的地址值。指针变量只能保存一个内存单元的地址值,但是,一个对象一般不只占据一个内存单元的大小,因此指针变量只能选择存储对象的首地址值或尾地址值(通常存储首地址值),然后再根据该对象的类型,就能确定该对象应占据多少个内存单元。因此指针变量相当于是一个指向某个对象的箭头。比如

double a=1;		//假设a占据8个内存单元(即8字节大小)
double *p=&a;		//通常,指针p保存变量a的第一个内存单元的地址(即首地址),然后根据a的类型,
						//就能确定a占据多少个内存单元,此时指针p就好像一个指向变量a首地址的箭头一样

3、C++规定,指针变量的值可以是该对象占用的内存中的第一个字节的地址(即对象的首地址),或该对象占用的存储结束后的第一个字节的地址(即指针可以越过对象,但不建议这样做),此时不被认为指向与该对象类型不相关的对象,即使该位置存在一个不相关对象。此处说明了如下几点:

  • 指针变量可以存储对象所占用内存单元的首地址。注意,以上规则只是说明了允许指针变量存储什么位置的值,虽然通常情况下,指针变量存储的是对象所占内存单元的首地址,但以上规则并未明确规定指针变量的值必须是对象所占内存单元的首地址。
  • 指针指向对象所占内存空间之后的第一个内存单元的位置是合法的,但不建议这样做,因为,此时不知道指针指向的内存单元的内容到底是什么,这可能会得到意想不到的结果。比如,
示例6.X:指针越过对象
int a=1;		//假设占据内存单元1~4
float b=2;		//假设占据内存单元5~8,按IEEE754标准,浮点数2被存储为0x40000 0000,
				//即二进制串0100 0000 0000 0000 0000 0000 0000 0000
int *p=&a;		//通常情况下应存储内存单元1的地址值(即a的首地址)
p+4;			//指针的加法详见后文。将指针向后偏移4个字节,即p+4指向内存单元5,这是合
 				//法的,但不建议这样做,因为,这可能会得到意想不到的结果。虽然内存单元5存储
 				//的是float类型的对象,但此时仍认为p+4指向的是int类型而不是float类型
cout<<*(p+4);	//输出1073741824(即0x40000 0000)而不是2,这是按int类型解释
 				//内存单元5~内存单元8中存储的浮点数据2的结果。

4、因为地址值是一个整数值,这就意味着可以直接将一个整数值赋给指针变量,但C++禁止了直接将整数值赋给指针变量的这一行为(0值除外),因此在C++中将一个整数赋给指针类型变量不会成功。在很早的C语言中,这种赋值方式是允许的。但是,可通过将整数值强制转换为地址类型(指针类型)后再使用,注意,不建议这样做,因为并不知道该内存中的内容具体是什么。比如

*(int *)100=1; 		//把值1存储于地址值为100的内存位置。可能会产生内存冲突或程序崩溃,
 						//因为并不知道地址值为100的位置究竟是什么。
int *p=(int *)100;	//把内存中地址值为100的位置赋给指针p。C++20允许,但不推荐这样做。

四、图解对象、变量、类型、左值、内存、内存的划分、指针
在这里插入图片描述

1、内存单元占据1个字节(8位),其中的每一位都可以存储一个二进制数值,每个内存单元都使用一个地址值进行标识,对于32位机器,一般使用4个字节的地址值来标识一个内存单元,比如图6.1的内存单元1使用地址值0x0012FF60进行标识,其中0x0012FF60是一个16进制整数值,但其类型是地址(或指针)类型。

2、数据的类型决定了分配多少个连续的内存单元,可以存储什么样的数据及运算等特性,说简单一点就是数据类型决定了怎样解释内存单元中的数据。比如int型一般占据4个字节,则int就决定了应为数据分配4个字节的内存单元,即要分配4个内存单元。

3、对象由一个或多个连续的内存单元组成。在图6.1中,假设某数据为int型,且为该数据分配的内存单元分别是图6.1中的内存单元1 ~ 4,则对象指的是内存单元1 ~ 4,总共4个内存单元。

4、变量是命名的对象,左值是指示一个对象的表达式。假设图6.1的内存单元1 ~ 4为一个对象,并为该对象取一个名字a,则名称a就是变量,表示的就是内存单元1 ~ 4这4个字节的内存单元。左值为指示一个对象的表达式,说明左值应是一个表达式,这个表达式应能表明指示的是一个对象,比如a=1这个表达式中,a指示了对象表示的内存单元1 ~ 4,因此a是左值。因为单独的一个变量就是一个表达式,所以单独的变量名称a就是一个左值,表示一个或多个连续的内存单元。

5、内存中存储的数据依声明时的类型而被解释。若图6.1中内存单元1 ~ 4的数据为整型则数据被解释为整数1094713344,若为浮点型则被解释为浮点数12(按IEEE754标准进行转换),若此数据被解释为一个内存地址值,则此数据为地址值0x41400000(16进制)。

6、指针变量存储的值应是地址类型的值(地址值)。假设有一个指针变量p,则存储的值应是内存单元的地址值,比如图6.1中的0x0012FF60,0x0012FF61等。假设int型被分配到图6.1中的内存单元1~4,并且将这段对象命名为a(即变量),并把这个变量a的地址赋给了指针p,则指针p存储的值将是变量a所代表的内存单元的首地址(依机器而定),即p的值是内存单元1的地址值0x0012FF60。C++语句的形式为:

int a=1094713344;
int *p=&a;		//将a的地址值赋给p。p=0x0012FF60,*p=1094713344。关于&和*运算符,详见后文

注意:p是一个变量(指针变量),p存储的值是a的地址值,*p是指的p所指向的对象(即内存)的值。

五、易混概念

以下概念经常被混淆使用,但从上下文应能很容易区分开来。

1、地址、地址值、地址类型经常被统称为地址。单独说“地址”一词时既可以表示地址值,也可以表示地址类型,有时还表示内存单元,还可以表示对所有内存单元地址的统称,这能从上下文区分开来,这就好比我们说3是一个整数一样,这里的整数既可以代表整数值也可以代表整数类型,这能从上下文区分开来。

2、由于习惯的原因指针、指针类型、指针变量、指针值常被统称为指针,比如int * p; 则描述“p是一个指针”,是在说p是一个指针变量。描述“p的类型是一个指针”,是在说p的类型是指针类型。描述“0x0012FF60是一个指针”,这是在说0x0012FF60是一个指针值。

3、地址和指针两个概念也常被混合使用,比如&a;既可以说成返回a的地址,也可以说成返回a的指针。

6.1.2 取址运算符&与解引用运算符*

注:读取内存地址时本章都假设读取的是首地址而不是尾地址。一个内存单元占1个字节的大小。

一、&(地址或取址)运算符

1、简单理解取址运算符&
取址运算符&的作用是读取对象所示内存单元的地址(值),但只能读取该对象所示内存单元的首地址(依机器而不同),即第一个内存单元的地址值。比如&a,表示读取操作数a的地址值,该运算符并不会把操作数所表示对象的所有内存单元的地址值都读取出来,一般只读取第一个内存单元的地址(即首地址),然后再根据操作数的类型,就能知道该操作数共占据多少个内存单元,比如若a是int型,假如int占4字节内存单元,若地址分别从0x0000 0002至0x0000 0005,则&a将只会读取到操作数a所在的连续内存单元的首地址0x0000 0002,而不会全部读出,然后再根据0x0000 0002所表示的对象a的类型,就能确定a占据地址0x0000 0002至0x0000 0005(假设对象所占据的内存单元是连续的)。

2、取址运算符基本规则如下:

  1. 操作数要求:取址运算符&是一元运算符,其操作数应是类型为T的lvalue(左值),不能是位段。取址运算符&的操作数是左值的原因非常简单,因为,只有左值才存在内存地址。
  2. 结合性及求值顺序:取址运算符&的结合性是按照从右到左执行的。取址运算符&没有规定操作数的求值顺序。
  3. 结果:取址运算符&的结果的值类别是prvalue(纯右值)。若操作数是类型T的lvalue,则结果类型是“指向类型T的指针”,即类型为“T*” ,这个指针(准确的说是地址)指向由操作数所指示的对象或函数。也就是说,取值运算符的结果其实是一个地址(指针),通过这个地址的值(地址值)能间接的找到由操作数所指示的对象或函数。由于取址运算符&的结果是一个地址值,并且没有为该地址值分配内存空间,所以,取址运算符&的结果不能作为左值,只能作为右值。比如int a=1; 则&a的结果是指向操作数a所指示对象的指针,这个指针的类型是“指向int的指针”,即“int*”,这个指针并没有被分配内存,只能作为右值使用,这时这个指针的值就是操作数a所代表的一片连续的内存单元(假设为4个字节)的首或尾地址。
  4. 注意,对类型为“cv T”的变量取址,会得到一个类型为“指向cv T的指针”的指针,即类型为“cv T*”。比如
const int a=1;
//int *p=&a;			//错误。&a的类型是“指向const int的指针”,不能赋给指向int
 						//的指针,详见后文
const int *p1=&a;		//正确。

二、*(解引用或间接)运算符

1、解引用运算符 * 是一元运算符,其结合性是按照从右到左执行的,解引用运算符没有规定操作数的求值顺序。

2、解引用运算符的操作数必须是指向对象类型或指向函数类型的指针,并且应是右值。注意:对指向不完整类型(cv void除外)的指针执行解引用运算是有效的,但这样得到的左值只能以有限有方式使用(比如,用于初始化引用),该左值不能转换为纯右值。

3、若操作数的类型是指向type的指针,则结果的类型就是该type

4、解引用运算符的结果是其操作数所指向的对象或函数的lvalue。这意味着(操作数指向函数除外,指向函数的指针详见函数章节):

  • 解引用运算符的结果不是右值,若解引用运算符的结果是右值,则不能间接改变指针所指对象的值。

  • 解引用运算符可以获取操作数(或指针)所指向的对象,这意味着,二者指向同一个对象(即,表示同一内存空间),因此,可以使用解引用运算符间接改变指针所指向的对象的值。比如

    int a=1; 
    int *p=&a;		//注意,此语句是声明指针p的语法形式,此处的*不能解释为解引用运算符,而是声明
     				//符。若在此之后的表达式中再次出现*p,才能将*解释为解引用运算符。还应注意,
     				//此语句初始化的是指针p本身,而不是初始化的*p,此语句与以下两条语句等价
     				//int *p;  p=&a;
    *p=3;			//此时的*是解引用运算符。此处会间接改变变量a的值为3
    

    p=3中的p的结果是操作数p所指向的对象a的lvalue,操作数p指向的对象是被命名为a的对象,也就是说,此时 * p和a都表示相同对象的左值,说简单一点, p和a表示同一内存空间,所以,对 * p的改变,会影响到变量a的值,因此,* p=3,将使变量a的值也变为3。

  • 注意,普遍的最简单的理解方式“解引用运算符能得到操作数所指对象处存储的值”,这里只理解了解引用运算符的一部分内容。比如

    int b=1; 
    int *p=&b;
    

    则在需要将* p作为右值的地方(会经过从左值到右值的转换),确实得到了p所指对象处存储的值1。但解引用运算符的结果是左值,还可以对* p进行赋值,而简单的理解强调的是所指地址处存储的值,值是右值,不能对右值进行赋值操作。当然左值可以作为右值使用,所以简单的理解方式只解释了解引用运算符的一部分内容。下面再举一示例:

    int a=1; 
    *&a;
    

    其中 * & a 的操作数&a是一个指向对象a的指针,也就是说操作数指向对象a,因此 * & a的结果表示的就是操作数&a指向的对象a的左值,也就是说 * &a和a都是指的名称为a的对象的内存空间,因此 * &a与a等价,所以, * &a的值是1,同时*&a是左值,可以对其赋值,比如 * &a=3;这时会间接改变a的值。因为&a是指向int的指针,所以 * &a结果的类型是int型。

5、注意:* 解引用运算符与 * 乘法运算符是相同的,&取址运算符与&按位与运算符是相同的,这些运算符都能根据程序的上下文区分开来。

三、取址运算符&总结
1、&运算符能读取对象所示连续内存单元的地址(值),一般为首或尾内存单元地址值。
2、&的操作数是左值。
3、&运算符的结果是右值,不能作为左值。
4、若a的类型是T,则&a的结果类型是”指向T的指针”,即“T*”。

四、解引用运算符总结
1、使用解引用运算符之后,可以获取操作数(或指针)所指向的对象。比如int a=1; int * p=&a; 则 * p就是表示的是操作数p所指向的对象a。
2、
运算符的操作数应是右值,且应是指向对象或函数类型的指针。
3、* 运算符的结果是一个左值,因此可以对解引用运算符的结果进行赋值。
4、* 运算符结果的类型就是操作数指向的对象的类型。
5、* 运算符会间接改变操作数所指向的对象的值,这一点相当重要。

五、图解指针的概念、地址、&运算符与*运算符
在这里插入图片描述
1、假设有如下的声明(见图6.2):

int a=128;  
int *p=&a;		//注意,这是声明指针p的语法形式,此处的*是声明符而不是解引用运算符

在声明语句int * p=&a;中的&a表示读取变量a所代表的一片连续的内存单元的首地址(即图6.2中的内存单元1的地址)。然后将读取出来的首地址用于初始化指针变量p,这时指针变量p的值是变量a所指示的内存单元1的地址值,即0x0012FF60。因此指针p相当于一个箭头,这个箭头指向了变量a所表示的多个连续的内存单元的首个内存单元(即内存单元1)。
2、从图6.2中可见,指针变量p的值就是变量a的首地址值,指针变量p的值虽是一个整数,但他的类型是地址类型(指针类型),而不是整数类型。
3、若在以后的表达式或语句中使用形如 * p的形式,比如 * p=3;则是解引用运算符,而不是在声明时的声明符,这时解引用运算符的结果是指针p所指对象a的左值,即, p和a都表示图6.2中的内存单元1 ~ 4,因此 * p和a是等价的,因此 * p=3相当于是对a进行赋值,间接的改变了变量a存储在内存单元中的值;cout<< * p;相当于是输出a的值。

6.1.3 指针的描述方式

1、指针是一种复合类型,是由其他类型复合(或派生)而得到的,指针的类型在C++标准中被描述为

		指向....的指针

比如int * p;则指针p的类型描述为“指向int的指针”。指针类型需要使用更复杂更多的额外文字来描述,不像int、long等类型,可以使用简短的英文字符或类型本身来描述。有关“指针类型”的基础内容详见4.5.3节。

2、由于指针的描述容易产生混淆,本文对指针采取以下几种描述方式

  1. 指向某类型的指针
    这本来就是对指针类型的描述,因此,这里主要强调指针的类型,假设类型为T,则描述“指向T的指针”,表示指针的类型为“T*”

  2. 指向某对象的指针
    这里主要强调是一个指针变量,这里的对象是&运算符的操作数,也就是说,指针指向的对象实际上是&运算符的操作数,否则,是不正确的描述。只有这样才能弄清楚指针指向的对象倒底是什么。比如

    示例6.X:指针指向的对象1
    int a=1;
    int *p=&a;
    

    则p可描述为“指向a的指针”,注意,a是&运算符的操作数,若不满足这个条件则不能这样描述。于是p的类型可通过将a的类型int替换a而得到,即指针p的类型为“指向int的指针”,即“int * ”。由于p与&a在此处等价,因此,对于&a也可与指针p有相同的描述,比如,也可将&a描述为“指向a的指针”,其类型为“指向int的指针”等等。

    示例6.X:指针指向的对象2
    int b[4];
    int (*p)[4]=&b;		
    int *p1=b;
    
  • 由于b的类型是int4,因此,“指向b的指针”描述的指针的类型是“指向int[4]的指针”,即“int( * )[4]”,即以上代码中的p。

  • 由于b[0]的类型是int,因此,“指向b[0]的指针”描述的指针类型是“指向int的指针”,即“int * ”。

  • 注意,由于int * p1=b中的b不是&运算符的操作数,因此,p1不能被描述为“指向b的指针”,也就是说p1指象的对象不是b。实际上,b在此处表示一个指针而非数组(其原因详见后文“数组到指针的转换”),因此,该语句实际上是

    int *p1=&b[0];
    

    因此,p1是“指向b[0]的指针”,也就是说p1指向的对象是b[0]而不是b,其类型为“指向int的指针”,即“int*”。

6.1.4 指针的声明

指针的声明详见第4章,但要注意以下问题
1、声明多个指针时注意区分声明符和声明说明符。比如

int *const p1, p2;		//p1是指向int的const指针,p2是int型变量

以上语句的声明说明符是int,而*const并不是声明说明符,所以,*const p1是一个声明符,同理p2也是声明符,因此以上语句与以下语句等效

int *const p1;
int p2;

2、建议在声明指针时将*靠近标识符一侧,而不要靠近类型一侧,以免产生误解,比如

int* p1, p2;		//*靠近类型,容易误认为p1和p2都是一个指向int的指针
int *p1, p2;		//*靠近标识符,更容易区分p1是一个指针,p2是一个int型变量

3、在C++中,指针声明符、乘号、解引用运算符都是使用的符号“ * ”,注意从上下文区分 * 的作用,这通常很容易区分,比如:

示例6.X:区分声明符、乘号、解引用运算符
int a=2;
int *p1,*p2;	//这是一个声明语句,因此,*是声明符
p1=&a;
p2=&a;
(*p1)*(*p2);	//第1个*和第3个*是解引用运算符,第2个*是乘号,因此,此表达式的结果为4

4、不要使用未初始化的指针
若使用了未初始化的指针,则其行为是未定义的,使用未初始化的指针可能导致程序崩溃。未初始化的指针未定义,并不代表指针没有值,而是拥有一个随机值,因为指针保存的是地址,因此这个随机值会被解释为内存地址,当使用解引用运算符*访问这个指针中保存的随机地址处的内容时就极易可能导致程序崩溃。

5、因为指针变量存储的值是对象的地址,即指针的值是地址值,因此指针名将是一个地址值。比如

int a=1; 
int *p=&a; 
cout<<p;		//输出p 的值,因为p的值是变量a的地址(一般为首地址),因此将输出变量a的地址
cout<<*p;		//输出存储在指针所指变量a处的值1应在指针名前使用*运算符

6、注意,指针不能指向引用类型,指针永远不能指向位域(因为位域不能取值)。比如

int &(*p);			//错误。指针指向引用类型
int &&(*p1);		//错误。同上

6.1.5 各种指针

一、二级指针(指向指针的指针)及多级指针
1、指针本身是一个变量,因此指针本身也会占据内存空间,因此可以使用另一个指针存储指针所占内存空间的地址值,这样就出现了指向指针的指针,即多级指针。图6.3是二级指针的一个示例图,图中假设指针类型和int类型的大小都是4字节。
在这里插入图片描述
2、指向指针的指针(二级指针)使用两个 * 号声明,三级指针使用三个 * 声明,四级指针使用四个 * 声明,以此类推。对于二级指针应将一个指针的地址赋给他,对于三级指针应将一个二级指针的地址赋给他,四级指针应将三级指针的地址赋给他,以此类推。比如

示例6.X:多级指针的赋值
int a=1; 
int *p=&a; 
int **p1; 		//声明一个二级指针
int ***p2;		//声明一个三级指针
p1=&p;			//正确。将一级指针p的地址赋值给二级指针p1
p2=&p1;			//正确。
//p1=p; 		//错误。指针名p代表的是指针变量存储的值,即p所指向对象的地址值,而不是指针变量
 				//p的地址,说简单点就是,p1的类型是int**,p的类型是int*,二者类型不兼容。
//p1=&a;		//错误,原因同上。

3、对二级指针进行一次解引用操作将得到的是他所指向的一级指针,要访问到真正存储值的对象应对其进行解两次引用,同理对于三级指针应使用3次解引用操作才能访问到真正存储值的对象。比如

int a=1; 
int *p=&a; 
int **p1=&p; 
cout<<*p1;		//输出p1所指向的对象p的值,即p指向的对象a的地址(即&a) 
cout<<**p1;		//输出p1所指向的对象p所指向的对象a的值1。

4、若声明的是N级指针,则解一次引用将得到N-1级指针,解二次引用将得到N-2级指针,以此类推。比如,int *****p; 则p是5级指针, * p是一个四级(5-1=4)指针,**p是一个三级(5-2=3)指针,以此类推。

5、对二级或多级指针初始化时应注意,在未对二级或多级指针进行有效的初始化时,就对其使用解引用通常会产生意想不到的结果。比如

示例6.X:二级指针的初始化
int a=1; 
int b=2; 
int *p1=&a; 
int **p; 	//二级指针p没有初始值,p未指向任何对象
//*p=&a;	//错误。这不是对二级指针p赋值,而是对解一次引用之后的对象赋值,由于此时p未指向任何
 			//对象,对其进行解引用显然会引发错误。当二级指针指向确定的对象后才可这样使用该语句
p=&p1;  		//对二级指针p赋值,现在二级指针p指向了p1
*p=&b; 		//正确。因为p指向的是p1,因此*p就是指的p1本身,这样就间接的改变了指针p1的值,
 			//即让p1指向了对象b,而不是以前的a
p=0; 		//正确。将地址值0赋值给二级指针p。注意:整数0可以当作地址值使用
//*p=&b;	//错误,因为p是一个空指针,通常指向内存位置为0的位置,该位置并不能确定保存有什么 
 			//数据,对其进行解引用并进行修改,显然会得到意想不到的结果。

二、空指针、空指针常量、指针字面值
1、空指针常量(null pointer constant)和空指针值(null pointer value)

  1. 空指针常量是指值为0的整数字面值或者类型为std::nullptr_t的纯右值(prvalue)。可见,整数字面值0可以作为一个地址类型的值赋值给指针变量,除0之外的其他整数类型是不能作为地址类型的值赋值给指针变量的。

    示例6.X:理解整数字面值0
    0L			//这是整数字面值0
    '\0'		//不是整数字面值0,因为'\0'的类型是char
    0.0F		//不是整数字面值0,因为0.0F的类型是float
    4-4			//不是整数字面值0,因为4-4的结果不是字面值
    0-0			//不是整数字面值0,因为0-0的结果不是字面值
    (int*)0		//不是整数字面值0,因为(int*)0的结果不是字面值
    (int)0		//不是整数字面值0,原因同上
    
  2. 空指针转换(null pointer conversion)
    空指针常量可以转换为指针类型,其结果是该类型的空指针值,并且与其他对象指针类型或函数指针类型的值不同(这意味着二者不相等),这种转换被称为空指针转换。空指针转换属于指针转换,而指针转换属于标准转换,标准转换又属于隐式转换,隐式转换会自动执行。比如

    示例6.X:空指针转换
    int *p=0;				//空指针常量0可转换为空指针值,其类型为int*
    double *p1=nullptr;		//空指针常量nullptr可转换为空指针值,其类型为double*
    //p==p1;				//错误。虽然p和p1的值相等,但他们的类型不同(不兼容),不能进行比较
    
  3. 注意,C++并未明确规定空指针值的所有值位必须全部为0(即0x0000 0000,假设指针占32位),只规定了空指针值可以由空指针常量转换而得到,比如,可以将整数字面值0转换为0x0000 0000,也就是说,空指针常量并不一定是指的内存中地址值为0x0000 0000(假设指针占32位)的位置,也可能是其他位置,但是,现在绝大多数计算机都是使用的0地址值作为空指针值。

2、std::nullptr_t类型

  1. std::nullptr_t是C++11之后引进入的一种类型,这种类型不是指针类型,但该类型的纯右值(prvalue)是一个空指针常量,可被转换为空指针值。

  2. sizeof(std::nullptr_t)等于sizeof(void*),即std::nullptr_t类型的大小与void*相同。

  3. 在进行从左值到右值转换时,若类型是cv std::nullptr_t,则结果是一个空指针常量。

  4. 整数字面值0可转换为std::nullptr_t类型的纯右值。

    示例6.X:理解std::nullptr_t类型
    std::nullptr_t p;		//声明一个类型为std::nullptr_t的对象p
    p=0;					//正确。0是空指针常量
    p=nullptr;				//正确。nullptr(见下文)是一个空指针常量
    //p=2;					//错误。2和p的类型不兼容,2是int型,p是std::nullptr类型。
     						//或者说,p需要一个空指针常量作为其值。
    std::nullptr p1;
    p1=p;							//正确。将p进行从左值到右值转换时,其结果是空指针常量
    int *p3=(std::nullptr_t)0;		//正确。
    int *p4=(std::nullptr_t)0.0;	//错误。0.0不是整数字面值0,不允许转换为std::nullptr_t
    

3、nullptr关键字
关键字nullptr是C++11之后引入的一种指针字面值(即地址值),其类型是std::nullptr_t。与整型字面值、浮点字面值等其他字面值类似,但nullptr是与指针有关的字面值。在C++中除nullptr之外(注:0也可理解为是一种指针字面值),没有与指针有关的字面值,典型示例就是“除nullptr和0之外,不能直接使用一个值对指针变量进行赋值”。比如

示例6.X:理解nullptr
int *p=0x12345678;		//错误,0x12345678不是一个地址值(即指针字面值)
int *p1=nullptr;		//正确。nullptr是一个地址值。
int *p2=0;				//正确。0可以作为地址值使用。
int a=1;
int *p3=&a;				//正确。获取a的地址值,间接初始化指针p3
//int b=nullptr;		//错误。类型不兼容,nullptr的类型是std::nullptr

4、0与nullptr、空指针值的区别

  1. 0是整数字面值,类型是int,而空指针值是一个地址值,虽然地址值可能是0x0000 0000,但二者并不相同。

  2. 因为类型为std::nullptr_t的纯右值是一个空指针常量,而nullptr的类型又是std::nullptr_t,所以,nullptr是一个空指针常量,可被转换为空指针值,这一点nullptr和0是相同的。

  3. 空指针常量或空指针值的类型并不一定必须是std::nullptr_t,但nullptr的类型一定是std::nullptr_t,对于空指针值,其类型可能是int*,float*等多种类型,需视情况而定,比如

    示例6.X:nullptr与空指针值的区别(二者类型不相同)
    int *p=0;			//0会被转换为空指针值,这个空指针值的类型为int *
    int *p1=nullptr;	//nullptr会被转换为空指针值,这个空指针值的类型为int*,
     					//但nullptr的类型仍然是std::nullptr_t
    
  4. nullptr与0(类型为int)不是同一种类型,使用nullptr可以把空指针值与整数0区分开来,所以,建议使用nullptr而不是0。这在函数重载时能体现出其区别。比如

    示例6.X:理解nullptr0的区别(二者类型不相同)
    void f(std::nullptr_t p){}
    void f(int a){}
    void g(int a){}
    void h(int *p){}
    f(nullptr);			//调用第1个f函数
    f(0);				//调用第2个f函数
    //g(nullptr);		//错误。nullptr的类型是std::nullptr_t而不是int
    g(0);				//正确。
    h(nullptr);			//正确。nullptr可转换为空指针值
    h(0);				//正确。0可转换为空指针值
    

5、空指针(null pointer)是指值为空指针值的指针。比如:

示例6.X:空指针
int *p=0;			//p是空指针
//int *p1=4-4;	//错误。p1不是空指针。4-4的结果不是值为0的整数字面值
int *p2=nullptr;	//p2是空指针
std::nullptr_t x=nullptr;
int *p3=x;		//p3是空指针。注:x会被转换为空指针常量
int a=0;
//int *p4=a;		//错误,类型不兼容。a不是空指针常量,是值为0的变量,因此,p4不是空指针
int *p5=0L;		//p5是空指针。long类型的字面值0,仍然是空指针常量
int *p6=0.0F;		//错误。类型不兼容。0.0F不是空指针常量

6、总结。

  1. 通过以上讲解可知,以下情形可获得一个空指针常量,空指针常量可转换为空指针值
    • 值为0的整数字面值
    • nullptr
    • std::nullptr_t类型的纯右值(prvalue)。比如,当对cv std::nullptr_t类型进行从左值到右值的转换时。
  2. 若需要使用空指针值,建议使用nullptr而不是0。
  3. nullptr的类型不是指针类型,而是std::nullptr_t
  4. 空指针常量可转换为任何指针类型。因此,0或nullptr都可转换为任何指针类型。

7、空指针的相关运算

  • 两个相同类型的空指针值比较时相等。
  • 一个std::nullptr_t类型的操作数与一个空指针常量,或者,两个std::nullptr_t类型的操作数相等。
  • 注意:将两指针类型不兼容的指针(含空指针)进行比较是错误的。
  • 将空指针与0相加或相减的结果为空指针值,其结果类型为空指针值的类型。
  • 两空指针相减的结果为0,其结果类型为实现定义的有符号整型。
  • 注意:不能将指针(包括空指针)与空指针常量相加或相减,并且两指针(含两空指针)也不能相加。因为,加运算符的一个操作数若是指针,另一操作数必须是整型(空指针常量的类型不是整型)。减运算符的两操作数要么是相同类型的指针,要么左操作数是指针,右操作数必须是整型,详见后文“指针的运算”。表6.X是空指针允许的所有加或减运算的总结

在这里插入图片描述

示例6.X:空指针的运算
int *p=0;
int *p1=0;
const int *p2=0;
float *p3=0;
int a=1;
int *p4=&a;
//1、空指针间的比较
p==p1;			//true
p==p2;			//true
//p==p3;		//错误。虽然p和p3都是空指针,但二者类型不兼容
p==p4;			//false。
//2、空指针与空指针常量间的比较
std::nullptr_t p5=0;
std::nullptr_t p6=nullptr;
p5==p6;			//true
p5==0;			//true
nullptr==0;		//true。nullptr的类型是std::nullptr_t,两操作数都会转换为该类型
p==nullptr;		//true。nullptr和p会转换为复合指针类型再比较,详见后文
p==0;			//true。同上
p==p5;			//true。同上
p3==nullptr;	//true。同上
//3、空指针间的加减。注意,含有指针的加或减运算时,不会进行类型转换
p-p1;			//正确。结果为0。结果的类型由实现定义
//p-p3;			//错误。右操作数必须是整型或与左操作数类型相同。
//p-nullptr;	//错误。原因同上
p-0;			//正确。此处的0是整型,结果为空指针值,类型为int*

三、void指针
1、void指针是指向类型为void的指针。void表示无类型,因此void指针也称为无类型指针,void指针表明这是一个指针,但不清楚该指针指向何种类型的对象。
2、void指针的转换规则

  • void指针可以保存任何对象类型(不含函数类型)的地址,这意味着,可以将其他任何对象类型的地址赋值给void指针,但反过来却不可以。或者说,“指向cv T的指针(T是对象类型)”的纯右值可以转换为“指向cv void的指针”的纯右值,即可以将类型“cv T * ”(T是对象类型)转换为类型“cv void * ”,这里的转换属于指针转换,指针转换属于标准转换,因此会自动执行。

  • 使用强制类型转换可以将void指针转换为其他指针,反之,任何指针也可强制转换为void指针。

    示例6.X:void指针转换规则
    void f(){}
    using T=void();		//T是返回void的无形参的函数类型的别名
    int a=1;
    //1、void*与对象指针间的转换
    void *p=&a;			//正确。p是void指针,可保存任何类型对象的地址,或者说
     						//int*可自动转换为void*
    //int *p1=p;			//错误。void*不会自动转换为int*
    int *p2=(int*)p;		//正确。可以将void*强制转换为int*,即(int*)p这种转换是允许的
    //2、void指针与函数指针间的转换
    T *p3=f;				//正确。p3是一个指向函数的指针
    //void *p4=f;			//错误。指向函数的指针不会自动转换为void*
    //void *p5=p3;		//错误。原因同上
    void* p6=(void*)p3;	//正确。将指向函数的指针p3,强制转换为void*
    //T *p7=p6;			//错误。void*不会自动转换为指向函数的指针
    T *p8=(T*)p6;			//正确。可将void*强制转换为指向函数的指针
    

3、cv void * 类型的对象具有与cv char * 相同的表示和对齐要求。
4、由于void没有类型,无法确定void类型的对象的大小,因此,不能对void指针使用解引用运算符以对其指向的对象进行直接的操作,也不能对void指针进行加、减等计算。注意:可对void指针进行关系运算,此时会将关系运算符的两指针转换为复合指针类型(详见后文)。比如

int a=1; 
void *p=&a; 
//p+1;			//错误。若加运算符的操作数是指针,则必须是指向完整对象类型的指针
//*p;			//错误。解引用运算符的操作数必须是指向对象类型或函数类型的指针
p==&a;			//正确。可对void指针进行关系运算

5、注意:void指针不是空指针,也就是说,void指针存储的地址值不是空指针值。
6、指针转换
指针转换包括本小节讲解的空指针转换(即空指针常量转换为空指针值)和void指针转换(即cv T * 转换为cv void * 的转换)。指针转换属于标准转换。

6.1.6 指针与cv限定符

一、常量指针与指针常量

1、常量指针(如:const int * p)
常量指针指的是指向const(常量)的指针(即 * p是常量)。指向常量的指针不能通过指针来修改它所指对象的值(即 * p不可更改),但指针本身不一定是常量(即p不一定是常量),即指针存储的地址可以更改。指向cv限定类型的指针实际上不必指向cv限定的对象,但它被视为确实指向了cv限定的对象。也就是说,虽然指向const的指针不能通过指针来修改它所指向的对象的值,但不能保证指向const的指针所指向的对象的值不能被修改,因为指向const的指针可以指向非const对象。注意,这里其实存在一个cv限定转换的问题,详见稍后的讲解。比如

int a=1,b=2; 
const int *p=&a; 	//在此处存在一个cv限定转换,详见稍后讲解
//*p=3;				//错误。*p是常量,不可更改。将p视为指向了const限定的对象,因此,
 					//不能使用*p来改变p所指向的对象
a=2;				//正确。不能保证通过改变变量a的值来修改指针p所指向的对象的值
p=&b;				//正确。因为p不是常量。

2、指针常量(如:int * const p)
指针常量指的是指针本身是常量(即p是常量),本文将其称为const指针。指针常量不可改变指针本身的值(即不能改变指针存储的地址),但指针所指向的对象不一定是常量(即 * p不一定是常量) 。比如

int a=1,b=2; 
int *const p=&a; 
//p=&b;				//错误。p是常量,不可更改
*p=3;				//正确。*p不是常量,可以改变*p的值

3、常量指针与指针常量两个概念很易搞混,因此本文不推荐使用这两个概念,在本文后面的内容将常量指针称为指向常量的指针,将指针常量称为const指针,这样更通俗易懂。
4、指向const对象的const指针(const int * const p)
这种指针既不能修改指针本身的值,也不能通过指针修改它所指向的对象的值,即p和 * p都是常量,比如

int a=1,b=2;
const int *const p=&b; 	//正确。但在此处存在一个cv限定转换,详见稍后讲解
//p=&a;					//错误。p是常量
//*p=2;					//错误。*p是常量

5、const int * p与int * const p的语法声明分析
const int * p这里p首先与结合,说明p是一个指针,指针指向的类型是const int,此处const限定的是int,而不是指针;而int * const p这里p首先与const结合,说明p是常量,然后与结合,说明这个常量是一个指针,这个指针指向int,此处const限定的是指针p本身,而不是int。

二、cv限定转换(qualification conversion)

1、cv限定转换简称为限定转换,属于标准转换,这意味着会由系统自动执行。
2、更多的cv限定
更多的cv限定是指具有更多的cv限定符,为便于描述,本文使用>、<、=表示其数量关系,比如

无cv限定 		< 	const				//右侧有更多cv限定
无cv限定 		< 	volatile			//同上
无cv限定 		< 	const volatile		//同上
const 			< 	const volatile		//同上
volatile		<	const volatile		//同上
const volatile	=	const volatile		//cv限定相等

3、一级指针的cv限定转换
若cv2 T比cv1 T有更多的cv限定,则类型为“指向cv1 T的指针”的纯右值可转换为类型为“指向cv2 T的指针”的纯右值。说简单一点就是,若两类型,其类型T相同,则可以添加cv限定符,但不能删除cv限定符。注意,这里的“cv限定”限定的是指针指向的类型。该规则说明以下问题:

  • 对于赋值运算符来说,意味着左操作数所指向的类型应具有右操作数所指向的类型的全部限定词。比如,

    const int a=1;
    //int *p=&a;				//错误,右操作数的类型为const int *,比左操作数的int*
     							//有更多的cv限定符,或者说,右操作数转换为左操作数时删除了
    								//const限定符,这是不允许的
    const int *p1=&a;			//正确。左右操作数的cv限定符相等
    const volatile int*p2=&a;	//正确。左操作数包含(此处大于)右操作数的cv限定符
    //volatile int*p3=&a;		//错误。左操作数没有包含右操作数的const限定符。此处仍然可理
     							//解为右操作数转换为左操作数时删除了const限定符
    
  • 意味着不可使用指向非const对象的指针(比如int *p)指向const对象。因为若允许这种操作,则很明显可以通过指针来间接修改const限定类型的对象的值,这违背了const的常量状态。比如

    const int a=1; 
    int *p=&a;		//假设允许此语句。
    *p=3;				//若上一条语句允许,则此语句会改变a的值,从而违背了a的const常量状态
    
  • 注意:若试图通过使用非const限定类型的左值去修改定义为const限定类型的对象,则是未定义的,比如

    const int a=2; 
    int*p;
    //p=&a;				//错误。不能将const int*转换为int*(不允许删除cv限定符)
    p=(int *)&a;  		//正确。虽然不能将&a隐式转换为int*,但可强制转换为int*
    *p=3;  				//常量a的值是否改变是未定义的
    *(int *)&a=3; 		//同上。
    
示例6.X:一级指针的cv限定转换
const int a=1;
int b=2;
//int *p=&a;				//错误。p指向int,而&a指向const int。所以p<&a
const int *p1=&a;			//正确。p1指向const int,&a指向const int。所以p1=&a
p1=&b;						//正确。p1指向const int,&b指向int。所以p1>&b
//int *p2=p1;				//错误。p1指向const int,p2指向int。所以p2<p1
//int *const p3=&a;			//错误。const指针p3指向int,&a指向const int。所以p3<&a。
 							//注意,此处的const限定的是p3,而不是p3指向的类型
volatile int *p4=&b;		//正确。p4指向volatile int,&b指向int。所以p4>&b
//p4=&a;					//错误。p4指向volatile int,&a指向const int。所以,
 							//p4不比&a有更多的cv限定
const volatile int *p5=&a;	//正确。p5>&a,读者可自行分析

4、多级指针的类型描述
描述如下指针声明的类型

在这里插入图片描述

其中,U表示一个不包含cv限定符的声明说明符,注意,U不包含声明符。在此之后出现的T,若未特殊说明,均是指的一个完整的类型id,即,T是由声明说明符和声明符组成的(但不含声明的名称),比如

const int ** const  ** p; 

U = int,T = const int ** const ** 

按4.5.3节的方法,令
在这里插入图片描述
根据4.5.3节的公式2和公式3,得到
在这里插入图片描述
因此,指针p的类型为
在这里插入图片描述
在这里插入图片描述

5、cv分解(cv-decomposition)

在这里插入图片描述

6、cv限定签名(cv-qualification signature)、顶级cv限定符
在这里插入图片描述
7、类型相似(similar)

类型相似针对于指针类型和数组类型。对于指针类型,若类型T1和T2具有相同n的cv分解,使得对应的Pi分量相同,且U的类型相同,则二者类型相似。也就是说,当U相同时,若T1和T2的指针具有相同的级数,则二者类型相似。对于数组类型,若一个为“array of Ni”,对应的另一个为“array of unknown bound of (未知边界的数组)”,并且U的类型相同,则二者类型相似(注意,数组的维数需要相等)。比如,假设a的类型为int,则a[2][3]与a[][]类型相似,同理,a[2]与a[]类型相似,注意,对于多维数组,通常只允许第一维是未知边界的,这意味着数组相似时,除第一维外,其他维的大小应相等,比如a[2][3]与a[][3]相似,但a[2][3]与a[][2]不相似,因为第二维的大小不相等。

8、cv组合类型(cv-combined type)
在这里插入图片描述

示例6.X:cv组合类型1
int *const** p1=0;
int **const* p2 = 0;
//以下语句可输出p1和p2的cv组合类型。p1和p2的cv组合类型是二者各对应分量的并集,即
//int *const*const*。注:条件运算符的结果类型是第2和第3操作数的复合指针类型。
cout << typeid(1 ? p1 : p2).name() << endl;			//输出int *const*const*
示例6.X1:cv组合类型2
int *volatile** p1=0;
const int *** p2 = 0;
cout << typeid(1?p1:p2).name();			//输出const int* const volatile* const*
 										//注意,VC++有可能输出const int* volatile**

在这里插入图片描述

9、cv限定转换(多级指针)

  • 若类型T1和T2相似,则保证可以将T1和T2都转换为二者的cv组合类型T3。注意,此种情形T1不一定能转换为T2,T2也不一定能转换为T1。
  • 如果T1和T2的cv组合类型为T2,则可以将T1类型的纯右值转换为T2类型。

10、注意,C++不允许将T * * 类型的指针赋值给类型为const T * * 类型的指针,对于更多级指针也是如此,因为若允许此种类型的转换,则有可能会无意中修改const对象。其实从cv组合类型的分析可以很清楚的明白为什么不能这样赋值,比如,假设

T1=int **
T2=const int **

则,T1和T2的cv组合类型T3为:

T3=const int *const*

按多级指针的cv限定转换规则可知,T1和T2保证可以转换为T3,但T1和T2并不一定能相互转换,详见以下示例中的p1和p2的相互赋值。

示例6.X1:cv限定转换(多级指针)
int **p1=0;
const int **p2=0;		
const int *const* p3=0;				//p3的类型是p1和p2的cv组合类型
int *const* p4=0;
//p1=p2;							//错误。p1和p2的cv组合类型既不是P1也不是p2
//p2=p1;							//错误。原因同上
cout << typeid(1?p1:p2).name();		//输出p1和p2的cv组合类型const int *const*
p3=p1;			//正确。p3和p1的cv组合类型是p3
p3=p2;			//正确。p3和p2的cv组合类型是p3
p4=p1;			//正确。p4和p1的cv组合类型是p4
//p4=p2;		//错误。p4和p2的cv组合类型是既不是P4也不是p2
//p2=p4;		//错误。原因同上
//p4=p3;		//错误。p4和p3的cv组合类型是p3。
p3=p4;			//正确。p4和p3的cv组合类型是p3。

6.1.7 复合指针类型(composite pointer type)

1、注:可以使用条件运算符的结果类型来测试两指针的复合类型。

2、若两操作数p1、p2的类型分别为T1、T2,且至少有一个是指针类型、或指向成员的指针类型、或std::nullptr_t类型,则复合指针类型的规则为(其中,第4 ~ 7条规则暂时不需理解,4 ~ 7条主要讲解指向函数、指向类类型、指向成员的指针的复合情形):

  1. 若p1和p2都是空指针常量,则复合指针类型为std::nullptr_t。

  2. 若p1或p2是空指针常量,则复合指针类型为T2或T1。也就是说,若p1、p2二者之一是空指针常量,则复合指针类型是另一个非空指针常量的类型。比如,

    int *p1=0;
    1?p1:0;		//p1类型为int*,0是空指针常量,所以,结果类型为int*。
    
  3. 若一个为“指向cv1 void的指针”,另一个为“指向cv2 T的指针”,则复合指针类型为“指向cv12 void的指针”,其中cv12是cv1和cv2的并集,T是对象类型或void。说简单点就是,cv1 void与cv2 T复合为cv12 void*。比如

    const int *p1=0;
    volatile void *p2=0;
    1?p1:p2;		//结果类型为const volatile void*
    
  4. 若一个是“指向noexcept函数的指针”,而另一个是“指向函数的指针”,函数类型的其他方面相同,则复合指针类型为“指向函数的指针”。

  5. 若T1为“指向cv1 C1的指针”,T2为“指向cv2 C2的指针”,其中C1是与C2引用相关的(reference-related),或C2是与C1引用相关的,则复合指针类型分别为T1和T2的cv组合类型,或者T2和T1的cv组合类型。注:“引用相关的”详见引用章节,其中类型相似属于引用相关。此规则主要用于指针指向有继承关系的类类型时的情形。

  6. 若一个是“指向函数类型C1的成员指针”,另一个是“指向noexcept函数类型C2的成员指针”,并且C1是与C2引用相关的,或C2是与C1引用相关的,函数类型的其他方面相同,则复合指针类型分别为“指向函数类型C2的成员指针”或“指向函数类型C1的成员指针”

  7. 若T1为“指向类型cv1 U的C1成员指针”,T2为“指向类型cv2 U的C2成员指针”,其中,U是某种非函数类型,C1是与C2引用相关的,或C2是与C1引用相关的,则复合指针类型分别为T2和T1的cv组合类型,或T1和T2的cv组合类型。

  8. 若T1和T2的类型相似,则复合指针类型为T1和T2的cv组合类型。

  9. 除以上情况外,复合指针类型是不规范的(ill-formed)。

6.1.8 指针的基本运算规则

1、指针存储的是地址,虽然计算机常把地址作为整数处理,但是他们的类型并不相同,地址描述的是内存的位置,显然将两个地址进行相乘或相除是没有意义的。

2、C++对非空指针的加、减运算、关系运算(==和!=除外)仅限于指向数组中某个元素的指针,否则是未定义的行为。因为,若指针的这些运算不是在数组内部进行或结果超出数组的范围,则不知道进行运算后指针所指向的新位置究竟是什么内容,若使用这些内容会产生意想不到的后果。本小节需要数组的基本知识,有关数组的内容详见6.2节。

3、指针可用于关系运算符、条件运算符、逻辑运算符、算术运算符,但指针的算术运算只限于两种形式:1、指针与整数相加,2、指针与整数或指针相减。注意:两指针不能相加,整数不能减去指针。

示例6.X:允许的指针运算
int p1=0,p2=0;
//p1+p2;		//错误。两指针不能相加
//4-p1;			//错误。整数不能减指针
//p1*p2;		//错误。不能对指针进行乘除运算
//p1*4;			//错误。原因同上
p1+2;			//正确。但这是未定义的行为,因为p1不是数组
p1-2;			//正确。但这是未定义的行为,因为p1不是数组
p1-p2;			//正确。两指针可以相减,但这是未定义的行为,因为p1和p2不是数组
p1>p2;			//正确。指针可比较,但这是未定义的行为
//p1&0;			//错误。p1不是整型。注:&运算符的操作数需要整型。
p1&&2;			//正确。指针可转换为bool型。注:&&运算符的操作数会转换为bool

4、指针算述运算的计算规则
指针加上或减去一个整数值n,将产生一个新的指针,并且可以直接使用这个新指针,这个新指针指向的位置将向前或向后偏移

		n*指针所指向类型所占字节数

这么多个字节,可见指针的类型决定了指针加上或减去一个整数的偏移量。这意味着,指针加上或减去一个整数值n,并不是将指针的地址值简单的加上或减去整数值n,而是加上或减去指针所指向的类型的大小再乘以整数值n,这样可以确保指针指向的一定是下一个对象,而不是对象内部(对象一般不只占据一个内存单元)的某个位置。比如:

int a=1; 		//假设int占4字节大小,假设指针类型占4字节
int *p=&a; 	//假设&a的地址为:0x0014 FCE4
p+2; 			//未定义的行为。p+2的结果为:0x0014 FCEC = 0x0014 FCE4 + 8
//*(p+2)=2;	//可使用指针运算后的新指针,但在本示例将产生意想不到的结果,甚至程序崩溃

p+2会把指针的地址值加上指针所指向的int类型的大小4乘以数值2,因此p+2将把指针向后偏移8个字节,而不是2个字节。因为p不位于数组内,因此对p+2后,并不知道指针指向的内存究竟是什么内容,即对p+2解引用后 * (p+2),会得到意想不到的值

5、指针加上或减去一个整数值n,虽然会产生一个新的指针并可以使用这个指针,但算术运算的结果是右值,不是左值,不能对该指针进行赋值,但可以使用解引用操作访问新指针所指向的内容,解引用运算符的结果是左值。比如

int a[33]={0}; 
int *p1=a; 
int b=3;
//(p1+3)=&b; 		//错误。p1+3的结果是右值。
*(p1+3)=3;		//正确。

6、再次提醒,指针可以指向对象占用的存储结束后的第一个字节的地址。

7、当相加或减的操作数为指针时的类型要求详见5.2.1节,当进行关系运算对操作数为指针时的类型要求详见5.2.2节。本小节不再对操作数类型的要求作讲解,均假设操作数的类型是合法的。

8、指针与整型的加减
当把指针类型P的表达式与整型表达式J相加或相减时,其结果类型是P的类型,其结果值为:

  • 若P为空指针,J求值为0,则结果为空指针值。空指针详见6.1.4节
  • 若P是指向具有n个元素的数组x的第i个元素,若0≤ i+J ≤ n,则表达式P+J或J+P指向x的第i+J个元素(即地址为&x[i+J]),或者说P+J=J+P=&x[i+J];若0≤ i−J ≤ n,则表达式P−J指向x的第i−J个元素(即地址为&a[i-J]),或者说P-J=&x[i-J]。否则,是未定义的。
    • 注意,数组的元素是从0开始标号的,也就是说x[0]是数组的第1个元素。i+j或i−J的结果可以等于n,意味着P+J或P-J的结果可以指向元素x[n],这意味着指针P指向了数组的最后一个元素后面的第一个字节的位置,此时P指向的位置超出了数组的范围,这种行为在C++语法范围内虽然是合法的,但可能会产生意想不到的结果,应避免这种情形。
    • 该规则说明,指针P加上或减去一个整型J之后,将使新指针所指向的位置在数组中向后或向前偏移J个元素,若新指针指向同一数组之中的元素,或指向数组最后一个元素后面的第一个字节的位置则是合法的,否则是未定义的。
示例6.X:指针与整型的加减
int a[3]={1,2,3};	//假设int占4字节,a占据内存单元1~12,假设指针类型占4字节
 					//假设内存单元12之后紧接着内存单元13、14、15....
int *p=&a[1];		//p指向数组中的第1个元素,即i=1
p+1;				//将指针p指向的位置向后移一个元素,即指向a[2]。此处J=1,i=1,
 					//所以p+1指向a[i+J]=a[2],即p+1=&a[2]
p+2;				//合法,指向a[3],即内存单元13。注意,a[2]是数组a的最后一个元素,即a[2]
 					//位于内存单元12,因此,a[3]已超出数组a所占的内存空间,位于内存单元13,
 					//虽然p+2是合法的,但不代表能得到想要的结果。
p+3;				//未定义的。指向a[4],即内存单元14。
p-1;				//未定义的。指向数组a的第一个元素a[0]的前面的位置,这是未定义行为

9、两指针相减

  1. 若两指针表达式P和Q相减,则结果的类型是实现定义的有符号整型,该类型是在头文件中所定义的std::ptrdiff_t类型,C++标准建议std::ptrdiff_t的类型应选择整数转换等级不大于(5.3.4节)signed long int的类型。两指针之差的结果值应保证在std::ptrdiff_t所规定的类型的取值范围之内,否则是未定义的。

  2. 两指针P-Q的结果值为:

    • 若P和Q都求值为空指针值,则结果为0。空指针详见6.1.4节
    • 若P和Q分别指向相同的数组x中的元素i和j,则P-Q的值为i-j,否则,是未定义的。也就是说,只有当两指针都是指向同一数组中的元素时,两指针才能相减,否则是未定义的。
  3. 注意:

    • 因为结果类型是有符号整型,因此,两指针相减的结果有可能是负数。

    • 不同类型的指针之间不能相减,比如

      int *p1=0; 
      short int *p2=0;
      //p2-p1				//错误。两指针类型不兼容
      
    • 两指针之差是数组元素下标之差,其单位并不是以字节为单位的,这个差值并不是指的两数组元素(或两指针)之间相差的宽度(即相差的字节数)。两数组元素之间相差的宽度是这个差值乘以数组的类型的长度。比如

      int a[3]={1,2,3};	//假设int占据4字节,假设指针类型占4字节
      int *p1=&a[1];		//假设&a[1]的地址为0x0000 ff61
      int *p2=&a[3];		//根据&a[1]的假设,&a[3]的地址为0x0000ff69=0x0000ff61+2*4
      p2-p1;				//结果为2。即(0x0000ff69-0x0000ff61)/4=2,其中,4表示int占
       						//据的宽度为4字节,数值2是数组a的元素下标3和1的差,
       						//p1和p2之间相差2*4=8字节的内存宽度。
      

10、多级指针的加减运算
多级指针加上或减去一个整数N,同样会使指针向前或向后偏移“N*指针所指向类型所占字节数”这么多个字节。比如

示例6.X:多级指针的加减运算
//注意:由于本示例未使用数组,所以对指针的加减运算的行为是未定义的
double a=1;
double* p2=&a;
double** p1=&p2;
double*** p=&p1; 	//假设指针类型占据4字节,double占据8字节
p+1;				//将使指针向后偏移4个字节(一个指针类型的大小),因为三级指针p所指向的类型
 					//是一个“指针类型”,指针类型占据4字节的大小,因此p+1向后偏移4字节,而
 					//不是double型的8字节
*p+1;				//也将向后偏移4字节,因为*p指向的类型仍然是“指针类型”。注:*p=p1=&p2
**p+1;				//将向后偏移double型的8个字节,因为**p指向类型的是double类型。
 					//注:**p=p2=&a

11、指针的>、<、>=、<=关系运算
若两个操作数都是指针,则将其转换为复合指针类型,其比较规则如下:

  • 若两指针指向同一数组的不同元素,或其子对象,则下标较大的元素的指针较大。
  • 若两指针递归地指向同一对象的不同非静态数据成员,或指向此类成员的子对象,则指向后声明的成员的指针更大,前提是,两个成员的访问控制相同,且两个成员都不是大小为0的子对象,且它们的类不是联合(union)。
  • 否则,两个指值都不比另一个大。
示例6.X:指针的大小比较
class A{public:int x; int y;};
union B{int x;int y;};
int a[3]={0};
A ma;
B mb;
ma.x=1;				ma.y=2;
mb.x=1;				mb.y=2;
int *p1=&a[1];		int *p2=&a[2];
int *p3=&ma.x;		int *p4=&ma.y;
int *p5=&mb.x;		int *p6=&mb.y;
p1> p2;					//false。因为p2更大。
p3>p4;					//false。因为在类A中,成员x比y先声明,所以p4更大。
p5>p6;					//false。因为mb是联合,p5不比p6大。 
p5<p6;					//false。因为mb是联合,p6也不比p5大。
p5==p6;					//true。指向同一联合成员的两指针相等。

12、指针的==、!=关系运算
几乎所有类型的指针都可进行相等或不相等的运算,因为指针要么指向同一地址,要么指向不同的地址,但前提是,两指针需要转换为复合指针类型。


6.2 数组


注:有关声明数组的语法规则以及与数组有关的类型分析详见4.5.4节。

6.2.1 一维数组

一、数组基本规则

1、数组用于存储一组具有相同类型的对象,这些对象没有名字,但可通过数组来确定其所在位置并进行访问。下标运算符(也称为数组运算符)“[]”是判断数组类型的重要标志。数组中存储的对象,被称为数组的元素(有时也称为数组的成员),数组的每个元素都具有相同的类型。数组的内存空间是连续分配的。

2、数组的维数
声明数组时中括号“[]”的数量决定了数组的维数,若声明时有1个[],则是1维数组,有两个[],则是2维数组,有3个[],则是3维数组,以此类推。

3、数组的大小
数组中元素的数量被称为数组的大小或边界(bound),数组的大小在声明数组时指定,并且必须是一个转换为std::size_t(见2.3.4节)类型的常量表达式,且必须大于等于1(不能为0)。数组的大小是固定的,一旦确定了数组的大小,则无法改变其大小。从以上规则可见:

  • 不能声明大小为0的数组,也不能声明大小为负数的数组

  • 注意:常量表达式与常量不同,常量表达式是一个编译时求值的常量,有关常量表达式的内容详见后文。

  • 因为std::size_t是一个无符号整型,这意味着数组的大小除了是一个常量表达式外,还必须是整型,不能是浮点型等其他类型。

  • 若数组类型所表示的字节数超过了std::size_t 可表示的最大值,则是非规范的(ill-formed)。

    示例6.X:指定数组的大小
    int x=1;
    const int x1=2;		//x1是常量表达式
    const int x2=x;		//x2不是常量表达式
    //int a[0];			//错误。数组的大小不能为0。
    //int a1[x];		//错误。x不是常量表达式
    int a2[x1];			//正确。声明一个大小为2的一维数组
    //int a3[x2];		//错误。x2不是常量表达式。
    //int a4[3.3];		//错误。3.3不是整型
    //int a5[-2];		//错误。数组的大小不能是负数。
    

4、省略数组的大小与未知边界数组

  1. 以下情形可以省略数组的大小:

    • 函数的形参声明中,如void f(int a[]){};
    • 声明不完整类型。本小节仅讲解此情形。
    • 声明数组时同时显示初始化。
  2. 声明(但不定义)不指定大小的数组(即未知边界的数组)是不完整类型,不完整类型不会分配内存空间。这意味着:

    • 不能使用不未知边界的数组,因为未分配内存空间。
    • 不能定义(但可以声明)一个未知边界的数组。
    • 定义一个数组时必须指定数组的大小或者显示初始化。也就是说,若在声明数组时指定了初始化器,则可以省略数组的大小,此时,数组的大小从提供的初始值的数量推断出来。
    • 对于多维数组,若可以省略数组的大小,则只有第一维的大小可以省略。
  3. 若在同一作用域中,存在指定大小的数组的先前声明,则省略的数组大小将被视为与之前的声明相同,对于类的静态数据成员也适用该规则。

    示例6.X:省略数组大小与未知边界数组
    extern int a1[];		//正确。声明而不定义一个未知边界数组。
    extern int a2[2];		//正确。
    extern int a3[2];		//正确。
    int a2[];				//正确。a2具有之前声明的大小2
    int main(){
    	//int a4[];			//错误。不能定义一个未知边界数组。
    	int a5[2];			//正确。定义数组时必须指定数组的大小。
    	int a6[]={1,2};		//正确。使用显示初始化间接确定数组的大小为2,其中a4[0]=1,a4[1]=2
    	int a1[2];			//正确。对不完整类型a1的补充定义以使其完整
    	//int a3[];			//错误。a3[]与之前的extern声明不在同一作用域
    }
    

5、数组类型和数组元素的类型
数组类型和数组元素的类型类似于指针类型与指针指向的类型。比如int a[2],则数组类型为“array of 2 int(有2个int的数组)”,数组元素的类型为int,即a[0]和a[1]的类型为int。数组元素的数型不能是占位符类型、引用类型、函数类型、未知边界数组或cv void,这意味着,数组元素的类型可以是指针类型、数组类型(多维数组)、类类型、枚举类型等类型。

示例6.X:数组元素的类型
//int &a[2];				//错误。数组元素的类型不能是引用
//auto a1[2] = { 1,2 };		//错误。数组元素的类型不能是占位符类型
//decltype(auto) a2[2] = { 1,2 };	//错误。原因同上
decltype(3) a3[2];			//正确。decltype(3)不是占位符类型
//void a4[2];				//错误。数组元素的类型不能是void
//void (a5[2])();			//错误。数组元素的类型不能是函数类型
//float a6[2][];			//错误。数组元素的类型不能是无边界数组类型
//extern int a7[2][];		//错误。原因同上。注意,此语句声明的数组不是一个不完整类型,其原因
 							//详见多维数组
float a8[2][3];				//正确。声明一个二维数组。相当于声明一个大小为2的一维数组,该一维
 							//数组中每个元素的类型是类型为“含3个float元数的数组”的数组类型,
							//即数组元素的类型为“float[3]”,也就是说,a8[0]和a8[1]的类型都
							//是“float[3]”。或者说,一维数组存储的元素又是一个有3个元素的一
 							//维数组(数组中的数组)
double *a9[2];				//正确。数组的元素类型是“指向double的指针”,即a9[0]和a9[1]的
 							//类型都为“double*”

6、访问数组的元素

  1. 访问数组的元素同样使用下标运算符[],但此时[]中的数值表示访问的元素在数组中的位置,并且从0开始计数,并且不必是常量表达式,但仍然需要整型。

  2. 注意:编译器不会检查数组的下标是否在数组的有效范围之内,若下标超过数组的有效范围,则可能发生意想不到的后果,因为并不清楚在该内存位置是什么值。比如数组a只拥有11个元素,但在程序中使用了a[15]来访问数组范围之外的元素,则可能会发生意想不到的结果。

    示例6.X:访问数组的元素
    int a=1; 
    float b=2;  
    int s[4]={1,2,3,4};  //s[0]=1,s[1]=2,s[2]=3,s[3]=4
    b=s[0];				//正确。b=1。访问数组中的元素时,下标从0开始记数
    b=s[1+2];			//正确。b=4。 数组s中的第4个元素
    b=s[a+1];			//正确。b=3。数组s中的第3个元素
    //s[b];				//错误。b不是整型
    //s[2.0];			//错误。2.0不是整型
    //s[1+b]			//错误。1+b的结果不是整型
    

二、数组的初始化
1、数组的初始化器
由于数组存储的是一系列类型相同的对象,因此应使用可以带有初始化列表(initializer-list)的初始化器初始化数组,即,应使用以下3种形式的初始化器

(表达式列表)			//圆括号初始化器
{...}
={...}

对于数组类型,不能在大括号初始化列表中使用指示初始化器。由于表达式列表与初始化列表(initializer-list)是等同的,初始化列表的元素是初始化子句。因此,数组的初始化的形式可进一步表示为

(初始化子句,初始化子句,......)
{初始化子句,初始化子句,......}
={初始化子句,初始化子句,......}

比如

int a[2](1,2);		//a[0]=1,a[1]=2。圆括号初始化器
int b[2]{1,2};		//b[0]=1,b[1]=2;大括号初始化列表
int c[2]={1,2};		//c[0]=1,c[1]=2;带等号的大括号初始化列表
int d[1]={1};		//d[0]=1。
//int e[1]=1;		//错误。不能使用“=赋值表达式”形式的初始化器初始化数组
//int f[2]=(1,2);	//错误。右侧的(1,2)不是初始化器,而是一个表达式

2、初始化数组的基本规则

  1. 数组的初始化器中的初始化子句被作为数组元素的初始化器,按数组元素的下标顺序逐个使用初始化子句复制初始化。比如

    int x[2]={1,2};			//正确。按顺序逐个初始化数组的元素,因此x[0]=1,x[1]=2
    int x1[2]={{1},{2}};	//正确。等同于x[0]={1},x[1]={2}。即,把初始化子句{1}作为x[0]
     						//的初始化器,{2}作为x[1]的初始化器
    
  2. 若初始化子句的数量超过数组的元素数量,则是不规范的(ill-formed)。比如

    //int a[2]={1,2,3};	//错误。初始化子句数量过多
    
  3. 若初始化子句的元素数量少于数组元素数量,则剩余的数组元素使用空初始化列表复制初始化,这种初始化也被称为隐式初始化(即由系统隐式执行的)。注意,使用空初始化列表的初始化是值初始化,这意味着,若数组元素的类型若不是类类型,将会被初始化为0。也意味着,对于不是类类型的数组,若使用空初始化列表,如{}或(),则可将数组的所有元素都初始化为0。比如

    int b[3]={1};	//正确。等同于b[0]=1,b[1]={},b[2]={}。因此,b[0]=1,b[1]=0,b[2]=0
    int c[11]={};	//正确。数组的所有元素都使用{}初始化,即,数组的所有元素都被初始化为0
    int d[11]{};	//正确。数组的所有元素都被初始化为0
    int e[11]({});	//正确。同上
    //int f[11]={()};	//错误,可理解为int f[11]=();
    //int g[11]();	//错误。该语句会被认为数组的元素是一个返回int的函数,而不会把()认为是
     				//初始化器。详见4.6.2节第10小点
    //int h[11]=();	//错误。等号右侧的()不是初始化器,而是一个表达式
    
  4. 若是未知边界的数组,则数组的大小由数组的初始化列器所提供的初始化子句的数目确定,该数组初始化之后,不再是不完整类型。注意:未知边界的数组不能使用空的初始化器初始化,因为若这样做,将无法确定数组的大小。比如

    int d[]={1,2};	//正确。d的大小为2,且d[0]=1,d[1]=2
    //int e[]={};	//错误。初始化器不能为空。不能确定e的大小,或者说e的大小被确定为0。
    int f[]={{}};	//正确。初始化器不是空的,含有一个元素“{}”,即f的大小为1,且被值初始化0
    

2、注意,int a[11]={0};或者int a[11]={};都会把数组a中的所有元素初始化为0,但前者是将a[0]使用0初始化,之后的元素是使用“{}”进行的值初始化。因此,诸如int a[11]={1}等类似语句并不会把数组中的所有元素都初始化为1,该语句只有数组的第1个元素a[0]的值被初始化为1,其余元素的值为0。

3、当对数组进行赋值时,不能使用初始化器,初始化器只能用于对数组的初始化,因此需要对数组的元素赋值时,只能对数组元素逐个进行赋值,一般采用循环语句进行赋值;同理当需要输出数组中的所有元素值时,也应逐个元素进行输出。比如

int a[3]={};  	//将数组的所有元素初始化为0
//a={1,2,3}		//错误
//a[1]={1,2,3};	//错误
a[2]={1}; 		//正确。等同于a[2]=int{1}。赋值运算符的右侧可以是单个元素的初始化列表,
 					//详见4.5.2节
a[2]=1;			//正确。注意,这是赋值语句,而不是初始化。注意与下一条语句的区别
//int b[2]=1;		//错误。这是初始化。初始化数组时不允许使用“=赋值表达式”形式的初始化器
//a[1]+{1};		//错误。{1}不是表达式

4、不能使用一个数组直接初始化另一个数组,也不能将一个数组赋给另一个数组。要把一个数组赋给另一个数组,必须对其每个元素逐一进行赋值。这意味着,若声明数组时没有对其进行初始化,则在以后只能对数组元素逐个进行赋值。比如

int a[3]={1,2,3}; 
//int b[3]=a;			//错误。不能使用数组a直接初始化数组b
//int c[3]={a};		//错误。原因同上
int d[3];
d=a;					//错误。不能将一个数组赋给另一个数组
//要将数组a赋给数组b,应逐一元素进行赋值,比如
b[0]=a[0]; 
b[1]=a[1]; 
b[2]=a[2];

5、若数组是静态存储持续期的,则初始化只进行一次,若未对其进行显示初始化,则自动将数组中的各个元素初始化为0。若数组是自动存储持续期的,若未显示初始化,则值是不确定的,也就是值是由系统随机分配的。存储持续期详见相关章节

6、初始化数组时,不能使用添加逗号的方式跳过要初始化的元素,以使其自动初始化为0,而应显示初始化为0;比如

//int a[3]={1, , 3}; 		//错误
int b[3]={1,{},3};		//正确

7、注意,初始化列表中不允许使用窄化(或称为收缩)转换,但对于数组,VC++不一定支持此规则。比如

int a[2]={2.2,1};		//错误,窄化转换。VC++不一定支持此规则
int b[2]({2.2},1);	//错误。窄化转换。
int c[2](2.2,1);		//正确。不包含初始化列表

8、数组元素的初始化按大括号初始化列表中初始化子句的顺序求值(注意,求值包括值计算和副作用),也就是说,在大括号初始化列表中的每个逗号之前都有一个序点。比如

示例6.X:初始化数组时的求值顺序
int i=1,j=10;
int a[2]={i++,i++};		//正确。a[0]=1,a[1]=2
int b[2](i++,i++);		//不明确。因为没有大括号初始化列表,所以没有序点
int c[2][2]({i++,i++},{j++,j++});	//正确。c[0][0]=1;c[0][1]=2;c[1][0]=10;c[1][1]=11
int d[2][2]({i++,i++},{i++,i++});	//不明确。在两个大括号中的逗号之前有序点,但在两个大括号
 									//之间的逗号(即第二个逗号)处没有规定序点。

9、对于字符数组的初始化,可以使用初始化列表进行初始化,也可以使用字符串的形式进行初始化,使用字符串的形式进行初始化时,应注意字符串末尾有个空字符。有关字符串详见后续章节。比如

int a[3]={‘c’,+,+,’\0}; 
int a[4]=”c++;		//同上,但要注意,字符串"c++"的长度为4,因为字符串以空字符’\0’结尾

6.2.2 多维数组

一、多维数组基础
1、多维数组各子数组的类型
对于二维及多维数组,其实就是数组中的数组,即相当于数组中包含一个子数组。多维数组的元素是所有第一维的元素,只是这一维的元素又是数组(即子数组)而已,因此对于多维数组除第一维外的其他所有维都是多维数组中的元素的一部分,在理解多维数组时,可按一维数组的方式进行拆分理解。比如

int a[2][3][4]

其实这个数组总共只有第一维指定的2个元素即a[0]和a[1],其中每个元素又是一个二维数组(即子数组),这个二维数组有3个元素,每个元素又是一个有4个元素的一维数组。也就是说

a的类型是“int[2][3][3]”
a[i] (0≤i<2)的类型是“int [3][4]”,即a[i]是一个数组类型(二维)。
a[i][j] (0≤i<20≤j<3)的类型是“int[4]”,即a[i][j]是一个数组类型(一维)。
a[i][j][k](0≤i<20≤j<30≤k<4)的类型是“int”,即a[i][j][k]是一个int型。

由此可见,对于N维数组,除第N维外,凡是小于N维的子数组都可看作是一个数组类型。注意,数组类型通常简称为数组。
对于

T a[x1][x2]...[xn]

则(假设0≤wi<xi)

a的类型是“T [x1][x2]...[xn]”
a[w1]的类型是“T[x2][x3]...[xn]”,即a[w1]是n-1维数组类型
a[w1][w2]的类型是“T [x3][x4]...[xn],即a[w1][w2]是n-2维数组类型
......
a[w1][w2]...[wn]的类型是“T”

在这里插入图片描述

示例6.X:多维数组各子数组的类型
int a[2][4][5][3][7]={};		//5维数组
int (&x)[4][5][3][7]=a[0];		//a[0]的类型是int[4][5][3][7],其维数是5-1=4维注意,在此
 								//处未执行数组到指针的转换(详见后文),a[0]表示数组,而非指针
int (&x1)[4][5][3][7]=a[1];		//a[1]的类型同a[0],因为二者都是一维子数组
int (&x2)[5][3][7]=a[0][0];		//a[0][0]的类型为int[5][3][7]
int (&x3)[5][3][7]=a[0][2];		//a[0][2]的类型同a[0][0],因为二者都是二维子数组
int (&x4)[5][3][7]=a[1][1];		//a[1][1]的类型同a[0][0]
int (&x5)[3][7]=a[1][1][2];		//a[1][1][2]的类型为int[3][7]
int (&x6)[3][7]=a[0][1][1];		//a[0][1][1]的类型同上,因为二者都是三维子数组

2、对于二维数组有一种更简单的理解方式,可认为是一个由N行M列组成的表格,数组的第1维指定行数,第2维指定列数,比如int a[3][4],可以看成是拥有3行4列的一个表格。

3、多维数组元素的存储次序
多维数组是按照行主序的原则进行存储的,即多维数组的存储按照最右边的下标先进行存储。比如

int a[2][3][4]

则在内存中存储的顺序依次是

a[0][0][0],a[0][0][1],a[0][0][2],a[0][0][3]	
a[0][1][0],a[0][1][1],a[0][1][2],a[0][1][3]
......

再如

int a[3][4]

则数据在内存中的顺序是,先存储第一行的4个元素a[0][0], a[0][1], a[0][2], a[0][3];然后是第二行a[1][0], a[1][1], a[1][2], a[1[3];。

4、访问多维数组的元素的方法
使用下标算符访问N维数组中的具体元素时应使用N个下标算符,若使用的下标算符少于N个,则表示该数组的一个子数组,大多数情况下表示的是一个指向某处的指针,即表示的是某处的地址(具体规则详见后文)。比如

int a[2][3][4]

假设已初始化,应使用3个下标算符访问具体的元素,比如a[0][1][1], a[1][2][3]等都能访问到具体的元素,而 a[1][2], a[1], a[0][1]等都是该数组的一个子数组,大多数情况下,表示的是指向某处的指针,数组和指针的问题,详见后文。

二、初始化多维数组
1、二维及多维数组的初始化与一维数组的初始化绝大部分是一致的,本小节仅讲解初始化多维数组的特定部分。

2、多维数组可以按以下两种方式初始化:

  1. 按行初始化
    由于多维数组的元素是一个子数组,所以,其子数组的元素可以使用以大括号括起来的初始化列表(initilalizer-list)初始化,此时,以左大括号开始的后面以逗号分隔的初始化子句将依次初始化子数组的元素,其余规则遵守一维数组的初始化规则,比如,若初始化子句的数量过多,则是错误的。因此,初始化二维数组需要二层大括号的初始化列表,三维数组则需要三层大括号的初始化列表,以此类推,其中嵌套的初始化列表之间以逗号分隔。

    示例6.X:按行初始化多维数组
    int a[2][3]={{1,2,3},{4,5,6}};		//使用{1,2,3}初始化a[0]的3个元素,即
     									//a[0][0]=1; a[0][1]=2; a[0][2]=3;
     									//使用{4,5,6}初始化a[1]的3个元素,即
     									//a[1][0]=4; a[1][1]=5; a[1][2]=6; 
    int b[2][3]={{1,2},{3}};			//正确。b[0]的3个元素被{1,2}初始化为
    									//b[0][0]=1; b[0][1]=2; b[0][2]=0;
    									//b[1]的3个元素被{3}初始化为
    									//b[1][0]=3; b[1][1]=0; b[1][2]=0; 
    int c[2][3]={{1},{2}};				//正确。c[0]的3个元素被{1}初始化为
    									//c[0][0]=1; c[0][1]=0; c[0][2]=0;
    									//c[1]的3个元素被{2}初始化为
    									//c[1][0]=2; c[1][1]=0; c[1][2]=0; 
    int d[2][3]={{1,2}};				//正确。d[0]的3个元素被{1,2}初始化为
    									//d[0][0]=1; d[0][1]=2; d[0][2]=0;
    									//其余元素被初始化为0。
    //int e[2][3]={{1,2,3,4},{5,6}};	//错误。第二层初始化列表{1,2,3,4}中的元素数量多于e
     									//的第二维的元素数量
    //int f[2][3]={{1},{2},{3}};		//错误。第二层初始化列表的数量多余f的第1维的元素数量
    //int g[2]={{1,2}};				//注意,这是错误的。相当于g[0]={1,2},g[1]={}
    
  2. 按顺序初始化
    可以省略初始化子数组的初始化列表的大括号,此时的初始化规则就像初始化一维数组一样,使用初始化器中的初始化子句按照最右边的下标先初始化(即行主序)的原则对多维数组的元素逐个初始化。其余规则遵守一维数组的初始化规则。比如

    //结果为:a[0][0][0]=1; a[0][0][1]=2;a[0][0][2]=3; a[0][0][3]=4; 
    //a[0][1][0]=5; a[0][1][1]=6; a[0][1][2]=7; a[0][1][3]=8; 
    //a[0][2][0]=9; 其余元素被自动初始化为0
    int a[2][3][4]={1,2,3,4,5,6,7,8,9};		
    

3、不推荐在初始化多维数组时,既使用顺序初始化又使用按行初始化的混合初始化方法,若这样做程序既容易出错又不方便理解。若最外层的初始化列表(即初始化器)中的元素不是以左大括号开始的,则初始化器中的其余元素都应是一个表达式(但允许大括号中包含单个表达式)。比如

int a[2][3]={1,{2}};			//正确。相当于a[0][0]=1; a[0][1]={2},其余元素初始化为0
//int b[2][3]={1, {2,3}};		//错误。{2,3}包含了过多的表达式。相当于
 								//b[0][0]=1,b[0][1]={2,3},可见,b[0][1]是错误的
int c[2][3]={{1,2},3,4};		//正确。相当于c[0][0]=1;c[0][1]=2;c[0][2]=0;
									//c[1][0]=3;c[1][1]=4;c[1][2]=0;

4、对于多维数组,除了第一维之外的其他维都是其元素类型的一部分,因此在初始化多维数组时除第一维之外的其他维是不能省略的。多维数组第一维的长度可以省略,省略的维数通过初始化列表的内容确定。比如

int a[][2]={1,2,3};				//第一维的长度是2
int b[][2]={{1},{2,3}, {4}};		//第一维的长度为3
//int c[2][]={1,2}				//错误。除第一维外的其他维不能省略

6.3 指针与数组


要掌握好指针,需要弄清楚指针指向的类型及指针指向何处,复杂的指针一般与数组脱不了关系,其次就是二级及多级指针,本小节重点讲解指针与数组的关系,弄清指针指向的类型和指向何处。

6.3.1 数组名的理解

1、对于数组名的理解,关键是要明白数组名所表示的指针的类型,注意:指针类型决定了对指针进行运算时的偏移量,所以,指针类型是很重要的。
2、数组到指针的转换(array-to-pointer conversion)
类型为“N个类型为T的数组”或“未知边界的类型为T的数组”的lvalue(左值)或rvalue(右值),可以被转换为“指向T的指针”的纯右值,最终结果是指向数组第一个元素的指针(即,指向的对象是数组的第一个元素)。这被称为数组到指针的转换,这种转换属于标准转换,并且还会应用临时具体化转换(见5.1.3节)。数组到指针的转换可以简单的描述为:

类型“T [N]”或“T []”,可以被转换为“T*

可见,数组转换为指针时,与数组的大小没有关系,也就是说,无论T[N]中的N取值是多少,都可以被转换为T*。
数组到指针的转换需要弄清楚以下几个关键问题:

  • 转换之后的指针的类型。
  • 转换之后的指针指向何处,即指针指向的对象。这关系到指针的值是多少的问题。
  • 转换之后的指针的值类别。
  • 指针类型和数组类型的区别

下面分别详细说明以上问题:

  1. 很明显,数组名的类型满足“N个类型为T的元素的数组(即T [N])”的要求。因此,数组名可以被转换为一个指针,该指针指向的对象是数组中的第一个元素。这里的关键是弄清楚指针指向的对象“数组的第一个元素”,在6.1.3节已讲解过,指针指向的对象必是&运算符的操作数,反过来,指针的值就是指向的对象的地址。对于数组,无论数组有多少维,数组的元素数量永远由第一维决定,其第一个元素永远是第一维下标为0的元素,比如int a[3][4][5][6],则数组a共有3个元素,第一个元素是a[0],而不是a[0][0],a[0][0][0],a[0][0][0][0]。

    示例6.X:理解一维数组名
    int a[11];
    a+1;		//等同于&a[0]+1,a的类型为“int *”
    

    分析:a+1中的a会执行数组到指针的转换,因为a的类型为“int[11]”,使用文字描述为“11个类型为int的数组”,因此,转换后的a的类型为“指向int的指针”,即“int * ”,指向的对象是数组a中的第一个元素a[0],也就是说a=&a[0] (注:指针指向的对象必是&运算符的操作数),也就是说,a+1中的a是指针类型,而不再是数组类型。假设int占4字节,则a+1将使地址从&a[0]偏移4个字节。

  2. 类型T [N]可以被转换为T * ,这意味着,被转换的对象只要其类型是一个数组都可以被转换为指针。由前面讲解可知,对于N维数组,除第N维外,凡是小于N维的子数组都可看作是一个数组类型,这意味着,凡是小于N维的子数组都可以被转换为一个指针类型。下面以示例讲解多维数组的情形。

    示例6.X:理解二维数组名
    int b[2][3];
    b+1;			//b的类型为“指向int[3]的指针”,即“int (*)[3]”
    

    分析:b+1会执行数组到指针的转换,因为b的类型为“int[2][3]”,使用文字描述为“2个类型为int[3]的数组”,因此,转换后的b的类型为“指向int[3]的指针”,即“int ( * )[3]”,指向的对象是b的第一个元素b[0],即,此时b相当于是一个指针,其类型为“int ( * )[3]”(即指针指向一个数组),指向的对象是b[0],即b=&b[0]。假设int占4字节,则b+1将使地址从&b[0]偏移4 * 3=12个字节。注意,数组b的第一个元素的地址是&b[0],而不是&b[0][0],虽然二者的地址值是相同的,但二者的类型明显不同。

    示例6.X:理解多维数组名
    对于n维数组T a[x1][x2]...[xn],其原理类似,若数组名a执行数组到指针的转换,则转换之后得到
    的指针的类型是“指向一个n-1维数组的指针”,即,类型是“T (*)[x2][x3]...[xn]”,指向的对象
    永远是数组的第一个元素a[0],即a=&a[0]。注意,多维数组的第一个元素是a[0],而不是a[0][0]、
    a[0][0][0]、a[0][0][0][0]等等。这里还应注意,多维数组转换为指针后,指针的类型与第一维的大小
    没有任何关系。
    
  3. 执行数组到指针的转换后的指针的值类别是纯右值,而不是左值。注意,若未执行数组到指针的转换,则数组名表示的是数组,其类型是数组类型,且是左值,但该左值是不可修改的,只有在执行从数组到指针的转换后,数组名才会成为纯右值的指针。当数组名用作sizeof和取址运算符的操作数,以及用于初始化数组引用时不会执行数组到指针的转换,此时就能体现出数组类型和指针类型的区别了。

    示例6.X:数组类型和指针类型的区别(初始化引用)
    int a[2]={};
    //int *(&y)=a;		//错误。a会执行数组到指针的转换,转换之后的结果是纯右值,因此,数
     					//组名a是一个指针,且是纯右值,而&y是左值引用不能引用右值
    int *(&&x)=a;		//正确。此处的a是指针,是纯右值,可以被右值引用所引用
    int *p=a;			//正确。执行数组到指针的转换,a是指针,指向a[0],因此,p的值为&a[0]
    int (&x1)[2]=a;		//正确。此处不会执行数组到指针的转换,数组名a表示数组而不是指针,是不可
     					//修改的左值,即a的类型是“有2个int元素的数组”,即“int[2]”
    //int (&&x2)[2]=a;	//错误。此处的数组名a是数组,且是左值(不可修改),不能被右值引用所引用
    int b[2]={};
    //a=b;				//错误。a是不可修改的左值
    cout << std::is_lvalue_reference<decltype((a)) >::value ;	//输出1,即,a是左值。
     					//因为此处的a不会执行数组到指针的转换,此时a是左值,但不可被修改
    

3、由以上讲解可见,数组名既可表示指针也可表示数组(这取决于是否会进行数组到指针的转换),并且他们的值类别不同,当数组名表示指针时是纯右值,当数组名表示数组时是左值。在绝大多数情况下数组名都会执行数组到指针的转换,并且由于习惯的原因,本文有时候会将二者混合使用,注意从上下文区分数组名究竟表示数组还是指针。

4、从以上讲解可见,多维数组名所表示指针的类型与多维数组的第一维的大小是没有关系的,只与除第一维外的其他维的大小有关系,但这并不意味着第一维的大小没有作用。

6.3.2 指针与数组名的关系

1、下标运算

  1. 下标运算符“[]”是一个运算符,既然是运算符就自然有其运算规则、操作数及运行结果等性质。下标运算符的形式通常为

    E1[E2]
    
  2. 操作数及结果类型:下标运算符需要2个操作数,一个操作数必须是类型为“类型T的数组”的glvalue或者是“指向类型T的指针”的纯右值,类型T必须是完整定义的对象类型,另一个操作数必须是整型的纯右值。说简单点就是,一个操作数必须是数组或指针,另一个必须是整型。下标运算的结果类型为类型“T”。

  3. E1在E2之前排序,即下标运算符有序点。

  4. 注意,E1和E2可交换顺序,但排序规则不受影响。比如

    int a[2]={1,2};
    1[a];				//正确。等同于a[1],但不建议这种写法
    
  5. 结果值:表达式E1[E2]的结果值与 * ((E1)+(E2))相同,即

    E1[E2]=*((E1)+(E2))						//公式1
    

    但二者的值类别可能会不相同,对于E1[E2],若数组操作数为lvalue,则结果为lvalue,否则为xvalue,注意,编译器不一定支持此规则。对于 * ((E1)+(E2)),则根据解引用运算符的规则,其结果始终为lvalue。在表达式 * ((E1)+(E2))中,以E1为例,若E1是数组名,则会执行数组到指针的转换;若E1是指针,则认为该指针指向的是一个单个元素的数组;无论怎样,E1最终必须是指针,因此,使用公式1的前提条件是“E1或E2是指针”,也就是说,若E1或E2是数组,则必须在需要执行数组到指针转换的条件下才能使用公式1,若不会执行数组到指针的转换,则不能应用公式1。

    示例6.X:将指针视为指向单个元素的数组
    int a=1;		//将a视为a[1]
    int *p=&a;		//将p的地址视为&a[0]
    p[1];			//p[1]=*(p+1);注意,这可能会得到一个意想不到的值
    
    示例6.X:下标运算符的序点及结果值计算简介
    int a[4]={1,2,3,4};
    int *p=a;			//p的地址为&a[0]
    (p+i++)[i++];		//这是明确的,因为(p+i++)在[i++]之前排序。最终为a[3],即值4
    

    分析:(p+i++)[i++] = (p+1)[2] = * ((p+1)+2) = *(p+3) = * (&a[0]+3) = * (&a[3]) = a[3],稍后会更详细的讲解怎样计算下标运算的结果值

    示例6.X:下标运算符的值类别
    using T=int(&&)[2];			//T是一个对数组int[2]的右值引用
    T f(){return {1,2};}		//函数f()返回一个有两个元素(分别为1和2)的数组
    int main(){
    	int &&x=f()[1];			//正确。f()[1]的值类别是xvalue,注意,VC++不一定支持
    	//int &y=f()[1];		//错误。但VC++正确,因为VC++认为f()[1]的值类别为左值
    }
    
    示例6.X:下标运算的类型判断方法
    int a[2][3]={};
    a[1];			//由于a的类型为“2个类型int[3]的数组”,因此,a[1]的类型为int[3]
    a[0][1];		//由于a[0]的类型为int[3],即“3个int的数组”,因此a[0][1]的类型为int
    

2、下标运算的类型判断方法

  1. 以以下数组为例

    int a[2][3][4];
    

    对于以下下标运算

    a[1]
    

    假设不会执行数组到指针的转换,则数组名a的类型为“int[2][3][4]”,使用文字描述为“2个类型int[3][4]的数组”,根据下标运算的规则分析类型时,不需要考虑数组的大小,因此,a的类型可简化为“类型int[3][4]的数组”,然后根据下标运算符的规则:

    “类型T的数组”的类型为T
    

    得到“类型int[3][4]的数组”的类型为“int[3][4]”,即a[1]的类型为“int[3][4]”,也就是说,a[1]是一个二维数组。

    对于以下下标运算

    a[0][1]
    

    先分析a[0],由于a[0]与a[1]的类型相同,即类型为“int[3][4]”,使用文字描述为“3个类型int[4]的数组”,简化为“类型int[4]的数组”,因此,a[0][1]的类型是“int[4]”,即a[0][1]是一个一维数组。

    对于以下下标运算

    a[0][1][2]
    

    先分析a[0][1],从前面的分析可知,a[0][1]的类型是“int[4]”,使用文字描述为“4个类型int的数组”,于是简化为“类型int的数组”,因此,a[0][1][2]的类型是“int”,即a[0][1][2]不是一个数组。

  2. 对于N维数组,可使用以上方法进行类似分析,最终得到的结论与6.2.2节中的“多维数组各子数组的类型”相同。以上的方法是使用“下标运算”的计算规则进行的类型分析,更严谨,而6.2.2节是根据子数组的原理推论出来的,二者是一致的,但是对于下标运算还需要考虑数组名转换为指针的问题,因此,还需要对6.2.2节的结论作进一步的分析。以下均假设不会执行数组到指针的转换,即数组名表示数组。
    在这里插入图片描述

3、对数组名的取址运算
取址运算符&的结果是“指向操作数类型的指针”,当对数组名进行取址运算时,不会执行数组到指针的转换,此时的数组名表示的是数组类型,因此对数组名执行取址运算后的类型是“指向数组类型的指针”,此时可将其理解为,产生的指针指向的是整个数组。比如

示例6.X:对数组名的取址运算
int a[2]={};			
int b[2][3][4]={};	
int (*p)[2]=&a;			//a表示数组,其类型是int[2],因此&a的类型是“指向int[2]的指针”
int (*p1)[2][3][4]=&b;	//b的类型是int[2][3][4],所以&b的类型是“指向int[2][3][4]的指针”

4、对多维数组的子数组的取址运算
在这里插入图片描述
在这里插入图片描述

示例6.X:指向多维数组的指针类型相同的判断方法
int a[3][4][5][6][7]={};
int b[5][8][6][7]={};
//注:以下各表达式右侧均会执行数组到指针的转换
int (*p1)[6][7]=a[0][0];	//a[0][0]=&a[0][0][0],类型为“指向int[6][7]的指针”
int (*p2)[6][7]=a[0][1];	//a[0][1]=&a[0][1][0],类型同上
int (*p3)[6][7]=a[1][2];	//a[1][2]=&a[1][2][0],类型同上
int (*p4)[6][7]=b[2];		//b[2]=&b[2][0],类型同上。虽然数组a和b的总维数不同,并且
 							//前两维的大小不同,但二者后两维(即指针指向的数组的维数)的大小相同
int (*p5)[5][6][7]=a[1];	//a[1]=&a[1][0],类型为“指向int[5][6][7]的指针”
int (*p6)[8][6][7]=b;		//b=&b[0],类型为“指向int[8][6][7]的指针”。
							//可见,b和a[1]的类型不相同。
示例6.X:多维数组的取址运算
int a[2][4][5][3][7]={};		//5维数组。注:以下语句都会执行从数组到指针的转换
int (*p1)[4][5][3][7]=a;		//在此处,a是一个指针而非数组,指向a[0],即a=&a[0],类型为
								//“指向int[4][5][3][7]的指针”,此处m=0,n=5,因此,
 								//a是指向5-0-1=4维数组的指针
int (*p2)[5][3][7]=a[0];		//a[0]=&a[0][0],类型为“指向int[5][3][7]的指针”
int (*p3)[5][3][7]=a[1];		//a[1]=&a[1][0],类型为“指向int[5][3][7]的指针”,注意,
								//a[1]和a[0]的类型相同,但二者的地址值并不相同。
int (*p4)[3][7]=a[0][0];		//a[0][0]=&a[0][0][0],类型为“指向int[3][7]的指针”
int (*p5)[3][7]=a[1][3];		//a[1][3]=&a[1][3][0],类型为“指向int[3][7]的指针”
int *p6=a[1][2][3][2];			//a[1][2][3][2][1]=&a[1][2][3][2][1][0],
 								//类型为“指向int的指针”

5、多维数组的地址值

对于N维数组a,&a[0]、&a[0][0]、&a[0][0]…[0]的地址值相同,也就是说这些指针的值是相同的,但要注意,这些指针的类型并不相同,最典型的影响就是,当对指针进行加减等运算时,指针偏移的字节数不相同。比如,

示例6.X:指针类型对指针运算的影响
int a[2][3]={};			//假如int型占据4字节内存
&a[0];					//假设地址值为0x0014FCF8
&a[0]+1;				//结果地址为:0x0014FD04=0x0014FCF8+0xC。因为&a[0]指向的对象a[0]是
 						//具有3个元素的int数组(或者说a[0]的类型为“int[3]”),因此
 						//&a[0]+1将向后偏移12(即4*3)个字节
&a[0][0]+1;				//结果地址为:0x0014FCFC=0x0014FCF8+4。向后偏移4个字节

6.3.3 指针与数组的混合运算

1、数组名的运算
在这里插入图片描述
2、指针与数组混合运算的规则
在这里插入图片描述

示例6.x:指针与数组的混合运算
int b[3][4]={1,2,3,4,5,6,7,8,9,10,11,12};
cout<<"例1:"<<b[1][2]<<"="<<*(*(b+1)+2)<<endl;	//输出7=7
cout<<"例2:"<<(&b[1])[1]<<"="<<b[2]<<endl;		//输出两相等的地址
cout<<"例3:"<<*b <<"="<<b[0]<<endl;				//输出两相等的地址
cout<<"例4:"<<*b[1]<<"="<<b[1][0]<<endl;			//输出5=5
cout<<"例5:"<<*(*(b+1)+1)<<"="<<b[1][1]<<endl;	//输出6=6
cout<<"例6:"<<*(b+1)<<"="<<b[1]<<endl;			//输出两相等的地址
cout<<"例7:"<<(*b)[1]<<"="<<b[0][1]<<endl;		//输出2=2
cout<<"例8:"<<(*(b+1))[1]<<"="<<b[1][1]<<endl;	//输出6=6
cout<<"例9:"<<(b+1)[1]<<"="<<b[2]<<endl;			//输出两相等的地址
cout<<"例10:"<<*(b+1)[1]<<"="<<b[2][0]<<endl;	//输出9=9
cout<<"例11:"<<(*b+1)[2]<<"="<<b[0][3]<<endl;}	//输出4=4

在这里插入图片描述

6.3.4 数组指针( * p)[]和指针数组 * p[]

数组指针强调的是指针,即(p)[]此处p是指针,指向数组。而指针数组强调的是数组,即p[]此处p是数组,存储的是指针。同理数组引用强调的是引用,即(&p)[]此处p是引用,引用的是数组。

一、数组指针
1、数组指针的形式为:

int (*p)[N];				//定义一个“指向int[N]的指针”,即指针p指向一个一维数组
int (*p)[x1][x2]...[xn];	//定义一个“指向int [x1][x2]...[xn]的指针”,即p指向n维数组

这里p先与 * 结合,表示p是一个指针,然后与下标运算符结合,表示这个指针指向数组。注意,小括号不能省略,因为 * 指针运算符的优先级低于下标运算符。

2、对数组指针进行赋值时,应注意赋值的类型应与数组指针的类型相同,即必须指向相同维数数组的指针,且每一维相应的元素大小应分别相等,且元素的类型应相同。比如

示例6.X:对数组指针的赋值
int (*p)[4];
int a[4]={}; 
int b[33][4]={}; 
int c[5]={};  
float d[4]={};  
int e[2][3][4]={};
p=&a;   		//正确,&a的类型是“指向int[4]的指针”
p=b;    		//正确,二维数组名b的类型是“指向int[4]的指针”
p=&b[0];  	//正确,&b[0]的结果是“指向int[4]的指针”
p=&b[1];  	//正确,&b[1]的结果是“指向int[4]的指针”。&b[1]是二维数组第二个元素的地址,而
 				//&b[0]是二维数组第1个元素的地址,他们的类型是相同的,但二者的地址值不同
p=e[1]; 		//正确,e[1]指向e[1][0],即e[1]=&e[1][0],类型是“指向int[4]的指针”
p=&e[0][0];	//正确,&e[0][0]的类型同上,但地址值不同
p=&e[1][2];	//正确,&e[1][2]的类型同上,但地址值不同
p=e[1]+1;   	//正确,e[1]=&e[1][0],对其加1指向e[1][1],即e[1]+1=&e[1][1],其类型是
 				//“指向int[4]的指针”,与指针p的类型相同
//p=a;   		//错误,一维数组名a=&a[0],他的类型是“指向int的指针”,与p类型不同
//p=&b;     	//错误,&b的类型是“指向int[33][4]的指针”,因此,&b与p的类型不同
//p=&c;    	//错误。&c的类型是“指向int[5]的指针”,虽然&c与p都是指向一维数组的指针,但二者
 				//指向的数组的大小不相同,因此二者类型不同
//p=&d;    	//错误。&d的类型是“指向float[4]的指针”,虽然&d与p都是指向一维数组的指针,二者
 				//指向的数组大小也相同,但二者的元素类型不相同,一个为int,一个为float,因此二者
 			//类型不同

3、访问数组指针的元素

  1. 若数组指针的类型与数组名所表示的指针的类型相同,则可以把数组指针p当成数组名来理解,即可以像使用数组名一样使用数组指针,比如

    int a[2][3]={1,2,3,4,5,6}; 
    int (*p)[3]=a; 		//执行该语句后,可以像使用二维数组名a那样使用数组指针p
    p[1]+1;				//等同于a[1]+1。因为p[1]=*(p+1)=*(&a[0]+1)=*(&a[1])=a[1]
    p[1][2]+1;			//等同于a[1][2]+1。读者可自行计算p[1][2]的结果
    
  2. 若数组指针指向的类型与数组名所表示的指针的类型不相同,则在使用数组指针时,应将其替换为指向的地址之后,再利用指针与数组的混合运算法则,计算出数组指针实际指向何处。

示例6.X:数组指针的计算
int x[3][4]={1,2,3,4,5,6,7,8,9,10,11,12};
int y[4]={13,14,15,16};
int (*p)[4]=&y;    	//必须要有&运算符
 //p=y;            	//错误,数组名y的类型是指向int的指针,与p的类型不同
 cout<<p[0][1];    	//输出14。p[0][1]=(*(p+0))[1]=(*(&y+0))[1]=(*(&y))[1]=y[1];
 cout<<(*p)[1];    	//输出14。(*p)[1]=(*(&y))[1]=y[1]
 cout<<*p[0];      	//输出13。 *p[0]=*(*(p+0))=*(*(&y+0))=*(*(&y))=*y=*(&y[0])=y[0]
 cout<<(*p)[6];    	//输出随机值(超出指针所指向的数组范围)。(*p)[6]=(*(&y))[6]=y[6]
 cout<<*p[1];    	//输出随机值(超出指针所指向的数组范围)。*p[1]=*(*(p+1))=*(*(&y+1))
 					//这里&y+1超出一维数组y所示地址的范围。
 cout<<p[0];       	//输出y[0]的地址。p[0]=*(p+0)=*(&y+0)=y=&y[0];
 p=x;              	//把数组x的地址赋给指针p
cout<<p[1][1];    	//输出6。数组名x的类型与指针p的类型相同,可将数组指针p作为数组名x来使用
 cout<<(*p)[5];    	//输出6。(*p)[5]=(*(x))[5]=(*(&x[0]))[5]=(x[0])[5]=x[0][5],这里的
					//第2维超过范围,但二维数组整体未超范围,因为数组是连续存储的,在&x[0][0]
 					//位置向后偏移5个int,相当于移至了x[1][5-4]=x[1][1]。不建议超范围
 					//使用数组下标,以免内存溢出。
 cout<<*p[1];      	//输出值5。*p[1]=*(*(p+1))=*(*(x+1))=*(*(&x[0]+1))=*(*(&x[1]))
								//=*(x[1])=*(&x[1][0])=x[1][0]
 p=x+1;				//p=&x[1];
 cout<<p[1][1];    	//输出10。将p替换为&x[1],因此,p[1][1]=(&x[1])[1][1] 
							//=(*(&x[1]+1))[1]=(x[2])[1]*(x[2]+1)=x[2][1]
 cout<<(*p)[5];   	//输出10。 (*p)[5]=(*(&x[1]))[5]=(x[1])[5]=*(x[1]+5)=x[1][5]

4、虽然可将数组指针当成数组名来理解,但并不代表数组指针与数组名完全相同,他们只是在一般情况下可以等同使用,数组指针的类型始终是指针,而数组名的类型永远是数组,只有在执行数组到指针的转换后,数组名才能作为指针使用。比如,当数组名用作sizeof或取址运算符&的操作符,以及用于初始化引用时,都不会执行数组到指针的转换。

示例6.X:数组指针与数组的区别
int a[3][5]={}; 	//假设int的大小为4字节
int (*p)[5]=a; 
sizeof a; 		//将得到值60,即3*5*4=60,此处的a是数组,sizeof将返回数组中的所有元素
 					//所占据的字节数
sizeof p;		//在32位机器上将得到值4,此处的p是指针,sizeof将返回指针的长度
&p;				//此处的p是指针,因此,&p将得到一个二级指针,即&p与int (**p)[4]相同
&a;				//此处的a是数组(不会执行数组到指针的转换),因此,&a与int (*a)[3][4]相同

二、指针数组

1、指针数组的形式为:

int *a[N];				//定义一个“含有N个int*的数组”,即a是一个一维数组,表示定义了一个有
 						//N个元素的数组,其中每个元素都是一个int型指针
int *a[x1][x2]...[xn];	//定义一个“含有x1个int*[x2][x3]...[xn]的数组”,即a是一个n维数组

这里p先与下标算符结合,表示p是数组,然后与 * 运算符结合,表示数组中存储的元素是一个指针。

2、初始化指针数组时,应像初始普通数组一样,使用初始化列表进行初始化,但必须对每个元素使用指针进行初始化。比如

int a=0, b=1; 
int *p1=&a;  
int *p[3]={&a, &b, p1}; 	//初始化指针数组p

3、记住,指针数组是数组类型而不是指针类型,拥有与数组相同的性质,只是指针数组最终存储的元素是一个指针。因此,指针数组的数组名同样指向的是该数组的第一个元素,对指针数组进行计算时,应将指针数组替换为指针所指向的实际地址再进行计算,具体计算方法与6.3.3节相同。

4、指针数组一般用于字符串,比如char * p[5]={“1”, “22”,”333”};

5、指针数组的类型

  1. T * p[N]={k1,k2, … ,kn}
    假设ki是一个T类型的指针,即ki的类型为T * ,并且假设对数组p执行了数组到指针的转换,即p是指针,则
    在这里插入图片描述
    在这里插入图片描述

    示例6.X:一维指针数组
    int a=1,b=2;
    int *p[4]={&a,&b,0,0};
    int **p1=p;			//正确。p1=&p[0],类型为“int**” 
    int **p2=p+1;		//正确。p2=&p[1],类型为“int**”
    cout<<*(p+1);		//输出&b的地址。即*(p+1)=p[1]
    cout<<**(p+1);		//输出2。即**(p+1)=*(*(p+1)) = *p[1]=*(&b)=b;
    cout<<*p[1];		//输出2。即*p[1]=*(&b)=b;
    

在这里插入图片描述
在这里插入图片描述

示例6.X:指针数组
int b[36];
for (int i = 0; i < 36; i++)b[i] = i;	//使用循环语句对数组赋值,即b[0]=0,b[1]=1,...
int* pp = &b[2];
int* p[3][3][4] = {
	{										//p[0]
		{&b[0],& b[1],&b[2],&b[3]},			//p[0][0]
		{&b[4],&b[5],&b[6],&b[7]},			//p[0][1]
		{&b[8],&b[8],&b[10],&b[11]}			//p[0][2]
	},
	{										//p[1]
		{&b[12],&b[13],&b[14],&b[15]},		//p[1][0]
		{&b[16],&b[17],&b[18],&b[19]},		//p[1][1]
		{&b[20],&b[21],&b[22],&b[23]}		//p[1][2]
	},	
	{										//p[2]
		{&b[24],&b[25],&b[26],&b[27]},		//p[2][0]
		{&b[28],&b[29],&b[30],&b[13]},		//p[2][1]
		{&b[32],&b[33],&b[34],&b[35]}		//p[2][2]
	}
};
int *(*p1)[3][4]=p;		//正确。p1=p=&p[0],其类型为“int *(*)[3][4]”。注意,p的类型不是
 							//二级指针,而是是一个一级指针,指向一个2维数组,因此,p1是一个数组
 							//指针,在此处可将其当作一个三维数组名p来使用。
int *(*p2)[3][3][4]=&p;	//正确。p2=&p,其类型为“int *(*)[2][3][4]”
int *(*p3)[3][4]=p+1;		//正确。p+1的类型与p相同,但p3=p+1=&p[1]。
cout<<p1[1][2];			//输出地址值&p[1][2][0]。将p1当作数组名p使用,因此,直接将其替换为
 							//数组名p得到p[1][2]=&p[1][2][0]。也可通过计算而得到,如下所示:
							//p1[1][2]=(&p[0])[1][2]=(*(&p[0]+1))[2]=(p[1])[2]=*(p[1]+2)
							//=*(&p[1][2])=p[1][2]=&p[1][2][0]
cout<<*p[1][2];			//输出p[1][2][0]的元素的值,即&b[20]。
cout<<**p[1][2];			//输出20,即**p[2][2]=**(&p[1][2][0])=*p[1][2][0]=*(&b[20])=20
cout<<p3[1][2];			//输出地址值&p[2][2][0]。
							//p3[1][2]=(&p[1])[1][2]=(*(&p[1]+1))[2]=(p[2])[2]
							//=*(p[2]+2)=*(&p[2][2])=p[2][2]
cout<<*p3[1][2];			//输出&b[32]的值。p[2][2][0]的元素的值,即地址值&b[32]。
cout<<**p3[1][2];			//输出32,即**p[2][2]=**(&p[2][2][0])=*p[2][2][0]=*(&b[32])=32

cout<<p1[0][1][2];		//输出&b[6]的值,将p1当作数组名p使用,直接将其替换为p得到
								//p[0][1][2];
cout<<*p1[0][1][2];		//输出6。将p1替换成p,再将p[0][1][2]替换成其中的元素&b[6],得到
								//*p1[0][1][2]=*(&b[6])=b[6]=6
int *(*p4)[4]=p[1];		//正确。p[1]=&p[1][0],说明p[1]是一个指针,而p[1][0]存储的元素的类
 							//型是“int*[4]”,因此,p[1]的类型是“指向int*[4]的指针”,即
							//“int*(*)[4]”。注意p[1]仍然是一个一级指针。
cout<<p4[1];				//输出。p4[1]=*(p4+1)=*(&p[1][0]+1)=p[1][1]=&p[1][1][0];
cout<<*p4[1];				//输出&b[16];
cout<<**p4[1];			//输出16。

int **p5=p[1][0];			//正确。p[1][0]=&p[1][0][0],说明p[1][0]是一个指针,而p[1][0][0]
								//存储的元素的类型是“int *”,因此,p[1][0]的类型是“指向int*的指
							//针”,即“int **”。注意,这里的p[1][0]是一个二级指针。
cout<<p5[1];				//输出&b[13]。p5[1]==*(p5+1)=*(&p[1][0][0]+1)=p[1][0][1]
cout<<*p5[1];				//输出13。*p5[1]=*p[1][0][1]=*(&b[13])=b[13];

6.4 引用


6.4.1 引用基础

1、引用是所引用的对象的别名。若表达式最初具有“对T的引用”类型,则在进行进一步分析之前将该类型调整为T,即,在计算表达式时,会将T&调整为T。

2、C++有两种类型的引用:左值引用(lvalue reference)和右值引用(rvalue reference)。左值引用使用运算符&声明;右值引用使用运算符&&声明。在4.1节讲解了一些引用的基础规则,本小节将进一步详细讲解引用的其他规则。

3、本文将引用const类型的左值引用称为const左值引用。比如const int &a=3;其中,a就是const左值引用。

4、绑定(bind)
将使用初始化表达式初始化引用的行为称为绑定(bind),使用“绑定”一词有时会更方便描述。比如将引用绑定到初始化表达式,将对象绑定到引用,引用与它的初始值对象绑定在一起等等。

5、cv限定符不能限定引用,否则是不规范的(ill-formed),但是通过使用typedef名称或decltype引入的cv限定符可以限定引用,此时会忽略cv限定符。比如

示例6.X:cv限定符不能限定引用
using T=int &;		//T是一个typedef名称
int a=1;
//int &const b=a;	//错误。不能使用const限定引用。注:VC++允许,但会忽略const
const int &c=4;		//正确。const不是限定的引用。
const T d=a;			//正确。d的类型int&,但理论上来讲d的类型应是int& const,这是不允许的,
 						//但C++标准要求在此时忽略const,因此d的类型为int&
//const T e=3;		//错误。e的类型是int&,而不是const int &。

6、引用折叠(reference collapsing)
引用折叠是指,若typedef名称或decltype表示的类型TR是对类型T的引用(含左值引用和右值引用),即

using TR=T &,或者,using TR=T&&
  • 则将“对cv TR的左值引用”创建为“对T的左值引用”。即,将cv TR&创建为T&,说简单一点就是忽略掉cv TR&中的cv限定符和&符号,并将TR替换为T&。
  • 将“对cv TR的右值引用”创建为TR。即,将cv TR&&创建为TR,说简单一点就是忽略掉cv TR&&中的cv限定符和&&符号。
示例6.X引用折叠
int a=1;
using TR=int&;
using TRR=int&&;
TR& b1=a;			//b1的类型为int&,而非int&&。将TR&创建为T&,最终为int&
const TR& b2=a;		//b2的类型为int&。将const TR&创建为T&,最终为int&
TR&& b3=a;			//b3的类型为int&。将TR&&创建为TR,最终为int&
const TR&& b4=a;		//b4的类型为int&。将const TR&&创建为TR,最终为int&

TRR& b5=a;			//b5的类型为int&。将TRR&创建为T&
const TRR& b6=a;		//b6的类型为int&。将const TRR&创建为T&
TRR&& b7=4;			//b7的类型为int&&。将TRR&&创建为TRR,最终为int&&
const TRR&& b8=5;	//b8的类型为int&&。将const TRR&&创建为TRR,最终为int&&

decltype(b2)& b9=a;		//b9的类型为int&。将TR&创建为T&
decltype(b2)&& b10=a;	//b10的类型为int&。将TR&&创建为TR,最终为int&
decltype(b7)&& b11=6;	//b10的类型为int&&。将TR&&创建为TR,最终为int&&

6.4.2 引用的初始化

一、引用初始化的基本规则

1、基本规则

  1. 引用必须包含初始化器(即,必须初始化引用),除非是显示的extern声明、类定义中的类成员声明、形参或返回类型的声明。比如

    示例6.X:引用必须包含初始化器
    extern int &a;		//正确。这是声明,可以不用初始化。
    //int &&b;			//错误。引用必须初始化。
    void f(int &a){}	//正确。函数的形参int &a可以不包含初始化器
    
  2. 无法将引用重新绑定到另一个对象,一旦初始化完成,引用将与其所引用的初始值对象一直绑定在一起。

    示例6.X:引用不能重新绑定到另一个对象
    int a=1,b=2;
    int &c=a;	//c将一直是a的别名。
    c=b;		//正确。这不是将c改变成对b的引用,而是将b的值赋值给c引用的对象,即,相当于a=b
    
  3. 不能指定对cv void类型的引用,引用必须初始化为引用有效的对象或函数。比如

    //extern void &b;		//错误。
    
  4. 右值引用不能绑定到左值,非const左值引用不能绑定到右值,const左值引用既可绑定到左值也可绑定到右值。比如

    示例6.X:引用与初始化表达式的值类别关系
    int a=1;
    //int &&b=a;		//错误。右值引用b不能绑定到左值a
    //int &c=3;			//错误。非const左值引用不能绑定到右值
    int &&d=4;			//正确。
    const int &e=a;		//正确。const左值引用可绑定到左值
    const int &f=4;		//正确。const左值引用可绑定到右值
    
  5. 不能有对引用的引用,不能有存储引用的数组,指针不能指向引用。引用也不能直接绑定到位域(bit-field)。比如

    示例6.X:引用的限制
    //extern int &(&a);		//错误。不能有引用的引用
    //extern int &b[3];		//错误。数组不能存储引用
    //extern int &(*p);		//错误。指针不能指向引用
    int (&&c)[2]={1,2};		//正确。可以引用数组
    int x=1;
    int *(&&p1)=&x;			//正确。可以引用指针
    
  6. 左值到右值的转换、数组到指针的转换、函数到指针的转换不会执行。比如

    示例6.X:绑定到引用时不会执行的转换
    int a[2]={};
    int b=1;
    int (&b)[2]=a;			//正确。此处未执行数组到指针的转换,因此a表示数组类型而非指针,是左值
    //int (&&c)[2]=a;		//错误。a是左值,未执行数组到指针的转换
    //int &&d=a[1];			//错误。a[1]是左值,不会对a[1]执行从左值到右值的转换。
    //int &&e=b;			//错误。原因同上
    
  7. 对于非const左值引用的初始化表达式不会执行通常的算术转换等隐式转换,对于const左值引用和右值引用,若初始化表达式与引用的类型不是引用兼容的(详见稍后的讲解),则会进行通常的算术转换等隐式转换,并且会启用临时具体化转换(即,会产生一个临时对象)。注意,第6点的转换仍然不会被执行。

    示例6.X:引用与隐式转换
    int i=2;
    //float &d=i;			//错误。非const左值引用不会执行算术转换,或者说引用不兼容
    const float &f=i;		//正确。const左值引用会执行算术转换。转换后的结果是纯右值
    float &&g=i;			//正确。右值引用会执行算术转换,将i转换为float类型后是右值
    //int &&h=i;			//错误。i与int的类型是兼容的,此处不会进行类型转换,因此,i是左值
    
  8. 引用与cv限定符之间遵守cv限定转换的规则。

    示例:6.X3:引用与cv限定符
    const int i=2;
    //int &a=i;					//错误。a比i有更少的cv限定符
    //volatile int &b=i;		//错误。b的cv限定符并不比i更多
    const int &c=i;				//正确。
    const volatile int &d=i;	//正确。
    //int && e=i;				//错误。i是左值
    //const int && f=i;			//错误。i是左值。
    float &&g=i;				//正确。i的类型与g的类型不兼容,首先进行隐式转换,并应用临时具体
     							//化转换,此时会将i的cv限定符删除(将非类类型的表达式转换为纯右
     							//值时,会删除其cv限定符),因此,转换后i的类型为float的纯右值,
    								//也就是说隐式转换后不存在cv限定符的问题
    //int *(&&h)=&i;			//错误。h比&i拥有更少的cv限定符,因为i和h的类型都为int,在此
     							//处不会执行隐式转换,因此,最终&i的类型是const int *
    const int *(&&k)=&i;		//正确。原因见上
    //const int *(&m)=&i;		//错误。&i的结果是右值。注意,m是左值引用,引用的是指针,该指针指向
     							//一个常量,但并不表示m引用的是常量
    const int*const (&n)=&i;	//正确。n是左值引用,引用一个const,也就是说,n是const左值引用,
     							//引用的是一个const int *对象
    
  9. 引用是否需要使用存储空间是不确定的。

  10. 引用不是一个对象,而是所引用对象的别名。左值引用和右值引用是两种不同的类型。

3、引用相关的(reference-related)和引用兼容的(reference-compatible)

  • 类型“cv1 T1”和cv2 T2“,若T1和T2类型相似(见6.1.6节),或T1是T2的基类,则称“cv1 T1”与“cv2 T2”是引用相关的。引用相关意味着:

    • T1和T2类型相同(由类型相似得出的结论)。
    • 指针的级数相同(由类型相似得出的结论)。
    • T1是T2的基类。
  • 若“指向cv2 T2的指针”类型的纯右值,可通过标准转换序列转换为“指向cv1 T1的指针”,则称“cv1 T1”与“cv2 T2”是引用兼容的。说简单点就是,引用兼容是指可使用标准转换序列将

    “cv2 T2 *”转换为“cv1 T1*

    注:cv2 T2是初始化引用的初始化表达式的类型,cv1 T1是引用的类型。

    注意,这里的引用兼容是“指针类型”之间的转换,因此,引用兼容意味着:

    • 引用兼容不会执行通常的算术转换等隐式转换。比如,int和float不是引用兼容的,因为int不能转换为flaot
    • 引用兼容需要满足cv限定转换(见6.1.6节)的要求。

4、若引用绑定需要使用引用兼容关系来建立,但使用的标准转换序列是不规范的(ill-formed),则这种引用绑定是不规范的(ill-formed)。

二、引用初始化的详细规则
1、左值引用的初始化规则
若引用是左值引用,其类型为“cv1 T1”,初始化表达式的类型为“cv2 T2”,则

  1. 若初始化表达式是lvalue(但不是位域),且cv1 T1与cv2 T2是引用兼容的,则将引用绑定到初始化表达式的lvalue。
  2. 若T2是类类型,其中,T1和T2不是引用相关的(即T1不是T2的基类),并且可以转换为“cv3 T3”的lvalue(左值),其中cv1 T1与cv3 T3是引用兼容的(通过转换函数、重载转换等步骤),则将引用绑定到转换后的lvalue。
  3. 或者,将左值引用绑定到初始化表达式对象的适当基类子对象。
  4. 对于其他情形,则左值引用的类型必须是“const限定类型,或者,非volatile限定类型”,否则,程序是不规范的(ill-formed)。
  5. 以上规则说明以下几个问题:
    • 对于非const左值引用必须满足以下要求:

    • 初始化表达式必须是lvalue(左值)。

    • 初始化表达式最终必须引用兼容。这意味着不会执行通常的算术转换等隐式转换,并且需要满足cv限定转换的要求。注意,初始化表达式必须是lvalue(左值)。

      示例6.X:引用兼容(const左值引用不能进行隐式转换)
      const int a=1;
      //int &b=a;			//错误。引用不兼容,因为从“const int *”到“int *”的转换不满足
       						//cv限定转换的规则。在此处,int &比const int有更少的cv限定符
      const int &c=a;		//正确。
      int i=2;
      //float &d=i;		//错误。不执行算术转换,或者说引用不兼容(不能将int*转换为float*)
      //int &e=4;			//错误。4是纯右值,非const左值引用必须绑定到左值。
      const float &f=i;	//正确。const左值引用会执行算术转换。转换后的结果为是纯右值
      const int &g=4;		//正确。const左值引用可绑定到纯右值
      
    • 对于类类型(T1不是T2的基类的情形)的初始化表达式可通过转换函数转换,注意,转换后的结果必须是lvalue。

      示例6.XX1:转换函数和引用
      class B {};
      class C{};
      class A {public:
      	operator B& () {B mx;return mx; }	//转换函数。可将类型A转换为类型B的左值引用
      operator C() { return C();}			//转换函数。结果转换为类型C的纯右值,而非lvalue
      };
      A ma;
      B &mb=ma;			//正确。调用operator B&()将ma转换为类型B,转换结果是lvalue
      //B&& mb1=ma;		//错误。
      //C &mc =ma;		//错误。调用operator C(),但转换的结果是纯右值。
      C&& mc1=ma;		//正确。
      
    • 对于T1是T2基类的情形,则将左值引用绑定到对象的适当基类子对象。

      示例6.X:带基类的引用(转换为左值)
      class B{};
      class A:public B{};		//类A继承自类B。注:需要公有继承
      A ma;				//
      B &mb=ma;			//正确。将mb绑定到ma的基类子对象(即类B子对象)
      //B &&mb1=ma;		//错误。ma转换为类B子对象之后的结果是左值。
      //B& mb2=A();		//错误。A()创建的是一个临时对象,因此转换为基类子对象后的结果是右值
      B&& mb3=A();		//正确。
      
  • 对于const左值引用(由第4点规则得出以下结论):
    • 若初始化表达式不是左值(如纯右值),则左值引用必须是const限定类型的(即const左值引用)。
    • 若初始化表达式不是引用兼容的,则必须是const左值引用。比如,int和float不是引用兼容的,因为int * 不能转换为flaot * ,此时需使用const左值引用。
    • 可见,const左值引用既可以绑定到纯右值也可以绑定到左值
    • 当然,这并不是说,const左值引用就能绑定所有其余情形,这将在稍后讲解。
      示例6.X:const左值引用可绑定到右值
      int x=1;
      //int &a=3;			//错误。3不是左值,且a不是const左值引用
      const int &b=4;		//正确。b是const左值引用。
      //int &c=x;			//错误。int和float类型不兼容,且c不是const左值引用。对于非cons
       					//左值引用,不会执行通常的算术转换。
      const float &d=x;	//正确。int和float类型不兼容,但是,对于const左值引用会执行通常的
       					//算术转换(详细规则稍后讲解),转换后的结果是纯右值。
      const int &e=x;		//正确。x不会进行转换,是左值。const左值引用可以绑定到左值。
      

2、右值引用或const左值引用的初始化
若引用类型为“cv1 T1”,初始化表达式的类型为“cv2 T2”,则

  • 若初始化表达式是rvalue(但不是位域)或函数左值(lvalue),且cv1 T1与cv2 T2是引用兼容的。

  • 若初始化表达式是类类型,其中,T1和T2不是引用相关的(即T1不是T2的基类),并且可以转换为“cv3 T3”的rvalue(右值)或函数左值,其中cv1 T1与cv3 T3是引用兼容的。

  • 若第一种情况下初始化表达式的值和第二种情况下转换后的结果是一个纯右值(prvalue),假设其类型为T4,则将其调整为“cv1 T4”类型(即执行cv限定转换,见6.1.6节),并启用临时具体化转换(见5.1.3节)。然后,引用绑定到转换之后的结果的glvalue(广义左值),或者绑定到适当的基类子对象。注:由于转换之后的结果是一个临时对象,glvalue强调的是临时对象的内存空间而非值,因此,绑定到转换之后的结果的广义左值(glvalue)意味着该引用是右值引用或者const左值引用。

  • 以上规则是对第1点规则的进一步补充说明,说明了当初始化表达式是右值(rvalue)、函数左值、以及由转换函数转换为右值(可参见示例6.XX1)时,其结果应该被绑定到右值引用或者const左值引用,以上规则仍然必须是引用兼容的,因此,仍然不会执行算术转换,但实际上若引用是右值引用或const左值引用时是允许出现隐式转换的,稍后的规则会说明这一点。

    示例6.X:带基类的引用(转换为右值)
    class B{};
    class A:public B{};		//类A继承自类B
    A f(){return A();};
    //B &x=f();		//错误。x会绑定到A的基类子对象,但,函数f()返回的结果是一个右值
    B&& x1=f();		//正确。
    const B& x2=f();	//正确。
    //B& y=A();		//错误。A()是临时对象,因此,转换为类B子对象之后的结果是右值
    B&& y1=A();		//正确。
    A ma;
    B& y2=ma;			//正确。ma是左值,转换之后的结果仍是左值。
    //B&& y3=ma;		//错误。
    

3、除第1点和第2点的情形外的其他情形,初始化表达式隐式转换为“cv1 T1”类型的prvalue,并应用 临时具体化转换(见5.1.3节),并将引用绑定到转换的结果。注意,启用临时具体化转换意味着会产生临时对象,只有右值引用或const左值引用才能绑定到临时对象。此规则说明,对于右值引用或const左值引用,当引用不兼容时,会进行隐式转换,并会产生临时对象,但要注意,cv限定转换的规则仍然必须遵守。或者说,当引用不兼容时,需要使用右值引用或const左值引用,不能使用非const左值引用。关于此规则的示例,可参阅示例6.X3,因此,不再重复列举示例了。

4、注意:右值引用不能绑定到lvalue(左值)。


6.5 动态分配内存与new运算符基础


注:重载new和delete运算符、定位(placement)new表达式等内容详见后续章节,本小节仅讲解new/delete基础内容

6.5.1 内存管理基础

一、内存管理概述
1、内存管理器
内存是由内存管理器根据相应的内存分配策略进行分配的,本文只需知道内存是由内存管理器分配的这一事实就行了,有关内存管理器存在于何处,是怎样实现的,没必要对其进行深入了解。

2、内存管理策略
一般有3种策略,即任意大小的分配策略、固定大小的分配策略、垃圾收集的分配策略。任意大小分配策略即可以分配任意大小的内存块,这种方法非常灵活,但性能差且易产生碎片(即许多小的,不连续的未分配的内存区域)。固定大小分配策略,即总是分配固定大小的内存块。

3、内存管理的分层
内层管理一般存在着几种不同的内存管理层面,比如操作系统内核提供最基础的内存分配服务,而编译器也会建立起自已的内存分配服务,比如C++中的new或C中的malloc是建立在操作系统本地分配服务基础上的,有可能是编译器从操作系统那里首先预定一大块的内存,然后根据需要再分配出去,以此来覆盖操作系统的本地分配服务,至于编译器是怎样实现的,不是C++语言的重点。

4、静态内存分配与动态内存分配

  1. 通常有两种类型的内存分配策略,即静态内存分配和动态内存分配。

  2. 静态内存分配由编译器在编译时(即程序运行之前)自动完成,不需手动干预,即内存的分配和释放由编译器自动完成,或者说静态分配的内存是编译器提前分配的内存。动态内存分配是在运行时,根据需要由用户(即程序员)分配内存,当不再需要时,由用户释放内存,也就是说,内存的分配和释放是由用户手动完成的(即内存的分配和释放由用户控制),而不由编译器自动完成,这就会存在用户忘记释放内存的情形,此时就会产生内存泄漏问题。C++一般使用new动态分配内存,使用delete释放动态分配的内存,也就是说程序员可以控制动态分配的内存的生命期。

  3. 由于静态分配的内存是提前(即程序运行前)分配的内存,因此,一旦被分配,内存将一直存在直到结束,这意味着,静态分配的内存大小不能被改变,也不能被重复使用。而动态分配的内存是在执行过程中由用户根据需要分配的内存,因此,内存的大小可以被修改,并且用户可以在任意时刻释放内存,也可以被重复使用。

  4. 静态内存分配在编译阶段分配内存空间,不管该内存是否被使用,一旦分配了就会一直在那里占用内存空间。动态内存分配在运行阶段需要时就创建,不需要时就不创建。二者的典型区别是,每次运行程序时对于静态分配的内存,其地址是相同的,而动态分配的内存,则每次运行程序时的地址可能会不一样。比如

    示例6.X:编译时分配内存与运行时分配内存的区别
    #include<iostream>
    using namespace std;
    int main() {
    int a =2;				//a位于栈中(稍后会讲解),其内存是静态分配的
    int *b = new int;		//new int的内存是动态分配的。注意,b的内存是静态分配的
    cout << &a << endl;		//输出为a分配的地址。每次运行时输出的地址将保持不变
    cout << b << endl;		//输出new int分配的地址。每次运行时将输出不一样的地址
    cout<<&b<<endl;			//每次运行时输出的地址将保持不变
    }
    

    将以上代码保存为一个源文件,如a.cpp,并使用g++或VC++等将其构建成一个可执行文件,比如为a.exe,则每次运行a.exe时,对于&a,每次都会输出相同的地址值,但对于b,则每次都会输出不一样的地址值。这是因为a的内存在运行之前就已经分配好了,而new int是在运行的时候才分配的内存。对于&b,其原理与&a相同。

二、内存的划分

1、对于C++,通常理解为将内存划分为5个区,分别是堆区(自由存储区)、栈区、全局/静态存储区、常量存储区、代码区。根据C++有关静态存储持续期(详见第8章)的规则,全局/静态存储区和常量存储区应归为同一类。

  • 堆(heap)区(自由存储区)
    堆区也称为堆、自由存储区,通常使用动态分配内存的方式分配堆区的空间,也就是说,堆区通常由程序员手动分配和释放。比如,使用C++的new/delete表达式或C语言的malloc/free函数分配和释放堆区。堆区分配的内存的生命周期与C++的动态存储持续期(详见第8章)相对应。

  • 栈(stack)区
    栈区简称为栈,也称为堆栈、堆栈区,通常用来存放局部变量(详见第8章)、函数形参等。栈区通常使用静态分配内存的方式分配栈空间,也就是说,栈区的分配和释放是由编译器自动完成的。栈区分配的内存的生命周期与C++的自动存储持续期(详见第8章)相对应。

  • 全局/静态存储区
    全局/静态存储区简称为静态区,通常用于存入静态变量(详见第8章)、全局变量(详见第8章),此区域的数据被全局所共享,其分配由系统负责,一旦分配会一直持续到程序结束,然后由系统释放。也就是说,静态区的分配和释放也是由编译器自动完成的,不能由程序员控制。静态区分配的内存的生命周期与C++的静态存储持续期(详见第8章)相对应。

  • 常量存储区
    常量存储区简称为常量区,该区存放常量,不允许被修改,其分配和释放由系统负责。常量区分配的内存的生命周期与C++的静态存储持续期(详见第8章)相对应。因此,严格的来讲,常量存储区应归类为静态区。

  • 代码区
    代码区用于存放程序的代码并且是只读的,主要是cpu执行的机器指令,如有关函数的指令。注意,C++没有与代码区相关的存储持续期。

    示例6.X:内存的划分
    int a=1;				//全局/静态区
    static int a1=2;		//全局/静态区
    void f(					//代码区
    	int b,int c			//形参位于栈区
    )
    { int d=1;			//栈区。d是局部变量
    int *e=new int;		//e位于栈区。new int分配的内存位于堆区。该语句的意思是,在栈中存放一个
     					//指针e,e指向由new在堆上分配的内存,程序先使用new在堆中分配一块合
     					//适大小的内存,然后返回这块内存的首地址,并将这个地址放入栈中。
    static int g=2;		//g位于全局/静态区
    char *h="abc";		//h位于栈区,"abc"是常量,位于常量区
    const int i=4;		//i位于常量区
    }
    int main(){}
    

2、各内存区的主要区别
各内存区的主要区别在于分配的内存空间的生命周期不同,堆区的生命周期由程序员控制,其余区的生命周期由系统控制,但他们的结束时间(即生命周期)并不相同,有关生命周期的问题详见第8章。

3、堆和栈的区别

  • 栈是静态分配的,由编译器自动管理,无需手动干预。而堆是动态分配的,需要由程序员控制,若忘记释放内存,则会产生内存泄漏问题。
  • 计算机会在底层对栈提供支持,分配专门的寄存器存放栈的地址,并有专门的指令对栈进行操作,因此,分配栈的效率非常高。而堆则是由C++的函数库提供的支持,其机制非常复杂,为了分配一块内存,库函数会按照一定的算法在堆中搜索可用的空间,显然,分配堆的效率比栈低很多。而且由于大量的分配/释放堆空间,容易造成大量的内存碎片。
  • 虽然如此,但是栈没有堆灵活,栈是一块连续的内存区域,堆不是连续的,这意味着执行插入和删除等操作时,栈会产生更多的移动和内存浪费,所以,栈常用于数组,堆用于链表等数据结构。

4、各内存区的大小

  • 由于栈受到计算机的底层支持,因此,栈的容量和地址通常是由系统预先规定好了的,所以,栈的大小通常不会很大,对于VC++,栈的大小默认可能是1M=1048576字节,也就是说,其最大大小为1048575=0xF FFFF字节。VC++可以通过“项目”→“属性”→“链接器”→“系统”,然后在“堆栈保留大小”处查看其大小。比如

    int main(){
    double a[65536*2];		//假设double占8字节,则对于VC++可能会产生栈溢出错误。因为
    								//65536*2*8=1048576=1MB,其分配的栈的大小可能过大(VC++
     							//默认栈的大小可能是1MB)
    }
    
  • 堆、静态区的大小可能是2GB=2147483648字节,也就是说其最大大小为0x7FFF FFFF=2147483647字节,若分配超过2147483647字节的内存空间可能会产生溢出错误。

  • 总之,栈、堆、静态区的大小因系统而异。

6.5.2 new运算符基础

一、new运算符基础
1、new和delete被C++实现为运算符,记住他们是一个运算符,与常规的运算符类似,具体怎样实现的不用去关心,只需知道new和delete是运算符即可。new和delete是运算符,就自然存在与该运算符相关的表达式以及运算符的特性(如拥有一个结果值、结果类型等)。就像“+”是运算符,而a+b是使用“+”运算符时的表达式一样,new运算符也有与此相关的new表达式,最常见的new表达式就是使用常规的new分配内存的方法。比如int * p=new int;其中new int就是new表达式。

2、C++使用new运算符来动态分配内存,delete释放以前new分配的内存。使用new运算符系统将从自由存储区(即堆区)中为对象分配内存,并返回一个指向该对象的指针(即该对象的地址)。

3、最简单的使用new运算符分配内存的方法是在关键字new之后跟随一个类型名称(如T)即可,即

new T

这时new会根据指定的类型T找到一个长度合适的内存块,然后返回该内存块的地址,也就是说,new运算符的结果类型是“指向T的指针”,即“T*”(注意,若T是数组类型会有一些不同,详见后文),结果值是一个地址值。然后,只需创建一个类型匹配的指针,指向使用new分配的内存,就可以通过指针间接的访问该内存了。比如

int *p=new int; 	//表示使用new从空闲存储区分配一个int型的对象,并返回该对象的地址,然后让指
					//针p指向这个内存地址,然后就可以通过指针p间接对new分配的内存进行访问了
*p=1;				//将1存入由new分配的内存

4、new运算符的特点是,用new运算符分配的对象没有名字,对该对象的操作都要通过指针间接地完成。例如new int,就是从空闲存储区分配了一个int型对象,但这个对象没有名字,因此没法直接对这个对象进行操作,只是从存储区分配了这么一个空间,要操作此对象需要通过指针来间接完成。比如

int i;
int*p=&i;
int *p1=new int

都是将int对象的地址赋给了指针,但不同的是前者可以用名称i和 * p来访问该int型对象。而后者则只能用 * p1来访问该对象,也就是说p1指向的内存没有名称。

二、new表达式的完整语法

1、以下是new表达式的完整语法:
在这里插入图片描述
2、包含“new定位(new-placement)”的new表达式与定位new有关,本小节仅讲解有关new表达式的基础内容,不讲解定位new,定位new详见后续章节。下面列举几个new表达式的示例,以熟悉new表达式的语法。

示例6.X:常见的new表达式语法形式
int *p=0;
new int;				//正确。int是“new类型id”
new int *;			//正确。int*是“new类型id”
new int *[2];			//正确。int*[2]是“new类型id”
//new int (*)[2];		//错误。int (*)[2]不是正确的“new类型id” 
new (int *[2]);		//正确。圆括号中的int*[2]不是表达式列表,因此,(int *[2])是“(类型id)”
new (p) int[2];		//正确。定位new。(p)中的p是表达式,因此,(p)是“new定位”,
 						//int[2]是“new类型id”
new (p) (int[2]);		//正确。定位new。因为,(p)中的p是表达式

三、new表达式的基本规则
1、由new表达式创建的对象的类型必须是完整的对象类型,但不能是抽象类类型或抽象类类型的数组。注意:函数、引用都不是对象类型,也就是说,函数、引用不能由new表达式创建。比如

示例6.X:new表达式只能创建对象且必须是完整类型的
//new int &;			//错误。不能由new表达式创建引用,引用不是对象
//new (int ());		//错误。试图创建一个返回int的函数,函数不是对象。
new int();			//正确。此处的int是“new类型id”,圆括号是初始化器。
//new int [];			//错误。创建的对象类型必须是完整的。

2、new表达式中的“new类型id”是可能的最长序列(即贪心算法)。比如

new int *a;		//语法错误。解释为(new int*)a,而不是(new int)*a,第2种情形将*理解为乘法运
 					//算符,这也是一种错误的情形,因为指针(new表达式的结果是一个指针)不能进行乘法运算

3、new表达式的结果类型(含数组)

  1. 两个基本规则:

    • 当new表达式创建的对象不是数组时,new表达式的结果是指向所创建对象的指针。比如

      new T			//假设T不是数组,则结果类型是“指向T的指针”,即结果类型是“T*”
      new (T)		//同上
      
    • 若创建的对象是数组,则结果是指向数组第一个元素的指针,此时的类型与数组名的类型类似,其类型的分析方法详见6.3.1节、6.3.4节。也就是说,若使用new表达式创建一个数组,其返回的结果类似于一个数组指针,可像数组指针一样来使用。注:在进行类型分析时,注意从前缀后缀运算符之间的位置开始分析,并记住使用优先级规则(详见4.8节)。

  2. 注意:

    int *p=new int[30];		//new表达式的结果为类型为“int*”
    int *p1=new int; 			//同上
    p[29]=3;					//正确。
    p1[29]=3;					//可能发生内存错误而崩溃
    

    不要认为p和p1都只是int指针而没有区别,其实两者有很大的差别,new int[30]分配了存储30个int类型的空间,因此可以保证在以p为基础的地址上向后在29个单位内偏移而不会出错;但new int只分配了存储1个int类型的空间,因此在以p1为基础的地址上进行偏移,访问到的将是不确定的内存空间(有可能是空闲的内存也可能不是空闲的内存),比如p1[29]=3;程序就有可能因为内存不是空闲的而崩溃;但p[29]=3;就不会有错。

示例6.X:new表达式的结果类型的分析方法
int *p=new int;			//new表达式的结果类型是“指向int的指针”,即“int *”
int *p1=new int[2];		//new表达式创建的是一个数组,假设创建的数组为a[2],则new表达式
								//的结果是指向a[0]的指针,由于a[0]的类型是int,因此结果类型是“指向
 								//int的指针”,即“int*”。注意,结果类型不是指向int[2]的指针,即不
 								//是“int (*)[2]”
int (*p2)[3]=new int[2][3];	//假设new表达式创建的数组为a[2][3],则new表达式的结果是指向
 								//a[0]的指针,由于a[0]的类型是int[3],因此,结果类型是“指向
									//int[3]的指针”,即“int(*)[3]”
int (*p3)[3][4]=new int[2][3][4];		//new表达式创建的是数组,因此,其类型是“指向int[3][4]”
										//的指针,即“int (*)[3][4]”
int **p4=new int*;			//new表达式创建的不是数组,因此,其类型是‘指向int *的指针”,即结
 								//果是一个指向指针的指针,即类型是“int **”。注:这是使用new表
 								//达式创建多级指针的方法。
int **p5=new int*[2];			//new表达式创建的是一个数组,由于该数组第一个元素的类型是int*,
 								//因此,new表达式的结果类型是“一个指向int *的指针”,即,结果类 
 								//型是“int **”
//int **p6=new int(*)[2];		//错误。“new类型id”不能包含圆括号,应使用“(类型id)”的形式
int (**p7)[2]=new (int(*)[2]);	//new表达式创建的不是一个数组,而是一个指针,因此,其类型是
 									//“指向int (*)[2]的指针”,即类型是“int(**)[2]”
int *(*p8)[3]=new int *[2][3];	//new表达式创建的是一个数组,由于该数组第一个元素的类型是
										//int*[3],因此,结果类型是“指向int*[3]的指针”,即
 									//“int *(*)[3]”
int *const(*p9)[5]=new int *const[4][5]{}; 	//new表达式创建的是一个数组,由于该数组第一个元
 									//素的类型是int *const[5],因此,结果类型是“指向
 									//int*const[5]的指针”,即“int*const(*)[5]”。注:最末
 									//尾的大括号“{}”是初始化器

4、“new类型id”和“(类型id)”的区别
由“new类型id”的语法可见,“new类型id”中只能出现“[]、* ”运算符,不能出现圆括号,若出现圆括号要么会被认为是带圆括号的初始化器,要么就是错误的。因此,“(类型id)”形式的new表达式能创建更复杂类型的对象。注:这种差别可使用typedef名称来解决。比如

示例6.X:“new类型id”和“(类型id)”的区别
//new int (*);		//错误。会被解释为(new int)(*),即会将圆括号解释为初始化器,但*不是表达式
//new int (*)[2];		//错误。会被解释为(new int)(*)[2]
new (int (*)[2]);		//正确。创建一个指向int[2]的指针
//new int (*[2])();	//错误。会被解释为(new int) (*[2])()
new (int (*[2])());	//正确。创建一个有2个元素的指向函数的数组。
using T=int (*[2])();
new T;				//正确。创建的对象同上

5、new表达式创建的数组

  • 由new表达式创建的数组被称为动态数组。

  • 由new表达式的语法可见,“new类型id”所表示的数组的第一维可以是一个“表达式”,除第一维外的其他维必须是“常量表达式(即编译时求值的常量)”,但是,由“(类型id)”所表示的数组的所有维都必须是“常量表达式”,不能有“表达式”。也就是说:

    • a) 若new表达式创建的数组的第一维不是常量表达式,则不能使用圆括号括起来,但编译器不一定支持此规则,有些编译器会发出一个警告,有些编译器将允许此情况。因此,在使用new表达式创建数组时,建议使用“new类型id”形式的new表达式。
    • b) 要想创建大小可改变的数组(仅限第一维),只能使用“new类型id”的形式创建,此时创建的数组的第一维的大小不需在编译时知道其长度,可以在运行时才确定其大小,并且其大小可以改变,因此,可使用变量来指定第一维的大小。除此之外,“(类型id)”形式的new表达式创建的数组和常规方式创建的数组的大小都不能被改变,都必须在编译时知道其大小,即只能指定“常量表达式”作为其数组的大小。
  • C++允许动态分配长度为0的空数组(注:长度为0的数组变量是非法的),使用new创建的长度为0的空数组,new返回的将是一个有效的非零指针,因为这种指针没有指向任何元素,因此在程序中不能对该指针进行解引用操作,否则可能会出错。不建议使用长度为0的数组。比如

    int a[0];			//错误。不允许大小为0的数组变量
    int *p=new int[0]; 	//正确。允许大小为0的动态数组
    *p=2;				//可能会出错。
    
  • 有关new表达式声明数组时的其他规则与数组的规则类似,比如,下标不能是负数。下标的数值不能大于std::size_t的大小。若省略数组第一维的大小时,必须使用new初始化器,其数组大小由初始化器中的元素数量决定等等。

    示例6.X:new表达式数组的下标
    int a=2;
    new int[a][3];	//正确。这是符合C++标准的,第1维可以不是常量表达式。
    new (int[a][3]);	//若严格按C++标准,则第1维必须是常量表达式,因此,是错误的。但编译器一般
     					//允许此情况,有些会编译器发出一个警告
    //new int[3][a];	//错误。除第1维外的其他维必须是常量表达式
    

6、内存分配失败的处理
自由存储区的空间是有限的,虽然现在内存很大,但也有可能被耗尽。若new表达式无法获得足够大的内存空间或内存分配失败,则new表达式可能会返回一个空指针,或者抛出一个std::bad_alloc异常,具体规则本小节暂不讲解,详见后续章节。

三、初始化new表达式创建的对象
由new表达式的语法可见,new表达式只能使用圆括号初始化器和大括号初始化器,并且不能使用带等号的初始化器,也就是说,new表达式创建的对象只能直接初始化。若new表达式没有指定初始化器,则创建的对象被默认初始化,此时需要注意,对于非类类型,不管此语句是在全局还是在局部范围内,若没有初始化,则对象都将拥有一个不确定的随机值,对于类类型将使用默认构造函数进行初始化。

示例6.X:new表达式的初始化
int *p=new int(2);	//正确。圆括号初始化器,将new创建的对象初始化为2
cout<<*p;			//输出2
new int{4};			//正确。大括号初始化器,将new创建的对象初始化为4
new int();			//正确。圆括号是初始化器,这是值初始化,因此,将new创建的对象初始化为0
new int{};			//正确。大括号是初始化值,这也是值初始化,其初始化的结果为0
new int[2](1,2);	//正确。末尾的(1,2)是初始化器。将创建的数组的第一个元素初始化为1,
 				//第二个元素初始化为2。
new int[2]{1,2};	//正确。初始化结果同上
new (int[2]){1,2};	//正确。初始化结果同上
new (int[2])(1,2,);	//正确。末尾的(1,2)是初始化器。初始化结果同上。
//new int[2]={1,2};	//错误。只能使用直接初始化
int *p1=new int;	//未指定初始化器。无论该语句位于全局还是局部,new表达式创建的对象都将拥有一
 					//个不确定的值。
cout<<*p1;			//将输出一个不确定的值

6.5.4 delete运算符基础

一、delete表达式基础
1、以下是delete表达式的完整语法:
在这里插入图片描述
2、delete表达式用于销毁对象或数组。形式1被称为单对象delete表达式,形式2被称为数组delete表达式。只要在delete之后跟随一个空的方括号就被认为是数组delete表达式。delete的结果类型为void。

3、用new分配的对象将一直存在,直到使用delete结束该对象的生命期,或者说释放该对象的内存,或者说将内存归还给自由存储区,或者说销毁(或删除)该对象。也就是说若没有使用delete释放由new分配的内存,则该资源会一直被占用。
4、销毁对象的基本规则

  1. delete的操作数(即强制转换表达式)必须是指向对象类型或类类型的指针。这意味着,不能通过使用void类型的指针来销毁对象。

  2. 对于单对象delete表达式,其操作数可以是空指针、由new表达式创建的非数组对象的指针,或者由new创建的非数组对象的基类子对象的指针,若不是以上指针,则行为是未定义的。

  3. 对于数组delete表达式(即带方括号的delete表达式),其操作数可以是空指针、由new表达式创建的数组类型的指针,若不是以上指针,则行为是未定义的。空方括号的作用是告诉编译器,后面的指针指向的是自由存储区中的数组而不是单个对象。

  4. 总结
    以上规则说简单一点就是,除空指针外,单对象delete表达式只能销毁由new创建的非数组对象,由new创建的数组对象必须由数组delete表达式销毁,其中,指针允许是new创建的对象的基类类型,否则,行为是未定的。其中最重要的一点是,不是由new表达式创建的对象(空指针除外),不能由delete表达式销毁,或者说,delete只能销毁由new创建的对象。

  5. 若使用数组delete表达式释放非数组(未定义的行为),或者释放动态数组时漏写了方括号(未定义的行为),则编译器不一定会报错,但不保证程序能正确运行。

  6. 若释放动态数组时漏写了方括号(未定义的行为),虽然不会一定会报错,但至少可以表示编译器少释放了由new分配的内存空间,这样会产生内存泄漏。

    示例6.X:delete销毁对象的基本规则
    int a=1,b=2,c=3,d=4;
    int *p=&a;			
    int *p1=&b;
    int *p2=new int;
    int *p3=new int[2];		//p2指向由new创建的数组对象
    int *p4=0;				//空指针
    void *p5=new int;		//void指针
    int *p6=new int;
    int *p7=new int[2];
    void *p8=&d;
    delete p2;			//正确。
    delete [] p3;		//正确。
    //delete a;			//错误。操作数必须是指针。
    //delete &c;		//未定义的行为。释放了不是由new分配的内存,可能引起程序崩溃
    //delete p;			//未定义的行为。原因同上
    //delete []p1;		//未定义的行为。原因同上
    delete p4;			//正确。因为p4是空指针
    delete []p4;		//正确。原因同上
    delete p5;			//正确。但是不能销毁由p5指向的对象,因为p5的类型是void*
    //delete p8;		//未定义的行为。因为p8指向的对象不是由new创建的(即使p8的类型是void*),
     					//可能引起程序崩溃
    //delete [] p6;		//未定义的行为,编译器不一定会报错,但不保证程序能正确运行。因为p6指向的对象
     					//不是由new创建的数组。
    //delete p7;		//未定义的行为,编译器不一定会报错,但不保证程序能正确运行。因为p7指向的对象
     					//是由new创建的数组
    //delete p2;		//错误,可能引起程序崩溃。因为由p2指向的对象在之前已经被销毁过了,因此,同
     					//一new分配的内存被销毁了两次
    class A{};
    class B:public A{};	
    A* p8=new B;		//正确。指向由new创建的类B的基类的子对象
    A*p9=new B[2];		//正确。同上
    //A (*p10)[2]=new B[3][2];		//错误。类型A(*)[2]与B(*)[2]不兼容
    delete p8;				//正确。
    delete []p9;			//正确。
    

4、delete表达式的其他规则

  1. 释放new创建的数组,不管是几维数组,方式都为“delete [] 操作数”,并不是使用new创建了N维数组就需要在delete和操作数之间加N对空方括号,只需在delete和操作数之间加上一对空的方括号即可。比如

    int (*p)[3][4][5]=new int[2][3][4][5]; 
    delete []p;					//释放由p指向的数组对象
    //delete [][][][]p;			//错误。只需要一对方括号即可
    
  2. 对于二级指针(其余多级指针的原理类似),比如

    int **p=new int *;
    

    只表示指针p是动态分配的内存,可以使用delete p进行释放,但对于 * p指向的内存是否是动态分配的,这就要看程序而定,若不是动态分配的,则使用delete * p程序可能会出错(释放了不是由new分配的内存)。

  3. 使用new创建的指向const对象的指针也是使用单对象delete表达式销毁的,比如

    const int *p=new const int(2);		//const对象必须初始化
    cout<<*p;							//输出2
    delete p;							//销毁由p指向的const对象
    

5、delete表达式关键原理与悬垂指针
delete销毁的是由delete的指针所指向的对象,也就是说

  • delete只是把指针指向的对象销毁了,但指针本身并不会被销毁或者成为空指针,这种指针被称为悬垂指针(也被称为野指针),悬垂指针往往是错误的根源。悬垂指针并不是一种错误,只是悬垂指针指向的位置不能确定,在大多数机器上,悬垂指针还是保存着指针原来的地址,虽然还能输出这个地址,但这并不是说还可以继续使用这个地址,悬垂指针只是保存着这个地址,但这个地址已经被释放掉了,此时该地址有可能正在被其他程序使用,若继续使用这块地址,则可能会出现意想不到的错误,甚至程序崩溃。但是,若将悬垂指针重新指向其他正确的地方,则仍可被继续使用。为了避免误用悬垂指针而引起的错误,建议在delete之后,将悬垂指针指向nullptr或0,以避免误用。
  • 同理,若指针消亡了,并不能代表指针所指向的内存被释放了(或者说指向的对象被销毁了)。
  • delete销毁的对象取决于指针所指向的对象是什么,与指针本身没有关系。
示例6.X:delete表达式关键原理1(悬垂指针)
int *p= new int;  	//new int分配的内存在堆上,而指针p则可能创建于栈上,二者并不是同一内存
 						//空间,是彼此相互独立的
int a=1;  
delete p;  			//正确。销毁p指向的由new创建的对象,p成为悬垂指针。注意,这不是一种错误,
 						//指针p也并未被销毁,只是现在指向的内存位置不能确定。
//cout<<*p;			//误用悬垂指针可能会使程序崩溃。因为p现在指向一个不确定的位置
p=&a;				//正确。指针p仍可使用,使指针p指向a。
cout<<*p;			//输出1。
p=nullptr;			//建议delete之后,将悬垂指针指向nullptr以免被误用
示例6.X:delete表达式关键原理2(改变指针指向的对象)
int a=1;
int *p=new int;		//p指向new创建的对象
p=&a;				//修改p指向对象a,这将导至无法销毁上一步由new创建的对象
//delete p;			//未定义的行为,可能崩溃。p现在指向的是对象a,而不是由new创建的对象
示例6.X:delete表达式关键原理3(多次销毁同一对象)
int *p=new int;
int *p1=p;		//p1与p指向相同的对象,即,都指向由new创建的对象
delete p1;		//正确。销毁p1指向的由new创建的对象
//delete p;		//错误。再次销毁由new创建的对象,对使用同一new创建的对象销毁了两次(即,
 					//同一内存空间被释放了两次)
示例6.X:delete表达式关键原理4(内存泄漏)
void f(){int *p=new int; }   
int main(){
		f();		//内存泄漏,但程序不会出错。函数f调用结束后,p的生命期结束,也就是说函数调
 					//用结束后p就消亡了,但p所指向的由new分配的内存并没有被释放,这块内存仍然
 					//占据着堆空间,函数f每调用一次就会多占用一块堆内存空间,若多次调用f可能会
 					//使内存耗尽。这种情形被称为内存泄漏。
}

二、使用delete表达式的注意事项及内存错误总结

1、不要使用delete释放已经释放过的内存块,对这种情况可能导致程序崩溃。这种情况一般发生在多个指针同时指向同一动态创建的对象的时候,这种错误比较难被发现。

2、不要使用delete来释放不是new分配的内存(空指针除外),这是未定义的行为,对这种情况可能导致程序崩溃。

3、应使用delete []来释放动态数组,释放动态数组必须要有[]方括号。若忘记加上空的方括号则编译器不会报错,并且不保证程序能正确运行。若漏写了方括号对,则至少可以表示编译器少释放了由new分配的内存空间,这样会产生内存泄漏。

4、delete只能用于释放前次使用new分配的空间,比如

int *p=new int;  
p=new int;  		//这将改变指针p的指向,使其指向第二个new分配的内存,这会导致第一个new分
 					//配的内存无法释放
delete p; 			//该语句并没有释放掉第一个new分配的内存,只能释放第二次使用new分配的空间
//delete p; 		//错误。对相同的内存释放两次

5、若用new分配了资源,一定要记住使用delete释放,若没用delete释放该资源,则用new分配的资源将一直被占用,这会导致内存泄漏,最终有可能耗光内存。

6、执行delete 后,应立即将该指针设置为空指针,以使指针不指向任何对象,以避免误用悬垂指针。

7、总结:使用new分配的动态内存易发生三种类型的内存错误,第一种使用delete失败(或忘记使用delete释放内存)产生的内存泄漏;第二种是对同一内存区使用了两次delete;第三种是误用了delete释放后的内存(一般指悬垂指针)。


作者:黄邦勇帅(原名:黄勇)
2024年6月1日

在这里插入图片描述

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值