OOP

概述

OOP,全名:Object-Oriented Programming

其核心思想为:

  • 数据抽象(封装):使得类的接口和实现分离

  • 继承:可以建立相似关系模型

  • 动态绑定(多态):可以在一定程度忽略相似类型的区别从而以统一的方式使用

基类和派生类

在继承体系中,处于根部的就是基类(base class),而以此衍生的则是派生类(derived class)

基类负责定义层次关系中所有类都共有的成员,每个派生类则定义自己特有的成员。

基类有两种成员函数:虚函数(virtual function)非虚函数(non-virtual function)

前者是是基类希望派生类进行覆盖的,后者则直接继承

但实际上,虚函数还可细分为:纯虚函数(pure virtual function)非纯虚函数(impure virtual function)

这个可以参考Meyes的《Effective C++》Item 34

前者是为了让派生类只继承函数接口,而后者是继承函数接口和默认实现

直接基类和间接基类

1
2
3
class base{}
class D1:public base{} //base为直接基类
class D2:public D1{} //base为间接基类,D1为直接基类

最终派生类对象将包含直接基类的子对象和每个间接基类的子对象

final说明符

final说明符跟在class名字后面表示不能作为基类

继承体系中的静态对象

静态对象在继承体系中只有一个实例,可以继承下去,通过基类或派生类的作用域运算符都能调用

静态成员的访问则遵循的一般访问控制规则(后面会展开)

派生类对象的概念模型

一个派生类对象由多个子对象组成:

  • 派生类自己定义的非静态成员的子对象

  • 基类对应的子对象

  • 多重继承则有多个基类子对象

注意:C++标准并未明确规定派生类对象的内存分布,所以我们这里只是概念模型不是物理模型

虚函数

虚函数声明方式:

1
virtual function-name(parameters)//const;

虚函数你要了解:

  • 必须是任何构造函数以外的非静态函数

  • virtual关键字只能出现在声明中,即类外定义不需要

  • 基类中的虚函数继承下去仍然是虚函数(隐式)

  • 解析过程不是在编译期而是运行期(动态绑定)

  • 虚函数请一定提供定义不论是否被用到

类型转换

因为派生类对象包含了基类子对象,因此可以基类指针或引用可以绑定派生类的基类部分,这个称为:派生类到基类(derived-to-base) 的类型转换,这个隐式转换意味着:

  • 在需要基类引用的地方可以用派生类对象或是派生类对象的引用代替

  • 在需要基类指针的地方可以用派生类对象的指针代替

反向转换是肯定不行的,因为每个派生类必然包含基类子对象,但是基类对象不一定是派生类对象的一部分,基类对象作为独立对象的话应当禁止这种隐式转化。

1
2
3
4
5
6
7
base b;
derived d;
base* pb=&b //pb指向base对象
base* pd=&d; //pb指向derived对象的base部分
base& rd=d; //rb绑定了derived对象的base部分
derived* pb=&b; //error
derived& rb=b; //error

静态类型与动态类型

这是C++多态语义的一部分,要充分理解!

由于基类指针或引用可以绑定派生类对象,这往往意味着我们不知道它到底绑定了谁,基类对象?派生类对象?因此有了这两种类型以便讨论,我先给出其概念:

  • 静态类型(static type)

    变量声明时的类型或表达式生成的类型,在编译期确定

  • 动态类型(dynamic type)

    变量或表达式表示的内存中的对象的类型(对应指针或引用),在运行期确定

如果表达式既不是指针或引用,则静态类型和动态类型永远相同,换言之,讨论静态类型和动态类型一般都是讨论指针或引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class base{
public:
virtual void f(){
std::cout<<"base f() is called"<<'\n';
}
};
class derived:public base{
public:
void f() override{
std::cout<<"derived f() is called"<<'\n';
}
};

int main(){
derived d;
base* pd=&d; //动态类型是derived,静态类型是base*
pd->f(); //调用派生类的f()
}

