C++复合类型及其const限定符详解

目录

  • 1 复合类型
    • 1.1 引用
    • 1.2 指针
    • 1.3 复合类型的声明格式
  • 2 const 限定符
  • 3 复合类型中的 const 修饰符
    • 3.1 const 与引用
    • 3.2 const与指针
      • 3.2.1 指向常量的指针(常量指针 / 底层const)
      • 3.2.2 指针本身是个常量(指针常量 / 顶层const)
      • 3.2.3 顶/底层const混合的复杂形式

1 复合类型

C++中最常用的两种复合类型分别是指针和引用。指针是C++继承自C的特性,引用是C++新发展的特性。它们的特点是都可以借此间接访问对象,规避拷贝行为,以此来节省空间,两者的根本区别之一在于指针是对象(内存中的具体值)而引用不是(只是对象的别名)。

1.1 引用

基本定义:

引用是对已经存在的对象所起的另一个名字,引用不是对象。

性质:

  • 引用必须初始化。定义引用时,是把引用和其初始值对象绑定在一起,不是将初始值拷贝给引用
  • 一旦初始化完成,引用将始终与其初始值对象绑定在一起,无法令引用重新绑定到另一个对象。
  • 引用本身不是对象,所以不能定义引用的引用。
  • 除了两种特殊情况,其他所有引用的类型都要和其绑定的对象类型严格匹配。
  • 引用只能绑定在对象上,不能与字面值或某个表达式的计算结果绑定。
  • 引用被定义后,对引用的操作本质上都是在操作与之绑定的对象

范例:

//例1
int ival = 1024;
int &refVal = ival; //定义一个int类型的引用,名字为refVal,通过将其绑定在ival这个对象上完成其初始化,绑定完成后,refVal代表ival的另一个名字
int &refVal2; //❌,引用必须被初始化
refVal = 2; //把2赋给refVal绑定的对象,即赋给ival
int i1 = refVal; //把refVal绑定的对象赋给i1,等价于i1 = ival
int &refVal3 = refVal; //将refVal3绑定到refVal指代的那个对象(即ival)上
int i = refVal; //利用refVal指代的对象(iVal)的值(1024)初始化int类型变量i

//例2
int i = 1024, i2 = 2048;
int &r = i; r2 = i2; //r是int类型的引用,绑定i;r2是int变量,由i2的值拷贝初始化
int i3 = 1024, &r3 = i3; //i3是int变量,r3是int类型的引用,绑定i3
int &r3 = i3, &r4 = i2; //i3和i4都是int类型的引用,分别绑定i3和i2

//例3
int &refVal4 = 10; //❌,不能用字面值来初始化引用
double dval - 3.14;
int &refVal5 = dval; //❌,引用的类型要与其绑定的对象类型严格匹配,不能将int类型的引用绑定在double类型的对象上

1.2 指针

基本定义:

指针是一个对象,也就是说,指针是一个实际的内存空间,其中存放的值是所指向对象的地址

性质:

  • 指针可以只声明而不初始化,但建议初始化所有指针,最好在定义了对象之后再定义指向它的指针,这样就可以解决初始化的问题。如果实在不清楚指向何处,请将指针的值初始化为0或nullptr。否则仅有声明的指针是不会进行默认初始化的,其值为一个不确定的值,对这种无效指针的操作会引发未定义的后果,非常危险。
  • 指针本身就是一个对象,因此允许对指针本身进行赋值和拷贝,这意味着指针在其生命周期内可以指向几个不同的对象。
  • 类似引用,除了两种例外情况,其他所有指针的类型都要和他所指向的对象类型严格匹配。
  • 如果指针指向了一个对象,则可通过解引用操作符(*)访问该对象。对指针解引用会得到指针所指的对象本身,如果给解引用的结果赋值,实际就是给指针所指的对象赋值。

范例:

