C++面向对象编程进阶II

参考南软2024秋C++高级程序设计课件CPP-2-3

多态

多态是面向对象编程语言的重要特性之一

  • 同一论域中的一个元素可以有多种解释
  • 提高语言灵活性
  • C++中的体现有:
    • 一名多用(函数重载)
    • 模板编程(template)
    • 虚函数

操作符重载

C++重载类型

  • 函数重载
    • 函数同名但参数列表不同(后者必要条件)
    • 静态绑定
  • 操作符重载
    • 重载的动机?
      • 为了自定义数据类型可以像内置(built_in)数据类型一样运算
      • 让编译器代替程序员展开计算(从运算符展到函数)
    • 提高可读性
    • 提高可扩展性

函数重载的细节

可以总结为通过规范匹配顺序来尽可能避免歧义的问题

精确匹配

当调用一个有多个重载版本的函数时,编译器会首先尝试进行精确匹配,即参数类型完全一致的函数签名,这种情况下的匹配最直接最合适。

void func(int);
void func(double);

func(42); // func(int)为精确匹配的最合适结果,42直接匹配int

更好匹配

当编译器找不到参数类型完全一致的函数签名时,即不存在精确匹配,编译器会选择更好匹配的重载,这种情况下通常需要发生隐式类型转换。

  • 不进行不必要的转换:即若存在精确匹配则不会尝试进行类型转换匹配
void func(float);
void func(int);

func(3); // 直接选择func(int);因为没有必要做浮点数转换

窄化转换

如果有多个候选函数的匹配都需要某种类型转换,编译器会优先选择不需要窄化转换的函数。窄化转换是指从一个范围更大的类型转换为范围更小的类型,这通常会丢失数据。

void func(double);
void func(int);

func(3.14f); // 传入float类型,选择到double的扩展匹配func(double)
// 因为float到double是扩展类型转换,而到int是窄化转换,可能会丢失精度

标准转换与用户定义转换

  • 标准转换:内建类型的转换比如int->double, int->long
  • 用户定义转换:例如通过explicit转换构造函数或类型转换运算符进行的转换

编译器会优先选择标准类型转换,而不一定会选择需要通过用户定义的转换来进行的转换,因为用户定义的转换比内建类型的转换优先级较低

歧义

若没有精确匹配且两个或更多的重载函数之间存在匹配,则编译器无法决定用哪个,导致歧义,引发编译错误;这种情况往往是没有精确匹配而需要类型转换,而有多个类型转换的版本都可以匹配

void func(double a) {}
void func(float a) {}

int main() {
// func(3); // 编译错误,因为没有int版本的精确匹配,而两个类型转换的版本都可以匹配
}

操作符重载

语法

<returnType> operator操作符(<argvList>){<definition>}

class Complex {
// 复数类
double real, imag;
public:
Complex(): real(0), imag(0) {}
Complex(double r, double i): real(r), imag(i) {}
Complex add(Complex& x); // 用函数而不是操作符重载
Complex operator+(Complex& x) {
// 操作符重载,虽然本质上也是定义了一个函数
// +是二元运算符而这个函数事实上也有两个参数,还有一个隐藏参数是当前对象this
Complex tmp;
tmp.real = real + x.real;
tmp.imag = imag + x.imag;
return tmp;
}
};

int main() {
Complex x(1, 2), y(3, 4);
Complex z;
z = x.add(y);
z = x + y; // 两种写法结果一样但显然这种用'+'的可读性更好
// z = x + y; <=> z = x.operator+(y);
}

重载运算符的参数数量由运算符是几元的决定

不可重载的操作符

., .*, ::, ?:

重载原则

两种方式:

  • 类的成员函数
  • 或者带有类参数的全局函数

重载后的运算符仍然遵循原有的语法,即:

  • 单目/双目
  • 优先级
  • 结合性

全局函数重载运算符

实现方式:对应需要重载运算符的类中为重载运算符函数分配友元,之后再在外部全局域给出实现