动态类型依赖于pd绑定的实际类型,编译器找到f()发现它是虚函数就会进行动态绑定,这个过程发生在运行期,在这个阶段就能确定动态类型,从而调用其虚函数。

注意动态绑定是基类指针或引用调用虚函数才会进行动态绑定的(具体为什么与vptr和虚函数表有关,这里不细说)

关于基类向派生类的转换

这里还说明一下,基类的确是不可以隐式转换为派生类,但可没说不能强制转换:

static_castdynamic_cast都可以进行这种强制转换,但是注意,对于static_cast对于运行期才能确定的类型是没办法检测的,也就是说用它进行转换是不安全的,而dynamic_cast是在用于RTTI的运算符,可以检测出来转换是否合理,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>

class base{
public:
virtual ~base()=default;
};

class derived:public base{
};

class test:public base{
};

int main(){
std::cout<<std::boolalpha;
derived d;
test t;

base* pd=&d;

derived* p1=dynamic_cast<derived*>(pd);
std::cout<<(p1!=nullptr)<<std::endl; //true


test* p2=dynamic_cast<test*>(pd);
std::cout<<(p2!=nullptr)<<std::endl; //false


derived* p3=static_cast<derived*>(pd);
std::cout<<(p3!=nullptr)<<std::endl; //true


test* p4=static_cast<test*>(pd);
std::cout<<(p4!=nullptr)<<std::endl; //true

}

可以看出static_cast尽管pd指向的是derived对象,但是还是转换成了test对象指针,这是极其不安全的,好在我们有dynamic_cast可以帮助我们检测,不过还得注意dynamic_cast转换的基类必须包含虚函数(因为运行期转换需要知道类对象信息,这个是通过虚函数表实现的,以此来判断是否有继承关系)

覆盖

虚函数一旦在基类中声明为virtual,则在派生类中隐式声明为virtual,因此无须在派生类中继续为其继承下来的虚函数声明virtual

虚函数可以选择继承其接口和默认实现,也可以选择覆盖该默认实现,覆盖的要求是签名式完全相同,即返回类型与参数列表,但是,如果虚函数返回类型为类本身的指针或引用,此规则无效。

e.g. D继承自B,则B的虚函数返回B,D的虚函数则可以返回D,前提是D到B的转换是可访问的(后面会提)

override说明符

当函数的参数列表写错了,不会覆盖其基类的虚函数,而是隐藏(hide) 了基类的虚函数

这时候如果通过基类指针去调用虚函数,就会出现逻辑错误,我想要调用派生类的重新实现版本,结果得到的确实基类的实现版本,而且这种错误是十分难以检测到的,因此C++11推出了override说明符表示我这个函数就是要覆盖基类的同名虚函数,如果没有覆盖则编译器报错

final说明符

final表示不再允许覆盖虚函数

虚函数的默认实参

虚函数的默认实参由其静态类型决定,也就是说基类指针即使指向派生类对象,使用的默认实参是基类版本的,因此如果虚函数使用默认实参,最好保证基类和派生类的默认实参一致,不然发生未定义行为自己负责

回避虚函数的机制

在某些情况下,我们不需要虚函数的调用进行动态绑定,而是强迫调用虚函数的某个特定版本

方式:作用域运算符

1
auto result=basep->base::base::f();

Note:这个机制通常发生在一个派生类的虚函数调用被它覆盖的基类虚函数版本时,否则无限递归

多态

既然提到了这个词语,还是稍微解释一下:

英文polymorphism,源自古希腊,意为“多种类型”

我们把继承体系中的多个类型称为多态类型,因为我们使用这些类型的“多种形式”而无须在意其差异

静态类型与动态类型的不同是C++支持多态性的根本

Note:当且仅当通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有这个时候对象的静态类型才可能与动态类型不同

抽象基类

至少有一个纯虚函数的基类就称为纯虚基类,纯虚函数无须定义,纯接口继承下去便可(但是纯虚函数也可以提供类外定义,类内不行)

