变量和基本内置类型
- 一、基本内置类型
- 1.1 算数类型
- 1.2 带符号类型和无符号类型
- 1.3 类型转换
- 含有无符号类型的表达式
- 1.4 字面值常量
- 整形和浮点型字面值
- 字符和字符串字面值
- 转义序列
- 指定字面值的类型
- 二、变量
- 2.1 变量的定义
- 初始化
- 列表初始化
- 默认初始化
- 2.2 变量声明和定义的关系
- extern关键字
- 2.3 标识符
- 变量命名规范
- 2.4 名字的作用域
- 嵌套的作用域
- 三、复合类型
- 3.1 引用
- 引用即别名
- 引用的定义
- 3.2 指针
- 获取对象的地址
- 指针值
- 利用指针访问对象
- 关键概念
- 空指针
- 注意事项
- void* 指针
- 3.3 理解复合类型的声明
- 指向指针的指针
- 指针的引用
- 四、const限定符
- 4.1 const的引用
- 初始化和对const的引用
- const引用的本质
- 4.2 指针和const
- 指向常量的指针
- const指针
- 4.3 顶层const
- 4.4 constexpr和常量表达式
- constexpr变量
- 字面值类型
- 五、处理类型
- 5.1 类型别名
- 指针、常量和类型别名
- 别名声明
- 5.2 auto类型说明符
- 复合类型、常量和auto
- 5.3 decltype类型指示符
- decltype和引用
- 六、自定义数据类型
- 6.1 定义类类型
- 数据成员的初始化
- 6.2 文件的编译
一、基本内置类型
1.1 算数类型
- C++定义了一套包括算数类型和空类型在内的基本数据类型。
- 其中算数类型包含了字符、整形数、布尔值、浮点数。空类型不对应具体的值,仅用于一些特殊的场合,例如最常见的是:当一个函数不返回任何值时使用空类型作为返回类型。
- 算数类型分为两种:整形(包括字符和布尔类型在内)和浮点型。
- 算数类型的尺寸(该类型数据所占比特数)在不同机器上有所差别,C++标准仅规定了其尺寸的最小值。
- char类型的最小尺寸为8字节,一个char的空间应确保可以存放机器基本字符集中任意字符对应的数字值。也就是说,一个char的大小和机器字节一样。
- 其他类型用于扩展字符集,如wchar_t、char16_t 和 char32_t 。wchar_t 类型的空间应确保可以存放机器最大扩展字符集中的任意一个字符。char16_t 和 char32_t 则为Unicode字符集服务(Unicode是用于标识所有自然语言中字符的标准)。
- 其他类型各自不同的精度和大小,大多数编译器都实现了浮点数的更高精度。算数类型:bool char wchar_t char16_t char32_t short int long long long flaot double long double(扩展精度浮点数)。
1.2 带符号类型和无符号类型
- 除去布尔型和扩展的字符型外,其他整型可以划分为带符号的(signed)和无符号的(unsigned)两种。带符号整形可以表示正数、负数或0,无符号类型仅能表示大于0的值。
- 类型int、short、long和long long都是带符号的,通过在这些类型名前添加unsigned就可以得到无符号类型,例如unsigned ling。类型unsigned int可以缩写为unsigned。
- 与其他整形不同,字符型被分为了三种:char、signed char和unsigned char。要注意的是char和signed char并不相同。尽管字符型有三种,但是字符的表现形式却只有两种:带符号的和无符号的。类型char实际上会表现为上述两种形式的一种,具体是哪种由编译器决定。
- 无符号类型中所有比特都用来存储值,例如8比特的unsigned char可以表示0~255区间中的任何值。
- 选择类型的经验准则:
- 当明确知晓数值不可能为负时,选用无符号类型。
- 使用int执行整数运算。在实际应用中short常常显得太小,而long一般和int有一样的尺寸。因此如果你的数值超过了int的表示范围,选用long long。
- 在算数表达式中不要使用char和bool。因为类型char在一些机器上是有符号的,而在另一些机器上是无符号的。所以如果使用char进行运算很容易出问题。如果你需要使用一个不大的整数,那么请明确指定它的类型是unsigned char或者signed char。
- 执行浮点数选用double,这是因为float通常精度不够而且双精度浮点数和单精度浮点数的计算代价相差无几。事实上某些机器的双精度运算甚至比单精度还快。long double提供的精度在一般情况下是没有必要的,它也会带来更多的运行消耗。
1.3 类型转换
- 对象的类型定义了对象能包含的数据和能参与的运算,其中一种运算被大多数类型支持,就是将对象从一种给定的类型转换(convert)为另一种相关类型。
- 当在程序的某处我们使用了一种类型而其实对象应该取另一种类型时,程序会自动进行类型转换。列举自动转换的例子:
#include <iostream>
int main()
{
bool b = 42;
int i = b;
i = 3.14;
double pi = i;
unsigned char c = -1;
signed char c2 = 256;
std::cout << b << " " << i << " " << pi << " " << c << " " << c2;
return 0;
}
- 运行结果:
- 当我们把非布尔类型赋值给布尔类型时,初始值为0则结果为false,否则为true。当我们把一个布尔值赋给非布尔类型时,初始值为false则结果为0,初始值为true则结果为1。将浮点数赋值给整数时,整数类型仅保留浮点数中小数点之前的部分。将整数类型赋值给浮点数时,小数部分记为0,如果该整数所占空间超过了浮点类型的容留,精度可能有损失。当我们赋值给无符号类型一个超过它表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余数。当我们赋值给带符号类型一个超过它表示范围的值时,结果是未定义的,程序可能继续工作、可能崩溃,也可能生成垃圾数据。
- 程序应当尽量避免依赖于实现环境的行为。当程序移植到别的机器上后,依赖于实现环境的程序就可能发生错误,要从过去的代码中定位到这类错误不是一件轻松愉快的工作。
- 当程序的某处使用了一种算数类型的值而其实所需的是另一种类型时,编译器同样会执行上述的类型转换。例如:
#include <iostream>
int main()
{
int i = 42;
if (i)
std::cout << i;
return 0;
}
- 上述程序会将i转换为true。
含有无符号类型的表达式
- 尽管我们不会故意给无符号对象赋一个负值,却很可能写出这样做的代码。例如当一个算数表达式中既有无符号数也有int值时,那个int值就会转换成无符号数。把int转换成无符号数的过程和把int直接赋给无符号变量一样。实验代码:
#include <iostream>
int main()
{
unsigned u = 10;
int i = -42;
std::cout << i + i << std::endl;
std::cout << u + i << std::endl;
return 0;
}
- 运行结果:
- 在表达式二中相加前需要把整数-42转换为无符号数,把负数转换为无符号数类似于直接给无符号数赋一个负值,结果等于这个负数加上无符号数的模。
- 如果表达式里既有带符号类型又有无符号类型,当带符号类型取值为负时会出现异常结果,这是因为带符号数回自动地转换为无符号数。因此,切勿混用带符号类型和无符号类型!
1.4 字面值常量
整形和浮点型字面值
- 一个形如42的值被称作字面值常量(literal),这样的值一望而知。每个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型。
- 整形字面值可以使用0开头表述八进制数,以0x开头表示十六进制数。
- 浮点型字面值表现为一个小数或以科学计数法表示的指数,其中指数部分用E或e标识:3.14150、3.14159E0、0.、0e0、.001。浮点型字面值默认是一个double。
字符和字符串字面值
- 字符字面值’a’,字符串字面值"Hello World!"。字符串字面值的类型实际上是由常量字符构成的数组,编译器将在每个字符串的结尾处添加一个空字符’\0’,因此字符串字面值的实际长度要比它的内容多1。实验代码:
#include <iostream>
int main()
{
std::cout << sizeof("Hello");
return 0;
}
- 运行结果:
转义序列
- 有两类字符不能直接使用:第一类是不可打印的字符,如退格或其他控制字符。第二类是在C++语言中有特殊含义的字符(单引号、双引号、问号、反斜线)。在这些情况下需要用到转义序列,转义序列均以反斜线作为开始,C++语言规定的转义序列包括:
换行符 \n 报警(响铃)符 \a 双引号 \" 问号? 单引号\’ 回车符 \r - 实验代码:
#include <iostream>
int main()
{
std::cout << sizeof("Hello") << "\a";
return 0;
}
- 运行程序会听到经典的windows响铃!
指定字面值的类型
- 字符和字符串字面值的前缀:u-Unicode16字符-char16_t U-Unicode32字符-char32_t L-宽字符-wchar_t u8-UTF8(仅用于字符串字面常量)-char。utf-8用8位编码一个Unicode字符。
- 整形字面值的后缀:u或U-unsigned l或L-long ll或LL-long long。
- 浮点型字面值的后缀:f或F-float l或L-long double。
- 布尔字面值和指针字面值:true和false是布尔类型的字面值。nullptr是指针的字面值。
二、变量
- 变量提供一个具名的、可供程序操作的存储空间。C++每个变量都有其数据类型,数据类型决定着变量所占内存空间的大小和布局方式、该空间能存储的值的范围,以及变量能参与的运算。对C++程序员来说:变量(variable) 和 对象(object) 一般可以互换使用。
2.1 变量的定义
- 实例代码:
#include <iostream>
int main()
{
int sum = 0, value, units_sold;
std::string book("Hello World!");
return 0;
}
- 基本形式:首先是类型说明符,随后紧跟一个或多个变量名组成的列表,其中变量名以逗号分隔,最后以分号结束。列表中每个变量名的类型都由说明符指定,定义时还可以位一个或多个变量赋初值。
- string是一种可变长字符序列的数据类型,在C++库中提供了几种初始化string类对象的方法,其中一种就是上文中把字面值拷贝给string对象。
- 通常情况下,对象是指一块能存储数据并具有某种类型的内存空间。
初始化
- 初始化:当对象在创建时获得了一个特定的值,我们说这个对象被初始化了。
- 初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,而以一个新值来代替。初始化示例:
#include <iostream>
int main()
{
int sum = 0, value, units_sold;
std::string book("Hello World!");
return 0;
}
列表初始化
- 作为C++1新标准的一部分,用花括号来初始化变量得到了全面应用,而在此之前这种初始化的形式仅在某些受限的场合下才能使用。出于3.3.1节将要介绍的原因,这种初始化的形式被称为列表初始化。现在,无论是初始化对象还是某些时候为对象赋值,都可以使用这样一组花括号括起来的初始值了。
- 当使用内置类型的变量时,列表初始化形式有一个重要的特点:如果我们使用列表初始化且初始值存在丢失信息的风险,则编译器会报错:
#include <iostream>
int main()
{
long double ld = 3.1415926536;
int a{ ld }, b{ ld }; // 错误:转换未执行,因为存在丢失信息的风险
int c(ld), d = ld; //正确:转换执行,且确实丢失了部分值
return 0;
}
- 尽管我们不会故意用long double的值去初始化int变量,然而这种初始化可能在不经意间发生。
- 报错信息:
默认初始化
- 如果变量定义时没有指定初值,则变量被默认初始化,此时变量被赋予了"默认值"。默认值是什么由变量的类型和定义变量的位置有关。
- 如果变量是内置类型且未被显示初始化,则定义在任何函数体之外的变量会被初始化为0,定义在函数体内部的变量将不被初始化。一个未被初始化的内置类型变量的值是未定义的,如果试图拷贝或以其他形式访问此值将引发错误。示例如下:
- 如果变量属于类类型,则由每个类自己决定其初始化对象的方式,并决定是否允许不经初始化就定义类对象。绝大多数类都支持无须显式初始化定义对象的方式,这样的类提供了一个合适的默认值。例如string类对于未显式初始化定义的对象,定义生成一个空串。
- 如果类要求每个对象都显式初始化,则不显式初始化就定义类对象的操作会引发错误。
- 建议初始化每一个内置类型变量,虽然并非必须这么做。
2.2 变量声明和定义的关系
- 为了允许把程序拆分成多个逻辑部分来编写,C++语言支持分离式编译机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。
- 程序被分为多个文件,则需要有在文件间共享代码的方法。为了支持分离式编译机制,C++语言将声明和定义区分开来。声明使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义负责创建与名字关联的实体。
- 变量的声明:规定变量的类型和名字。变量的定义:申请存储空间,可能对变量赋初值。
- 如果想要声明一个变量而非定义,就需要在变量前添加关键字extern,并且不要进行显式初始化
extern关键字
extern double ld ; // 声明变量,变量名为ld,变量类型为double
extern double ld = 3.1415926f; // 显式初始化导致声明无效,等价于:double ld = 3.1415926;
- 在函数体内部初始化一个由extern关键字标记的变量,将引发错误。
- 变量能且只能被定义一次,但是可以被多次声明。
- 在分离式编译中,变量的定义必须出现并且只能出现在一个文件中。
2.3 标识符
- C++标识符由字母、数字和下划线组成,而且必须以字母或下划线开头。标识符长度无限制,但大小写敏感。
- C++标准库保留了一些名字,用户自定义的标识符中不能连续出现两个下划线,不能以下划线紧接大写字母开头,定义在函数体外的标识符不能以下划线开头。
变量命名规范
- 标识符需要体现实际含义。
- 变量名一般用小写字母。例如:index,而非Index。
- 用户自定义的类名一般以大写字母开头。例如:Sales_item。
- 如果标识符由多个单词组成,则单词间应有明显区分。例如:student_loan。
2.4 名字的作用域
- 不论是在程序的什么位置,使用到的每个名字都会指向一个特定的实体:变量、函数、类型等。
- 作用域(scope)是程序的一部分,在其中的名字有其特定的含义。C++语言中大多数作用域都以花括号分隔。
- 常见作用域:
- 全局作用域,在整个程序的范围内都可以使用。例如:main函数不被任何一个花括号包括,它处于全局作用域。
- 块作用域,在对应的代码块中才可使用。例如:main函数中的变量d,for循环中定义的整形i,它们处于对应代码块的块作用域。
- 建议当你第一次需要使用一个变量时再定义它。这样的好处是我们能确定它的初始值。
嵌套的作用域
- 作用域能彼此包含,被包含的作用域称为内层作用域,包含着别的作用域的作用域称为外层作用域。
- 内层作用域可以访问外层作用域的名字,内存作用域还可以重写定义外层作用域已有的名字。
- 实验代码:
#include <iostream>
int main()
{
// 外层作用域中定义i
int i = 0;
{ // 花括号形成了一个内层作用域
// 内层作用域使用外层名字i
std::cout << i << std::endl;
// 内层作用域重写定义名字i
int i = 10;
std::cout << i << std::endl;
}
return 0;
}
- 运行结果:
三、复合类型
- 复合类型是指基于其他类型定义的类型。C++语言有多种符合类型,下面介绍其中的两种:引用和指针。
- 我们之前提到变量的声明是:基本数据类型 + 变量名列表。其实更通用的描述是:基本数据类型 + 声明符列表。每个声明符命名了一个变量,并指定该变量为与基本数据类型有关的某种类型。
- 目前我们使用的声明符就是变量名,而定义出的变量类型就是对应的基本数据类型。其实还有更复杂的声明符,如 " & " 和 " * ",它们基于基本类型得到更复杂的类型,并把它指定给变量。
3.1 引用
- C++11新增了一种"右值引用",这种引用主要用于内置类。严格来说当我们使用术语"引用"时,指的其实是"左值引用"。
- 引用就是为对象另起一个别名,引用类型需要引用一种类型,通常使用"&d"形式的声明符来定义引用,其中d是声明的变量名。示例代码如下:
#include <iostream>
int main()
{
int a = 0; // 使用 声明符a + 基本类型int 定义一个int类型的变量a
int& d = a; // 使用 声明符&d + 基本类型int 定义一个int& 类型的变量d
std::cout << "a:" << a << " d:" << d << std::endl;
d = 1; // 引用类型就是起别名,d就是a,它们共用一块内存
std::cout << "a:" << a << " d:" << d << std::endl;
return 0;
}
- 运行结果:
- 引用类型需要引用一种类型,上文代码中d为int&类型,引用了int类型。
- 当定义引用时,程序把引用和它的初始值绑定在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法使引用重新绑定到初始值以外的其他对象上,因此引用在定义时就必须初始化。
引用即别名
- 引用并非对象,相反的,它只是为一个已经存在的对象所起的另外一个名字。
- 因为引用本身不是一个对象,所以不能定义引用的引用,即引用类型需要引用的那种类型不能是一种与引用。
引用的定义
- 引用需要使用到"&d"形式的声明符,要注意的是&仅针对d单个变量有效。如果"int& d,b"中定义d为引用类型,而b只是int类型,如果想要它们都为引用类型需要写为:“int& a,&b”。
- 你可能会问定义的类型不是"int&“吗?其实应该写成的格式是"int &a,&b”,int是基本类型,后面每个声明符都表示对应变量定义的类型与基本类型的关系 以及 变量名。编译器会帮你对齐成"int& a"这个样子,这种缩进的不同不会影响到定义。
#include <iostream>
int main()
{
int i = 1024;
int& a = i, b = i;// a为i的别名为引用类型,b为int类型仅数值等于i
float f = 0.5f;
int& c = f, d = f;// 错误,无法将int&引用绑定float对象,d为f截取整数的结果即0
return 0;
}
- 引用类型要求:自身的基本数据类型和绑定对象的基本数据类型严格匹配。而且引用只能绑定在对象上,而不能与某个字面值或者表达式的计算结果绑定在一起。
3.2 指针
- 指针是"指向"另外一种类型的复合类型。与引用类似,指针也实现了对其他对象的间接访问,然而指针与引用相比也具有很多不同点:
- 第一:指针本身就是一个对象,因此指针允许对自身的值进行修改和拷贝。(注意这里的拷贝是指:指针允许将自身的值赋值给另一个指针,而引用无法拷贝,因为一个引用无法被其他引用绑定)
- 第二:指针无须再定义时赋初值。(在块作用域中定义的指针如果未初始化也将具有不确定的值)
- 类似于引用的声明符形式"&d",定义指针的声明符形式为"*d"。示例代码:
#include <iostream>
int main()
{
double d1, * d2;// 注意这里d1是double类型,而d2是double*类型即double类型的指针
return 0;
}
获取对象的地址
- 如何使用指针呢?指针内存放的是某个对象的地址,我们要想使用指针来实现间接访问其他对象,那么我们必须先获得其他对象的地址。
- 使用取地址运算符(操作符&)获取对象的地址。示例代码:
#include <iostream>
int main()
{
double d1, * d2;// 注意这里d1是double类型,而d2是double*类型即double类型的指针
d1 = 0.5; // 给d1赋值
d2 = &d1; // 使用取地址运算符得到d1变量的地址,并把地址赋值给指针d2
std::cout << d1 << " " << d2 << std::endl;
return 0;
}
- 运行结果
指针值
- 指针的值(即地址)应属于下列4种状态之一:
- 1.指向一个对象。(即指针的值是一个对象的地址)
- 2.指向紧邻对象所占空间的下一个位置。(即指针的值是一个对象地址+“1”)
- 3.空指针,意味着指针没有指向任何对象。
- 4.无效指针,指针如果具有上述情况以外的值则为无效指针。
- 拷贝或以其他方式访问无效指针的值都将引发错误。因此程序员必须清楚任意给定的指针是否有效。
利用指针访问对象
- 如果指针指向了一个对象(即指针的值为对象的地址),则允许使用解引用符(操作符*)来访问该对象。实验代码:
#include <iostream>
int main()
{
double d1, * d2;// 注意这里d1是double类型,而d2是double*类型即double类型的指针
d1 = 0.5; // 给d1赋值
d2 = &d1; // 使用取地址运算符得到d1变量的地址,并把地址赋值给指针d2
std::cout << d1 << " " << d2 << std::endl;
// 通过解引用符来访问d2指向的对象,即d1
std::cout << "*d2:" << *d2 << std::endl;
// 通过解引用符访问d1并修改它的值
*d2 = 1.5;
std::cout << d1 << " " << *d2 << std::endl;
return 0;
}
- 运行结果:
- 解引用操作符适用于那些确实指向了某个对象的有效指针。
关键概念
- 像&和 * 这样的符号,既能用作定义变量语句中作为声明符的一部分,还可以用作表达式里的运算符。这样的定义容易让人感觉很混乱,其实&和 * 符号的含义取决于代码的上下文。
- 如果在类型定义的声明符里出现&和 * ,则它们代表声明符的一部分,仅用来表示变量被定义的类型和基本数据类型之间的关系(是基本关系的引用或者指针)。
- 如果在表达式即中出现&和 * ,则它们分别表示:取对象的地址 和 解引用。
- 我们完全可以把定义声明符中的&、 * 和表达式中&、* 当作不同的符号来看待。
空指针
- 空指针不指向任何对象,因此在试图使用一个指针之前可以检查它是否为空。以下是几种将指针设置为空指针的方法:
#include <iostream>
int main()
{
int* p1 = nullptr; // 最推荐的方式
int* p2 = 0; // 等价于=NULL
int* p3 = NULL; // 尽量避免使用NULL
return 0;
}
- nullptr是C++11新标准引入的特殊类型字面值,它可以被转换成任何其他的指针类型。
- NULL是一个预处理变量,这个变量在头文件cstlib中定义,它的值就是0。预处理变量由预处理器负责管理,预处理器是运行于编译过程之前的一段程序,它会自动将用到的所有预处理变量替换为实际值,因此NULL和0初始化指针是一样的。预处理变量由预处理器负责管理,不属于命名空间std,因此可以直接使用。
- 现在的C++程序最好使用nullptr,避免使用NULL。
- 把int类型赋值给指针是错误的操作,即使int变量的值等于0。(编译器不允许这种类型转换):
- 使用未经初始化的指针是引发运行时错误的一大原因。因此建议在使用指针时才定义,这样可能就可能进行指针的初始化了。如果实在不清楚指针应该指向何处,就把它初始化为nullptr,这样程序就能检测并且指定它没有指向任何对象了。
注意事项
- 通过给指针赋值我们可以使指针指向另外一个对象。
- 当且仅当指针拥有合法值时能将指针用在条件表达式中。两个指针相等说明它们的值相同。
- 指针可以判断是否为空,也可以做比较运算,但前提必须是指针为有效指针。(使用非法指针作为条件和比较都会引发不可预计的后果)
- 实验代码(判断指针为空):
#include <iostream>
int main()
{
int* p = nullptr;
if (!p)
std::cout << "p is nullptr";
return 0;
}
- 运行结果:
void* 指针
- void * 是一种特殊的指针类型,可用于存放任意对象的地址。一个void * 指针同样存放着一个地址,但是这个地址并没有标记指向对象的类型。
- 如我们使用double * 或者int * 指针时,编译器知道指针的类型,因此在对地址进行访问时就会根据对应类型的长度去访问地址。如int类型值占4个字节,那么int指针访问对象时就会从首地址开始访问4个字节长度的内存。
- void * 指针能做的事:和其他指针做比较、作为函数的输入或输出、赋值给另一个void * 指针。
- 因为我们并不知道这个对象的类型,因此就无法确定能在这个对象上进行哪些操作,概括来说void * 记录的内存空间就是内存空间,无法映射到对象。
3.3 理解复合类型的声明
指向指针的指针
- 一般来说声明符中的修饰符(&或*)的个数没有限制。示例代码:
#include <iostream>
int main()
{
int i = 10;
// 定义三个指针:p1指向int类型,p2指向int*类型,p3指向int**类型
int* p1, ** p2, *** p3;
p1 = &i; // 使p1指向int类型的i
p2 = &p1; // 使p2指向int*类型的p1
p3 = &p2; // 使p3指向int**类型的p2
// 通过p1访问i即*p1,通过p2访问p1即*p2,通过p2访问i则为*(*p2),p3同理
std::cout << i << " " << *p1 << " " << **p2 << " " << ***p3 << std::endl;
return 0;
}
- 运行结果:
指针的引用
- 引用不是对象,因此无法定义指向引用的指针。
- 引用可以引用任何非引用类型的对象,因此引用可以引用指针。示例代码:
#include <iostream>
int main()
{
int i = 10;
int* p1;
// 定义p2为p1的别名(引用)
int*& p2 = p1;
// 使得p1指向i
p1 = &i;
// 查看两个指针的地址
std::cout << "p1:" << p1 << " p2:" << p2 << std::endl;
// 通过p2修改指向对象的值
*p2 = 20;
// 查看i的值
std::cout << i << " " << *p1 << " " << *p2;
return 0;
}
- 运行结果:
- 只要是引用就必须在定义时赋初值,并且无法改变引用的值。
- 要理解一个变量的类型,如"int * & p2",我们要从右往左读,距离变量名最近的符号对变量的类型有最直接的影响。因此&表示p2是一个引用,再读到 * 和 int,因此p2引用的是一个int类型的指针(即int * )。
- 面对一条复杂的指针或引用的声明语句时,从右向左阅读有助于弄清楚它的真实含义。
四、const限定符
- 使用const关键词可以对变量的类型加以限定,使得其自身的大小无法被改变。示例代码:
#include <iostream>
int main()
{
int k = 10;
// const类型的对象必须初始化,但初始化它的对象可以不是const
const int i = k;
// const类型对象可以初始化非const类型对象
int j = i;
// const类型对象可以参与大部分运算
std::cout << i + j + k;
// 一句话:不要改变const类型对象自身的值就行!
return 0;
}
- 运行结果:
4.1 const的引用
- 可以把引用绑定到const对象上,我们称它为对常量的引用。
- 由于常量是无法改变的,因此对常量的引用也应该是无法改变的,即我们不能通过引用来修改引用绑定的对象。示例代码:
#include <iostream>
int main()
{
const int i = 10; // 定义一个整形常量
const int& j = i; // j的类型为:对const int(即整形常量) 的引用
int& k = i; // 错误:由于i是常量,因此我们需要将引用声明为对常量的引用,以此表示无法通过引用修改对象
return 0;
}
- 类型A的常量类型为const A,我们要引用A定义为:A&,我们要引用const A定义为:const A& 。
初始化和对const的引用
- 一般来说引用的类型必须与其所引用的对象的类型一致,并且不能直接绑定字面值或表达式,但是有两个例外,常量引用和存在继承关系的类。示例代码:
#include <iostream>
#include "H/Sales_item.h"
int main()
{
int i = 42;
const int& r1 = i; // 绑定类型不同,引用类型为const int,被绑定的对象类型为int
const int& r2 = 20; // const引用即常量引用可以绑定字面值
const int& r3 = i * 10; // const引用即常量引用可以绑定表达式
int& r4 = i * 10; // 错误:非const引用无法绑定表达式
return 0;
}
- 编译器检查结果:
- 之所以const引用即常量引用能绑定到字面值或表达式,是因为编译器修改了代码。编译器定义了一个对应类型的常量对象,去保存字面值或者表达式的值,然后再将常量引用绑定到这个对象上。这个对象叫做临时量对象,是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。
- 实验代码(注释代码等价于未注释代码的组合):
#include <iostream>
int main()
{
// const int& i = 20;
const int t = 20;
const int& i = t;
return 0;
}
- 根据上述原理进行实验,我们使用一个double(可以转换为int)类型的常量,去初始化一个int类型的常量引用(即const int &)。
#include <iostream>
int main()
{
const int& i = 24.123456789;
std::cout << i;
return 0;
}
- 运行结果:
const引用的本质
- const引用的本质是:限制引用对原对象的修改。而原对象自身可以是const类型也可以不是,即原对象可能是可修改的。
- const引用仅保证我们无法通过这个引用对象去修改被绑定的对象,而原对象可能被其他途径修改。实验代码:
#include <iostream>
int main()
{
int i = 0;
const int& r = i;
while (i != 10)
{
i++;
std::cout << r << " ";
}
std::cout << std::endl;
return 0;
}
- 运行结果:
- 概括总结:引用类型就是绑定对象,如果对象是const那么引用必须是const。如果引用定义为const则可以引用常量、非常量、字面值、表达式。其中字面值和表达式用到了临时量。const引用仅表示不能通过引用修改被绑定的对象,由于对象允许不是常量,因此const引用值可能发生改变。
4.2 指针和const
指向常量的指针
- 指向常量的指针(pointer to const)要求:不能用于改变其所指向的值。
- 指向常量的指针仅限制了不能通过指针修改被指向的对象,并不限制被指向对象是否是常量,即被指向的对象可能通过其他途径修改自身的值。
- 要存放常量对象的地址,必须使用常量的指针。(对一个const常量对象,无论引用它还是指向它,都必须使用const修饰符限定,使得引用和指针无法改变它)
#include <iostream>
int main()
{
int i = 0;
const int* p = &i;
while (i < 10)
{
i++;
std::cout << *p << " ";
}
std::cout << std::endl;
return 0;
}
- 运行结果:
const指针
- 指向常量的指针要求不能通过指针修改被指向对象。而const指针限定指针为常量,即指针的值必须在定义时就初始化,并且后面是无法更改的。
- const指针仅限制指针自身存储的地址即指向的对象是不变的,并未限制不能使用指针修改指向的对象。即我们不能修改const指针,但可以修改const指针指向的对象。实验代码:
#include <iostream>
int main()
{
int i = 0;
int* const p = &i;
while (i < 10)
{
(*p)++;
std::cout << i << " ";
}
std::cout << std::endl;
return 0;
}
- 运行结果:
- 注意分析一个变量的类型依旧是从变量名开始,对于"int* const p",从右到左首先是const,因此p是一个常量。又因为int * ,因此p是常量并且是指向一个int类型的指针。
4.3 顶层const
- 顶层const表示对象自身是不可修改的常量,比如任意常量、const指针和const引用。
- 底层const表示指针或引用所指向或绑定的对象无法修改。而其他基本类型如int、float用不用const修饰都不是底层const。示例代码:
#include <iostream>
int main()
{
int i; // 不具备任何const
const int j = 1; // 仅具备顶层const
int* p1; // 不具备任何const
int* const p2 = &i; // 具备顶层const
const int* p3 = &i; // 具备底层const
const int* const p4 = &i; // 具备顶层和底层const
const int& r = i; // 具备底层const
return 0;
}
- 引用不是对象,在定义时就必须绑定对象并且无法修改,因此没有" int& const r "的说法。
- 底层const的经典代表是:const引用、指向常量的指针。它们的重点都在描述无法通过引用和指针修改指向或绑定的对象。
- 当我们要把一个指向常量的指针赋值给另一个指针的时候,我们要告诉那个指针什么呢?当然是:“嘿你小子,别去修改这个值哈!”。当然它尊不遵守约定我们不知道,因此我们也需要限定这个指针不能修改它指向的内容。也就是说:底层const的指针值只能传给具备底层const资格的指针对象,引用同理。示例代码:
#include <iostream>
int main()
{
int i; // 不具备任何const
const int j = 1; // 仅具备顶层const
int* p1; // 不具备任何const
int* const p2 = &i; // 具备顶层const
const int* p3 = &i; // 具备底层const
const int* const p4 = &i; // 具备顶层和底层const
const int& r = i; // 具备底层const
p1 = p2; // 正确: p2不具备底层const,无底层cosnt要求
p1 = p3; // 错误:将底层const p3传给不具备底层const的p1
return 0;
}
-
编译器检查:
-
引用同理:
-
概括总结:指向常量的指针只能初始化指向常量的指针,const引用只能初始化const引用。
4.4 constexpr和常量表达式
- 常量表达式指值不会改变并且在编译过程就能得到计算结果的表达式。显然字面值属于常量表达式。
- 用常量表达式初始化的const对象也是常量表达式。注意:要求对象不可修改即为const常量,并且对象初始化的表达式也可以在编译过程得到计算结果。
constexpr变量
- 在一个复杂系统中几乎不能分辨一个初始值到底是不是常量表达式。
- C++11新标准规定:允许将变量声明为constexpr类型以便编译器去验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用表达式初始化。示例代码:
#include <iostream>
int main()
{
int i;
constexpr int a = i; // 错误:i不为常量表达式
constexpr int b = 20; // 正确:字面值属于常量表达式
constexpr int b = size(); // 当且仅当size为constexpr函数时正确
return 0;
}
- 新标准允许定义一种特殊的constexpr函数,这种函数应足够简单,以至于在编译时就可以计算出结果。
- 一般来说,如果你认为变量是一个常量表达式,那就把它声明成constexpr类型。
- 概括总结:如果一个变量是一个常量表达式,那么计算它是很快速的,因此我们应该尽力把变量变为常量表达式。我们如何知道一个变量满不满足常量表达式的条件了呢?我们在变量定义前面加一个constexpr,则编译器就会帮我们检测它是不是常量,并且检测它的初始值是不是常量表达式。如果不满足条件,编译器会报错阻止你运行程序。
字面值类型
- 我们把声明constexpr类型时用到的常量表达式的值称为"字面值"。
- 指针和引用都能声明为constexpr,但如果这么做它们的初始值会受到严格限制。(constexpr要求对象是const常量,因此初始值就是对象的值)
- 一个constexpr指针的初始值必须是nullptr或者0,或者是存储于某个固定地址中的对象。
- 函数体内定义的变量一般来说并非存放在固定地址中,因此constexpr不能指向这样的变量。相反定义于所有函数体之外的对象其地址固定不变,能用来初始化constexpr指针。
- 允许函数定义一类有效范围超过函数本身的变量,这类变量和定义在函数体之外的变量一样也有固定地址,constexpr指针能指向这样的对象,constexpr引用也能绑定到这样的对象上。示例代码:
#include <iostream>
int k;
int main()
{
int i;
constexpr int* p1 = &i; // 错误:指向函数体内的变量
constexpr int* p2 = &k; // 正确:指向函数体外的变量
constexpr int& r1 = i; // 错误:绑定函数体内的变量
constexpr int& r1 = k; // 正确:绑定函数体外的变量
return 0;
}
- 需要注意的是对于:constexpr int * p; ,constexpr不等价于const,即constexpr限定p指针本身不可修改,而非指向对象不可修改。因此constexpr指针指向的对象既可以是常量也可以是非常量。
五、处理类型
5.1 类型别名
- 类型别名是一个名字,它是某种类型的同义词。有两种方法可用于定义类型别名,传统的方法是使用typedef。实验代码:
#include <iostream>
typedef double wages; // wages是double的别名
typedef wages base, *p; // base是wages的别名,p是指向wages的指针
int main()
{
wages w = 10.0;
base b = 10.0;
p p1 = &b;
std::cout << w << " " << b << " " << *p1 << std::endl;
return 0;
}
- 运行结果:
指针、常量和类型别名
- typedef的使用方法很简单,但是它在遇到类型别名指代的是复合类型或常量时,就会产生意想不到的结果。实验代码:
#include <iostream>
typedef double* dp; // 声明dp是double类型的指针
int main()
{
double a = 10.0;
const dp p1 = &a; // 定义p1为一个指向常量的指针
double b = 20.0;
p1 = &b; // 尝试改变指针指向的对象,发生错误
return 0;
}
- 编译器检查:
- 报错的原因其实是p1的类型为指向double类型的常量指针,我们尝试改变它的值因此编译器报错。你可能会说:“const dp p1不就是const double * p1 吗?”。其实主要是当typedef使用了指针和常量等类型名,导致混合出我们意想不到的结果。那我们又想起别名怎么办呢?
别名声明
- 新标准规定了一种新的方法,使用别名声明来定义类型的别名。实验代码:
#include <iostream>
using dp = double*;
using cp = const double*; // 重新定义我们需要的别名(而不是基于别名进行声明)
int main()
{
double a = 10.0;
cp p1 = &a;
double b = 20.0;
p1 = &b;
return 0;
}
- 使用格式就是using 别名 = 原类型。使用这样的方式类型会更加明了,如果又设计指针和const,再重新定义一个名字使用即可。
5.2 auto类型说明符
- 编程中需要把表达式的值赋给变量,这就要求在声明变量时清楚地知道表达式的类型,但这并不简单,甚至有时根本做不到。
- C++11新标准引入了auto类型说明符,使用它就能让编译器代替我们去分析表达式所属的类型。和原来那些只对应一种特定类型的说明符(比如double)不同,auto让编译器通过初始值来推算变量的类型。显然,auto定义的变量必须有初始值。
- 实验代码:
#include <iostream>
int main()
{
auto d = 4.0 + 6.0; // 编译器分析出d的类型为double
std::cout << d;
return 0;
}
- 运行结果:10 。
复合类型、常量和auto
- 编译器推断出来的auto类型有时候和初始值类型不完全一样,编译器会适当地改变结果类型使其更符合现代化规则。
- 使用引用其实是使用引用的对象,因此编译器会将引用对象的类型作为auto的类型。
- auto一般会忽略掉顶层const,比如初始值是一个常量指针,常量指针自身不可修改因此它具备顶层const。但是易知我们不希望推断出的类型是常量指针,我们只是想暂时使用一下这个常量指针的值而已,因此auto一般会忽略顶层const。实验代码:
#include <iostream>
int main()
{
int i = 10;
int* const p1 = &i;
auto p2 = p1; // auto忽略顶层const,因此p2不为常量可以修改
std::cout << *p2 << std::endl;
p2 = nullptr; // 修改p2的值
if(!p2) // p2修改成功则打印
std::cout << "p2 is nullptr" << std::endl;
return 0;
}
-
运行结果:
-
如果你就想要得到一个顶层const指针怎么办?auto一般会忽略顶层const,因此你必须自行声明顶层const,即将声明改为:
const auto p = p1;
- auto会保留底层const,比如初始值是一个指向常量的指针。即使我们使用auto推断类型,但我们也不希望这个指针试图去改变它指向的常量。因此auto会保留底层const(即推断出的类型包含const修饰)。实验如下:
5.3 decltype类型指示符
- 有时会遇到:希望从表达式的类型推断出要定义的变量的类型,但是不想用该表达式的值初始化变量。为了满足这一要求,C++11新标准引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,但是不实际计算表达式的值。
- 实验代码:
#include <iostream>
int f()
{
std::cout << "f 函数被调用。" << std::endl;
return 0;
}
int main()
{
// 编译器判断decltype()括号内表达式值的类型,此处为int,因此等价于 int i
decltype(f()) i;
//给i赋值并查看i的值
i = 10;
std::cout << i << std::endl;
return 0;
}
-
运行结果:
-
可以看到f函数并没有被调用,同样对于表达式,使用decltype编译器也只会推断类型而非计算。
-
对于引用或者常量指针,它们必须在声明时进行初始化,类似格式如下:
#include <iostream>
int& f()
{
int i = 20;
std::cout << "f 函数被调用。" << std::endl;
return i;
}
int main()
{
int j = 10;
// 编译器推断i的类型为int&,因此此处必须对i进行初始化
decltype(f()) i = j;
std::cout << i << std::endl;
return 0;
}
- 运行结果为:10 。
decltype和引用
- 当引用类型和其他类型进行计算时,返回的是基本数据类型。示例代码:
#include <iostream>
int main()
{
int i = 42, * p = &i, & r = i;
decltype(r + r)b = 0; // 单独的int&返回int&,而int&和任何类型计算返回int类型
std::cout << b << std::endl;
return 0;
}
-
运行结果:
-
当对指针进行解引用操作时,返回的是基本数据类型的引用类型。示例代码:
#include <iostream>
int main()
{
int i = 42, * p = &i, & r = i;
decltype(*p)b = i; // 单独的p返回int*类型,而inr*返回int的&类型即int&
b = 10;
std::cout << "i:" << i << std::endl;
return 0;
}
- 运行结果:
- 当对变量添加一个括号的时候,它会被认作是一种可以作为赋值语句左值的特殊表达式,所以这样的decltype就会得到引用类型。示例代码:
#include <iostream>
int main()
{
int i = 42, * p = &i, & r = i;
// 单独的i返回类型为int,而(i)是一种可以作为赋值语句左值的特殊表达式,因此就会返回引用类型
decltype((i))b = i;
b = 10;
std::cout << "i:" << i << std::endl;
return 0;
}
- 运行结果:
六、自定义数据类型
6.1 定义类类型
- 使用struct关键字定义我们字节的类。示例类代码:
struct People
{
std::string name;
int age;
bool sex;
};
- 类体由花括号包围形成了一个新的作用域,类内部定义的名字必须唯一,但是可以与类外部定义的名字重复。
- 类体右侧的表示结束的花括号后必须写一个分号,这时因为类体后面可以紧跟变量名以示对该类型对象的定义,所以分号必不可少。示例代码;
struct People
{
std::string name;
int age;
bool sex;
}zhang_san,li_si;
- 上述代码在定义类的基础上还声明了两个类的对象:zhang_san 和 li_si 。建议不要在定义类时定义类对象,操作混合是不好的。
数据成员的初始化
- C++11新标准规定,可以为数据成员提供一个类内初始值。创建对象时,类内初始值将用于初始化数据成员,没有初始值的成员将被默认初始化。示例代码中name将被默认初始化为空字符,age默认初始化的值未知,sex被默认初始化的值未知(int bool没有默认初始化)。示例代码:
#include <iostream>
struct People
{
std::string name;
int age;
bool sex;
};
int main()
{
People p;
// 查看默认初始化后的值
std::cout << "name:" << p.name << " age:" << p.age << " sex:" << p.sex << std::endl;
return 0;
}
- 运行结果:
- 我们可以使用花括号和等号为类内数据成员提供类内初始值。示例代码:
#include <iostream>
struct People
{
std::string name{ "张三" };
int age{ 22 };
bool sex = true;
};
int main()
{
People p;
// 查看类内初始化后的值
std::cout << "name:" << p.name << " age:" << p.age << " sex:" << p.sex << std::endl;
return 0;
}
- 运行结果:
6.2 文件的编译
- 头文件一旦改变,相关的源文件就必须重新编译以获取更新过的声明。
- 预处理器由C++语言从C语言基础而来。预处理器是在编译之前执行的一段程序,可以部分地改变我们所写的程序。例如当预处理器看到#include标记时就会用指定的头文件的内容代替#include。
- C++程序还会用到的一项预处理功能是头文件保护符,头文件保护符依赖于预处理变量。预处理变量有两种状态:已定义和未定义。示例代码:
#ifndef PEOPLE_H
#define PEOPLE_H
struct People
{
std::string name{ "张三" };
int age{ 22 };
bool sex = true;
};
#endif
- 上述代码的含义是如果未定义PEOPLE_H即未引入此头文件,则定义PEOPLE_H并且引入头文件,否则不引入头文件直接结束。
- 预处理变量无视C++语言中关于作用域的规则。
- 头文件无论是否使用,都要基于头文件中类的名字来构建保护符的名字,并且设置头文件保护符在头文件中。