需注意全局函数重载时没有隐藏参数this了,因此要把参数声明全

class MyClass {
friend MyClass operator操作符(<argvList>);
};

MyClass operator操作符(<argvList>) {
// ...
}

注:=, (), [], ->不能作为全局函数重载

1. 不能全局重载**= 运算符(赋值运算符)**

赋值运算符 (operator=) 不能作为全局函数重载的原因是,赋值运算符的操作通常是为了对类的成员进行修改,因此赋值运算符的定义必须依赖于类的内部机制。

  • 在类内部重载赋值运算符时,通常会涉及到类的成员(例如拷贝或移动构造、资源管理等),并且需要对 this 指针所指向的当前对象进行修改。
  • 因为赋值操作的左右两边总是类对象,所以只能作为成员函数来实现。

2. 不能全局重载**() 运算符(函数调用运算符)**

operator() 被称为函数调用运算符,通常用于模拟对象的“可调用性”。这意味着一个类可以通过定义这个运算符,使得其对象能够像函数一样被调用。

  • operator() 的实现依赖于对象的状态,且通常是为了给类的对象提供类似函数的调用语法。
  • 这个运算符的调用通常依赖于类的成员,因此它也必须是类的成员函数。

3. 不能全局重载**[] 运算符(下标运算符)**

下标运算符 (operator[]) 用于数组访问,通常需要对对象的特定成员进行操作。在 C++ 中,operator[] 必须是成员函数,因为它操作的是类对象的数据(比如访问某个特定的元素)。

  • operator[] 必须访问类对象的内部状态(例如数组或容器),因此它通常是类的成员函数。
  • 虽然可以定义一个全局的 operator[],但是这不符合常见的用法和设计模式,且无法直接访问类内部数据。

4. 不能全局重载**-> 运算符(成员指针访问运算符)**

operator-> 用于指针或指向成员对象的访问。其主要作用是允许对象表现得像指针一样,便于通过 -> 来访问对象的成员。与其他运算符一样,operator-> 操作的是对象的成员,因此只能在类内定义。

  • operator-> 让对象模拟指针的行为,通常用于类封装的智能指针或容器类中。
  • 它需要访问类的内部数据,且通常是指针类型的成员,因此必须在类的内部定义。

操作符重载注意点

  1. 二元运算符注意是否满足交换等价:当运算符两边的操作数类型不一致(比如内建类型和自定义类型),注意实现两个版本的操作符重载以满足不同的操作数顺序

示例:

class CL {
int count;
public:
friend CL operator+(int i, CL& a);
friend CL operator+(CL& a, int i);
};

int main() {
CL cl;
cl + 10;
10 + cl; // 分别调用两个版本的+重载
}
  1. 永远不要重载逻辑运算符&&||:否则你会失去短路求值的特性

因为操作符重载本质上是函数调用,那么在传参的时候就会把两个操作数的结果都算出来才能传进去,那么就没有短路求值的特性了。

单目操作符重载

  • 类成员函数:隐含this参数因此不需要显式声明参数
<returnType> operator单目操作符();
  • 全局函数重载:需要显式声明一个操作数
<returnType> operator单目操作符(<arg>);

自增运算符重载

a++ vs ++a

编译器是如何区分前缀运算和后缀运算的呢?

虚拟参数(dummy argument)

指定义的时候只写类型而不写形参名,告诉编译器这是一个虚拟参数只用来占位(和其它函数区分)而不作实际使用

示例,前缀++和后缀++的实现:

class Counter {
int value;
public:
Counter() {value = 0;}
Counter& operator++() {
// ++a, 前缀++
value++;
return *this;
}
Counter operator++(int) { // dummy argument
// a++, 后缀++
Counter tmp = *this;
value++;
return tmp;
}
};

特殊操作符的重载

=

默认的赋值操作符重载函数:

  • 逐个成员赋值
  • 对于对象成员,该赋值行为是递归进行的(即调用对应对象的赋值操作)