若派生类没有重新实现纯虚函数,则该类继续成为抽象基类

我们不能声明抽象基类的对象,因此也不能指向抽象基类的对象,但是由于多态,往往我们声明抽象基类指针指向不同的派生类对象来实现动态绑定

继承中的访问控制

protected成员

这个成员是基类想与派生类分享但不愿公开给别人使用的一种继承体系中起作用的成员

可以认为其杂合了public和private的特性:

  • 与public相似,它向派生类的成员和友元开放

  • 与private相似,类的用户是无法访问protected成员的

还有一点重要性质:

派生类的成员或友元只能通过派生类对象访问基类的protected成员,而派生类对于基类对象的protected成员没有访问权

这个性质是由于假如我们有一个派生类的友元函数,它的参数是基类对象,如果通过基类对象可以修改其protected成员的话,protected的访问保护好比纸糊的,因为它不是基类成员或友元,没有特权访问该部分,因此作出如下规定:

派生类的成员和友元只能访问派生类对象中基类部分的protected成员,而对于普通的基类对象来说,protected(和private)成员都没有特殊访问权

三类继承

某个类对继承而来的成员的访问权限受两个因素影响:

  • 基类中该成员的访问说明符

  • 派生列表中派生类的访问说明符

前者影响派生类的成员和友元对基类的成员的访问权

e.g. 基类的private成员,管你什么继承,都不能访问,若想访问,一般有两种方法:

  • 在基类public部分提供private成员的接口

  • 将其改为protected

而后者影响的是派生类用户对基类成员的访问权

e.g. 一个类有一个public成员函数,通过private继承,这个函数派生类可以访问但其用户不可以访问

然后细分这三种继承有什么区别:

  • public继承:这个用的最多,也是最好理解的。按照原有顺序继承,即原来基类成员是什么访问说明符,在派生类也是如此。派生类成员和友元可以访问其public和protected成员,派生类用户只能访问public

  • private继承:继承的成员在派生类中全是private。因此派生类用户对这部分完全没办法访问,只能通过派生类提供的新public接口进行访问(不过一般都是其他用途才会private继承)

  • protected继承:继承的所有public成员成为protected成员。派生类的成员和友元都可以访问这部分,但是对于派生类用户不能访问

友元是不可继承的

友元关系不可传递,同样,友元也不能继承,基类的友元在访问派生类对象时不具有特殊性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class base{
protected:
int pro;
friend class Pal;
};

class derived:public base{
int j;
};

class Pal{
public:
//pal是base的友元,因此base对象可以访问所有成员
int f(base b){return b.pro;}
//pal不是derived的友元
int f2(derived d){return d.j;} //error

//因为pal是base的友元,因此derived对象的基类部分成员开放
int f3(derived d){return d.pro;}
};

记住每个类负责控制自己成员的访问权限,因此即使pal不是derived的友元,derived对象的基类部分也可以向它的成员开放(后面知道了作用域是嵌套的再结合概念模型就能明白为什么了)

友元只对当前声明类有效:

1
2
3
4
5
6
class derived_:public pal{
public:
int mem(base b){
return b.pro; //error:友元不能继承
}
}

using改变访问性

通过在类内部使用using声明可以修改该类的直接(或间接)基类中可访问成员的访问权限。

依据是using声明前的访问说明符

默认继承

class默认继承为private继承,而struct默认为public继承

struct和class的唯一区别就是默认成员访问说明符和默认继承方式

但是C++中一般都是将struct当聚合类或是POD类型使用,而对于非POD类型用class来表示

派生类向基类转换的可访问性

假定D继承自B

  • 只有当Dpublic继承B用户代码才能使用派生类向基类的转换,其他继承不允许

  • 无论哪种继承方式,D的成员和友元都能使用派生类到基类的转换

  • 如果Dpublic/protected继承B,则D派生类的成员和友元可以使用D向B的类型转换

Tip: 对于代码中某个给定结点来说,如果基类的公有成员是可访问的,则派生类向基类的转换也是可访问的,反之不行

