CPP中级
一如既往,开始还是先说一下这篇博客的动机。本来是打算这段时间好好把STL给过一遍,然后把《STL源码剖析》看了之后自己找个小项目参考参考实现小型STL库,结果问题就出现了。因为之前学C++其实一直都属于是自学状态,很多重要的概念以及需要动手操练的部分我为了赶进度基本上没碰过,这就导致STL里面的很多东西我看的头晕脑胀(说白了还是自己的基础太差了),为了弥补自己在coding方面的不足,所以决定再找两本书来静下心来好好看看并理解C++中的高级特性。
阅读方法:
- 强烈的学习欲望(不要为了装逼看完一本大部头的书,这是在找虐)
- 保持专注,学习时候远离手机等分心产品,使用番茄钟方法(休息时间不要看那种分心的东西),需要十多分钟预热是正常的
- 每章阅读两遍,第一遍过一遍快速掌握章节内容,期间对重点、不理解的地方做标记,不要死磕某个难点。第二遍做md的同时深入理解第一遍留下的难点
“C++支持多种抽象形式:抽象数据类型(ADT)、面向对象程序设计(OOP)、泛型程序设计(GP)” —— 《C++沉思录》
——第一部分 基本语言——
1.第一章_快速入门
1.1 main()函数
每个C++程序都包含一个或多个函数,而且必须有一个命名为main。函数由执行函数功能的语句序列组成。操作系统通过调用main函数来执行程序,main函数则执行组成自己的语句并返回一个值给操作系统。
main函数是(唯一)被操作系统显式调用的函数。
main函数的返回值必须是int型,该类型表示整数。int类型是内置类型,即该类型是由C++语言定义的。我们之前写的什么void main()是违法的,尽管可以这样写,但是并不推荐这样写,有些编译器会因此报错
1.2 iostream库
iostream库(简称IO库,是标准库的一部分)用于处理格式化输入和输出,iostream库的基础是两种命名为istream
和ostream
的类型,分别表示输入流和输出流。流是指要从某种IO设备上读入或写出的字符序列。术语“流”试图说明字符是随着时间顺序生成或消耗的。
iostream库定义了4个IO对象:
- 处理输入的istream类型的cin对象,也称为标准输入;
- 处理输出的ostream类型的cout对象,也称为标准输出;
- ostream类型的ceer对象,也称为标准错误;
- ostream类型的clog对象,用于产生程序执行的一般信息;
endl操作符
endl是一个特殊值,称为操纵符(manipulator),将它写入输出流时,具有输出换行的效果,并刷新
与设备相关联的缓冲区(buffer)。
作为程序员,我们在调试程序的过程中插入输出语句,这些语句应该都书信输出流也就是使用endl操作符,避免输出停留在缓冲区。
1.3 注释
- 当注释跨越多行时,最好能直观地指明每一行都是注释。我们的风格是在注释的每一行以星号开始,指明整个范围是多行注释的一部分;
1 |
|
- 最好将一个注释块放在所解释的代码的上方;
1.4 类简介
C++中通过定义类来定义自己的数据结构,每个类定义一种类型,类型名与类名相同,创建类类型的变量(也称为实例化对象)和创建普通类型变量是一样的(不要加括号!!!)
1 |
|
成员函数
成员函数是由类定义的函数,也称为类方法;
同一个类的所有对象共享一组类方法,即每个类方法只存在一个副本;
使用点操作符(.)来调用成员函数,使用调用操作符(())来执行成员函数;
2.第二章_变量和基本类型
2.1 基本数据类型
2.1.1 整型
整型中的字符类型有两种:char和wchar_t。char类型保证有足够的空间,能够存储机器基本字符集中任何字符相应的数值,因此,char类型通常是单个机器字节(byte)。wchar_t类型用于扩展字符集,比如汉字和日语,这些字符集中的一些字符不能用单个char表示。
2.1.2 浮点型
决定使用哪种整型可能会让人费解,但是决定使用float | double | long double我们毫无疑问应该选择double类型
2.2 字面值常量
字面值:只能用它的值称呼它
常量:它的值不能修改
每个字面值都有相应的类型,只有内置类型存在字面值,不存在类类型的字面值
所有的字面值都是常量,但常量不一定是字面值(字面值是常量的一种)
2.2.1 整型字面值
1 |
|
注意:没有short类型的整型字面值常量
2.2.2 浮点型字面值
默认浮点字面值常量为double类型
2.2.3 转义字符
非打印字符又称为不可显示字符(因为cout的时候这些特殊字符没法正确显示比如tab缩进),当然这些不可显示字符并不是绝对不可显示的,我们可以使用转义序列进行转义后就能正常cout了
当然除了上述特殊字符可以使用转义序列外,任何字符都可以使用以下通用转义形式表示
2.3 变量
变量提供了程序可以操作的有名字的存储区。C++中的每一个变量都有特定的类型,该类型决定了变量的内存大小和布局、能够存储于该内存中的值的取值范围以及可应用在该变量上的操作集。C++程序员常常把变量称为“变量”或“对象(object)”。
一般来说,C++程序员所说的对象就是内存中具有类型的区域,我们不需要具体区分程序中可操作的数据究竟是内置类型还是类类型,都可以成为对象
2.3.1 左值和右值
左值表达式:左值可以出现在赋值语句的左边或右边——变量是左值,可以出现在赋值语句的左边或者右边
右值表达式:右值只能出现在赋值的右边,不能出现在赋值语句的左边——字面值是右值,不能被赋值
2.3.2 变量名
变量名也就是变量的标识符,不能使用关键字作为标识符,也不能使用C++操作符替代名作为标识符
2.3.3 定义 | 声明 |初始化
(1)定义
变量定义(也可以称为对象定义),指定了变量的类型和标识符,在定义的同时可以为对象提供初始值,这称为变量的初始化
(2)初始化
C++支持两种初始化变量的形式:
- 复制初始化,使用(=)
- 直接初始化,直接将初始化参数放在括号中
初始化并不绝对等于赋值,初始化是指创建/定义变量并给它赋予初始值,赋值是指擦除对象的当前值并使用新的值代替(该概念在编写复杂类的时候需要注意);
概念辨析:
- 初始化类类型的对象时,复制初始化和直接初始化之间的差别很细微,直接初始化语法更加灵活且效率更高;
- 初始化内置类型的对象时,无论使用的是复制初始化还是直接初始化其底层基本原理都是提供一个值并将该值复制到新定义的对象中,因此对于内置类型的对象复制初始化和直接初始化几乎完全等价;
- 对于类类型的对象来说,因为涉及到自定义构造函数,所以有些情况下的初始化只能使用直接初始化完成(当我们要使用的构造函数有多个参数的时候,只能使用直接初始化)
内置类型变量的初始化:
内置类型变量是否自动初始化取决于变量定义的位置:
- 在函数体外部定义的变量都初始化为0;
- 在函数体内部定义的内置类型变量不进行自动初始化;
类类型变量的初始化:
类通过定义一个或多个构造函数来控制类对象的初始化;
(3)声明
C++中变量的定义和声明并不一样:
- 变量的定义为变量分配存储空间,同时也可以为变量指定初始值(初始化),在一个程序中一个变量的定义有且仅有一个(定义就是创建变量,通常把一个对象定义在首次使用它的地方);
- 声明用于向程序表明变量的类型和名字,定义也算是一种声明:定义变量的时候我们声明了它的类型和名字;
定义和声明的区别:参考自C++之声明与定义的区别 - chenyangsocool - 博客园 (cnblogs.com)
1 |
|
在头文件中不能放
普通变量
的定义,一般存放变量的声明。因为头文件要被其他文件包含,如果放到头文件当中就不能避免变量被多次定义;例外是头文件可以定义类、const对象(因为const变量默认是定义该变量的文件中的局部变量)和inline函数,只要这些实体在每个源文件中的定义是相同的则可以在多个源文件中重复定义;
关于类的声明可以参考c++中定义和声明的区别 - 百度文库 (baidu.com)
2.4 类类型
C++通过定义类来自定义数据类型;
类定义以class关键字开始,接着是该类的名字标识符,类体位于花括号中,花括号后面必须跟上一个分号;
类体可以是空的,类体中的定义组成了该类型的数据和操作,这些数据和操作是类的一部分也称为类的成员,操作称为成员函数,数据称为数据成员;
类的数据成员的定义方式与普通变量的定义相似,但是两者之间有一个非常重要的区别:类的数据成员不能在类定义中初始化,只能通过构造函数来控制初始化;
除了class关键字,C++还支持使用关键字struct来定义一个类,struct关键字是从C语言继承而来;
如果使用class关键字来定义类,那么定义在第一个访问标号前的任何成员都隐式指定为private;如果使用struct关键字,那么这些成员都是public;
使用class还是struct关键字来定义类,仅仅影响默认的初始访问级别(当然我们实际上在使用的过程中仍然使用class定义类使用struct定义一些常用结构体)
2.5 引用类型
引用是一种复合类型(复合类型是指由其他类型定义的类型),通过在变量名前添加&符号来定义(注意,C++只是赋予了&新的含义,用于定义引用,而&本质上并没有被重载);
实际程序中引用主要用作函数的形式参数;
不能定义引用类型的引用,除此之外可以定义其他任何类型的引用;
引用变量必须使用与该引用同类型的对象进行初始化
1 |
|
引用初始化后,只要该引用存在它就永远保持绑定到初始化时指向的对象,不能将引用绑定到另一个对象上(因此const引用不存在二义性);
const引用是指指向const对象的引用,可以绑定到不同但是相关的类型对象或右值上(对非const引用来说这是不合法的)
1 |
|
此时的引用变量refval只能读取但是不能修改(当然也不可以直接对val赋值,因为val对象也是一个const)
3.第三章_标准库类型
Q:既然拥有继承为什么还需要模板?两者有什么区别?
A:模板是编译时多态,继承是运行时多态(宏观上来看两者似乎真的很相似)
- 当对象的类型不影响类中函数的行为时使用模板(模板的好处是方便重复,但是对于任何有一点点不同的东西都需要重新实现整个模板);
- 当对象的类型影响类中函数的行为时使用继承;
如果我们将模板和继承集合在一起就出现了CRTP模式,可以参考继承、模板与 CRTP —— 谈谈 C++ 多态设计模式 - 知乎 (zhihu.com)
3.1 vector类型
vector是同一种类型的对象的集合,我们将包含其他对象的这种性质称为容器,注意一个容器中的所有对象必须是同一种类型的;
vector是一个类模板,可用于多个不同的数据类型编写类定义(可以定义保存string对象的vector,也可以定义保存int值的vector);
vector不是一种数据类型,仅仅只是一个类模板,类模板的作用才是用于定义多种数据类型,所以vector< int >和vector< string >都是数据类型,可以定义对应的对象;
3.2 迭代器
除了使用下标来访问vector对象的元素外,标准库还提供了迭代器来访问元素,迭代器是一种检查容器内元素并遍历元素的数据类型
;
C++之所以倾向于使用迭代器,是因为标准库为每一种标准容器定义了对应的迭代器类型(当然其实每种容器类都拥有不止一个迭代器)—— 因此所有的标准容器都可以使用对应的迭代器,但是只有少数的容器支持下标操作(如vector),所以迭代器的适用范围更广;
4.第四章_数组和指针
数组和指针是C++的内置数据类型,这两种类型是介于vector和string这种高级的抽象数据类型和第二章介绍的基本数据类型(低级数据类型)中的低级抽象类型;
现代C++应该尽量使用vector和迭代器而避免使用低级的数组和指针(数组和指针是类似于vector和迭代器的低级复合类型,除了运行速度稍微快一点以外,优势不及前面两者)
指针和数组在现代C++中已经逐渐被淘汰了,因为指针和数组往往会出现一些不可预知的问题;
4.1 数组
与vector不同,一个数字不能用另外一个数组初始化,也不能将一个数组赋值给另一个数组;
数组一旦经过定义,就不允许再添加新元素;
除了程序员自己注意细节并彻底测试自己的程序外,没有别的办法可以防止数组越界;
4.2 指针
指针是指向某种类型对象的复合数据类型,是用于数组的迭代器:指向数组中的某个元素
;
尽管指针是C程序中的重要部分,但在C++程序中仍然有作用;
与迭代器不同,指针还可以用于指向单个对象(指针结构更为通用),而迭代器只能用于访问容器内的元素;
每个指针都有一个与之关联的数据类型,该数据类型决定了指针所指向的对象的类型:如一个int型的指针只能指向int型的对象,因此初始化或者赋值时必须保证类型匹配:int型指针只能把其指向的对象当作int型数据来处理;
一个有效的指针必然是以下三种状态之一:
- 保存一个特定对象的地址;
- 指向某个对象后面的另一个对象(指针算数);
- 0值(NULL,也就是空指针)表明该指针不指向任何对象;
4.2.1 void*指针
void*指针可以保存任何类型对象的地址;
void*仅仅表示该指针与某一地址值相关,并不反映存储在该地址上的对象的类型;
void*指针仅支持几种有限的操作,不允许使用void *指针操作它指向的对象;
4.2.2 指针和引用
虽然使用引用(reference)和指针都可间接访问另一个值,但它们之间有两个重要区别:
第一个区别在于引用总是指向某个对象:定义引用时没有初始化是错误的;
第二个重要区别则是赋值行为的差异:给引用赋值修改的是该引用所关联的对象的值,而并不是使引用与另一个对象关联。引用一经初始化,就始终指向同一个特定对象(这就是为什么引用必须在定义时初始化的原因);
4.2.3 指针的指针
C++使用**操作符指派一个指针指向另一个指针
1 |
|
为了访问真正的val对象,ipi指针需要两次解引用
1 |
|
注意,很容易混淆引用&和取地址&,可以使用一句话概括:与类型一起使用的是引用,与变量一起使用的是取地址
int &b=a;//引用
int *p=&a;//取地址
4.2.4 指针和数组
在表达式中使用数组名时,该名字会自动转换为指向数组第一个元素的指针;
5.第五章_表达式
C++支持操作符重载,允许程序员自定义用于类类型的操作符含义(标准库就是使用重载定义用于库类型的操作符);
表达式由一个或多个操作数通过操作符组合而成;
最简单的表达式仅包含一个字面值常量或变量,含有两个或多个操作符的表达式称为复合表达式;
每个表达式都会产生一个结果,如果表达式没有操作符则其结果就是操作数本身;
操作符执行什么操作以及操作结果的类型取决于操作数的类型;
5.1 自增、自减操作符
自增(++)和自减(–)操作符为对象加1或减1操作提供了方便简短的实现方式。它们有前置和后置两种使用形式:
- 前置操作返回(+1)后的值,所以返回对象本身,即返回左值;
- 后置操作返回右值;
因为前置操作直接返回结果所以需要的性能开销非常小,所以尽量使用前置而少用后置;
在同一个表达式里,不要在两个或多个子表达式中对同一对象做自增或自减操作;
5.2 逗号操作符
逗号表达式是一组由逗号分隔的表达式,这些表达式从左向右计算。逗号表达式的结果是其最右边表达式的值。如果最右边的操作数是左值,则逗号表达式的值也是左值。
6.第六章_语句
语句类似于自然语言中的句子。C++语言既有只完成单一任务的简单语句,也有作为一个单元执行的由一组语句组成的复合语句(语句块);
通常情况下语句是顺序执行的,结合C++提供的控制流语句可以有条件的执行或者重复执行某部分的功能;
6.1 空语句
空语句用于语法上需要一个语句但是逻辑上不需要的地方,如
1 |
|
6.2 复合语句
复合语句也被称为块,是用一对花括号括起来的语句序列(当然也可以是空的或者只包含空语句);
与其他大多语句不同,块并不是以分号结束的(while语句没有分号,do while语句总是以分号结束);
6.3 goto语句
goto 语句提供了函数内部的无条件跳转,实现从goto语句跳转到同一函数内某个带标号的语句(然而这个语句已经开始不主张了,因为使用goto会导致跟踪程序的控制流程变得困难且程序复杂)。
- 语法格式
1 |
|
其中label是用于标识带标号的语句的标识符
,在任何语句前提供一个标识符和冒号,即得带标号的语句:
1 |
|
形成标号(label)的标识符只能用作goto的目标,故标号标识符可以与变量名以及程序里的其他标识符一样,但要注意不能与别的标号标识符重名;
goto语句和获得所转移的控制权的带标号的语句必须位于同一个函数内,goto语句不能跨越变量的定义语句向前跳转(这可能导致变量在没有定义的情况下被使用);
6.4 异常
首先我们要注意区分一下代码错误和运行时异常,可以参考:(22条消息) C/C++ 中错误与异常的区别 【汇总】_呐c的博客-CSDN博客
不要尝试使用异常处理机制来应对编译时产生的错误如少写了花括号、冒号等,异常处理机制是在程序运行时启动的,如果代码连编译都不能通过别说运行了;
异常:程序运行时出现的不正常情况(运行时内存耗尽、非法输入…),异常存在于程序的正常功能外,要求程序立即处理(否则会吗默认导致不正常的退出)
C++的异常处理机制提供程序中异常检测部分以及异常处理部分之间的通信:
- throw 表达式(throw expression),错误检测部分使用这种表达式来说明遇到了不可处理的错误。可以说,throw引发(raise)了异常条件。
- try块(try block),错误处理部分使用它来处理异常。try语句块以try关键字开始,并以一个或多个catch子句(catch clause)结束。在try块中执行的代码所抛出(throw)的异常,通常会被其中一个catch子句处理。由于它们“处理”异常,catch子句也称为处理代码(handler)。
- 由标准库定义的一组异常类(exception class),用来在throw和相应的catch之间传递有关的错误信息。
6.4.1 throw表达式
throw表达式的类型决定抛出异常的类型
1 |
|
这里throw表达式是runtime_error类型的对象,通过传递string对象作为构造函数的参数来创建runtime_error对象,以提供更多关于出现的问题的相关信息;
6.4.2 try块
try块以关键字try开始,接着是用花括号括起来的语句序列块(这个序列块中包含了throw表达式);
try块后面是一个或多个catch子句,每个catch子句包括三部分:关键字catch,圆括号内单个类型或者单个对象的声明一称为异常说明符,以及通常用花括号括起来的语句块;
1 |
|
what是runtime_error类的一个成员函数,每一个标准库异常类都定义了名为what的成员函数(异常类型只定义了有且仅有一个的名为what的操作),这个函数不需要参数,返回C风格字符串。在出现runtime_error的情况下,what返回的C风格字符串,是用于初始化runtime_error的string对象的副本也就是”Data must refer to same ISBN”
异常处理过程中可能的情况:
- 如果不存在处理该异常的catch子句,程序的运行就要跳转到名为terminate的标准库函数,该函数在exception头文件中定义。这个标准库函数的行为依赖于系统,通常情况下,它的执行导致程序非正常退出;
- 在程序中出现的异常,如果没有经try块定义,则都以相同的方式来处理:如果发生了异常,系统将自动调用terminate终止程序的执行(没有异常处理机制则发生异常直接退出程序);
6.4.3 标准异常
C++标准库定义了一组类,用于报告在标准库中的函数遇到的问题,标准库异常类定义在四个头文件中:
(1)exception头文件定义了最常见的异常类,它的类名是exception。这个类只通知异常的产生,但不会提供更多的信息。
(2)stdexcept头文件定义了几种常见的异常类。
(3)new头文件定义了bad_alloc异常类型,提供因无法分配内存而由new抛出的异常。
(4)type_info头文件定义了bad_cast异常类型。
7.第七章_函数
函数可以简单的看作是由程序员自定义的一系列操作:
- 与内置操作符相同,大多数时候函数会实现一系列的计算后返回一个计算结果,同时函数也可以和操作符一样可以重载;
- 与操作符不同,函数有自己的函数名并且不会限制操作数的数量;
7.1 函数定义
函数由函数名以及一组操作数类型唯一的表示(注意没有函数返回值类型!这也是函数重载的关键)
7.1.1 返回值
函数的返回类型可以是内置类型(如int或者double)、类类型或复合类型(如int&或string*),还可以是void类型,表示该函数不返回任何值;
函数不能返回另一个函数或者内置数组类型,但可以返回指向函数的指针,或指向数组元素的指针的指针;
定义或声明函数时
必须
显式指定返回值类型,否则将是不合法的(C++标准化之前缺少显式返回值类型编译器会自动假定返回值为int型)
7.1.2 形参列表
函数形参表可以为空,但不能省略。没有任何形参的函数可以用空形参表或含有单个关键字void的形参表来表示。例如,下面关干process的声明是等价的:
1 |
|
如果函数参数具有相同类型,不可以省略,需要重复声明其类型,同时参数列表中不允许出现同名的参数
1 |
|
7.2 函数参数
每次调用函数时,都会重新创建该函数所有的形参,此时所传递的实参将会初始化对应的形参(函数的运行以形参的隐式定义和初始化开始);
7.2.1 非引用形参
普通的非引用类型的参数通过复制对应的实参实现初始化。当用实参副本初始化形参时,函数并没有访问调用所传递的实参本身,因此不会修改实参的值。
非引用形参表示对应实参的局部副本。对这类形参的修改仅仅改变了局部副本的值。一旦函数执行结束,这些局部变量的值也就没有了。
(1)指针形参
当函数的形参非引用指针形参时,此时将复制实参指针(意味着这两个指针指向同一个对象),当此指针形参是非const类型的时候(此处翻译问题,注意不是非const指针而是指向的对象不是const类型),可以通过该形参指针修改指针指向的对象的值
- 可以将指向const对象的指针初始化为非const对象(形参指向const对象注意不是const指针,实参指向const对象);
- 不可以让指向非const对象的指针初始化为const对象(这与下面我们介绍const形参不同);
(2)const形参
- 在调用函数时,如果该函数使用非引用的非const形参,则既可给该函数传递const实参也可传递非const的实参(这种行为源于const对象的标准初始化规则,非引用形参初始化
复制
了初始化式的值,所以可用const对象初始化非const对象,反之亦然); - 如果形参是非引用的const形参,由于实参仍然是以副本初始化,所以实参既可以是const对象也可以是非const对象;
不适合使用非引用参数的情况:
- 当需要在函数中修改实参的值时;
- 当需要以大型对象作为实参传递时。对实际的应用而言,复制对象所付出的时间和存储空间代价往往过大(当然上述指针形参不存在这个问题);
- 当没有办法实现对象的复制时(数组不能被复制);
7.2.2 引用形参
引用形参直接关联到其所绑定的对象,而并非这些对象的副本,每次调用函数,引用形参被创建并与相应实参关联;
使用引用形参的主要好处:
- 函数利用引用形参修改实参的值;
- 向主调函数返回额外的结果(
任何普通函数都只能返回单个值
); - 主调函数向被调函数传递大型对象以及某些无法复制的类型时;
(1)指针形参
使用*定义指针,&定义引用,如何编写一个指针作为引用对象的函数呢?
1 |
|
(2)const形参
假如使用引用形参的唯一目的是为了避免复制实参,则应当将形参定义为const;
我们习惯将不需要修改的引用形参定义为const类型,因为非const引用形参既不能使用const对象初始化,也不能使用字面值或者产生右值的表达式初始化(因为引用形参的初始化方式不是复制而是直接引用);
7.2.3 数组形参
因为数组不能复制以及数组名自动转换为指向数组第一个元素的指针,所以这里选择使用指针形参(或者引用形参)而不是数组类型的形参来使用函数处理数组;
使用指针形参时有以下3种方式指定数组形参:
1 |
|
这3种方式是等价的,形参类型都是*int,但是注意,第一种方式最好,第三种方式容易引起误解(形参为非引用的情况下编译器直接会忽略数组长度,而引用形参的数组大小成为形参和实参类型的一部分会被检查)
7.2.4 可变形参
C++只能将简单的数据类型传递给含有省略符形参的函数;
在无法列举出传递给函数的所有实参的类型和数目时,可以使用省略符形参。省略符暂停了类型检查机制。它们的出现告知编译器,当调用函数时,可以有0或多个实参,而实参的类型未知。省略符形参有下列两种形式:
1 |
|
7.3 函数返回值
return 语句用于结束当前正在执行的函数,并将控制权返回给调用此函数的函数,return语句有两种形式:
1 |
|
7.3.1 无返回值return
不带返回值的return语句只能用于返回类型为void的函数(其实既然函数的返回类型为void那么return语句此时已经不是必须的了,可以直接不写,编译器会自动在函数末尾补上隐式return语句返回None);
一般情况下返回值类型是void的函数使用return语句仅仅只是为了引起函数的强制结束(比如跳出多层循环),这种用法类似于break;
返回值为void的函数可以返回另一个返回类型同样是void的函数的调用结果
1 |
|
7.3.2 有返回值的函数
任何返回类型不是void的函数都必须返回一个值,而且这个返回值的类型必须和函数的返回类型相同,或者能隐式转化为函数的返回类型(没有例外!!!没有return语句编译器直接报错)
return的原理:函数声明时的返回类型指明,函数出栈后要去寄存器中取值(取出来的是return值的地址),函数中没有return,只是没有给这个寄存器存入合法的值。函数出栈后还是会去读寄存器,只是读出来的是垃圾数据;函数缺少return语句是一种未定义行为,且编译器可能检测的出也可能检测不出这种错误,这可能导致在运行阶段出现问题;
这里有一个例外,同时也是我们之前一直纠结的问题
允许主函数main没有返回值就可结束,如果程序控制执行到主函数main的最后一个语句都还没有返回,那么编译器会隐式地插入返回0的语句(关于main函数的另一个特性是,main函数不允许递归也就是调用自身)
7.3.3 返回非引用
函数的返回值用于初始化在调用函数处创建的临时对象(temporary object)。在求解表达式时,如果需要一个地方储存其运算结果,编译器会创建一个没有命名的对象,这就是临时对象。
用函数返回值初始化临时对象与用实参初始化形参的方法是一样的。如果返回类型不是引用,在调用函数的地方会将函数返回值复制给临时对象。当函数返回非引用类型时,其返回值既可以是局部对象,也可以是求解表达式的结果。
7.3.4 返回引用
当函数返回引用类型时,没有复制返回值。相反,返回的是对象本身
千万不要返回局部对象的引用,也不要返回指向局部对象的指针(因为函数执行完毕局部对象的内存空间将被释放,这将导致局部对象的引用或指针指向不确定的内存);
当然上述的情况有一种例外,就是我们将局部变量变为静态局部变量(生命周期跨越了函数的多次调用,这种对象一旦被创建,在程序结束前都不会被撤销,静态变量会持续存在并保存它的值)
7.4 函数重载
函数不能仅仅基于不同的返回类型而实现重载;
形参与const形参的等价性仅适用于非引用形参(对于非引用形参来说const形参和非const形参没什么区别),有const引用形参的函数与有非const引用形参的函数是不同的。类似地,如果函数带有指向const类型的指针形参,则与带有指向相同类型的非const对象的指针形参的函数不相同(因此可重载);
7.5 函数指针
这章难度太大了,建议重复观看书P237~P239(函数指针有什么用?参考(22条消息) 函数指针的用途_hellokandy的博客-CSDN博客)
函数指针是指指向函数而非普通对象的指针,而这种指针的声明方式也比较特别,指针类型由函数返回类型和形参列表决定
1 |
|
我们从上面看到函数指针相当的冗长,可以使用typedef简化
1 |
|
函数指针只能通过同类型的函数或者函数指针或者0值常量表达式进行初始化或赋值,将函数指针初始化为0表明该指针不指向任何函数;
只有当指针初始化或者指向某个函数才能用于调用函数,不能将没有初始化的函数指针或者0值指针来调用函数;
8.第八章_标准IO库
C++的一切输入和输出都由IO标准库提供支持,标准库提供了一系列的类型,这些IO类定义了如何读写内置类型的值(对于用户自定义的类类型,也可以使用IO标准库为内置类型定义的操作符和规则来进行读写),IO标准库提供的类型和对象主要用于:
- 程序与用户控制窗口的交互;
- 程序读写已命名的文件;
- 程序使用IO操作格式化内存中的数据;
- 程序支持宽字符语言的读写(如汉字);
常用的IO标准库提供的工具(类和对象)如下:
-
istream(输入流)类型,提供输入操作。
ostream(输出流)类型,提供输出操作。
cin:读入标准输入的istream对象。
cout:写到标准输出的ostream对象。
cerr:输出标准错误的ostream对象。cerr常用于程序错误信息。
(>>)操作符,用于从istream对象中读入输入。
<<操作符,用于把输出写到ostream对象中。
getline函数,需要分别取istream类型和string类型的两个引用形参,其功能是从istream对象读取一个单词,然后写入string对象中。
IO标准库中提供的IO类型在三个独立的头文件中定义:
iostream头文件定义读写控制窗口的类型;
fstream头文件定义读写已命名文件的类型;
sstream头文件所定义的类型用于读写存储在内存中的string对象;
在fstream和sstream中定义的每种类型都是从iostream头文件中定义的相关类型派生而来;
任何IO对象都不能赋值或复制,也就是IO标准库类型不允许复制或赋值的操作;
——第二部分 容器和算法——
(这里讲的基本上都是一些常用容器的共性,需要详细查看各种容器的使用方法参考 STL初级 ),这部分对应书上第九章 —— 第十一章
1.容器类
普通数组不是容器类,容器类是高级抽象数据类型,数组是低级复合类型,只不过数组和指针用法类似于容器和迭代器;
容器类(又称为容器类型)共享公共的接口,只需要学会其中一种类型就能将其运用到另一种类型,通常不需要修改代码而只修改类型声明即可用一种容器类型代替另一种容器类型进而优化程序性能;
容器只定义了少量操作,大多数额外操作则由算法库提供(而迭代器提供的运算是使用标准库算法的基础),标准库为由容器类型定义的操作强加了公共的接口,不同容器类型的差别在于它们提供哪些操作,如果两个容器提供了相同的操作,则它们的接口(函数名字和参数个数)应该相同,容器的操作的集合应该具有以下的层次结构:
- 一些操作适用于所有容器类型;
- 一些操作只适用于顺序或关联容器类型;
- 一些操作只适用于顺序或关联容器类型的一个子集;
所有的容器都是类模板,要定义某种特定的容器(也就是创建一个容器对象)则需要再容器后带上 <容器中存放元素的类型>
1 |
|
所有的容器类型都定义了默认构造函数
,用于创建指定类型的空容器对象(如上所示)。默认构造函数不带参数,容器类型最常用的构造函数是默认构造函数,在大多数的程序中,使用默认构造函数能达到最佳运行时性能,并且使容器更容易使用;
1.1 容器的初始化
除了默认构造函数,容器类型还提供其他的构造函数,使程序员在创建容器对象的时候可以指定元素初值:
当不使用默认构造函数,而是用其他构造函数初始化顺序容器时,必须指出该容器有多少个元素,并提供这些元素的初值:
- C c(c2):将一个容器复制给另一个容器时,类型必须匹配:容器类型和元素类型都必须相同;
1 |
|
- C c(b,e):当不具备相同类型的容器内的元素需要复制以初始化另一种类型的容器对象,可以使用迭代器,使用迭代器不需要容器类型相同也不需要容器内的元素类型相同(只要这两种元素可以互相兼容,迭代器能够将复制元素类型转换为新容器元素类型)
1 |
|
- C c(n,t)
- 创建顺序容器时,可显式指定容器大小和一个(可选的)元素初始化式,容器大小可以是常量或非常量表达式(接受容器大小作为形参的构造函数只适用于顺序容器),元素初始化式则必须是可用于初始化其元素类型的对象的值;
- 当然也可以选择只指定容器的大小,此时将由标准库为该容器的元素实现初始化,这就要求元素类型必须是内置类型或提供了默认构造函数的类类型,否则必须显式指定元素初始化式;
1.2 元素的约束
容器的元素类型必须满足如下两个约束:
- 元素类型支持赋值运算(引用类型不支持一般意义的赋值运算);
- 元素类型的对象必须可以复制(IO库类型不支持复制和赋值);
Q:数组类型的对象可以作为容器的元素吗?
1.3 顺序容器
每种顺序容器都提供有以下的操作(这些操作是每种顺序容器都具备的):
在容器中添加元素;
在容器中删除元素;
设置容器大小;
获取容器内的第一个和最后一个元素(如果有的话);
1.3.1 类型别名
我们知道可以使用typedef来定义类型的同义词
- 格式
1 |
|
- 举例
1 |
|
使用由typedef定义的类型别名来作为类型说明符定义变量与常规类型说明符的使用方法是一致的
1 |
|
容器作为一种模板类,同样可以自定义一些类型(实际上是某些已存在的基于容器的数据类型的类型别名),我们拿string类类型中的string::size_type类型来说明;
许多标准库类型都定义了一些配套类型,通过这些配套类型,库类型的使用与机器无关,size_type类型定义为(当然应该没这么简单,这只是我现阶段能理解的方式)
1 |
|
我们给出一些通用的由容器定义的类型别名(由容器定义其实这种说法不好,类型别名的定义与容器无关,只是在使用的时候需要加上作用域标识符表明属于哪个容器)
要使用容器定义的类型格式大致为(注意加上作用域操作符)
1 |
|
1.3.2 概念辨析
在容器中添加元素时,系统是将元素值复制到容器里。类似地,使用一段元素初始化新容器时,新容器存放的是原始元素的副本。被复制的原始值与新容器中的元素各不相关,此后,容器内元素值发生变化时,被复制的原值不会受到影响,反之亦然(即容器中存放的元素并不是原来值的引用);
任何insert或push操作都可能导致迭代器失效(迭代器失效可以理解为变为了悬挂指针)。当编写循环将元素插入到vector 或 deque容器中时,程序必须确保迭代器在每次循环后都得到更新(迭代器怎么更新?其实更新并不是唯一的解决办法,我们也可以选择重新创建迭代器);
- 当执行增加或者删除操作,会把操作完的当前位置的新的迭代器返回,使用旧迭代器去接收返回的更新的迭代器;
- 至于更多如何解决迭代器失效,可以参考(23条消息) 【C++知识】关于迭代器失效的几种情况_烊萌的博客-CSDN博客_迭代器失效
弄清楚容器的capacity(容量)与size(长度)的区别非常重要:size指容器当前拥有的元素个数,而capacity则指容器在必须分配新存储空间之前可以存储的元素总数;
通常来说,除非找到选择使用其他容器的更好理由,否则vector容器都是最佳选择;在实际开发中如果无法确定某种应用应该采用哪种容器,则编写代码时尝试只使用vector和list容器都提供的操作:使用选代器,而不是下标,并且避免随机访问元素。这样编写代码,在必要时,可很方便地将程序从使用vector容器修改为使用list容器;
容器定义的操作非常少,只定义了构造函数、添加或删除元素的操作、设置容器长度的操作以及返回指向特殊元素的迭代器的操作。其他一些有用的操作,如排序、查找,则不是由容器类型定义,而是由标准库提供的标准算法定义;
1.4 关联容器
1.4.1 pair类型
与关联容器密切相关的一种简单标准库类型 —— pair类型,该类型在utility头文件中定义(关于pair类型与关联容器的关系参考(23条消息) 【STL】pair用法总结_舒泱的博客-CSDN博客):
pair包含了两个数据值,当然pair也是一种模板类由<键,值>构成的数据类型,pair是一种顺序容器
;
- 创建pair对象需要提供两个类型名:pair对象的两个数据成员的数据类型可以相同也可以不同
1 |
|
- pair类型的对象的值成员可以修改但键成员不能修改;pair对象的first成员存放const键,second成员存放值;
- map是关联容器的一种,由一个个pair对象组成(一个pair对象类似于一个数组元素);
1.4.2 关联容器
关联容器中容器元素根据键的次序排列:在迭代遍历关联容器时,我们可以按照键的顺序访问元素而与元素在容器中存放的位置完全无关;
关联容器共享大部分但并非全部的顺序容器操作,对于部分共享的操作,关联容器重新定义了这些操作的含义或返回类型(主要区别在于关联容器使用了键);
- 对于
键类型
,唯一的约束就是必须支持<操作符(换句话说就是比较函数必须在键类型上定义严格弱排序),至于是否支持其他的关系或相等运算,则不作要求;
1.4.3 概念辨析
- 使用下标访问map与使用下标访问数组或vector的行为截然不同:用下标(map的下标是指索引键)访问不存在的元素将导致在map容器中添加一个新的元素,它的键即为该下标值;
- 有别于vector或string类型,map下标操作符返回的类型(mapped_type类型的值)与对map选代器进行解引用获得的类型(pair对象)不相同;
2.迭代器
迭代器不是指针而是类模板,只是迭代器表现的非常像指针,模拟了指针的一些功能并重载了指针的一些操作符(智能指针);
每种容器类都提供了若干(不止一个)迭代器,类似于如果两个容器提供了相同的操作则这两个接口的类型应该完全相同,如果某种迭代器支持某种操作则其他支持相同的操作的迭代器拥有相同的操作方式(所有容器的迭代器都支持以解引用运算从容器中读入一个元素;容器都提供自增和自减操作符来支持从一个元素到下一个元素的访问),下面列出的是适用于所有迭代器的操作方式
当然也有一些特殊的迭代器运算,如只适合vector和deque容器的迭代器的运算(关系操作符只适用于vector和deque容器,这是因为只有这两种容器为其元素提供快速、随机的访问。它们确保可根据元素位置直接有效地访问指定的容器元素。这两种容器都支持通过元素位置实现的随机访问,因此它们的选代器可以有效地实现算术和关系运算。)
2.1 迭代器范围
迭代器范围这个概念是标准库的基础;
迭代器范围是指C++利用一对
迭代器标记的范围,更详细的说就是这两个迭代器(begin和end)分别指向同一个容器中的第一个元素和最后一个元素的下一个位置,这种元素范围称为左闭合区间[begin,end),注意end一定不能在begin之前,使用左闭合区间是有实际意义的:
- 当first与last相等时,选代器范围为空;
- 当first与last不相等时,选代器范围内至少有一个元素,可以通过从first自增遍历所有元素;
2.2 无效迭代器
无效迭代器类似于悬挂指针,某些对容器的操作会导致原本容器的迭代器部分或全部失效,且编译器无法检测出迭代器是否有效,也无法通过调试来发现迭代器是否失效,只能要求程序员在开发过程中尽量少的使用迭代器并且严格检查迭代器是否会失效;
2.3 特殊迭代器
标准库定义的迭代器不依赖于特定的容器,我们在 STL初级 中简单介绍过一些常用的迭代器,C++还提供有一些特殊的迭代器:
- 插入迭代器:与容器绑定,实现在容器中插入元素的功能;
- iostream迭代器:与输入/输出流绑定,用于迭代遍历关联的IO流;
- 反向迭代器:用于实现后向遍历(普通迭代器都是前向遍历),所有的容器类型都定义了自己的reverse_iterator类型,由rbegin和rend成员函数返回;
2.3.1 插入迭代器
我们将在下面接触到一个函数 back_inserter ,这个函数是迭代器适配器,使用一个对象作为实参(本质上是容器参数,可以是一个容器的引用),生成一个适应其实参行为的新对象(本质上是一个迭代器,可以是绑定在该容器上的插入迭代器),这种函数我们称为插入器
,C++提供了不止一个插入器,差别在于插入元素的位置不同:
- back_inserter:创建使用 push_back实现插入的迭代器;
- front_inserter:使用 push_front 实现插入;
只有当容器提供push_front操作时,才能使用front_inserter,在vector或其他没有push_front运算的容器上使用front_inserter,将产生错误(原因是该函数创建的迭代器会调用器关联的基础容器的push_front成员函数代替赋值操作)
- inserter:使用insert实现插入操作,除了所关联的容器外,inserter还带有第二个实参:指向插入起始位置的迭代器;
1 |
|
2.3.2 iostream迭代器
虽然iostream类型不是容器,但标准库同样提供了在iostream对象上使用的选代器:istream_iterator 用于读取输入流,而 ostream_iterator则用于写输出流;
iostream迭代器将它们所对应的流视为特定类型的元素序列。使用流迭代器时,可以用泛型算法
从流对象中读数据(或将数据写到流对象中);
流迭代器是类模板:
- 任何已定义输入操作符(>>操作符)的类型都可以定义istream_iterator迭代器
- 任何已定义输出操作符(<<操作符)的类型可定义ostream_iterator迭代器
(1)定义流迭代器
创建istream_iterator迭代器有两种方式
1 |
|
创建ostream_iterator迭代器只有一种方式
1 |
|
知识补充:
空格字符 ASCII 码 32,打印出来是空一格;
空字符 ASCII 码 0,用作字符串结束符,不打印东西,也不走空白格子;
(2)限制
不可能从ostream_iterator对象(输出流对象)读入,也不可能写到istream_iterator对象(输入流对象)中;
一旦给 ostream_iterator对象赋了一个值,写入就提交了。赋值后,没有办法再改变这个值。此外,ostream_iterator对象中每个不同的值都只能正好输出一次;
ostream_iterator 没有->操作符;
2.3.3 反向迭代器
反向迭代器是一种反向遍历容器的迭代器。也就是,从最后一个元素到第一个元素遍历容器。反向迭代器将自增(和自减)的含义反过来了:对于反向迭代器,++运算将访问前一个元素,而–运算则访问下一个元素。
反向迭代器相对的是正向迭代器(随机迭代器的一种)而不是前向迭代器;
2.4 迭代器分类
STL中定义了各种各样的迭代器,包括上面介绍的三种特殊迭代器在内,我们将这些迭代器根据 算法要求其迭代器支持什么类型的操作 分为了如下五个类别:
2.4.1 输入迭代器
可用于读取容器中的元素,但是不保证支持容器的写入操作;
输入选代器只能顺序使用;
一旦输入迭代器自增了,就无法再用它检查之前的元素;
标准库istream_iterator类型就是输入迭代器,输入迭代器支持的泛型算法包含find和accumulate;
2.4.2 输出迭代器
- 可视为与输入选代器功能互补的选代器;输出选代器可用于向容器写入元素,但是不保证能支持读取容器内容;
- 使用输出选代器时,对于指定的迭代器值应该使用一次*运算,而且只能用一次;
标准库ostream_iterator类型就是输出迭代器;
2.4.3 前向迭代器
用于读写指定的容器,这类迭代器只会以一个方向遍历序列;
前向迭代器支持输入选代器和输出迭代器提供的所有操作,除此之外,还支持对同一个元素的多次读写;
可复制前向迭代器来记录序列中的一个位置,以便将来返回此处;
需要前向迭代器的泛型算法包括replace;
2.4.4 双向迭代器
- 从两个方向读写容器,除了提供前向选代器的全部操作之外,双向选代器还提供前置和后置的自减运算(–);
需要使用双向选代器的泛型算法包括reverse,所有标准库容器提供的选代器都至少达到双向选代器的要求;
2.4.5 随机访问迭代器
- 提供在常量时间内访问容器任意位置的功能,这种迭代器除了支持双向迭代器的所有功能之外,还支持其他操作;
需要随机访问迭代器的泛型算法包括sort算法,vector、deque和string迭代器是随机访问迭代器,用作访问内置数组元素的指针也是随机访问迭代器;
2.4.6 总结
除了输出选代器,其他类别的迭代器形成了一个层次结构 —— 需要低级类别迭代器的地方,可使用任意一种更高级的迭代器:
- 对于需要输入迭代器的算法,可传递前向、双向或随机访问迭代器调用该算法;
- 调用需要随机访问迭代器的算法时,必须传递随机访问迭代器;
C++标准为所有泛型和算术算法的每一个迭代器形参指定了范围最小的迭代器种类(find至少需要一个输入迭代器,replace至少需要一对前向迭代器),对于每一个形参,选代器必须保证最低功能,将支持更少功能的选代器传递给函数是错误的,而传递更强功能的选代器则没问题;(向算法传递无效迭代器引起的错误,编译器不一定能捕获,这将导致运行时错误)
3.泛型算法
因为标准库的容器类定义的操作非常少(几乎只有获取容器大小、添加和删除元素、获取第一个位置以及哨兵迭代器等),标准库提供了一组不依赖于容器类型的泛型算法(本质上是模板函数),可作用于不同类型的容器的不同类型的元素,当然这些泛型算法不止可用于STL容器,还可以用于普通的数据类型序列(如sort()函数用于数组)甚至自定义容器类型;
- 大多数算法通过遍历由两个迭代器标记的一段元素来实现其功能(这段范围遵守左闭合原则,被称为操作范围);
- 每个泛型算法的实现独立于单独的容器,与容器类型无关 —— 算法永远不执行容器提供的操作,仅仅依赖迭代器和迭代器操作实现算法,这意味着算法可能改变存储再容器中的元素中的值,也可能移动容器中的元素,但是算法不直接添加或者删除元素(添加或删除元素属于容器提供的操作而不是迭代器提供的操作,However,算法借助插入迭代器可以实现向容器中添加元素,但是这属于借助迭代器而非算法直接添加元素);
我们按照对算法在操作范围内的元素的操作方式的不同将算法分为如下几类:
3.1 只读算法
许多算法只会读取其输入范围内的元素,而不会写这些元素:
- 累加算法accumulatte
- 查找算法如find、find_first_of
3.2 写容器算法
注意:写容器并不完全等同于直接向容器内添加元素,只有当借助了插入迭代器的写容器算法才具备直接向容器中添加元素的功能;
对于普通的写容器算法来说,必须确保算法规定的序列范围能够存储要写入的元素(通俗来说就是一个int array[10]最多只能写入10个元素,超出会导致运行时错误)
3.2.1 写序列范围
这种方式的写算法是最安全的,只会写入与指定范围数量相同的元素(这个算法只会对规定的序列范围内存在的元素进行读写操作)
1 |
|
3.2.2 写指定数目
这种算法的原理就是从迭代器指向的元素开始,将指定数量的元素设置为给定的值
1 |
|
这种算法可能导致不安全 —— 编译器对指定数目的元素做写运算(以及下面会介绍的写入目标迭代器)不会检查目标的大小是否能够存储要写入的元素,这将导致严重的运行时错误;
解决方法是引入插入迭代器(给基础容器添加元素的迭代器),原理是使用普通迭代器写入元素的时候被赋值的是迭代器指向的元素,而使用插入迭代器写入元素此时会在容器中添加一个新元素对其赋值;
1 |
|
Q:这样的话应该就不能达到我们想要指定从vec序列的某个元素开始开始写元素的目的了吧?
A:这里我们可以选择使用的插入器的类型,如果是inserter插入器就可以指定插入的位置;
Q:“算法不直接修改容器的大小,如果要添加或者删除元素,必须使用容器操作”,这句话是真理,但是例外情况就是我们上面的那种用法,但是我感觉上面那种用法事实上也并不常见;
3.2.3 写目标迭代器
这种算法是向目标迭代器写入未知个数的元素,目标迭代器指向存放写入元素序列的第一个元素
1 |
|
3.3 排序算法
标准库定义了四种排序算法:
- sort算法:这是最简单的排序算法,按照字典次序排列;
- stable_sort算法:保留相等元素的原始相对位置
3.4 算法结构
标准库提供的算法本质上和容器类似,都建立在一致的设计模式上,因此我们有必要理解算法公共的结构以及共同的设计基础;
算法最基本的性质是需要使用的选代器种类 —— 所有算法都指定了其每个迭代器形参可以使用的迭代器类型,无效迭代器不能用于该算法;
3.4.1 算法的形参模式
大多数算法采用下面四种形式之一:
- alg:算法的名字;
- beg:算法操作的元素范围左边界;
- end:算法操作的元素范围右边界;
- dest、beg2、end2:都是迭代器;
- dest:用于指定存储输出数据的目标对象;
- beg2、end2:
- 带有beg2而不带end2的算法将beg2视为第二个输入范围的首元素,但没有指定该范围的最后一个元素。这些算法假定以beg2开始的范围至少与beg和 end指定的范围一样大。
- 算法同时使用beg2 和 end2时,这些选代器用于标记完整的第二个范围;
- other parms:其他非迭代器形参,属于这些算法特有;
3.4.2 算法的命名规范
查找某个值的算法通常提供第二个版本,用于查找使谓词函数返回非零值的元素,对于这种算法,第二个版本的函数名字以_if后缀标识;
类似地,很多算法提供所谓的复制版本,将(修改过的)元素写到输出序列,而不是写回输入范围,这种版本的名字以_copy结束;
——第三部分 类和数据抽象——
类类型
常被称为抽象数据类型
,是面向对象编程和泛型编程的基础;
类定义了数据成员和函数成员:
- 数据成员用于存储与该类类型的对象相关联的状态;
- 函数成员则负责执行赋予数据意义的操作;
C++使用类来定义自己的抽象数据类型,数据抽象能够隐藏对象的内部表示,同时仍然允许执行对象的公有操作;
1.第十二章_类
简单来说,类就是定义了一个新的类型和一个新的作用域,类的基本思想是数据抽象
(通过接口与实现分离实现)和封装
(将实现部分封装、隐藏起来),类的设计过程就是一个抽象化的过程;
结论1:类的定义以分号结束 ———— 分号是必需的,因为在类定义之后可以接一个对象定义列表,定义必须以分号结束;
结论2:类的用户即类的使用者或类的开发人员本人,在设计类的接口的时候需要假设类的用户对类的细节并不知情;
类背后蕴涵的基本思想是数据抽象和封装:
- 数据抽象是一种依赖于接口和实现分离的编程(和设计)技术;
- 封装是一项将低层次的元素组合起来形成新的、高层次实体的技术,函数和类都属于封装的形式;
简单来说,封装就是隐藏细节的实现,抽象就是提供接口;
注意:如果类是用struct关键字定义的,则在第一个访问标号之前的成员是公有的;如果类是用class关键字定义的,则这些成员是私有的;
并非所有类类型都必须是抽象的,标准库中的pair类就是一个实用的、设计良好的具体类而不是抽象类(因为pair类是用struct定义的结构体),具体类会暴露而非隐藏其实现细节,对于某些简单的类来说隐藏数据成员反而会造成类型使用的复杂化(因为要访问隐藏的数据成员就只能通过公共的接口函数来实现)
数据抽象和封装提供了两个重要优点:
- 避免类内部出现无意的、可能破坏对象状态的用户级错误(用户简单理解为使用该程序的人或者使用该类的人);
- 随时间推移可以根据需求改变或缺陷报告来完善类实现,而无须改变用户级代码;
结论3:当类的声明和定义分离时
- 在进行类定义之前,不能将该类用于声明一个对象;
- 在进行类声明之后,类定义之前,也就是该类是一个不完整的类型,这种不完整类型可以定义指向该类型的指针或引用,也可以作为函数声明中的参数或返回类型(因为只需要类类型的名字即可);
1.1 类成员
每个类可以没有成员,也可以定义多个成员,成员可以是数据、函数或类型别名;
所有成员必须在类的内部声明
,一旦类定义
完成后,就没有任何方式可以增加成员了;
1.1.1 成员函数
在类内部,声明成员函数是必需的,而定义成员函数则是可选的(可以选择在类外部定义成员函数,这也是我们最常用的方式),在类内部定义的函数默认为inline(实际上究竟是否是inline由编译器决定);
在类外部定义的成员函数必须指明它们是在类的作用域中;
结论1:成员函数的声明必须在类的内部,成员函数的定义可以在类内部也可以在类外部
1.1.2 this指针
this指针与调用成员函数的对象绑定在一起,this指针存放的是当前对象的地址
在普通的非const成员函数中,this的类型是一个指向类类型的const指针。可以改变this所指向的值,但不能改变this所保存的地址;
在const成员函数中,this的类型是一个指向const类类型对象的const指针。既不能改变this所指向的对象,也不能改变this所保存的地址;
1.1.3 const成员函数
使用const修饰函数这个确实很少见(至少在我之前从来没遇见过),这种用法出现在修饰类的成员函数中
1 |
|
使用const修饰的成员函数称为常量成员函数,此处const改变了隐含的this形参的类型,this本身是一个const的指针,当我们在它前面加上const使其指向的内容也是const,因此该成员函数不能修改调用该函数的对象 ———— 函数avg_price()只能读取而不能修改调用它的对象的数据成员;
- 常量指针:const int *PtrConst;//指向常量的指针
- 指针常量:int *const ConstPtr=&a;//指针是常量
1.1.4 static类成员
static数据成员独立于该类的任意对象而存在,每个static数据成员是与类关联的对象,并不与该类的对象相关联:
- static数据成员必须且仅能一次在类定义体的外部定义;
- static数据成员并不是通过类构造函数进行初始化,而是在定义时初始化(这点也解释了为什么不能在类定义体内部定义,因为在内部定义的话是不能初始化的);
- static数据成员的定义非常有意思,在类外定义不能使用static关键字,在类内初始化某些特定static数据成员可以使用static关键字(因为static关键字只能用于类定义体内部的声明)
1 |
|
类不仅可以定义共享的static数据成员,也可以定义static成员函数:
- static成员函数没有this形参(因为static成员函数不是任何对象的组成部分),它可以直接访问所属类的static成员,但不能直接使用非static成员;
- static成员函数不能被声明const(将成员函数声明为const意味着不会修改该函数所属的对象,但是static成员函数根本没有对象的概念);
- static成员函数不能被声明为虚函数;
1.1.5 可变数据成员
(这一节翻译的太差了,可以直接参考(23条消息) const和mutable关键字_vegetablesssss的博客-CSDN博客_mutable关键字)
当我们希望类的数据成员可以被const成员函数(一般地,const修饰的成员函数不能用于更改常规成员变量)修改,我们可以将其声明为mutable来实现;
1 |
|
任意成员函数(包括const函数)都可以改变可变数据成员的值;
1.1.6 指针成员
在类中定义一个指针成员将会是一件非常麻烦的事(比如处理悬挂指针等问题),如果要设计一个好的具备指针成员的类,我们需要进行必要的成员管理:
(1)指针成员采取常规指针型行为。这样的类具有指针的所有缺陷但无需特殊的复制控制;
(2)类可以实现所谓的“智能指针”行为。指针所指向的对象是共享的,但类能够防止悬垂指针;
(3)类采取值型行为。指针所指向的对象是唯一的,由每个类对象独立管理;
1.2 构造函数
构造函数是一个特殊的、与类同名的成员函数,用于给每个数据成员
设置适当的初始值;
结论1:构造函数不能声明为const(没有意义,因为本身构造一个对象就是从无到有的状态)
1 |
|
结论2:当类没有声明任何构造函数并且所有类类型(注意不是内置类型)的成员都有默认构造函数时,编译器才会自动地生成默认构造函数(如果存在类内的初始值,则使用该初始值来初始化成员,否则默认初始化该成员)
注意,大多数时候我们最好自己提供构造函数,默认构造函数存在缺陷;
假如我们自己提供了构造函数的版本,则编译器不会提供默认构造函数,我们可以显式指定
1 |
|
结论3:有时候初始化列表是必不可少的
1 |
|
如果成员函数是const、引用或者属于某种未提供默认构造函数的类类型,则必须通过初始化列表提供初始值;
1 |
|
结论4:调用默认构造函数的时机
- 默认初始化;
- 通过值来进行初始化;
1 |
|
1.3 拷贝、赋值和析构函数
与默认构造函数一样,当我们没有定义时编译器会提供默认的合成版本;
有关上述几种函数我们会在类控制中详细讲解;
1.4 特殊类
1.4.1 聚合类
如果一个类满足:
- 所有成员都是public的;
- 没有定义任何构造函数;
- 没有类内初始值;
- 没有基类也没有virtual函数;
1 |
|
1.4.2 字面值类
简单来说,一个类型前面只要能够使用constexpr来修饰,那么它就是一个字面值类型(常见的有算术类型、引用和指针);
字面值类有如下特点:
数据成员必须都是字面值类型
类必须至少有一个constexpr构造函数
数据成员的类内初始值
内置类型:必须是一条常量表达式
类类型:使用成员自己的constexpr构造函数
类必须使用析构函数的默认定义
2.第十三章_类控制
一个类通过定义五种特殊的成员函数来控制对象的拷贝、移动、赋值和销毁
操作(这些操作统称为拷贝控制操作,其实这里和下面所说的复制控制又似乎有点冲突,不用太纠结,只需要知道指代的是什么即可):
- 拷贝构造函数(复制构造函数)
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
- 析构函数
如果一个类没有定义上述几种成员函数,编译器会自动合成缺失的成员函数;
结论1:可以使用=default显式要求编译器生成合成的版本(前提是编译器有这个能力且该成员函数具备合成版本)
1 |
|
复制构造函数、赋值操作符和析构函数统称为复制控制(复制控制是定义C++类类型必不可少的部分,尽管编译器常常替我们定义了它们,但是我们有必要了解类对象在复制、赋值以及撤销的时候发生了什么,编译器合成的复制控制对于某些类来说会导致灾难(当类具有指针成员时)
)。编译器自动实现这些操作,当然也可以定义自己的版本(这章我们基于类讨论,默认内置类型不需要我们关心);
- 无论是内置类型还是类类型,都为该类型对象的一组(可能为空)操作含义进行了定义,这些操作定义了用给定类型的对象可以完成什么任务;
- 每种类型还定义了创建该类型的对象时会发生什么,构造函数定义了该类类型对象的初始化;
- 类型还能控制复制、赋值或者撤销该类型对象时会发生什么(内置类型暂时还不知道怎么控制…但是类类型通过特殊的成员函数:复制构造函数、赋值操作符以及析构函数来控制这些行为);
在阅读本章之前可以先简单看一下这篇文章(23条消息) 内置类型和类类型复制控制的方方面面_HyHarden的博客-CSDN博客
Q:内置类型有构造函数吗?有析构函数吗?
A:(答案参考自网上,可能不准确)内置类型没有析构函数,销毁内置类型成员不需要做任何额外操作;关于内置类型是否有构造函数这个问题网上很多争议(内置类型有默认构造函数吗? (imooc.com))…我们暂且认为没有吧;
结论2:当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员均被定义为delete的(这里实际上就是一环套一环,只要其中某一个缺失则整体都无法正常实现)
2.1 复制构造函数
复制构造函数是一种特殊构造函数,又称为拷贝构造函数,具有单个形参,该形参(常用const修饰)是对该类类型的引用(即拷贝构造函数要求第一个参数是自身类型的引用,额外的参数都有默认值):
- 当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式使用复制构造函数;
- 当将该类型的对象传递给函数或从函数返回该类型的对象时,将隐式使用复制构造函数;
- 拷贝构造函数通常不应该是explicit的,否则该拷贝构造函数只能是显式地被调用时才有效;
1 |
|
Q1:为什么一定要是自身类型的引用?
A:因为这类似于盒子里的剪刀的问题,如果使用普通的赋值,那么当调用拷贝构造函数的时候需要将实参的值赋给形参,而这将又调用拷贝构造函数造成无限循环,而引用仅仅只是简单的将地址赋值,不会调用拷贝构造函数;因此我们有一个结论 —— 拷贝构造函数用于初始化非引用类类型参数,所以自己的参数必须是引用类型
Q2:为什么有时候拷贝构造函数声明为 B(const B&b){};有时候声明为B(const B&){}; 难道可以不带上变量吗?
A:此处是函数章节的知识点,变量名(形参)仅仅在函数内部起作用,函数调用的时候参数传递并拷贝只需要知道变量是什么类型无需知道变量名是什么;
复制构造函数可用于:
根据另一个同类型的对象显式或隐式初始化一个对象;
复制一个对象,将它作为实参传给一个函数;
从函数返回时复制一个对象;
初始化顺序容器中的元素;
根据元素初始化式列表初始化数组元素;
C++支持直接初始化和复制初始化,对于类类型的对象,直接初始化直接调用与实参匹配的构造函数,复制初始化总是调用复制构造函数(首先使用指定/默认构造函数创建一个临时对象,利用复制构造函数将临时对象复制到正在创建的对象)
1 |
|
结论1:当我们需要阻止某些对象的拷贝(如IO对象不允许拷贝,这可能导致同一个缓冲区被多个对象同时使用),可以采用=delete的方式阻止拷贝(字面上就是直接把这个拷贝构造函数给删了)
1 |
|
- 相较于default,delete没有那么多的使用限制,可以对任何成员函数使用delete;
- 与=default不同,=delete必须出现在函数第一次声明的时候(因为delete直接决定是否定义该函数,而default是解决如何定义该函数)
结论2:定义一个类的时候有两种选择,通过定义拷贝操作(即拷贝构造函数以及拷贝赋值运算符),使得类的行为看起来像一个值(自己保存自己的值)或像一个指针(可能多个指针指向同一个内容);
这样做的原因本质上就是避免多次析构同一个地址;
2.2 拷贝赋值操作符
拷贝赋值即之前存在的一个对象我们才能对其进行赋值,拷贝构造是之前不存在的对象被创造出来(无中生有);
1 |
|
拷贝赋值操作符可以通过指定不同类型的右操作数实现操作符重载
,右操作数为类类型的版本比较特殊:如果我们没有编写这种版本,编译器将为我们合成一个;
结论1:拷贝赋值运算符接受一个与其类型相同的参数形式(等号左边),返回的是自身的一个引用(等号右边)
1 |
|
上面仅仅是一个简单的格式示例,我们给出一个等价于编译器自动给出的合成拷贝赋值运算符的定义
1 |
|
2.3 析构函数
析构函数:在类的名称前面加上~且不能有参数,因为析构函数没有参数也就意味着析构函数无法重载:
- 先执行函数体,释放对象的使用资源;
- 销毁对象的非static数据成员,其释放顺序与构造函数相反;
析构函数是构造函数的互补:构造函数用于初始化对象的非static数据成员,当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数;
结论1:与复制构造函数和赋值操作符不同,无论类是否定义了自己的析构函数,都会创建和运行合成析构函数。如果类定义了析构函数,则在类定义的析构函数结束之后运行合成析构函数。
析构函数可用于释放对象因为构造或在对象的生命期中所获取的资源,不管类是否定义了自己的析构函数,编译器都自动执行类中非static数据成员的析构函数;
结论2:当指向一个对象的引用或指针离开作用域时,不会执行析构函数,所以必须自己去回收这部分的内容
结论3:需要自定义析构函数的类往往也需要自定义拷贝和赋值操作
简单来说就是如果使用编译器合成的拷贝和赋值操作,最终可能导致对同一个对象多次析构的情况,这是不允许的
结论4:需要自定义拷贝操作的类也需要自定义赋值操作,反之亦然(注意这里没有提到析构函数)
结论5:对于析构函数被delete的类类型,我们不能定义这种类型的对象或释放指向该类型的指针
2.4 移动操作
拷贝和移动的区别就在于,拷贝指的是按照椅子自己建造了一把椅子,移动指的是直接把椅子拿过来使用;
在某些情况下是不允许拷贝的,比如IO类的对象只能使用移动而不能使用拷贝操作;
2.4.1 值类型
先简单介绍一下左值和右值(C/C++中表达式有前缀表达式、条件运算符表达式、字面值和变量、函数的返回值等):
- 左值指的是既可以出现在等号左边也可以出现在等号右边的变量或表达式,右值指的是只能出现在等号右边的变量或表达式;
- 左值是指有名字的变量,可以被赋值也可以在多条语句中使用;右值是指没有名字的临时变量,不能被赋值且只能在一条语句中出现;
实际上C++11之后表达式的值分为左值、将亡值、纯右值、混合泛左值以及右值五种,这五种类别的分类依据表达式的两个特征:
- 具名:可以确定表达式是否与另一表达式指代同一实体,例如通过比较它们所标识的对象或函数的地址
- 可移动:移动构造函数、移动赋值运算符或实现了移动语义的其他函数重载能够绑定于这个表达式
因此上述五种表达式的值类别重定义为:
- lvalue:具名且不可被移动
- xvaue:具名且可被移动
- prvalue:不具名且可被移动
- glvalue:具名,lvalue和xvalue都属于glvalue
- rvalue:可被移动的表达式,prvalue和xvalue都属于rvalue
(1)左值_lvalue
左值lvalue即赋值符号左边的值,但准确来说左值是表达式后依然存在的持久对象;
可以将左值看作是一个关联了名称的内存位置,允许程序的其他部分来访问(此处的名称解释为任何可用于访问内存位置的表达式);
左值具有如下特征:
- 可通过取地址运算符&获取其地址
- 可修改的左值可用作内建赋值和内建复合赋值运算符的左操作数
- 可以用来初始化左值引用
常见左值如下:
(2)纯右值_prvalue
字面值或者函数返回的非引用都是纯右值,常见纯右值如下:
纯右值:
- 不会是多态;
- 不会是抽象类型或数组;
- 不会是不完全类型;
(3)将亡值_xvalue
将亡值是C++11新增的与右值引用相关的表达式(通常是将要被移动的对象),可以理解为通过“盗取”其他变量内存空间的方式获取到的值 —— 在确保其他变量不再被使用或即将被销毁时,通过“盗取”的方式可以避免内存空间的释放和分配,以此延长变量值的生命周期;
xvalue只能通过两种方式来获得:
- 返回右值引用的函数的调用表达式如static_cast<T&&>(t);
- 转换为右值引用的转换函数的调用表达式如std::move(t);
将亡值定义这样一种行为:具名的临时值同时能够被move
(4)混合类型_glvalue
泛左值(也称为广义左值)表达式是具名表达式,对应了一块内存,包括了lvalue和xvalue两种形式;
glvalue的特征如下:
- 可以自动转换成prvalue;
- 可以是多态的;
- 可以是不完整类型,如前置声明但未定义的类类型;
(5)右值_rvalue
rvalue指的是可以移动的表达式,包含了prvalue和xvalue,具有以下特征:
无法对rvalue进行取地址操作。例如:&42,&i++,这些表达式没有意义,也编译不过;
rvalue不能放在赋值或者组合赋值符号的左边,例如:3 = 5,3 += 5,这些表达式没有意义,也编译不过;
rvalue可以用来初始化const左值引用,如:const int& a = 1;
rvalue可以用来初始化右值引用;
rvalue可以影响函数重载:当被用作函数实参且该函数有两种重载可用,其中之一接受右值引用的形参而另一个接受 const 的左值引用的形参时,右值将被绑定到右值引用的重载之上;
更多关于左值和右值可以参考c++中的左值跟右值怎么区分? - 知乎 (zhihu.com)
2.4.2 引用
既然提到了左值和右值,就不得不提引用,
在C++11之前引用分为左值引用和常量左值引用两种,但是C++11之后引入了右值引用,因此C++11中包含了如下三种引用:
- 左值引用(由&表示);
- 常量左值引用(常量引用主要用来修饰形参,防止误操作在函数形参列表中,防止形参改变实参 —— 简单来说就是不可通过该引用对绑定的对象进行修改,但是该对象本身也许可以修改也许不可以修改);
- 右值引用(由&&表示,延长右值的生命周期);
1 |
|
关于右值引用需要注意的一点就是,尽管右值引用的是右值,但是其本身是左值(这就是我们说的变量是左值,即使这个变量是右值引用 )
2.4.3 标准库move函数
1 |
|
2.4.4 移动构造函数
移动构造函数和移动赋值运算符两者类似对应的拷贝操作,但是它们是从给定对象“窃取”资源而不是拷贝资源,因为不会发生拷贝所以没必要分配动态对象(移动构造函数不分配新内存),并且这种时候一般不会有异常发生,需要写上noexcept(不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept,否则编译器会默认认为移动构造函数是不安全的转而调用拷贝构造函数)
移动构造函数,因为本身并不会分配新内存,本质上就是将原来的对象的指针拿来自己使用;
1 |
|
2.4.5 移动赋值运算符
1 |
|
结论1:只有当一个类没有定义任何自己版本的拷贝控制成员且所有数据成员都能移动构造或移动赋值时,编译器才会合成移动构造函数或移动赋值运算符
结论2:移动构造函数、拷贝构造函数同时存在,编译器将使用普通的函数匹配规则,赋值的情况也类似
结论3:假如只有拷贝构造函数没有移动构造函数(此时不会自动生成移动构造函数),这意味着只能拷贝不能移动
不可以用一个左值引用去引用一个右值,但是可以将其写为const的形式(2.4.2有介绍)
3.第十四章_重载操作符与转换
3.1 重载操作符的定义
重载操作符是一种具有特殊名称的函数
,没有默认参数,需要明确的指出到底是一个还是两个操作对象;
很多人可能会疑问,既然可以使用自定义的函数对类对象进行操作,何必还需要对这个类单独进行操作符重载呢?—— 重载运算符的最大优点就是借助对符号本身良好的认识,可以减少学习和使用的成本(直接使用+和调用自定义的add()函数当然是前者更丝滑)
需要注意的一点就是重载某个符号的时候要符合实际情况(最好和内置类型的运算符保持一致性,不要把+定义为减法操作,可以使用但不合理),同时操作符重载不能改变结合律、优先级;
结论1:一个运算符函数,或是类的成员,或是至少含有一个类类型的参数;
主要限制不允许修改内置类型的运算符
1 |
|
对于可以被重载的运算符,其中某些由我们自己定义仍然很难保持一致性的如, && ||最好也不要去做重载
结论2:对于一个重载的运算符来说,其优先级和结合律(还有操作数的数目)与对应的内置运算符保持一致
关于这点个人认为应该是建议而不是强制性措施,如果非要改后果自负
1 |
|
结论3:具有对称性的运算符可以转换任意一端的运算对象,通常应该定义为普通的非成员函数
做操作符重载的时候有两种选择,可以将其作为成员函数也可以作为非成员函数,作为成员函数是有限制的,比如+左侧必须是对象本身,即this指针所指向的对象
3.2 重载操作符的实现
具体实现参考14.2操作符重载实现_哔哩哔哩_bilibili
3.2.1 重载输出运算符<<
结论1:输入输出运算符必须是非成员函数
因为cout为了保持一致性必须放在左边 cout<<
如果需要写成成员函数就只能写为cout的成员函数即IO类的成员函数,当我们自己写一个Sales_data类的时候将<<操作符重载为成员函数就需要将Sales_data的对象放在符号的左边
1 |
|
3.2.2 重载输入运算符>>
结论2:输入运算符必须处理输入可能失败的情况,输出运算符不需要
3.2.3 重载算数和关系运算符
通常情况下都将算数和关系运算符定义为非成员函数 —— 如果定义为成员函数会有限制,符号的左边必须是this指针指向的对象
(1)加号运算符
结论3:如果类同时定义了算术运算符和相关的复合赋值运算符,则通常情况下应该使用复合赋值来实现算术运算符
(2)相等运算符
结论4:如果类包含(= =),则当且仅当<的定义和(==)产生的结果一致时才定义<运算符
3.2.4 重载赋值运算符
重载运算符都必须定义为成员函数;
除了前面介绍过的拷贝赋值和移动赋值以外,还可以进行其他赋值如初始化列表赋值等
3.2.5 重载复合运算符
尽管复合运算符可以定义成非成员函数,但一般情况下会将其定义为成员函数
3.2.6 重载下标运算符
下标运算符必须是成员函数;
下标运算符表示为[],借助下标运算符可以获取数组中单独的元素,下标运算符需要两个操作数,最简单的情况是一个操作数是数组名称而另一个操作数是一个整数;
3.2.7 重载递增和递减运算符
需要定义前置和后置的版本,通常都被重载定义为类的成员;
重载前置版本
重载后置版本
为了能够区分前置和后置,我们给后置增加一个并不需要的int参数;
1 |
|
3.2.8 重载成员访问运算符
成员访问运算符包括解引用(*)和箭头访问符(->),(关于为什么不是点运算符(.)这一点确实不太清楚)箭头运算符必须是类的成员,解引用运算符通常也是类的成员
3.2.9 重载函数调用运算符
首先介绍函数对象的概念:函数对象本质上就是一个对象(对象是需要一个类来生成的),它的行为看上去像一个函数(因此函数对象又被称为仿函数);
通过对生成函数对象的类的函数调用运算符()进行重载,可以像使用函数一样使用类的对象(与其他运算符不同,里面的参数没有限制);
函数调用运算符必须是成员函数;
函数对象类中的数据成员通常用于定制调用运算符中的操作或存储不同的状态(这也是函数对象和普通函数之间的最大的区别)
结论5:函数对象常作为泛型算法的实参
标准库中定义了一些函数对象可以直接使用,包括算数类型、关系类型以及逻辑类型
这些函数对象的使用方式也非常简单
3.2.10 重载类型转换运算符
类型转换运算符是一个特殊的成员函数,它负责将一个类类型的值转换成其它类型,一般形式为:operator type()const;//type在实际应用中要替换成其他具体类型
1 |
|
结论6:类型转换操作符必须定义为成员函数
1 |
|
结论7:类型转换运算符是隐式执行的,不能传递实参
1 |
|
1 |
|
结论8:避免有二义性的类型转换
1 |
|
—第四部分 面向对象编程与泛型编程—
这部分是对第三部分的深化研究,属于C++的高级主题;
2022/8/27 9:18 前段时间因为回校的原因以及小学期,所以停了一段时间,现在准备接着把Primer看完,之后看Effective后写一写STL的小项目;本来说的是想参考一下B站黑马的视频,然后发现和Primer的行文结构完全不一样(主要确实Primer的结构不符合市面上的编程习惯),因为这部分涉及C++的高级,难免会有搞不懂的,记住多借助Google和视频讲解,视频可以参考15.1OOP概述_哔哩哔哩_bilibili;
2022/8/27 9:50 稍微说两句,上面提到的视频讲的还不错,这之后的课程可以对照着视频学习(书上关于这部分的内容讲的实在太混乱了真的看不懂),学习之前先把前面的知识点简单过一遍温习一下,毕竟也有接近一个月没有接触过;
2022/8/27 20:11 需要注意的是,这里介绍的面向对象编程的特征和常规(指我们之前课堂上学的 “封装”、“继承”和“多态”)侧重点不同,但是概念并没有区别;
2022/9/1 16:42 我们这里的行文结构没有按照书上来,书上这部分翻译的实在是不够好,所以以罗列小知识点的方式将重要的一些结论介绍,注意这里并不适合初学者学习,介绍的大部分是进阶知识点,想要学习基本知识点可以参考《C++ Primer Plus》;
1.第十五章_面向对象编程
面向对象编程的关键思想是
多态性
,C++中的多态性仅用于通过继承相关联的类型的引用或指针;
面向对象编程基于三个基本概念:
- 数据抽象:类实现数据抽象 ———— 接口与实现分离;
- 继承:派生实现类之间的继承 ———— 定义相似的类,并对其相似关系进行建模;
- 动态绑定:使程序在运行时决定是使用基类中定义的函数还是派生类中定义的函数 ———— 忽略相似类的区别,以统一的方式使用它们;
下面给了继承和动态绑定的例子,主要是简单介绍一下虚函数在继承和动态绑定中分别起了什么作用(这也是最令初学者混淆的)
1 |
|
1.1 基本思想
1.1.1 继承
派生类(derived class)能够继承基类(baseclass)定义的成员,派生类可以无须改变而使用那些与派生类型具体特性不相关的操作,派生类可以重定义那些与派生类型相关的成员函数,将函数特化,考虑派生类型的特性。最后,除了从基类继承的成员之外,派生类还可以定义更多的成员;
将派生类对象当作基类对象是安全的:基类的引用或指针可以引用基类对象也可以引用派生类对象;
友元关系不能继承,也就是基类的友元对派生类的成员没有特殊的访问权限;
如果基类定义了static成员,则整个继承层次中只有一个这样的成员。无论从基类派生出多少个派生类,每个static成员只有一个实例;
关于构造函数:构造函数只能初始化其直接基类,因此派生类构造函数不能初始化基类的成员,并且不应该对基类成员赋值(尊重基类构造函数,尊重基类的接口意义)
1.1.2 动态绑定
在C++中,派生类经常(但不总是)覆盖其父类的虚函数:
- 派生类重定义继承的虚函数不是必须的,如果派生类没有重定义某个虚函数则使用基类中定义的版本;
- 一旦函数在基类中被声明为虚函数,它就一直是虚函数,派生类无法改变该函数为虚函数这一事实;
- 对非虚函数的调用在编译时确定,对虚函数的调用在运行时决定;
- 除了构造函数之外,任意非static成员函数都可以是虚函数(构造函数是不能够写成虚函数的,但是析构函数是可以写为虚函数的,并且基类通常都应定义一个虚析构函数,表明希望派生类定义一个自己的版本的析构函数);
通过动态绑定(dynamic binding)我们能够编写使用继承层次中任意类型的对象
的程序,无须关心对象的具体类型也无需关心函数是在基类还是派生类中定义,类的成员函数默认是非虚函数,保留字virtual的目的是启用动态绑定:
- 只有指定为虚函数的成员函数才能进行动态绑定,成员函数默认是非虚函数;
- 要触发动态绑定必须通过基类类型的引用或指针进行函数调用;
1.2 基类和派生类
结论1:可以把派生类对象的指针用在需要基类指针的地方(即用基类指针指向派生类对象),此时就算使用的是派生类对象的指针,但实际上调用的还是基类的指针,调用的时候只会调用它拥有的权限;
可以使用基类类型的指针或引用来引用派生类型对象 ———— 因为每个派生类对象都拥有基类部分(但是派生类并不能拥有基类的private部分,所以这里是否不严谨呢?)
Q:C++派生类的对象包含基类中的private成员吗?
A:这是一个非常好的问题,在C++编译器的内部,类可以理解为结构体,此时子类实际上是由父类成员叠加子类新成员得到的(无论是以哪种方式继承,在内存中子类包含父类的所有成员)
结论2:派生类必须使用基类的构造函数初始化继承而来的成员
结论3:声明派生类的时候只需要告诉编译器这个派生类是存在的并不需要指出它派生自哪一个基类;
关于派生类的声明需要注意
1 |
|
结论4:使用final关键字可以限制将一个类作为基类
1 |
|
结论5:在使用基类类型的引用或指针时,编译器无法知道指针或引用所绑定的对象的类型
无论实际对象是哪种类型,编译器都将其作为基类类型对象 ———— 因此,任何可以在基类对象上执行的操作也可以通过派生类对象使用;
结论6:不存在从基类向派生类的隐式类型转换(这里指的转换是指引用和指针)
因为派生类可调用的接口(成员)比较多,而基类相较于派生类的接口数量较少;
结论7:可以声明一个派生类的对象,用派生类的对象去构造一个基类对象
1 |
|
1.3 虚函数
结论1:基类中的虚函数在派生类中仍然是一个虚函数,该函数在派生类中的形参必须与基类中的形参严格匹配;
1 |
|
结论2:回避虚函数
如果我们已经定义了虚函数,但是又不希望在运行的时候才确定调用哪个版本,可以强制调用基类中的版本而不管指针的动态类型是什么
1 |
|
结论3:如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致
因为派生类的基类部分仍然是使用基类中的构造函数进行初始化的,所以如果不一致可能导致基类的部分和预想的不一致
1.4 抽象基类
可以认为普通的(具体的)类是用来生成对象的,而抽象基类并不用于生产对象而用于生产具体的类(因为抽象类中某些地方是抽象的,不能直接用于生产对象);
(1)纯虚函数
在声明语句的分号之前书写=0可以将其定义为纯虚函数(纯虚函数和虚函数几乎没什么关系)
1 |
|
如果一个类拥有纯虚函数表示该类不完整即不能用该类来描述一个对象,该类只能作为抽象基类用于派生其他类;
(2)抽象基类的意义
为什么还要大费周章的创造Disc_quote这样的一个抽象基类呢?
结论1:派生类构造函数只初始化它的直接基类
简单来说就是当派生类调用自己的构造函数的时候,当构造其基类的部分时会调用其直接基类而非最顶层的基类的构造函数,依此递推(这对于抽象基类仍然适用)
1.5 访问控制
访问控制用于控制其成员对于派生类来说是否可以访问(注意这里仅仅只是访问,实际上派生类的内存中是有基类的部分的无论是什么访问方式)
- 如果成员在基类中为private,则只有基类和基类的友元可以访问该成员,派生类不能访问基类的private成员,当然也不能使自己的用户访问这些private成员;
- 如果成员在基类中为public或protected,则派生类中该成员的访问级别由派生列表中访问标号决定:
- 如果是公用继承(public inheritance),基类成员保持自己的访问级别:基类的public成员为派生类的public成员,基类的protected成员为派生类的protected成员;
- 如果是受保护继承(protected inheritance),基类的public和protected成员在派生类中为protected成员;
- 如果是私有继承(private inheritance),基类的所有成员在派生类中为private成员;
尽管如此,最常见的继承形式仍然还是public
结论1:派生类友元对于一个基类对象中的受保护成员没有任何访问特权
简单复习一下友元:通常情况下C++的公有类(public)方法提供唯一的访问对象的私有(private)部分的途径,友元提供了另一种形式的访问权限,友元主要形式有 友元函数、友元类及友元成员函数;
其中友元类的所有方法
都可以访问原始类中的私有成员和保护成员以及公有成员(也就是全体成员
),正是因为友元关系无法继承,所以基类的友元对派生类的成员没有特殊的访问权限
,同时派生类的友元对其基类中的成员也没有特殊的访问权限
(简单来说就是 小明和小王是朋友,但是小明和小王的爸爸不是朋友)
结论2:某个类对其继承类的成员的访问权限受到两个因素的影响:
- 在基类中该成员的访问说明符;
- 在派生类的派生列表中的访问说明符;
- 派生访问说明符的目的是控制
派生类用户
对于基类成员的访问权限; - 派生访问说明符还可以控制
继承自派生类的新类
的访问权限;
结论3:我们常认为private是不能够继承访问的,但实际上private成员可以继承访问,只是需要通过内存地址等非常规方式进行访问;
结论4:通过使用using改变个别成员的可访问性
1 |
|
注意:派生类只能为它可以访问的名字提供using声明 —— 因此不可以使用using把private拿下来,因为private在Derived中本身就无法访问
1.6 继承中的类作用域
结论1:如果一个名字在派生类的作用域中无法解析,则编译器将继续在外层的基类作用域中寻找该名字的定义;
结论2:内层作用域(即派生类)的名字会隐藏定义外层作用域(即基类)的名字(同名)
注意,这里不会覆盖,仅仅只会隐藏,内存中表现为
1 |
|
结论3:与结论2类似的,同名函数(不需要参数列表相同)只会被隐藏(注意隐藏并不等于覆盖,覆盖是指对虚函数进行具体化,可以把隐藏理解为重写),但是不会被重载
注意这里和我们之前理解的可能有一些差异,我们以前可能认为如果在派生类中出现了基类的同名函数的话可能基类的同名函数就不会被继承下来了,但实际上也是被继承下来了只是在默认情况下看不见而已;
- 通过基类的对象可以直接调用基类的函数;
- 通过派生类的对象可以直接调用派生类的函数;
- 通过派生类的对象调用基类的函数不可以直接调用,需要使用::指定
d.Base::memfun();
;
1.7 继承、构造函数与拷贝控制
结论1:通过在基类中将析构函数定义为虚函数以确保执行正确的析构函数版本;
1 |
|
当出现上述情况时,假如我们仅仅只是析构该派生类的基类部分会出现内存泄漏的问题,我们希望的情况是当delete指针的时候将整个动态类型对象全部回收,此时就需要使用动态绑定的方式去书写析构函数;
当我们动态绑定析构函数后,析构函数的属性会被继承,且Quote的派生类的析构函数都将是虚函数;
结论2:如果定义了一个移动构造函数/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符被定义为删除的
假如我们希望在基类中有移动操作,最好手动添加五个拷贝控制
1 |
|
结论3:如果定义(注意删delete除也是一种定义)了拷贝构造、赋值运算符或析构函数,则编译器不会自动合成移动构造和移动运算符
1.7.1 派生类的拷贝控制成员
派生类构造函数
如果想要继承父类的构造函数,可以使用如下写法
1 |
|
对于默认构造函数来说,基类的默认构造函数默认情况下会对派生类的对象的基类部分进行初始化,故可以不用在派生类中写出基类的默认构造函数;
但是要想使用拷贝或者移动构造函数则必须在构造函数初始值列表中显式调用该构造函数
1 |
|
派生类赋值运算符
如果需要调用基类的赋值运算符也需要很明确的去进行调用
1 |
|
派生类析构函数
因为析构函数一般自动调用所以我们不需要管它
结论1:如果构造函数或析构函数调用了某个虚函数,则应该执行与该函数所属类型对应的虚函数版本
简单来说就是就算在派生类中覆盖了基类的构造函数的虚函数版本,但是在构造基类部分的时候仍然调用的是基类的虚函数版本而不是子类覆盖的版本,因为子类在这个阶段并没有构造成功,此时调用子类的成员就是一种未定义的行为;析构函数与之类似;
2.第十六章_模板与泛型编程
所谓泛型编程即独立于任何特定类型的方式编写代码,因此在使用泛型程序时需要指定具体的程序实例所操作的类型或值,标准库所提供的容器、迭代器、算法都是泛型编程的例子
- 模板是C++泛型编程的基础;
- 为模板提供足够的信息就能够生成特定的类或函数;
泛型编程与面向对象编程都依赖于某种形式的多态
:
- 面向对象编程依赖的多态性称为运行时多态性,在运行时应用于存在继承关系的类;
- 泛型编程依赖的多态性称为编译时多态性或参数式多态性,所编写的类和函数能够多态地用于跨越编译时不相关的类型;
2.1 函数模板
函数模板是一个独立于类型的函数,可作为一种方式产生函数的特定类型版本;
模板定义以关键字template开始,后接模板形参表(模板形参表不能为空),模板形参表是用尖括号括住的一个或多个模板形参的列表,形参之间以逗号分隔;
1 |
|
结论1:编译器生成的版本通常被称为模板的实例,使用模板的过程我们称为实例化
1 |
|
结论2:类型参数T可以用来指定返回类型或函数的参数类型
1 |
|
结论3:可以在模板中定义非类型参数,表示一个值而非一个类型
1 |
|
我们常将模板中的参数限制为const,这样模板就可以用于const和非const实参
结论4:inline说明符跟在模板参数列表之后、返回类型之前
1 |
|
2.2 类模板
结论1:如果类的成员函数定义在类之外,则需要写上template,模板参数列表和类模板保持一致
1 |
|
结论2:默认情况下对于一个实例化了的类模板,其成员只有在使用的时候才会被实例化
结论3:在一个类模板的作用域内,我们可以直接使用模板名而无需指定模板实参
2.2.1 类模板和友元
结论4:为了使所有实例成为友元,友元声明中必须使用与模板本身不同的模板参数(多对多的关系)
1 |
|
2.2.2 模板类型别名
1 |
|
1 |
|
2.2.3 类模板的静态成员
1 |
|
2.2.4 模板形参
1 |
|
2.2.5 模板实参
结论5:编译器通常不会对实参进行类型转换,而是生成一个新的模板实例(当然假如显式指定了模板实参则忽略这一条)
因为本身T是什么类型就不确定,更别谈类型之间的转换,将实参传递给有模板类型的函数形参时,能够自动应用的类型转换只有const转换及数组或函数到指针的转换
1 |
|
结论6:当编译器无法推断某个模板实参(因为它可能未出现在函数参数列表中)时,我们可以显式指定该模板实参
1 |
|
结论7:显式指定实参后可以使用常规的类型转换
2.2.6 模板成员
一个类可以包含本身是模板的成员函数:成员模板(注意成员模板不能是虚函数)
1 |
|
1 |
|
2.3 重载与模板
函数模板可以被另一个模板或一个普通非模板函数重载
1 |
|
关于重载模板与类型转换
1 |
|
2.4 可变参数模板
“可变”指的是参数的个数可变,可能为0个可能为多个
1 |
|
与一般的模板相同,当编译器遇到可变参数模板函数的调用时,会根据调用时所传递的实参来推断模板参数类型以及包中参数的数目
1 |
|
使用sizeof…运算符可以知道包中的参数数目
1 |
|
2.5 模板特例化
模板的作用就是泛化,但是泛化的过程中可能无法满足我们的需求或者我们并不希望使用泛化的这个版本,此时我们可以指定一个特例化的版本(特例化还有一个好处就是某些不匹配的版本特例化之后就可以匹配了)
1 |
|
1 |
|
类模板也可以部分特例化,注意只能部分特例化类模板,不能部分特例化函数模板;
部分特例化本质上还是生成一个模板
1 |
|