赋值操作符的重载不能继承

  • 赋值操作的重载通常涉及深拷贝或资源管理,因此其实现必须严格控制源对象和目标对象的状态;而继承意味着派生类可能会有额外的资源继承的重载赋值操作无法处理这些资源,因此需要显式定义
  • 如果基类没有显式重载赋值操作符,那么派生类会生成一个默认的赋值操作符,但默认的赋值操作往往无法正确处理指针资源(因为是浅拷贝

派生类正确重载赋值操作符:

  • 调用基类赋值操作符
  • 处理派生类新增成员的赋值和资源管理
class A {
public:
int x;
A(int i): x(1) {}
A& operator=(const A& other) {
if (this != &other) {
// 避免自赋值
x = other.x;
}
return *this;
}
};

class B: public A {
public:
int* data;
B(int x, int value): A(x), data(new int(value)) {}
B& operator=(const B& other) {
if (this != &other) {
A::operator=(other);
// 随便处理一下data代表资源管理
*data = *(other.data);
}
return *this;
}
~B() {
delete data;
}
};

int main() {
B b1(2, 3), b2(3, 4);
b2 = b1;
delete b2.data; // 自定义赋值重载实现深拷贝,delete后不会出现悬挂指针
b2.data = nullptr;
std::cout << *b1.data << endl; // 行为正常
// 若在派生类B中没有显式重载赋值操作,那么默认的赋值会执行资源浅拷贝,b2的data指针和b1的data指针指向同一内存
// 这将导致delete后出现悬挂指针
}

注:处理”赋值“类操作的时候一定要注意特判自赋值**情况!!!

[]

[](下标运算符)的重载需要实现两个版本

示例:

class string {
char* p;
public:
string(char* p1) {
p = new char[strlen(p1) + 1];
strcpy(p, p1);
}
// 版本1
char& operator[](int i) {
return p[i];
}

// 版本2
const char operator[](int i) const {
return p[i];
}

virtual ~string() {delete[] p;}
};

int main() {
string s("abcd");
s[2] = 'b'; // 调用版本1
std::cout << s[2]; // 调用版本2
const string cs("const");
std::cout << cs[0]; // 调用版本2
}

若某个调用产生了两个候选函数,而一个有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 {
int n1, n2;
int *p;
public:
Array2D(int l, int c): n1(l), n2(c) {
p = new int[n1 * n2];
}
virtual ~Array2D() {delete[] p;}
int* operator[](int i) {
return p + i * n2;
}
};

// 对于上述代码,可以通过像正常访问二维数组一样访问Array2D类型的变量
int main() {
Array2D data(4, 5);
std::cout << data[2][3]; // data[2]返回一个int*的指针,可以再通过[3]下标运算访问指针指向的空间(即数组的元素)
}
// 若再声明一个Array1D类型的类
class Array1D() {
int *q;
public:
Array1D(int *p) {q = p;}
int& operator[](int j) {
return q[j];
}
};

class Array2D {
// ...
// 将int* p换成如下内容
Array1D* data;
// ...
public:
Array1D& operator[](int j) {
return data[j];
}
};

// 则这样写之后除了可以通过data[i][j]访问Array2D的元素,还可以通过如下方式
int main() {
Array2D data(4, 5);
std::cout << data.operator[](2).operator[](3); // 若没有声明Array1D是不能这样写的,因为data.operator[](2)将返回一个基本类型指针而非对象
// 而指针是不能通过.运算符访问成员的
}

()

作为优先级的()是不能重载的,因为重载不改变优先级;可以重载作为函数调用类型转换()

函数调用

示例:

class Func {
double para;
int lower, upper;
public:
double operator()(double, int, int); // 重载函数调用运算符,给该类声明一个函数调用行为
// ...
};

int main() {
Func f;
f(2.4, 1, 2);
}

类型转换

基本数据类型和自定义类都可以

示例:

class Rational {
private:
int n, d;
public:
Rational(int n1, int n2): n(n1), d(n2) {}
operator double() {return (double)n / d;} // ()前加上double和之前的函数调用重载进行区分
};

int main() {
Rational r(1, 2);
double x = r; // 自动触发类型转换(因为要从Rational->double), 返回1 / 2 = 0.5
x = x + r; // 再次触发类型转换Rational->double,返回1 / 2 = 0.5
}

重载类型转换运算符()之后,在涉及需要类型转换的地方将自动按照对应的存在规则进行转换。从而可以减少混合计算中需要定义的操作符重载函数的数量。

->

智能指针的实现必须重载该运算符;->是二元运算符,但是重载时按一元操作符存在描述,这是因为编译器并不关心->后面的东西是什么,只关心应该用->访问谁,即->前面的操作数。

class A {
public:
void f();
};

template <typename T>
class SmartPtr {
private:
T* ptr;
public:
SmartPtr(T* p): ptr(p) {}
T* operator->() {
return ptr;
}
};

int main() {
A* a= new A;
SmartPtr* p = new SmartPtr(a);
p->f(); // 实际上调用的是A::f
}

即将所有SmartPtr -> 都翻译成 T* ->

局限性:必须符合编译器控制的生命周期,即需要小心管理指针资源

RAII

在初始化时分配资源,即构造函数中获取资源,析构函数中释放资源,避免裸资源(只有类自己持有该资源的引用/指针)泄漏。这样可以利用编译器控制的生命周期(new自动调用构造函数,delete自动调用析构函数)来管理资源,避免手动时的遗漏

new, delete

什么时候需要重载new和delete

  • 频繁调用系统的存储管理,影响效率(e.g. 线程池)
  • 程序自身管理内存,提高效率

重载方法

  • 调用系统存储分配(malloc), 申请一块较大的内存
  • 针对该内存,自己管理存储分配、去配
  • 通过重载newdelete来实现
  • 重载的newdelete是静态成员
  • 重载的newdelete遵循类访问控制权限,可继承

*注:*全局的newdelete不能重载,但为特定类定义的newdelete会在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));
A* a = new(memory) A(); // 调用A的默认构造函数
// ...
~a(); // 请显式调用析构函数而不是使用delete
// ...
free(memory);