这个蛮好理解的,如果你公有成员都访问不了,你还转换个锤子

继承中的类作用域

派生类的作用域是嵌套于基类作用域中,这其实就是给OL(ordinar lookup)提供了指引:如果一个名字在派生类的作用域中无法找到,编译器会继续在外层的基类作用域中继续寻找。

但是你要明白名字查找是编译期进行的,也就是说你能使用哪些成员决定于静态类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class base{
};

class derived:public base{
public:
void g(){}
};

int main(){
derived d;
base* pd=&d;
d->g(); //error:静态类型为base*,因此从base开始查找

}

隐藏名字

派生类的成员会隐藏基类的同名成员(与虚函数的覆盖是不同的)

可能有时候会需要这种隐藏,但是尽量避免,如要使用隐藏名字则使用作用域运算符直接访问基类成员即可

继承体系中的名字查找

这里主要讲的是OL,ADL另论,针对的为类类型,流程如下:

  • 首先确定静态类型,确定开始要查找的类作用域

  • 如果在静态类型所在的作用域中找不到,则到外层作用域中寻找直至继承链的顶端,如果最终没有找到,编译器报错

  • 如果找到则进行类型检查,以确认调用是否合法

    • 如果该函数为虚函数并且是通过指针或是引用来调用的则编译器产生的代码在运行期确定到底调用虚函数的哪个版本

    • 如果为非虚函数或是通过对象调用的虚函数都在编译期确定好,产生常规函数调用

注意:名字查找是优先于类型检查的

我们看看代码加深理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class base{
public:
virtual int fcn();
};

class D1:public base{
public:
int fcn(int); //隐藏了基类的fcn且为非虚函数
virtual void f2();
};

class D2:public D1{
public:
int fcn(int); //隐藏了D1的fcn
int fcn(); //覆盖了base的fcn
void f2(); //覆盖了D1的f2
};

int main(){
base b;
D1 objd1;
D2 objd2;

base* pb=&b;
base* pd1=&objd1;
base* pd2=&objd2;
pb->fcn(); //虚调用,调用base::fcn()
pd1->fcn(); //虚调用,调用base::fcn()
pd2->fcn(); //虚调用,调用D2::fcn()

D1* d1p=&objd1;
D2* d2p=&objd2;

pd1->f2(); //error:base没有f2()
d1p->f2(); //虚调用,调用D1::f2()
d2p->f2(); //虚调用,调用D2::f2()


base* p1=&objd2;

D1* p2=&objd2;
D2* p3=&objd2;

p1->fcn(42); //error:base没有fcn(int)
p2->fcn(42); //静态绑定,调用D1::fcn(int)
p3->fcn(42); //静态绑定,调用D2::fcn()
}

覆盖重载函数

派生类希望基类的所有重载函数在派生类都是对其可见的,则需要覆盖全部或一个都不覆盖(不理解就在脑子里搭个模型)

如果只覆盖部分,我们可以通过using声明语句将该重载函数的名字引入派生类,这样我们只需要定义派生类特有的版本,对派生类没有重新定义的版本的访问实际上是对using声明点的访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#define COUTENDL(expr) \
std::cout<<(expr)<<std::endl


class base{
public:
void f(){
COUTENDL("base::f()");
}
void f(int){
COUTENDL("base::f(int)");
}
virtual void f(char){
COUTENDL("base::f(char)");
}
};

class D1:public base{
public:
using base::f;
void f(double){
COUTENDL("D1::f(double)");
}
};

int main(){
D1 d1;
base b;

d1.f();
d1.f(1);
d1.f(1.1);
d1.f('a');
}

继承中的拷贝控制成员

当一个类(基类或派生类)没有定义拷贝控制成员,则编译器将为它合成一个版本。

虚析构函数

这个参考Meyes Effective C++ Item 7

虚析构函数会阻止合成移动操作

合成的拷贝控制成员

