- 第1章 开始
- 第2章 变量和基本类型
- 第3章 字符串、向量和数组
- 第4章 表达式
- 第5章 语句
- 第6章 函数
- 第7章 类
- 参考
《C++Primer》笔记 第Ⅰ部分 C++基础
包括第1至7章
[第1章 开始] [第2章 变量和出本类型] [第3章 字符串、向量和数组] [第4章 表达式]
[第5章 语句] [第6章 函数] [第7章 类]
第五版
第1章 开始
1.1 编写一个简单的C++程序
1.1.1 编译、运行程序
程序源文件命名约定
- 不同编译器使用不同的后缀,最常见的包括
.cc、.cxx、.cpp、.cp、.C
从命令行运行编译器
$ CC prog1.cc
- Windows生成可执行文件prog1.exe;UNIX会生成a.out
- 在Windows运行一个可执行文件可以忽略扩展名
$ prog1
或$ .\prog1
- 在UNIX运行可执行文件,需要全名
$ a.out
或$ ./a.out
- 通过echo命令获取返回值
- UNIX:
$ echo $?
- Windows:
$ echo %ERRORLEVEL%
- UNIX:
- 最常用的编译器是GNU编译器和微软Visual Studio
- GNU的命令是g++:
$ g++ -o prog1 prog1.cc
。$
是系统提示符,-o prog1
指定了可执行文件的文件名,生成一个名为prog1(UNIX)或prog1.exe(Windows)的可执行文件 - 省略-o prog1,则a.out(UNIX)或a.exe(Windows)
- 微软Visual Studio 2010:
C:\Users\me\Programs> c1 /EHsc prog1.cpp
。/EHsc
用来打开标准异常处理。这里将生成一个可执行文件,其名字与第一个源文件名对应,后缀为.exe
- GNU的命令是g++:
1.2 初识输入输出
- C++语言并未定义任何输入输出(IO)语句,由标准库来提供
- iostream库,包含两个基础类型:
- istream:输入流
- ostream:输出流
- 一个流就是一个字符序列
标准输入输出对象
- 4个IO对象
- cin:istream类型的对象,标准输入
- cout:ostream类型的对象,标准输出
- ceer:输出警告和错误消息
- clog:用来输出程序运行时一般性信息
一个使用IO库的程序
#include <iostream>
int main()
{
std::cout << "Enter two numbers:" << std::endl;
int v1 = 0, v2 = 0;
std::cin >> v1 >> v2;
}
向流写入数据
- 输出运算符(
<<
),std::cout << "Enter two numbers:" << std::endl;
<<
接受两个运算对象:- 左侧:ostream对象
- 右侧:要打印的值
- 若使用两次«运算,因为此运算符返回其左侧的运算对象,所以第一个运算符的结果成为了第二个运算符的左侧运算对象,如下写法与上文中的效果一样
(std::cout << "Enter two numbers:") << std::endl;
std::cout << "Enter two numbers:"; std::cout << std::endl
- endl是一个操作符的特殊值,效果是结束当前行,并将与设备关联的缓冲区中的内容刷到设备中。调试语句应该保持“一直”刷新流
使用标准库中的名字
std::
指出名字cout和endl是定义在名为std的命名空间中的。命名空间可以帮助我们避免不经意的名字冲突,以及使用库中相同名字导致的冲突- 标准库在命名空间std中
- 作用域运算符(::)来指出我们使用定义在命名空间std中的名字cout
从流读取数据
//以下三段句子效果一样
std::cin >> v1 >> v2;
( std::cin >> v1 ) >> v2;
std:: >> v1;
std:: >> v2;
- 输入运算符(»)接受一个istream作为其左侧运算对象,接受一个对象作为其右侧运算对象,»从给定的istream读入数据,并存入给定对象中
- 输入运算符返回其左侧运算对象作为其计算结果,即istream本身
1.4 控制流
1.4.3 读取数量不定的输入数据
while(std::cin >> value )
statement;
- 此循环条件实际检测的是std::cin,因为std::cin » value将值存在value之后,返回的是std::cin
- 当使用一个istream对象作为条件时,其效果是检测流的状态
- 如果流是有效的,即未遇到错误,那么检测成功,即为真
- 当遇到文件结束符、无效输入(即读入的值与»右侧对象类型不一样),istream对象的状态会变为无效,处于无效时,itsream对象会使条件变为假
- 编译器可以检查出的错误:
- 语法错误
- 类型错误
- 声明错误:每个名字都要先声明后使用,常见的有种错误:对来自标准库的名字忘记使用std::、标识符名字拼写错误
1.5 类简介
- 使用.h作为头文件后缀,也有用.H、.hpp或.hxx。标准库头文件通常不带后缀。大部分编译器不关心头文件名的形式
- 包含来自标准库的头文件,应该使用
<>
;不属于标准库的头文件,使用""
- 文件重定向
$ addItems < infile > outfile
,其中addItems为可执行文件,该命令会从一个名为infile的文件读取数据,并将输出结果写到outfile
1.5.2 初识成员函数
- 成员函数是定义为类的一部分的函数,也称为方法
item1.isbn()
使用.
来表达“名为item1的对象的isbn成员”,点运算符只能用于类类型的对象,左侧为类类型的对象,右侧为该类型的成员名
小结
- main函数是操作系统执行你的程序的调用入口
第2章 变量和基本类型
- 数据类型决定了程序中数据和操作的意义
2.1 基本内置类型
- 包括算术类型和空类型
- 算术类型包含:字符、整型、布尔值、浮点数
2.1.1 算术类型
- 算术类型分为两类
- 整型:包含字符和布尔值在内
- 浮点型
- 基本的字符类型是chart,一个char的大小和一个机器字节一样
char_16_t
和char_32_t
为Unicode字符集服务(Unicode是用于表示所有自然语言中字符的标准)short <= int <= long <= long long
- 大多数计算机以2的整数次幂个比特币来作块来处理内存
- 可寻址的最小内存块为“字节”,大多为8比特
- 存储的基本单元称为字,通常由几个字节组成,大多4或8字节,即32或64比特
- 类型决定了数据所占的比特数以及该如何解释这些比特的内容
- 浮点型包括:单精度、双精度和扩展精度,即float、double和long double
带符号类型和无符号类型
- 带符号:表示整数、负数和0
- 无符号:表示大于等于0的值
- short、int、long、long long都是带符号,它们前面加unsigned就是无符号;其中单单unsigned即表示unsigned int
- 字符分为:char、signed char和unsigned char;char与signed char不同;char为有符还是无符由编译器决定
- 在算术表达式中不要使用char和bool
2.1.2 类型转换
- 类型所能表示的范围决定了转换的过程:
- 非布尔类型赋值给布尔类型,初始值为0,则为false;否则为true
- 布尔值赋给非布尔值,false为0,true为1
- 浮点数赋给整数,仅保留小数点之前的部分
- 整型赋给浮点,小数部分为0,如果整数所占的空间超过了浮点类型的容量,精度可能有损失
- 赋值给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余
- 计算方法1:unsigned char由8位表示,那么
unsigned char c = -1;
为(-1+2的8次方) % 2的8次方
- 计算方法2:直接用-1的二进制转换为无符号的二进制,是多少就是多少,如-1位11111111,以无符号的表示,11111111为255
- 计算方法1:unsigned char由8位表示,那么
- 给带符号类型一个超出它表示范围的值时,结果是未定义的
含有无符号类型的表达式
- 当一个算术表达式既有无符号数又有int时,那个int会转换成无符号数
unsigned u = 10;
int i = -42;
//int占位,所以输出为4294967264,可以上上面提到的两个方法来计算
std::cout << u + i << std::endl;
- 无符号整数用于语句条件判断适要注意
//这里将出现死循环,因为u永远不会小于0
for ( unsigned u = 10; u >= 0; --u)
statemenet;
- 如果表达式里既有带符号类型又有无符号类型,当带符号类型取值为负时,表达式结果会出现异常结果,这是因为带符号数会自动转换成无符号数
2.1.3 字面值常量
- 字面值常量的形式和值决定了它的数据类型
整型和浮点型字面值
- 默认情况下,十进制字面值是带符号数;八进制和十六进制既可以是带符号,也可是是无符号
- 十进制字面值的类型是int、long、long long
- 八进制和十六进制是int、unsigned int、long、unsigned long、unsigned long long
- 严格来说,十进制字面值不会是负数,-42的负十进制字面值,那个负号并不在字面值之内,它的作用仅仅是对字面值取负值
- 浮点型字面值为一个小数或以科学计数法表示的指数。其中指数部分用E或e
- 浮点型字面值默认是一个double
字符和字符串字面值
`a` //字符字面值
"hello" //字符串字面值,每个字符串的结尾处添加一个空字符(`\0`)
转义序列
- 有两类程序员不能直接使用的字符
- 不可打印的字符,如退格
- 有特殊含义的字符,如单引号、双引号、问号、反斜线,这就需要用转义序列,这里和C语言的一样
- 泛化的转义序列
\x
后面紧跟1个或多个十六进制数字\
后面紧跟1-3个八进制数字\
紧跟超过3八进制数字,那么只有前3个被转义,即"\1234"
表示两个字符\123
和4
指定字面值的类型
- 例如L
u8"hi!"
位utf-8字符串字面值,更多参考表2.2
布尔字面值和指针字面值
- true和false是布尔类型的字面值
- nullptr是指针字面值
2.2 变量
- 变量和对象一般可以互换使用
2.2.1 变量定义
- 初始化和赋值是两个完全不同的操作
- 初始化的含义是创建变量时赋予其中一个初始值,赋值的含义是把对象的当前值擦掉,以一个新值来替代
- 初始化的方式有:
int units_sold = 0;
int units_sold = {0}; //称为列表初始化
int units_sold{0}; //称为列表初始化
int units_sold(0);
- 如果使用列表初始化且初始值存在丢失信息的风险时,编译器会报错
long double ld = 3.1415926536;
int a{ld}, b = {ld}; //错误,转换未执行
int c(ld), d = ld; //正确,转换执行
默认初始化
- 定义于任何函数体之外的变量初始化为0
- 定义在函数体内部的内置类型变量将不被初始化
- 绝大多数类都支持无须显示初始化而定义对象,这样的类提供了一个合适的默认值
- 类的对象如果没有显示初始化,则其值由类决定
2.2.2 变量声明和定义的关系
- 分离式编译,允许程序分隔为若干个文件,每个文件可被独立编译,因此声明和定义是分开的
- 声明使得名字为程序所知,规定了变量的类型和名字
- 定义负责创建与名字关联的实体,申请存储空间,也可能会赋一个初始值
- 如果想声明一个变量而非定义它,就添加extern,而且不要显示初始化变量
- 在函数内部,extern变量将引发错误
- 变量只能定义一次,但能声明多次
- C++是一种静态类型语言,其含义是在编译阶段检查类型,称为类型检查
2.2.3 标识符
- 用于自定义的标识符不能连续出现两个下画线,也不能下画线紧接大写字母开头
- 定义在函数体内的标识符不能以下划线开头
2.2.4 名字的作用域
- 同一个名字在不同作用域中可能指向不同的实体
- 全局作用域
- 块作用域
嵌套的作用域
- 被包含的作用域称为内层作用域,包含着别的作用域的作用域称为外层作用域
- 允许在内层作用域中重新定义外层作用域中已有的名字
- 在代码块中使用
::var_name
访问全局作用域
2.3 复合类型
- 复合类型是指基于其他类型定义的类型,如:指针和引用
- 一条声明语句由一个基本数据类型和紧随其后的一个声明符列表组成。每个声明符命名了一个变量并制定该变量为与基本数据类型有关的某种类型。例如
char *a;
中*a
为声明符,*
为声明符的一部分,称为类型修饰符,用来表示该变量与基本数据类型有关的某种类型,即指向整型的指针。类型修饰符有:&
、*
2.3.1 引用
- 引用包括右值引用和左值引用,13.6.1会介绍右值引用,这里值的是左值引用
- 引用为对象起了另一个名字,引用类型引用另外一种类型
int ival = 1024;
//refval指向ival(是ival的另一个名字)
int &refval = ival;
//报错:引用必须被初始化
int &refval2;
- 程序把引用和它的初始值绑定在一起,而不是将初始值拷贝给引用
- 因为无法令引用重新绑定到另外一个对象,因此引用必须初始化
- 引用并非对象,它只是一个已经存在的对象的另外一个名字
- 为引用赋值实际上是把值赋给了与引用绑定的对象;获取引用的值实际上是获取了与引用绑定的对象的值
//refval3绑定到了与refval绑定的对象上,即ival
int &refval3 = refval;
- 因为引用本身不是对象,所以不能定义引用的引用
引用的定义
- 除了2.4.1(常量引用)和15.2.3中的两个例外,所有引用的类型都要合与之绑定的对象严格匹配
- 引用只能绑定在对象上,而不能与字面值或某个表达式的计算结果绑定在一起(2.4.1中的常量引用除外)
2.3.2 指针
- 指针与引用的不同:
- 指针本身就是一个对象,允许对指针赋值和拷贝,而且指针的生命周期内可以先后指定鸡哥不同的对象
- 指针无须定义时赋值
获取对象的地址
- 因为引用不是对象,没有实际地址,所以不能获取引用的地址
//但我这样编译时可以通过的,输出*c为5,所以c获取的是b绑定的对象a的地址
//上面值得不能获取引用的地址应该是指 int &*d = &b; 这种?
int main()
{
int a = 5;
int &b = a;
int *c = &b;
std::cout << *c << std::endl;
}
- 除了2.4.2(常量指针)和15.2.3中的两个例外,其他所有指针的类型都要和它所指向的对象严格匹配
指针值
- 指针的值应该属于下列4种状态之一:
- 指向一个对象
- 指向紧邻对象所占空间的下一个位置(迭代器,pi+1,不能解引用)
- 空指针(不能解引用)
- 无效指针,上述情况之外的其他值
&和*的多重含义
- &:在等号左边表示引用,右边表示取地址符
- *:在等号左边表示指针,右边表示解引用符
空指针
//生产空指针的方法:
int *p1 = nullptr;
int *p2 = 0; //字面值常量0
int *p3 = NULL; //实际上 #define NULL 0
- 现在的C++程序最好使用nullptr,尽量避免使用NULL
int zero = 0;
pi = zero; //错误,不能把int变量直接赋给指针
其他指针操作
- 若干指针的值时0,条件取false;任何非0指针对应的条件值都是true
void* 指针
- void*可以存放任意对象的地址,但我们对该地址中到底是个什么类型的对象并不了解
- void*指针可以:
- 和别的指针比较
- 作为函数的输入或输出
- 赋给另一个void*指针
- void*指针不可以
- 直接操作void*指针所指向的对象
2.3.3 理解复合类型的声明
指向指针的引用
- 引用本身不是对象,因此不能定义指向引用的指针;但指针是对象,所以存在对指针的引用
int i = 42;
int *p;
int *&r = p; //r是一个对指针p的引用,即对一个整型指针的引用
r = &i; //相当于p = &i;
*r = 0; //相当于*p = 0;
2.4 const限定符
- 因为const对象一旦创建后其值就不能再修改,所以const对象必须初始化
默认状态下,const对象仅在文件内有效
- const对象呗设定为在文件内有效,当多个文件出现了同名的const变量时,其实等同于在不同文件中分别定义了独立的变量
- 如果想在多个文件之间共享const对象,必须在变量的定义之前添加extern关键字(使用的时候也需要使用)
2.4.1 const的引用
- 对常量的引用不能被用作修改它所绑定的对象
const int ci = 1024;
const int &r1 = ci; //正确,引用及其对应的对象都是常量
r1 = 42; //错误,r1是对常量的引用,不能修改
int &r2 = ci; //错误,试图让一个非常量引用指向一个常量对象
- “对const的引用”简称“常量引用”
初始化和对const的引用
- 引用的类型必须与其所引用对象的类型一致,但是有两个例外,第一个是const的引用,即下面要说的,第二个在15.2.3
- 允许一个常量引用绑定非常量的对象(即使是int常量引用绑定double对象也是可以的)、字面值和表达式
int i = 42;
const int &r1 = i;
const int &r2 = 42;
const int &r3 = r1 * r2;
int &r4 = r1 * 2; //上面的句子都正常,这句错误,因为r4是一个普通的非常量引用
//理解常量引用绑定到另外一种类型上发送了什么
double dval = 3.14;
const int &ri = dval;
//上面两个句子相当于
const int temp = dval; //由双精度浮点数生产一个临时的整型常量
const int &ri = temp; //让ri绑定这个临时常量
/*
ri绑定了一个临时量对象,所谓临时量就是当编译器需要一个空间来暂存表达式的求值结果临时创建的一个未命名的对
当ri不是常量时,那么上面的做法师非法的,即int &ri = dval;是非法的
*/
2.4.2 指针和const
- 指针的类型必须与其所指对象的类型一致,但是有两个例外,其中之一就是下面要讲的指向常量的指针,还有一个例外在15.2.3
- 指向常量的指针可以指向一个非常量对象
const指针
- 常量指针必须初始化
int errNumb = 0;
int *const curErr = &errNumb; //curErr为常量指针
const double pi = 3.1415926;
const double *const pip = π //指向常量对象的常量指针
double const *const pip = π //上面的也可以这样写
2.4.3 顶层const
- 顶层const表示指针本身是个常量,但顶层const也表示任意的对象是常量,如算术类型、类、指针
- 底层const表示指针所指向的对象是一个常量,与指针和引用等符合类型的基本类型部分有关,即如果指针和引用所指向(绑定)的对象是常量,则成为底层const
- 用于声明引用的const都是底层const,如
const int &r = ci
,更准确的说,引用只有底层const - 当执行对象的拷贝操作时,常量时顶层const还是底层const区别明显,其中顶层const不受什么影响
int i = 0;
int *const p1 = &i;
const int ci = 42;
const int *p2 = &ci;
const int *const p3 = p2;
const int &r = ci;
i = ci; //正确,i没有const,ci是顶层const,但顶层const不影响赋值操作
p2 = p3; //整除,p2和p3指向的对象类型相同,具有相同的底层const,p3的顶层const不影响赋值操作
- 拷入和拷出的对象必须具有相同的底层const资格,或者两个对象的数据类型必须能够转换
int i = 0;
int *const p1 = &i;
const int ci = 42;
const int *p2 = &ci;
const int *const p3 = p2;
const int &r = ci;
int *p = p3; //错误,p3有底层const,但p没有
p2 = p3; //正确,p2与p3有相同底层const
p2 = &i; //正确,虽然p2有底层const,但int *可以转换成const int *
int &r = ci; //错误,类型不一样,普通的int&不能绑定到const int上
const int &r2 = i; //正确,const int &可以绑定到普通的int上
2.4.4 constexpr和常量表达式
- 常量表达式是指值不会改变并且在编译过程就能得到计算结果的表达式,比如字面值、用常量表达式初始化的const对象
const int sz = get_size();
不是常量表达式
constexpr变量
- 允许将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式
- 声明为constexpr的变量一定是一个常量,并且必须用常量表达式初始化
constexpr int mf = 20;
constexpr int limit = mf + 1;
constexpr int sz = size(); //只有当size时一个constexpr函数时,才是一个正确的声明语句
字面值类型
- 一般比较简单、值也显而易见、容易得到,就把它们称为“字面值类型”
- 算术类型、引用类和指针类型属于字面值类型
- Sales_item、IO库、string不属于字面值类型,不能被定义成constexpr
- 一个constexpr指针的初始值必须为nullptr或0,或者是存储于某个固定定制的对象
- 定义于函数体外的对象,地址固定不变,可以能用来初始化constexpr指针
- 定义于函数体内的变量存放在非固定地址,但函数体内的static变量地址固定,可以用来初始化
指针和constexpr
- 在constexpr声明中如果定义了一个指针,限定符constexpr仅对指针有效,与指针所指的对象无关
const int *p = nullptr; //p是一个指向整型常量的指针
constexpr int *q = nullptr; //q是一个指向整数的常量指针
const int i = 42;
constexpr const int *p = &i; //p是常量指针,指向整型常量
2.5 处理类型
2.5.1 类型别名
- 类型别名是一个名字,它是某种类型的同义词
- 有两种方法可以定义类型别名
- typedef:
typedef double wages; //wages是double的同义词 typedef wages base, *p; //base是double的同义词,p是double *的同义词
- 新方法,using,称为别名声明
using SI = Sales_item; //SI是Sales_item的同义词
- typedef:
指针、常量和类型别名
typedef char *pstring;
/*
cstr是指向char的常量指针
注意与const char * cstr = 0;的区别,这个句子表示的是指向常量字符的指针
const pstring cstr = 0;应该相当于const (char *) cstr = 0;即cstr首先是这个指向char的指针,然后这个指针被const了,但是这样写是不对的
*/
const pstring cstr = 0;
const pstring *ps; //ps是一个指针,它指向的是 指向char的常量指针,即指针的指针
2.5.2 auto类型说明符
- auto类型说明符让编译器去替我们分析表达式所属的类型(区别于C的auto)
- auto让编译器通过初始值来推断变量的类型,auto定义的变量必须有初始值。
- 使用auto能使一条语句中声明多个变量,因为一条声明语句只能有一个基本数据类型,所以该语句中所有变量的初始值基本类型必须一样
auto i = 0, *p = &i; //正确
auto sz = 0; pi = 3.14; //错误
复合类型、常量和auto
- 编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则
- 当引用被用作初始值时,真正参与初始化的其实是引用对象的值,此时编译器以引用对象的类型作为auto的类型
int i =0; &r = i;
auto a = r; //a是整数
- auto一般会忽略顶层const,同时底层const会保留,比如当初始值是一个指向常量的指针时:
const int ci = i; &cr = ci;
auto b = ci; // b是一个整数,ci的顶层const被忽略
auto c = cr; // c是一个整数,cr是ci的别名,ci本身是一个顶层const,但是被忽略了
auto d = &i; // d是一个整型指针
auto e = &ci; // e是一个指向整数常量的指针,底层const被保留
//如果希望推出的auto类型是一个顶层const
//这里,ci推出的是int,f是const int
const auto f = ci;
- 还可以将引用的类型设为auto
auto &g = ci; //g是一个整型常量引用,绑定到ci,顶层const被保留
auto &h = 42; //错误,不能为非常量引用绑定字面值
const auto &j = 42; //正确,可以为常量引用绑定字面值
-
设置一个类型为auto的引用时,初始值中的顶层常量属性仍让保留,我们给初始值绑定一个引用,则此时的常量就不是顶层常量了(初始化表达式的顶层const变成了被初始化引用的底层const,所以初始值的顶层const没有被忽略,参考知乎:C++ Primer5th第二章上一句话有疑问?)
-
符号&和*值从属于某个声明符,而非基本数据类型的一部分
auto k = ci, &l = i; //k是整数,l是整数引用
auto &m = ci, *p = &ci; //m是对整型常量的引用,p是指向整型常量的指针,这里auto是const int
auto &n = i; *p2 = &ci; //错误,i的类型是int,而&ci是const int
- 对于const和auto一起使用时
auto const p = getP(); //函数的返回为一个普通的指针,则auto推出的是指针类型,又加上const关键字,所以p是常量指针
2.5.3 decltype类型指示符
- decltype的作用是选择并返回操作数的数据类型,编译器分析表达式并得到它的类型,却不实际计算表达式的值
decltype(f()) sum = x; //sum的类型就是f的返回类型
- 如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用在内):
const int ci = 0; &cj = ci;
/*
x为const int
这里要区别于auto
auto b = ci;
这里ci是个const int,但b只是个int,因为auto忽略顶层引用
*/
decltype(ci) x = 0; //
decltype(cj) y = x; //y为const int&,y绑定到变量x
/*
错误:z是一个引用,必须初始化
这里要区别于上文提到的auto
auto a = r;
这里r是个int对象的引用,但是a不是引用,是个int
*/
decltype(cj) z;
- 引用从来都是作为其所指对象的同义词出现,只有在decltype处是个例外
decltype和引用
- 如果decltype使用的表达式不是变量,则decltype返回表达式结果对应的类型,decltype的结果可以是引用类型
int i = 42; *p = &i; &r = i;
decltype( r + 0 ) b; //加法的结果是int,因此b是一个int
decltype(*p) c; //错误,c是int&,必须初始化
- 上面可知,如果表达式的内容是解引用操作,则decltype将得到引用类型
- decltype与auto的区别:decltype的结果类型与表达式密切相关
- 如果给变量加上一层或多层括号,编译器就把它当成是一个表达式
//decltype的表达式如果是加上了括号的变量,结果将是引用
decltype((i)) d; //错误,d是int&,必须初始化
decltype(i) e; //正确,e是一个int
- 切记:decltype((var))的结果永远是引用,而decltype(var)结果只有var本身是一个引用时才是一个引用
2.6 自定义数据类型
- 可以为数据成员提供一个类内初始值,类内初始值将用于初始化数据成员,没有初始值的成员将被默认初始化
- 对类内初始值的值:或者放在花括号里,或者放在等号右边,但不能用圆括号
- 类通常被定义在头文件中,而且类所在头文件的名字应与类的名字一样
- 确保头文件多次包含仍然能安全工作的常用技术使预处理器
- 头文件保护符依赖于预处理变量,预处理变量有两种状态,已定义和未定义
- #ifdef当且仅当变量已经定义时为真,#ifndef当且仅当变量未经定义时为真,一旦检查结果为真,则执行后续操作直至遇到#endif为止
//这是一个头文件
#ifndef SALES_DATA_H
#define SALES_DATA_H
#include <string>
//头文件具体内容
#endif
小结
- 复合类型包括指针、引用等,复合类型的定义以其他类型为基础
- C++语言运行用于以类的形式自定义类型
第3章 字符串、向量和数组
3.1 命名空间的using声明
- 作用域符(::)的含义是:编译器应从操作符左侧名字所示的作用域中去寻找右侧的那个名字
- 有了using声明就无须专门的前缀,也能使用所需的名字了
using namespace::name;
。注意,需要用分号结束 - 位于头文件的代码一般来说不应该使用using声明
3.2 标准库类型string
3.2.1 定义和初始化string对象
- 一个类可以定义很多种初始化对象的方法,只不过这些方法方式之间所有区别
- 以下为string初始化的方式
直接初始化和拷贝初始化
- 使用等号=初始化一个变量,是拷贝初始化
- 不使用等号,则执行的是直接初始化
- 当初始值只有1个时,使用直接初始化和拷贝初始化都行
- 当有多个值时,只能使用直接初始化
//初始值有两个,所以只能使用直接初始化
string s7(10, `c`);
3.2.2 string对象上的操作
使用getline读取一整行
- getline从给定的输入流中读入内存,直到遇到换行符为止(注意换行符也读进来了),存入到string对象中去(注意不存换行符),并最终返回它的流参数,即getline的结果也可以用作条件判断
- 触发getline函数返回的那个换行符实际上被丢弃了
string的empty和size操作
- empty函数,若字符串为”“即为空
- size返回string对象的长度,即string对象中字符的个数,但不包括`\0`
string::size_type类型
- size返回的是一个string::size_type类型的值
- 标准库定义了几种配套的类型,这些配套类型体现了标准库类型与机器无关的特性,类型size_type是其中一种
- string::size_type是无符号类型的值,而且能足够存放下任何string对象的大小
- 表达式中混用带符号数和无符号数可能产生意想不到的结果,例如n是一个带有负值的int,则表达式
s.size() < n
几乎肯定是true,因为负值n为自动转换成一个较大的无符号值
两个string对象相加
+
和+=
- 其内容是把左侧的运算对象与右侧的运算对象串接而成
字面值和string对象相加
- 当把string对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保每个加法运算符两侧的对象至少有一个是string
string s4 = s1 + ","; //正确
string s5 = "hello" + ","; //错误,运算符两边都不是string
/*
正确
相当于
string s6 = ( s1 + "," ) + "world";
*/
string s6 = s1 + "," + "world";
/*
正确
相当于
string s7 = ( "hello" + "," ) + s2;
*/
string s7 = "hello" + "," + s2;
- 为了与C兼容,C++语言中的字符串字面值并不是标准库类型string的对象,字符串字面值与string是不同的类型
3.2.3 处理string对象的字符
- 在cctype头文件中定义
- C++标准库中除了定义C++语言特有的功能外,也兼容了C语言的标准库
- C语言的头文件形如name.h,C++将这些头文件命名为cname
- cctype和ctype.h是一样的
- 在C++程序中应该使用cname,标准库中的每个名字总能在命名空间std中找到
处理每个字符?使用基于范围的for语句
//范围for语句
for( declaration : expression )
statement
使用范围for语句改变字符串中的字符
- 如果想要改变string对象中的字符,必须把循环变量定义成引用类型
string s("Hello world!!!");
for ( auto &c :s )
{
c = toupper(c);
}
只处理一部分字符?
- 想要访问string对象中的单个字符有两种方法:下标和迭代器
- 下标运算符接收的参数是string::size_type,返回值是该位置上的引用,下标从0开始
- 只要字符串不是常量,就能为下标运算符返回的字符赋予新值
3.3 标准库类型vector
- vector所有对象的类型都相同
- vector也常被称为容器
- C++语言既有类模板,也有函数模板,vector是类模板
- 编译器根据模板创建类或函数的过程称为实例化
- 通过一些额外信息来指定模板到底实例化成什么样的类,即在模板名字后面跟一对尖括号,在括号内放在信息上
#include <vector>
using std::vector
vector<int> ivec;
vector<vector<string>> file; //该向量的元素是vector对象
- 引用不是对象,所以不存在包含引用的vector
- 在早期版本的C++标准中,右尖括号和其他元素之间添加一个空格,
vector<vector<int> >
3.3.1 定义和初始化vector对象
- 在
vector<T> v4(n)
中,v4包含了n个重复地执行了值初始化的对象,如int则都为0
列表初始化vector对象
- C++提供了几种初始化方式:
=0
、={}
、{}
、()
,大多数情况可以等价使用 - 三种例外:
- 使用拷贝初始化(
=
)时,只能提供一个初始值 - 提供的是类内初始值则只能使用拷贝初始化或使用花括号的形式,即不能使用
()
- 如果提供的是初始元素的列表,则只能把初始值都放在花括号里进行初始化
- 使用拷贝初始化(
vector<string> v1 {"a","an","the"}; //列表初始化
vector<string> v1 ("a","an","the"); //错误
列表初始值还是元素数量?
- vector初始化时,整数的含义可能是vector对象的容量也可能是元素的值,使用花括号或元括号来区别
vector<int> v1(10); //10个元素,每个都是0
vector<int> v2{10}; //1个元素,为10
vector<int> v3(10, 1); //10个元素,每个都是1
vector<int> v1{10, 1}; //2个元素,10和1
- 圆括号提供的值是用来构造vector对象的
-
花括号提供的值是列表初始化该vector对象
- 如果初始化时使用了花括号的形式,但提供的值又不能用来列表初始化
vector<string> v5{"hi"}; //初始化列表,有1个元素
vector<string> v6("hi"); //错,不能使用字符串字面值构建vector对象
vector<string> v7{10}; //V7有10个默认值的初始化元素,确认无法执行列表初始化后,编译器会尝试用默认值初始化vector对象
vector<string> v8{10,"hi"}; //v8有10个值为"hi"的元素
3.3.2 向vector对象中添加元素
- vector成员函数
push_back
向其中添加元素,push_back负责把一个值当成vector对象的尾元素 - 如果循环体内部包含有向vector对象添加元素的语句,则不能使用范围for,范围for语句体内不应该改变其所遍历序列的大小
3.3.3 其他vector操作
- size返回vector对象中元素的个数,返回值的类型是由vector定义的size_type类型
vector<int>::size_type //正确
vector:size_type //错误
不能用下标形式添加元素
- vector对象(以及string对象)的下标运算符可用于访问已存在的元素,而不能用于添加元素
- 确保下标合法的一种有效手段是尽可能使用范围for语句
3.4 迭代器介绍
- 所有标准库容器都可以使用迭代器,但只有其中少数几种才同时支持下标运算符
- string不属于容器类型,但string支持很多与容器类型类似的操作
- vector支持下标运算符,这点和string一样;string支持迭代器,这也和vector一样
- 迭代提供了对对象的间接访问
3.4.1 使用迭代器
- begin和end的成员
- begin返回指向第1个元素(或第1个字符)的迭代器
- end返回指向容器(或string对象)“尾元素的下一个位置”的迭代器,该迭代器指示的是容器的一个根本不存在的“尾后”元素,仅仅是个标记而已,表示我们已经处理完了容器中的所有元素,被称为尾后迭代器,或尾迭代器
- 如果begin和end返回的是同一个迭代器,都是尾后迭代器
迭代器运算符
- 如果两个迭代器指向的元素相同或都是同一个容器的尾部迭代器,则它们相等
- 试图解引用一个非法迭代器或者尾后迭代器都是未被定义的行为
- 因为end返回的迭代器并不实际指向某个元素,所以不能对其进行递增或解引用操作
- 泛型编程:
- C++程序员习惯地使用
!=
,因为这种编程风格在标准库提供的所有容器上都有效 - 标准库容器的迭代器都定义了
==
和!=
,但它们中的大多数没有定义<
- C++程序员习惯地使用
迭代器类型
- 拥有迭代器的标准库使用
iterator
和const_iterator
来表示迭代器的类型 - 如果vector对象或string对象是一个常量,只能使用const_iterator
- 如果vector对象或string对象不是一个常量,那两个类型都可以使用
vector<int>::iterator it; //能读写vector<int>的元素
string::iterator it2; //能读写string对象中的字符
vector<int>::const_iterator it3; //只能读vector<int>的元素
string::const_iterator it4; //只能读string对象中的字符
begin和end运算符
- begin和end返回的具体类型由对象是否是常量决定,常量返回const_iterator,非常量返回iterator
- C++11引入两个新函数,cbengin和cend,不论vector对象是否是常量,都返回const_iterator
结合解引用和成员访问操作符
(*it).empty() //解引用it,然后调用结果对象的empty操作
*it.empty() //错误,试图访问it的empty成员,但it是个迭代器
- 箭头运算符
->
把解引用和成员访问两个操作结合起来,就是说it->mem
等于(*it).mem
某些对vector对象的操作会使迭代器失效
- 任何一种可能改变vector对象容量的操作,比如pull_back都会使得vector的迭代器失效
- 但凡使用了迭代器的循环体,都不要向迭代器所属的容器添加元素
3.4.2 迭代器运算
- 两个迭代器相减,得到距离,所谓距离指的是右侧的迭代器向后移动多少位置就能追上左侧的迭代器,其类型名为difference_type的带符号整型
3.5 数组
- 与vector相似的地方是,数组也是存放类型相同的对象的容器,这些对象本身没有名字,需要通过其所在位置访问
- 与vector不同的是,数组大小确定不变,不能随意向数组添加元素
3.5.1 定义和初始化内置数组
- 数组是一种复合类型
- 维度必须是一个常量表达式
- 数组的元素被默认初始化,即:
- 在函数体外,初始化
- 在函数体内,不初始化
- 定义数组的时候必须指定数组的类型,不允许使用auto由初始化列表判断类型
- 数组的元素应为对象,因此不存在引用的数组
字符数组的特殊性
- 字符数组有一种额外的初始化形式,我们可以用字符串字面值对此数组初始化,一定要注意字符串字面值的结尾处还有一个空字符,这个空字符也会像字符串的其他字符一样拷贝到字符数组中去
const char a4[6] = "Daniel"; //错误,没有足够空间存放空字符
不允许拷贝和赋值
int a[] = { 1, 2, 3 };
int a2[] = a; //错误,不允许使用一个数组初始化另一个数组
a2 = a; //错误,不允许把一个数组直接赋值给另一个数组
//b表示v的第1个元素,e表示v尾元素的下一个位置
//b和e的类型相同
auto b = v.begin(), e = v.end();
理解复杂的数组声明
int *ptrs[10]; //ptrs是含有10个整型指针的数组
int &refs[10] = /* ?*/; //错误,不存在引用的数组
int (*Parray)[10] = &arra; //Parray指向一个含有10个整数的数组
int (&arrRef)[10] = arra; //arraRef引用一个含有10个整数的数组
int *(&array)[10] = ptrs; //array是数组的引用,该数组含有10个整型指针
- 默认情况下,类型修饰符从右向左依次绑定,就数组而言,由内向外更好理解
- 要想理解数组声明的含义,最好的方法是从数组的名字开始按照由内向外的顺序阅读
3.5.2 访问数组元素
- 数组下标为size_t类型,是一种机器相关的无符号类型,在cstddef头文件中定义(是C标准库stddef.h的C++版本)
3.5.3 指针和数组
- 使用数组的时候编译器一般会把它转换成指针
- 在很多用到数组名字的地方,编译器都会自动将其替代为一个指向数组首元素的指针
string *p2 = nums;
等价于p2 = &nums[0];
- 使用数组作为auto参数的初始值时,推断得出的类型是指针而不是数组
int ia[] = { 0, 1, 2 };
auto ia2(ia); //ia2是一个整型指针,指向ia的第1个元素,相当于auto ia2(&a[0]);
decltype(ia) ia3 = { 4, 5, 6 }; //decltype的返回类型是由3个整数构成的数组
指针也是迭代器
- 允许使用递增运算符将指向数组元素的指针向前移动到下一个位置
- 通过数组名就可以获得指向首个元素的指针
- 获取尾后指针就要用到数组的另一个特殊性质,假设arra有10个元素,
int *e = &arra[10];
的e即是尾元素的下1个位置的指针
标准库函数begin和end
- C++引入了两个名为begin和end的函数(定义在iterator头文件中),这两个函数与容器中的两个同名函数功能类似,不过数组毕竟不是类类型,因此这两个函数不是成员函数,使用方法如下:
int ia[] = { 0, 1, 2 };
int *beg = begin(ia); //作为参数传入
int *last = end(ia);
指针运算
- 两个指针相减的结果的类型是ptrdiff_t的标准库类型,在cstddef头文件中,带符号
下标和指针
int *p = &ia[2];
int k = p[-2]; //正确,k即是ia[0]
- 虽然标准库类型string和vector也能执行下标运算,但数组与它们相比还是有所不同
- 标准库类型限定使用的下标必须是无符号类型,而内置的下标运算符无此要求
3.5.4 C风格字符串
C标准库String函数
- 传入此类函数的指针必须指向以空字符作为结束的数组:
char ca[] = { 'c', '+', '+' };
cout << strlen(ca) << endl; //错误,ca没有空字符串结束,该函数可能沿着ca在内存中不断向前寻找,直到遇到空字符才停下来
比较字符串
//C++风格
string s1 = "ABC";
string s2 = "DEF";
if ( s1 < s2)
//C风格
const char ca1[] = "ABC";
const char ca2[] = "DEF";
if (ca1 < ca2) //未定义,试图比较两个无关地址,即比较的是两个const char*的值,争取的应该调用strcmp函数
目标字符串的大小由调用者指定
//C++风格
string largeStr = s1 + " " + s2;
//C风格
//ca1+ca2试图将两个指针相加,非法的,应该使用strcat或strcpy函数
3.5.5 与旧代码的接口
- 现在C++程序不得不与那些充满了数组和/或C风格字符串的代码衔接,为了使一些工作简单易行,C++专门提供了一组功能
混用string对象和C风格字符串
- 任何出现字符串字面值的地方都可以以空字符结束的字符数组来替代
- 允许以空字符结束的字符数组来初始化string对象或为string对象赋值
- 在string对象的加法运算中允许以空字符结束的字符数组作为其中一个运算符(不能两个都是)
- 在string对象的复合赋值运算中允许使用以空字符结束的字符数组作为右侧运算对象
- 反过来不成立,某处需要一个C风格字符串,不能用string替代
- 不能用string对象直接初始化指向字符的指针
- 但提供了c_str成员函数
char *str = s; //错误
const char *str = s.c_str(); //正确,返回的是一个C风格的字符串
- 无法保证c_str返回的数组一直有效,如果后续的操作改变了s的值可能就让之前的数组失去效用
使用数组初始化vector对象
- 不允许使用vector初始化数组,但允许数组初始化vector
- 只需要指名拷贝区域的首元素地址和尾后地址就可以了
int int_arr[] = { 0, 1, 2, 3, 4, 5};
vector<int> ivec(begin(int_arr),end(int_arr));
vector<int> subVec(int_arr+1,int_arr+4); //拷贝3个元素int_arr[1]、int_arr[2]、int_arr[3]
- 现代的C++程序应该尽量使用vector和迭代器,避免使用内置数组和指针;尽量使用string,避免使用C风格的字符串
3.6 多维数组
- 严格来说,C++没有多维数组,多维数组只是数组的数组
多维数组的下标引用
- 如果表达式含有的下标运算符数量和数组的维度一样多,该表达式的结果将是给定类型的元素
- 如果下标运算符数量少于维度数量,则表达式的结果将是给定索引所处的一个内存数组
//ia位2维,arr为3维
ia[2][3] = arra[0][0][0]; //返回arr的首元素
int (&row)[4] = ia[1]; //把row绑定到ia的第二个4元素数组上
使用范围for语句处理多维数组
//ia[3][4]
size_t cnt = 0;
for ( auto &row : ia ) //对于外层数组的每一个元素,row是含有4个整数的数组的引用
for(auto &col :row) //对于内存数组的每一个元素,col是整数的引用
{
col = cnt;
++cnt;
}
//这个循环没有任何写操作,但外层循环的控制变量还是引用类型,是为了避免数组被自动转成指针
size_t cnt = 0;
for ( auto &row : ia ) //对于外层数组的每一个元素,row是含有4个整数的数组的引用
for(auto col : row) //对于内存数组的每一个元素,col是整数
{
cout << col << endl;
}
size_t cnt = 0;
//因为row不是引用类型,所以编译器初始化row时会自动将数组形式的元素转换指向数组首元素的指针,那么row的类型就是int*,这样内存的循环就不合法了,编译器试图在int*内遍历
for ( auto row : ia )
for(auto col : row)
- 要使用范围for语句处理多维数组,除了最内层的循环,其他所有循环的控制变量都应该是引用类型
指针和多维数组
int ia[3][4]
int (*p)[4] = ia; //p指向含有4个整数的数组,即ia[0]
p = &ia[2]; //p指向ia的尾元素
//上面声明中圆括号不可少
int *ip[4]; //整型指针的数组
int (*ip)[4]; //指向含有4个整数的数组指针
//p指向含有4个整数的数组,区别于范围for
for ( auto p = ia; p != ia + 3; ++p )
//q指向4个整数数组的首个元素,也就是说,q指向一个整数
for( auto q = *p; q != *p + 4; ++q )
cout << *q << ` `;
cout << endl;
小结
- 数组和指向数组元素的指针在一个较低的层次上实现了与标准库类型string和vector类似的功能
- 一般来说,应该优先使用标准库提供的类型,之后再考虑C++语言内置的低层的替代品数组和指针
第4章 表达式
4.1 基础
4.1.1 基本概念
- 优先级、结合律、运算对象的求值顺序
重载运算符
- 当运算符作用域类类型的运算对象时,用户可以自行定义其含义,称为运算符重载
- 运算符重载可以定义运算对象的类型、返回值的类型;不能定义运算对象的个数、运算符的优先级和结合律
左值和右值
- 旧概念:左值可以位于赋值语句的左侧,右值不能
- 当一个对象被用作右值的时候,用的是对象的值(内容);当被用作左值时,用的是对象的身份(在内存中的位置)
- 需要右值的地方可以用左值来代替,但不能把右值当成左值(也就是位置)使用;当一个左值被当成右值,实际上使用的是它的内容(值)
- 内置类型和迭代器的递增递减运算符作用于左值运算对象,其前置版本(前置
++
或前置--
)所得的结果也是左值(前置++
返回的是对象++
后的引用,后置++
返回的是运行++
前的拷贝) - 如果表达式的求值结果是左值,decltype作用于表达式(不是变量)得到一个引用类型
- 假设p为
int *
,那么decltype(*p)
的结果是int&
;decltype(&p)是int**
- 假设p为
4.1.2 优先级和结合律
- 优先级相同,则其组合规则由结合律确定
4.1.3 求值顺序
<<
运算符没有明确规定何时以及如何对运算对象求值,因此下面的输出表达式是未定义的
int i = 0;
/*
未定义的
如果先求值++i,那么输出结果为1 1
如果先求值i,那么输出结果为0 1
*/
cout << i << " " << ++i << endl;
- 4种运算符明确规定了运算对象的求值顺序
- 逻辑与(&&):先求左值再求又值,左值为真才继续求右值
-
逻辑或( ):先求左值再求又值,左值为假才继续求右值 - 条件(
?:
)运算符 - 逗号运算符
求值顺序、优先级、结合律
f()+g()*h()+j()
对于这些函数的调用顺序没有明确规定- 如果其中某几个函数影响同一个对象,则它是一条错误的表达式,将产生未定义的行为
4.2 算术运算符
- 算术运算符的运算对象和求值结果都是右值
- 所有运算对象最终会转换成同一类型
bool b1 = true;
bool b2 = -b; //b2是true
- 布尔类型的运算对象将被提升为int类型
- 整数相除结果还是整数,如果商含有小数部分,直接废除(C++11新的标准规定商一律向0取整,即直接切除小数部分)
- 关于新标准的除法和求余,除了-m导致溢出的特殊情况,其他时候
(-m)/n
和m/(-n)
都等于-(m/n)
m%(-n)
等于m%n
(-m)%n
等于-(m%n)
-m%(-n)
等于-(m%(-n))
等于-(m%n)
//TODO:不知道这个对不对
21 % 6; /* 结果是3 */ 21 / 6; /* 结果是3 */
21 % 7; /* 结果是0 */ 21 / 7; /* 结果是3 */
-21 % -8; /* 结果是-5 */ -21 / -8; /* 结果是2 */
21 % -5; /* 结果是1 */ 21 / -5; /* 结果是-4 */
4.3 逻辑和关系运算符
- 短路求值
关系运算符
if( i < j < k ) //因为 i < j是布尔值,要么0,要么1,如果大于1,则为真
if( i < j && j < k ) //正确的写法应该这样
- 进行比较运算符时除非比较的对象是布尔类型,否则不要使用布尔字面值true和false作为运算对象
4.4 赋值运算符
- 赋值运算的结果是它的左侧运算对象,并且是一个左值,结果的类型就是左侧运算对象的类型
- C++11新标准允许使用花括号括起来的初始值列表作为赋值语句的右侧对象
- 花括号会检查是否溢出
int k = { 3.14 };
这里是错误的,因为窄化转换 - 如果左侧运算对象是内置类型,那么初始值列表最多只能包含一个值,而且该值即使转换的话其所占的空间也不应该大于目标类型的空间
赋值运算满足右结合律
int ival, jval;
//正确,靠右的赋值运算jval = 0作为靠左的赋值运算符的右侧对象,靠右的赋值运算符的结果即jval被赋给了ival
ival = jval = 0;
int ival,*pval;
//错误,不能把指针的值赋值给int
ival = pval = 0;
赋值运算优先级较低
while((i = get_value()) != 42)
//区别该语句与上面语句,这里相当于hile(i = (get_value() !=42)),那么i要么是1要么是0
while(i = get_value() !=42)
符合赋值运算符
- 类似
+=
、-=
- 使用符合运算符只求值一次
- 使用普通的运算符则求值两次:一次是作为右边子表达式的一部分求值,另一次是作为赋值运算的左侧运算对象求值
4.5 递增和递减运算符
- 递增和递减有前置版本和后置版本
- 这两个版本运算符必须作用于左值运算对象
- 前置版本将对象本身(即引用)作为左值返回,后置版本将对象原始值的副本作为右值返回
在一条语句中混用解引用和递增运算符
*pbeg++
等价于*(pbeg++)
。pbeg++
把beg的值加1,然后返回pbeg的初始值的副本作为其求值结果,此时解引用运算符的运算对象是pbeg未增加之前的值
运算对象可任意顺序求值
- 大多数运算符都没有规定运算对象的求值顺序
- 如果一条子表达式改变了某个运算对象的值,另一条子表达式又要使用该值的话,运算对象的求值顺序就很关键了
while(beg != s.end() && !isspace(*beg))
*beg = toupper(*beg++); //错误,该赋值语句未定义
*beg = toupper(*beg); //如果先求左侧的值
*(beg + 1) = toupper(*beg); //如果先求右侧的值
4.6 成员访问运算符
4.7 条件运算符
- 条件运算符的优先级特别低,因此当一条长表达式中嵌套了条件运算子表达式时,通常要在它两端加上括号
//语句1
cout << ( ( grade < 60 ) ? "fail" : "pass" ); //输出pass或者fail
//语句2
cout << ( grade < 60 ) ? "fail" : "pass" ; //输出1或0
//语句3
cout << grade < 60 ? "fail" : "pass" ; //错误:试图比较cout和60
//语句2相当于
cout << ( grade < 60 ) ; //输出1或0
cout ? "fail" : "pass" ; //根据cout的值是true还是false产生对应的字面值
//语句3相当于
cout << grade; //小于运算符的优先级低于移位运算符,所以先输出grade
cout <60 ? "fail" : "pass" ; //比较cout和60,错误
4.8 位运算符
- 带符号的且它的值为负,那么位运算符如何处理运算符对象的“符号位”依赖于机器。而且,此时的左移操作可能会改变符号位的值,因此是一种未定义的行为
- 关于符号位如何处理没有明确的规定,所以强烈建议仅将位运算符用于处理无符号类型
移位运算符
- 移位运算符可能会对类型进行提升(char提升为int)
- 移位运算符右侧的运算对象一定不能为负,而且值必须严格小于结果的位数
使用位运算符
unsigned long quiz1 = 0;
quiz1 |= 1UL << 27; //将quiz1的第27位设为1
quiz1 &= ~(1UL << 27); //将quiz1的第27位设为0
bool status = quiz1 & (1UL << 27); //判断quiz1的第27位是1还是0
移位运算符(又叫IO运算符)满足左结合定律
cout << "hi" << " there" << endl;
//与上句相同
((cout << "hi") << " there") << endl;
4.9 sizeof运算符
- sizeof运算符返回一条表达式或一个类型名字所占的字节数,满足右结合律,其所得的值是一个size_t类型的常量表达式
sizeof (type)
sizeof expr
- sizeof并不实际计算其运算对象的值
Sales_data data, *p;
sizeof p; //指针所占的空间大小
sizeof *p; //p指向类型所占的空间大小,即sizeof(Sales_data)
sizeof data.revenue //Sales_data的revenue成员对应类型的大小
sizeof Sales_data::revenue; //另一种获取revenue大小的方式
- 因为sizeof不会实际运算对象的值,所以即使p是一个无效(即为初始化)的指针也不会有什么影响,在sizeof的运算对象中解引用一个无效指针仍然是一种安全的行为
- sizeof不需要真正的解引用指针也能知道它所指向的类型
- sizeof运算符的结果部分依赖于其作用的类型
- 对char或者类型为char的表达式指向sizeof,结果为1
- 对引用类型指向sizeof得到被引用对象所占用的空间大小
- 对指针执行sizeof得到指针本身所占空间的大小
- 对解引用指针执行sizeof得到指针所指向的对象所占空间的大小,指针不需要有效
- 对数组执行sizeof运算得到整个数组所占空间的大小,等价于对数组中所有元素各执行一次sizeof并将所得结果求和,注意,sizeof不会把数组转换成指针来处理
- 对string或vector对象执行sizeof只返回该类型固定部分的大小(不返回动态分配),不会计算对象中的元素占用了多少空间,参考对string对象和vector对象执行sizeof运算结果
4.10 逗号运算符
- 逗号运算符也规定了运算对象的求值的顺序
- 首先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值
4.11 类型转换
- 如果两种类型可以相互转换,那么它们就是关联的
- 类型转换如果是自动执行的,无须程序源的介入,那么被称为隐式转换
何时发生隐式类型转换
- 以下会发生隐式类型转换:
- 在大多数表达式中,比int类型小的整型值首先提升为较大的整数类型
- 在条件中,非布尔值转换成布尔类型
- 在初始化中,初始值转换成变量的类型;在赋值语句中,右侧运算对象转换成左侧运算对象的类型
- 如果算数运算或关系运算的运算对象有多种类型,需要转换成同一种
- 如第6章将要介绍的,函数调用时也会发生类型转换
4.11.1 算术转换
- 算术转换的含义使把一种算术类型转换成另外一种算术类型
- 运算符的对象将转换成最宽的类型,如果一个运算符的对象是long double,那么不论另一个运算对象的类型是什么都会转换成long double
整型提升
- 整型提升负责把小整数转换成较大的整数类型
无符号类型的运算符
- 如果某个运算对象的类型是无符号类型,转换的结果依赖机器中各个整数类型的相对大小
- 转换过程,首先执行整数提升,如果结果的类型匹配,那么无须进行进一步转换:
- 如果两个(提升后的)运算对象的类型要么都是带符号的,要么都是无符号的,则小类型的运算对象转换成大的类型
- 如果一个运算对象是无符号、另一个运算对象是带符号
- 无符号类型
>=
带符号类型,那么带符号的运算对象转换成无符号的,如2.1.2节一样 - 带符号类型
>
无符号类型,此时转换结果依赖于机器;如果无符号类型的所有值都能存在带符号类中,则无符号转换成带符号,如果不能,带符号转化成无符号
- 无符号类型
4.11.2 其他隐式类型转换
- 数组转换成指针:
- 当数组被用作decltype的参数时,或作为取地符、sizeof和typeid的运算对象时,上述数组不会转换成指针
- auto声明的变量用数组名初始化,是当作指针的
- 用一个引用来初始化数组,上述转换也不会发生,即
int arr[10]; int (&arr_ref)[10] = arr; // arr不会退化成指针
- 指针的转换:
- 常量整数0或者字面值nullptr能转换成任意指针类型
- 指向非常量的指针能转换成
void*
- 指向任意对象的指针能转换成
const void*
- 在有继承关系的类型间还有另外一种指针转换的方式
- 转换成布尔类型:
- 如果指针或算数类型的值为0,转换结果是false;否则为true
- 转换成常量:
- 允许将指向非常量类型的指针转换成指向相应的常量类型的指针
- 对于引用也是这样
- 相反的转换不存在,因为它试图删除底层const
- 类类型定义的转换
- 类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换,在7.5.4有例子
//类类型转换的例子
string s, t = "a value"; //字符串字面值转换成string类型
while(cin >> s) //while的条件部分把cin转换成布尔类型
4.11.3 显示转换
- 显示转换成为强制类型转换
- 强制类型转换的形式:
cast-name<type>(expression);
- type是转换的目标类型
- expression是要转换的值
- 如果type是引用类型,则结果是左值
- cast-name有:
static_cast
、dynamic_cast
、const_cast
、reinterpret_cast
- cast-name指定了执行的是哪种转换
dynamic_cast
是支持运行时类型识别,将在19.2做更详细介绍
static_cast
- 任何具有明确定义的类型转换,只要不包含底层const,都可以使用
static_cast
- 把一个较大的算术类型赋值给较小的类型时,但可能存在精度损失
- 编译器无法自动执行的类型转换时
void *p = &d; //任何非常量对象的地址都能存入void*
double *dp = static_cast<double *>(p); //正确,将void*转换回初始的指针类型
- 上面类似的例子中,必须保证转换后所得的类型就是指针所指的类型
const_cast
- const_cast只能改变运算对象的底层const
const char *pc;
char *p = const_cast<char *>(pc); //正确,但是通过p写值是未定义的行为
- 对于常量对象转换成非常量对象的行为,我们称为“去掉其const性质”
- 如果被转换对象本身不是一个常量,使用强制类型转换获得的对象的写权限是合法的(意思是指针指向的内容不是常量,但是指针声明为指向常量的指针的情况,常量引用绑定非常量对象也属于这种情况)
- 如果被转换对象是一个常量,再使用const_cast执行写操作就会产生未定义的后果
- 只有
const_cast
能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误;同样的,不能用const_cast
改变表达式的类型
const char *cp;
//错误,static_cast不能转换掉const性质
char *q = static_cast<char*>(cp);
//错误,const_cast只能改变常量属性
const_cast<string>(cp);
reinterpret_cast
reinterpret_cast
通常为运算对象的位模式提供较低层次上的重新解释
int *ip;
//以上pc所指的对象是一个int而非字符,如果把pc当普通字符指针使用就可能在允许时发生错误
char *pc = reinterpret_cast<char*>(ip);
//可能导致异常的运行时行为
string str(pc);
- 由于显示地声称这种转换合法,所以编译器不会发出任何警告或错误信息
- 接下来再使用pc时就会认定它的值是char*类型,编译器没法知道它实际存放的是指向int的指针
- 最终的结果是:在上面的例子中虽然用pc初始化没有什么实际意义,甚至还可能引发更槽糕的后果,但仅从语法上而言这种操作无可指摘
旧式的强制类型转换
type (expr); //函数形式的强制类型转换
(type) expr; //C语言风格的强制类型转换
- 旧式的强制类型转换分别具有与
const_cast
、static_cast
或reinterpret_cast
相似的行为 - 当我们在某处执行旧式的强制类型转换时,如果换成
const_cast
和static_cast
也合法,则行为与对应的命名转换一致 - 如果替换后不合法,则旧式强制类型转换执行与
reinterpret_cast
类似的功能
//这里旧式强制类型转换的效果和使用reinterpret_cast一样
int *ip;
char *pc = (char*)ip;
4.12 运算符优先级表
- P147
小结
- 求值顺序可能导致问题,如果两个运算对象指向同一个对象而其中一个改变了对象的值,就会导致程序出现不易发现的严重缺陷
第5章 语句
5.1 简单语句
ival = v1 + v2 ;; //正确,第二个分号表示一条多余的空语句
5.3 条件语句
5.3.1 if语句
悬垂else
- 怎么知道某个给定的else是和哪个if匹配呢?
- 这个问题通常称为悬垂else
- C++规定else与离它最近的尚未匹配的if匹配
5.3.2 switch语句
switch(expr)
中expr表达式的值转换成整数类型,然后与每个case标签的值比较- case标准必须是整型常量表达式(可以是const char这些)
- 任何两个case标签的值不能相同
swtich内部的控制流
- case标签之后不一定非得换行,把几个case标签写在一行里,强调这些case代表的是某个范围内的值:
switch(ch)
{
case:`a`:case:`b`:case:`c`:
++vowelCnt;
break;
}
漏写break容易引发缺陷
- 尽管switch语句不是非得在最后一个标签后面写上break,但是为了安全起见,最好这么做
default标签
- switch结构以一个空的default标签作为结束,则该标签后面 必须跟一条空语句或一个空块
switch内部的变量含义
- 如果在某处一个带有初值的变量位于作用域之外,在另一处该变量位于作用域之内,则从前一处跳转到后一处的行为是非法行为
- 不允许跨过变量的初始化语句直接跳转到该变量作用域内的另一个地方
case: true:
//因为程序的执行流可能绕开下面的初始化语句,所以该switch语句不合法
string file_name; //错误,控制流绕过一个隐式初始化的变量
int ival = 0; //错误,控制流绕过一个显示初始化的变量
int jval; //正确,因为jval没有初始化
break;
case: false:
//正确,jval虽然在作用域内,但是它没有被初始化
jval = next_num();
if(file_name.empty()) //错误,file_name在作用域内,但是没有被初始化
//...
- 如果需要为某个case分支定义并初始化一个变量,我们应该把变量定义在块内,从而确保后面的所有case标签都在变量的作用域之外
case true:
{
//正确,声明语句位于语句块内部
string fine_name = get_file_name;
//...
}
break;
case false:
if(file_name.empty()) //错误,file_name不在作用域之内
5.4.2 传统的for语句
for (init-statement;condition;expression)
statement;
for语句中的多重定义
- init-statement也可以定义多个对象,但是init-statement只能有一条声明语句,因此所有变量的基础类型必须相同
for(decltype(v.size()) i = 0, sz = v.size(); i != sz; i++)
v.push_back(v[i]);
省略for语句头的某些部分
- 省略condition的效果等价于在条件部分写了一个true,所以在循环体内必须有语句负责退出循环,否则循环就永无休止地执行下去
5.4.3 范围for语句
- 在范围for语句中,预存了end()的值,一旦在序列中添加(删除)元素,end函数的值就可能变得无效了,将在9.3.6有更详细的介绍
5.4.4 do while语句
- 因为do while先执行语句或者块,后判断条件,所以不允许在条件部分定义变量
do{
//...
numble(foo);
} while(int foo = get_foo()); //错误,将生命放在了do的条件部分
5.5 跳转语句
- C++提供了4种跳转语句:break、continue、goto、return
5.5.1 break语句
- break语句负责终止离它最近的while、do while、for(包括范围for)或switch语句,并从这些语句之后的第一条语句开始继续执行
5.5.2 continue语句
- continue语句终止最近的循环中的当前迭代并立即开始下一次迭代
- 只能出现在for(包括范围for)、while和do while的循环内部,或嵌套在此类循环的语句或块的内部
5.5.3 goto语句
- goto语句的作用是从goto语句无条件跳转到同一函数内的另一条语句
- 带标签语句
end: return;
可以作为goto的目标 - 标签标示独立于变量或其他标示符的名字,因此,标签标示符可以和程序中其他实体的标示符使用同一个名字而不会互相干扰
- goto语句和控制权转向的那条标签的语句必须位于同一个函数之内
- goto语句也不能将程序的控制权从变量的作用域之外转移到作用域之内
..///
goto end;
int ix = 10; //错误,goto语句绕过(往下)一个带初始化的变量定义
end:
ix = 42; //错误,此处的代码需要使用ix,但是goto语句绕过了它的声明
- 向后跳过一个已经执行的定义是合法的。跳回到变量定义之前意味着系统将销毁该变量,然后重新创建它:
//向后(往上)跳过一个带初始化的变量定义是合法的
begin:
int sz = get_size();
if(sz <= 0) {
goto begin;
}
- goto语句执行后将销毁sz,因为跳回到begin的动作跨过了sz的定义语句,所以sz将被重新定义并初始化
5.6 try语句块和异常处理
- 当程序的某部分检测到一个它无法处理的问题时,需要用到异常处理
- throw表达式,异常检测部分使用throw表达式来表示它遇到了无法处理的问题
- try语句块,异常处理部分使用try语句块处理异常,并以一个或多个catch子句结束
5.6.1 throw表达式
//...
throw runtime_error("Data must refer to name ISBN")
// 如果程序执行到这里,表示没有出现异常
cout << item1 + item2 << endl;
- 类型runtime_error是标准库异常类型的一种,定义在stdexcept头文件中
5.6.2 try语句块
try{
program-statement
} catch (exception-declaration) {
handler-statement
} catch (exception-declaration) {
handler-statement
} //,,,
catch (exception-declaration)
括号内一个(可能未命名的)对象的声明(称为异常声明)- 当选中了某个catch自己处理异常之后,执行与之对应的块,catch一旦完成,程序跳转到try语句块最后一个catch子句之后的那条语句继续执行
编写处理代码
- 每个标准库异常类都定义了名为what的成员函数,这些函数没有参数,返回值是C风格的字符串(即const char *)
函数在寻找处理代码的过程中退出
- 在寻找处理代码的过程与函数调用链刚好相反。
- 当异常被抛出后,首先搜索抛出异常的函数
- 如果没有找到匹配的catch语句,终止该函数
- 并在调用该函数的函数中继续寻找
- 如果还是没有找到匹配的catch子句,这个新的函数也被终止
- 继续搜索调用它的函数
- 以此类推,沿着程序的执行路径逐层回退,直到找到合适类型的catch子句为止
- 如果最终还是没有找到任何匹配的catch子句,程序转到名为terminate的标准库函数
- 如果一段程序没有try语句块且发生了异常,系统会调用terminate函数并终止当前程序的执行
- 异常发生时应确保对象有效、资源无泄漏、程序处于合理状态
5.6.3 标准异常
- C++标准库定义了一组类,用于报告标准库函数遇到的问题
- exception头文件定义了最通用的异常类exception。它只报告异常的发生,不提供任何额外信息
- stdexcept头文件定义了几种常用的异常类,详情在下面表5.1中列出
- new头文件定义了
bad_alloc
异常类型,将在12.1.2详细介绍 type_info
头文件定义了bad_cast
异常类型,将在19.2详细介绍
- 标准库异常类只定义了几种运算,包括创建异常类型对象、拷贝异常类型对象、对异常类型的对象赋值
exception
、bad_alloc
、bad_cast
对象以默认初始化方式初始化,不允许为这些对象提供初始值- 除以上三种之外的异常类型,应该使用string或C风格字符串初始化这些类型的对象,但不允许使用默认初始化的方式
- 异常类型值定义了一个名为what的成员函数,该函数没有任何参数,返回值是一个指向C风格字符串的
const char*
。该字符串的目的是提供关于异常的一些文本信息 - what函数返回的C风格字符串的内容与异常对象的类型有关。
- 如果异常类型有一个字符串初始值,则what返回该字符串
- 对于其他无初始值的异常类型,what返回的内容由编译器决定
第6章 函数
6.1 函数基础
调用函数
- 函数的调用完成两项工作:
- 用实参初始化函数对应的形参
- 将控制权转移给被调用函数
- return语句也完成两项工作:
- 返回return语句中的值(如果有的话)
- 将控制权从被调用函数转移回主调用函数
形参和实参
- 实参是形参的初始值
函数的形参列表
- 函数的形参列表可以为空,但不能省略
void f1() { /* ... */ }; //隐式地定义空形参列表
void f2() { void }; //显式地定义空形参列表,C风格
- 形参名是可选的,但由于无法使用未命名的形参,所以一般形参都应该有一个名字
- 函数确实有个别形参不会被用到,此类形参通常不命名表示在函数体内不使用它
函数返回类型
- 返回类型是void,表示不返回任何值
- 返回类型不能是:
- 数组类型
- 函数类型
- 返回类型可以是:
- 指向数组的指针
- 指向函数的指针
6.1.1 局部duix
- 作用域和生命周期的概念
- 名字的作用域是程序文本的一部分,名字在其中可见
- 对象的生命周期是程序执行过程中对象存在的一段时间
自动对象
- 把只存在于块执行期间的对象称为自动对象
- 对于局部变量对应的自动对象:
- 如果变量定义含有初始值,就用这个值进行初始化
- 如果变量不含初始值,执行默认初始化,意味着内置类型的未初始化局部变量将产生未定义的值
局部静态对象
size_t count_calls()
{
static size_t ctr = 0; //调用结束后,这个值仍然有效
return ++ctr;
}
- 在控制流第一次经过ctr的定义之前,ctr被创建并初始化为0
-
每次调用将ctr加1返回新值
- 如果局部变量没有显示的初始值,它将执行值初始化,内置类型的局部变量初始化为0
6.1.2 函数声明
- 函数只能定义一次,可以声明多次,唯一的例外是15.3,如果一个函数永远不被用到,可以只有声明没有定义
- 函数声明与定义非常类似,唯一区别是函数声明无函数体,用一个分号代替即可
- 因为函数的声明不包含函数体,所以也就无须形参的名字
- 函数三要素:返回类型、函数名、形参类型
6.1.3 分离式编译
//一下两句为编译
$ CC -c factMain.cc # 生成factMain.o
$ CC -c fact.cc # 生成fact.o
//以下两句为链接
$ CC factMain.o fact.o # 生成factMain.exe或者a.out
$ CC factMain.o fact.o -o main # 生成main或者main.exe
6.2 参数传递
- 如果形参是引用类型,它将绑定到对应的实参上,否则实参的值拷贝后赋给形参
- 当形参是引用类型,称为:实参被引用调用或函数被引用调用
- 当实参的值拷贝给形参时,称为:实参被值传递或函数被传值调用(指针属于这种)
6.2.1 传值参数
6.2.2 传引用参数
使用引用避免拷贝
- 当某种类型不支持拷贝操作时,函数只能通过引用形参 访问该类型的对象
- 如果函数无须改变引用形参的值,最好将其声明为常量引用
使用引用形参返回额信息
- 引用形参为我们返回多个结果提供了有效途径
- 返回多个信息的方法
- 定义一个新的数据类型,包含兑个成员
- 使用引用实参
6.2.3 const形参和实参
- 当形参是const时,顶层const作用于对象本身
- 当实参初始化形参时会忽略掉顶层const,换句话说,形参的顶层const被忽略掉了
- 当形参有顶层const时,传给它常量对象或非常量对象都可以
void fcn( const int i );
void fcn( int i ); //错误,不属于函数重载,属于重复定义,因为顶层const被忽略掉了,所以这两个fcn的形参可以完全一样
指针或引用形参与const
- 可以使用非常量初始化一个底层const对象,但反过来不行
//函数1
void reset(int &i);
//函数2
void reset(int *i);
int i =0;
const int ci = i;
reset(&i); //调用函数2
reset(&ci); //错误,不能用指向const int的对象的指针初始化函数2
reset(i); //调用函数1
reset(ci); //错误,不能把普通引用绑定到const对象ci上
reset(42); //错误,不能把普通引用绑定到字面值上
- 想调用函数1,只能使用int类型的对象,不能使用字面值、求值结果为int的表达式、需要转换的对象或const int类型的对象进行形参初始化
6.2.4 数组形参
- 因为不能拷贝数组,所以无法以值传递的方式使用数组形参
- 因为数组会被转换成指针,所以当为函数传递一个数组时,实际上传递指向数组首元素的指针
- 尽管不能以值传递的方式传递数组,但可以把形参写成类型数组的形式:
//尽管形式不同,但这三个函数式等价的
//每个函数都有一个const int*类型的形参
void print(const int*);
void print(const int[]);
void print(const int[10]); //这里的维度表示我们期望数组含有多少元素,实际不一定
//当编译器处理对print的调用时,只检查传入的是不是const int* 类型
//数组的大小对函数的调用没有影响
int i = 0, j[2] = { 0, 1 };
print(&i); //正确
print(j); //正确,j转换成int*并指向j[0]
- 管理指针形参有三种常用技术:
- 1.要求数组本身包含一个结束标记,如C风格的字符串
- 2.传递指向数组首元素和尾后元素的指针
- 3.专门定义一个表示数组大小的形参
数组形参和const
- 6.2.3中关于引用的讨论同样适合指针
数组引用形参
- 形参也可以是数组的引用,引用形参就是绑定到数组上:
//正确,形参是数组的引用,维度是类型的一部分
void print( int (&arr)[10] );
//数组的大小是构成数组类型的一部分,只能将函数作用于大小为10的数组
int i = 0, j[2] = { 0, 1 };
int k[10] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
print(&i); //错误
print(j); //错误
print(k); //正确
传递多维数组
- 当将多维数组传递给函数时,真正传递的是指向数组首元素的指针
- 因为我们处理的是数组的数组,首元素本身就是一个数组,指针就是一个指向数组的指针
- 数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略
// matrix指向数组的首元素,该数组的元素是由10个整数构成的数组
void print( int (*matrix)[10], int rowSize ) { /* ... */ };
//等价定义,实际上形参是指向含有10个整数的数组的指针
void print( int matrix[][10], int rowSize ) { /* ... */ };
6.2.5 main:处理命令行选项
prog -d -o ofile data0
- 命令行通过两个(可选的)形参传递给main函数
int main(int argc, char *argv[]){...}
- argv是一个数组,数组元素是C风格字符串的指针
- argc表示数组中字符串的数量
int main(int argc, char **argv){...}
也可以这样写- argv的第一个元素指向程序的名字或者一个空字符串,接下来的元素依次传命令行提供的实参,最后一个指针之后的元素值保证为0
argv[0] = "prog"; //或者argv[0]也可以指向一个空字符串
argv[1] = "-d";
argv[2] = "-o";
argv[1] = "ofile";
argv[1] = "data0";
argv[1] = 0;
6.2.6 含有可变形参的函数
- C++11新标准提供了两种主要方法:
- 实参类型相同:initializer_list标准库类型
- 实参类型不同:可变参数模板,将在16.4介绍
- 还有一种形参类型:省略符形参,这种功能一般只能用于与C函数交互的接口函数
initializer_list形参
- initializer_list是一种标准库类型,用于表示某种特定类型的值的数组,定义在同名头文件中
initializer_list
中的参数永远是常量值,我们无法改变initializer_list
对象中元素的值
//使用例子
void error_msg(initializer_list<string> il)
{
for(auto beg = il.begin(); beg != il.end(); ++beg)
{
cout << *beg << " ";
cout<< endl;
}
}
//向initializer_list形参传递一个值的序列,必须把序列放在一堆花括号中
error_msg({"function","okay"});
//含有initializer_list形参的函数也可以同时拥有其他形参
void error_msg(ErrCode e,initializer_list<string> il)
{
cout << e.msg() << " ";
for(auto beg = il.begin(); beg != il.end(); ++beg)
{
cout << *beg << " ";
cout<< endl;
}
}
error_msg(ErrCode(0),{"function","okay"});
省略符形参
- 省略符形参是为了方便C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能,参考《C和指针》笔记 7.6
- 省略形参只能出现在形参列表的最后一个位置
//省略形参例子
void foo(parm_list,...);
void foo(...); //这个我记得在《C和指针》里面说是不允许的,必须要有一个命名参数
6.3 返回类型和return语句
//形式1
return;
//形式2
return expression;
6.3.1 无返回值函数
- 返回void的函数不要求非得有return语句,因为在这类函数的最后一句后面会隐式地执行return
- 想在返回类型为void的函数的中间位置提前退出,可是使用return语句
- 返回void的函数也能用形式2的return语句,但是return的expression必须是另一个返回void的函数
6.3.2 有返回值函数
- return返回值的类型必须与函数的返回值类型相同或者能隐式地转换成函数的返回值类型
- 在含有return语句的循环后面应该也有一条return语句,如果没有的话该程序就是错误的。很多编译器都无法发现此类错误
值是如何被返回的
- 函数返回引用
const string &shorterString(const string &s1, const string &s2)
{
return s.size <= s.size() ? s1:s2;
}
不要返回局部对象的引用或指针
//严重错误,这个函数试图返回局部对象的引用
const string &manip()
{
string ret;
//以某种方式改变一下ret
if(!ret.empty())
return ret; //错误,返回局部对象的引用
else
return "Empty"; //错误,字符串字面值转换成一个局部临时string对象,所以"Empty"是一个局部临时变量
}
引用返回左值
- 调用一个返回引用的函数得到左值
char &get_val(string &str,string::size_type ix)
{
return str[ix]; //假的索引是有效的
}
int main()
{
string s("a value");
cout << s << endl; //输出 a value
get_val(s,0) = `A`;
cout << s << endl; //输出 A value
}
列表初始化返回值
- C++新标准规定,函数可以返回花括号包围的值的列表
vector<string> process1()
{
return {"abc","def"};
}
int process2()
{
return { 1 };
}
- 如果返回值是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间
- 如果函数返回的是类类型,由类本身定义初始值如何使用
主函数main的返回值
- 允许main函数没有return语句直接返回,编译器将隐式地插入一条返回0的return语句
- main函数的返回值可以看作是状态指示器,返回0表示成功,返回其他表示执行失败,可以返回:
- EXIT_FAILURE
- EXIT_SUCCESS
6.3.3 返回数组指针
- 函数不能返回数组,但可以返回数组的指针或引用,可以选择使用类型别名的方法和不使用类型别名的方法
- 最直接的方法是使用类型别名
//使用类型别名
typedef int arrT[10]; //arrT是类型别名,它表示的类型是含有10个整数的数组
using arrT = int[10]; //与上句等价
arrT* func(int i); //func返回一个指向含有10个整数的数组的指针
声明一个返回数组指针的函数
- 一下介绍不使用类型别名的方法
- 定义一个返回数组指针的函数,则数组的维度必须跟在函数名字后面
Type (*function(parameter_list))[dimension] //返回值为指向数组的指针的函数
Type *function(parameter_list)[dimension] //注意区分,这里返回值是个数组,该数组的元素是指,这样是不合法的
//正确使用的例子
int (*func(int i))[10];
使用尾置返回类型
- C++新标准:尾置返回类型,任何函数的定义都能使用尾置返回
- 尾置返回类型跟在形参类别后面并以一个
->
符号开头,在本应该出现返回类型的地方放置auto
//注意int(*)[10]的圆括号,没有圆括号是不对的
auto fun(int i) -> int(*)[10]
使用decltype
- 如果知道了返回的指针指向哪个数组,可以使用decltype声明返回类型
int odd[] = { 1, 3, 5, 7, 9 };
int even[] = { 0, 2, 4, 6, 8 };
//注意这里的*,因为decltype并不负责把数组类型转换成对应的指针,所以decltype的结果只是个数组,想要表示数组指针,还得加上*
decltype(odd) *arrPtr(int i)
{
return (i % 2) ? &odd : &even
}
6.4 函数重载
- 如有同一个作用域内的几个函数名相同但形成列表不同,我们称之为重载函数
- main函数不能重载
- 要求:在形参数量或形参类型上有所不同,不允许两个函数除了返回类型外其他所有的要求都相同
判断两个形参的类型是否相异
//以下每对声明的都是同一个函数,不属于函数重载
Record lookup(const Account &acct);
Record lookup(const Account &); //省略了形参的名字
typedef Phone Telno
Record lookup(const Phone&);
Record lookup(const Telno&);
重载和const形参
- 一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来
Record lookup(Phone);
Record lookup(const Phone); //重复声明Record lookup(Phone)
Record lookup(Phone*);
Record lookup(Phone* const); //重复声明Record lookup(Phone*)
- 如果形参是某种类型的指针或引用,则通过区分其指向的是常量还是非常量可以实现函数重载,此时const是底层的
Record lookup(Account &); //函数作用于Account的引用
Record lookup(const Account &); //重载的新函数,作用于常量引用
Record lookup(Account*); //相对于上面两个函数是重载的新函数,作用于指向Account的指针
Record lookup(const Account &); //重载的新函数,作用于指向常量的指针
- 编译器可以通过实参是否是常量来推断应该调用哪个函数
- 当传递一个非常量时,编译器会优先选择非常量版本的函数
调用重载的函数
- 重载确定:编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数
- 一些情况要想选择合适比较困难:比如当两个重载函数参数数量相同且参数类型可以相互转换时。在6.6介绍函数调用存在转换时编译器处理的方法
- 当调用重载函数时可能的结果:
- 编译器找到一个与实参最佳匹配的函数,并生成调用该函数的代码
- 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配的错误信息
- 有多于一个可以匹配,但每一个都不是明显的最佳选择,此时也将发送错误,称为二义性调用
6.4.1 重载与作用域
- 如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体,在不同的作用域中无法重载函数名
string read();
void print(const string &);
void print(double); //重载print函数
void fooBar(int ival)
{
bool read = false; //新作用域,隐藏了外层的read,注意变量名也会隐藏函数名
string s = read(); //错误,read是一个布尔值,而非函数
void print(int); //不属于函数重载,新作用域,隐藏了之前的print
print("Value:"); //错误,print(const string &)已经被隐藏掉了
print(ival); //正确,调用print(int)
print(3.14); //正确,调用print(int),print(double)被隐藏了
}
- C++的名字查找发生在类型检查之前
6.5 特殊用途语言特性
- 在函数的很多次调用中他们都被赋予一个相同的值,此时,我们把这个反复出现的值称为函数的默认实参
- 调用含有默认实参的函数时,可以包含该实参,也可以省略该实参
typedef string::size_type sz;
//这里为每个形参都提供了默认实参
string screen(sz ht = 24, sz wid = 80, char backgrnd = ` `); //声明语句
- 一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值
使用默认实参调用函数
string window;
window = screen(); //等价于screen(24,80,` `)
window = screen(66); //等价于screen(66,80,` `)
window = screen(66,256); //等价于screen(66,256,` `)
window = screen(66,256,`#`); //等价于screen(66,256,`#`)
window = screen(,,`?``); //错误,只能省略尾部的实参
window = screen('?'); //调用screen('?',80,` `),因为`?`可以转换为sz类型
默认实参声明
- 在给定的作用域中一个形参只能被赋予一次默认实参,即函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值
string screen(sz, sz, char = ` `); //一个声明
string screen(sz, sz, char = `*`); //错误,重复声明,不能修改一个已经存在的默认值
string screen(sz = 24, sz = 80, char); //正确,添加默认实参,之后调用该函数三个形参都有默认实参
默认实参初始值
- 局部变量不能作为默认实参
- 只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参
//wd def和ht的声明必须出现在函数之外,即不能是局部的
sz wd = 80;
char def = ` `;
sz ht();
string screen(sz = ht(), sz = wd, char = def);
string window = screen(); //调用screen(ht(),80,` `)
- 用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时:
void f2()
{
def = `*`; //改变默认实参的值
sz wd = 100; //因此了外层定义的wd,但是没有改变默认值,该局部变量与传递给screen的默认实参没有任何关系
window = screen(); //调用screen(ht(),80,`*`)
}
6.5.2 内联函数和constexpr函数
内联函数可避免函数调用的开销
- 将函数指定为内联函数,通常就是将它在每个调用点上“内联地”展开
cout << shorterString(s1,s2) << endl;
//如果shorterString是内联函数,编译过程中展开成类似下面的形式
cout << (s1.size() < s2.size() ? s1 : s2) << endl;
- 在shorterString函数的返回类型前面加inline就可以将它声明成内联函数
- 内联说明只是向编译器发出一个请求,编译器可以选择忽略这个请求
constexpr函数
- constexpr函数是指能用于常量表达式(2.4.4)的函数
- 需要遵守的约定:
- 函数的返回类型及所有形参的类型都得是字面值类型
- 函数体必须只有一条return语句
constexpr int new_sz() { return 42; }
constexpr int foo = new_sz(); //正确,foo是一个常量表达式
- 执行初始化任务时,编译器把对constexpr的调用替换成其结果值,为了能在编译过程中随时展开,constexpr隐式地指定为内联函数
- constexpr函数体可以包含:
- 其他语句,只要这些语句在运行时不执行任何操作
- 空语句
- 类别别名
- using声明
- 允许constexpr函数的返回值并非一个常量
//如果arg是常量表达式,则scale(arg)也是常量表达式,这里返回值就不是一个常量
constexpr size_t scale(size_t cnt) { return new_sz() * cnt; }
int arr[scale(2)]; //正确,scale(2)是常量表达式
int i = 2; //i不是常量表达式
int a2[scale(i)]; //错误,scale(i)不是常量表达式
- 如果我们用一个非常量表达式调用scale,比如int类型的i,则返回值是一个非常量表达式,当把scale放在需要常量表达式的上下文时,由编译器检查函数的结果是否符合要求,如果结果不是常量表达式,编译器将发出错误信息
- constexpr函数不一定返回常量表达式
- 根据C++ const 和 constexpr 的区别?
constexpr表示这玩意儿在编译期就可以算出来(前提是为了算出它所依赖的东西也是在编译期可以算出来的)。而const只保证了运行时不直接被修改(但这个东西仍然可能是个动态变量)。
const并未区分出编译期常量和运行期常量;constexpr限定在了编译期常量
constexpr修饰的函数,返回值不一定是编译期常量
constexpr修饰的函数,简单的来说,如果其传入的参数可以在编译时期计算出来,那么这个函数就会产生编译时期的值。但是,传入的参数如果不能在编译时期计算出来,那么constexpr修饰的函数就和普通函数一样了。不过,我们不必因此而写两个版本,所以如果函数体适用于constexpr函数的条件,可以尽量加上constexpr
6.5.3 调试帮助
- 程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用,当程序写完准备发布时,需要屏蔽掉调试代码,这种方法用到两种处理功能:
- assert
- NDEBUG
- 可以参考《C和指针》笔记 16.7.2 断言
assert预处理宏
- assert宏定义在cassert头文件中
- 预处理名字由预处理器管理而非编译器,因此直接使用预处理名字而无需using,使用assert而不是std::assert
NDEBUG预处理
- 使用#define语句定义NDEBUG关闭调试状态
- 编译时使用命令行关闭调试状态
$ CC -D NDEBUG main.c
- 也可以使用NDEBUG编写自己的调试代码
void print()
{
#ifndef NDEBUG
//调试代码
#endif
}
- 5种用于程序调试很有用的名字,可以参考《C和指针》笔记 14.1
_ _func_ _
:const char静态数组,编译器为每个函数都定义了,用于存放函数的名字_ _FILE_ _
:存放文件名的字符串字面值_ _LINE_ _
:存放当前行号的整型字面值_ _TIME_ _
:存放文件编译时间的字符串字面值_ _DATE_ _
:存放文件编译日期的字符串字面值
6.6 函数匹配
- 这里将讨论当几个重载函数的形参数量相等以及某些形参的类型可以由其他类型转换的来时的情况
void f();
void f(int);
void f(int,int);
void f(double,double);
f(5.6); //调用void f(double,double)
- 确定候选函数和可行函数
- 函数匹配分为以下几步:
- 1.选定对应的重载函数集合,称为候选函数,具备两个特点:同名;声明在调用点可见
- 2.选出能被这实参调用的函数,称为可行函数,具备两个特性:形参与实参数量相等;每个实参与对应形参类型相等,或可转换成形参的类型
- 3.从可行函数中选择最佳匹配的函数,逐一检查函数调用的实参,寻找形参与实参类型最匹配的可行函数,实参与形参越接近,匹配得越好
- 如果没有找到可行函数,编译器将报告无匹配函数的错误
含有多个形参的函数匹配
- 如果有且只有一个函数,满足下列条件,匹配成功
- 该函数每个实参的匹配都不劣于其他可行函数需要的匹配
- 至少有一个实参的匹配优于其他可行函数提供的匹配
- 如果在检查了所有实参之后没有任何一个函数脱颖而出,则调用是错误的,编译器报告二义性调用的信息,注意区别“无匹配函数的错误”
- 调用f(42,2.56)会发生什么?编译器最终因为这个调用具有二义性而拒签请求
6.6.1 实参类型转换
最佳匹配:
- 1.精确匹配,包括:
- 实参和形参类型相同
- 实参从数组或函数类型转换成对应的指针类型(6.7将介绍)
- 向函数添加顶层const或者从实参中删除顶层const
- 2.通过const转换实现的匹配(4.11.2)
- 3.通过类型提示实现的匹配(4.11.1)
- 4.通过算术类型转换(4.11.1)或指针转换(4.11.2)实现的匹配
- 5.通过类类型转换实现的匹配(14.9将介绍)
需要类型提升和算术类型转换的匹配
- 假设有两个重载函数,一个接受int、一个接受short,则只有当调用提供的是short类型的值才会选择short版本的函数,即使传入的是一个很小的整数值,它也会提升为int,此时使用short版本反而会导致类型转换
void ff(int);
void ff(short);
ff(`a`); //char提升为int,调用void ff(int)
- 所有算术类型转换的级别都一样,从int到unsigned int和int到double的级别一样
void manip(long);
void manip(float);
manip(3.14); //错误,二义性调用
函数匹配和const实参
- 如果重载函数的区别在于引用类型的形参是否引用了const,或者指针形参是否指向了const,通过实参是否是常量来决定选择哪个函数,这里指的是底层const
Record lookup(Account&);
Record lookup(const Account&);
const Account a;
Account b;
lookup(a); //调用Record lookup(const Account&)
lookup(b); //调用Record lookup(Account&)
- 指针类型的形参也和上面的例子类似
6.7 函数指针
//pf指向一个函数,该函数的参数是两个const string的引用,返回值是bool,该指针未初始化
bool (*pf)(const string &, const string &);
//注意区别,这里pf是函数名,返回值是bool*
bool *pf(const string &, const string &);
使用函数指针
- 把函数名作为一个值使用时,该函数自动地转换成指针,可以参考《C和指针》笔记 13.3
pf = lengthCompare;
pf = &lengthCompare; //这两个句子等价,取址符是可选的
//使用指针函数时,无须提前解引用指针,以下三个调用等价
bool b1 = pf("hello","goodbye");
bool b2 = (*pf)("hello","goodbye");
bool b1 = lengthCompare("hello","goodbye");
- 在指向不同函数类型的指针间不存在转换规则
- 可以为指针函数赋一个nullptr或者0的常量表达式,表示该指针没有指向任何函数
- 指针函数声明时,返回值、形参列表都应该和指向的函数一样
函数指针形参
- 虽然不能定义函数类型的形参,但是形参可以是指向函数的指针
//第2个参数为函数类型,它会自动转换成函数指针
void test(int i, bool pf(const string&, const string&));
//等价声明
void test(int i, bool (*pf)(const string&, const string&));
//调用,无须取址,函数名会自动转换成函数指针
test(1, lengthCompare);
- 类别别名和decltype能简化函数指针代码
//Func和Func2是函数类型
typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2; //等价的类型
//FuncP和FuncP2是函数指针
typedef bool (*FuncP)(const string&, const string&);
typedef decltype(lengthCompare) *Func2; //等价的类型,注意这里的*,decltype返回函数类型,此时不会将函数自动转换成指针类型
//对test的声明使用类型别名
void test(int i, Func); //自动转换成指针
void test(int i, Func2);
返回指向函数的指针
- 函数不能返回一个函数,但能返回指向函数类型的指针
- 必须把返回类型写成指针形式,编译器不会自动将函数返回类型当成对应的指针类型处理
using F = int(int*, int);
using PF = int(*)(int*, int);
//和函数类型的形参不一样,返回类型不会自动转换成指针,必须显示地返回类型为指针
PF f1(int); //正确
F f1(int); //错误
F *f1(int); //正确
//当然也可以直接声明,按照由内向外的顺序阅读这条声明
int (*f1(int))(int *,int);
//也可以使用尾置返回类型的方式
auto f1(int) -> int (*)(int*, int);
将auto和decltype用于函数指针类型
string::size_type sumLength(const string&, const string&);
string::size_type largerLength(const string&, const string&);
//将decltype用于某个函数时,它返回函数类型而非指针类型,因此需要*
decltype(sumLength) *getFcn(const string &);
小结
- 函数可以被重载,只要这些函数的形参数量或形参类型不同就行
第7章 类
7.1 定义抽象数据类型
7.1.2 定义改进的Scala_data类
- 成员函数的声明必须在类的内部
- 它的定义既可以在类内部也可以在外部,作为接口组成部分的非成员函数,如read、add、print等,它们的定义和声明都在类的外部
class Sales_data {
std::string isbn() const { return bookNo; } //定义,内联函数
Sales_data& combine(const Sales_data&); //声明
double avg_price() const; //声明
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
- 定义在类内部的函数是隐式的inline函数
引入this
- 成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象
- 当我们调用一个成员函数时,用请求该函数的对象地址初始化this,如调用
total.isbn()
//编译器把total的地址传递给isbn的隐式形参this,可以等价地认为编译器将调用重写为:
//伪代码,用于说明实际的执行过程
Sales_data::isbn(&total)
- 在成员函数内部,可以直接调用该函数的对象的成员,无须使用成员访问运算符,任何对类成员的直接访问都被看作this的隐式引用,即isbn直接使用bookNo时相当于
this->bookNo
- 任何自定义名为this的参数或变量的行为都是非法的
- this是一个常量指针
引入const成员函数
- const的作用是修改隐式this指针的类型,由默认的指向非常量的常量指针变为指向常量的常量指针
- 常量成员函数:如下:
//这里的const表示this是指向常量的常量指针
std::string isbn() const { return bookNo; }
//相当于下面这段伪代码,这里是非法的,因为我们不能显示地定义自己的this,这里只是为了表明含义
std::string Sales_data::isbn(const Sales_data *const this)
{ return this->isbn; }
- 常量对象、常量对象的引用或指针,都只能调用常量成员函数
类作用域和成员函数
- 即使成员声明在成员函数体之后也是可以被成员函数使用的
- 类的编译过程分两步处理:
- 首先编译成员的声明
- 然后编译成员函数体
- 因此成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序
在类的外部定义成员函数
//定义
double Sales_data::avg_price() const {
if (units_sold)
return revenue/units_sold; //使用隐式this来调用类的成员
else
return 0;
}
定义一个返回this对象的函数
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold += rhs.units_sold; // add the members of rhs into
revenue += rhs.revenue; // the members of ``this'' object
return *this; // return the object on which the function was called
}
7.1.3 定义类相关的非成员函数
istream &read(istream &is, Sales_data &item)
{
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item)
{
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
- IO类属于不能被拷贝的类型,只能通过引用来传递它们,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用
7.1.4 构造函数
- 类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数称为构造函数
- 只要类的对象被创建,就会执行构造函数
- 7.5、15.7、18.1.3、13章会介绍更多构造函数的知识
- 构造函数的名字和类名相同,没有返回类型,类可以包含多个构造函数(参数数量或参数类型须有区别)
- 构造函数能声明为const
- 直到构造函数完成初始化过程,对象才能真正取得“常量”属性,因此构造函数在const对象的构造过程中可以向其写值
合成的默认构造函数
- 默认构造函数无须任何实参
- 如果类没有显示定义构造函数,编译器会隐式定义一个构造函数,称为合成的默认函数
- 默认构造函数初始化成员的过程:
- 如果存在类内的初始值,用该初始值来初始化成员(即声明成员时直接用=赋值的情况)
- 否则,默认初始化(2.2.1)该成员
某些类不能依赖于合成的默认构造函数
- 必须定义它自己的默认构造函数,原因有三
- 编译器只有在发现类不包含任何构造函数的情况下才会生成一个默认的构造函数,一旦定义过一些构造函数,那么默认构造函数就需要自己定义了,否则就没有默认构造函数
- 对于某些类,合成的默认构造函数可能执行错误的操作,用户在创建类时可能会得到未定义的值
- 有的时候编译器不能为某些类合成默认构造函数,如类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该函数
定义Sales_data的构造函数
class Sales_data {
...
//新增的构造函数
Sales_data() = default;
Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) { }
Sales_data(std::istream &);
...
};
=default的含义
-
如果我们,需要默认的行为,那么可以通过在参数列表后面写上=default来要求编译器生成构造函数
-
既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部
构造函数初始值列表
//`:`到`{`之间的部分,称为构造函数初始值列表
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) { }
在类的外部定义构造函数
Sales_data::Sales_data(std::istream &is)
{
//read的作用是从is读取一条信息存入this中
read(is, *this);
}
- 没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(即在成员声明时使用=来初始化的情况)初始化,或执行默认初始化。对于本例中,首先,
units_sold
、revenue
为0,bookNo
为空string,然后,才被read()进行赋值
7.1.15 拷贝、赋值和析构
- 编译器生成相应的默认函数版本来进行成员拷贝、赋值和销毁操作
某些类不能依赖于合成的版本
- 管理动态内存的类不能依赖上述操作的合成版本
- 使用vector或string的类能避免分配和释放内存带来的复杂性
- 如果类包含vector或string成员,其拷贝、赋值和销毁的合成版本能够正常工作,vector或string对于这些操作定义好了函数
7.2 访问控制与封装
- 访问说明符:public、private
- 定义在public之后的成员在整个程序可被访问,public成员定义类的接口
- 定义在private之后的成员可以被类内的成员函数访问,但不能被使用该类的代码访问,private部分封装了类的实现细节
class Sales_data {
public:
// constructors
Sales_data() = default;
Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) { }
Sales_data(std::istream &);
// operations on Sales_data objects
std::string isbn() const { return bookNo; }
Sales_data& combine(const Sales_data&);
double avg_price() const;
private:
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
使用class或struct关键字
- 都可以用来定义类
- 区别,默认的访问权限不同:
- struct:定义在第一个访问说明符之前的成员是public
- class:定义在第一个访问说明符之前的成员是private
7.2.1 友元
- 类可以允许其他类或函数访问它的非公有成员,即令这些类或函数为自己的友元,在声明前加上friend即刻
class Sales_data {
friend Sales_data add(const Sales_data&, const Sales_data&);
friend std::ostream &print(std::ostream&, const Sales_data&);
friend std::istream &read(std::istream&, Sales_data&);
...
};
- 友元声明只能出现在类定义的内部,但在类的出现位置不限
- 友元不是类的成员,不受它所在区域访问控制级别的约束
友元的声明
- 如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明(在用户代码使用该友元函数之前再声明该友元函数)
class Sales_data {
friend void f();
};
int main()
{
//f是Sales_data的友元,如果要使用它,必须先声明
void f();
Sales_data sd;
f();
}
7.3 类的其他特性
7.3.1 类成员再探
定义一个类型成员
- 类还可以自定义某种类型在类中的别名,可以使用访问说明符来限制访问权限
- 用来定义类型的成员必须先定义后使用,与普通成员有所区别
class Screen {
public:
//也可以使用 using pos = std::string::size_type
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
令成员作为内联函数
- 定义在类内的成员函数是自动inline的
- 在类外定义的函数也能使用inline修饰
重载成员函数
- 成员函数也可以被重载
可变数据成员
- 我们希望能修改类的某个数据成员,即使是在一个const成员函数内,可以通过在变量的声明中加入mutable关键字做到这一点
- 一个可变数据成员永远不会是const
class Screen {
public:
void some_member() const;
private:
mutable size_t access_ctr; //即使在一个const对象内也能被修改
};
void some_member() const
{
++access_ctr;
}
- 任何成员函数,包括const函数在内都能改变它的值
类数据成员的初始值
- 类内初始值必须使用=初始化形式或花括号括起来的直接初始化形式,不能用圆括号
7.3.2 返回*this的成员函数
inline // we can specify inline on the definition
Screen &Screen::move(pos r, pos c)
{
pos row = r * width; // compute the row location
cursor = row + c; // move cursor to the column within that row
return *this; // return this object as an lvalue
}
inline Screen &Screen::set(char c)
{
contents[cursor] = c; // set the new value at the current cursor location
return *this; // return this object as an lvalue(左值)
}
//使用
//由于move、set返回的是引用,即左值,可以这样使用
myScreen.move(4,0).set(`#`);
//相当于
myScreen.move(4,0);
myScreen.set(`#`);
从const成员函数返回*this
//类内定义
Screen &display(std::ostream &os) const
{ do_display(os); return *this; }
//display返回常量引用,调用set将引发错误
//即使myScreen是非常量,对set的调用也无法通过编译
myScreen.display(cout).set(`*`);
- 一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用
基于const的重载
- 通过区分成员函数是否是const的,可以对其进行重载
- 只能在常量对象上调用const成员函数
- 虽然可以在非常量上调用常量版本或非常量版本,但非常量版本是更好的匹配
7.3.3 类类型
- 即使两个类的成员列表完全一致,它们也是不同的类型
- 声明类对象的例子:
Sales_data item1; //默认初始化Sales_data类型的对象
class Sales_data item1; //等价声明
struct Sales_data item1; //等价声明
类的声明
- 可以仅声明类而暂时不定义它
- 一个类,在声明之后定义之前是一个不完全类型
- 不完全类型只能用在:
- 定义指向这种类型的引用或指针
- 可以声明(但不能定义)以不完全类型作为参数或返回类型的函数
- 创建一个类的对象之前,必须被定义过,而不能仅仅被声明
- 类必须被定义,才能引用或者指针访问其成员
- 7.6(类的静态成员)将描述一种例外,直到类被定义之后数据成员才能被声明成这种类类型,即首先完成类的定义,编译器才只能存储该数据成员需要多少空间
- 一个类的成员类型不能是自己,但可以是指向自身类型的指针
7.3.4 友元再探
- 类还可以把其他的类、其他类的成员函数定义成友元
- 友元函数能定义在类的内部,这样的函数是隐式内联的
- 友元类的成员函数可以访问此类包括非公有成员在内的所有成员
- 友元关系不存在传递性,每个类负责控制自己的友元类或友元函数
class Screen {
...
friend class Window_mgr;
...
}
令成员函数作为友元
- 可以只为某个类的成员函数提供访问权限
class Screen {
...
friend void Window_mgr::clear(ScreenIndex);
...
}
- 必须如下设计程序:
- 首先定义Window_mgr类,其中声明clear函数,但不能定义它,在clear使用Screen的成员之前必须先声明Screen
- 定义Screen,包括对clear的友元声明
- 定义clear,此时才可以使用Screen成员
函数重载和友元
- 如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明;
- 若只声明了部分重载函数,那只有这部分函数能使用这个类的成员
友元声明和作用域
- 即使我们仅仅是用声明友元的类的成员调用该友元函数,该友元函数也必须是被声明过的
struct X{
frend void f(); //友元函数可以定义在类的内部
X() { f(); } //错误,f还没有被声明
void g();
void h();
}
void X::g(){ return f(); } //错误,f还没有被声明
void f();
void X::h(){ return f(); } //正确,现在f的声明在作用域中了
7.4 类的作用域
Screen::pos ht = 24, wd = 80; //可以在类外使用类定义的类型
作用域和定义在类外部的成员
- 类的外部定义成员函数必须同时提供类名和函数名
- 在类的外部,成员的名字呗隐藏起来了
- 一旦遇到了类名,定义的剩余部分就在类的作用域之内了,剩余部分包括:参数列表、函数体,它们可以直接使用类内的其他成员
- 但返回类型必须声明它是哪个类的成员
7.4.1 名字查找与类的作用域
- 类的定义分两步处理:
- 首先,编译成员的声明
- 知道类全部可见后才编译函数体
用于类成员声明的名字查找
- 以上两种阶段的处理方式只适用于成员函数中使用的名字,声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见
typedef double Money;
string bal;
class Account {
public:
//使用的Money为外层的Money类型
//返回的bal为内层Money类型的bal
Money balance() { return bal; }
private:
Money bal;
}
类型名需要特殊处理
- 一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过
- 然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能再之后重新定义该名字
typedef double Money;
string bal;
class Account {
public:
//使用的Money为外层的Money类型
//返回的bal为内层Money类型的bal
Money balance() { return bal; }
private:
typedef float Money; //错误,不能重新定义Money
Money bal;
}
成员定义中的普通块作用域的名字查找
- 成员函数中使用的名字按照如下方式解析:
- 1.首先,在成员函数内查找
- 2.如果在成员函数内没找到,则在类内查找,这时类的所有成员都可以被考虑
- 3.如果类内没有,在成员函数定义之前的作用域内查找
- 如果类的成员被成员函数的名字隐藏了,仍然可以通过
this->val_name
的方式访问类对象的成员
类作用域之后,在外围的作用域中查找
- 如果外层的对象被隐藏了,可以通过
::val_name
的方式访问
在文件中名字的出现处对其进行解析
- 当成员定义在类的外部时,名字查找的3.中,不仅要考虑类定义之前的全局作用域中的声明,还要考虑在成员函数定义之前的全部作用域的声明
7.5 构造函数再探
7.5.1 构造函数初始值列表
构造函数的初始值有时必不可少
- 有时我们可以忽略数据成员初始化(构造函数初始化列表方式)和赋值之间的差异(构造函数体内赋值的方式),但如果成员是const或引用的话,必须使用初始化的方式
- 初始化const或引用类型的数据成员唯一的机会就是通过构造函数初始值(构造函数初始化列表方式)
- 如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值
成员初始化的顺序
- 构造函数初始值列表只能用于初始化成员的值,而不限定初始化的具体执行顺序
- 成员的初始化顺序与它们定义在类中的出现顺序一致
- 不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键
class X{
int i;
int j;
public:
//未定义的,i在j之前被初始化
X(int val):j(val),i(j){}
};
- 最好令构造函数初始值的顺序与成员声明的顺序保持一致
默认实参和构造函数
- 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数
7.5.2 委托构造函数
- 一个委托构造函数使用它所属类的其他构造函数执行自己的初始化过程,或者说它把自己的一些(或全部)职责委托给了其他构造函数
- 在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身
class Sales_data {
public:
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n) { }
Sales_data(): Sales_data("",0,0);
Sales_data(const std::string &s): Sales_data(s,0,0);
//Sales_data(std::istream &)为委托者
//Sales_data()为受委托者,为默认构造函数
Sales_data(std::istream &): Sales_data() { read(is, *this); }
...
};
- 当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体首先被执行,然后控制权才会交还给委托者的函数体
7.5.3 默认构造函数的作用
class NoDefault {
public:
NoDefault(const std::string&);
//还有其他成员,但没有其他构造函数了
};
struct A {
//默认情况下my_mem是public的
Nodefault my_mem;
}
A a; //错误,不能为A合成构造函数
struct B {
B(){} //错误,b_member没有初始值
Nodefault b_member;
}
- 在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数
使用默认构造函数
Sales_data obj(); //正确,定义了一个函数而非对象,是一个不接受任何参数的函数,返回值为Sales_data
if(obj.isbn() == Primer_5th_ed.isbn()) //错误,obj是一个函数
//使用默认构造函数进行初始化的对象
//正确obj是个默认初始化的对象
Sales_data obj;
7.5.4 隐式的类类型转换
- 如果构造函数值接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,称为转换构造函数
- 14.9介绍如何将一种类类型转换为另一种类类型的转换规则
- 能通过一个实参实参调用的构造函数定义了一条从构造函数的参数类型转向类类型隐式转换的规则
string null_book = "9-999-99999-9";
//构造一个临时的Sales_data对象
//该对象的units_sold和rebenue等于0,bookNo等于null_book
//用一个string实参调用了Sales_data的combine成员,该调用是合法的,编译器用给定的string创建了一个Sales_data对象
//新生成的这个临时Sales_data对象被传递给combine
//因为combine的参数是一个常量引用,所以我们可以给该参数传递一个临时量
item.combine(null_book);
只允许一步类类型转换
//错误,这里需要用户定义的两种转换
//把"9-999-99999-9"转为string
//把string转为Sales_data
item.combine("9-999-99999-9");
//正确,显示转为string,隐式转为Sales_data
item.combine(string("9-999-99999-9"));
//正确,隐式转为string,显示转为Sales_data
item.combine(Sales_data("9-999-99999-9"));
抑制构造函数定义的隐式转换
- 在要求隐式转换的程序上下文中,可以通过将构造函数声明为explicit加以阻止
- 如果构造函数声明了explicit,那么该构造函数不能用于隐式创建它所在的类
- 关键字explicit只对一个实参的构造函数有效
- 只能在类内声明构造函数时使用explicit,在外部定义时不需要重复
explicit构造函数只能用于直接初始化
- 当我们用explicit声明构造函数时,它只能以直接初始化的形式使用
//正确,显示转换,直接初始化
Sales_data item1(null_book);
//错误,不能将explicit构造函数用于拷贝形式的初始化过程
Sales_data item1 = null_book;
为转换显示地使用构造函数
- 不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显示地强制进行转换
//正确,实参是一个显示构造的Sales_data对象
item.combine(Sales_data(null_book));
//正确,static_cast可以使用explicit的构造函数
item.combine(static_cast<Sales_data>(null_book));
7.5.5 聚合类
- 当类满足一下条件,为聚合类
- 所有成员都是public的
- 没有定义任何构造函数
- 没有类内初始值(成员声明时初始化)
- 没有基类,也没有virtual函数,将在15章介绍
- 聚合类可以使用花括号括起来的成员初始化列表来初始化数据成员,但初始值的顺序必须与声明的顺序一致
- 如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化
- 初始化列表的元素个数不能超过类的成员数量
7.5.6 字面值常量
- 数据成员都是字面值类型的聚合类是字面值常量类
- 如果一个类不是聚合类,但符合以下要求,也是字面值常量类
- 数据成员必须都是字面值类型
- 类必须至少有一个constexpr构造函数
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象
constexpr构造函数
- 尽管构造函数不能是const的,但字面值常量类的构造函数可以是constexpr
- 一个字面值常量类至少提供一个constexpr构造函数
- constexpr构造函数可以声明成=default的形式或者删除函数的形式,将在13.1.6介绍
- constexpr构造函数的函数体一般来说应该是空的
class Debug {
public:
constexpr Debug(bool b = true): hw(b), io(b), other(b) { }
constexpr Debug(bool h, bool i, bool o):
hw(h), io(i), other(o) { }
constexpr bool any() { return hw || io || other; }
constexpr bool hardware() { return hw || io; }
constexpr bool app() { return other; }
void set_io(bool b) { io = b; }
void set_hw(bool b) { hw = b; }
void set_other(bool b) { hw = b; }
private:
bool hw; // hardware errors other than IO errors
bool io; // IO errors
bool other; // other errors
};
- constexpr构造函数用于生产constexpr对象以及constexpr函数的参数或返回值
//生产一个constexpr对象
constexpr Debug io_sub(false,true,false);
7.6 类的静态成员
声明静态成员
- 在成员的声明之前加static使得其与类关联在一起,可以是public或private
- 类的静态成员存在任何对象之外,对象中不包含任何与静态数据成员有关的数据,所有类的对象共享静态成员
- 静态成员函数不包含this指针,不能声明为const,不能在static函数体内使用this指针,即不能显示或隐式地使用非静态成员
使用类的静态成员
- 使用作用域运算符世界访问静态成员
double r = Account::rate();
- 使用类的对象、引用或指针来访问静态成员
double r = ac1.rate()
或double r = ac2->rate()
- 成员函数不用通过作用域运算符就能直接使用静态成员
定义静态成员
- 可以在类的内部也可以在类的外部定义静态成员函数
- 类的外部定义静态成员时,不能重复static,只出现在类内的声明语句中
- 静态数据成员不属于类的任何对象,所以它们并不是在创建类的对象时被定义的
- 静态数据成员不由类的构造函数初始化
- 不能在类的内部初始化静态成员,必须在类的外部定义和初始化每个静态成员
//这里是类的外部
//定义并初始化一个静态成员
//从类名开始,这条语句的剩余部分就都属于类的作用域之内了,因此可以直接使用initRate()函数
double Account::interestRate = initRate();
const string Account::accountType("Savings Account");
- 把静态数据成员的定义与其他非内联函数的定义放在同一个文件中
静态成员的类内初始化
- 通常情况下,类内的成员不应该在类的内部初始化
- 然而,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr
class Account {
public:
static double rate() { return interestRate; }
static void rate(double);
private:
static constexpr int period = 30;// period是常量表达式
double daily_tbl[period];
};
//这里是类的外部
//如果在类的内部提供了一个初始值,则它的成员定义不能再指定一个初始值
//一个不带初始值的静态成员的定义
//初始值在类内的定义提供
constexpr int Account::period;
- 即使一个常量静态数据成员(1.static const;2.static constexpr)在类内被初始化了,通常情况下也应该在类的外部定义一下该成员
静态成员能用于某些场景,而普通成员不能
- 静态成员可以是不完全类型,静态成员的类型可以就是它所属的类类型
class Bar{
public:
//...
private:
static Bar mem1; //正确,静态成员可以是不完全类型
Bar *mem2; //正确,指针成员可以是不完全类型
Bar mem3; //错误,数据成员必须是完全类型
}
- 我们可以使用静态成员作为默认实参,非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分
class Screen{
public:
Screen& clear(char = bkground);
private:
static const char bkground;
}