OOP
引入 OOP
传统的面向过程编程,数据经常是无监管的,分散在代码中。它们可以随意被多个函数访问、使用、修改。这通常是不安全的。最初的
例如,对于一个栈,我们只希望使用栈的相关操作,而不希望能够直接访问或修改栈中内容实际对应的内存。我们可以看下面的栈的例子,OOP 版本的栈提供了这一保护机制:
#include<iostream.h>
#define STACK_SIZE 100
struct Stack{
int top;
int buffer[STACK_SIZE];
}
//implements for push and pop
//...
int main(){
Stack st1, st2;
st1.top = -1;
st2.top = -1;
int x;
push(&st1, 12);
pop(&st1, x);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<iostream.h>
#define STACK_SIZE 100
class Stack{
private:
int top;
int buffer[STACK_SIZE];
public:
Stack(){top = -1;}
bool push(int i);
bool pop(int &i);
}
//implements for push and pop
//...
int main(){
Stack st1, st2;
st1.push(12);
int x;
st2.pop(x);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
C++
中的 class
和 struct
在 C++
中,class
和 struct
基本相同。区别在于 class
内成员变量的默认访问权限是 private
,而 struct
是 public
。
早期 C++
提供的面向对象机制,大体上属于逻辑的范畴,可以理解为一种代码检查的机制。它通过 Cfront
直接编译为 C
代码,因此面向对象的引入不会增大运行时开销。
我们知道,对象是数据和操作的集合,对象之间通过函数调用传递信息,而类提供对象的抽象。如果仅有这些特性,可以被称为基于对象的语言(Object-Based),这也是早期 Ada
等语言采取的范式。然而现代的面向对象的语言(Object-Oriented),通常还支持继承的机制。
类
我们刚刚提到,类是对象的抽象,而对象是数据和操作的集合。因此,类中有相对应的概念:成员变量和成员函数。如下面的示例:
//Tdate.h
class Tdate{
public:
void SetDate(int y, int m, int d); // 成员函数
int isLeapYear(); // 成员函数
private:
int year, month, day; // 成员变量
}
//Tdate.cpp
void TDate::SetDate(int y, int m, int d){
year = y;
month = m;
day = d;
}
int TDate::isLeapYear(){return (year%4 == 0 && year % 100 != 0) || (year % 100 == 0);}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
同普通的函数一样,成员函数也需要先定义后使用。可以直接在类中声明时定义,也可以在类外定义。在类外定义时应当在函数声明前加上类名和作用域解析符 ::
。
可以将类的定义和成员函数的实现分别放在头文件和 cpp
文件中,如上文所示。但如果成员函数的实现较为简单,则可以一并放入头文件,编译器会自动进行内联操作。
头文件 or 模块 (C++ 20)?
头文件是从 C
流传下来的库声明文件,它设计的初衷是结构清晰地将声明和实现分离。但它的设计存在一些缺陷,如重复包含问题、引入顺序和大型项目效率低下的问题。
而 C++20
引入了模块机制,这是一种更现代化的代码组织方式。与传统的头文件不同,模块直接支持在语言层面进行依赖管理,以提高编译速度并减少复杂性。例如:
// math_utils.ixx
export module math_utils; // 定义模块并导出
export int add(int a, int b) { // 直接实现和导出函数
return a + b;
}
2
3
4
5
import math_utils; // 导入模块
#include <iostream>
int main() {
std::cout << add(3, 4) << std::endl;
return 0;
}
2
3
4
5
6
7
使用 export
和 import
来进行模块的导入和导出。
在声明成员变量时可以指定默认初始化的值。C++
提供了两种方式:
class A{
int a = 1;
int b{2};
}
2
3
4
推荐在每次声明成员变量时指定默认初始化的值,防止出现奇奇怪怪的问题。
注意
这种默认初始化的结果会被下面介绍的成员初始化表所覆盖。也就是说,如果你声明了成员初始化表,那就相当于不会执行默认初始化。
构造函数
构造函数可以用来初始化对象,如分配内存和初始化值等。构造函数有如下特点:
- 与类同名,无返回类型。
- 自动调用,不可单独直接调用。
- 在一个对象中只调用一次。
- 可以重载。
- 默认为
public
,但可定义为private
,接管对象的创建(如单例模式)。
如果没有显式声明构造函数,那么编译器会自动补全默认构造函数。默认构造函数是无参数的。但一旦显式声明了构造函数,编译器就不再提供(当然,还可以通过在函数声明后加 =default
来显式声明默认构造函数)。
构造函数会在创建对象时,选择一个合适的使用。一般来说,创建对象的范式有两种:
ClassName
object-name
=ClassName
(Args
)ClassName
object-name
(Args
)
可以参考下面的例子:
class A{
public:
A();
A(int i);
A(char *p);
}
A a1 = A(1);//此为显式初始化, 调用 A(int i)
//也可以采用隐式初始化: 'A a1(1);' or 'A a1 = 1;'
A a2 = A(); //此为显式初始化, 调用 A()
//也可以采用隐式初始化: 'A a2;'
//注意:'A a2();' 不是初始化!这是一个函数的声明.
A a3 = A("abcd"); //此为显式初始化, 调用 A(char *p);
//也可以采用隐式初始化: 'A a3("abcd");' or 'A a3="abcd";',
A a[4]; // 调用四次 A()
A b[5] = {A(), A(1), A('abcd'), 2, "xjy"};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
强制显式初始化
可以在构造函数类中定义前加 explicit
关键字,来避免通过隐式初始化构建对象。
在函数体之外定义的类会首先执行零初始化,然后再执行默认初始化和自定义初始化,就像在函数体外定义内置类型的变量一样。
成员初始化表
成员初始化表是构造函数的补充,它可以对初始化提供更细致的操控,并提高效率。此外,对于常量和引用成员,必须在定义时使用成员初始化表进行初始化。
成员初始化表的语法为构造函数名后面添加 : Member1(Value1), Member2(Value2), ...
。成员的声明顺序是类中的定义顺序,而不是成员初始化表的顺序。
当初始化时,如果需要初始化的常量比较少,应当使用成员初始化表以提高效率。否则,应当考虑在构造函数体内赋值。
析构函数
析构函数当对象消亡时自动调用。为了遵守
- 名字为
~
加类名,无返回类型。 - 自动调用,不可单独直接调用。
- 在一个对象中只调用一次。
- 默认为
public
,但可定义为private
。
拷贝构造函数
创建对象时,用同一类的对象对其进行初始化,这就是拷贝构造。自定义拷贝构造函数的语法是:
public:
A(const A& a){
// Your own implemention...
}
2
3
4
调用拷贝构造函数的场景有:
A b = a
初始化赋值时;- 函数按值传参时;
- 函数返回一个类对象时;
可以看出,如果将对象想象成 int
类型,则拷贝构造发生在大多数初始化值传递的情形。如果没有进行自定义,编译器会自动补全一个默认拷贝函数。当拷贝构造时,如果原来对象的成员有指针,我们一般应当执行深拷贝,即拷贝指针指向的内容,而不是指针本身。否则,当原对象消亡时,新对象的指针就会指向一个不存在的内存区域。
深拷贝和浅拷贝
浅拷贝和深拷贝一般指指针的拷贝方式。浅拷贝只是简单地拷贝指针的值;而深拷贝则是将指针指向的内容一并拷贝。在面向对象的拷贝构造时,一般会再申请一块新的内存空间以实现深拷贝。
如果自定义了拷贝构造函数...
如果类中含有自定义的拷贝构造函数,那么在执行拷贝之前,会先执行新对象的默认构造函数(注意,即使有自定义的构造函数,也会执行默认的!),然后再执行自定义的拷贝构造函数。
也就是说,如果自定义了拷贝构造函数,编译器就不会自己发挥,而是按照最简单(但不一定最符合直觉)的方式执行(即初始化)。看下面的例子:
class Copy{
public:
int x;
int y;
Copy(){
x = 1;
y = 2;
}
Copy(const Copy &other){
cout<<"Copy!"<<endl;
y = other.y;
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
然后进行拷贝:
Copy x;
Copy y = x;
cout<<"y: "<<y.x<<" "<<y.y<<endl;
2
3
这时,y.x
的值是多少呢?大概率不是 1
,而是一个随机值。这是因为我们在拷贝构造函数中没有使用自定义的构造函数对 x
进行初始化。
但是,有时拷贝构造函数也会拖慢速度,例如下面的代码:
string generate(){
return string("test");
}
string S = generate();
2
3
4
这段简短的代码竟然执行了两次拷贝构造函数(假设没有移动构造函数的话)!一次用于将 generate()
临时变量拷贝到调用者栈帧中的无名变量,另一次用于将无名变量拷贝至 S
。事实上,我们只需要将临时变量中的地址传给 S
就行了(这两种方式就类似值传递和引用传递)。下面我们介绍的移动构造函数,解决了这一问题。
返回值优化
实际上,现代 C++
编译器会自动进行返回值优化
更多信息可以参考 CPP Reference 上的 Copy elision。
移动构造函数
左值是能通过名称访问的一块内存,而右值要么没有名称,要么无法被访问。非 const
的引用是一个左值。右值对象一般是没有名称的,或者是一个临时对象。因此许多情况下,右值的内存是一经赋值,不会再次被使用的。这时,我们可以直接在赋值时让左值来全盘接管右值的内存,就省去了拷贝构造的时间,这就是移动构造函数的主要思想。
移动构造函数的语法是:
public:
A(A&& rvalue){
// Your own implemention...
}
2
3
4
一般而言,移动构造函数不应该申领任何资源,所以不该抛出异常,可以加上 noexcept
关键字。
例如,我们可以看下面矩阵类 Matrix
的例子:
class Matrix {
int n{0};
int *mat{nullptr};
public:
// Some constructors and functions...
Matrix(Matrix && other) noexcept :n(other.n), mat(other.mat){
other.mat = nullptr; // 将资源完全移交给新对象
}
Matrix& operator=(Matrix && other) noexcept{
if(n!=other.n){
return *this;
}
if(this != &other){
delete [] this->mat; // 注意:必须先释放原有资源
this->mat = other.mat; // 移交资源
other.mat = nullptr; // 将资源完全移交给新对象
}
return *this;
}
// Destructor...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
这样,当希望将右值临时对象转交给左值时,通过只转交地址,我们就可以避免上文的拷贝构造函数的问题。
注意
一般来说,移动构造函数和拷贝构造函数是互斥的,当拷贝的值是左值时会调用拷贝构造函数,而当拷贝的值是右值时会调用移动构造函数。如果可能的话,编译器会优先调用移动构造函数。
有时,我们可以通过 =delete
来禁用拷贝构造函数,只保留移动构造函数(或者相反)。
此外,移动构造和拷贝构造函数函数一般还需要重载赋值运算符,以应对一般的赋值操作。
三五零原则对上述的问题做出了总结:
- 三:如果一个类需要定义拷贝构造函数、拷贝赋值运算符或析构函数,则通常需要同时定义这三个。
- 五:如果一个类需要定义移动构造函数或移动赋值运算符,则通常需要同时定义这五个(移动、拷贝、析构以及其响应的赋值重载)。
重载运算符注意释放资源
在进行有重载运算符的移动构造时,要注意提前释放被顶替的资源,防止内存泄漏。
此外,还需要检查自赋值的情况,避免资源被提前释放。
左值是不能直接赋值给右值的。但我们可以使用 std::move(lvalue)
,这将会生成一个 lvalue
对应的右值。
动态对象
动态对象存放在堆上,而前面介绍的对象存放在栈上。C++
引入 new
和 delete
来新建和释放动态对象,实现内存管理。使用它们而非 malloc
和 free
的原因是:可以正确操作构造函数和析构函数。
new
的主要任务是:先在堆中申请内存,然后选择合适的构造函数进行调用;delete
的主要任务是:先调用析构函数,然后回收堆中的内存。
提醒
你可以对一个空指针调用 delete
,但是不能对一个已经释放的指针调用 delete
。这样会导致未定义行为。
一个好的习惯是在 delete
之后将指针设为 nullptr
,这样可以避免下面提到的野指针的问题。
动态对象的创建和释放可以参考下面的例子:
A *p, *q;
p = new A; // 申请内存,调用 A 的默认构造函数或者 A::A() (如果有的话)
q = new A(1); // 申请内存,调用 A::A(int)
delete p; // 调用析构函数,释放内存
delete q; // 调用析构函数,释放内存
2
3
4
5
6
与栈上的对象不同,动态对象的创建和释放是与作用域解耦的,因此可以利用这一性质申请全局可用的内存空间,同时也要谨防内存泄露和野指针。
内存泄露和野指针
内存泄露指一块内存没有任何指向它的指针,那么它的位置信息也就丢失了。此时,如果程序不关闭,这一块内存就再也无法分配给其他对象,也就无法被使用了。因此,解决办法是在最后一个指向他的指针消失前,把内存释放。
野指针指指针指向的那块内存不可用,这很可能是因为指针没有正确初始化,或先前指向的内存区域已经被释放。前者的解决办法是严格遵循 delete
时把相应的指针全部设为 NULL
。
我们还可以创建动态对象数组。需要注意的是,释放时必须在数组指针前加 []
,这样编译器才知道要释放哪些内存和调用多少次析构函数。例如
A *p;
p = new A[100];
delete [] p; // 不能省略 '[]' !
p = nullptr; // 避免野指针
2
3
4
串联一下,我们还可以利用上面的知识来实现一个动态数组:
char **chArray2;
// allocate the rows
chArray2 = new char* [ ROWS ];
// allocate the (pointer) elements for each row
for (int row = 0; row < ROWS; row++ )
chArray2[ row ] = new char[ COLUMNS ];
// Reverse the creation algorithm to delete
for (int row = 0; row < ROWS; row++)
{
delete [ ] chArray2[ row ];
chArray2[ row ] = NULL;
}
delete [ ] chArray2;
chArray2 = nullptr;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
记得使用智能指针
C++11
引入了智能指针,它可以自动管理内存,避免内存泄露和野指针的问题。前面介绍过了 unique_ptr
,还有 shared_ptr
和 weak_ptr
,它们都是智能指针的一种。
例如,我们可以采用以下几种方式创建一个指向字符串 111
的 unique_ptr
:
auto ptr = std::make_unique<std::string>("111");
// 或者
std::unique_ptr<std::string> ptr(new std::string("111"));
2
3
const
成员
在任何可能的地方将成员设置为常量,可以进一步提高保护性。const
常量成员是依附于对象而非类的(与静态成员不同),因此它在每一个类中都有一个副本。const
常量的初始化需在构造函数的成员初始化表中进行。
而 static const
常量是在编译期中的常量,它依附于类而非对象。static const
常量的初始化只能在定义的时候立即进行。
如果一个对象被声明为 const
,那么它的所有成员变量都不可以被修改。这一对象中的成员函数,有的是可以调用的,有的不行,其区别在于是否修改了成员变量。至于不修改成员变量的成员函数,我们可以在函数参数列表的括号后加 const
,例如:
class A{
int x, y;
public:
A(int x1, int y1);
void f();
void show() const;
}
2
3
4
5
6
7
无论是否加 const
,编译器还是会阻止对 const
对象成员变量的修改。其实 void show() const;
等价于
void show(const A* const this);
相当于一个指向常量的指针常量。
可以绕过这一限制吗...?
C++
总归是一个自由的国度。可以利用引用来间接修改,看下面的例子:
class A{
int a{0};
int &indirect_a;
public:
A():indirect_a(a){}
void f() const{
indirect_a++;
}
};
int main(){
const A a;
a.f(); // 可以增加 a 的值
}
2
3
4
5
6
7
8
9
10
11
12
13
14
这是因为 indirect_a
是一个引用,我们没有修改它的值,而是修改了它指向的值,并且我们也没有直接修改成员变量 a
,所以我们可以绕过 const
限制。
静态成员
静态成员变量提供了同一个类中不同对象间共享变量的方法。如果我们直接把它定义为全局变量,那么就丧失了
我们可以在类中的成员变量的类型名前加 static
声明静态成员变量,静态成员变量必须在类外对其进行定义。例如:
// in .h
class A{
static int shared;
//...
};
// in .cpp
int A::shared = 0;
2
3
4
5
6
7
8
如果是静态常量,则必须在声明时进行定义。
静态成员函数与之类似,它不需要初始化类即可调用,相当于类中的全局函数。
类也是对象
类也可以看作是对象。因此,也可以使用它的成员变量或函数,只不过语法上有一些区别:
- 调用类中的成员,用命名空间作用域运算符
::
,如A::foo
,A::a()
。 - 调用对象中的成员,用点运算符
.
,如a.bar
,a.b()
。
友元
当我们希望在某个类中访问另一个类的私有成员变量,并且不希望破坏被访问类的私有性(如,不将那个私有成员变量声明为 public
或增加一个 public
方法来修改它),我们就可以使用友元。此外,使用友元直接访问,还可以增加效率,提高灵活性。
我们可以通过在希望被访问的类中添加访问者,并在前面增加 friend
关键字来声明友元。例如
void func();
class B;
class C{
...
void f();
};
class A{
...
friend void func(); //友元函数
friend class B; //友元类
friend void C::f(); //友元类成员函数
}
2
3
4
5
6
7
8
9
10
11
12
类的设计原则
友元可以体现出类的设计原则:尽量使功能完满但最小化。这也遵循了迪米特法则。
如何解决循环依赖
使用友元时,可能会出现下面的情况
class A{
friend void B::func(A);
};
class B{
public:
void func(A a){}
};
2
3
4
5
6
7
8
这时,我们可以重新排列它们的顺序,并利用前向声明来改写
class A;
class B{
public:
void func(A& a){}
};
class A{
friend void B::func(A&);
};
2
3
4
5
6
7
8
9
10
在 B
中,我们只能使用 A
的指针或者引用,这时因为此时还不知道 A
类的具体内容,无法确定其大小,所以无法按值传递。
继承
单继承
单继承可以让子类拥有父类的成员变量和函数。其语法为:
class Son: public Father{
...
};
2
3
继承中引入新关键字 protected
:派生类可以访问,外部其他类不可访问。而派生类无法访问父类的 private
成员。
C++ 对同名函数的处理
Overriding
:子类有与父类同名且参数相同的函数,在类继承中出现;
Overloading
:有同名但参数不同的函数,与继承无关。
C++
确定调用同名函数的过程为:先在当前类的名空间找名称,如果找到了就不再寻找上层(父类)的名空间;但如果找到了但没有参数匹配的版本,则报错。
C++
使用静态绑定来确定同名函数的调用(默认),这意味着在编译期间就可确定被调用的函数。因此,利用对象指针来调用成员函数是通过指针的类型进行确定的,而非指针实际指向的对象。比如
class Base{
void foo();
...
};
class A: public Base{
void foo();
}
Base* p = new A;
p -> foo(); //调用 Base 类的 foo
2
3
4
5
6
7
8
9
10
11
C++
默认采取静态绑定的方式(除了下面的虚函数)。这是 C++
的一个重要特性,可以借此判断许多同名函数调用问题。
除了判断同名函数调用的问题,判断 public
等访问权限也是静态的。因此,静态绑定思想应当牢记。编译器希望为你多做一些事情。
派生类进行构造时,默认先执行基类的无参构造函数,然后执行派生类成员类的构造函数(如果有的话),最后执行派生类的相应构造函数。如果要执行基类的有参构造函数,则必须在派生类构造函数的成员初始化表中使用。此外,也可以在派生类声明 using Base::Base
来继承所有 Base
基类的构造函数,这样就可以在构造派生类对象时调用基类构造函数(如果没有找到匹配的派生类构造函数的话),而非必须在成员初始化表中使用。
虚函数
前文我们介绍了静态绑定的特性,我们发现,当我们使用指针或引用调用函数时,编译器会根据指针或引用的类型来确定调用的函数。这种行为尽管十分高效,但也不甚灵活。我们当然希望在运行时根据对象的实际类型来确定调用的函数,这样可以更好地实现多态。
我们为什么要使用指针或引用?
一个问题是,我们为何不直接使用相应类型的对象呢?一方面是多态上的考虑,我们希望可以将用派生类对象替换基类对象,而不会出现问题。但是由于对象切片的存在,直接使用对象会导致派生类对象的数据被舍弃。另一方面,直接使用对象也会造成不必要的数据拷贝。
数据切片
数据切片指将派生类对象赋值到基类对象时,会舍弃掉派生类相对基类特有的数据和方法。
解决办法:使用引用或指针赋值。
因此相对地,我们可以定义动态绑定,即在运行时根据对象的实际类型来确定调用。实现动态绑定的函数就是虚函数。虚函数可以通过在函数声明前加 virtual
关键字来定义。
虚函数的特点
如果基类(或派生类)的某一函数被定义为虚函数,则它的派生类对其重定义的函数均为虚函数。这一属性可以一直传递下去。
静态、内联、构造函数不能是虚函数。事实上,构造函数也没有必要是虚函数,因为它不涉及对象的指针和引用。
析构函数可以(也应该)是虚函数。如果派生类申请了资源,则一定要定义虚析构函数。
虚函数在完成构造后才会绑定。如果在基类构造(析构)函数内直接调用当前正在构造(析构)对象的虚函数,则还是会调用基类的虚函数(派生类仍未构造完成)。关于这一点,可以在下面的例子中得到说明,同时也可以参考 During construction and destruction。
override
关键字可以明确告知编译器当前函数是覆盖基类中的一个虚函数。应当及时添加override
增加可读性,并防止潜在的拼写错误。此外,还可以使用final
关键字来防止派生类进一步重载。除了纯虚函数,虚函数可以有默认实现。如果派生类不重写,就会调用基类的默认实现。
不要重写默认参数值
重新定义继承而来的缺省参数值是无效的,编译器仍然会使用静态绑定。编译器使用虚函数表确定调用的函数,只记录调用函数的入口,而不记录调用函数的参数,因此缺省参数值无法动态绑定。
一个综合的例子
阅读下面的代码,下面的橙色高亮处的顺序应该与注释相同。为什么?
class A{
public:
A() {f(); }
virtual void f();
void g();
};
class B: public A{
public:
void f(){ g();}
void g();
};
//Construct B
B b; // 1. A::A(), A::f(), B::B()
A* ptr = new B(); // 2. A::A(), A::f(), B::B()
ptr->f(); // 3. B::f(), B::g()
//NOT: B::f(), A::g()
ptr->g(); // 4. A::g()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
解释:
- 在构造
B
时,由于继承,先调用A
的构造函数A::A()
。由于A
的构造函数中调用了f()
,这一f()
是this->f()
的缩写且是虚函数(即上文所述的在构造函数内直接调用当前正在构造的对象的虚函数),满足上文所说的虚函数在完成构造后才会绑定这一原则,因此应当调用A::f()
。之后调用B
的构造函数B::B()
。 - 跟 1 相同,同样是构造
B
,然后只是把地址存在A*
类型的指针中,所以不影响构造的流程。 - 由于
f
是虚函数,所以在运行时根据ptr
的实际类型来确定调用的函数。因此,调用B::f()
。而B::f()
中调用了g()
,即this->g()
。由于g
不是虚函数,且this
指针的类型为B*
,所以根据静态绑定的原则,只看指针的定义类型,直接调用B::g()
。 - 同样,由于
g
不是虚函数,且ptr
指针的类型为A*
,所以根据静态绑定的原则,直接调用A::g()
。
纯虚函数
纯虚函数即在虚函数原型后面加上 =0
。纯虚函数不给出具体实现,子类必须提供其实现。
有纯虚函数的类叫做抽象类。它不能用于创建对象,而只能用于为派生类提供框架;派生类提供抽象基类 的所有成员函数的实现。
好的类设计
替换原则;- 不要重定义与继承而来的非虚成员函数同名的成员函数;
- 使用虚析构函数;
私有继承
私有继承指继承时使用 private
关键字。私有继承的特点是:
- 基类的公有和保护成员在派生类中变为私有成员,而私有成员在派生类中不可见;
- 需要使用基类中的 protected 成员,或者只需要重写基类的虚函数时,可以使用私有继承;
多继承
有时,一个类可能同时扮演两种角色,具有两组 is-a
关系。因此我们希望进行多继承,即由多个基类派生出派生类。多继承的语法很简单,只需在普通继承的基础上将继承类按逗号排列即可,其中继承方式可以各自指定。如
class A: public B, private C{
//...
};
2
3
多继承如果不进行仔细设计,会产生名冲突。例如,如果上面的 B
和 C
类中都有 weight
同一个成员变量,那么 A
类在继承的时候就会产生冲突。
解决办法:采用虚基类或使用名空间。
虚基类
虚基类是为了解决多继承中的二义性问题。虚基类的构造函数由最底层的派生类负责调用,而且只调用一次。下面的例子展示了虚基类的使用:
class A{
int weight;
public:
A(int w): weight(w){}
};
class B: virtual public A{
//...
};
class C: virtual public A{
//...
};
class D: public B, public C{
//...
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在这个例子中,D
类只有唯一的 weight
成员变量,而不会出现二义性。
多态
前面我们多次提过多态这一特性。简单来说,多态就是同一个域中一个元素有多种解释,即一名多用。C++
中的多态包括函数重载、模版和虚函数。下面我们还要介绍函数重载的一种:操作符重载。
操作符重载
为了提高可读性和可扩充性,我们可以使用操作符重载,通过定义相关操作符的具体操作(即操作符重载函数),让自定义类的对象可以像普通内置类型的变量一样进行运算。例如,我们可能想 cout
一个对象,或者将两个 Matrix
对象相加。这时,我们就可以自定义操作符重载。
操作符重载的基础机制很容易理解。与函数重载类似,操作符重载只需要对一些相应的函数进行重载即可。下面的例子实现了一个虚数类加法的重载:
class Complex{
double real, imag;
// ... Constructors
Complex operator + (Complex &x){
Complex temp;
temp.real = x.real + real;
temp.imag = x.imag + imag;
return temp;
}
}
//main function
Complex a(1,2), b(3,4), c;
c = a + b; // use operator overloading.
2
3
4
5
6
7
8
9
10
11
12
13
14
操作符重载的特点有:
- 大部分操作符均可重载,除了
.
、.*
、::
、:?
等。 - 重载函数是类成员函数或全局函数,若是全局函数则其参数需包含自定义的类。
[]
和()
不能作为全局函数重载。 全局函数主要的用处是作为类成员函数的补充,增加符号的完备性。例如可以使用类成员函数obj + 10
,但只能用全局函数实现10 + obj
。 - 重载函数无法改变原操作符的运算顺序。
自动构造
前文举的 10 + obj
的例子,如果 obj
所在类定义了接受参数为 int
的构造函数,则不需要全局函数重载。编译器会调用相应的构造函数构造为相应类,然后使用操作符重载函数进行重载。当然,相应的构造函数不能声明为 explicit
。
注意
不应该对 &&
和 ||
进行重载,会影响短路机制而出现问题。
下面将介绍几种常见的重载的细节。
重载输出流符号
重载输出流符号的返回值和参数较为特殊,由于需要形成链式操作,所以必须接受和返回 ostream&
。此外,重载函数必须是全局函数。
ostream& operator << (ostream& o, /*Your own object*/){
//While printing 'String' ...
o << "String" << endl;
return o; // return the modified o
}
2
3
4
5
返回类型
大部分算数运算操作符重载函数的返回值应该是按值返回。其他返回方式会出现问题:
- 返回堆上对象(按指针或引用):链式计算时产生内存泄漏;
- 返回静态对象:产生覆盖问题。
此外,返回值还有左值和右值之分。如自增操作符,a++
返回的是 a
的右值,而 ++a
返回的是 a
的左值。
class Counter{
int value;
public:
Counter(){ value = 0; }
Counter& operator ++(){//++a
value++;
return *this;
}
Counter operator ++(int){//a++
Counter temp = *this;
value++;
return temp;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
在上面的例子中,++a
返回的是 Counter
类的引用,而 a++
返回的是 Counter
类的值。后者的函数参数列表中的 int
是一个哑运算符,只为了区分前缀和后缀自增操作符。
重载赋值运算符
赋值运算符存在默认实现,默认实现为逐个成员赋值。我们也可以提供自定义的重载赋值运算符改写。
class A{
int x, y;
public:
// Constructors...
A& operator = (const A& a){
x = a.x;
y = a.y;
return *this;
}
};
2
3
4
5
6
7
8
9
10
赋值运算符类似拷贝构造函数,但返回类型为类对象的引用,通常返回 *this
。这样,我们即可进行链式赋值。
保存副本
涉及堆上内存赋值时,通常应该按照以下的步骤进行:
A& operator = (const A& a){
if(this == &a) return *this; // 避免自我赋值
auto new_p = new char[strlen(a.p)+1];// 有可能抛出异常
strcpy(new_p, a.p); // 复制资源
delete[] p; // 释放原资源
p = new_p; // 重新指向资源
return *this;
}
2
3
4
5
6
7
8
9
10
在这里,我们首先将资源复制到一个新的内存空间,然后释放原有的资源,最后将指针指向新的资源。这样可以预防 new
失败时,原有资源被释放。
避免自我赋值
一般来说,涉及到动态内存的赋值操作符重载,都应该避免自我赋值。这是因为不采取措施会导致:
- 如果未保存副本,那么会先释放资源,再分配资源,这样显然在复制资源时会出现问题;
- 如果保存了副本,那么会反复进行复制,浪费了时间和空间。 因此,我们应该在赋值操作符重载中加入自我赋值检查,即证同测试。通常的做法是:
A& operator = (const A& a){
if(this == &a) return *this;// 证同测试
// 其他操作
}
2
3
4
下标操作符重载
为了轻松地像数组那样访问对象中的元素,可以考虑重载 []
操作符。下标操作符重载时的参数只有一个,如 (int i)
。
我们以下面的 string
类为例子,探讨下标操作符重载:
class string{
char *p;
public:
string(char *p1){
p = new char [strlen(p1)+1];
strcpy(p, p1);
}
char& operator [](int i) const {
return p[i];
}
virtual ~string(){delete[] p;}
}
// in main
string s("aacd");
s[2] = 'b';
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这段代码可以让我们成功使用下标操作符来访问 s[2]
,但会出现一个问题,即 const string cs("const")
产生的对象,可以通过下标操作符修改 p
中的内容,这并不是我们希望的。我们可以将下标操作符改写为用两个函数重载:
char& operator [](int i){ return p[i];} // common object
const char operator [](int i) const{ return p[i];} // const object
2
NOTE
这两个函数的参数列表其实是不同的,因此可以进行重载。
注意,一般有 const
需要的类都应该这样重载下标运算符:一个返回普通引用,另一个是类的常量成员并且返回常量。
另外,我们可能会重载下标操作符用于多维数组。主要的思路是在高维类中使用低维的代理类来作为重载函数的返回值。如下面的代码所示,Array2D
为外层的高维类,Array1D
为内层的低维代理类。
class Array2D {
int *p;
int num1, num2;
public:
class Array1D {
int *p;
public:
Array1D (int *p) {this->p = p;}
int & operator[] (int index) {return p[index];}
const int operator[] (int index) const {return p[index];}
};
Array2D (int n1, int n2) {p = new int[n1 * n2]; num1 = n1; num2 = n2;}
virtual ~Array2D () {delete [] p;}
Array1D operator[] (int index) {return p + index * num1;}
const Array1D operator[] (int index) const {return p + index * num1;}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
括号操作符重载
括号操作符重载可以创建函数对象。函数对象是一种更优于函数指针的实现,可以获得更安全的类型检查,以及诸多类的特性。此外,函数对象的类中还可以引入其他成员变量,以储存函数的状态。下面的 Func
函数类就创建了函数对象 f
:
class Func{
double para;
int lb, ub;
public:
double operator ()(double, int, int);
}
Func f;
f(2.3,1,3);
2
3
4
5
6
7
8
9
括号操作符重载的另一个方面是重载类型转化操作符。例如,我们自定义了一个分数类 Fraction
,我们可以通过重载括号操作符来实现将分数转化为 double
类型。
class Fraction{
int num, den;
public:
Fraction(int n, int d): num(n), den(d){}
operator double() const {return double(num) / den;}
}
2
3
4
5
6
可以看出,类型转化操作符的重载函数原型较为特殊,它没有返回值类型,也没有参数列表。
成员访问运算符重载
可以重载成员访问运算符 ->
,来改变箭头具体获取成员的对象。具体的语法为:
[pointer or object] operator->(){}
由于箭头访问运算符的目的是获取成员,所以重载的返回值必须是一个指针或一个重载了成员访问运算符类的对象。具体地,形如 point->member
表达式的执行如下:
如果
point
是一个指针,则调用内置的箭头运算符,相当于(*point).member
;如果
point
是一个重载了成员访问运算符类的对象,那么使用自定义的重载函数point.operator->()
。如上所述,这一函数必须返回一个指针或一个重载了成员访问运算符类的对象,那么可以分情况再次跳转到 1. 或 2. 中处理。
书写 Wrapper 类
由于栈上的自定义类对象会自动释放,我们可以利用这一性质,以及成员访问运算符重载,来进行更自动化的内存管理,实现
下面创建了 A
类专属的 AWrapper
来接替 A
对象的内存管理。
class A{
public:
void f();
int g(double);
void h(char);
};
class AWrapper{
A* p;
public:
AWrapper(A *p){ this->p = p;}
~AWrapper(){ delete p;}
A* operator->(){ return p;}
};
void test(){
AWrapper p(new A);
p->f();
p->g(1.1);
p->h('A')
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这样,在 test
退出时,A
对象会自动释放,不会造成内存泄漏。并且我们可以像普通对象一样使用 ->
运算符。怎么样,是不是想起来了 unique_ptr
?
new
和 delete
操作符重载
有时针对某些系统的功能,我们希望手动管理动态内存的使用,而不使用 C++
内置的 new
和 delete
的内存管理。这时,可以考虑重载 new
和 delete
,先一开始申请大块内存空间,然后自行管理。
new
重载的语法是 void *operator new (size_t *size, …)
,其中 size
是系统自动计算申请对象的大小,后面的变长参数列表是传给 new
的其他形参。
void operator delete(void *p, size_t size)
,其中 p
是被撤销对象的地址,size
参数是可选的,代表撤销对象的大小。
同样,如果重载了 new/delete
,那么通过 new/delete
动态创建该类的对象时将不再调用内置的(预定义的)new/delete
。
异常处理
//TODO