基类或派生类的合成拷贝控制成员的行为与通常的无异:对类的成员进行初始化。赋值或销毁,当然派生类合成的这些成员还会使用直接基类中对应的操作对直接基类进行初始化、赋值或销毁。

使用基类成员的时候,不要求其是否为合成的,唯一的要求的是相应的成员可以访问且不是delete

派生类拷贝控制删除的情况

  • 基类的默认构造、拷贝构造函数、拷贝赋值运算符或析构函数是delete或不可访问的,则派生类对应成员为delete,原因是编译器无法用基类成员对派生类对象的基类部分进行构造、赋值和析构

  • 基类中析构函数为delete或不可访问的,则派生类中合成的默认和拷贝构造是delete,原因是编译器无法销毁派生类对象的基类部分

  • 对于移动操作,如果对应操作是delete或不可访问的,则派生类中该函数是delete的,原因是基类部分无法移动;析构函数是delete或是不可访问的则派生类的移动构造函数delete

派生类的拷贝控制成员

构造函数

每个类管理自己成员的初始化过程,因此派生类构造函数对于基类成员的初始化会调用相应的基类操作来处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class base{
public:
base(int,int);
private:
int i;
int j;
};

class derived{
public:
derived(int i,int j,double d1):base(i,j),d(d1){
}
private:
double d;
}

析构函数

析构函数只需要销毁派生类自己分配的资源就行了(trivial就随意了)

拷贝和移动构造函数

除了要初始化自己的成员,还得处理基类部分的成员

1
2
3
4
5
6
7
8
9
10
class base{/*....*/};

class D:public base{
public:
D(D const& d):base(d)
                    /*D的成员初始值*/{/*...*/}
D(D &&d):base(std::move(d))
/*D的成员初始值*/{/*...*/}

};

这里我们是把派生类对象传递给基类拷贝构造函数,负责将d的基类部分拷贝给要创建的对象,移动构造类似

赋值运算符重载

同样,必须显式地为基类部分赋值:

1
2
3
4
5
6
7
8
D& D::operator=(D const& rhs){

if(this!=&rhs) {
base::operator=(rhs);
//...
return *this;
}
}

在构造函数和析构函数中调用虚函数

我们创建派生类对象,在执行基类构造函数时,派生类部分是未被初始化状态

在我们销毁派生类对象,在执行基类析构函数时,派生类部分已被销毁

即我们在执行基类相关操作时,派生类部分都是未完成状态

为了正确处理这种状态,编译器认为对象的类型在构造或析构的过程中好像发生了变化,换言之,当我们构造一个对象时,需要把对象的类型和构造函数的类看做同一个

对虚函数的调用绑定符合这一要求,包括直接调用和间接调用(间接调用就是指调用另一个函数其中有直接调用)

因此我们要了解:

如果构造函数和析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本

e.g.

继承的构造函数

一个类只初始化它的直接基类,出于该原因,一个类只继承其直接基类的构造函数。注意,不能继承默认、拷贝和移动构造函数以及析构函数。如果没有这些函数,编译器将为其合成。

派生类继承基类构造函数的方式是提供using声明语句,这个using与之前的只是提供声明点不一样,而是令编译器产生代码,即对于基类的每个构造函数而言,编译器都会在派生类中生成一个参数列表完全相同的构造函数

1
derived(params): base(args){}

如果派生类含有自己的数据成员则被默认初始化

特点

  • 构造函数的using声明不会改变该构造函数的访问级别

  • using不能指定explicitconstexpr,如果基类有该属性,继承的也有

  • 含有默认实参的并不会被继承,而是获得多个继承的构造函数,分别省略一个含有默认实参的参数

一般而言,派生类会继承所有这些构造函数,但是有两个例外:

  • 派生类定义的构造函数与基类构造函数具有相同参数列表

  • 默认构造、拷贝和移动构造

由于继承的构造函数不被当做是用户自定义的,即不是special member,因此只含有继承的构造函数时,编译器仍会为其合成构造函数

后续待补完

多重继承和虚继承先留坑