C++17入门经典
注意
- 任何数字,只要其分数部分是奇数,就不能准确地表示为二进制浮点数。
第1章 基本概念
- C++适合写哪些程序?
答:C++几乎可以写任何程序,最适合编写对性能有较高要求的应用程序,如大数据处理、大型游戏、嵌入式或移动设备上的程序等。 - 什么是现代C++编程?
答:使用C++11及更新版本的功能进行编程称为“现代C++”编程 - C++标准版本的发布间隔?
答:3年 - 源文件和头文件及其扩展名
答:源文件包含函数和全部可执行代码。.cpp、.cc、.cxx、.c++
头文件包含源文件中的可执行代码使用的函数原型,以及使用的类和模板的定义。.h、.hpp - 如何注释单行或多行内容?
答:单行// 多行/*…*/ - 预处理指令及其作用,如何包含头文件?
答:以某种方式修改源代码,之后把它们编译为可执行的形式。#include <iostream>
把iostream标准库文件的内容添加到其所在文件中的#include
位置 - 什么是函数?main函数的特点?什么是函数头?
答:1)函数为一个命名了的代码块,执行定义好的操作;2)程序的执行从main函数开始;3)函数由函数头和花括号中的可执行代码2部分组成,函数头用于标识函数,如int greater(intn a, int b)
定义了main函数的返回类型、函数名、输入参数。 - 变量
答:变量是一个命名了的内存块,用于存储某种数据。 - 语句的地位、语句块、复合语句,什么是嵌套?
答:1)语句是C++的基本单元,总以分号结束;2)花括号中的多个语句就是语句块,又称复合语句;3)将一个语句块包含在另一个语句块内的概念叫作嵌套。 - 如何执行数据的输入输出?
答:使用“流”来执行输入输出,流是数据源或数据接收器的一种抽象表示。 - return语句的作用,不同返回值的含义
答:return用于结束函数,把控制权返回给调用函数的地方。return语句可能返回一个值,也可能没有返回值。只有main()函数中,忽略return语句才相当于返回0。 - 名称空间的作用
答:用于解决名称定义的混乱 - 名称的定义规则、保留字的特征
答:1)可用字母、数字、下划线;必须以下划线或字母开头;区分大小写。2)关键字属于保留字,许多保留字以下划线开关,因此避免使用下划线开头的名称定义。
第2章 基本数据类型
-
变量初始化的3种形式:初始化列表、函数表示法、赋值表示法,建议用哪一种,原因
答:
初始化列表初始化:int apple_count {15};
函数表示法:int orange_count (5);
赋值表示法:int total_fruit = apple_count + orange_count;
建议使用初始化列表进行初始化,可更好避免缩窄转换(float ⇒ \Rightarrow ⇒ int)。 -
为什么最好在单个语句中定义每个变量?
答:指不用int a{1}, b{2}, {3};
形式。因为在单个语句中定义每个变量可提高代码的可读性。 -
什么是字面量?说明各种字面量的类型
答:指各种类型的常量。每个字面量都有特定的类型。常量 字面量 类型(const) 语句 -123 整型字面量 int int value{-123};
123’456’789LL 整型字面量 long long long distance {123'456'789LL};
123u 整型字面量 unsighed short unsighed short price {123u};
2.3245 浮点型字面量 float float factor {2.3245};
‘Z’ 字符字面量 char char alph {'Z'};
“number” 字符串字面量 char [] char name[] {"Mae West"};
ture 布尔字面量 bool -
为什么建议仅在有充足理由时使用using指令?
答:使用using指令声明名称空间后,using std::cout;
,就不需要使用名称空间限定名称了,这种用法过多会增加名称冲突的可能性。 -
sizeof运算符及其结果类型
答:可对类型、变量、表达式使用,得到其所占用的字节数,结果类型为size_t;
size_t不是内置的基本类型名称,而是标准库定义的一个类型别名;
size_t是一个不带符号的整数,可存储任何类型理论上可能存在的对象的最大大小,常用于数组索引和循环计数。 -
递增递减运算符的前后缀形式的区别
答:前缀先计算值再计算表达式,后缀先计算表达式后计算值 -
浮点类型什么时候用double,什么时候用float?
答:大多数情况下,使用double类型就足够了,只有速度或数据大小非常关键时,才会使用float。 -
数学函数头文件
答:cmath头文件定义了许多三角函数和数值函数,所有函数名都在std名称空间中定义。函数的结果总是与浮点型参数的类型相同,整型参数的结果为double类型。 -
输出流格式化头文件(2个)及其区别
答:iostream头文件,无参数
iomanip头文件,有参数 -
什么是显式类型转换,为什么需要它?如何实现?
答:把表达式的值显式转换为给定类型static_cast<type_to_convert_to> (表达式)
,显式强制转换以得到希望的结果类型。static_cast表示进行该强制转换要进行静态检查。 -
如何确定数值的上下限?
答:std::numeric_limits<double>::max()
、std::numeric_limits<double>::min()
-
字符变量(char类型)是否可参与算术表达式?
答:可以,因为char类型的变量是数值,它们存储了表示字符的整数代码,所以它们可以参与算术表达式。 -
auto关键字的使用场景,是否推荐用于定义基本类型变量?为什么?
答:用于推断类型,建议只用于推断很长的自定义类型、长的指针等,定义基本类型的变量应显式指定类型。
第3章 处理基本数据类型
-
运算符的执行顺序和什么有关?
答:执行顺序由运算符的优先权决定。 -
枚举类型的定义及使用枚举类型定义变量的语句实现
答:enum class Day {Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday};
Day today {Day::Tuesday};
要输出today的值,就必须先把它转换为数值类型,因为标准输出流不能识别自定义类型std::cout << "Today is " << static_cast<int> (today) << std::endl;}
-
新旧枚举类型定义方法的不同及新方法的优势
答:旧语法enum Day {Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday};
C++11后新语法enum class Day {Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday};
新语法在定义时加入class关键字,使得枚举成员在转换为整型甚至浮点类型时,会进行强制转换,更不容易出错。 -
枚举类型成员的默认值及成员显式赋值
答:1)第一个枚举成员的值默认为0,其后的成员依次加1;
2)枚举成员不一定有唯一值,且不一定必须以升序进行赋值;
可以根据以前的枚举成员定义新的枚举成员值。enum class Day {Sunday=7, Monday=1, Tuesday=Monday, Wednesday, Thursday, Friday, Saturday};
-
枚举成员值类型的要求
答:枚举成员的值必须是编译期间的常量,即编译器可以计算出来的常量表达式,只包括字面量、以前定义的枚举成员、声明为const的变量;
枚举成员可以是包含默认类型int在内的任何整数类型。 -
枚举成员的类型规范
答:枚举成员的类型规范放在枚举类型名的后面,用冒号隔开enum class Punctuation : char {Comma = ',', Exclamtion = '!', Question = '?'};
-
数据类型的别名的新旧指定方式及新方法对比旧方法的优势
答:旧方式typedef unsigned long long BigOnes;
新方式using BigOnes = unsigned long long;
新方式更加直观,可读性也更好,但写出具体类型能够让代码更容易理解,因此应该有节制的使用类型别名。 -
变量的生存期(4种)
答:变量生存多长时间取决于其存储持续时间。生成方式 存储持续时间 称谓 解释 在代码块中声明的非静态变量 动态的存储持续时间 自动变量、局部变量 从声明它的那一刻开始,到包含其声明的代码块的结尾结束,具有局部作用域或块作用域 使用static关键字定义的变量 静态的存储持续时间 静态变量 从定义的那一刻开始,到程序结束时消失 在运行期间分配内存的变量 动态的存储持续时间 — 从创建它们那一刻开始,到释放其内存、销毁它们时消失 使用thread_local关键字声明的变量 线程存储持续时间 — — -
全局变量的特点及其访问方式、优缺点
答:全局变量在所有代码块和类外部定义,具有 全局(名称空间)作用域;
在默认情况下具有静态的存储持续时间;
初始化在main()之前进行,若没有初始化,则默认初始化为0(自动变量在没有初始化时包含的是垃圾值)
要访问全局变量value,必须使用作用域解析运算符::限定它std::cout << "Global value = " << ::value << std::endl;
全局变量使用过多会占用较多内存,且会大大增加修改变量时出错的可能性,因此原则要求避免使用全局变量,但是很适合用于定义全局常量,即用const声明的全局变量。
第4章 决策
-
布尔类型
答:条件为 bool 类型,bool 值只有 true 和 false,它们是 bool 类型的字面量,布尔字面量;
bool 类型若使用空{}来初始化,初始值为false;
在条件语句中使用关系运动符和逻辑运算符(&&, ||, !)可完成各种复杂的条件;
某类型中只有0的对等值会被转换为布尔值false -
bool值的输出
答:bool默认显示为0或1,可以使用std::boolalpha将其显示为true或falsestd::cout << std::boolalpha; std::cout << (5>3) <<std::endl;
-
if语句及其嵌套
答:if (condition){ } else{ }
-
条件运算符
答:若条件为真,则c=表达式a,否则c=表达式bc = 条件? 表达式a : 表达式b;
-
switch语句及其类型要求
答: switch的选择表达式应为整数表达式,即整型、字符型、枚举类型等;
case值必须唯一,但不必按顺序;
选择表达式对应哪个标签值,就执行哪个case后的语句;
若选择表达式不对应任一标签值,就执行default标签后的语句;
一般每个case后的break语句都是必需的;
default标签后的break语句不是必需的,但加上它是个好习惯。switch (选择表达式){ case 标签1: ... break; case 标签2: ... break; case 标签n: ... break; default: ... break; }
-
switch语句的“贯穿”现象
答:当移除了某个case后的break语句时,它下面的case标签的代码也会运行,这叫作贯穿;
C++17为故意使用贯穿添加了新的语言功能,将原本的break语句替换为[[fallthrough]];
-
switch中case标签后的多条语句什么时候需要加花括号
答:1)case后的多条语句一般不需要加花括号;
2)因为花括号内的代码构成代码块,是自动变量或局部变量的生存范围。因此当需要使用局部变量时,就需要加上花括号。见下一条。 -
switch语句块中各个位置的变量的作用域
答:switch语句内的变量是自动变量;
其中自动变量的定义要能保证在正常执行过程中可以被访问;
因为是自动变量,所以从它的定义到整个switch语句的结束都是它的作用域,不能绕过变量的定义而进入其作用域,因此,case中若要进行变量的定义,就要加花括号,形成这个case自己的语句块;
对于放在最后的case,比如default,由于其后没有其他case,所以肯定不能绕过,因此可以进行变量的定义而不加花括号。 -
C++17中,为if语句和switch语句添加了初始化语句,以将变量限制到if或switch语句块:
if(initialization; condition) {...} switch(initalization; condition) {...}
第5章 数组和循环
- 数组
答:数组可以存储相同任意类型的多个数据项;
数组元素占用的内存量由数组类型决定;
数组的所有元素都存储在一个连续的内存块中;
数组的大小必须用常量表达式来指定;
初始化列表的个数不能超过数组的元素个数;
初始化列表中未指定值的元素默认初始化为0;
编译器不会检查数组索引值是否有效,程序员应自己确保引用的元素不会超出数组边界。
usigned int height[6] {24,34,73};
- 幻数,如何避免,为什么要避免
答:幻数是裸字面常量,由于没有名称,无法看出它的含意。
使用幻数会导致代码可读性差,因此,应该定义幻数或任何常量且仅定义一次。 - for循环
答:控制for循环的任一表达式或所有表达式都可以省略,但分号必须有。
for(初始化;条件;迭代) {循环体}
- 使用std::size(array)来确定数组的大小,控制for循环的迭代次数
答:std::size()可用来获得标准库定义的任何元素集合的大小,该函数头文件为<array>
。注意该函数为C++17引入,所以需设置C++标准
int value[] {1,2,3,1,2,34,23,4,5,234,23,22};
for (size_t i {}; i<std::size(value); ++i) {}
- 基于范围的for循环,无符号整数控制for循环
答:什么是范围:标准库提供的容器都是范围
for (range_declaration : range_expression)
loop statement or block;
range_expression为数据源的范围,range_declaration标识一个变量,它会被依次赋予范围中的每个值,在每次迭代都会赋予一个新值。 - while循环,do-while循环
答:while(条件) { }
do{ }while(条件);
- continue,break的使用
答:continue; 立即跳到当前迭代的末尾,直接开始下一次迭代;
break; 立即终止循环,开始执行循环后面的语句。
应该谨慎使用break语句;
应尽可能将决定循环何时结束的条件放到for或while语句的圆括号内。 - 字符数组:char类型数组中,存储字符的数组与存储字符串的数组的区别
答:char类型的数组有两种形式:<1>作为一个字符数组,每个元素存储一个字符;
<2>作为一个字符串,每个字符存储在一个数组元素中,结尾以 空字符’\0’ 表示。
char类型中0的对等值为’\0’,故若以少于数组元素个数的字符初始化数组,数组就会包含一个字符串;
该数组有9个元素;char name[] {"Mae West"};
对于以空字符'\0'
结尾的char类型数组,由于'\0'
可以代替数组长度隐式限制数组大小,因此可以使用数组名输出数组内容,其他类型不可以。std::cout << name << std::endl;
- 如何读取输入的包含空格的字符串
答:#include <iostream> const int max_length {100}; char text[max_length] {}; std::cin.getline(text, max_length, '所指定的标志输入结束的字符(默认为'\n')');
- 多维数组
答:多维数组最右边的索引值总是变化最快,最左边的索引值则变化最慢;
编译器无法推断第一个维度之外的其他维度,必须总是显式指定除第一个维度外的全部数组维度;
花括号的嵌套次数就是数组的维数。
严格来说,C++中没有多维数组,所谓的多维数组其实是数组的数组double carrots[3][4] { //定义了一个大小为3的数组,该数组的每个元素都是含有4个double类型数值的数组,数组名与起始地址为carrots {2.5, 3.2 }, {4.3 }, {5.3, 2.5, 6.4 } }; int ia[3][4] = {0,1,2,3,4,5,6,7,8,9,10,11}; //内部每行的花括号是可选的
- 多维字符数组的定义
答:char类型的二维数组可以存储一组C样式的字符串;
使用字符串字面量初始化char类型的二维数组时,界定字符串的双引号就已经完成了花括号的工作。char stars[][80] { "Robert Redford", "Hopalong Cassidy", "Lassie", };
- std::array<T,N>容器,头文件,定义,初始值设置,访问,
#include <array> std::array<double, 100> values; //std::array<double, 100> values {0.3, 0.45, 0.23, 0.5}; values.fill(0.5); double total {}; for (size_t i {}; i<= values.size(); ++i) { total += values.at(i); //使用at()访问array<>对象的元素,会自动检查索引值的有效性 } value[4] = value[3] + 2.0*values[2]; //使用[]不会进行检查
- std::vector容器,头文件,定义,初始值,添加元素,
#include <vector> std::vector<double> values; values.push_back(0.5); std::vector<double> values1 (20); //20个元素被初始化为0 std::vector<long> values2 (20, 99L); //20个元素被初始化为99L std::vector<unsigned int> values3 {1, 2, 3, 4, 5}; //使用初始化列表进行初始化
第6章 指针和引用
-
语句long* pnumber {}; 中,变量pnumber的类型写作long* ,读作___ ?
答:指向long -
对应于0的空指针为___ ?
答:nullptr -
定义指针时,是否需要初始化
答:不是必须的,但总要初始化,至少将其初始化为nullptr -
地址运算符,间接运算符、解引用运算符
答:地址运算符&,获取变量的地址
间接运算符(解引用运算符)*,访问指针所指向的内存位置的数据 -
指向char类型的指针的特殊初始化方式
答:用字符串字面量初始化const char* pproverb {"A miss is as good as a mile."};
-
指针数组相对于字符数组为什么占用的内存空间更小了
答:每个字符串都占用容纳其所有字符所必需的字节数,而不是统一的长度;
在字符串长度差异大或字符串较多时,用于存储字符串地址的内存相比上一条节省的内存是相当可观的const char* pstars[] { "Robert Redford", "Hopalong Cassidy", "Lassie", };
-
常量指针、指向常量的指针、指向常量的常量指针
答:类型 注释 示例 常量指针(*const) 指针变量中的地址不能修改 int data {20}; int* const pdata {&data};
(const修饰指针,指针为常量,指向int)指向常量的指针 (const int) 指针指向的内容不能修改 const int value {20}; const int* pvalue {&value};
(指针指向const int)指向常量的常量指针 指针变量中的地址与指针指向的内容都不能修改 const float value {3.14}; const float* const pvalue {&value};
-
void* 指针
答:void* 是一种特殊的指针类型,可用于存放任意对象的地址。
用途:和其它类型的指针比较、作为函数的输入或输出、赋值给另一个void指针
不能直接操作void指针所指的对象,因为不知道所指对象的类型,只知道其起始地址 -
动态内存分配
答:在运行期间分配存储数据所需的内存空间(不是在编译程序时分配预定义的内存空间) -
自动变量、栈、堆、自由存储区
答:自动变量 在执行其定义时创建的变量
栈 在内存区域给自动变量分配的空间
自由存储区 操作系统和当前加载的其他程度未占用的内存
堆 一些人认为,C++中new和delete运算符操作的是自由存储区,C的内存管理函数操作的是堆 -
new、delete
答://分配内存 1) double* pvalue {}; pvalue = new double; //new 返回新分配内存的地址 2) //double* pvalue {new double {3.14} }; 3) double* data {new double[100] {} }; //对数组进行动态内存分配时,编译器无法推断数组的维数,因此应显式指定数组的大小 //释放内存 delete pvalue; pvalue = nullptr; //释放内存后,原指针成为悬挂指针,应重新设置该指针或置为空指针 delete[] data; //释放数组内存,要加上[],且这里不能填入维数 data = nullptr;
-
为什么可以用数组名初始化指针
答:数组名相当于一个在其定义时就固定了的地址double values[] {12.234, 28.243, 32.3, 4.54, 545, 6.445}; double* pvalue {values}; *(pvalue +1) = *(pvalue +2);
-
数组名的指针表示法
答:可以这样做的原因是编译器自动地将数组名替换为一个指向数组首元素的指针double data[] {}; for (size_t i {}; i< std::size(data); ++i) { *(data + i) = 2 * (i+1); }
-
指针数组与数组指针
答:指针数组,元素均为指针类型数据的数组。
int *p[4];
[]
比*
优先级更高,说明p
为一个数组,其中元素为int*
(指向int)类型
数组指针,指向数组的一个指针。
int (*p)[4]; //n是一行里有几个元素,也就是列数
()
优先级高,首先说明p
是一个指针,指向一个整型的一维数组,这个一维数组的长度是n
,也可以说是p
的步长。也就是说执行p+1
时,p
要跨过n
列的长度。 -
多维数组的指针
答:单从数组的指针的定义是看不出其所指向的数组的大小的,无论是指向一维还是多维数组
由于多维数组其实是数组的数组,所以多维数组指针所指向的是内层数组的起始地址int ia[3][4] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; int (*p)[4] = ia; std::cout << p <<std::endl; //输出数组ia起始地址, 即ia[0][0]地址 std::cout << *p <<std::endl; //输出数组ia[0]起始地址, 即ia[0][0]地址 std::cout << *(p+1) <<std::endl; //输出数组ia[1]起始地址, 即ia[1][0]地址 std::cout << *(*(p+1) +2) <<std::endl; //输出数组ia[1][2]的内容
-
多维数组的动态内存分配
答:标准C++不支持有多个动态维数的多维数组,最多能让第一维的大小是动态的;(从多维数组指针的定义也能看出)size_t rows {3}; //double (*carrots)[4] {new double [rows][4] {} }; auto carrots {new double[rows][4] {} }; //C++11之后,可使用auto来代替上一句的写法 ... delete[] carrots; carrots = nullptr;
-
间接成员运算符(箭头运算符)
答:通过类对象的指针访问其成员时,需要先对其解引用,再使用成员访问运算符.
,这一过程可用间接成员运算符->
代替
std::vector<int> data;
auto* pdata = &data;
//(*pdata).push_back(66);
pdata->push_back(66);
-
内存泄漏
答:由于改写了指针中保存的地址,丢失已分配的自由存储区内存地址,而出现的问题 -
unique_ptr<T>指针,定义,重置
答:唯一地存储地址,独占拥有其所指向的值。
常用于保存多态指针(指向动态分配对象的指针,允许动态分配的对象是任意数量的相关类类型)std::unique_ptr<double> pdata { std::make_unique<double>(999.0) };//make_unique<T>为C++14引入 auto pdata { std::make_unique<double>(999.0) }; const size_t n {100}; auto pvalues { std::make_unique<double[]> (n) }; pvalues.reset(); //reset()可把任意类型的智能指针重置为指向nullptr.
-
shared_ptr<T>指针,定义,初始化,赋值
答:可以有任意多个shared_ptr<T>对象包含或共享相同的地址,共享自由存储区中对象的所有权;
包含给定地址的shared_ptr<T>对象的个数,称为引用计数;
引用计数自动维护,当为0时,释放占用的内存空间std::shared_ptr<double> pdata { std::make_shared<double>(999.0) }; auto pdata { std::make_shared<double>(999.0) }; pdata.reset();
-
vector容器、智能指针、数组的使用推荐
答:优先使用vector,然后是智能指针,最后为数组 -
语句double& rdata {data}; 中,变量rdata的类型写作double&,读作___ ?
答:对double的引用 -
为什么在基于范围的for循环中使用引用会很高效?
答:使用引用避免了对象复制成本
第7章 操作字符串
- cstring头文件中提供了许多用于处理C样式字符串的函数,这种处理C样式字符串的方式有什么风险
答:cstring头文件中用来处理C样式字符串的函数,它们的操作取决于标记字符串末尾的空字符,若空字符被省略或覆盖,就会操作字符串尾部后面的内存,直到遇到空字符串或出现故障,会导致内存被随意覆盖 - 定义string对象的6种方式
std::string empty; //empty为一个不包含字符的字符串 std::string proverb {"Many a mickle makes a muckle."}; //以字符串字面量定义 std::string part_literal {"Least said soonest mended.", 5}; //以字符串字面量的前5个字符定义 std::string sleeping (6, 'z'); //以6个重复字符z来定义 std::string sentence {proverb}; //以已有的string对象包含的字符串字面量来初始化 std::string phrase {proverb, 0, 13}; //以string对象从索引0开始的13个字符来初始化
- 加号“+”连接string对象与字符串、string对象与字符的机制
- 连接 字符串1、字符串2、string对象 的解决办法
- 连接string对象与数字的方法
- 访问字符串中字符的方法
- 获取string对象的子字符串
- 可用于比较字符串的比较运算符及其比较算法
- string对象的compare()函数的用法
- 使用substr()函数进行比较
- p162
第8章 定义函数
- main()必须在哪一个名称空间中定义
答:全局名称空间 - 为什么要跟踪程序在内存的哪个地方进行了函数调用,这些信息保存在哪里
答:1)可能同时有若干个函数同时执行;2)保存在栈中,这里常称为调用栈,调用栈中记录了所有函数调用的信息以及传送给每个函数的数据的详细信息 - 函数由函数头和函数体组成,函数头包括什么,什么是函数签名
答:函数头包括返回类型、函数名、参数列表;函数名和参数列表的组合称为函数签名 - 函数体中定义的变量都是局部变量,该规则的例外是什么变量
答:定义为static的变量 - 非void返回类型的函数必须返回一个值(存有return语句),该规则的例外是什么函数
答:main()函数,对于main()函数,执行到右花括号相当于返回0 - return语句所返回的变量为一个自动变量,那么它的值是如何返回的呢
答:系统自动复制返回值的一个副本,该副本对调用函数来说是可用的。 - 返回类型为void时,return语句怎么写
答:写为return;
,或不写return语句。 - 什么是函数原型、什么是函数声明
答:函数原型定义了函数的返回类型、函数名及其参数列表(参数列表内可以只写各参数的类型,但最好包含有描述性的参数名),能够充分描述函数,可以让编译器编译对该函数的调用。
函数原型有时被称为函数声明,函数的定义也是函数声明。 - 解释按值传送机制
答:在调用函数时,编译器会创建实参的副本,并将其存储在调用栈的一个临时位置,在执行代码时,代码中对函数参数的所有引用都被映射到实参的这些临时副本上,执行完函数后,就废弃实参的副本。对于有返回值的函数,值返回的机制如前所述。 - 按值传送机制下,传送指针、数组的方法
答:按值传送指针:
传送的其实是一个地址,在函数的内部对指针解引用后再操作,可完成对指针指向的值的修改,因此按值传送指针有时无需返回值。
指针可以为nullptr,所以在使用指针参数前,要先测试它是否为nullptr。
按值传送数组:
数组实际上不能作为一个实参传送,而是使用数组名将数组的地址传送给函数
因为数组参数只是数组的地址,所以不能在函数内使用sizeof运算符或std::size()函数来避免指定count参数;
编译器不会检查数组实参的实际维度(第一维长度),若参数对其指定,如(double array[10])
,在实际传入的数组长度小于10时,程序会读取超过数组边界的值,因此也不能在数组参数中指定维数和索引大小。//指针 double changeIt(double* pointer_to_it); double it {5.0}; double result {changeIt(&it)}; //数组 double average(double array[], size_t count); //使用数组表示法 double average(double* array, size_t count); //使用指针表示法。编译器认为这两个函数原型完全相同 double values[] {0.2, 1.2, 2.45, 3.78, 4.1, 5.3}; std::cout << "Average = " << average(values, std::size(values) ) << std::endl;
- 按值传送机制下,如何确保函数不会修改数组元素
答:使用const声明参数 - 按值传送机制下,如何传送多维数组,参数列表中数组维数如何设置
答:如前所述,编译器会忽略指定的维度,但第二个维度大小可能得到期望的效果(因为内层维度是外层数组的元素);
在函数的内部实现中,数组表示法比指针表示法更清楚。数组表示array[i][j]
,指针表示*(*(array+i)+j)
double yield(const double values[][4], size_t n); double beans[3][4]{ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0 }; std::cout << "Yield = " << yield(beans, std::size(beans)) << std::endl;
- 解释按引用传送机制
答:将参数类型指定为引用,在调用函数时,对应于引用参数的实参不会复制;
编译器编译引用的方式与编译指针的方式相同;
在使用时不需要地址和解引用运算符,不需要担心nullptr - 函数参数是否为const,对实参的影响
答:对于函数不会修改的实参,总应该将其声明为const。
如,对于一个函数调用do_it(it);
,不看其定义或声明就不知道函数实参是按值传送还是按引用传送,也不知道是否会修改it的值。 - 什么是输入-输出参数,如何看待输入-输出参数
答:同时作为输入和输出的参数;最好避免使用这种参数,使每个参数只负责一个用途会使代码更易读 - 输入、输出参数的建议传送方式
答:输入参数通常应该是对const的引用,只有比较小的值(主要是基本类型的值)应该按值传送,对于要修改的参数,使用指向非const值的指针;
只对输出参数使用对非const值的引用,且对于要输出的值,应优先考虑使用返回值。 - 如何按引用传送数组
double average10(const double (&array)[10]) { //按引用传送数组可以指定数组的第一维大小 double sum {}; for (size_t i {}; i < 10; ++i) { sum += array[i]; return sum/10; } double values[] {1.0, 2.0, 3.0}; std::cout << "Average = " << average10(values) << std::endl; //此时编译器会检测传入数组的长度,传入的数组与需要的数组长度不同时会报错
- 参数与实参类型不同时,按引用传送机制是如何进行隐式转换的?哪种隐式转换是合法的(对参数类型的要求)
答:首先,按值传送的隐式转换并没有特殊之处;
对于按引用传送(以下面代码为例),
对于引用const值的参数:编译器在调用print_it()
之前,会在内存的某个位置隐式地创建一个临时的double,以存储转换后的int值,然后把临时内存位置的引用传送给print_it()
。
对于引用非const值的参数:不支持上述形式的隐式转换。(若也进行上述转换,则执行完double_it(i);
后,会存在1个值为246.0的临时double变量和值仍为123的int变量i
,在继续的操作要将double类型的246.0转换到int赋给变量i
,而这是不允许的。)void double_it(double& it) { it *= 2; } void print_it(const double& it) { std::cout << it << std::endl; } int i {123}; //double_it(i); /*error, does not compile! */ print_it(i);
- 按引用传送机制下,字符串字面量实参到string引用的形参是如何进行隐式转换的(对const std::string&类型的形参输入字符串字面量实参)
答:字符串字面量(类型为const char[]) -> 使用字符串字面量的一个副本实例化一个临时std::string对象。
可见其中过程存在字符串实参的复制,即使用const std::string&
类型传递参数无法完全避免函数不复制输入的字符串参数 - 如何创建不会复制输入字符串实参的函数
答:为输入参数使用std::string_view
类型(C++17标准库新增的string_view头文件中定义的一个类型)。
该类型只需指向某个实际的std::string对象、字符串字面量或其它字符数组中存储的任何字符序列即可,任何时候都不能通过它们的公共接口修改它们封装的字符串。因此复制该类型的成本很低,直接使用按值传送std::string_view即可,无需使用const std::string_view&传递参数。 - 默认实参值
答:void show_error(string_view message = "Program Error");
若创建了函数原型和函数定义,则在函数原型中指定默认实参;
对于引用非const值的参数,若对默认值进行隐式转换需要创建临时对象,则该默认值是非法的;
多个有默认值的参数必须一直放在参数列表最后,越可能省略的越往后放。 - main()函数的参数argc、argv
答:可以将main()函数定义为在运行程序时接收从命令行输入的实参,有参数的main()函数定义如下:
int main (int argc, char* argv[]) { }
第一个参数argc是从命令行输入的字符串实参的个数;
第二个参数argv是一个指针数组,指向命令行实参,包括程序名,数组类型表示的所有命令行实参都接收为C样式的字符串。调用程序时使用的程序名记录在argv[0]中,argv[argc]总是nullptr,即argv中元素个数为argc+1。 - 从函数中返回指针的注意事项
答:必须包含nullptr,或者调用函数中仍旧有效的地址,即在指针返回到调用函数时,指针指向的变量仍在其作用域中。 - 从函数中返回引用的注意事项
答:不能从函数中返回自动局部变量的引用 - 返回值与输出参数的使用场合
答:一般情况首选返回值,对于数组或包含数组的对象使用输出参数 - 如何使用auto推断一个引用返回类型
答:使用 auto& 或 const auto& - std::optional<T> re
答:C++17引入std::optional,提供了一个更好的解决方案来处理可能存在或可能不存在的值。它要么包含一个有效的值,要么不包含任何值。当 std::optional 对象没有值时,它处于一种特定的无效状态。- 允许函数返回一个可能的值或者没有值。函数可以通过返回 std::optional 对象来明确表示可能不存在返回值的情况。
- 允许变量声明为可能存在或可能不存在的值。这样可以避免使用指针或者特殊值(如 null)来表示缺失的情况,从而提高代码的可读性和安全性。
- 提供了一系列的成员函数来操作 std::optional 对象,例如 使用
re.has_value()
判断是否有值,使用operator*()运算符*re
或re.value()
获取值,使用re.value_or(default)
获取对象的值或默认值default等,此外,还定义了operator->()
运算符,通常联 std::nullopt
是一个字面值常量,用来表示 std::optional 对象的无值状态。
- 静态变量的使用
答:静态变量只在第一次调用时,创建和初始化一次,以后就不再执行该语句 - 内联函数
答:使用inline进行声明的函数,该关键字在编译时会建议编译器使用内联代码代替函数调用,内联函数通常放在头文件中。内联函数适用于频繁调用的简短函数、类的成员函数和头文件中的模板函数。通过合理地使用内联函数,可以在适当的情况下提高程序的性能,减少函数调用开销。 - 函数重载
答:一组函数名相同的函数,以参数列表的区别来区分。且对于默认实参值,必须确保所有函数调用都可以唯一地标识应调用的函数。void do_it(std::string number); void do_it(std::string& number); //type与type&,不能区分 //----------------------------------------- long larger(long a, long b); //对于按值引用,函数是不会改变实参值的,因此编译器会忽略按值引用中基本类型的const声明 long larger(const long a, const long b); //因此,对于基本类型,有无const,不能区分 //----------------------------------------- long* larger(long* a, long* b); long* larger( long* const a, long* const b); //同上,指针变量中所存储的地址也不会改变,因此,指针是否声明为const,无法区分 //----------------------------------------- int largest(int* pvalues, size_t count); int largest(float* pvalues, size_t count); //指向不同类型的指针,可区分 //---------------------------------------- long* larger( long* a, long* b); const long* larger( const long* a, const long* b); //对指向的值加const,可禁止修改该地址中的值。指向的值是否为const,可区分。 //---------------------------------------- long& larger(long& a, long& b); //引用是对一个确定的变量的别名,不可更改,这一层面上相当于已经是常量,因此不能在&后加const long larger(const long& a, const long& b); //但同上一条一样,T& 和 constT& 是不同的,可区分。
- 相互递归函数的实现
第9章 函数模板
-
函数模板
答:函数模板是函数的参数化定义,从函数模板中生成的函数定义称为模板的一个实例或模板的实例化。函数模板的参数通常是数据类型,作为类型的占位符,但不一定是类型,也可以是其他内容,如维度。template <typename T> //关键字template将这段代码标识为模板;typename将其后的参数列表(本例只有一个T)中的模板参数标识为类型, T larger(T a, T b){ //T为模板类型参数,作为类型的占位符,可用在具体类型的任何上下文中(函数签名、返回类型和函数体的任何位置) return a>b ? a : b; } std::cout<<larger(1.5, 2.5)<<std::endl;
-
创建函数模板的实例
答:编译器从函数调用的实参中推断用以替代T的类型(模板实参推断),之后编译器会搜索相关类型的现有定义,若没有找到,则创建该实例,每个模板实例只生成一次。 -
如何显式地指定模板实参,以显式地实例化模板
答: 如下。编译器会使用double类型的函数实例,并将实参20隐式转换为double类型larger<double>(20, 19.6);
-
函数模板重载
答:为特定情况定义重载,可在编译器使用时优先于模板实例,每个重载的函数也都必须有唯一的签名。 -
【①以特例重载】函数模板的特例
答:对于某个(组)特定参数值,模板的特例定义了不同于标准模板的行为;
特例的定义必须放在原始模板的声明或定义之后;
特例的定义以关键字template开头,但要省略参数;
必须定义特例的实参类型,并放在模板函数名后面的尖括号中。template <> int* larger<int*> (int* a, int* b) { return *a > *b ? a : b; }
-
【②以函数重载】此处使用函数定义,而没有用特例定义
int* larger(int* a, int* b){ return *a > *b ? a : b; }
-
【③以模板重载原有的模板】
// 为数组定义一个模板 template <typename T> T larger(const T data[], size_t count){ T result {data[0]}; for (size_t i {1}; i < count; ++i) if (data[i] > result) result = data[i]; return result; } // 为向量定义一个模板 template<typename T> T larger(const std::vector<T>& data) { T result {data[0]}; for (auto& value : data) if (value > result) result = value; return result; } // 为指针定义一个模板 template<typename T> T* larger(T* a, T* b) { return *a > *b ? a : b; }
-
有多个参数的函数模板
答:模板具有多个参数时,若在调用模板时有省略模板类型参数的需要时,显式指定模板实参时的顺序要和参数列表中的一致。template <typename TReturn, typename TArg1, typename TArg2> TReturn larger(TArg1 a, TArg2 b){ return a > b ? a : b; } larger<size_t, double>(1.5, 2); //将返回类型指定为size_t,第二个类型TArg1指定为double
-
模板的返回类型推断
答:
(1) 使用auto关键字(C++14中引入)template <typename T1, typename T2> auto larger(T1 a, T2 b){ return a > b ? a : b; }
(2) 使用decltype的拖尾返回类型语法
template <typename T1, typename T2> //decltype(expression)可得到expression计算结果的类型,decltype并不会实际计算expression //decltype(a > b ? a : b) larger(T1 a, T2 b){ //编译不会通过。因为编译器是从左往右处理模板,因此当decltype处理返回类型时,编译器还不知道a,b的类型 auto larger(T1 a, T2 b) -> decltype(a > b ? a : b) //使用拖尾返回类型语法,可将返回类型规范放到参数列表后面,此处的auto用于告知编译器返回类型规范将出现在函数头最后。 return a > b ? a : b; }
(3) decltype(auto),该语法从C++14引入,用于简略(2)的语法。
template <typename T1, typename T2> decltype(auto) larger(T1 a, T2 b){ return a > b ? a : b; }
上面,拖尾decltype()和decltype(auto)会推断为引用类型,且会保留const修饰符。而auto总是推断为值类型,即会不可避免地复制值
-
模板参数的默认值
答:可以为模板参数列表指定默认值,其位置没有严格要求;当使用一个模板参数作为另一个参数的默认值时,前者在列表中的位置应更加靠前。template<typename TReturn=double, typename TArg1, typename TArg2> //可以在实参列表一开始指定默认值 TReturn larger(const TArg1& ,const TArg2&); //------------------------------ template< typename TArg, typename TReturn=TArg> //使用一个模板参数作为另一个参数的默认值 TReturn larger(const TArg& ,const TArg&);
-
非类型的模板参数
答:在定义模板时,非类型的模板参数(和其他的类型参数一起)放在参数列表中,并且,在使用时必须显式指定非类型模板参数。template<int lower, int upper, typename T> bool is_in_range(const T&value){ return (value <= upper) && (value >= lower); } std::cout << is_in_range<0, 500>(value);
这种方法只能在编译期间提供上下限,但更好的方法是为上下限使用函数参数
模板实参推断特别强大,甚至能够从传送给模板的实参的类型,推断出非类型的模板实参Ntemplate <typename T, size_t N> T average(const T (&array)[N]){ T sum{}; for (size_t i {}; i < N; ++i) sum += array[i]; return sum / N; } //--------------------- double moreDouble[] {1.0, 2.0, 3.0, 4.0}; std::cout << average(moreDoubles) << std::endl; //在没有显式指定数组维数时,函数也能工作 //--------------------- //double* pointer = moreDoubles; //std::cout << average(pointer) << std::endl; //编译不会通过。编译器无法从指针推断数组大小 //--------------------- std::cout << average({1.0, 2.0, 3.0, 4.0};) << std::endl; //重用了上次的模板实例
第10章 程序文件和预处理指令
在处理包含头文件和库文件的第三方库时,涉及到以下几个步骤:
-
预处理阶段:
- 在预处理阶段,预处理器会解析源文件中的#include指令,,将其内容插入到源文件中。
- 通常,第三方库的头文件是通过在编译命令中指定的包含路径或者在系统默认的包含路径中查找的。预处理器根据指定的路径和文件名找到头文件,并将其内容插入源代码中。
-
编译阶段:
- 编译器会处理包含第三方库头文件的源文件。它会使用头文件中的声明信息来验证源代码中对第三方库中函数和变量的使用,进行类型检查等操作。
-
链接阶段:
- 在链接阶段,链接器会解析包含第三方库的引用,将其与对应的静态库或动态库进行关联。
- 对于静态库,链接器会从指定的静态库文件中提取引用的函数和变量的定义,并将其与程序中的引用进行解析和链接。
- 对于动态库,链接器会在最终的可执行文件中生成对动态库函数和变量的引用,在程序运行时动态加载相应的动态库。
在联系头文件与静态库或动态库的过程中,可以通过链接器的一些命令选项来指定库的路径和名称,以告诉链接器去哪里查找库文件。比如,在编译命令中可以使用-L选项指定库文件路径,在链接命令中可以使用-l选项指定库文件的名称。链接器根据指定的路径和名称,在静态库或动态库文件中找到对应的函数和变量的定义,与程序中的引用进行解析和链接。
综上所述,处理包含头文件和库文件的第三方库的过程主要发生在预处理和链接阶段。预处理阶段将头文件的内容插入源文件中,编译阶段处理源文件和头文件的声明信息,而链接阶段将头文件中引用的函数和变量与静态库或动态库进行关联。
-
程序构建过程介绍
注:处理头文件的内容是在预处理和编译阶段完成的,处理源文件和库文件的内容是在编译和链接阶段完成的。不同编译器和链接器可能具有不同的实现细节和命令选项,但它们的基本处理方式和原理是相似的。-
预处理:
- 处理头文件:预处理器根据源代码中的#include指令 (当然也包括第三方库的头文件),将相应的头文件内容插入到源文件中。这样编译器就可以获得头文件中的声明信息。
- 处理宏定义:预处理器还会处理代码中的宏定义,例如宏替换和条件编译。它会将代码中的宏替换为相应的文本,并根据条件编译指令决定哪些代码会包含在编译过程中。
- 处理其他预处理指令:预处理器还可以处理其他预处理指令,如#define、#ifdef、#ifndef等。
-
编译:
- 处理源文件:编译器将源文件作为输入,根据语法规则进行词法分析、语法分析和语义分析等步骤,生成中间代码(通常是汇编语言或机器代码)。如果源文件中调用了第三方库的函数,编译器会生成对应的函数调用指令,但是这些函数的定义并不在该源文件中。
- 处理头文件的声明:编译器使用头文件中的声明信息来验证源代码中的函数和变量使用是否正确,以及进行类型检查等操作。
- 进行优化:编译器还可以对生成的中间代码进行一定的优化,以提高程序的效率和性能。
-
汇编:
- 处理汇编语言代码:汇编器将编译器生成的汇编语言代码作为输入,将其转换为机器代码。它将汇编语言中的助记符和操作数转换为相应的二进制表示形式,生成机器指令。
- 处理标号和地址:汇编器会为代码中的标号分配地址,并将相对地址或符号引用转化为适当的绝对地址或偏移量。
-
链接:
- 符号解析:链接器首先会对所有被引用的符号进行解析。在编译和汇编阶段,代码中使用的变量、函数或者其他符号可能只是声明而没有定义。链接器会在已经编译和汇编的文件中查找这些符号的定义,并将其解析为正确的地址。
- 重定位:在编译和汇编阶段,生成的中间文件中使用的地址是相对的,而不是最终的内存地址。链接器会对这些相对地址进行重定位,将其转换为最终的绝对地址。这样,不同中间文件中的地址引用才能正确地连接在一起。
- 符号合并:如果多个中间文件中定义了相同的符号,链接器会将这些符号合并为一个。这通常发生在程序中使用了相同的全局变量或者函数时。符号合并确保最终生成的可执行文件或者库文件中只有一个定义,避免了符号重复定义的错误。
- 库文件的处理:链接器还负责处理库文件。库文件是预先编译好的一组代码和数据的集合,可以在链接过程中被引用。
- 静态库会在链接时将其代码和数据直接合并到最终的可执行文件中。链接器通过读取静态库文件中的目标文件,提取其中的函数和变量的定义,并将其与程序中的引用进行解析和连接。这样,在最终的可执行文件中,代码中对库函数和变量的引用将与静态库中的定义关联起来。
- 而动态库会在运行时动态加载,链接器会通过符号表为可执行文件生成一个对动态库的引用。链接器会在最终的可执行文件中生成对动态库函数和变量的引用,以便在运行时进行动态链接。
- 生成可执行文件或库文件:链接器最后的任务是生成最终的可执行文件或库文件。它会将经过符号解析、重定位和符号合并后的代码和数据组合在一起,并生成一个适当的文件格式。可执行文件可以直接运行,而库文件则可以被其他程序链接和使用。
-
-
C++编译流程示例
答:有程序文件目录结构组织如下:├── main.cpp └── inc ├── math │ ├── arithmetic.h │ └── arithmetic.cpp └── utils ├── logging.h └── logging.cpp
各个文件的内容:
- main.cpp:
#include <iostream> #include "math/arithmetic.h" #include "utils/logging.h" int main() { int result = add(4, 5); std::cout << "Result: " << result << std::endl; logMessage("Hello, world!"); return 0; }
- inc/math/arithmetic.h:
#ifndef ARITHMETIC_H #define ARITHMETIC_H int add(int a, int b); #endif // ARITHMETIC_H
- inc/math/arithmetic.cpp:
#include "math/arithmetic.h" int add(int a, int b) { return a + b; }
- inc/utils/logging.h:
#ifndef LOGGING_H #define LOGGING_H void logMessage(const std::string& message); #endif // LOGGING_H
- inc/utils/logging.cpp:
#include <iostream> #include "utils/logging.h" void logMessage(const std::string& message) { std::cout << "Log: " << message << std::endl; }
- main.cpp:
-
分步操作:
要使用 g++ 的不同选项进行预处理、编译、汇编和链接,可以按照以下步骤进行操作:
① 预处理(Preprocessing):用于将所有的#include头文件以及宏定义替换成其真正的内容。使用-E
选项对源代码文件进行预处理,生成预处理后的文件(通常以.ii
或.i
扩展名结尾)。// 注意使用 -I 添加头文件目录,-I 与后面的inc间的空格可有可无 g++ -E main.cpp -o main.ii -Iinc g++ -E inc/math/arithmetic.cpp -o arithmetic.ii -Iinc g++ -E inc/utils/logging.cpp -o logging.ii -Iinc
② 编译(Compilation):这里的编译不是指程序从源文件到二进制程序的全部过程,而是指使用
-S
选项将经过预处理之后的文件编译成特定汇编代码(assembly code)的过程 (通常以.s
扩展名结尾)。g++ -S main.ii -o main.s -Iinc g++ -S arithmetic.ii -o arithmetic.s -Iinc g++ -S logging.ii -o logging.s -Iinc
③ 汇编(Assemble):使用
-c
选项将上一步的汇编代码转换成目标文件 (机器码, machine code,通常以.o
扩展名结尾),这一步产生的文件叫做目标文件,是二进制格式。使用-c
选项将汇编语言文件转换为目标文件()。g++ -c main.s -o main.o g++ -c arithmetic.s -o arithmetic.o g++ -c logging.s -o logging.o
④ 链接(Linking):将多个目标文以及所需的库文件(.so等)链接在一起,生成最终的可执行文件(executable file)。
g++ main.o arithmetic.o logging.o -o program
-
一步完成:
也可以直接生成可执行文件:g++ main.cpp inc/math/arithmetic.cpp inc/utils/logging.cpp -Iinc -o program
上述命令中使用了 -I
选项来指定头文件的包含路径为 inc
目录,可以根据实际情况调整路径。完成上述步骤后,将得到一个名为 program
的可执行文件。
-
g++的命令选项
答: 参考g++入门教程、man g++
gcc [-c|-S|-E] [-std=standard] //-E 预处理;-S编译生成汇编代码;-c汇编生成目标文件;-std指定语言版本 [-g] [-pg] [-Olevel] //-g编译时生成调试信息; [-Wwarn...] [-pedantic] [-I incdir...] [-L libdir...] //-I 设置头文件目录;-L 设置库文件目录 [-Dmacro[=defn]...] [-Umacro] // [-foption...] [-mmachine-option...] // [-o outfile] [@file] infile... //-o 指定输出文件名;@file 用于从文件中读取命令行选项;infile...为输入文件列表 ------------------------------------------------ #对于`#include "file"`,若使用`-I`指定了头文件目录`incdir`,gcc/g++会先在`incdir`下查找头文件`file`,否则在当前目录下查找,若未找到,再到系统默认的头文件目录下查找。 #对于`#include <file>`,若使用`-I`指定了头文件目录`incdir`,gcc/g++会先在`incdir`下查找头文件`file`,否则直接到系统默认的头文件目录查找。 #选项`-include [file]` 相当于代码中的`#include`,用于包含某个代码。 #举例: $ gcc -std=c++17 -g -I ./include -L ./lib -include /usr/include/pianopan.h -o hello1.cpp hello2.cpp
-
头文件 ( .h )、静态库 ( .lib , .a ) 和共享库 ( .dll , .so )
答:GCC and Make Compiling, Linking and Building C/C++ Applications
①静态库 vs. 动态库
库是一组预先编译的目标文件的集合,可通过链接器将其连接到程序中。这些库提供了一系列功能,可以在程序中使用这些功能而无需自己从头开始实现。库通常包含常见任务或函数的实现,例如系统函数 printf() 和 sqrt()。有两种类型的外部库:静态库和动态库,动态库以称为共享库:区别 静态库 动态库 (共享库) 后缀 在 Unix 中的文件扩展名为".a" (archive file),在 Windows 中的文件扩展名为".lib"(library) 在 Unix 中的文件扩展名为".so" (shared objects) ,在 Windows 的文件扩展名为".dll"(dynamic link library) 链接过程 在编译时将代码复制到可执行文件中。
当程序链接到静态库时,程序中使用的外部函数的机器代码将复制到可执行文件中。在运行时加载并共享代码。
当程序链接到共享库时,只会在可执行文件中创建一个小表。在可执行文件开始运行之前,操作系统会加载外部函数所需的机器代码——这个过程称为动态链接内存使用 占用较多内存空间,每个程序拥有一份库的代码和数据 占用较少内存空间,多个程序共享同一份库的代码。
此外,大多数操作系统允许所有正在运行的程序共享内存中动态库的同一副本,从而节省内存。更新和维护 需要重新编译整个程序 只需要替换库文件,不需要重新编译程序 可执行文件大小 较大,包含了库的代码和数据 较小,只包含对库的引用 可移植性 可移植性好,所有依赖项嵌入到可执行文件中 可移植性稍差,依赖于操作系统提供相应的库文件 由于动态链接的优势,默认情况下,GCC 会链接到可用的共享库。
②搜索头文件和库(-I, -L and -l)
编译程序时,编译器需要头文件来编译源代码;链接器需要库来解析来自其他目标文件或库的外部引用。
◆对于源代码中使用的每个头文件(通过 #include 指令),编译器会在所谓的包含路径中搜索这些头文件。包含路径是通过-Idir
选项(或环境变量 CPATH)指定的。由于头文件名是已知的(例如,iostream.h、stdio.h),编译器只需要目录。如-I/include
◆链接器在库路径中搜索将程序链接到可执行文件时所需的库,需要指定库路径和库名称。库路径通过-Ldir
选项(大写"L"后跟目录路径)(或环境变量 LIBRARY_PATH)指定。在 Unix 中,库 libxxx.a 是通过 -lxxx 选项指定的)小写字母"l",没有前缀"lib"和".a"扩展名)。在 Windows 中,提供全名,例如 -lxxx.lib。链接器需要知道目录和库名称。因此,需要指定两个选项。如-L/lib -lmath
-
转换单元
答:每个源文件及其所包含的头文件内容称为一个转换单元,即对应一个源文件和若干个头文件;
编译器独立处理程序中的每个转换单元来生成对象文件,对象文件包含机器码和实体引用的信息;
链接程序在对象文件之间建立必要的连接, 以生成可执行程序模块;
编译和链接转换单元合称为“转换”。 -
单一定义规则 (ODR, One Definition Rule)
答:ODR的目的是确保程序中的标识符具有唯一的定义,以避免冲突和不确定行为。即整个程序中,一个名称只能定义一次。任何变量、函数、类类型、枚举类型、概念 (C++20 起)或模板,在每个转换单元中都只允许有一个定义(其中部分可以有多个声明,但只允许有一个定义)。
在整个程序(包括所有的标准或用户定义的程序库)中,被 ODR 式使用(odr-used意味着在其定义必须存在的环境中使用某些东西(variables或函数))的非 inline 函数或变量只允许有且仅有一个定义。
inline 函数或 inline 变量 (C++17 起)的定义必须在调用它们的每个转换单元中出现一次,但在所有的转换单元中,给定内联函数和变量的所有定义必须相同。因此应在头文件中定义内联函数和变量,并在需要内联函数或变量的源文件中包含这个头文件。
通常要在多个转换单元中使用类或枚举类型,故允许程序中的几个转换单元分别包含给定类型的定义,但这些定义必须相同。因此,可把类类型的定义放在头文件中,再使用#include
指令把头文件添加到需要类型定义的源文件中,但,在同一个转换单元中给定类型的重复定义是非法的。 -
链接属性
答:转换单元中的名称在编译链接过程中处理的方式由链接属性确定;
链接属性指定了由一个名称表示的实体可以在程序代码的什么地方使用;
当某个名称被用于在声明它的作用域外部访问其程序内容时,就有链接属性;
没有链接属性表示其实体只能在该名称中作用域中访问。
内部链接属性表示其实体可以在同一转换单元的任何地方访问;
外部链接属性表示其实体可以在整个程序中共享和访问; -
外部函数
答:函数名默认具有外部链接属性。
若函数没有在调用它的转换单元中定义,编译器就会为将这个调用标记为外部链接属性,让链接程序处理。
因此,对包含A函数定义的A.cpp、包含A函数声明(原型)的B.cpp,通过链接即可生成正确的可执行文件。
但通过把函数原型放到头文件中,然后使用#include
指令把头文件包含到转换单元中。 -
外部变量
答:(1)非const变量默认具有外部链接属性。若要访问在当前转换单元外部定义的变量,必须使用extern
关键字声明变量名称,避免违反ODR规则,也可以在上一条的外部函数声明前使用extern
修饰符,以明确指出函数定义在另一个转换单元中。//cpp1 int power_range{3}; //定义一个全局变量 double power(doublex, int n){...} //定义一个函数 //cpp2 extern int power_range; //extern必需,全局变量在没有初始化列表时自动初始化为0,加extern避免违反ODR规则 extern double power(double x, int n); //extern可选
(2)const变量在默认情况下有内部链接属性,这使它不能在其他转换单元中使用,使用
extern
关键字可以重写这个属性,//cpp1 extern const int power_range{3}; //定义一个全局常量,注意对const变量添加外部链接属性要在定义时也加入extern //cpp2 extern const int power_range; //声明
-
内部名称
答:上面是需要声明外部链接的场合,有时还会有一些只需要在当前转换单元使用的局部辅助函数,但“函数名默认具有外部链接属性”的特性使得它们总会有外部链接属性,也就不能在其他转换单元中定义有相同签名的函数。过去的解决方案是使用static
关键字声明函数。在现代C++中:
任何时候都不要再使用static
关键字来标记应该具有内部链接属性的名称;相反,应该总是使用未命名的名称空间。 -
预处理指令
答:#
字符串化运算符;##
连接运算符
#define IDENTIFIER sequence of characters
该指令将宏标识符IDENTIFIER
替换为一个字符串,以前常用于定义符号常量、创建类似于函数的宏。但在现代C++中:
总不要使用预处理宏来定义符号常量和简单函数,而总应该使用const常量和普通的C++函数或函数模板 -
头文件
答:在源文件中包含自己的头文件时,常用双引号:#include "myheader.h"
;
多个或多级头文件包含时,有些头文件可能会被多次包含到一个源文件中;#include
指令的无限递归会导致编译失败。可使用下面的 #include保护符://对于头文件myheader.h #ifndef MYHEADER_H #define MYHEADER_h //myheader.h中的所有代码放在这里 #endif
#include保护符保证了一个头文件中的内容在同一个转换单元中只出现一次,但不能阻止它们被包含到多个转换单元中。若在头文件中进行了定义,或在头文件中进行声明而在多个源文件中分别对同一个变量进行定义,都会导致重复定义违反ODR。因此要避免在头文件对变量进行初始化,若需要在多个源文件中使用同一个变量时,使用extern标识符。
-
名称空间
答:如果没有定义名称空间,就默认使用全局名称空间;
当存在同名的局部声明覆盖了全局名称时,就需要使用作用域解析运算符显式地访问全局名称:::power(2.0, 3)
;
在名称空间中不能包含main()
函数;
如果要把函数放在名称空间中,只需要把函数的原型放在名称空间中即可,函数可以在其他地方定义,但要使用限定过的名称。
当名称空间较长时,可以为其定义一个别名:namespace alias_name = original_namespace_name;
namespace calc{ ... } namespace sort{ ... } namespace calc{ //扩展名称空间定义 ... }
-
未命名的名称空间
答:在不给名称空间指定名称时,会由编译器生成一个内部名称;
在一个转换单元中只能有一个未命名的名称空间,其余没有命名的名称空间都是其扩展;
不同的转换单元中的未命名名称空间是不同的。
未命名的名称空间中声明的所有名称都具有内部链接属性(即使用extern修饰符定义了名称),它们的实体都是定义它们的转换单元中的局部成员。因此可以作为static的良好替代。 -
逻辑预处理指令
答:逻辑#if指令:可以定义预处理标识符,为要编译的代码指定环境,并据此选择代码或#include指令。在不同的硬件或操作系统环境中运行或维护应用程序时非常有用。#if constant_expression // #endif # if LANGUAGE==EN // #elif LANGUAGE==CN // #else // #endif
-
调试方法
答:(1)在函数中使用预处理指令#if #endif,以隔离调试所需的代码;
(2)使用assert()宏。assert(expression)
会在expression
false时,终止程序,并输出诊断信息。其中的expression可以是任意逻辑表达式。assert()的头文件是<cassert>
,通过在cassert的#include语句前添加#define NDEBUG
可关闭预处理断言机制,忽略转换单元中所有断言语句。
(3)静态断言:
静态断言是语言内置的部分,用于在编译时静态检查条件;
当constant_expression是false时,程序会编译失败,若提供了error_message,就会输出一条包含它的诊断消息,否则编译器将基于constant_expression生成一个;当constant_expression为true时,静态断言什么也不会做;
静态断言的一个常见用途是在模板定义中验证模板参数的特征。static_assert(constant_expression, error_message); static_assert(constant_expression); //C++17新加,省略error_message字符串字面量 //-------------------------- static_assert(std::is_arithmetic_v(T), "Type parameter for average() must be arithmetic");
第11章 定义自己的数据类型
-
面向对对象编程(OOP)
答:根据要解决的问题范围内所涉及的对象来编写程序,因此程序开发过程的一部分是设计一组类型来满足这个要求。 -
封装
给定类的每个对象都组合了下述内容:
一组数据值,作为类的成员变量,也称为数据成员或字段,指定对象的属性;
一组操作,作为类的成员函数,也称为方法。
把这些数据值和函数打包到一个对象中,就称为封装;
在一般情况下,不允许访问对象的数据值,这就是数据隐藏,数据成员一般不能从外部访问;
隐藏对象中的数据,可以禁止直接访问该数据,但可以通过对象的成员函数来访问;
成员变量表示对象的状态,操纵它们的成员函数则表示对象与外界的接口;
在设计阶段,正确设计类的接口非常重要,以后可以修改其实现方式,而不需要对使用类的程序进行任何修改;
建议:以一致的方式隐藏对象的数据,并只通过其接口中的函数来访问和操作对象的数据。 -
继承
根据类BankAccount
定义一个新的LoanAccount
叫做继承,BankAccount
被称为基类,LoanAccount
派生于BankAccount
。 -
多态性
多态性表示在不同的时刻有不同的形态。它意味着属于一组继承性相关的类
的对象可以通过基类指针和引用来传送和操作。指向基类的指针变量可以存储该基类及其派生类对象的地址,这组类中会有相同的基类中的成员函数,该指针对其中某个成员函数名的调用会根据所指向对象的不同而调用不同的函数实体,即同一个函数调用会根据指针指向的对象完成不同的操作。 -
类成员访问修饰符
- private (默认): 只能由该类中的函数、其友元函数访问,不能被任何其他访问,该类的对象也不能访问;
- protected: 可以被该类中的函数、子类的函数、以及其友元函数访问,但不能被该类的对象访问;
- public: 可以被该类中的函数、子类的函数、其友元函数访问,也可以由该类的对象访问。
注:友元函数包括两种:设为友元的全局函数,设为友元类中的成员函数
-
结构体
C++中也可以定义 (来自于C的) 结构体。相较于类,结构体的定义使用struct
关键字代替class
,且结构的成员默认为公共的,除此之外,结构与类的定义完全相同。 -
定义类
//Box.h #ifndef BOX_H #define BOX_H class Box{ private: double length {1.0}; double width {1.0}; double height {1.0}; public: //Box (double lengthValue = 1.0, double widthValue = 1.0, double heightValue = 1.0);//有初始值的默认构造函数 Box(double length, double width, double height); //explicit Box(double size); Box(const Box& box); Box() = default; //显式生成默认构造函数(在需要无参数的默认构造函数时,推荐) //Box() {} //或自己定义默认构造函数(不推荐) double volume(); }; //类定义的右花括号后面必须有分号 #endif //------------------------------------------ //Box.cpp #include "Box.h" #include<iostream> //Constructor define //成员初始化列表对成员变量赋值 Box::Box(double lv, double wv, double hv) : length{lv}, width{wv}, height{hv} { } //最为推荐,注意初始化列表放在定义时 //Box::Box(double side) : Box{side, side, side} { } //委托构造函数 Box::Box(const Box& box) : Box(box.length, box.width, box.height} {} //副本构造函数、委托构造函数 double Box::volume(){ return length*width*height; } //------------------------------------------ //main.cpp #include<iostream> #include"Box.h" int main(){ Box firstBox {80.0, 50.0, 40.0}; //使用自定义构造函数创建对象 double firstBoxVolume {firstBox.volume()}; Box secondBox; //使用默认构造函数创建对象 double secondBoxVolume {secondBox.volume()}; } ```
-
构造函数
- 构造函数用于在创建对象时设置成员变量的值,确保成员变量包含有效的值;
- 构造函数常常与包含它的类同名;
- 构造函数没有返回值(所以也没有返回类型);
- 在定义类时的常见做法是将类放到一个头文件中,将成员函数和构造函数放到对应的源文件中;
- 可以为类的成员函数指定参数的默认值,构造函数和成员函数的默认实参值总是放在类中,不放在外部构造函数和成员函数中;
- 可以在构造函数中使用成员初始化列表对成员变量赋值
- 这种方式对成员变量初始化的顺序由类定义中声明成员变量的顺序决定,而不是由成员初始化列表中的顺序决定。
- 这种方式相比较在函数体中初始化成员变量的效率要高得多
- 使用成员初始化列表对成员变量赋值相当于使用值直接初始化成员变量,而在函数体中对成员变量赋值相当于将初始化与赋值操作分开做,因此这种方式是为某些类型的成员变量设置值的唯一方式:
- const 成员变量:const成员变量是指在类中声明为const的成员变量,表示该变量在对象创建后不可修改。由于const成员变量的值不能被更改,它必须在对象创建时进行初始化。
- 引用成员变量:引用成员变量是指在类中声明为引用类型(&)的成员变量。引用成员变量必须在初始化时引用另一个对象,因此只能在构造函数的成员初始化列表中进行初始化。
-
默认构造函数
- 默认构造函数就是无参构造函数,其唯一作用就是创建对象。即不用输入参数就能够实例化对象的构造函数叫做默认构造函数;
- 如果不为类定义任何构造函数,编译器将生成默认构造函数,这种非用户定义的默认构造函数被称为默认的默认构造函数;
- 若使用默认构造函数创建对象,成员变量就会使用默认值,如果没有为指针类型或基本类型的成员变量指定初始值,它们就会包含垃圾值;
- 只要用户提供了(任何)构造函数,编译器就不会隐式生成默认构造函数了,若此时仍然想让对象被默认构造,可以显式创建默认构造函数:
Box () {}
无参数的空函数Box () = default;
使用default关键字显式创建一个默认构造函数;Box (double lv, double wv, double hv) : length {lv}, width {wv}, height {hv} {}
所有参数都有默认值的构造函数算作默认构造函数- 一个类中只能有一个默认构造函数。
- 除了默认构造函数外,还可以在类定义中为成员变量添加默认值,这相当于在构造函数中为成员变量指定默认值。
-
使用explicit关键字
只有一个实参的构造函数
和有多个参数,但除了第一个参数外其他参数都有默认值的构造函数
在编译器看来是等效的,因此只能存在一个。
类的构造函数只有一个参数是有问题的,比如,在使用时,若一个以该类的实例为参数的函数被传入了和这个单参数构造函数的参数类型相同的参数,会使用这个参数对类隐式实例化,导致非预期的结果,如//对Cube类,其构造函数为: Cube(double aside); //它的一个成员函数为: bool hasLargerVolumeThan(Cube aCube); //该函数用于比较当前对象与作为参数的aCube对象的体积大小 //可见上面这个函数的参数应该是一个Cube类型的对象,如下面这样使用: Cube box1 {7.0}; Cube box2 {3.0}; if (box1.hasLargerVolumeThan(box2)) std::cout<< "box1 is larger than box2" <<std:endl; //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ //但由于构造函数只有一个参数,当成员函数hasLargerVolumeThan的参数与构造函数的参数类型相同或缩窄转换后相同时,对于如下语句: //↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ if (box1.hasLargerVolumeThan(5.0)) std::cout<< "box1 is larger than 5.0" <<std:endl; //↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ //编译器会将实参5.0转换为一个Cube对象: //↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ if (box1.hasLargerVolumeThan(Cube {5.0})) std::cout<< "box1 is larger than 5.0" <<std:endl; //这相当于把box1与一个边长为5.0的box进行了比较,与期望的用法不同 //把构造函数声明为explicit可避免这种情况: expilicit Cube(double aside);
explicit声明用在(头文件)函数定义内的原型中;
编译器不会把声明为explicit的构造函数用于隐式类型转换,它只能在程序代码中显式创建对象。默认情况下,应该将所有包含一个实参的构造函数(包括有多个参数,但除了第一个参数外其他参数都有默认值的构造函数)声明为explicit。 -
委托构造函数
答:(只能) 在初始化列表中调用 (一次) 同一个类中的另一个构造函数完成实例化,若有剩余的实参用于初始化成员变量,应放在函数体中执行。 -
副本构造函数
- 通过复制已有的对象来创建对象;
- 若没有定义副本构造函数,编译器会提供一个默认的副本构造函数;
- 由于复制指针时,不会复制指针指向的内容,当成员变量中存在指针时,通过副本构造函数创建的对象就会与原对象链接起来,导致两个对象都指向相同内容的成员,这种情况应该重写副本构造函数;
- 在自定义副本构造函数时,考虑以下问题:
- 使用副本构造函数创建新实例时,若副本构造函数是按值传递,就会为作为参数的输入对象创建副本,这就又会调用副本构造函数,导致副本构造函数的无限递归调用。因此:副本构造函数的实参必须是const引用(const是由于要保证不改变原对象)。
-
为什么成员变量要用private声明
暴露出来的接口应该是稳定的,而类中的变量随着需求的变动有可能会增删改,private成员变量可使类的调用更加鲁棒;
对于非常稳定的成员变量,可以将它们声明为public,可方便其使用。 -
访问私有类成员
使用访问器函数(getter)来提取成员变量的值;
使用更改器成员函数(setter)来修改成员变量;double getLength() {return length;} void setLength(double lv} {if (lv>0) length = lv;}
-
this指针
在执行任何成员函数时,该成员函数都会自动包含一个隐藏的指针,称为this指针,该指针包含调用该成员函数的对象的地址;
一般情况下不必显式使用this指针。 -
从函数中返回this指针的用法
答:把成员函数的返回类型指定为类类型的指针或引用,就可以从函数中返回this指针或其解引用。Box* Box::setLength(double lv){ if (lv>0) length = lv; return this; } Box* Box::setWidth(double wv){ if (wv>0) width = wv; return this; } Box* Box::setHeight(double hv){ if (hv>0) height = hv; return this; } Box mybox{3.0, 4.0, 5.0}; mybox.setLength(-20.0)->setWidth(40.0)->setHeight(10.0); //使用指针的方法链 //---------------------------------------------- //---------------------------------------------- Box& Box::setLength(double lv){ if (lv>0) length = lv; return *this; } Box& Box::setWidth(double wv){ if (wv>0) width = wv; return *this; } Box& Box::setHeight(double hv){ if (hv>0) height = hv; return *this; } Box mybox{3.0, 4.0, 5.0}; mybox.setLength(-20.0).setWidth(40.0).setHeight(10.0); //使用引用的方法链
-
const对象和const成员函数
类类型的const变量称为const对象,构成const对象状态的任何成员变量都不能被修改;
这一原则也适用于指向const变量的指针和对const变量的引用;
对于const对象,只能调用const成员函数,即要把所有不修改对象的函数指定为const,<限制1>const成员函数不允许修改对象;
将成员函数指定为const,会使该成员函数的this指针成为const指针,指向const对象;
联系前两条可知,<限制2>在const成员函数内不能调用任何非const成员函数,这2条限制被称为const正确性,可防止const对象被 修改;
有无const是函数签名的一部分,因此可以用const版本来重载一个非const版本的成员函数;
添加public成员函数来返回对private成员变量的引用,越过了成员变量的private声明,可以在类外读写private成员变量,但这与简单地将这些变量声明为public一样不好;
可以使用mutable关键字声明成员变量,以在const对象中修改它;cnost Box mybox {3.0, 4.0, 5.0}; //const对象 Box mybox {3.0, 4.0, 5.0}; const Box* boxpointer = &mybox; //指向const变量的指针 const Box& boxrefer = mybox; //对const变量的引用 //---------------------------- double volume() const; //把不修改对象的函数指定为const double Box:volume() const {return length*width*height;} //--------------------------- double& length() { return _length; } //public成员函数,返回private成员变量的引用(不推荐) double length() const { return _length; } // const重载
-
友元
友元可以访问类对象的任意成员,无论这些成员的访问修饰符是什么(只有在绝对有必要时才应使用友元);
类的友元函数要在类定义内用关键字friend来编写函数原型,友元函数可以是一个全局函数或另一个类的成员。
友元函数不是类成员,所以成员变量必须用对象名来限定;
友元类的所有成员函数都可以不受限制地访问原有类的成员;friend double surfaceArea(const Box& abox); friend class Carton;
-
类的对象数组
类对象的数组的每个元素都根据初始化参数由构造函数创建。 -
类对象的大小
类对象的大小一般是类中成员变量大小的总和,或稍大(这是由于边界对齐导致的) -
类的静态成员
静态成员是指声明为static的成员;
静态成员独立于类类型的所有对象,不是类对象的一部分 (因此它不会影响对象的大小,const成员函数也可以修改静态成员变量),但可由它们访问;
静态成员变量只定义一次,无论定义多少个类对象,每个静态成员变量的实例只有一个;
即使没有创建对象,静态成员变量也存在,可以使用类名限定变量名来使用静态变量;
从C++17开始,inline支持内联变量 (以前只支持函数和成员函数),在多个源文件中包含该头文件时,编译器会将这些定义视为同一个实体,并保证只有一个实际的定义;
将静态成员变量声明为inline变量,可在头文件中初始化它们,而不必在源文件中单独进行定义,避免违反ODR;// 不使用内联变量的静态成员变量使用方式 // MyClass.h(头文件) class MyClass { public: static int count; // 声明静态成员变量 }; // MyClass.cpp(实现文件),在源文件中初始化,避免违反ODR int MyClass::count = 0; // 初始化静态成员变量 // 使用静态成员变量 int main() { MyClass::count++; // 访问和修改静态成员变量 return 0; } //======================================== //======================================== // 使用内联变量的静态成员变量使用方式 // MyClass.h(头文件) class MyClass { public: inline static int count = 0; // 内联静态成员变量 }; // 使用内联静态成员变量 int main() { MyClass::count++; // 访问和修改内联静态成员变量 return 0; }
静态成员变量常常用于定义常量,可避免每个对象都创建一个该成员变量的副本;
通常将定义为static和const的所有成员变量也定义为inline,以在类定义中直接初始化;
对关键字static、inline、const的顺序没有要求;
可以定义类类型的静态成员变量,但需要在类的外部进行定义和初始化;
静态成员函数也独立于类的对象,可使用类名调用静态函数;
由于静态成员函数是所有对象公用的,因此可以通过对象来调用静态成员函数(不推荐),但静态成员函数不能访问调用它的对象;
若想让静态成员函数访问类的对象,需要把该对象作为参数传递给静态函数;
由于静态成员函数与对象无关,所以它没有this指针,不能使用const声明。 -
析构函数
对类对象应用delete运算符或处在创建类对象的块的末尾,就会释放类对象;
释放类对象会执行析构函数,它与类同名,但名称前有一个~
;
如果没有定义析构函数,编译器会提供一个默认的;~Box() {} //声明 Box::~Box() = default; //使用default定义一个默认析构函数
-
使用指针作为类成员
现实中的程序通常由大量彼此协作的对象构成,这些对象通过指针、智能指针和引用链接在一起;
需要创建这些对象的网络,将它们链接在一起,最后再释放它们(通常使用智能指针)。
现代C++中,通常不再需要使用delete关键字,而是总应该使用智能指针来管理动态分配的对象——资源获取即初始化 (RAII)拖尾指针(Trailing Pointers)是一种通过在数据结构中保留对最后一个元素的引用来提高操作效率的技术。它通常用于链表和队列等数据结构中。
在传统的链表和队列实现中,如果要访问最后一个元素,需要从头结点或队列头部开始遍历,直到到达最后一个元素。这样的访问方式的时间复杂度是O(n),其中n是数据结构中的元素个数。
而使用拖尾指针技术,可以在数据结构中维护一个指向最后一个元素的指针(拖尾指针)。通过拖尾指针,可以直接访问最后一个元素,而无需遍历整个数据结构。这样的访问方式的时间复杂度是O(1),即常数时间。
拖尾指针可以提高插入、删除和访问最后一个元素的效率,尤其在需要频繁进行这些操作的场景下非常有用。然而,需要注意的是,使用拖尾指针会增加数据结构的复杂性,并需要额外的操作来保证指针的正确性。
需要注意的是,拖尾指针只适用于在操作过程中不会改变元素顺序的情况。如果数据结构的操作可能涉及元素的插入、删除或移动等操作,拖尾指针可能需要进行更新以保持正确性。因此,在使用拖尾指针之前,需要仔细考虑数据结构的设计和操作的特性。 -
嵌套类
在嵌套类内可以访问包含类的私有成员;
嵌套类的成员函数可以引用包含类的静态成员,以及其他在包含类中定义的类型或枚举成员
包含类不能访问嵌套类的私有成员,对包含类来说,嵌套类和另一个类是一样的。
第12章 运算符重载
//==========通过运算符重载编写成员函数===========
class Box {
private:
double length {1.0};
double width {1.0};
double height {1.0};
public:
// 重载<运算符
bool operator< (const Box& aBox) const {return volume() < aBox.volume();}
double volume () const {return length*width*height;};
};
// 使用重载运算符
if (box1 < box2) {...}
if (box1.operator< (box2) ) {...} //这两行对重载运算符的调用是等价的
//==========通过运算符重载编写非成员运算符===========
inline bool operator< (const Box& box1, const Box& box2) {return box1.volume() < box2.volume; }
//总是应该将非成员运算符与它们操作的对象的类放在相同的名称空间中
- 运算符重载允许把标准运算符 (如+、-、*、<等) 应用于类类型的对象;
- 通过运算符重载编写成员函数或普通函数 (非成员运算符),可以让基本运算符操作类对象,其与内置类型的运算符使用方式类似;
- 重载给定运算符的函数名由关键字operator和要重载的运算符组成;
- 总是应该将非成员运算符与它们操作的对象的类放在相同的名称空间中;
- 要把非成员运算符定义为内联函数,并放在头文件中,以避免ODR错误;
- 通过添加对运算符重载的重载版本,可以增加运算符重载的支持范围,如对象与对象间的操作、对象与变量的操作、变量与对象的操作;
- 成员运算符总是把this指针提供为左操作符,若左操作数不是定义这个运算的类,可以将运算符实现为普通运算符,或友元函数。
- 若对类定义了小于运算符和等于运算符,就可以使用utility头文件中的
std::rel_ops
名称空间下的运算符模板,包括==, <=, >, >=, !=
- 上一条所说的预定义的小于和等于运算符,可以是成员运算符、友元运算符、普通非成员运算符,只要运算符的操作数和返回类型与运算符模板参数的类型匹配即可;
- 标准运算符中,按重载方式可分为三类:
- 只能重载为成员运算符:赋值运算符
=
、数组下标运算符[ ]
、函数调用运算符( )
和转换为类型T运算符T
- 只能重载为非成员运算符:分配内存和解除内存分配运算符
new new[ ] delete delete[ ]
和 用户定义的字面量运算符""_
- 其他
- 只能重载为成员运算符:赋值运算符
- 重载运算符本质上是一个函数,即重载运算符的所有操作数都会被计算
- 运算符重载的限制:
- 不能发明新的运算符;
- 不能修改现有运算符的操作数个数、相关性、优先级及操作数的计算顺序
- 只能重载已有的运算符,重载运算符的签名必须涉及至少一种类类型;
- 重载运算符的指导原则:
- 重载运算符的意义和操作上应直观、合理、与常见用法保持一致;
- 绝不应该重载逻辑运算符
&&
和||
- 重载输出流运算符
std::ostream& operator<< (std::ostream& stream, const Box& box) { stream << "要输出的box内容" ; return stream; }
- 可以根据一个运算符来实现另一个运算符。比如可以先实现+=运算符,然后实现+运算符。总是应当根据
op=()
来实现op()
运算符,而不是反过来。因为op=()
的直接实现可以通过引用,而op()
会创建临时副本,因此根据op=()
来实现op()
运算符更高效。 - 如何选择使用哪种方式实现运算符重载
- 优先使用成员函数,其次是非成员函数,尽量避免友元函数。
- 当有一个运算符被重载为非成员函数时,应考虑将其他运算符重载也实现为非成员函数,以保持一致。
- 当希望二元运算符的左操作数可被隐式转换时,首选将运算符重载实现为非成员函数
- 比如希望对一个integer对象重载+,则
integer1.operator+ (integer)
只能实现interger1 + int
,而非成员函数的实现方式operator+ (integer, integer)
可实现interger1 + int
和int + interger1
- 注意:std::rel_ops名称空间的运算符模板不允许隐式转换
- 比如希望对一个integer对象重载+,则
- 重载递增和递减运算符
- 重载前缀形式的递增运算符,其返回值应该总是对当前对象的引用 (直接返回计算后的对象的引用)
- 重载后缀形式的递增运算符,其返回值应该总是原对象在被递增之前的一个副本(先返回原对象的副本,再计算递增或递减)
- 后缀形式递增运算符的重载的返回值被声明为const,以阻止编译 theObject++++ 这样的表达式
- 根据C++标准,后缀形式的递增运算符函数声明中必须包含一个额外的、未命名的int形参来区分前缀和后缀形式,虽然该形参在函数体内部并不会被实际使用
class MyClass { public: // 重载前缀形式的递增运算符 MyClass& operator++ (); // 重载后缀形式的递增运算符 const MyClass operator++(int); }; //=========================== inline BOx& Box::operator++() { ++length; ++width; ++height; return *this; } inline const Box Box::operator++(int) { auto copy {*this}; ++(*this); return copy; }
- 重载下标运算符
operator[]()
运算符的作用是从许多可解释为数组的对象中选择元素,重载下标运算符可以访问稀疏数组 (许多元素都为空的数组)、关联数组或链表中的元素。- 下标运算符通常应返回数据结构中实际值的非const引用,若要返回指针的引用,由于不能返回对nullptr的引用,这种情况就会出现错误
那么考虑指针为空时的情况,最简单的解决方案是提前预定义一个不指向任何东西的静态空对象,并永久存储在全局内存中,在要返回空指针的引用时就返回它。但下标运算符需要考虑超出索引的情况,这种时候会返回之前定义的静态空对象,由于该引用为非const引用,那么可能发生对这个空指针赋非null值的情况,导致下标运算符瘫痪- 最合理的机制是使用异常来解决这种问题。
- 函数对象
- 函数对象的本质是一个重载了函数调用运算符
operator()()
的类的实例 - 函数对象是可以像函数一样使用的对象,也被称为仿函数(Functor)
- 函数调用运算符必须被重载为成员函数
- 函数调用运算符是唯一不限制参数个数,且能够有默认实参的运算符
class Volume { public: double operator () (double x, double y, double z) {return x*y*z;} double operator () (const Box& box) {return box.volume();} }; Volume volume; double room1 {volume(16, 12, 8)}; Box box{10, 5, 2.8}; double room2 {volume(box)};
- 函数对象的本质是一个重载了函数调用运算符
- 重载类型转换
- 重载类型转换运算符可以将一个类的对象转换为另一个类型。
- 可以将其所在的类对象转换成基本类型或类类型
- 重载类型转换默认可以隐式转换,可使用explicit限制为显式转换。
- 类型转换运算符必须重载为成员函数。
- 一般形式如下,注意重载类型转换没有返回值:
class Box { public: //不添加explicit,可隐式调用,也可显式调用 operator double () const {return volume();} //添加explicit,只能显式调用 explicit operator double () const {return volume();} }; Box box {1, 2, 3}; // 隐式调用 double boxVolume = box; // 显式调用 double boxVolume = static_cast<double>(box); //============================== //若有一个类Type2,其构造函数可从Type1对象构造Type2的对象: Type2 (const Type1& theObject); // 在Type1中有一个类型转换运算符函数将Type1对象转换为Type2对象: operator Type2(); // 在需要隐式转换时,编译器会不知道使用上面哪种方法来转换, //为避免这种模糊性,可以把其中一个或这两个成员都声明为explicit
- 重载赋值运算符
- 编译器会生成默认复制赋值运算符:
Box& operator= (const Box& right_hand_side); //原型 oneBox = otherBox; //调用
- 默认复制赋值运算符会简单的逐个复制类的所有成员变量
- 由上一条可知,若类中包含指针,使用默认的复制赋值运算符会导致两个对象中的部分成员指向同一个地址,那么两个对象的销毁会对相同地址调用两次delete。因此:由自身管理动态分配内存的类,应重新定义赋值运算符
- 上面所说的由自身管理动态分配内存的类指的是使用了析构函数和构造函数来管理内存的类,这种类通常被称为"自动资源管理"类或"RAII(Resource Acquisition Is Initialization)"类。在这种类中,构造函数负责动态分配内存或申请其他资源,并将其保存在成员变量中。析构函数则在对象被销毁时自动调用,负责释放已分配的内存或释放其他资源。
- 赋值运算符必须定义为成员函数。
- 由于赋值运算符可以完成这样的表达式:
msg1 = msg2 = msg3;
,它等效于msg1.operator=(msg2.operator=(msg3));
,这种形式称为方法链。 - 任何赋值运算符都应该返回对
*this
的引用- 为什么需要返回值:其中每个赋值运算符返回的内容都是另一个赋值运算符调用的实参,所以必须要返回左操作数;
- 为什么要返回引用:为了避免不必要的复制。同时右操作数声明为const以避免更改。
- 在实现赋值运算符时
- 应首先检查自我赋值的情况;
- 然后释放当前对象各成员的内存,并遍历对象中的数据,为各成员逐个分配内存,并复制到当前对象的内存中
Message& operator=(const Message& message) { if (this != &message) { delete pText; pText = new std::string(*message.pText); } return *this; }
- 实现正确、安全的赋值运算符的标准技术——复制后交换,将在16章介绍
- 只有当赋值给之前构造的、已经存在的对象时,才会使用赋值运算符
Message beware {"Carefull"}; //下面的情况都是调用的副本构造函数: Message warning {beware}; Message otherWarning = warning;
- C++中的指针可分为智能指针和原始指针两种。若类管理的成员有指向自由存储区内存的指针,则不能不加修改地使用副本构造函数和复制赋值运算符;如果有原指针成员,就必须定义析构函数,因为原始智能不能像智能指针那样自动释放内存。
- 类的赋值运算符可以有几个重载版本,其他版本的右操作数可以与类类型不同,因此它们实质上是类型转换。
- 编译器会生成默认复制赋值运算符:
第13章 继承
-
在许多真实的问题中,所涉及的实体的类型都是相关的,这些关系可以用"
is-a
"、“has-a
”、"use-a
"关系来表示 -
类的层次关系
- 可以根据一个类指定另一个类,以开发出相互关联的类层次结构
- 在这种层次结构中,一个类派生自另一个类,并添加其他属性以特殊化,使新的类成为一般类的特殊版本 (从一般到特殊)
- 这种层次结构有两种:“是”和“有”。如
狗
是对动物
的特殊化,狗是
动物;汽车
是对引擎
的特殊化,汽车有
引擎
-
类的继承
- 根据一个一般类A创建一个特殊类B,这个一般类A叫做基类,也叫做父类,这个特殊类B叫做派生类,也叫做子类。
- 派生类自动包含基类的所有成员变量和所有成员函数 (但是有一些限制),还有自己的成员变量和成员函数。
- 若类B是直接派生于类A,则称类A为类B的直接基类
- 若有类C派生于类B,而类B派生于类A,则类A是类C的间接基类
-
类间的关系
- 类间的关系包括继承关系、关联关系、依赖关系、聚合关系和组合关系
- 程序员通过仔细阅读代码,理解类之间的关系和代码的逻辑,以确定实体类型之间的关系
is-a
关系:继承关系可以通过种类测试判断。任何派生类对象都是基类类型的对象。has-a
关系:类A与类B是整体与部分的关系,类B是类A的成员变量。包括聚合和组合关系,可以通过包含测试判断。- 在聚合关系中,作为成员变量的类B可以在类A外部创建并传递给类A的构造函数,即类B独立于类A,即使类A的对象销毁,被包含的类B对象也依然存在;
- 在组合关系中,作为成员变量的类B只能通过类A的构造函数创建,不能在类A外部创建和传递,类B的对象不能单独存在,若类A的对象被销毁,被包含的类B对象也会被销毁。。
use-a
关系:类A与类B是个体与个体的关系。包括依赖关系和关联关系,一个类(依赖者或关联者)需要使用另一个类(被依赖者或关联者)的功能或服务。- 依赖关系通常体现在方法参数、局部变量或临时引用上,它是一种临时的关系,只在需要时与另一个类进行交互,且依赖关系是单向的;
- 关联关系强调一个类(关联者)与另一个类(被关联者)之间的长期关联,可能会持有被关联者的引用或使用被关联者的方法。关联关系可以是单向的或双向的。
-
访问修饰符
- 第11章已经介绍过了类成员访问修饰符,在类的派生机制中:
- 基类的私有成员会由其派生类继承,但不能由派生类的成员函数直接访问,必须通过基类方法进行访问
- 基类的受保护成员和公共成员可以由派生类访问
- 基类访问修饰符用于在定义派生类时修饰基类,基类修饰符是派生类继承的基类的成员修饰符的上限:
- private:将所有基类成员继承为private,private是默认的基类访问修饰符
- protected:将所有基类成员继承为最高protected(private -> private, protected -> protected, public -> public)
- public: 将所有基类成员继承为最高public,即不改变基类成员的访问状态
- 使用private和protected修饰基类,可以阻止在派生类的外部访问基类的成员
- 可以使用using声明改变继承成员的访问状态:
class Carton : private Box { //基类访问状态为private private : std::string material; public : using Box::volume; explicit Carton(std::string_view mat = "cardboard") : material {mat} {} }
- 在对基类成员名应用using声明改变其访问状态时,必须使用基类名限定
- 不应给出成员函数的参数列表及返回类型,仅需成员函数名称
- 该语法可用于基类的成员变量和成员函数
- 不能将using声明应用于基类的私有成员,因为基类的私有成员在派生类中不能访问
- 第11章已经介绍过了类成员访问修饰符,在类的派生机制中:
-
派生类中的构造函数
- 派生类构造函数调用基类构造函数完成实例创建
- 即使有好几级派生,也是先调用最一般的基类构造函数,再调用派生于基类的派生类构造函数,直到调用最特殊的类的构造函数
- 基类成员的初始化
- 基类中非私有的成员变量可以在派生类中访问,但不能在派生类构造函数的初始化列表中初始化,但可以在派生类构造函数的函数体中更改基类非私有的成员变量
- 基类中私有的成员变量不能由派生类直接访问,需要使用基类的构造函数进行初始化
- 最好总是将构造函数的实参交给合适的基类构造函数,让基类初始化继承的成员
- 为派生类编写构造函数时,一定要确保正确地初始化派生类对象的成员,包括所有直接继承的成员变量和派生类特有的成员变量
- 为派生类编写副本构造函数时,要显示调用基类的副本构造函数
- 派生类中的默认构造函数会调用基类的无参构造函数。因此若编译器提供了无参构造函数,那么基类中的无参构造函数必须要是非私有的。
- 继承构造函数
- 派生类一般不会继承基类的构造函数
- 若想要继承基类的构造函数,可以在派生类中使用using声明:
class Carton : public Box { using Box::Box; private: //... public: //...
- 上面这个
using Box::Box;
会继承基类中除默认构造函数和副本构造函数以外的所有构造函数; - 所继承的构造函数与其在基类中具有相同的访问修饰符,而与
using Box::Box;
在派生类中的位置和基类的访问修饰符无关。比如只要一个构造函数在基类中为public,那么不管using Box::Box;
是在所有public后面、protected后面、private后面,亦或是class Carton : public Box
、class Carton : protected Box
、class Carton : private Box
,它在派生类中都是public
- 上面这个
-
继承中的析构函数
对象的释放顺序与创建它们的顺序相反。最先创建的对象最后释放;在创建派生类对象时,会先创建其基类的子对象,再创建派生类对象,那么在释放它时,会先释放派生类对象,再释放其基类子对象。 -
重名成员
- 重复的成员变量名
若派生类中定义了与其基类中同名的成员变量时,在派生类中使用基类的成员变量时要使用基类名限定以进行区分,未使用类名限定的变量表示派生类中的成员变量 - 重复的成员函数名
- 当派生类中成员函数与基类成员函数名称相同,但参数列表不同时:由于两个函数的作用域不同,因此不属于函数重载,派生类的成员函数会隐藏基类的同名成员函数。这种情况下,若要使用基类的成员函数,必须使用using声明在派生类的作用域中引入基类的成员函数限定名,编译器可以根据参数列表的不同来区分它们。
class Base { public: void doThat(int arg); //... }; class Derived::public Base { public: void doThat(double arg); using Base::doThat; //... }; Derived object; object.doThat(2); //调用继承的基类成员函数 object.doThat(2.5); //调用派生类的成员函数
- 当派生类中成员函数与基类成员函数签名相同,同上一条,由于作用域不同,两个函数仍是不同的,基类中的同名函数也会被隐藏,但由于它们签名相同,这种情况,要在调用时使用要使用的类名限定函数。
Derived object; object.Base::doThat(3);
- 当派生类中成员函数与基类成员函数名称相同,但参数列表不同时:由于两个函数的作用域不同,因此不属于函数重载,派生类的成员函数会隐藏基类的同名成员函数。这种情况下,若要使用基类的成员函数,必须使用using声明在派生类的作用域中引入基类的成员函数限定名,编译器可以根据参数列表的不同来区分它们。
- 重复的成员变量名
-
多重继承
- 从多个直接基类派生一个新类称为多重继承,最好尽量避免多重继承
- 多重继承通常使用多个基类,将这些基类的特性合在一起,形成一个包含所有基类功能的合成对象——混合编译
- 使用多重继承定义类时,多个基类在类头部后的冒号后指定,用逗号隔开,每个基类都有自己的访问修饰符
-
多重继承会造成模糊性,导致编译失败
-
派生类会继承多个直接基类的成员,这些成员可能会有相同的名称,这会造成继承成员的模糊性,可使用以下方法解决:
- 一开始就避免重复的成员名,已有重复成员名可以考虑可否重新编写类
- 在使用对象中继承的有同名的成员时,使用限定名:
std::cout << "cornflakes volume is " << cornflakes.Carton::volume() << std::endl << "cornflakes weight is " << cornflakes.Contents::getWeight() << std::endl;
- 使用时添加强制转换:
std::cout << "cornflakes volume is " << static_cast<Carton&>(cornflakes).volume() << std::endl << "cornflakes weight is " << static_cast<Contents&>(cornflakes).getWeight() << std::endl;
- 指定使用同名成员中的哪一个,而不再区分它们:
class CerealPack : public Carton, public Contents { public: using Cartion::volume; using Contents::getWeight; };
-
当定义派生类时,不能把一个类多次用作直接基类,但仍有可能出现间接基类重复的情况,如果出现多条继承路径中包含同一个间接基类,就会导致重复继承的模糊性。这种情况称为"菱形继承"或"钻石继承"问题。这可能导致基类成员在派生类中的重复定义和二义性。
- 在菱形继承问题中,可以允许基类的重复继承,但是在使用基类的成员时需要进行引用的限定,以避免二义性。可以通过以下两种方式进行引用的限定:
- 使用作用域解析运算符(::)来限定成员的引用:通过在成员前面加上基类的名称和作用域解析运算符,可以明确指定要引用的是哪一个基类的成员。例如,如果存在重复继承的基类Common,可以使用Base1::Common::value来限定引用Base1中的Common类的value成员。
- 使用using 声明来引入对基类成员的限定:可以使用using关键字来引入基类成员,将其纳入派生类的作用域中,以消除二义性。例如,可以在派生类中使用using Base1::value;来引入Base1中的value成员,然后直接使用value来访问它。
- 一般情况下,应该避免基类的重复,而不是上面使用限定。要解决这个问题,C++提供了虚继承(virtual inheritance)的机制。通过在继承关系中使用virtual关键字,可以告诉编译器只在继承路径中保留一份基类的实例,从而避免了重复继承问题。
- 当某个类通过虚继承间接继承了一个基类时,其他派生类在继承该基类时也必须使用虚继承。虚继承通过引入虚基类(virtual base class)来实现。虚继承通过共享一个虚基类的实例来避免多个副本的存在。在派生类中,对于每个虚基类,只会存在一个实例,而不管它在继承体系中被继承多少次。这样就避免了基类在派生类中被重复继承的问题。
#include <iostream> class Common { public: Common(int value) : commonData(value) {} void display() { std::cout << "Common Data: " << commonData << std::endl; } protected: int commonData; }; class Base1 : public virtual Common { public: Base1(int value) : Common(value) {} }; class Base2 : public virtual Common { public: Base2(int value) : Common(value) {} }; class Derived : public Base1, public Base2 { public: Derived(int value) : Common(value), Base1(value), Base2(value) {} }; int main() { Derived d(42); d.display(); return 0; }
- 在菱形继承问题中,可以允许基类的重复继承,但是在使用基类的成员时需要进行引用的限定,以避免二义性。可以通过以下两种方式进行引用的限定:
-
-
在相关的类类型之间转换
- 每个派生类对象都至少包含一个基类对象,把派生类型转换为基类类型总是合法且自动的。可以使用副本构造函数和复制赋值进行转换
Carton carton{40, 50, 60, "fiberboard"}; //副本构造函数 Box box1{carton}; //复制赋值 Box box2; box2 = carton;
- 类层次结构中向上的转换 (即向基类方向的转换) 只要没有模糊的成分,就是合法和自动的。
- 如使用限定成员名来解决菱形问题的二义性时会导致有多个基类子对象存在,就会出现模糊性,此时应将将派生类对象强制转换为某个中间类型的子对象
CerealPack cornflakes; Common common{static_cast<Carton&>(cornflakes)};
- 但若使用虚继承来解决,就没有问题。
- 如使用限定成员名来解决菱形问题的二义性时会导致有多个基类子对象存在,就会出现模糊性,此时应将将派生类对象强制转换为某个中间类型的子对象
- 对象不能自动实现向下的转换 (即向比较特殊的类进行转换)
- 每个派生类对象都至少包含一个基类对象,把派生类型转换为基类类型总是合法且自动的。可以使用副本构造函数和复制赋值进行转换
第14章 多态性
- 理解多态性
-
派生类对象总包含每个基类的完整子对象 (但每个基类只表示派生类对象的一部分)
–> 派生类对象是其基类的一个特例——它是一个基类对象
–> 可以使用任何直接或间接基类的指针存储派生类对象的地址 -
如有类层次结构如下:
定义如下变量:
Box* pBox {}; Box box{2.0, 1.0, 1.0}; Carton carton {10.0, 10.0, 5.0}; ToughPack hardcase {12.0, 8.0, 2.0};
- pBox的类型
Box*
被称为静态类型。静态类型指的是变量在声明时所采用的类型。它是在编译时确定的,表示变量可以引用的对象的类型。静态类型决定了在编译时可以调用哪些方法和访问哪些成员变量。 - 根据之前据说,pBox作为指向基类的指针,可以包含任何以Box为基类的对象的地址,可根据指向的对象类型而变化,称它具有动态类型。动态类型指的是在程序运行时实际存储在变量中的对象的类型。它可以是静态类型的子类或实现接口的类。动态类型决定了在运行时调用哪个对象的方法。
当pBox指向Carton对象carton时,它的动态类型就是“指向Carton类型的指针”;当pBox指向ToughPack对象hardcase时,它的动态类型就是“指向ToughPack类型的指针”;而当pBox指向Box对象box时,它的动态类型与静态类型相同。这就是多态性,它描述了同一操作可以在不同对象上具有不同行为的能力。通过多态性,可以使用父类或接口类型的引用来引用子类对象,并在运行时根据实际对象的类型调用相应的方法。pBox = &carton; pBox = &hardcase;
- 多态性总是要在调用成员函数时使用对象的指针或引用。使用基类指针调用成员函数时,编译器会根据基类指针的动态类型在运行期间选择调用基类和各个派生类中的哪个版本的成员函数。
- 多态性仅用于共享一个公共基类的类层次结构
- pBox的类型
-
- 虚函数
- 当我们在编程语言中调用一个函数或方法时,编译器或解释器会根据程序的静态信息来确定要调用的函数或方法。这个过程被称为函数调用的静态解析或静态绑定,或术语“早期绑定”。静态解析发生在编译时或解释时,而不是在程序运行时。
- 静态函数是指在编译时就可以确定调用的函数。编译器在编译过程中通过函数名和参数类型来确定要调用的函数。因此,静态函数的调用是固定的,不会根据运行时的对象类型而变化。
#include<iostream> class Box { protected: double length {1.0}; double width {1.0}; double height {1.0}; public: Box() = default; Box(double lv, double wv, double hv): length{lv}, width{wv}, height{hv} {} double volume() const {return length * width * height;} void showVolume() const {std::cout<< "Box usable volume is << volume() << std::endl;} //showVolume对volume的调用被静态绑定了 }; class Toughpack : public Box { public: ToughtPack(double lv, double wv, double hv) : Box {lv, wv, hv} {} double volume() const {return 0.85 * length * width * height;} }; int main() { Box box{2, 3, 4}; ToughPack hardcase{2, 3, 4}; box.showVolume(); hardcase.showVolume(); //每次调用showVolume()时,都使用所绑定的基类函数volume() std::cout << "hardcase volume is " << hardcase.volume() << std::endl; Box *pBox {&hardcase}; std::cout << "hardcase volume is " << pBox->volume() << std::endl; //hardcase.volume()和pBox->volume()的调用都是静态解析的 }
- 通过指针的静态函数调用仅取决于指针的类型,由静态类型而不是动态类型决定 --> 通过静态解析的基类指针来调用函数,都会调用基类函数。
- 在执行程序时根据指针指向的对象来调用成员函数的操作称为动态绑定或后期绑定,动态绑定是多态性的一种表现形式,它使得程序可以在运行时确定调用的函数或方法,而不是在编译时确定。通过动态绑定,可以实现基类指针或引用调用派生类对象的成员函数,从而实现不同对象上的不同行为。
- 虚函数是用于实现动态绑定的一种特殊函数。在基类中将一个成员函数声明为虚函数 (使用virtual限定符),相当于告诉编译器,在派生于这个基类的任何类中,该函数的调用都是动态绑定的。
- 在基类中声明为virtual的函数在从基类 (直接或间接) 派生的所有类中都是虚函数 (无论在那个派生类中是否将它指定为virtual)
- 可以在派生类中进行重写,以实现自有的虚函数版本
- 只能在类定义内部的声明或定义中添加限定符virtual
- 并不是所有的虚函数都需要在最一般的类中声明
- 使用类层次结构中间类对象的指针调用虚函数时,只能调用该中间类及其派生的类中的虚函数,而不能调用更一般的类中的虚函数
- 使用对象调用虚函数总是进行静态解析,只有通过指针或引用调用虚函数,才会进行动态解析
- 在基类变量中存储派生类对象会使派生类对象出现切片现象,所以基类变量没有派生类的特性
- 虚函数使用要求:
- 虚函数在任意派生类和基类中的定义都必须有相同的签名,一般情况下,返回类型也应该相同,当返回类型是类类型的指针或引用时除外,这种情况下,可用协变性处理。协变性通常是指函数返回类型在子类中可以是父类返回类型的子类型。换句话说,一个函数的返回类型可以在派生类中被放宽或扩展,而不是被限制。
- 虚函数不能是模板函数
- 不能对静态成员函数使用virtual关键字,因为对静态函数的调用总是静态解析的
- override限定符
- 显式指示派生类中的成员函数是覆盖(override)基类中的虚函数而不是重载,用于编译器验证基类是否用相同的签名声明了一个虚成员。有助于预防可能的错误,例如在函数签名中拼写错误从而导致意外创建了一个新的函数
- 总是要在虚函数重写的声明中添加override限定符
- override限定符仅出现在类定义中,不能用于成员函数的外部定义
- final限定符
- 禁止当前类的派生类重写当前类版本的某个函数。
- virtual是为了允许函数重写,而final是为了防止重写,因此这两个限定符无需同时在基类中使用
- 可同时使用override和final,顺序无关,意为禁止进一步重写自己重写的函数
double volume() const override final {}
- 把类指定为final将不允许使用这个类作为基类进行派生
class Carton final : public Box
- final和override都不是关键字
- 类层次结构中的虚函数访问修饰符
- 派生类中显式的访问修饰符仅影响静态解析的调用
–> 派生类中虚函数的访问修饰符可以不同于基类中的访问修饰符 - 在通过基类指针调用虚函数时,虚函数的访问权限由其在基函数中的访问修饰符决定
–> 在多态性调用中,虚函数在基类中的访部修饰符会被其所有派生类继承
虚函数的使用步骤如下:
- 派生类中显式的访问修饰符仅影响静态解析的调用
在基类中声明需要具有多态行为的成员函数为虚函数,在函数声明前加上 virtual 关键字。
在派生类中重写(Override)基类的虚函数,使用相同的函数签名(返回类型、函数名和参数列表)并加上 override 关键字。
通过基类指针或引用调用虚函数,会根据实际对象的类型动态决定调用哪个实现。
- 多态性引发的成本
- 确定动态类型
- 纯虚函数
第15章 运行时错误和异常
- 处理错误
- 理解异常
- 用类对象作为异常
- 重新抛出异常
- 未处理的异常
- 捕获所有的异常
- 不抛出异常的函数
- 异常和资源泄漏
- 标准库异常
第16章 类模板
- 理解类模板
- 定义类模板
- 定义类模板的成员函数
- 创建类模板的实例
- 非类型的类模板参数
- 模板参数的默认值
- 模板的显式实例化
- 类模板特化
- 在类模板中使用static_assert()
- 类模板的友元
- 带有嵌套类的类模板
第17章 移动语义
- lvalue和rvalue
- 移动对象
- 显式移动对象
- 看似矛盾的情况
- 继续探讨函数定义
- 继续讨论定义移动成员
第18章 头等函数
第19章 容器与算法
第20章 STL
20.1 容器(Containers)
type | 功能 | 描述 |
---|---|---|
std::vector<> 封装动态大小数组的序列容器。 | .reserve(size_type n) | 为向量申请内存空间 |
.at(size_type n) | 返回对位置元素的引用 | |
.erase(iterator) | //删除一个元素 vector::erase(iterator position); //删除一个范围内的元素 vector::erase(iterator start_position, iterator end_position); 它返回一个迭代器,指向由 vector::erase() 函数擦除的最后一个元素后跟的元素。 | |
std::unordered_map<key, value> | .end() | 返回一个迭代器,该迭代器指向unordered_map容器中容器中最后一个元素之后的位置 |
.find(key) | 如果给定的键存在于unordered_map中,则它向该元素返回一个迭代器,否则返回映射迭代器的末尾,因此可配合 end()判断某键值对是否在map中 | |
.at(key) | 返回对应元素value的引用 | |
.count(key) | 检查unordered_map中是否存在具有给定键的元素,如果Map中存在具有给定键的值,则此函数返回1,否则返回0。 | |
.emplace() | 向容器中添加新键值对,效率比 insert() 方法高。 | |
.empty() | 若容器为空,则返回 true;否则 false。 | |
.size() | 返回当前容器中存有键值对的个数。 | |
std::set 按照特定顺序存储; 关联容器,key就是value,key唯一; | .insert() | 在集合容器中插入元素 |
答:
算法(Algorithms)
迭代器(iterators)
Eigen
-
Eigen内存分配器Eigen::aligned_allocator
在使用Eigen的时候,如果STL容器中的元素是Eigen数据库结构,比如下面用vector容器存储Eigen::Matrix4f类型或用map存储Eigen::Vector4f数据类型时:std::vector<Eigen::Matrix4d>; std::map<int, Eigen::Vector4f>;
这么使用编译能通过,但运行时会报段错误。
对eigen中的固定大小的类使用STL容器的时候,如果直接使用会出错,所谓固定大小(fixed-size)的类是指在编译过程中就已经分配好内存空间的类,为了提高运算速度,对于SSE或者AltiVec指令集,向量化必须要求向量是以16字节即128bit对齐的方式分配内存空间,所以针对这个问题,容器需要使用eigen自己定义的内存分配器,即aligned_allocator。
这个分配器所在头文件为:
#include <Eigen/StdVector>
根据STL容器的模板类,比如vector的声明:
template<typename _Tp, typename _Alloc = allocator<_Tp> > class vector : protected _Vector_base<_Tp, _Alloc> { ..... }
使用aligned_alloctor分配器,上面的例子正确写法为:
//std::vector<Eigen::Matrix4d>; //std::map<int, Eigen::Vector4f>; std::vector<Eigen::Matrix4d,Eigen::aligned_allocator<Eigen::Matrix4d>>; std::map<int, Eigen::Vector4f, Eigen::aligned_allocator<std::pair<const int, Eigen::Vector4f>>;
上述的这段代码才是标准的定义容器方法,只是我们一般情况下定义容器的元素都是C++中的类型,所以可以省略,这是因为在C++11标准中,aligned_allocator管理C++中的各种数据类型的内存方法是一样的,可以不需要着重写出来。但是在Eigen管理内存和C++11中的方法是不一样的,所以需要单独强调元素的内存分配和管理。
-
std::allocate_shared
用法:std::shared_ptr<类型A> 指针变量B = std::allocate_shared<类型A> (类型A的allocator, 指针变量B所指向的类型A的参数列表);std::shared_ptr<std::pair<int,int>> baz = std::allocate_shared<std::pair<int,int>> (std::allocator<int>,30,40); //类型Frame中含有Eigen类型的变量 std::shared_ptr<Frame> pkf = std::allocate_shared<Frame>(Eigen::aligned_allocator<Frame>(), *pcurframe_);