//例1
int *ip1, *ip2; //声明ip1和ip2都是指向int类型对象的指针,此时它们还未被初始化,拥有未确定的值
double *dp2, dp; //声明dp2是指向double类型对象的指针,有用未确定的初值,声明dp是double类型的变量,被默认初始化为0.0
int ival = 42;
int *p = &ival; //通过ival的地址来初始化int类型的指针p,此时可以说p指向ival
double dval;
double *pd = &dval; //通过dval的地址来初始化double类型的指针pd
double *pd2 = pd; //正确,通过pd的值拷贝初始化pd2的值,使得pd2也指向dval
int *pi = pd; //❌,指针类型不匹配,pi是指向int类型的指针,pd是指向double类型的指针
pi = &dval; //❌,不能把double类型对象的地址赋给指向int类型的指针
cout << *p; //对指针p解引用,得到其所指的对象ival,因此等价于cout << ival; 输出42
*p = 0; //将指针p解引用的结果(即对象ival)赋值为0
cout << *p << ", " << ival; //输出“0, 0”,因为p解引用得到的ival对象值已经被修改为0

空指针:(有三种指定方式)

int *p1 = nullptr; //方法1,用字面值nullptr来初始化指针,代表该指针不指向任何对象,是一个空指针,这是最推荐的方式
int *p2 = 0; //方法2,直接将p2初始化为字面常量0,也代表p2是一个空指针
int *p3 = NULL; //方法3,需要#include ,不推荐使用

//要特别注意方法2带来的误区,如下:
int zero = 0;
int *pi = zero; //❌,zero是一个int变量,不是int变量的地址,不能用来给指向int类型的指针赋值,即使zero的值恰好为0也不行,这不代表pi是一个空指针

在条件表达式中使用指针判别:

  • 只要指针拥有一个合法值,就能将他用在条件表达式中。和采用算数值作为条件遵循的规则类似,如果指针的值为0,条件取false,否则取true
  • 对两个类型相同合法指针,可以用相等不相等操作符来做比较,比较结果是布尔类型。如果两个指针的值(存放对象的地址)相同则它们相等,否则不相等。这里的相等有三种可能情况:
  1. 都是空指针
  2. 都指向同一个对象
  3. 都指向了同一个对象的下一个地址

例如:

int ival = 1024;
int *pi = 0; //pi是一个合法的空指针
int *pi2 = &ival;
if (pi) { //pi的值为0,此处条件判断为false
	...
}
if (pi2) { //pi2的值非0,此处条件判断是true
	...
}

void * 指针

定义:一种特殊的指针类型,可以存放任意类型对象的地址

范例:

double obj = 3.14, *pd = &obj
void *pv = &obj; //正确,pv是一个void *指针,可以存放任何类型对象的地址
pv = pd; //正确,void *类型的指针也可以接收来自任何类型指针的拷贝赋值

1.3 复合类型的声明格式

我们称int、double等为(基本)数据类型,而代表指针和引用的*、&符号为类型修饰符,称被声明的变量名为声明符。变量的定义包括一个(基本)数据类型和一组声明符。在同一条定义语句中,数据类型只有一个,但声明符的形式可以不同,在复合类型的声明中,这意味着一条定义语句可以定义出不同类型的变量
特别注意,请认为类型修饰符是声明符的一部分,而不是数据类型的一部分。
例如:

int i = 1024, *p = &i, &r = i; //i是一个int类型的对象,p是一个指向int类型的指针,由i的地址初始化,r是int类型的引用,绑定在i这个对象上,是i的别名
//请特别区分两种写法
/*写法1*/
int* p; //合法但有误导,等价于int *p;
int* p1, p2; //p1是指向int类型的指针,p2是int类型对象,这是第一种写法的常见误导所在,错误地把类型修饰符*当作数据类型int的一部分,认为它们是一个整体数据类型,名字为int*
/*写法2 规范写法*/
int *p1, *p2; //p1和p2都是指向int类型的指针

多个类型修饰符的情况
因为指针是对象,引用不是对象,因此:
可以有指向指针的指针,例如:

int ival = 1024;
int *pi = &ival; //pi指向ival
int **ppi = &pi; //ppi指向pi(一个指向int类型对象的指针)
//使用三种方式输出ival的值
cout << ival;
cout << *pi;
cout << **ppi; //两次解引用,第一次解引用得到pi,第二次解引用(对pi解引用)得到ival

可以有绑定指针的引用,例如:

int i = 42;
int *p = nullptr; //p是一个指向int类型对象的指针,初始化为空
int *&r = p; //赋值号左侧的声明部分从右往左解读,r是一个引用(&),其绑定的对象类型为:指向int类型对象的指针(int *),用p来初始化绑定r
r = &i; //r是p的别名,p是指向int类型的指针,这里把i的地址赋给r,也就是令p指向i
*r = 0; //r是p的别名,对r做解引用操作就是对p做解引用操作,得到i这个对象,并将其赋值为0

没有指向引用的指针。

2 const 限定符

定义:

const限定符用来对变量做限定,使得其值不能被改变。

性质:

  • const对象必须初始化,因为其一旦创建后值就不能再改变。
  • 可以用任意复杂的表达式来初始化const对象,只要该表达式能正确地返回const修饰的对象类型即可。
  • 非const对象也能完成同类型const对象能完成的操作,唯一的区别在于不能改变本身的值。也就是说,const带来的常量特征只有在执行改变其修饰变量的操作时才会发挥作用。
  • 可以用非const对象初始化const对象,也可以用const对象初始化非const对象,因为拷贝操作并不会改变const对象的值。
  • 默认状态下,cosnt对象仅在文件内有效。当多个文件中出现了同名的const变量,等同于再不同文件中分别定义了独立的const变量。如果想让一个const变量在多个文件中共享使用,即在一个文件中定义const变量,而在其他文件中声明并使用他,则需要在该const变量的声明和定义处都添加extern关键字

编译器处理const对象的本质:

  • 如果在前文定义了某个const变量v初始值为1,则编译器在编译时会将程序中所有用到v的地方都替换成1这个常量值。
  • 由于这个性质,建议用const量来替换#define宏

范例:

/*例1*/
const int bufSize = 512; //定义了一个int类型常量bufsize,在编译时初始化,其初始值为512,之后不能更改
bufSize = 512; //❌,试图修改const对象的值
const int i = get_size(); //只要get_size()返回的是一个int类型变量就正确,此语句会在运行时初始化const变量i
const int k; //❌,k是一个未经初始化的常量,必须要初始化
int a = 42;
const int ca = a; //把非常量int对象a的值拷贝给常量对象ca,完成ca的初始化
int b = ca; //把常量对象ca的值拷贝给非常量int对象b,完成b的初始化

/*例2*/
//file1.cpp定义并初始化了一个常量,使用extern使得该常量能被其他文件访问
extern const int bufSize = fcn(); //该语句位于file1.cpp文件中
//file1.h中通过同样的声明,表明此处的bufSize与file1.cpp中的是同一个
extern const int bufSize; //该语句位于file1.h

3 复合类型中的 const 修饰符

3.1 const 与引用

把一个引用绑定到 const 对象上,称为对常量的引用(reference to const),也可以称作常量引用,作用是表明不能通过该引用修改其绑定的常量对象。可以这样理解,因为该引用是一个常量对象的别名,因此自然不能修改它指代的常量对象。
范例:

const int ci = 1024;
const int &r1 = ci; //r1是对int常量ci的引用
r1 = 42; //❌,通过引用来修改其绑定的对象值,但其绑定的对象是常量,不能修改
int &r2 = ci; //❌,不能用int类型的常量对象ci来绑定int类型的非常量引用r2

注意:

前文提到,引用的类型必须与其所引用对象的类型一致,但有两个例外,其中一个就是:在初始化常量引用时,允许使用任意类型表达式作为初始值,只要该表达式的结果能转换为引用的类型即可。特别是,允许为一个常量引用绑定非常量对象、字面值、或一般表达式

范例:

int i = 42;
const int &r1 = i; //允许常量引用绑定非常量对象
const int &r2 = 42; //允许常量引用绑定字面值
const int &r3 = r1*2; //允许常量引用绑定表达式
int &r4 = r1*2; //❌,r4是个普通的非常量引用,但右边的r1是个常量引用,类型不一致

特别注意思考以上范例中的最后一行为什么错误
一般的理解是(这种理解是❌的):r1是非常量 int 对象i的别名,那么对r1的操作实际上是在操作i这个对象,所以表达式r1*2应该还是一个非常量 int 对象,那么将非常量 int 引用r4绑定在非常量 int 对象上应该是正确的呀?
让我们看看当把一个常量引用绑定到另外一个类型上(如非常量变量)究竟会发生什么:
解释:

//当我们做这样的操作时
double dval = 3.14;
const int &ri = dval; //将一个对int类型常量对象的引用ri绑定到一个double类型对象dval上

//编译器实际上将以上的第二行代码展开改为了如下形式:
const int temp = dval; //先用dval的值拷贝初始化一个int类型常量temp(临时量),但确实是一个实际存在的对象
const int &ri = temp; //将ri绑定到这个转换出来的临时量上

也就是说,此时对ri的操作(如求表达式ri*2),实际上是在操作一个(int类型)常量(临时量temp)对象。因此对于上一个范例中的最后一行代码int &r4 = r1*2;来说,实际上等号右边是一个int类型常量对象,因此不能用一个int类型非常量引用r4去绑定,故而这种写法是错误的。
总之,常量引用只是限定了该引用可参与的操作(不能通过引用修改其绑定的对象),而对其绑定的对象本身是不是一个常量并没有限定。因为该对象本身也可能是个非常量,因此可以通过其他途径(不通过引用)来改变它的值。

3.2 const与指针

特别注意:指针本身是一个对象,它又可以指向另一个对象(存另一个对象的地址值)。因此,指针本身是不是常量指针所指的对象是不是常量是两个独立的问题。

3.2.1 指向常量的指针(常量指针 / 底层const)

代表指针所指向的对象是一个常量(不能改变其所指对象的值)。
范例:

const int ci = 42; //ci是一个常量对象
const int *p = &ci; //p是一个指向常量对象(ci)的指针,这里的const是底层const
*p = 40; //❌,试图通过指针p改变其指向的常量对象的值
const int ci1 = 50;
p = &ci1; //可以修改p的指向,使其指向另一个int常量对象

3.2.2 指针本身是个常量(指针常量 / 顶层const)

代表指针本身是一个常量(不能改变其所指的方向)。
范例:

int i = 0;
int *const p = &i; //p是一个指向i的指针常量,其指向不能改变,只能指向i(相当于绑定),这里的const是顶层const
*p = 10; //可以通过指针p来修改其指向的int类型对象i的值
int i2 = 10;
p = &i2; //❌,不能修改p的指向,因为p是一个指针常量

3.2.3 顶/底层const混合的复杂形式

按照以下规则来分析理解:

  • 左侧的const是底层,右侧的const是顶层
  • 顶层const表示其后面跟的对象名是常量,这对任何数据类型都有用,如算术类型、类、指针等
  • 底层const仅与指针和引用等复合类型基本类型部分有关
  • 指针类型既可以是顶层const(表明指针本身/指向是常量)也可以是底层const(表明所指对象是常量)
  • 带const的引用类型其const一定是底层const,因为引用是与对象绑定的,本来就不能改变指向,不存在顶层的const的含义

范例:

int i = 0;
int *const p1 = &i; //顶层const,p1是指针常量,不能改变其指向(不能改变p1的值)
const int ci = 42; //顶层const,ci是int常量,不能改变ci的值
const int *p2 = &ci; //底层const,代表p2所指的对象是常量,不能通过p2修改其值,但可以修改p2自身的值(修改指向)
const int *const p3 = p2; //从右往左解读p3的类型声明,先读右侧const,代表其后跟的变量名p3是一个常量,不允许修改其值(顶层const),再读左侧const int *,代表这是一个指向int常量的指针,因此不允许通过该指针修改其所指向的对象(底层const)
const int &r = ci; //声明引用的const都是底层const

执行对象拷贝操作时,常量是顶层const还是底层const区别明显。 拷贝操作不受顶层const影响,但底层const却代表着拷入和拷出的对象都具有相同的底层const资格(或者两个对象的数据类型能够转换,一般而言,非常量可以转为常量,反之不行)

//接上一代码范例中的定义
i = ci; //可以将常量的值拷贝给非常量
p2 = p3; //p2和p3的底层const代表的对象类型相同,p3的顶层const不影响拷贝操作
p2 = &i; //&i是int*,p2是const int*,可以转换(非常量转常量)
int &r = ci; //❌,不能将普通int引用绑定到int常量对象上
const int &r2 = i; //可以将int常量引用绑定到普通int对象上

你可能感兴趣的:(C++,c++,开发语言,后端)