C++面向对象编程进阶II
C++面向对象编程进阶II
参考南软2024秋C++高级程序设计课件CPP-2-3
多态
多态是面向对象编程语言的重要特性之一
- 同一论域中的一个元素可以有多种解释
- 提高语言灵活性
- C++中的体现有:
- 一名多用(函数重载)
- 模板编程(template)
- 虚函数
操作符重载
C++重载类型
- 函数重载
- 函数同名但参数列表不同(后者必要条件)
- 静态绑定
- 操作符重载
- 重载的动机?
- 为了自定义数据类型可以像内置(built_in)数据类型一样运算
- 让编译器代替程序员展开计算(从运算符展到函数)
- 提高可读性
- 提高可扩展性
- 重载的动机?
函数重载的细节
可以总结为通过规范匹配顺序来尽可能避免歧义的问题
精确匹配
当调用一个有多个重载版本的函数时,编译器会首先尝试进行精确匹配,即参数类型完全一致的函数签名,这种情况下的匹配最直接最合适。
void func(int); |
更好匹配
当编译器找不到参数类型完全一致的函数签名时,即不存在精确匹配,编译器会选择更好匹配的重载,这种情况下通常需要发生隐式类型转换。
- 不进行不必要的转换:即若存在精确匹配则不会尝试进行类型转换匹配
void func(float); |
窄化转换
如果有多个候选函数的匹配都需要某种类型转换,编译器会优先选择不需要窄化转换的函数。窄化转换是指从一个范围更大的类型转换为范围更小的类型,这通常会丢失数据。
void func(double); |
标准转换与用户定义转换
- 标准转换:内建类型的转换比如
int->double, int->long
- 用户定义转换:例如通过
explicit
转换构造函数或类型转换运算符进行的转换
编译器会优先选择标准类型转换,而不一定会选择需要通过用户定义的转换来进行的转换,因为用户定义的转换比内建类型的转换优先级较低。
歧义
若没有精确匹配且两个或更多的重载函数之间存在匹配,则编译器无法决定用哪个,导致歧义,引发编译错误;这种情况往往是没有精确匹配而需要类型转换,而有多个类型转换的版本都可以匹配
void func(double a) {} |
操作符重载
语法
<returnType> operator操作符(<argvList>){<definition>}
class Complex { |
重载运算符的参数数量由运算符是几元的决定
不可重载的操作符
.
, .*
, ::
, ?:
重载原则
两种方式:
- 类的成员函数
- 或者带有类参数的全局函数
重载后的运算符仍然遵循原有的语法,即:
- 单目/双目
- 优先级
- 结合性
全局函数重载运算符
实现方式:对应需要重载运算符的类中为重载运算符函数分配友元,之后再在外部全局域给出实现
需注意全局函数重载时没有隐藏参数this
了,因此要把参数声明全
class MyClass { |
注:=
, ()
, []
, ->
不能作为全局函数重载
1. 不能全局重载**=
运算符(赋值运算符)**
赋值运算符 (operator=
) 不能作为全局函数重载的原因是,赋值运算符的操作通常是为了对类的成员进行修改,因此赋值运算符的定义必须依赖于类的内部机制。
- 在类内部重载赋值运算符时,通常会涉及到类的成员(例如拷贝或移动构造、资源管理等),并且需要对
this
指针所指向的当前对象进行修改。 - 因为赋值操作的左右两边总是类对象,所以只能作为成员函数来实现。
2. 不能全局重载**()
运算符(函数调用运算符)**
operator()
被称为函数调用运算符,通常用于模拟对象的“可调用性”。这意味着一个类可以通过定义这个运算符,使得其对象能够像函数一样被调用。
operator()
的实现依赖于对象的状态,且通常是为了给类的对象提供类似函数的调用语法。- 这个运算符的调用通常依赖于类的成员,因此它也必须是类的成员函数。
3. 不能全局重载**[]
运算符(下标运算符)**
下标运算符 (operator[]
) 用于数组访问,通常需要对对象的特定成员进行操作。在 C++ 中,operator[]
必须是成员函数,因为它操作的是类对象的数据(比如访问某个特定的元素)。
operator[]
必须访问类对象的内部状态(例如数组或容器),因此它通常是类的成员函数。- 虽然可以定义一个全局的
operator[]
,但是这不符合常见的用法和设计模式,且无法直接访问类内部数据。
4. 不能全局重载**->
运算符(成员指针访问运算符)**
operator->
用于指针或指向成员对象的访问。其主要作用是允许对象表现得像指针一样,便于通过 ->
来访问对象的成员。与其他运算符一样,operator->
操作的是对象的成员,因此只能在类内定义。
operator->
让对象模拟指针的行为,通常用于类封装的智能指针或容器类中。- 它需要访问类的内部数据,且通常是指针类型的成员,因此必须在类的内部定义。
操作符重载注意点
- 二元运算符注意是否满足交换等价:当运算符两边的操作数类型不一致(比如内建类型和自定义类型),注意实现两个版本的操作符重载以满足不同的操作数顺序
示例:
class CL { |
- 永远不要重载逻辑运算符
&&
和||
:否则你会失去短路求值的特性
因为操作符重载本质上是函数调用,那么在传参的时候就会把两个操作数的结果都算出来才能传进去,那么就没有短路求值的特性了。
单目操作符重载
- 类成员函数:隐含
this
参数因此不需要显式声明参数
<returnType> operator单目操作符(); |
- 全局函数重载:需要显式声明一个操作数
<returnType> operator单目操作符(<arg>); |
自增运算符重载
a++
vs ++a
编译器是如何区分前缀运算和后缀运算的呢?
虚拟参数(dummy argument)
指定义的时候只写类型而不写形参名,告诉编译器这是一个虚拟参数只用来占位(和其它函数区分)而不作实际使用
示例,前缀++和后缀++的实现:
class Counter { |
特殊操作符的重载
=
默认的赋值操作符重载函数:
- 逐个成员赋值
- 对于对象成员,该赋值行为是递归进行的(即调用对应对象的赋值操作)
赋值操作符的重载不能继承
- 赋值操作的重载通常涉及深拷贝或资源管理,因此其实现必须严格控制源对象和目标对象的状态;而继承意味着派生类可能会有额外的资源,继承的重载赋值操作无法处理这些资源,因此需要显式定义
- 如果基类没有显式重载赋值操作符,那么派生类会生成一个默认的赋值操作符,但默认的赋值操作往往无法正确处理指针资源(因为是浅拷贝)
派生类正确重载赋值操作符:
- 调用基类赋值操作符
- 处理派生类新增成员的赋值和资源管理
class A { |
注:处理”赋值“类操作的时候一定要注意特判自赋值**情况!!!
[]
[]
(下标运算符)的重载需要实现两个版本
示例:
class string { |
若某个调用产生了两个候选函数,而一个有const
一个没有,若调用处不修改对象,则优先选择带有const
的函数,因为const
保证不修改,选const
函数使得更多代码可以在const
上下文中使用,避免非const
函数隐式修改。
注意到char& operator[](int i)
和const char operator[](int i) const
的参数列表好像是一样的只有返回值不一样,应该会重载失败;但实际上两者的参数列表是不一样的,因为后者在函数定义前加上了const
修饰,而类成员函数有一个隐藏参数this
,加上了const
后该隐藏参数会变为const string* this
,而前者的隐藏参数仍是string* this
, 因此两者的参数列表实际上是不一样的,可以重载成功。
多维数组
class Array2D { |
// 若再声明一个Array1D类型的类 |
()
作为优先级的()
是不能重载的,因为重载不改变优先级;可以重载作为函数调用或类型转换的()
函数调用
示例:
class Func { |
类型转换
基本数据类型和自定义类都可以
示例:
class Rational { |
重载类型转换运算符()
之后,在涉及需要类型转换的地方将自动按照对应的存在规则进行转换。从而可以减少混合计算中需要定义的操作符重载函数的数量。
->
智能指针的实现必须重载该运算符;->
是二元运算符,但是重载时按一元操作符存在描述,这是因为编译器并不关心->
后面的东西是什么,只关心应该用->
访问谁,即->
前面的操作数。
class A { |
即将所有SmartPtr ->
都翻译成 T* ->
局限性:必须符合编译器控制的生命周期,即需要小心管理指针资源
RAII
在初始化时分配资源,即构造函数中获取资源,析构函数中释放资源,避免裸资源(只有类自己持有该资源的引用/指针)泄漏。这样可以利用编译器控制的生命周期(new
自动调用构造函数,delete
自动调用析构函数)来管理资源,避免手动时的遗漏
new, delete
什么时候需要重载new和delete
- 频繁调用系统的存储管理,影响效率(e.g. 线程池)
- 程序自身管理内存,提高效率
重载方法
- 调用系统存储分配(
malloc
), 申请一块较大的内存 - 针对该内存,自己管理存储分配、去配
- 通过重载
new
和delete
来实现 - 重载的
new
和delete
是静态成员 - 重载的
new
和delete
遵循类访问控制权限,可继承
*注:*全局的new
和delete
不能重载,但为特定类定义的new
与delete
会在new
/delete
对应类的时候自动触发转换
重载new
void* operator new(size_t size, ...)
- 返回类型:
void*
- 第一个参数:
size_t
,系统自动计算对象的大小,并传值给size - 其他参数:可有可无
A* p = new(...) A
,...
表示传给new的其他参数;有一个隐藏参数sizeof(A)
,即A* a = new A
实际上是A* a = new(sizeof(A)) A
- 返回类型:
- new的重载可以有多个
- 如果重载了new,那么通过new动态创建的类实例将不会再调用内置的默认new
一种new的用法:定位new(placement new)
定位new
允许在已分配的内存上创建对象,用法:
new(place) Type
,place是已分配内存的地址(指针)
示例:
void* memory = malloc(sizeof(A)); |
为什么要显式调用析构函数而不是使用delete?
因为delete
会尝试归还内存,而这个对象仅是在已申请的内存上创建的,我们可能只是想销毁对象而不是归还内存,以此避免使用delete
而是显式调用析构函数。最后用free
释放内存。
重载delete
void operator delete(void* p, size_t size)
- 返回类型
void
- 第一个参数:
void*
, 表示被销毁对象的地址 - 第二个参数:可有可无,若有则必须是
size_t
类型,表示被撤销对象的大小
- 返回类型
delete
的重载只能有一个- 如果重载了
delete
,那么通过delete
销毁对象时将不再调用内置的默认delete
同样也有定位 delete
模板
为了让模板在其他模块也能使用,C++一般把模块的完整定义写在头文件里(因为若模块B想要使用模块A的模板的一个实例,而模块A并未使用该实例,则模块B无法使用,编译错误)
模板(泛型)会进行类型检查,比宏安全
异常捕获
try
, throw
, catch
throw
throw <expr>
throw
是把结果返回到上层调用者,所以一般需要进行值拷贝
catch
catch
完还在当前层,所以如果catch一个类(异常抛出一个类),一般写类引用,即catch(Obj&)
需要注意的是,throw
的结果会一直向上层传,直到某一级被catch
捕获,或者直到main
都没有被捕获则程序abort
因此若catch一个类(比如写了很多种不同的异常分别对应一个派生类,继承自异常基类),一般把基类写在最后,防止所有异常都被基类捕获了。
智能指针
智能指针多套一层可以很方便地管理资源(内存),因为可以在析构函数中主动释放管理的指针(内存),不必再在外部手动释放,符合RAII的思想。
特殊"override"的技巧
全局函数或部分非虚成员函数没法override,可以写另一个“虚化接口”过渡,传入一个多态类,在该接口中调用多态成员函数(虚函数),从而实现多态。示例:
class B { |
更多例子:全局重定向运算符(<<
,>>
)的重写
C++多态的坑
数组多态容易出问题,主要是地址计算导致的。因为数组的访问往往通过下标运算符实现,即start_addr + i * sizeof(Obj)
,但是派生类和基类的sizeof
结果往往是不一样的,但是C++又是先编译再运行的语言且编译完成后不保留类型信息(不似python
这种解释型语言在运行过程中获取具体类型信息),所以sizeof(Obj)
在编译时就确定了,后续涉及多态的地方只要派生类和基类的大小不一样就会出问题,数组访问到错误的地址。因此不要用数组存多态实例,而要使用支持多态的容器,比如vector
。
constexpr
constexpr
是C++11引入的关键字,随着C++14、C++17和C++20的发布该特性进一步增强。具体来说:
constexpr
告诉编译器这是一个常量,其值可以在编译时确定(类似编译器优化中的常量传播?);例如可以配合enum使用、提高可读性的同时提升性能(提升性能不太清楚原因,我觉得constexpr
关键字要求编译器做到的东西开-O2/-O3优化都能做到)
<function>库
std::function<returnType(argsType...)> f(functPtr | lambda);
可以声明一个函数,其功能由传入的funcptr
或lambda
表达式决定
|
lambda表达式
示例:
[](int a, int b) { |
vector<int> arr = {5, 8, 1, 6, 7}; |
NULL or nullptr
考虑两个重载函数:
void f(int); |
如上的调用形式如果不加约定就会引发歧义,而C
语言是通过宏定义的NULL,NULL就是整数0(C语言不存在重载也就没有歧义),但C++亟需解决这个问题,因此C++引入了新的空指针nullptr
,并规定nullptr
是char *
类型的,即:
f(0); // => f(int) |
因此C++中使用空指针的地方尽量用nullptr
而不是NULL
(•‿•)