C++面向对象编程

第一章 C++引论
程序设计语言
面向过程程序设计程序基本单元是函数,面向对象程序设计程序基本单元是类。
程序编译技术
编译过程包括:
- 预处理:替换预处理指令
#define #include
等 - 词法分析:产生单词序列(token)
- 语法分析:简单分析语法,如分号,括号的配对,
if else
配对等 - 代码生成:生成低级语言代码如机器语言或汇编语言。标识符在生成低级代码会进行换名,C和C++换名策略不同
- 模块连接:将中间代码和标准库、非标准库连接起来,形成一个可执行的程序。静态连接lib库与动态连接dll库
面向对象的程序设计语言
- 纯OO型语言:程序全部由类构成:SMALLTALK、JAVA、 C#、OBJECT-ORIENTED PASCAL
- 混合型OO语言:程序由类、全局过程或函数以及全局变量定义构成:C++
面向对象的基本概念
- 函数绑定:找到函数入口地址的过程。其中早期绑定发生在程序开始执行前,由编译程序或者操作系统静态或动态连接完成;晚期绑定发生在程序运行过程中,由程序自己完成
- 对象:抽象出来的数据对象,描述一个问题中的一件事物
- 类:描述对象共同特征和行为的数据类型。
- 封装:将对象的数据结构和算法包装在一起,描述对象的组织结构和功能,提供外部访问接口但是屏蔽对象的行为细节
- 交互:直接交互指一对象调用另一对象的“操作”、“功能”或“函数”; 间接交互通过发送或监听消息完成。
- 重载:用一个函数名称来定义完成不同功能的多个函数。重载函数要么参数个数不同,要么参数类型不同,或者都不同
- 多态:通过一个函数名能表现出不同行为。重载函数行为发生在早期绑定,属于静态多态;虚函数行为发生在晚期绑定,属于动态多态。多态一般指动态多态。多态增强了程序的泛用性。
- 继承:一个对象获得另一个或多个对象的“特征”和“行为”, 从而实现了软件重用。
- 抽象:从事物到概念,从低级概念到高级概念
- 抽象类:抽象级别最高的类,无法描述具体特征和行为。
C++语言的特点
- 兼容C,代码质量高,速度快
- 多继承,强类型,混合型面向对象
- 支持面向对象的运算符重载
- 提供函数模板和类模板等高级抽象机制
- 支持面向对象的异常处理
- 支持名字空间
C++由三种头文件,一种是老式的C头文件,与C的头文件一致;一种是C++新的头文件,没有.h
后缀;一种是老式C头文件的封装,将老式头文件的.h
后缀删去,在前面加上c,与老式头文件的区别在于有名字空间。
第二章 类型、常量和变量
C++的单词
单词包括标识符和保留字。保留字不可用做自定义标识符。保留字表如下
基础类型变量及其类型解析
内置数据类型
数据类型告诉我们数据在内存中的表示方式。C++的内置数据类型如下表
类型 | 含义 | 最小尺寸 |
---|---|---|
bool | 布尔类型 | 未定义 |
char | 字符 | 8位 |
wchat_t | 宽字符 | 16位 |
char16_t | Unicode字符 | 16位 |
char32_t | Unicode字符 | 32位 |
short | 短整型 | 16位 |
int | 整型 | 16位 |
long | 长整型 | 32位 |
long long | 长整型 | 64位 |
float | 单精度浮点数 | 6位有效数字 |
double | 双精度浮点数 | 10位有效数字 |
long double | 扩展精度浮点数 | 10位有效数字 |
注:每种数据类型的字节数与硬件、操作系统、编译有关。默认一般整数常量当作为int类型,浮点常量当作double类型。
预定义类型的格式化输入输出格式与C一致,不做赘述
C++在使用变量前要先对变量进行定义或声明
- 变量声明:描述变量的类型及名称,没有初始化,声明是针对已经定义过的变量进行的,可以声明多次
- 变量定义:描述变量的类型及名称,为变量分配内存空间并初始化,只能定义一次
声明变量时需要用到关键字extern
,且不能初始化(若初始化则定义一个变量)
C++提供了多种初始化变量的方法,如下
1 | int a = 0; //C风格 |
如果定义变量时没有指定初始值,则变量呗赋予默认值,有一下几种情况
- 内置类型全局变量和局部静态变量初始化为0
- 函数内部的内置类型局部变量的值未确定,直接访问会报错
- 类对象取决于构造函数的定义
关键字static,const,constexpr,volatile,inline
- static用以定义静态变量,分为模块静态变量和局部静态变量。模块静态变量在函数外部定义,作用域为当前文件,在函数内部可以用
::
访问。局部静态变量在函数中定义,作用域为该函数,但生命周期为整个程序。 - const或constexpr说明或定义的变量,定义时必须同时初始化。当前程序只能读不能修改其值。其中constexpr变量必须用编译时可计算表达式( 常量表达式)初始化。
- volatile说明或定义的变量,可以后初始化。其值不一定非要由当前程序修改,可由其他程序修改。
- 保留字inline用于定义函数外部变量或函数外部静态变量、类内部的静态数据成员。inline函数外部变量的作用域和inline函数外部静态变量一样,都是局限于当前代码文件的,相当于默认加了static。
指针类型变量
主体知识与C中一样。需要强化理解的是如何识别复合类型指针。
指针类型的变量使用说明和定义的,即 *
后面的才是描述指针的内存类型的,而*
前面的部分描述的是指针所指向的数据的基本类型以及内存类型的。
指针使用注意事项
- 只读单元的指针(地址)不能赋给指向可写单元的指针变量,而可写单元的指针(地址)能赋给指向只读单元的指针变量。
- 父类指针可以指向子类对象
- 指针初始化三种方法:
1 | int *p1=nullptr;//推荐 |
引用类型变量
引用为变量起了一个别名,引用类型变量的声明符用&
来修饰变量名(指左值引用)。引用类型变量必须马上初始化。
在C++中,左值指的是可以寻址的值,右值指的是不能寻址的值。左值持久、右值短暂。左值具有持久的状态(取决于对象的生命周期),而右值要么是字面量,要么是在表达式求值过程中创建的临时对象。注意,const
类型的左值引用可以引用右值,如直接const int &w=4;
此时相当于为4分配了一块内存空间。
引用的本质是常量指针。但是引用不分配物理内存,因此无法定义引用的有一定以及引用类型的数组,且不能被重新赋值。同时由于计算机没有按位编址,因此不能引用位段成员。
引用初始化时,除了二种例外情况,引用类型都要与绑定的对象严格匹配,除了const类型引用和父类引用绑定子类(与指针类似)
右值引用用&&
修饰变量名,必须引用右值。右值引用可以进行赋值,如int &&a = 1;a = 2;
是合法的但是右值引用使用的是右值对象的缓存,自己并不分配内存,这一点的性质与左值引用一致。但是右值引用可以引用位段成员,因为位段成员无址。
数组,结构,联合
没啥好说的。。。。
第三章 语句,函数及程序设计
C++的语句
与C一致,需要了解的是asm语句和static assert静态断言。asm语句可以在C或C++程序中插入汇编代码。static assert使用格式为static assert(条件表达式," 输出信息")
,不满足条件则编译报错并抛出报错信息。
C++的函数
基本知识与C一致。
函数可说明或定义为四种作用域:
- 全局函数(默认)
- 内联即inline函数:可在程序文件内或类内说明或定义,只能被当前程序文件的程序调用。它是局部文件作用域的,可被编译优化掉
- 外部即extern函数:声明外部函数用
- 静态即static函数:可在程序文件内或类内说明或定义。类内的静态函数不是局部文件作用域的,程序文件内的静态函数是局部文件作用域的。
函数可以定义省略参数,用...
表示可以接受0至任意个任意类型的参数。通常须提供一个参数表示省略了多少个实参。由于省略参数连续存放,故可以通过指针移动访问。如下
1 | long sum(int n, ...) //n为省略参数的个数 |
函数可以定义默认参数,调用时若未传实参则用默认值。默认值所用的表达式不能出现同参数表的参数,否则可能会因为传递参数的顺序的问题造成错误。所有默认值必须出现在参数表的右边。
内联inline函数调用会被编译进行优化,即直接将其函数体插入到调用处,而不是编译为call指令,这样可以减少调用开销,提高程序执行效率。若函数为虚函数、或包含分支或取过函数地址,或调用时未见函数体(函数体定义在调用处后面),则内联失败。失败不代表程序有错,只是被编译为函数调用指令。
重载函数:通过参数差异识别重载函数,即若参数的个数或者类型有所不同(至少一个参数类型不一致),则同名的函数被自动视为重载函数。重载只与参数类型有关,与返回类型无关,若参数个数和类型完全相同、仅仅返回类型不同是不允许的。当使用缺省参数和省略号时,函数重载容易引起歧义,会使编译器报错
作用域
- 程序可由若干代码文件(.cpp)构成,整个程序为全局作用域:全局变量和函数**属于此作用域。
- 稍小的作用域是代码当前文件作用域:函数外的static变量和函数属此作用域。
- 更小的作用域是函数体:函数局部变量和函数参数属于此作用域。
- 在函数体内又有更小的复合语句块作用域。
- 最小的作用域是数值表达式:常量在此作用域。
除全局作用域外,同层不同作用域可以定义同名的常量、变量、函数。但他们为不同的实体。如果变量和常量是对象,则进入面向对象的作用域。同名变量、函数的作用域越小、被访问的优先级越高。
生命周期
- 常量的生命期即其所在表达式。
- 函数参数或自动变量的生命期当退出其作用域时结束。
- 静态变量的生命期从其被运行到的位置开始,直到整个程序结束。
- 全局变量的生命期从其初始化位置开始,直到整个程序结束。
- 通过new产生的对象如果不delete,则永远生存(内存泄漏)。
- 外层作用域变量不要引用内层作用域自动变量(包括函数参数),否则导致变量的值不确定:因为内存变量的生命已经结束(内存已做他用)
第四章 C++的类
类的声明和定义
class
、struct
或union
可用来声明和定义类。类的声明直接使用class 类型名;
即可,而类的定义则需要给出其成员的声明或定义,类的实现则需要即定义类的函数成员的函数体。
1 | class Circle |
定义类时应注意的问题
- 使用private、protected和public保留字标识主体中每一区间的访问权限,同一保留字可以多次出现。
- 成员在类定义体中出现的顺序可以任意,函数成员的实现既可以放在类的外面,也可以内嵌在类定义体中(此时会自动成为内联函数);但是数据成员的声明定义顺序与初始化顺序有关(先声明定义的先初始化)。
- 同一区间内可以有数据成员、函数成员和类型成员,习惯上按类型成员、数据成员和函数成员分开。
- 类的定义体花括号后要有分号作为定义体结束标志。
- 在类定义体中允许对数据成员定义默认值,若在构造函数的参数列表后面的
:
和函数体的{
之间对其进行了初始化(在成员初始化列表进行初始化),则默认值无效,否则用默认值初始化; - 构造函数和析构函数都不能定义返回类型。
- 如果类没有自定义的构造函数和析构函数,则C++为类生成默认的构造函数(不用给实参的构造函数)和析构函数。
构造函数用来产生对象,为对象申请资源,初始化数据成员。构造函数是函数名与类名相同的函数成员,可以在参数表中显式定义参数,通过参数变化实现重载。定义变量或者其生命期开始时自动调用构造函数,由于一个对象仅自动构造一次,故构造函数不能被显式调用。
析构函数是用来毁灭对象的,释放对象申请的所有资源。析构函数的函数名与类名相同且带波浪线,参数表无参,无法重载。变量的生命期结束时会自动调用析构函数,析构函数可以显式调用,大师应当防止反复析构(自动变量不要显式调用析构函数,系统会自动调用,若析构函数没有防止反复释放资源显式调用可能会引起程序错误)
不同对象的析构与构造:
- 全局对象:由开工函数构造,收工函数析构,储存在数据段里
- 静态局部变量:定义时自动构造,main函数执行完后由收工函数析构,储存在数据段里
- 局部自动变量:定义时自动构造,函数返回时自动析构,储存在堆栈里
- new:调用new时自动构造,需要手动调用delete,储存在堆里
程序不同结束形式对对象的影响:
exit
:静态和全局变量会在main函数exit时由收工函数析构,其他变量都不会自动执行析构函数abort
:所有对象都不会被析构return
:除new出来的对象以外所有的对象都会按照既定方式自动调用析构函数。
成员访问权限及对成员的访问
private
:私有成员,本类函数成员可以访问;派生类函数成员、其他函数成员和普通成员都不能访问protected
:保护成员:本类和派生类的成员函数可以访问,其他类成员和普通函数都不能访问public
:共有成员,任何函数均可访问
注意:
- 类的友元可以访问类的所有成员函数
- 构造函数和析构函数可以定义为任何访问权限
class
定义的类中,缺省访问权限为private
;struct
和union
定义的类冲缺省的访问权限为public
内联,匿名类和位段
在类体内定义的任何函数都会自动内联。当然用inline
修饰的函数也会内联。匿名类(即类定义时没有名字)的函数成员只能在类体内定义(内联),函数的局部类成员也只能在类体内定义(内联)
没有对象的全局匿名联合必须定义为static
,局部的匿名联合不能定义为static
,匿名联合只能定义公有数据成员,vi是相当与定义了几个变量,但是这些变量公用存储空间。
位段的用法和C一样
new和delete
C语言的内存管理使用malloc
和free
函数,C++使用new
和delete
运算符
new
:用法为new 类型表达式{初始值}
,其中初始值可以用圆括号,也可以省略,在为数组分配内存时第一维下标可以为任意表达式,其他必须为常量表达式。 new
的底层依然是调用malloc
函数,因此对简单对象new
和malloc
没什么不同,与malloc
不同的是new
还会调用类的构造函数。
delete
:用法为delete 指针
或者delete[] 数组指针
前者只能用于销毁单个实体,后者可以用于销毁对象数组,其底层为先调用析构函数,再调用free
隐藏参数this
this
指针是一个特殊的指针,它是普通函数成员(非静态函数成员)隐含的第一个参数,其类型是指向要调用该函数成员的对象的const
指针。可以认为this
指向这个类,可以用this->
调用类的成员。this
指针不允许移动
对象的构造与析构
非静态数据成员可以再如下位置初始化
- 就地初始化,在定义类的数据成员的时候初始化赋值
- 成员初始化列表初始化
- 就地初始化与成员初始化列表同时使用时以初始化列表为准
- 赋值,即在构造函数体内赋值
同样注意 const
和引用类型变量定义时必须初始化,且只能就地初始化或者在成员初始化列表中初始化。
若未定义或生成构造函数,则可以用{}
的形式初始化,这里和C的结构初始化是一样的。
若类未定义构造函数,编译器会尝试自动生成构造函数,成为合成的默认构造函数,一旦定义了构造函数,编译器生成的构造函数将不会被接受,除非用default
。
合成的默认构造函数工作结果分为以下几种情况
- 如果为数据成员提供了就地初始化,则工作成功
- 如果没有就地初始化
- 若只包含非
const
、非应用内置数据类型成员,如果类的对象是全局或局部静态的,则这些成员默认初始化为0;如果类的对象是局部自动的,当程序访问这些成员时编译器报错,如果对象是new出来的,则不会报错,但访问结果是随机值 - 如果包含
const
,引用数据成员,其他无默认构造类型的class
类型成员,一旦实例化对象,编译器报错
- 若只包含非
若自定义了构造函数之后仍要求编译器提供合成的默认构造函数,可以使用构造函数名称()=default
来使用不带参的默认构造函数。
转换构造函数
如果构造函数只接受一个实参,实际上它定义了转换为此类类型的转换机制,这种构造函数称为转换构造函数。如下面的例子
1 | class Integer |
这里的隐式转换的原因就是我们在定义一个对象时由于其构造函数只就收一个变量,故可以用=
将其初始化。注意这种转换只能进行一次,比如我们由下面的一个类
1 | class Age |
我们无法在主函数中直接Age a = 100
。
要避免这种隐式转换,可以在构造函数前面加上explicit
,这样的话我们便不能用=
对变量进行初始化,只能用()
拷贝构造函数
如果class A
的构造函数的第一个参数是自身类型引用(const A &
或A &
), 且其它参数都有默认值(或没有其它参数),则此构造函数是拷贝构造函数。用一个已经构造好对象去构造另外一个对象时会调用拷贝构造函数
如果没有为类定义拷贝构造函数,编译器会为我们定义一个合成的默认拷贝构造函数,编译器提供的合成的默认拷贝构造函数原型是ACopyable(const ACopyable &o
),在没有自定义构造函数或者用了default
之后可以直接使用。编译器提供的合成的默认拷贝构造函数其行为是:按成员依次拷贝。如果用类型A的对象o1
拷贝构造对象o2
,则依次将对象o1
的每个非静态数据成员拷贝给对象o2
的对应的非静态数据成员。
浅拷贝
按成员依次拷贝称为浅拷贝。在浅拷贝过程中,若实参对象包含指针类型的实例数据成员,只复制指针值则两个对象的该成员指向同一块内存,可能会造成内存的错误。
深拷贝
在传递参数时先为形参对象的指针成员分配新的存储单元,而后将实参对象的指针成员所指向的单元内容复制到新分配的存储单元中。为了在传递参数时能进行深拷贝,必须自定义参数类型为类的引用的拷贝构造函数,即定义A (A &)
、A (const A&)
等形式的构造函数。建议使用A (const A&)
。
如上面转换构造函数中提到的,单参数构造函数可以用=
调用,当我们没有自定义=
重载时,编译器会提供一个重载,但此时为浅拷贝构造。因此安全的做法是在自定义深拷贝构造函数后还要自己实现=
运算符的深拷贝重载
移动构造和移动赋值
当函数的参数为值参以及函数返回类型为值时,都会出现对象的频繁拷贝。在很多情况下,对象被拷贝后就立即销毁(特别是函数返回值时),在这些情况下,采用移动对象会大幅提高性能。所谓移动构造和移动赋值,就是将对象的指针指向实参指向的空间,然后将实参进入安全可析构的状态。
移动构造的本质是浅拷贝,但与浅拷贝不同的是由于实参是马上要被销毁的对象,因此我们可以对其随意更改,所以我们将实参的指针设为空指针,使其析构时不会释放其指向的内存。
如果一个类既有拷贝构造函数,也有移动构造函数,编译器根据参数类型进行匹配来确定使用哪个构造函数,赋值操作类似。因此这时遵循一个重要原则:移动右值,拷贝左值。若没有移动构造/移动赋值函数,右值也被拷贝构造/拷贝赋值。
前面提到过可以用=default
来显式要求编译器提供默认版本,还可以用=delete
定义为删除
第五章 成员及成员指针
成员指针:指向类的成员(普通和静态成员)的指针,分为实例成员指针和静态成员指针。变量、数据成员、函数参数和返回类型都可定义为成员指针类型, 即普通指针能用的地方成员指针都能用。例如int A::*p
即指向A
类的int
类型的实例数据成员。可将其赋值为p=&A::m
利用指针访问类的成员使用.*
或者->*
运算符,用法为对象名.*指针名
或对象指针->*指针名
注意这是指向类的成员的指针,不是指向实例化了的对象的成员的指针
实例成员指针
实例成员指针是指向实例成员的指针,可分为实例数据成员指针和实例函数成员指针。由于构造函数不能被显式调用,故不能有指向构造函数的实例成员指针。
实例成员指针式数据成员相对于对象首地址的偏移,不是真正的地址,所以实例成员指针不能移动,也不能转换类型。
其实很好理解,实例成员指针指向的是类的成员,没有实例化的时候绝对地址没有意义,所以记录的是相对地址。由于数据成员的大小,访问权限类型不一定相同,轻易移动指针很可能会出现问题。类型转换也是,若对指针进行类型转换其实相当于移动了指针
const、volatile和mutable
const
只读、volatile
易变、mutable
机动,前两者可以用来定义变量,类的数据成员、函数成员以及普通函数的参数和返回值,最后一个只能用来定义类的数据成员。含const
的数据成员的类必须定义有初始化,volatile
mutable
则不一定
关于这三个关键字在修饰类的成员时,应注意以下几点
- 普通函数成员参数表后出现
const
或volatile
,修饰隐含参数this
指向的对象。- 出现
const
表示this
指向的对象的非只读类型的静态数据成员可以修改,但不能修改调用对象的非静态数据成员,但如果该数据成员的存储类为mutable,则该数据成员就可以被修改。 - 出现volatile,常表示调用该函数成员的对象是挥发对象,这通常意味着存在并发执行的进程。
- mutable说明数据成员为机动数据成员,该成员不能用const、volatile或static修饰。
- 出现
const
volatile
对隐含参数的修饰还会影响函数成员的重载- 普通对象应调用参数表后不带const和volatile的函数成员;
- const和volatile对象应分别调用参数表后出现const和volatile的函数成员,否则编译程序会对函数调用发出警告。
静态数据成员
静态成员用static
声明, 包括静态数据成员和静态函数成员,static
声明只能出现在类内。即别在类外实现函数的时候出现inline
非const
、非inline
静态数据成员在类体内声明、类体外定义并初始化。const
inline
修饰的静态数据成员可以在类内就地初始化
类的静态数据成员在类还没有实例化对象前就已存在,相当于Java的类变量,用于描述类的总体信息,如对象总数、连接所有对象的链表表头等。访问权限同普通成员。逻辑上,所有对象共享静态数据成员内存,任何对象修改静态数据成员的值,都会同时影响其他对象关于该成员的值。物理上,静态数据成员相当于独立分配内存的变量,不属于任何对象内存的一部分。静态数据成员相当于有类名限定、带访问权限的全局变量
静态函数成员
静态函数成员通常在类内以static
说明或定义,没有this
参数。因此有this
的构造和析构函数、虚函数以及纯虚函数都不能定义为静态函数成员。
由于没有this
参数,静态函数成员参数表后不能用const
、volatile
、const
volatile
等修饰符修饰。静态函数成员也不能访问对象的非静态成员函数
静态成员指针
静态成员指针是指向类的静态成员的指针,包括静态数据成员指针和静态函数成员指针。
由于静态成员是在没有实例化对象前就已经存在了的,所以是有地址的,因此指向静态成员指针就用普通指针
第六章 继承与构造
继承是C++类型演化的重要机制,在保留原有类的属性和行为的基础上,派生出的新类可以有某种程度的变异。接受成员的新类称为派生类,提供成员的原有类型称为基类。
C++既支持单继承又支持多继承。单继承只能获取一个基类的属性和行为。多继承可获取多个基类的属性和行为。
单继承类
单继承是只有一个基类的继承方式。单继承的定义格式为
1 | class 派生类名:继承方式 基类名 |
继承方式
继承方式指明派生类采用什么继承方式从基类获得成员,分为三种:private
表示私有继承基类;protected
表示保护继承基类;public
表示公有继承基类。 三种继承方式派生类对基类成员的访问权限为
- 基类私有成员对派生类函数是不可见的。派生类不能访问基类私有成员,除非将派生类的声明为基类的友元类,或者将要访问基类私有成员的派生类函数成员声明为基类的友元。
- 公有继承时基类的公有成员和保护成员派生到派生类时都保持原有的状态
- 保护继承时基类的公有成员和保护乘员都称为派生类的保护乘员
- 私有继承时基类的功有成员和保护成员派生后都作为派生类的私有成员
用class
声明的类的继承方式缺省为private
,用struct
声明的继承方式和访问权限缺省为public
。用union
声明的类既不能作派生类的基类,也不能作任何基类的派生类。
派生类可以通过using 基类名::基类成员
或者直接基类名::基类成员
来改变访问权限,如:
1 | class A{ |
标识符的作用范围可分为从小到大四种级别:①作用于函数成员内;②作用于类或者派生类内;③作用于基类内;④作用于虚基类内。 标识符的作用范围越小,被访问到的优先级越高。如果希望访问作用范围更大的标识符,则可以用类名和作用域运算符进行限定。
构造与析构
单继承派生类的构造顺序为:
- 调用虚基类的构造函数
- 调用基类的构造函数
- 按照派生类中数据成员的声明顺序,依次调用数据成员的构造函数或初始化数据成员
- 调用派生类的构造函数
析构是构造的逆序。
析构时应该注意:
- 如果被引用的对象是
new
生成的,引用变量必须用delete &r
析构 - 若被指针指向的对象是
new
生成的,则指针变量必须用delete p
析构,不能用free(p)
父类与子类
如果派生类的继承方式位为public
,则这样的派生类称为基类的子类,而相应的基类则称为派生类的父类。
C++允许父类指针直接指向子类对象,也允许父类引用直接引用子类对象。编译程序只能根据类型定义静态地检查语义。由于父类指针可以直接指向子类对象,而到底是指向父类对象还是子类对象只能在运行时确定。编译时,只能把父类指针指向的对象都当作父类对象。因此编译时父类指针访问对象的数据成员或函数成员时不能超越父类为其相应对象规定的访问权限,也不能通过父类指针访问子类新增的成员。
子类指针指向父类对象时要经过强制类型转换。
第七章 可访问性
作用域
作用域:标识符起作用的范围。作用域运算符::
既是单目运算符,又是双目运算符。其优先级和结合性与括号相同。单目用于限定全局标识符,双目用于限定类的枚举元素、数据成员、函数成员以及类型成员等。双目运算符还用于限定名字空间成员,以及恢复从基类继承的成员的访问权限。
面向对象的编程中作用域大小前面说过了
名字空间
名字空间是C++引入的一种新作用域,C++名字空间既面向对象又面向过程:除可包含类外,还可包含函数、变量定义。
名字空间必须在全局作用域内用namespace定义,不能在类、函数及函数成员内定义,最外层名字空间名称必须在全局作用域唯一。同一名字空间内的标识符名必须唯一,不同名字空间内的标识符名可以相同。当程序引用多个名字空间的同名成员时,可以用名字空间加作用域运算符::
限定。
名字空间(包括匿名名字空间)可以分多次定义(在不同文件里):
- 可以先在初始定义中定义一部分成员,然后在扩展定义中再定义另一部分成员;
- 或者先在初始定义中声明的函数原型,然后在扩展定义中再定义函数体;
所谓扩展定义其实和初始定义一样,只是分了几次说而已。
名字空间成员有三种访问方式:
- 直接访问:
名字空间名称::成员名称
- 引用成员:
using 名字空间名称::成员名称
,引用时只能给出函数名,不能带函数参数。引用其实相当于extern声明,直接访问则是直接加个前缀就开始用 - 引用名字空间:
using namespace 名字空间名称
using
声明以关键字using
开头,后面是名字空间成员名, 如果是声明嵌套多层的名字空间里的成员,要多级限定。如果using
直接引用了成员,则在当前作用域下不能再定义同名变量;若引用的是整个名字空间,假设在当前作用域下定义了与名字空间中某个成员重名的变量,在未使用同名变量前不会报错,一旦在没有加上限定符的情况下就使用会报错。
嵌套名字空间:名字空间内可定义名字空间,形成多个层次的作用域,引用时多个作用域运算符自左向右结合。如下:
1 | namespace A { // A的初始定义 |
可以为名字空间定义别名,以代替过长和多层的名字空间名称。对于嵌套定义的名字空间,使用别名可以大大提高程序的可读性。如namespace AB=A::B
匿名名字空间:匿名名字空间的作用域为当前程序文件,名字空间被自动引用,其成员定义不加入当前作用域(面向过程或面向名字空间),即可以在当前作用域定义同名成员。一旦同名冲突,自动引用的匿名名字空间的成员将是不可访问的。 在同一个文件里,匿名名字空间也可分多次定义。
成员友元
类的友元函数是定义在类外部,但有权访问类的所有私有成员和保护成员。**要声明一个函数为一个类的友元,则在该类中用friend
声明该函数即可,函数的实现放在类外。
成员友元是一种将一个类的函数成员声明为其它类友元的函数。
如果类A的实例函数成员被声明为类B的成员友元,则这种友元称为实例成员友元。如果类A的静态函数成员被声明为类B的成员友元,则这种友元称为静态成员友元。
如果某类A的所有函数成员都是类B的友元,则可以简单的在B的定义体内用friend A;声明,不必列出A的所有函数成员。此时称类A为类B的友元类。
友元的派生不具有传递性和对称性
普通友元及其注意事项
包括主函数在内任何一个普通函数都可以定义为一个类的普通成员友元。普通友元不是类的函数成员,故普通友元可在类的任何访问权限下定义。一个普通函数可以定义为多个类的普通友元。
第八章 虚函数与多态
虚函数
虚函数是用virtual
定义的实例成员函数。当基类对象指针或引用指向或引用不同类型的派生类对象时,通过虚函数到基类或派生类中同名函数的映射实现动态多态。静态多态与动态多态的概念在第一章中有论述。下面是一个例子
1 | //静态多态 |
1 | //动态多态 |
注意:只有基类的指针或者引用才能体现出多态,这里的多态指的指针对基类的对象设计的代码可以应用于子类对象,调用的方法时最具体的类的方法
关于虚函数,有以下几个需要注意的问题
虚函数必须是类的实例成员函数,非类的成员函数不能说明为虚函数,普通函数如
main
不能说明为虚函数。虚函数一般在基类的
public
或protected
部分在派生类中重新定义成员函数时,函数原型必须完全相同;
虚函数只有在具有继承关系的类层次结构中定义才有意义,否则引起额外开销 (需要通过VFT访问)
一般用父类指针(或引用)访问虚函数。根据父类指针所指对象类型的不同,动态绑定相应对象的虚函数
虚函数有隐含的this参数,参数表后可出现
const
和volatile
构造函数构造对象的类型是确定的,不需根据类型表现出多态性,故不能定义为虚函数;析构函数可通过父类指针(引用)或
delete
调用,父类指针指向的对象类型可能是不确定的,因此析构函数可定义为虚函数(强烈建议,当然此时函数原型不相同,不过没有问题)。一旦父类(基类)定义了虚函数,所有派生类中原型相同的非静态成员函数自动成为虚函数(即使没有
virtual
声明);(虚函数特性的无限传递性)多态并不是只有父子关系之间有,若类之间的继承关系不是父子关系,其影响只是基类指针不能指向派生类的对象,指针或地址强制类型转换后多态性质依然成立
用父类引用实现动态多态性时需要注意,
new
产生的被引用对象必须用delete &
析构,而不能仅仅调用析构函数或者使用free
抽象类
纯虚函数:指不必定义函数体的函数,可以有重载、缺省参数、省略参数、内联等。定义格式为virtual 函数原型 = 0
。纯虚函数也有this
指针,不能定义为static
。纯虚函数的函数体应在派生类中实现,成为非纯虚函数。
含有纯虚函数的类称为抽象类。抽象类不应该有对象或类的实例。如果抽象类的派生类继承了抽象类的纯虚函数却没有实现,则该派生类依然为抽象类。只有在派生过程中所有纯虚函数都被实现,派生类才会成为非抽象类。
抽象类作为抽象级别最高的类,主要用于定义派生类共有的数据和函数成员。抽象类的纯虚函数没有函数体,意味目前尚无法描述该函数的功能。
第九章 多继承类与虚基类
多继承
多继承派生类有多个基类或虚基类。单继承是多继承的一种特例,多继承具有更强的类型表达能力。多继承派生类继承所有基类的数据成员和函数成员。
基类之间的成员可能同名,基类与派生类的成员也可能同名。在出现同名时,如面向对象的作用域不能解析,可使用基类类名加作用域运算符::来指明要访问基类的成员。
单继承语言在描述多继承的对象时,常常通过对象成员委托(代理)实现多继承。即继承一个类,然后将另一个类的数据对象作为数据成员,作为另一个类的行为的代理。
多继承方式定义派生类
1 | class 派生类名:派生方式 基类1,派生方式 基类2 |
关于多继承有以下几个需要注意的问题
- 一个类不能多次成为一个派生类的直接基类(即使是虚基类) 否则会报编译错误
- 一个类可以多次成为一个派生类的间接基类(即使不是虚基类)
- 一个类可以同时成为一个派生类的直接基类和间接基类,但该类作为直接基类时必须是虚基类。 否则会报警告错误
直接多继承和委托代理可能存在一个问题,如果两个基类在物理上存在同一个基类,可能对同一个物理对象重复初始化以及重复析构。
虚基类
虚基类用virtual声明,可把多个逻辑虚基类对象映射成同一个物理虚基类对象。映射成的这个物理虚基类对象尽可能早的构造、尽可能晚的析构,且构造和析构都只进行一次。
假设两栖机车AmphibiousVehicle
继承基类陆用机车LandVehicle
和水上机车WaterVehicle
,LandVehicle
和WaterVehicle
都继承自Engine
,则在构造时可能会对AmphibiousVehicle
的 Engine
初始化两次
使用虚基类Engine
,之后LandVehicle
和WaterVehicle
的Engine
都指向同一块物理内存,且只会构造和析构一次,则不会出现上述情况。
代码如下:
1 | //直接多继承 |
同一棵派生树中的同名虚基类,共享同一个存储空间;其构造和析构仅执行1次,且构造尽可能最早执行,而析构尽可能最晚执行。由派生类(根) 、基类和虚基类构成一个派生树的节点,而对象成员将成为一棵新派生树的根。
在非虚拟派生中,派生类只能显式初始化其直接基类,而虚拟基类的初始化则成了每一级派生类的责任,即只要使用了虚基类,虚基类的每一级派生类都必须调用虚基类的构造函数(因为虚基类必须最先构造),有虚基类的派生类构造函数不能使用constexpr
定义
派生类成员
虚基类和基类成员同名必然会导致二义性访问,编译程序会对这种二义性访问报错。当出现这种情况时,可用作用域运算符限定要访问的成员。
当派生类成员和基类成员同名时,优先访问作用域小的成员,即优先访问派生类的成员。当派生类数据成员和派生类函数成员的参数同名时,在函数成员内优先访问函数参数。
多继承的构造与析构
派生类对象的构造顺序:
- 按定义顺序构造派生树中所有虚基类
- 按定义顺序构造派生类中所有直接基类
- 按定义顺序构造派生类数据成员
- 执行派生类构造函数
析构顺序与构造顺序相反。注意:同名派生类在同一棵树中只构造一次
第十章 异常与断言
异常处理
异常时一种意外破坏程序正常处理的时间、由硬件或软件触发的事件。异常处理可以将错误处理流程同正常业务处理流程分离,从而使程序的正常业务处理流程更加清晰顺畅。
异常发生后自动析构调用链中的所有对象,这也使程序降低了内存泄漏的风险。 由软件引发的异常用throw
语句抛出,会在抛出点建立一个描述异常的对象,由catch
捕获相应类型的异常。
异常处理的一般结构为:
1 | try{ |
catch
必须出现try
语句块后面,不能有单独的try
语句块或者单独的catch
语句块,throw
语句要求直接或者间接出现在try语句块中,异常必须被某个try
的catch
捕获。如果没有被任何catch
捕获,则程序将被终止执行。
异常捕获的过程称为栈展开,即当try
语句快有异常抛出时,先检查该try
语句的相关catch
子句,没找到则检查外层try
自居配套的catch
子句,还没找到则退出当前函数,在调用该函数的外层函数继续寻找,如果到主函数种还没找到catch自居,程序退出,将异常交给OS处理。以上过程递归调用,称为栈展开。
如果在catch
子句中需要重新抛出异常,可以直接使用不带表达式的throw
语句用以传播已捕获的异常。若希望捕获所有异常,则可以使用catch(...)
。若接受的异常类型为字符串常量,则需要用catch(const char *p)
捕获。
捕获顺序
总的来说,先声明的异常处理先得到执行机会。特别的,异常的类型只要和catch
参数的类型相同或相容即可匹配成功。即派生异常对象(及对象指针或引用)可以被带基类型对象、指针及引用参数的catch
子句捕获。注意catch(const volatile void *)
能捕获任意指针类型的异常,catch(…)
能捕获任意类型的异常。
函数的异常接口
通过异常接口声明的异常都是由该函数引发的、而其自身又不想捕获或处理的异常。 异常接口定义的异常出现在函数的参数表后面,用throw
列出要引发的异常类型。其中throw()
、noexcept
、throw(void)
代表不引发任何异常,如果参数表后面不出现异常接口,表示该函数可能引发任何类型的异常。
对于未经处理的异常或传播后未经处理的异常,C++的监控系统将调用void terminate( )
处理。缺省情况下terminate
函数调用abort
函数来终止程序。可以调用set_terminate( )
函数,设置自定义的terminate处理函数。如下
1 | void (*old_terminate)();//缺省的teminate函数主要是后面用来恢复 |
断言
函数assert(int)
在assert.h
中定义。 断言是一个带有整型参数的用于调试程序的函数,如果实参的值为真则程序继续执行。 否则,将输出断言表达式、断言所在代码文件名称以及断言所在程序的行号,然后调用abort()
终止程序的执行。
保留字static_assert
定义的断言在编译时检查,为真时不终止编译运行。
第十一章 运算符重载
运算符概述
运算符分为纯单目运算符,纯双目运算符,三目运算符,既是单目又是双目运算符,多目运算符。根据运算结果又可以分为左值运算符和右值运算符。
C++用返回类型 operator 运算符(参数表)
进行运算符重载。如果用类的普通成员函数重载运算符,this
隐含参数代表第一个操作数对象。
不能重载的运算符有:sizeof
.
.*
::
?:
其他运算符能否重载的情况如下图所示
运算符重载基本和函数重载一样,但是由于运算符本身时有意义的,有以下几点要注意
- 若运算符为左值运算符,则重载后运算符函数最好返回非只读左值引用类型(左值)。当运算符要求第一个参数为左值时,不能使用
const
说明第一个参数(含this
),例如++
、--
、=
、+=
等的第一个参数。 - 重载一般也不改变运算符的操作数个数。特殊的运算符
->
、++
、--
(区分前置和后置)除外。 - 重载不改变运算符的优先级和结合性。
运算符参数
重载函数种类不同,参数表列出的参数个数也不同。
- 重载为普通函数(全局函数):参数个数=运算符目数
- 重载为类的普通成员函数(实例函数):参数个数=运算符目数 - 1 (即
this
指针) - 重载为类的静态成员函数:参数个数 = 运算符目数(没有this指针)
特殊运算符的重载
- 重载
++
,--
:参数应设为非const
左值引用,前置返回值为左值引用,后置返回值为右值。后置的参数应加上一个int
类型表示后置 - 也可以用
operator
定义强制类型转换函数。由于转换后的类型就是函数的返回类型,所以强制类型转换函数不需要定义返回类型。
第十二章 类型解析,转换和推导
显式与隐式类型转换
字节数少的类型向字节数多的类型转换时,不会引起数据的精度损失。 无风险的转换由编译程序自动完成,这种自动转换也称为隐式类型转换。强制类型转换称为显式类型转换
一般简单类型之间的强制类型转换的结果为右值,而进行左值引用转换时结果为左值。注意只读的简单类型变量如果转化为可写左值,依然不能修改其值,而对类的只读类型成员则可以(就离谱)
cast系列类型转换
static_cast
同C语言的强制类型转换用法基本相同,但不能从源类型中去除const
和volitale
属性,不做多态相关的检查。const_cast
同C语言的强制类型转换用法基本相同,但能从源类型中去除const
和volitale
属性。dynamic_cast
主要用于有继承关系的基类型和派生类型之间的相互转换。将子类对象转换为父类对象时无须子类多态,而将基类对象转换为派生类对象时要求基类多态。reinterpret_cast
通常为运算对象的位模式提供低层级的重新解释。主要用于名字(运算对象)同指针或引用类型之间的转换,以及指针与足够大的整数类型(能够存放地址)之间的转换。
static_cast——静态转换
使用格式为static_cast<T> (expr)
,如
1 | double x=2.0; |
目标类型T
不能包含存储位置类修饰符,如static
、extern
、auto
、register
等。 static_cast
仅在编译时静态检查源类型能否转换为T
类型,运行时不做动态类型检查。static_cas
t不能去除源类型的const
或volatile
。即不能将指向const
或volatile
实体的指针(或引用)转换为指向非const
或volatile
实体的指针(或引用)。 只要不转换底层const
(和指针与引用等复合类型的基本类型有关。修饰基本类型的const
),都可以使用static_cast
。即我们无法通过修改指针类型以修改只读变量的值。
const_cast——只读转换
使用格式与static_cast
一致,但其目标类型必须是指针或引用或指向对象类型成员的指针。可以去掉指针顶层和底层的const
和volatile
,但不能改变其类型。
dynamic_cast——动态转换
关键字dynamic_cast
主要用于子类向父类转换,以及有虚函数的基类向派生类转换。被转换的表达式必须涉及类类型。 使用格式为dynamic_cast<T> (expr)
,要求T
是类的引用、类的指针或者void*
类型,而expr
的源类型必须是类的对象(包括常量对象或变量对象:向引用类型转换)、父类或者子类的引用或指针。 dynamic_cast
转换时不能去除数值表达式expr
源类型中的const
和volitale
属性。 被转换的基类对象必须包含虚函数或纯虚函数才能转换为派生类对象。
reinterpret_cast——重释转换
关键字reinterpret_cast
实现有址表达式(有名字)到指针或(有址或无址)引用类型的转换以及指针与足够大整数类型间的相互转换。 使用格式为reinterpret_cast <T> (expr)
,用于将数值表达式expr
的值转换成T
类型的值。T
类型不能是实例数据成员指针。 转换为足够大的整数类型是指能够存储一个地址或者指针的整数类型,X86和X64的指针大小不同,X86使用int类型即可。 当T
为使用&
或&&
定义的引用类型时,exp
r必须是一个有址表达式。 有址引用和无址引用之间可以相互转换。
typeid
可以检查对象的类型,使用方法为typeid(a)==typeid(A)?
或者typeid(*p)!=typeid(D)
等,一般都是用作判断。
自动类型推导
保留字auto
在C++中用于类型推导。auto
让编译器通过初始值来推算类型,因此auto
定义的变量必须有初始值;使用auto
推导时,被推导实体不能出现类型说明,但是可以出现存储可变特性const
、voilatile
和存储位置特性如static
、register
,inline
。使用auto
可以在一条语句中声明多个变量,但必须保证这一条声明语句只能有一个基本数据类型。
关键字decltype
用来提取表达式的类型。 使用方法如decltype(f()) sum = x;
用函数f
的返回类型来声明变量sum
,并初始化为x
发生在编译时,且不会调用函数f
。凡是需要类型的地方均可出现decltype
。 可用于变量、成员、参数、返回类型的定义以及new
、sizeof
、异常列表、强制类型转换。 可用于构成新的类型表达式。
Lamda表达式
Lambda表达式是C++引入的一种匿名函数。存储Lambda表达式的变量被编译为临时类的对象。 该临时类变量的对象被构造时,此时Lambda表达式被计算。 若未定义存储该临时类对象的变量,则称该Lambda表达式没被计算。
Lambda表达式的声明格式为[捕获列表](形参列表)mutable 异常说明->返回类型{函数体}
,如auto f = [ ](int x=1)->int { return x; };
捕获列表的参数用于捕获Lambda表达式的外部变量。
关于捕获列表的参数
- Lambda表达式的外部变量不能是全局变量或static定义的变量也不能是类的成员。 可以是函数参数或函数定义的局部自动变量。
- 出现
&变量名
表示引用捕获外部变量,[&]
表示引用捕获所有函数参数或函数定义的局部自动变量。其可写特性同被捕获的外部变量一致。 - 出现
=变量名
表示值捕获外部变量的值(值参传递),[=]
表示捕获所有函数参数或函数定义的局部自动变量的值。 - 参数表后有
mutable
表示在Lambda表达式中可以修改“值参传递的可写变量的值”,但调用后不影响Lambda表达式捕获的对应该可写外部变量的值。
第十三章 模板与内存回收
变量模板及其实例
C++提供了3种类型的模板,即变量模板、函数模板和类模板。 变量模板使用类型形参定义变量的类型,可根据类型实参生成变量模板的实例变量。 生成实例变量的途径有三种:一种是从变量模板隐式地或显式地生成模板实例变量;另一种是通过函数模板和类模板生成。 在定义变量模板时,类型形参的名称可以使用关键字class
或者typename
定义,即可以使用template<class T>
或者template<typename T>
。 生成模板实例变量时,将使用实际类型名、类名或类模板实例代替T。如
1 | template<typename T> |
变量模板不能再函数内部声明,变量模板生成的模板实例也必须为全局或模块静态变量。模板的参数列表除了可以使用类型形参外,还可以使用非类型的形参,如
1 | template<class T, int x=3> //定义变量模板girth,其类型形参为T |
其中非类型的形参可以给出默认值。
函数模板
函数模板是使用类型形参定义的函数框架,可根据类型实参生成函数模板的模板实例函数。 函数模板不能在非成员函数的内部声明。 根据函数模板生成的模板实例函数也和函数模板的作用域相同。 在函数模板时,可以使用类型形参和非类型形参。 实例化时非类型形参需要传递常量作为实参。 可以单独定义类的函数成员为函数模板。
在调用函数时可隐式自动生成模板实例函数(根据实参推断)。 也可使用template 返回类型 函数名<类型实参>(形参列表)
,显式强制函数模板按类型实参显式生成模板实例函数。 有时候生成的模板实例函数不能满足要求,可定义特化的模板实例函数隐藏自动生成的模板实例函数(直接理解为重载算了)。 在特化定义模板的实例函数时,一定要给出特化函数的完整定义。
1 | template <class T> //class可用typename代替 |
类模板
类模板也称为类属类或参数化的类,用于为相似的类定义一种通用模式。 编译程序根据类型实参生成相应的类模板实例类,也可称为模板类或类模板实例。 类模板既可包含类型参数,也可包括非类型参数。 类型参数可以包含1个以上乃至任意个类型形参。 非类型形参在实例化是必须使用常量做为实参。使用方法如下:
1 |
|
如果函数是在类体里实现的,实现时不用加template<T>
可以用类模板定义基类和派生类。 在实例化派生类时,如果基类是用类模板定义的,也会同时实例化基类。这里的实例化指模板类实例化,生成实例类 派生类函数在调用基类的函数时,最好使用基类<类型参数>::
限定基类函数成员的名称,以帮助编译程序识别函数成员所属的基类。 对于实例化的模板类,如果其名字太长,可以使用typedef
重新命名定义。
当一个类模板有多个类型形参时,在类中只要使用同样的类型形参顺序,都不会影响他们是同一个类型形参模式。
类模板同样可以定义特化的类,但是和函数的形式有所不同,假设定义一个VECTOR的特化的字符指针的向量类,格式如下
1 | template < > //定义特化的字符指针向量类 |
实验材料、源码及实验报告
- Title: C++面向对象编程
- Author: Yizumi Konata
- Created at : 2020-12-21 14:36:02
- Updated at : 2024-06-06 23:04:55
- Link: https://zz12138zz.github.io/2020/12/21/C++/
- License: This work is licensed under CC BY-NC-SA 4.0.