为什么要显式调用析构函数而不是使用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 {
public:
virtual void f();
};

class D: public B {
public:
void f();
};

// 全局函数不能override,但是可以利用一个“虚化接口过渡
void func(B* pb);
void f(B* pb); // 虚化接口

void func(B* pb) {
// ...
f(pb);
// ...
}

void f(B* pb) {
// ...
pb->f(); // 调用多态成员函数
// ...
}

更多例子:全局重定向运算符(<<>>)的重写

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);可以声明一个函数,其功能由传入的funcptrlambda表达式决定

#include <function>

int main() {
std::function<bool(int, int)> add([](int a, int b) {
return a + b;
});
int c = add(2, 3); // c = 5
}

lambda表达式

示例:

[](int a, int b) {
return a + b;
}
[&](int a, int b) { // &用于按引用捕获当前作用域的所有变量
return a - b;
}
vector<int> arr = {5, 8, 1, 6, 7};
sort(arr.begin(), arr.end(), [](const auto a, const auto b) {
return a < b; // 按升序排列数组arr
});

NULL or nullptr

考虑两个重载函数:

void f(int);
void f(char*);

f(0); // 该调用哪个?有歧义

如上的调用形式如果不加约定就会引发歧义,而C语言是通过定义的NULL,NULL就是整数0(C语言不存在重载也就没有歧义),但C++亟需解决这个问题,因此C++引入了新的空指针nullptr,并规定nullptrchar *类型的,即:

f(0); // => f(int)
f(nullptr); // => f(char*)
// 而NULL还是0
f(NULL); // => f(int)

因此C++中使用空指针的地方尽量用nullptr而不是NULL

(•‿•)