变量和数据类型
- 引言:为什么需要变量?
- 一、变量
- 1. 变量的声明
- 2. 变量的赋值
- 3. 标识符
- 4. 作用域
- 5. 常量
- 6. 注意
- 二、基本数据类型
- 1. 整型
- 1.1 常用整型
- 1.2 无符号整型
- 1.3 char类型
- 1.4 bool类型
- 2. 浮点型
- 3. 字面值常量
- 3.1 整型字面值
- 3.2 浮点型字面值
- 3.3 字符和字符串字面值
- 3.4 布尔字面值
- 3.5 转义字符
- 4. 赋值时涉及到的类型转换
- 5. C++和Java易混淆处对比
- 三、复合数据类型
- 1. 数组
- 1.1 一维数组
- 1.1.1 数组的定义
- 1.1.2 数组的初始化
- 1.1.3 数组的访问
- 1.1.4 数组的大小
- 1.1.5 数组的遍历
- 1.2 多维数组
- 1.2.1 数组的定义
- 1.2.2 数组的初始化
- 1.2.3 数组的访问
- 1.2.2 数组的遍历
- 2. 模板类vector
- 2.1 头文件和命名空间
- 2.2 vector的基本用法
- 2.2.1 初始化
- 2.2.2 访问元素
- 2.2.3 遍历所有元素
- 2.2.4 添加元素
- 2.2.5 注意
- 3. 字符串
- 3.1 标准库类型string
- 3.1.1 定义和初始化
- 3.1.2 处理字符串中的字符
- 3.1.3 字符串相加
- 3.1.4 比较字符串
- 3.1.5 字符数组(C风格字符串)
- 3.2 读取输入的字符串
- 3.2.1 使用输入操作符读取单词
- 3.2.2 使用getline读取一行
- 3.2.3 使用get读取字符
- 3.3 简单读写文件
- 4. 结构体
- 4.1 结构体的声明
- 4.2 结构体初始化
- 4.3 访问结构体中数据
- 4.4 结构体数组
- 5. 枚举类
- 5.1 枚举类型定义
- 5.12 使用枚举类型
- 6. 指针
- 6.1 指针的定义
- 6.1.1 获取对象地址给指针赋值
- 6.1.2 通过指针访问对象
- 6.2 无效指针、空指针和void*指针
- 6.2.1 无效指针
- 6.2.2 空指针
- 6.2.3 void * 指针
- 6.3 指向指针的指针
- 6.4 指针和const
- 6.4.1 指向常量的指针
- 6.4.2 指针常量(const指针)
- 6.5 指针和数组
- 6.5.1 数组名
- 6.5.2 指针运算
- 6.5.3 指针和数组下标
- 6.5.4 指针数组和数组指针
- 7. 引用
- 7.1 引用的用法
- 7.2 对常量的引用
- 7.3 指针和引用
- 7.3.1 引用和指针常量
- 7.3.2 指针的引用
- 7.3.3 引用的本质
引言:为什么需要变量?
一段程序的核心有两个方面:一个是要处理的信息,另一个就是处理的计算流程。计算机所处理的信息一般叫做“数据”(data)。
对计算机来说,需要明确地知道把数据存放在哪里、以及需要多大的存储空间。在机器语言和汇编语言中,我们可能需要充分了解计算机底层的存储空间,这非常麻烦;而在C++程序中,我们可以通过“声明变量”的方式来实现这些。
一、变量
为了区分不同的数据,在程序中一般会给它们起个唯一的名字,这就是所谓的“变量”。在C++中,“变量”其实就是记录了计算机内存中的一个位置标签,可以表示存放的数据对象。
1. 变量的声明
想要使用变量,必须先做声明,也就是告诉计算机要用到的数据叫什么名字,同时还要指明保存数据所需要的空间大小。比如:
int a;
这里包含两个信息:一个是变量的名字,叫做·a
,它对应着计算机内存中的一个位置;另一个是变量占据的空间大小,这是通过前面的int
来指明的,表示我们需要足够的空间来存放一个整数类型(integer)数据。所以变量声明的标准语法可以写成:
数据类型 变量名;
变量名也可以有多个,用逗号分隔就可以。在C++中,可以处理各种不同类型的数据,这里的int
就是最基本的一种数据类型(data type),表示一般的整数。
2. 变量的赋值
如果我们直接在代码中声明一个变量,然后打印输出的话就会报错,因为这个变量没有被初始化。也就是说,上面a
这个变量现在可以表示内存中一个位置了,但是里面的数据是什么,就需要让a
有一个初始值:
int a = 1;
上面这个操作叫做赋值。需要说明的是,这里等号=
表示的是赋值操作,并不是数学上的等于。换句话说,我们还可以继续给a赋别的值:
int a = 1;
a = 2;
现在a
的值就是2了。a
的值可以改变,所以它叫做变量。如果变量在声明的同时进行赋值,可以统称为变量的定义。
3. 标识符
每个变量都有一个名字,就是所谓的变量名。在C++中,变量、函数、类都可以有自己专门的名字,这些名字被叫做标识符。标识符由字母、数字和下划线组成;不能以数字开头;标识符是大小写敏感的,长度不限。所以下面的变量名都是合法而且不同的:
int b, B, B2, a1_B2;
此外,C++中还对变量命名有一些要求和约定俗成的规范:
①不能使用C++关键字;
②不能用连续两个下划线开头,也不能以下划线加大写字母开头;
③些被C++保留给标准库使用;
④函数体外的标识符,不能以下划线开头;
⑤要尽量有实际意义(不要定义a、b,而要定义name、age);
⑥变量名一般使用小写字母;
⑦自定义类名一般以大写字母开头;
⑧如果包含多个单词,一般用下划线分隔,或者将后面的单词首字母大写;
所谓的关键字,就是C++保留的一些单词,供语言本身的语法使用。包括:
4. 作用域
- 变量有了名字,那只要用这个名字就可以指代对应的数据。但是如果出现重名怎么办呢?在C++中,有作用域(scope)的概念,就是指程序中的某一段、某一部分。一般作用域都是以花括号
{}
作为分隔的,就像之前我们看到的函数体那样。同一个名字在不同的作用域中,可以指代不同的实体(变量、函数、类等等)。定义在所有花括号外的名字具有全局作用域(global scope),而在某个花括号内定义的名字具有块作用域。一般把具有全局作用域的变量叫做全局变量,具有块作用域的变量叫做局部变量。 - 如果在嵌套作用域里出现重名,一般范围更小的局部变量会覆盖全局变量。如果要特意访问全局变量,需要加上双冒号
::
,指明是默认命名空间。
5. 常量
用变量可以灵活地保存数据、访问数据。不过有的时候,我们希望保存的数据不能更改,这种特殊的变量就被叫做常量。在C++中,有两种方式可以定义常量:
① 使用符号常量(不推荐):这种方式是在文件头用#define
来定义常量,也叫作宏定义。跟#include
一样,井号#
开头的语句都是预处理语句,在编译之前,预处理器会查找程序中所有的ZERO
”,并把它替换成0。这种宏定义的方式是保留的C语言特性,在C++中一般不推荐。
#define ZERO 0
② 使用const限定符:这种方式跟定义一个变量是一样的,只需要在变量的数据类型前再加上一个const
关键字,这被称为限定符。
// 定义常量
const int Zero = 0;
// 不能修改常量值
const
修饰的对象一旦创建就不能改变,所以必须初始化。const
跟使用#define
定义宏常量相比,const
定义的常量有详细的数据类型,而且会在编译阶段进行安全检查,在运行时才完成替换,所以会更加安全和方便。
6. 注意
- C++是一种静态类型(statically typed)语言,需要在编译阶段做类型检查(type checking)。也就是说所有变量在创建的时候必须指明类型,而且之后不能更改。对于复杂的大型程序来说,这种方式更有助于提前发现问题、提高运行效率。
- 如果不给初始值,后面再赋值、再使用也是合法的;但一般不能不赋值、直接使用。因为在函数中定义的变量不被初始化,而在函数外部定义的变量会被默认初始化为0值。
二、基本数据类型
定义变量时,不可或缺的一个要素就是数据类型。本质上讲,这就是为了实现计算需求,我们必须先定义好数据的样式,告诉计算机这些数据占多大空间,这就是所谓数据类型的含义。C++支持丰富的数据类型,它内置了一套基本数据类型,也为我们提供了自定义类型的机制。接下来我们先介绍基本数据类型,主要包括算术类型和空类型(void)。其中算术类型又包含了整型和浮点型;而空类型不对应具体的值,只用在一些特定的场合,比如一个函数如果不返回任何值,我们可以让void作为它的返回类型。
1. 整型
1.1 常用整型
- 整型(integral type)本质上来讲就是表示整数的类型。我们知道在计算机中,所有数据都是以二进制0和1来表示的,每个叫做一位(bit);计算机可寻址的内存最小单元是8位,也就是一个字节(Byte)。所以我们要访问的数据,都是保存在内存的一个个字节里的。一个字节能表示的最大数是256,这对于很多应用来讲显然是不够的。不同的需求可能要表示的数的范围也不一样,所以C++中定义了多个整数类型,它们的区别就在于每种类型占据的内存空间大小不同。
- C++定义的基本整型包括char、short、int、long,和C++ 11新增的long long类型,此外特殊的布尔类型bool本质上也是整型。
- 在C++中对它们占据的长度定义比较灵活,这样不同的计算机平台就可以有自己的实现了(这跟C是一样的)。由于char和bool相对特殊,我们先介绍其它四种。C++标准中对它们有最小长度的要求,比如:①short类型至少为16位(2字节)②int至少2字节,而且不能比short短③long至少4字节,而且不能比int短④long long至少8字节,而且不能比long短。现在一般系统中,short和long都选择最小长度,也就是short为16位、long为32位、long long为64位;
1.2 无符号整型
整型默认是可正可负的,如果我们只想表示正数和0,那么所能表示的范围就又会增大一倍。以16位的short为例,本来表示的范围是-32768 ~ 32767
,如果不考虑负数,那么就可以表示0 ~ 65535
。C++中,short、int、long、long long都有各自的无符号版本的类型,只要定义时在类型前加上unsigned就可以。
short a = 32768;
//溢出,无法正常输出
cout << "a = " << a << endl;
//输出2,代表字节数
cout << "a的长度为:" << sizeof a << endl;
unsigned short a2 = 32768;
//正常输出32678
cout << "a2 = " << a2 << endl;
//输出2,代表字节数
cout << "a2的长度为:" << sizeof a2 << endl;
上面的代码可以测试无符号数表示的范围。需要注意,当数值超出了整型能表示的范围,程序本身并不会报错,而是会让数值回到能表示的最小值;这种情况叫做数据溢出(或者算术溢出),写程序时一定要避免。
由于类型太多,在实际应用中使用整型可以只考虑三个原则:①一般的整数计算,全部用int;②如果数值超过了int的表示范围,用long long;③确定数值不可能为负,用无符号类型(比如统计人数、销售额等);
1.3 char类型
char类型一般并不用在整数计算,它更重要的用途是表示字符(character)。最常用的字符编码集就是ASCII码,它用0~127表示了128个字符,这包括了所有的大小写字母、数字、标点符号、特殊符号以及一些计算机的控制符。比如字母A
的编码是65,数字字符0
的编码是48。
//输出65对应的字符
char ch = 65;
cout << "65对应的字符为:" << ch << endl;
//输出66
cout << "ch + 1:" << ch + 1 << endl;
//输出66对应的字符
char ch2 = ch + 1;
cout << "66对应的字符为:" << ch2 << endl;
在程序中如果使用char类型的变量,我们会发现,打印出来就是一个字符;而它的底层是一个整数,也可以做整数计算。
char类型用来表示整数时,到底是有符号还是无符号呢?之前的所有整型,默认都是有符号的,而char并没有默认类型,而是需要C++编译器根据需要自己决定。所以把char当做小整数时,有两种显式的定义方式:signed char 和 unsigned char;至于char定义出来的到底带不带符号,就看编译器的具体实现了。
C++还对字符类型进行了扩容,提供了一种宽字符类wchar_t
。wchar_t
会在底层对应另一种整型(比如short或者int),具体占几个字节要看系统中的实现。wchar_t
会随着具体实现而变化,不够稳定;所以在C++11新标准中,还为Unicode字符集提供了专门的扩展字符类型:char16_t
和char32_t
,分别长16位和32位。
1.4 bool类型
在程序中,往往需要针对某个条件做判断,结果只有两种:成立和不成立;如果用逻辑语言来描述,就是真和假。真值判断是二元的,所以在C语言中,可以很简单地用1表示真,0表示假。
C++支持C语言中的这种定义,同时为了让代码更容易理解,引入了一种新的数据类型——布尔类型bool。bool类型只有两个取值:true和false,这样就可以非常明确地表示逻辑真假了。bool类型通常占用8位(1个字节)。
bool bl = true;
//输出1
cout << "bl = " << bl << endl;
2. 浮点型
跟整数对应,浮点数用来表示小数,主要有单精度float和双精度double两种类型,double的长度不会小于float。通常,float会占用4个字节(32位),而double会占用8个字节(64位)。此外,C++还提供了一种扩展的高精度类型long double,一般会占12或16个字节。
除了一般的小数,在C++中,还提供了另外一种浮点数的表示法,那就是科学计数法,也叫作“E表示法。比如:5.98E24
表示5.98×10的24次方
;9.11e-31
表示9.11×10的-31次方
。
这就极大地扩展了我们能表示的数的范围。一般来讲,float至少有6位有效数字,double至少有15位有效数字。所以浮点类型不仅能表示小数,还可以表示(绝对值)非常大的整数。
3. 字面值常量
我们在给一个变量赋值的时候,会直接写一个整数或者小数,这个数据就是显式定义的常量值,叫做字面值常量。每个字面值常量也需要计算机进行保存和处理,所以也都是有数据类型的。字面值的写法形式和具体值,就决定了它的类型。
3.1 整型字面值
整型字面值就是我们直接写的一个整数,比如30。这是一个十进制数。而计算机底层是二进制的,所以还支持我们把一个数写成八进制和十六进制的形式。以0
开头的整数表示八进制数;以0x
或者0X
开头的代表十六进制数。例如:
①30 十进制数
②036 八进制数
③0x1E 十六进制数
这几个数本质上都是十进制的30,在计算机底层都是一样的。
在C++中,一个整型字面值,默认就是int类型,前提是数值在int能表示的范围内。如果超出int范围,那么就需要选择能够表示这个数的、长度最小的那个类型。
具体来说,对于十进制整型字面值,如果int不够那么选择long;还不够,就选择long long(不考虑无符号类型);而八进制和十六进制字面值,则会优先用无符号类型unsigned int,不够的话再选择long,之后依次是unsigned long、long long和unsigned long long。
这看起来非常复杂,很容易出现莫名其妙的错误。所以一般我们在定义整型字面值时,会给它加上一个后缀,明确地告诉计算机这个字面值是什么类型。
默认什么都不加,是int类型;
l或者L,表示long类型;
ll或者LL,表示long long类型;
u或者U,表示unsigned无符号类型;
我们一般会用大写L,避免跟数字1混淆;而u
可以和L
或LL
组合使用。例如9527uLL
就表示这个数是unsigned long long类型。
3.2 浮点型字面值
前面已经提到,可以用一般的小数或者科学计数法表示的数,来给浮点类型赋值,这样的数就都是浮点型字面值。浮点型字面值默认的类型是double。如果我们希望明确指定类型,也可以加上相应的后缀:
f或者F,表示float类型
l或者L,表示long double类型
这里因为本身数值是小数或者科学计数法表示,所以L不会跟long类型混淆。
3.3 字符和字符串字面值
字符就是我们所说的字母、单个数字或者符号,字面值用单引号引起来表示。字符字面值默认的类型就是char,底层存储也是整型。
多个字符组合在一起,就构成了字符串。字符串字面值是一串字符,用双引号引起来表示。
字符串是字符的组合,所以字符串字面值的类型,本质上是char类型构成的数组(array)。关于数组的介绍,我们会在后面章节详细展开。
3.4 布尔字面值
布尔字面值非常简单,只有两个:true和false.
3.5 转义字符
有一类比较特殊的字符字面值,我们是不能直接使用的。在ASCII码中我们看到,除去字母、数字外还有很多符号,其中有一些本身在C++语法中有特殊的用途,比如单引号和双引号;另外还有一些控制字符。如果我们想要使用它们,就需要进行转义,这就是转义字符。C++中规定的转义字符有:
4. 赋值时涉及到的类型转换
我们在使用字面值常量给变量赋值时如果常量的值超出了变量类型能表示的范围,或者把一个浮点数赋值给整型变量,程序会进行自动类型转换。也就是说,程序会自动将一个常量值,转换成变量的数据类型,然后赋值给变量。
// 1. 整数值赋给bool类型
bool b = 25; // b值为true,打印为1
// 2. bool类型赋值给算术整型
short s = false; // s值为0
// 3. 浮点数赋给整数类型
int i = 3.14; // i值为3
// 4. 整数值赋给浮点类型
float f = 10; // f值为10.0,打印为10
// 5. 赋值超出整型范围
unsigned short us = 65536; // us值为0
s = 32768; // s值为-32768
转换规则可以总结如下:
①非布尔类型的算术值赋给布尔类型,初始值为0则结果为false , 否则结果为true 。
②布尔值赋给非布尔类型,初始值为false则结果为0,初始值为 true 则结果为1。
③浮点数赋给整数类型,只保留浮点数中的整数部分,会带来精度丢失。
④整数值赋给浮点类型,小数部分记为0。如果保存整数需要的空间超过了浮点类型的容量,可能会有精度丢失。
⑤给无符号类型赋值,如果超出它表示范围,结果是初始值对无符号类型能表示的数值总数取模后的余数。
⑥给有符号类型赋值,如果超出它表示范围,结果是未定义的(undefined)。此时,程序可能继续工作,也可能崩溃。
5. C++和Java易混淆处对比
C++和Java是两种当前使用范围很广泛的高级编程语言,两者有很多明显的不同,对此不再赘述。但对于相似之处的细微不同还需要进行细致区分,关于赋值和基本数据类型易混淆处的对比如下表所示:
C++ | Java |
---|---|
C++中的布尔类型在打印时会直接打印出1或0,且布尔类型可以其他基本数据类型进行相互转换,可以参加数学运算 | Java中的布尔类型是true和false,直接打印出的结果也是true和false,且该布尔类型不可进行计算,不可与其他基本数据类型相互转换 |
C++中可以将char类型的数据进行数学计算后赋给另外一个char类型的对象 | Java中char类型数据可以进行数学计算,但计算结果不可再赋值给char类型的对象 |
C++中float类型的数据可以使用科学计数法赋值 | Java中float不可以使用科学计数法赋值 |
C++左右值数据类型不同会自动根据左值的类型来对右值进行隐式类型转换 | Java左右值数据类型不同多数情况下需要手动对右值进行显式类型转换 |
三、复合数据类型
1. 数组
1.1 一维数组
1.1.1 数组的定义
数组的定义形式如下:
数据类型 数组名[元素个数];
首先需要声明类型,数组中所有元素必须具有相同的数据类型;
数组名是一个标识符;后面跟着中括号,里面定义了数组中元素的个数,也就是数组的长度;
元素个数也是类型的一部分,所以必须是确定的;
int a1[10]; // 定义一个数组a1,元素类型为int,个数为10
const int n = 4;
double a2[n]; // 元素个数可以是常量表达式
int i = 5;
//int a3[i]; // 错误,元素个数不能为变量
注意:没有通用的数组类型,所以上面的a1、a2的类型分别是int数组和double数组。这也是为什么我们把数组叫做复合数据类型。
1.1.2 数组的初始化
int a3[4] = {1,2,3,4};
float a4[] = {2.5, 3.8, 10.1}; // 正确,初始值说明了元素个数是3
short a5[10] = {3,6,9}; // 正确,指定了前三个元素,其余都为0
//long a6[2] = {3,6,9}; // 错误,初始值太多
//int a6[4] = a3; // 错误,不能用另一个数组对数组赋值
注意:
- 对数组做初始化,要使用花括号
{}
括起来的数值序列; - 如果做了初始化,数组定义时的元素个数可以省略,编译器可以根据初始化列表自动推断出来;
- 初始值的个数,不能超过指定的元素个数;
- 初始值的个数,如果小于元素个数,那么会用列表中的值初始化靠前的元素;剩余元素用默认值填充,整型的默认值就是0;
- 如果没有做初始化,数组中元素的值都是未定义的;这一点和普通的局部变量一致;
1.1.3 数组的访问
数组元素在内存中是连续存放的,它们排好了队之后就会有一个队伍中的编号,称为索引,也叫下标;通过下标就可以快速访问每个元素了,具体形式为:
数组名[元素下标]
这里也是用了中括号来表示元素下标位置,被称为下标运算符。比如a[2]
就表示数组a中下标为2的元素,可以取它的值输出,也可以对它赋值。
int a[] = {1,2,3,4,5,6,7,8};
cout << "a[2] = " << a[2] << endl; // a[2] = 3
a[2] = 36;
cout << "a[2] = " << a[2] << endl; // a[2] = 36
注意:
- 数组的下标从0开始;
- 因此a[2]访问的并不是数组a的第2个元素,而是第三个元素;一个长度为10的数组,下标范围是0到9,而不是1到10;
- 合理的下标,不能小于0,也不能大于 (数组长度 - 1);否则就会出现数组下标越界;实际上数组没有对越界的行为作出限制,但如果越界访问会产生重大安全隐患,所以不要越界访问属组。
1.1.4 数组的大小
所有的变量,都会在内存中占据一定大小的空间;而数据类型就决定了它具体的大小。而对于数组这样的复合类型,由于每个元素类型相同,因此占据空间大小的计算遵循下面的简单公式:数组所占空间 = 数据类型所占空间大小 * 元素个数
。这样一来,即使定义的时候没有指定数组元素个数,现在也可以计算得出了:
// a是已定义的数组
cout << "a所占空间大小:" << sizeof(a) << endl;
cout << "每个元素所占空间大小:" << sizeof(a[0]) << endl;
// 获取数组长度
int aSize = sizeof(a) / sizeof(a[0]);
cout << "数组a的元素个数:" << aSize << endl;
这里为了获取数组的长度,我们使用了sizeof
运算符,它可以返回一个数据对象在内存中占用的大小(以字节为单位);数组总大小,除以每个数据元素的大小,就是元素个数。
1.1.5 数组的遍历
如果想要依次访问数组中所有的元素,就叫做遍历数组。我们通常使用for循环进行遍历:
// 获取数组长度
int aSize = sizeof(a) / sizeof(a[0]);
for (int i = 0; i < aSize; i++ )
{
cout << "a[" << i << "] = " << a[i] << endl;
}
循环条件如果写一个具体的数,很容易出现下标越界的情况;而如果知道了数组长度,直接让循环变量i小于它就可以了。当然,这种写法还是稍显麻烦。C++ 11标准给我们提供了更简单的写法,就是之前介绍过的范围for循环:
for (int num: a )
{
cout << num << endl;
}
当然,这种情况下就无法获取元素对应的下标了。
1.2 多维数组
1.2.1 数组的定义
之前介绍的数组只是数据最简单的排列方式。如果数据对象排列成的不是一队,而是一个方阵,那显然就不能只用一个下标来表示了。我们可以对数组进行扩展,让它从一维变成二维甚至多维。
int arr[3][4]; // 二维数组,有三个元素,每个元素是一个长度为4的int数组
int arr2[2][5][10]; // 三维数组
C++中本质上没有“多维数组”这种东西,所谓的多维数组,其实就是数组的数组。比如:
①二维数组int arr[3][4]
表示:arr是一个有三个元素的数组,其中的每个元素都是一个int数组,包含4个元素;
③三维数组int arr2[2][5][10]
表示:arr2是一个长度为2的数组,其中每个元素都是一个二维数组;这个二维数组有5个元素,每个元素都是一个长度为10的int数组;
一般最常见的就是二维数组。它有两个维度,第一个维度表示数组本身的长度,第二个表示每个元素的长度;一般分别把它们叫做行和列。实际上,对于二维数组arr,我们输出arr[0]时得到的是一个地址值。
1.2.2 数组的初始化
和普通的一维数组一样,多维数组初始化时,也可以用花括号括起来的一组数。使用嵌套的花括号可以让不同的维度更清晰:
数据类型 数组名[行数][列数] = {数据1, 数据2, 数据3, …};
数据类型 数组名[行数][列数] = {
{数据11, 数据12, 数据13, …},
{数据21, 数据22, 数据23, …},
…
};
注意:
- 内嵌的花括号不是必需的,因为数组中的元素在内存中连续存放,可以用一个花括号将所有数据括在一起;
- 初始值的个数,可以小于数组定义的长度,其它元素初始化为0值;这一点对整个二维数组和每一行的一维数组都适用;
- 如果省略嵌套的花括号,当初始值个数小于总元素个数时,会按照顺序依次填充(填满第一行,才填第二行);其它元素初始化为0值;
- 多维数组的维度,可以省略第一个,由编译器自动推断;即二维数组可以省略行数,但不能省略列数。
// 嵌套的花括号的初始化
int ia[3][4] = {
{1,2,3,4},
{5,6,7,8},
{9,10,11,12}
};
// 只有一层花括号的初始化
int ia2[3][4] = { 1,2,3,4,5,6,7,8,9,10,11,12 };
// 部分初始化,其余补0
int ia3[3][4] = {
{1,2,3},
{5,6}
};
int ia4[3][4] = {1,2,3,4,5,6};
// 省略行数,自动推断
int ia5[][4] = {1,2,3,4,5};
1.2.3 数组的访问
可以用下标运算符来访问多维数组中的数据,数组的每一个维度,都应该有一个对应的下标。对于二维数组来说,就是需要指明行号列号,这相当于数据元素在二维矩阵中的坐标。
// 访问ia的第二行、第三个数据
cout << "ia[1][2] = " << ia[1][2] << endl;
// 修改ia的第一行、第二个数据
ia[0][1] = 19;
同样需要注意,行号和列号都是从0开始、到 (元素个数 - 1) 结束
1.2.2 数组的遍历
要想遍历数组,当然需要使用for循环,而且要扫描每一个维度。对于二维数组,我们需要对行和列分别进行扫描,这是一个双重for循环:
cout << "二维数组总大小:" << sizeof(ia) << endl;
cout << "二维数组每行大小:" << sizeof(ia[0]) << endl;
cout << "二维数组每个元素大小:" << sizeof(ia[0][0]) << endl;
// 二维数组行数
int rowCnt = sizeof(ia) / sizeof(ia[0]);
// 二维数组列数
int colCnt = sizeof(ia[0]) / sizeof(ia[0][0]);
for (int i = 0; i < rowCnt; i++)
{
for (int j = 0; j < colCnt; j++)
{
cout << ia[i][j] << "\t";
}
cout << endl;
}
同样,这里利用了sizeof运算符:①行数 = 二维数组总大小 / 每行大小 ②列数 = 每行大小 / 每个元素大小。当然,也可以使用范围for循环:
for (auto & row : ia)
{
for (auto num : row)
{
cout << num << "\t";
}
cout << endl;
}
这里的外层循环使用了auto
关键字,这也是C++ 11新引入的特性,它可以自动推断变量的类型;后面的&
是定义了一个引用.
2. 模板类vector
数组尽管很灵活,但使用起来还是很多不方便。为此,C++语言定义了扩展的抽象数据类型(Abstract Data Type, ADT),放在标准库中。对数组功能进行扩展的一个标准库类型,就是容器vector。顾名思义,vector容纳着一堆数据对象,其实就是一组类型相同的数据对象的集合。
2.1 头文件和命名空间
vector是标准库的一部分。要想使用vector,必须在程序中包含<vector>
头文件,并使用std命名空间。
#include<vector>
using namespace std;
在vector头文件中,对vector这种类型做了定义;使用#include
引入它之后,并指定命名空间std
之后,我们就可以在代码中直接使用vector了。
2.2 vector的基本用法
vector其实是C++中的一个类模板,是用来创建类的模子。所以在使用时还必须提供具体的类型信息,也就是说,这个容器中到底要容纳什么类型的数据对象;具体的形式是在vector后面跟一个尖括号<>
,里面填入具体类型信息。
vector<int> v;
2.2.1 初始化
跟数组相比,vector的初始化更加灵活方便,可以应对各种不同的需求。
// 默认初始化,不含任何元素
vector<int> v1;
// 列表初始化
vector<char> v2 = {'a', 'b', 'c'};
// 省略等号的列表初始化
vector<short> v3{1,2,3,4,5};
// 只定义长度,元素初值默认初始化,容器中有5个0
vector<int> v4(5);
// 定义长度和初始值,容器中有5个100
vector<long> v5(5, 100);
//可以复制赋值
vector<int> v6 = v5;
这里有几种不同的初始化方式:
- 默认初始化一个vector对象,就是一个空容器,里面不含任何元素;
- C++ 11之后可以用花括号括起来的列表,对vector做初始化;等号可以省略;这种方式是把一个列表拷贝给了vector,称为拷贝初始化;
- 可以用小括号表示初始化vector的长度,并且可以给所有元素指定相同的初始值;这种方式叫做直接初始化。
2.2.2 访问元素
vector是包含了数据对象的容器,在这个容器集合中,每个数据对象都会有一个编号,用来做方便快速的访问;这个编号就是索引(index)。同样可以用下标操作符来获取对应索引的元素,这一点跟数组非常相似。
cout << "v5[2] = " << v5[2] << endl;
v5[4] = 32;
//v5[5] = 16; // 严重错误!不能越界访问索引,会直接报错,根本无法访问
2.2.3 遍历所有元素
vector中有一个可以调用的函数size()
,只要调用它就能直接得到vector的长度(即元素个数):
// 获取vector的长度
cout << v5.size() << endl;
调用的方式是一个vector对象后面跟上一个点,再跟上size()
。这种基于对象来调用的函数叫做成员函数。这样我们就可以非常方便地用for循环遍历元素了:
for (int i = 0; i < v5.size(); i++)
{
cout << v5[i] << "\t";
}
当然,用范围for循环同样非常简单:
for (int num: v5)
{
cout << num << "\t";
}
2.2.4 添加元素
vector的长度并不是固定的,所以可以向一个定义好的vector添加元素。
// 在定义好的vector中添加元素
v5.push_back(69);
for (int num : v5)
{
cout << num << "\t";
}
这里的push_back
同样是一个成员函数,调用它的时候在小括号里传入想要添加的数值,就可以让vector对象中增加一个元素了。这就使得我们在创建vector对象时不需要知道元素个数,使用更加灵活,避免了数组中的缺陷。
2.2.5 注意
除了vector之外,C++ 11 还新增了一个array模板类,它跟数组更加类似,长度是固定的,但更加方便、更加安全。所以在实际应用中,一般推荐对于固定长度的数组使用array,不固定长度的数组使用vector。
3. 字符串
字符串我们并不陌生。之前已经介绍过,一串字符连在一起就是一个字符串,比如用双引号引起来的Hello World!就是一个字符串字面值。字符串其实就是所谓的纯文本,就是各种文字、数字、符号在一起表达的一串信息;所以字符串就是C++中用来表达和处理文本信息的数据类型。
3.1 标准库类型string
C++的标准库中,提供了一种用来表示字符串的数据类型string,这种类型能够表示长度可变的字符序列。和vector类似,string类型也定义在命名空间std中,使用它必须包含string头文件
#include<string>
using namespace std;
3.1.1 定义和初始化
我们已经接触过C++中几种不同的初始化方式,string也是一个标准库类型,它的初始化与vector非常相似。
// 默认初始化,空字符串
string s1;
// 用另一个字符串变量,做拷贝初始化
string s2 = s1;
// 用一个字符串字面值,做拷贝初始化
string s3 = "Hello World!";
// 用一个字符串字面值,做直接初始化
string s4("hello world");
// 定义字符和重复的次数,做直接初始化,得到 hhhhhhhh
string s5(8, 'h');
初始化方式主要有:
- 默认初始化,得到的就是一个空字符串;
- 拷贝初始化,用赋值运算符(等号
=
)表示;可以使用另一个string对象,也可以使用字符串字面值常量; - 直接初始化,用括号表示;可以在括号中传入一个字符串,也可以传入字符和重复的次数
可以发现,字符串也可以看做数据元素的集合;它里面的元素,就是字符。
3.1.2 处理字符串中的字符
通过初始化已经可以看出,string的行为与vector非常类似。string同样也可以通过下标运算符访问内部的每个字符。字符的索引,就是在字符串中的位置。
string str = "hello world";
// 获取第3个字符
cout << "str[2] = " << str[2] << endl;
// 将第1个字符改为'H'
str[0] = 'H';
// 将最后一个字符改为'D'
str[str.size() - 1] = 'D';
cout << "str = " << str << endl;
字符串内字符的访问,跟vector内元素的访问类似,需要注意:
- string内字符的索引,也是从0开始;
- string同样有一个成员函数size,可以获取字符串的长度;
- 索引最大值为 (字符串长度 - 1),不能越界访问;如果越界会直接报错;
- 如果希望遍历字符串的元素,也可以使用普通for循环和范围for循环,依次获取每个字符
3.1.3 字符串相加
string本身的长度是不定的,可以通过相加的方式扩展一个字符串。
// 字符串相加
string str1 = "hello", str2("world");
string str3 = str1 + str2; // str3 = "helloworld"
string str4 = str1 + ", " + str2 + "!"; // str4 = "hello, world!"
//string str5 = "hello, " + "world!"; // 错误,不能将两个字符串字面值相加
需要注意:
- 字符串相加使用加号
+
来表示,这是算术运算符+
的运算符重载,含义是字符串拼接; - 两个string对象,可以直接进行字符串相加;结果是将两个字符串拼接在一起,得到一个新的string对象返回;
- 一个string对象和一个字符串字面值常量,可以进行字符串相加,同样是得到一个拼接后的string对象返回;
- 两个字符串字面值常量,不能相加;这是为了兼容C语言的字符数组,string类型为C++扩展的类型,利用该类型的
+
可以进行字符串拼接,但是字面值常量底层实际上是字符数组,字符数组不存在+
操作符,也就无法进行拼接; - 多个string对象和多个字符串字面值常量,可以连续相加;前提是按照左结合律,每次相加必须保证至少有一个string对象(不一定非要是左边是string对象,只要有一个就行);
3.1.4 比较字符串
string类还提供几种用来做字符串比较的运算符,==
和!=
用来判断两个字符串是否完全一样;而<
、>
、<=
、>=
则用来比较两个字符串的大小。这些都是关系型运算符的重载。
str1 = "hello";
str2 = "hello world!";
str3 = "hehehe";
str1 == str2; // false
str1 < str2; // true
str1 >= str3; // true
字符串比较的规则为:
- 如果两个字符串长度相同,每个位置包含的字符也都相同,那么两者相等;否则不相等;
- 如果两个字符串长度不同,而较短的字符串每个字符都跟较长字符串对应位置字符相同,那么较短字符串小于较长字符串;
- 如果两个字符串在某一位置上开始不同,那么就比较这两个字符的ASCII码,比较结果就代表两个字符串的大小关系;
3.1.5 字符数组(C风格字符串)
通过对string的介绍可以发现,字符串就是一串字符的集合,本质上其实就是一个字符的数组。
在C语言中,确实是用char[]类型来表示字符串的;不过为了区分纯粹的字符数组和字符串,C语言规定:字符串必须以空字符结束。空字符的ASCII码为0,专门用来标记字符串的结尾,在程序中写作\0
。
// str1没有结尾空字符,并不是一个字符串
char str1[5] = {'h','e','l','l','o'};
// str2是一个字符串
char str2[6] = { 'h','e','l','l','o','\0'};
cout << "str1 = " << str1 << endl;
cout << "str2 = " << str2 << endl;
如果每次用到字符串都要这样定义,对程序员来说就非常不友好了。所以字符串可以用另一种更方便的形式定义出来,那就是使用双引号:
char str3[] = "hello";
//char str3[5] = "hello"; // 错误,"hello"的长度为6
cout << "str3 = " << str3 << endl;
这就是我们所熟悉的字符串字面值常量。这里需要注意的是,我们不需要再考虑末尾的空字符,编译器会自动帮我们补全;但真实的字符串的长度,依然要包含空字符,所以上面的字符串hello长度不是5、而是6。
所以,C++中的字符串字面值常量,为了兼容C依然定义为字符数组(char[])类型,这和string是两种不同类型;两者的区别,跟数组和vector的区别类似,char[]是更底层的类型。一般情况下,使用string会带来更多方便,也会更加安全。
3.2 读取输入的字符串
程序中往往需要一些交互操作,如果想获取从键盘输入的字符串,可以使用多种方法。
3.2.1 使用输入操作符读取单词
标准库中提供了iostream,可以使用内置的cin对象,调用重载的输入操作符>>来读取键盘输入。
string str;
// 读取键盘输入,遇到空白符停止
cin >> str;
cout << str;
这种方式的特点是:忽略开始的空白符,遇到下一个空白符(空格、回车、制表等)就会停止。所以如果我们输入“hello world”,那么读取给str的只有“hello”:这相当于读取了一个单词。剩下的内容world其也没有丢,而是保存在了输入流的输入队列里。如果我们想读取更多的输入信息,就需要使用更多的string对象来获取:
string str1, str2;
cin >> str1 >> str2;
cout << str1 << str2 << endl;
这样,如果输入“hello world”,就可以输出“helloworld”。注意,此时的空白符只是忽略,并没有丢弃,依然存在于输入队列中。
3.2.2 使用getline读取一行
如果希望直接读取一整行输入信息,可以使用getline函数来替代输入操作符。
string str3;
getline(cin, str3);
cout << "str3 = " << str3 << endl;
getline函数有两个参数:一个是输入流对象cin,另一个是保存字符串的string对象;它会一直读取输入流中的内容,直到遇到换行符为止,然后把所有内容保存到string对象中。所以现在可以完整读取一整行信息了。注意,此时的换行符已经丢弃,不然存在于输入队列中了。
3.2.3 使用get读取字符
还有一种方法,是调用cin的get函数读取一个字符。
char ch;
ch = cin.get(); // 将捕获到的字符赋值给ch
cin.get(ch); // 直接将ch作为参数传给get
有两种方式:
- 调用cin.get()函数,不传参数,得到一个字符赋给char类型变量;
- 将char类型变量作为参数传入,将捕获的字符赋值给它,返回的是istream对象
get函数还可以读取一行内容。这种方式跟getline很相似,也可以读取一整行内容,以回车结束。主要区别在于,它需要把信息保存在一个char[]类型的字符数组中,调用的是cin的成员函数:
// get读取一整行
char str4[20];
cin.get(str4, 20);
cout << "str4 = " << str4 << endl;
// get读取一个字符
cin.get(); // 先读取之前留下的回车符
cin.get(); // 再等待下一次输入
get函数同样需要传入两个参数:一个是保存信息的字符数组,另一个是字符数组的长度。
这里还要注意跟getline的另一个区别:键盘输入总是以回车作为结束的;getline会把最后的回车符丢弃,而get会将回车符保留在输入队列中。这样的效果是,下次再调用get试图读取一行数据时,会因为直接读到了回车符而返回空行。这就需要再次调用get函数,捕获下一个字符:
cin.get(); // 先读取之前留下的回车符
cin.get(); // 再等待下一次输入
这样就可以将之前的回车符捕获,从而为读取下一行做好准备。这也就解释了之前为什么要写两个cin.get():第一个用来处理之前保留在输入队列的回车符;第二个用来等待下一次输入,让窗口保持开启状态。
3.3 简单读写文件
实际应用中,我们往往会遇到读写文件的需求,这也是一种IO操作,整体用法跟命令行的输入输出非常类似。
C++的IO库中提供了专门用于文件输入的ifstream类和用于文件输出的ofstream类,要使用它们需要引入头文件fstream。ifstream用于读取文件内容,跟istream的用法类似;也可以通过输入操作符>>
来读单词(空格分隔),通过getline函数来读取一行,通过get函数来读取一个字符:
ifstream input("input.txt");
// 逐词读取
string word;
while (input >> word)
cout << word << endl;
// 逐行读取
string line;
while (getline(input, line))
cout << line << endl;
// 逐字符读取
char ch;
while (input.get(ch))
cout << ch << endl;
类似地,写入文件也可以通过使用输出运算符<<
来实现:
ofstream output("output.txt");
output << word << endl;
4. 结构体
实际应用中,我们往往希望把很多不同的信息组合起来,打包存储在一个单元中。比如一个学生的信息,可能包含了姓名、年龄、班级、成绩…这些信息的数据类型可能是不同的,所以数组和vector都无法完成这样的功能。C/C++中提供了另一种更加灵活的数据结构——结构体。结构体是用户自定义的复合数据结构,里面可以包含多个不同类型的数据对象。
4.1 结构体的声明
声明一个结构体需要使用struct关键字,具体形式如下:
struct 结构体名
{
类型1 数据对象1;
类型2 数据对象2;
类型3 数据对象3;
…
};
结构体中数据对象的类型和个数都可以自定义,这为数据表达提供了极大的灵活性。结构体可以说是迈向面向对象世界中类概念的第一步。我们可以尝试定义这样一个“学生信息”结构体:
struct studentInfo
{
string name;
int age;
double score;
};
这个结构体中包含了三个数据对象:string类型的名字name,int类型的年龄age,以及double类型的成绩score。一般会把结构体定义在主函数外面,称为“外部定义”,这样可以方便外部访问。
4.2 结构体初始化
定义好结构之后,就产生了一个新的类型,叫做studentInfo。接下来就可以创建这种类型的对象,并做初始化了。
// 创建对象并初始化
studentInfo stu = {"张三", 20, 60.0};
结构体对象的初始化非常简单,跟数组完全一样:只要按照对应顺序一次赋值,逗号分隔,最后用花括号括起来就可以了。结构体还支持其它一些初始化方式:
struct studentInfo
{
string name;
int age;
double score;
}stu1, stu2 = {"小明", 18, 75.0}; // 定义结构体之后立即创建对象
// 使用列表初始化
studentInfo stu3{"李四", 22, 87};
// 使用另一结构体对象进行赋值
studentInfo stu4 = stu2;
需要注意:
- 创建结构体变量对象时,可以直接用定义好的结构体名作为类型;相比C语言中的定义,这里省略了关键字struct
- 不同的初始化方式效果相同,在不同位置定义的对象作用域不同;
- 如果没有赋初始值,那么所有数据将被初始化为默认值;算术类型的默认值就是0;
- 一般在代码中,会将结构体的定义和对象的创建分开,便于理解和管理
4.3 访问结构体中数据
访问结构体变量中的数据成员,可以使用成员运算符(点号.
),后面跟上数据成员的名称。例如stu.name就可以访问stu对象的name成员。
cout << "学生姓名:" << stu.name << "\t年龄:" << stu.age << "\t成绩:" << stu.score << endl;
这种访问内部成员的方式非常经典,后面要讲到的类的操作中,也会用这种方式访问自己的成员函数。
4.4 结构体数组
可以把结构体和数组结合起来,创建结构体的数组。顾名思义,结构体数组就是元素为结构体的数组,它的定义和访问跟普通的数组完全一样。
// 结构体数组
studentInfo s[2] = {
{"小红", 18, 92},
{"小白", 20, 82}
};
cout << "学生姓名:" << s[0].name << "\t年龄:" << s[0].age << "\t成绩:" << s[0].score << endl;
cout << "学生姓名:" << s[1].name << "\t年龄:" << s[1].age << "\t成绩:" << s[1].score << endl;
5. 枚举类
实际应用中,经常会遇到某个数据对象只能取有限个常量值的情况,比如一周有7天,一副扑克牌有4种花色等等。对于这种情况,C++提供了另一种批量创建符号常量的方式,可以替代const。这就是枚举类型enum。
5.1 枚举类型定义
枚举类型的定义和结构体非常像,需要使用enum关键字。
// 定义枚举类型
enum week
{
Mon, Tue, Wed, Thu, Fri, Sat, Sun
};
与结构体不同的是,枚举类型内只有有限个名字,它们都各自代表一个常量,被称为枚举量。需要注意的是:
- 默认情况下,会将整数值赋给枚举量;
- 枚举量默认从0开始,每个枚举量依次加1;所以上面week枚举类型中,一周七天枚举量分别对应着0~6的常量值;
- 可以通过对枚举量赋值,显式地设置每个枚举量的值;如果对中间的某个枚举量设置了指定的整型值,该枚举量后面的枚举量的值会以该值为基础进行加1;
5.12 使用枚举类型
使用枚举类型也很简单,创建枚举类型的对象后,只能将对应类型的枚举量赋值给它;如果打印它的值,将会得到对应的整数。
week w1 = Mon;
week w2 = Tue;
//week w3 = 2; // 错误,类型不匹配
week w3 = week(3); // int类型强转为week类型后赋值
cout << "w1 = " << w1 << endl;
cout << "w2 = " << w2 << endl;
cout << "w3 = " << w3 << endl;
注意:
- 如果直接用一个整型值对枚举类型赋值,将会报错,因为类型不匹配;
- 可以通过强制类型转换,将一个整型值赋值给枚举对象;
- 最初的枚举类型只有列出的值是有效的;而现在C++通过强制类型转换,允许扩大枚举类型合法值的范围。不过一般使用枚举类型要避免直接强转赋值。
6. 指针
计算机中的数据都存放在内存中,访问内存的最小单元是字节(byte)。所有的数据,就保存在内存中具有连续编号的一串字节里。
指针顾名思义,是指向另外一种数据类型的复合类型。指针是C/C++中一种特殊的数据类型,它所保存的信息,其实是另外一个数据对象在内存中的地址。通过指针可以访问到指向的那个数据对象,所以这是一种间接访问对象的方法。
6.1 指针的定义
指针的定义语法形式为:
类型* 指针变量;
这里的类型就是指针所指向的数据类型,后面加上星号*
,然后跟指针变量的名称。指针在定义的时候可以不做初始化。相比一般的变量声明,看起来指针只是多了一个星号*
而已。例如:
int* p1; // p1是指向int类型数据的指针
long* p2; // p2是指向long类型数据的指针
cout << "p1在内存中长度为:" << sizeof(p1) << endl;
cout << "p2在内存中长度为:" << sizeof(p2) << endl;
p1、p2就是两个指针,分别指向int类型和long类型的数据对象。指针的本质,其实就是一个整数表示的内存地址,它本身在内存中所占大小跟系统环境有关,而跟指向的数据类型无关。64位编译环境中,指针统一占8个字节;若是32位系统则占4字节。
6.1.1 获取对象地址给指针赋值
指针保存的是数据对象的内存地址,所以可以用地址给指针赋值;获取对象地址的方式是使用取地址操作符&
。
int a = 12;
int b = 100;
cout << "a = " << a << endl;
cout << "a的地址为:" << &a << endl;
cout << "b的地址为:" << &b << endl;
int* p = &b; // p是指向b的指针
p = &a; // p指向了a
cout << "p = " << p << endl;
把指针当做一个变量,可以先指向一个对象,再指向另一个不同的对象。
6.1.2 通过指针访问对象
指针指向数据对象后,可以通过指针来访问对象。访问方式是使用解引用操作符*
:
p = &a; // p是指向a的指针
cout << "p指向的内存中,存放的值为:" << *p << endl;
*p = 25; // 将p所指向的对象(a),修改为25
cout << "a = " << a << endl;
在这里由于p指向了a,所以*p可以等同于a。
6.2 无效指针、空指针和void*指针
6.2.1 无效指针
定义一个指针之后,如果不进行初始化,那么它的内容是不确定的。如果这时把它的内容当成一个地址去访问,就可能访问的是不存在的对象;更可怕的是,如果访问到的是系统核心内存区域,修改其中内容会导致系统崩溃。这样的指针就是“无效指针”,也被叫做野指针。
int* p1;
//*p1 = 100; // 危险!指针没有初始化,是无效指针
指针非常灵活非常强大,但野指针非常危险。所以建议使用指针的时候,一定要先初始化,让它指向真实的对象。
6.2.2 空指针
如果先定义了一个指针,但确实还不知道它要指向哪个对象,这时可以把它初始化为空指针。空指针不指向任何对象,它一般在程序中用来做判断,看一个指针是否指向了数据对象。
int* np = nullptr; // 空指针字面值
np = NULL; // 预处理变量
np = 0; // 0值
int zero = 0;
//np = zero; // 错误,int变量不能赋值给指针
cosnt int cos = 0;
//np = cos; // 错误,int常量不能赋值给指针
cout << "np = " << np << endl; // 输出0地址
//cout << "*np = " << *np << endl; // 错误,不能访问0地址的内容
空指针有几种定义方式:
- 使用字面值nullptr,这是C++ 11 引入的方式,推荐使用;
- 使用预处理变量NULL,这是老版本的方式;
- 直接使用0值;
- 另外注意,不能直接用整型变量和常量给指针赋值,即使值为0也不行
所以可以看出,空指针所保存的其实就是0值,一般把它叫做0地址;但是C/C++中的这个0地址并不一定对应当前机器的0地址,而是会把0地址解释为一个不表示任何数据的不被使用的内存地址,具体指向哪里跟系统本身的实现有关;如果本身指向的就是内存中的0地址,这个地址由于有特殊的含义也是不允许访问的。
6.2.3 void * 指针
一般来说,指针的类型必须和指向的对象类型匹配,否则就会报错。不过有一种指针比较特殊,可以用来存放任意对象的地址,这种指针的类型是void*。
int i = 10;
string s = "hello";
void* vp = &i;
vp = &s;
cout << "vp = " << vp << endl;
cout << "vp的长度为: " << sizeof(vp) << endl;
//cout << "*vp = " << *vp << endl; // 错误,不能通过void *指针访问对象
void* 指针表示只知道保存了一个地址,至于这个地址对应的数据对象是什么类型并不清楚。所以不能通过void指针访问对象;一般 void 指针只用来比较地址、或者作为函数的输入输出。
6.3 指向指针的指针
指针本身也是一个数据对象,也有自己的内存地址。所以可以让一个指针保存另一个指针的地址,这就是指向指针的指针,有时也叫“二级指针”;形式上可以用连续两个的星号**
来表示。类似地,如果是三级指针就是***
,表示“指向二级指针的指针”。
int i = 1024;
int* pi = &i; // pi是一个指针,指向int类型的数据
int** ppi = π // ppi是一个二级指针,指向一个int* 类型的指针
cout << "pi = " << pi << endl;
cout << "* pi = " << * pi << endl;
cout << "ppi = " << ppi << endl;
cout << "* ppi = " << * ppi << endl;
cout << "** ppi = " << ** ppi << endl;
//必须一级一级的来,不能一次用两个&,这就乱套了,没理解底层的实现
int a = 0;
int** p = &&a;
如果需要访问二级指针所指向的最原始的那个数据,应该做两次解引用操作。
6.4 指针和const
指针可以和const修饰符结合,这可以有两种形式:一种是指针指向的是一个常量;另一种是指针本身是一个常量。
6.4.1 指向常量的指针
指针指向的是一个常量,所以只能访问数据,不能通过指针对数据进行修改。不过指针本身是变量,可以指向另外的数据对象。这时应该把const加在类型前。
const int c = 10, c2 = 56;
//int* pc = &c; // 错误,类型不匹配
const int* pc = &c; // 正确,pc是指向常量的指针,类型为const int *
pc = &c2; // pc可以指向另一个常量
int i = 1024;
pc = &i; // pc也可以指向变量
//*pc = 1000; // 错误,不能通过pc更改数据对象
这里发现,pc是一个指向常量的指针,但其实把一个变量i的地址赋给它也是可以的;编译器只是不允许通过指针pc去间接更改数据对象。
6.4.2 指针常量(const指针)
指针本身是一个数据对象,所以也可以区分变量和常量。如果指针本身是一个常量,就意味它保存的地址不能更改,也就是它永远指向同一个对象;而数据对象的内容是可以通过指针改变的。这种指针一般叫做指针常量。指针常量在定义的时候,需要在星号*
后、标识符前加上const。
const int c = 10, c2 = 56;
int i = 1024;
int* const cp = &i;
*cp = 2048; // 通过指针修改对象的值
cout << "i = " << i << endl;
//cp = &c; // 错误,不可以更改cp的指向
const int* const ccp = &c; // ccp是一个指向常量的常量指针
这里也可以使用两个const,定义的是指向常量的常量指针。也就是说,ccp指向的是常量,值不能改变;而且它本身也是一个常量,指向的对象也不能改变。
6.5 指针和数组
6.5.1 数组名
用到数组名时,编译器一般都会把它转换成指针,这个指针就指向数组的第一个元素。所以我们也可以用数组名来给指针赋值。
int arr[] = {1,2,3,4,5};
cout << "arr = " << arr << endl;
cout << "&arr[0] = " << &arr[0] << endl;
int* pia = arr; // 可以直接用数组名给指针赋值
cout << "* pia = " << *pia << endl; // 指针指向的数据,就是arr[0]
也正是因为数组名被认为是指针,所以不能直接使用数组名对另一个数组赋值,数组也不允许这样的直接拷贝:
int arr[] = {1,2,3,4,5};
//int arr2[5] = arr; // 错误,数组不能直接拷贝
6.5.2 指针运算
如果对指针pia做加1操作,我们会发现它保存的地址直接加了4,这其实是指向了下一个int类型数据对象:
pia + 1; // pia + 1 指向的是arr[1]
*(pia + 1); // 访问 arr[1]
所谓的指针运算,就是直接对一个指针加/减一个整数值,得到的结果仍然是指针。新指针指向的数据元素,跟原指针指向的相比移动了对应个数据单位。
6.5.3 指针和数组下标
我们知道,数组名arr其实就是指针。这就带来了非常有趣的访问方式:
* arr; // arr[0]
*(arr + 1); // arr[1]
这是通过指针来访问数组元素,效果跟使用下标运算符arr[0]、arr[1]是一样的。进而我们也可以发现,遍历元素所谓的范围for循环,其实就是让指针不停地向后移动依次访问元素。
6.5.4 指针数组和数组指针
指针和数组这两种类型可以结合在一起,这就是指针数组和数组指针。
- 指针数组:一个数组,它的所有元素都是相同类型的指针;
- 数组指针:一个指针,指向一个数组的指针;
int arr[] = {1,2,3,4,5};
int* pa[5]; // 指针数组,里面有5个元素,每个元素都是一个int指针
int(* ap)[5]; // 数组指针,指向一个int数组,数组包含5个元素
cout << "指针数组pr的大小为:" << sizeof(pa) << endl; // 40
cout << "数组指针ap的大小为:" << sizeof(ap) << endl; // 8
pa[0] = arr; // pa中第一个元素,指向arr的第一个元素
pa[1] = arr + 1; // pa中第二个元素,指向arr的第二个元素
ap = &arr; // ap指向了arr整个数组
cout << "arr =" << arr << endl; //arr实际上就是int型的指针,即int*
cout << "* arr =" << *arr << endl; //arr解引用,得到arr[0]
cout << "arr + 1 =" << arr + 1 << endl; //实际上是指向arr中第二个元素的指针,也是int型的指针,即int*
cout << "ap =" << ap << endl; //ap是数组指针,但是实际上ap的值和arr的值是相同的,只是类型不一样
cout << "* ap =" << *ap << endl; //ap解引用,得到的是arr数组,也可以说是int*类型的指针
cout << "ap + 1 =" << ap + 1 << endl; //输出arr之后的地址
这里可以看到,指向数组arr的指针ap,其实保存的也是arr第一个元素的地址。arr类型是int *
,指向的就是arr[0];而ap类型是int (*) [5]
,指向的是整个arr数组。所以arr + 1,得到的是arr[1]的地址;而ap + 1,就会跨过整个arr数组。
7. 引用
我们可以在C++中为数据对象另外起一个名字,这叫做引用(reference)。
7.1 引用的用法
在做声明时,我们可以在变量名前加上&
符号,表示它是另一个变量的引用**。引用必须被初始化。**
int a = 10;
int& ref = a; // ref是a的引用
//int& ref2; // 错误,引用必须初始化
cout << "ref = " << ref << endl; // ref等于a的值
cout << "a的地址为:" << &a << endl;
cout << "ref的地址为:" << &ref << endl; // ref和a的地址完全一样
引用本质上就是一个别名,它本身不是数据对象,所以本身不会存储数据,而是和初始值绑定(bind)在一起,绑定之后就不能再绑定别的对象了。定义了引用之后,对引用做的所有操作,就像直接操作绑定的原始变量一样。所以,引用也是一种间接访问数据对象的方式。
ref = 20; // 更改ref相当于更改a
cout << "a = " << a << endl;
int b = 26;
ref = b; // ref没有绑定b,而是把b的值赋给了ref绑定的a
cout << "a的地址为:" << &a << endl;
cout << "b的地址为:" << &b << endl;
cout << "ref的地址为:" << &ref << endl;
cout << "a = " << a << endl;
当然,既然是别名,那么根据这个别名再另起一个别名也是可以的:
// 引用的引用
int& rref = ref;
cout << "rref = " << rref << endl;
cout << "a的地址为:" << &a << endl;
cout << "ref的地址为:" << &ref << endl;
cout << "rref的地址为:" << &rref << endl;
引用的引用,是把引用作为另一个引用的初始值,其实就是给原来绑定的对象又绑定了一个别名,这两个引用绑定的是同一个对象。要注意,引用只能绑定到对象上,而不能跟字面值常量绑定;也就是说,不能把一个字面值直接作为初始值赋给一个引用。而且,引用本身的类型必须跟绑定的对象类型一致。
//int& ref2 = 10; // 错误,不能创建字面值的引用
double d = 3.14;
//int& ref3 = d; // 错误,引用类型和原数据对象类型必须一致
7.2 对常量的引用
可以把引用绑定到一个常量上,这就是对常量的引用。很显然,对常量的引用是常量的别名,绑定的对象不能修改,所以也不能做赋值操作:
const int zero = 0;
//int& cref = zero; // 错误,不能用普通引用去绑定常量
const int& cref = zero; // 常量的引用
//cref = 10; // 错误,不能对常量赋值
对常量的引用有时也会直接简称常量引用。因为引用只是别名,本身不是数据对象;所以这只能代表对一个常量的引用,而不会像常量指针那样引起混淆。
常量引用和普通变量的引用不同,它的初始化要求宽松很多,只要是可以转换成它指定类型的所有表达式,都可以用来做初始化。
const int& cref2 = 10; // 正确,可以用字面值常量做初始化
int i = 35;
const int& cref3 = i; // 正确,可以用一个变量做初始化
i = 40;
cout << cref3 << endl;// 如果更改了i,该常量引用的值随之更改
double d = 3.14;
const int& cref4 = d; // 正确,d会先转成int类型,引用绑定的是一个临时量
这样一来,常量引用和变量引用,都可以作为一个变量的别名,区别在于不能用常量引用去修改对象的值。
int var = 10;
int& r1 = var;
const int& r2 = var;
r1 = 25;
//r2 = 35; // 错误,不能通过const引用修改对象值
7.3 指针和引用
常量引用和指向常量的指针,有很类似的地方:它们都可以绑定/指向一个常量,也可以绑定/指向一个变量;但不可以去修改对应的变量对象。所以很明显,指针和引用有很多联系。
7.3.1 引用和指针常量
事实上,引用的行为,非常类似于指针常量,也就是只能指向唯一的对象、不能更改的指针。
int a = 10;
// 引用的行为,和指针常量非常类似
int& r = a;
int* const p = &a;
r = 20;
*p = 30;
cout << "a = " << a << endl;
cout << "a的地址为:" << &a << endl;
cout << "r = " << r << endl;
cout << "r的地址为:" << &r << endl;
cout << "*p = " << *p << endl;
cout << "p = " << p << endl;
可以看到,所有用到引用r的地方,都可以用p替换;所有需要获取地址&r的地方,也都可以用p替换。这也就是为什么把操作符,叫做“解引用”操作符。
7.3.2 指针的引用
指针本身也是一个数据对象,所以当然也可以给它起别名,用一个引用来绑定它。比如:pref是指针ptr的引用,所以下面所有的操作,pref就等同于ptr。
int i = 56, j = 28;;
int* ptr = &i; // ptr是一个指针,指向int类型对象
int*& pref = ptr; // pref是一个引用,绑定指针ptr
pref = &j; // 将指针ptr指向j
*pref = 20; // 将j的值变为20
可以有指针的引用、引用的引用,也可以有指向指针的指针;但由于引用只是一个别名,不是实体对象,所以不存在指向引用的指针。
int i = 56
int& ref = i;
//int&* rptr = &ref; // 错误,不允许使用指向引用的指针
int* rptr = &ref; // 事实上就是指向了i
7.3.3 引用的本质
引用类似于指针常量,但不等同于指针常量。它是对指针的一种伪装。
指针常量 | 引用 |
---|---|
指针常量本身还是一个数据对象,它保存着另一个对象的地址,而且不能更改 | 而引用就是别名,它会被编译器直接翻译成所绑定的原始变量;所以我们会看到,引用和原始对象的地址是一样,引用并没有额外占用内存空间;这也是为什么不会有指向引用的指针 |
引用的本质,只是C++引入的一种语法糖。指针是C语言中最灵活、最强大的特性;引用所能做的,其实指针全都可以做。但是指针同时又令人费解、充满危险性,所以C++中通过引用来代替一些指针的用法。后面在函数部分,我们会对此有更深刻的理解。