6.面向对象编程

  2021-9-10 


ch.15 OOP

三大核心思想

OOP三大核心思想:数据抽象、继承、动态绑定

类型转换

  1. 我们可以将基类的指针或引用绑定到派生类的对象上,这发生了派生类到基类的隐式转换。这代表着:当使用基类的引用或指针的时候,实际上我们并不清楚该引用或指针所绑定对象的真实类型,该对象可能是基类对象,也可能是派生类对象;这就是多态的重要特征,我们可以对外屏蔽具体派生类细节,外部只需要以使用基类的方式去使用对象即可,方便解耦与抽象。(P.S.前提是派生类public继承基类,见第9条)

    ATT!这里的转换只是对编译器来说类型转换了,让编译器在编译期把派生类当作基类对象来使用(因为派生类一定是完全包含基类的,不会对原派生类对象有任何修改)。其实际的运行时类型并没有被改变(而其它隐式转换会导致运行时类型一起变了),这导致了继承情况下进行类型转换,静态类型与动态类型会不一样,这也是下面动态绑定的基础

  2. 派生类到基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间实际上不存在这样的转换。然而转换是存在的它可以通过拷贝控制方式达到这种派生类类型到基类类型的转换(前提是基类有拷贝控制成员),即:派生类向基类的转换允许我们给基类拷贝/移动操作传递一个派生类的对象,而拷贝/移动构造器/赋值重载函数中只会存在对派生类中基类有的成员的操作,所以基类没有的成员会被”切掉”

  3. 基类的引用或指针不能隐式转型为派生类的引用或指针,除非使用类型转换运算符强制转换。如果在基类中含有一个或多个虚函数,我们可以使用dynamic_cast请求一个类型转换,该转换的安全检查将在运行时执行,用于将基类指针或引用安全地转换成派生类指针或引用(比static_cast更安全,在多态类型之间的显式强制转换主要用dynamic_cast);同样如果我们已知某个基类向派生类的转换一定是安全的,则可以使用static_cast强制覆盖掉编译器的检查工作(不进行运行时检查,不安全)。注意:类型转换所说的安全是指有问题能及时发现,比如一个指针转型成一个整数这很显然有大问题,尽早爆出来。 dynamic_cast就可以做到一定是符合条件的基类转换成派生类,否则会报错,所以比较安全

动态绑定

  1. 当我们使用继承关系的类型的时候,必须将一个变量或其它表达式的静态类型,与该表达式对象的动态类型区分开来。表达式的静态类型在编译时是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。比如A* a,静态类型指左边变量类型是A*,而对象的动态类型指右边对象a的实际类型。基类的指针或引用的静态类型可能与其动态类型不一致,这是C++支持多态的根本所在。为什么会不一致?因为可能做了派生类到基类的静态类型转换,这里所说的基类的指针或引用就可以通过这样获取

  2. 绑定:把一个方法与类对象关联起来的方法叫绑定。

  3. 在C++语言中,当我们①使用基类引用或指针调用一个虚函数时将发生动态绑定虚函数的运行版本由运行实参(动态对象)决定,所以称为动态绑定(变量绑定的对象类型是运行时决定的),其解析发生在运行时

    非虚函数/数据成员/直接对象的运行版本由变量静态类型决定,变量静态类型是什么,实际对象就是什么类型,所以是静态绑定,其解析发生在编译期。这种情况下,即使派生类重写了虚函数,但由于变量是基类类型,或者调用的函数是非虚函数,那么程序也只会调用基类的函数。只有虚函数是可以动态绑定的。

类型转换与动态绑定

思考这段代码(C++多态性典型应用):

struct Dad
{
    virtual void func() {cout<<"dad"<<endl; }
};
struct Son: Dad
{
    virtual void func() {cout<<"son"<<endl; }
};
int main()
{
    Dad dad;
    Son son;
    Dad *d = &son; //发生【静态类型转换】,son对象被隐式转换为Dad类型
    d->func();  //发生【动态绑定】,d的运行时对象是Son类型的,所以调用派生类的虚函数func而不是基类的虚函数func
}

通过基类指针或引用调用派生类对象的时候,虽然发生了派生类到基类的隐式类型转换(见上一条;这使得程序拥有多态特性,方便解耦与抽象),但这只是让编译器把派生类当作基类对象来使用,其运行时类型并没有被改变。

在调用这个基类指针或引用的拥有的虚函数的时候又触发了动态绑定,能够绑定到实际的运行时对象(派生类)的虚函数。但是派生类独有的非虚函数,通过基类指针或引用就无法调用了,因为非虚函数是静态绑定的,静态绑定调用根据静态类型来决定,静态类型是父类,没有派生类的成员,所以无法调用。

类型转换是编译期的事情,而动态绑定是运行时的事情,类型转换的存在是动态绑定特性的基础(毕竟,如果静态类型和动态类型是一致的,那就不会触发动态绑定了)

P.S. 关于类型转换与动态绑定《C++Primer5》一书中并没有深入展开,这里只是我读后的理解,可能有误,待看完《深度探索C++对象模型》之后再回来补充或修正

虚函数

  1. 函数前加关键字virtual声明为虚函数

    允许在函数末尾添加override关键字来提醒这是覆写基类的虚函数

    允许在函数末尾添加final关键字来提醒此函数/类不可继承or覆写

    override和final都是为了告诉编译器,让编译器提醒我们当且仅对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下,对象的动态类型才有可能和静态类型不同

  2. 如果某次函数调用使用了默认实参,则该默认实参值由本次调用的静态类型决定,虚函数也是如此

  3. 回避虚函数机制:通过作用域运算符来强制使用基类的虚函数。以上面的代码为eg:son->Dad::func()就调用了Dad的虚函数Dad::func()而不是son::func()

抽象基类、纯虚函数

纯虚函数才类似于JAVA中的抽象函数,只负责定义接口,通过在虚函数声明语句分号前声明为=0,实现了这个纯虚函数的类才能实例化,含有纯虚函数的类叫抽象基类

重构

重构:重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。

继承与虚析构函数、拷贝控制、构造函数

  1. 派生类拥有基类全部成员函数和成员变量,但是不一定拥有访问权。比如,基类有个private变量a,派生类也会拥有,但是派生类自己也无权访问这个变量。注意派生后,派生类就是一个包含基类成员的独立结构,操作派生类对象发生的事情都是在派生类对象中的(这里待看完《深入探索C++对象模型》后更多理解补充)。构造函数、拷贝控制构造函数不能被继承。(显然赋值重载函数会被继承)

    在一个对象中,继承自基类的部分和派生类自定义的部分不一定是连续存储的。

  2. 每个类控制它自己成员的初始化过程,即某个类的成员只能由自己的构造函数来初始化。且在派生类中首先构造函数初始化列表中通过调用基类构造函数初始化基类的部分(这就像JAVA中子类构造函数中需要先super),然后按照声明顺序依次初始化派生类的成员

  3. 构造函数不会被继承,所以初始化派生类中的基类成员需要手动调用所有的基类构造器。但是C++11之后可以通过using指定基类名,就能在派生类中自动生成所有基类构造器代码(注意,using一般只会改变作用域,但是这里不一样,它为派生类生成了基类构造器代码)。以下举例:

    (注意:using不会生成基类的拷贝控制构造函数(移动、赋值),以及析构

    #include <iostream>
    #include <string>
    using namespace std;
    struct Dad
    {
        Dad() = default;
        Dad(const int a) {cout<<"hi this is dad"<<endl; }
    };
    struct Son: public Dad
    {
        using Dad::Dad;
        Son(const double &b): Dad() {cout<<"hi this is son"<<endl;  } //自构造器,就不能用using生成的构造器,要自己在初始化列表中初始化基类构造器
    };
    int main()
    {
        Son son(1); //"hi this is dad"; 调用Son中using生成的基类构造器
        Son son2(3.4); //"hi this is son"; 调用Son自己写的构造器
    }

    ATT1:只能在构造函数初始化表中调用基类构造器,且直接调用不需要加作用域运算符

    ATT2:对于using生成构造函数,有两个例外:①如果派生类定义的构造函数与基类的构造函数相同参数表,则该基类构造函数不会被生成 ②也就是前面所说的,using不会生成拷贝控制构造函数

    ATT3:using Dad;只是在派生类中自动生成调用基类构造器的派生类构造器,这个派生类构造器参数和基类构造器一样,然后用这个参数初始化基类构造器形式:Son(params):Dad(args)),其它什么都不干,如果派生类自己要写新的构造器,就还是要在构造函数初始化表中调用基类构造函数来初始化基类部分。以下举例:

    struct Dad
    {
        Dad();
        Dad(const int a) {cout<<"hi this is dad"<<endl; }
        Dad(const string a) {cout<<"hi this is dad too!"<<endl; }
    };
    struct Son: public Dad
    {
        using Dad::Dad;
    };
    //等价于
    struct Son: public Dad
    {
        //派生类构造器参数和基类构造器一样,然后用这个参数初始化基类构造器,其它什么都不干
        Son():Dad(){};
        Son(const int a): Dad(a) {cout<<"hi this is dad"<<endl; } 
        Son(const string a):Dad(a) {cout<<"hi this is dad too!"<<endl; }
    };
  4. 构造函数拥有默认值会产生多个构造函数版本,且继承构造函数无法继承基类构造函数的默认参数,所以我们在使用有默认参数构造函数的基类时就必须要小心。

  5. 由于动态绑定,我们使用一个基类指针或引用的时候,实际上可能使用的对象是派生类,所以析构也必须调用对应派生类的析构函数来释放对应的动态资源,所以若要用到动态绑定特性,继承体系的析构函数最好是虚析构函数,以确保释放运行时对象管理的动态资源,这时候由于虚析构函数也是动态绑定的,所以对基类指针调用析构函数的时候就会调用运行时对象的虚析构函数。合成的析构函数并不是虚析构函数,所以这种动态资源+动态绑定情况下,需要自定义虚构函数。(若不用虚析构函数,那么析构函数是静态绑定到编译期变量(基类指针)的,那么只会调用基类的析构函数,于是GG,派生类资源泄漏;如果派生类没有自己的动态资源,确实可以不用虚析构函数,但这样一不小心就容易犯错,所以只要是这种情况,统统设为虚析构函数)

    虚析构函数也是析构函数,包含析构函数其它特性,比如,虚析构函数会阻止编译器合成移动操作。

  6. 派生类的析构函数只负责销毁由派生类自己分配的资源,父类的析构函数会被自动调用执行。也就是说构造的时候是从原基类成员开始构造,析构的时候是从派生类成员开始销毁(注意1:析构函数内只是析构前的工作,实际的解构和回收内存是在析构函数执行后隐式销毁的;注意2:使用虚析构函数也是一样,从派生类成员开始析构)

  7. 派生类中删除的拷贝控制与基类的关系:

    ①如果基类的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是删除的或不可访问的,则派生类中对应的成员将是被删除的(显然,派生类不能使用基类成员来执行派生类对象中基类部分的构造赋值或销毁等操作)

    ②如果在基类中有一个不可访问或删除的析构函数,则派生类中合成的默认和拷贝构造函数将是删除的(因为编译器无法销毁派生类中的基类部分,当然,基类的默认拷贝构造函数也是删除的)

    ③编译器将不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作的时候,如果基类中对应操作是删除或不可访问的,那么派生类该函数将是删除的(因为派生类对象的基类部分不可移动);同样,如果基类中的析构函数是删除或不可访问的,则派生类的移动构造函数也是删除的。

  8. 由于大多基类都会定义一个虚析构函数,所以基类合成移动操作默认删除,所以派生类的合成移动操作也默认删除,所以如果派生类要定义移动操作,应该首先在基类中定义

    总地来说:由于派生类的基类部分要依托基类的构造器,所以基类没有的,派生类就不能有

再次理解【重要】自己的成员自己管(用构造函数)

赋值操作符重载也是这样。在派生类中,一定要调用基类的拷贝控制函数来管理基类部分的成员,但是析构函数比较特殊,派生类析构函数中不需要调用基类析构函数,基类析构函数Dad:~Dad()会自动执行

派生类中没有初始化基类部分成员就使用会提示找不到该成员

在设计类继承体系的时候,就要明确,自己负责自己成员的拷贝控制,派生类要用的!

可以使用 using自动生成默认调用基类构造器的派生类构造器,但这样就无法自定义派生类的参数表。注意using只能生成构造函数,而拷贝控制构造函数是不行的。

/*****错误演示*****/
struct Dad
{
    int tmp = 0;
}
struct Son : Dad
{
    //【错误】Son的拷贝控制构造函数。使用了父类构造器初始化tmp,但是却用被拷贝对象的基类成员初始化本对象的基类成员,违背“自己的成员自己管原则”。tmp是继承的基类成员,要用基类的构造器(各种)来初始化!
    //这里初始化调用的是基类的默认构造函数进行初始化,【没有初始化基类部分成员tmp,所以这里会报错提示本对象(拷贝对象,Son类)找不到tmp】。我们能够在派生类中操作基类部分成员的前提也是基类成员已经初始化了
    Son(const Son &son):Dad(),tmp(son.tmp) { cout<<"hi this is son copy sontructor"<<endl; }  
}

/*****【正确方法】:在基类Dad中定义拷贝控制函数,由它负责成员tmp的拷贝操作,再由派生类来调用基类的拷贝控制函数进行拷贝操作*****/
struct Dad
{
    //实现了一个基类的拷贝控制构造函数,负责基类成员tmp的拷贝控制
    Dad(const Dad &dad):tmp(dad.tmp) {cout<<"Dad copy constructor"<<endl; }
    int tmp = 0;
}
struct Son : Dad
{
    //将被拷贝类引用传入基类的拷贝构造函数中,让它自己负责自己的成员!注意,这里将Son类型的son传入,Dad &dad = son,发生了静态隐式类型转换(见第一条),dad.tmp即son继承自基类的成员tmp
	Son(const Son &son):Dad(son) { cout<<"hi this is son copy sontructor"<<endl; }
}
//同理,赋值操作也要基类派生类各自实现,由于派生类没有拷贝成员操作,所以只需要父类实现一个赋值重载运算函数,派生类会函数匹配到父类的赋值重载运算函数(会发生派生类到基类的转型)
/*****为了演示完整,以下为包含赋值重载的完整版,包含了派生类赋值重载*****/
struct Dad
{
    Dad(const Dad &dad):tmp(dad.tmp) {cout<<"Dad copy constructor"<<endl; }
    Dad& operator=(const Dad &dad)
	{
    	this->tmp = dad.tmp;
    	return *this;
	}
    int tmp = 0;
}
struct Son : Dad
{
	Son(const Son &son):Dad(son) { cout<<"hi this is son copy sontructor"<<endl; }
	Son& operator=(const Son &son)
	{
    	Dad::operator=(son);  //必须显式调用基类的赋值重载函数,让基类拷贝赋值函数对继承来的基类成员拷贝操作
        this->var = son.var;
    	return *this;
	}
    imt var = 1;
}

派生类调用基类移动构造器的时候要注意一下,由于移动构造器要求实参是右值,传入派生类形参的右值变成了左值,所以想要在派生类中再将对象传递给基类移动构造器需要再取右值std::move()一下。举例:

#include <iostream>
#include <utility>
using namespace std;
struct Dad
{
    Dad() = default;
    Dad(Dad &&dad):m(dad.m) { dad.m = nullptr; }  //move 
    Dad& operator=(Dad &&dad)  //move 
    {
        this->m = dad.m;
        dad.m = nullptr;
        return *this;
    }
    int *m = new int(10);
    virtual ~Dad() {delete m; }
};
struct Son: public Dad
{
    Son() = default;
    Son(Son &&son):Dad(std::move(son)) { }  //move 注意这里,传入std::move(son)
    Son& operator=(Son &&son)  //move 
    {
        Dad::operator=(std::move(son));  //注意这里,传入std::move(son)
        return *this;
    }
    int *m = new int[2]();
    virtual ~Son() {delete m; cout<<"destroy"<<endl; }
};
int main()
{
    Son son;
    auto addr1 = son.Dad::m;  
    Son son2(std::move(son));
    auto addr2 = son2.Dad::m;
    cout<<(addr1==addr2)<<endl; //1 证明发生了对象移动
    cout<<addr1; //segmentfault 因为addr1已经寄了
}

继承与访问控制

基类中成员的访问权限说明符该类(基类)本身对外一切非本类的代码,包括派生类、用户代码)提供的访问权限。eg:class A {private int a;}

派生类继承基类的访问权限说明符派生类继承的基类成员对派生类本身以外提供的访问权限,是派生类继承的成分对外提供的权限。其基于基类成员函数的访问权限,只能变严,不能变松,且无法更改基类本身的成员访问权限。eg:class B: public A{}。即使是public继承的,但是从基类继承来的private成员依然无法被派生类和用户代码访问到。

如果没有继承访问权限说明符,则struct默认publicclass默认private

可以通过using改变派生类继承基类某名字的访问权限为public,注意改变的只是继承访问权限,即上面的”②“,所以也有一样的特性(只能变严或持平),其本质是让派生类继承的基类成员变量的对外访问权限和继承前基类中对外访问权限一致。eg:using Dad::abc; 主要用在如private继承之后,使用using修改个别名字的继承访问权限

派生类向基类转换的可访问性(假定D继承自B):

只有当Dpublic继承B时,用户代码才能使用派生类向基类的转换(1、2中的多态性的前提);如果Dprivateprotected继承B,用户代码不能使用该转换。

②无论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的

③如果Dpublicprotected继承B,则D的派生类的成员和友元可以使用D向B的转换;如果Dprivate继续B,则不能使用。

友元不具传递性和继承性

作用域

派生类的作用域是被嵌套在基类中的。名字查找是先从内层作用域找再到外层作用域找。

隐藏:派生类会隐藏基类中的所有同名函数(即使参数表不同)。(即使是虚函数也会被隐藏,这也是为什么派生类虚函数参数表一定要与基类一致,一不小心不是虚函数就)因为派生类是内层作用域,基类是外层作用域。外层作用域的名字会被内层作用域的名字隐藏

可以使用作用域运算符指定使用被隐藏的基类成员:int a = Dad::abc;

可以直接在派生类中用using把基类函数名添加到派生类作用域中(或者把基类同名函数全部在派生类中抄一遍)eg:using Dad:funcname只需要using指定函数名字,不需要参数表

继承、访问控制、作用域、构造与析构、自己的成员自己管,结合五者可以明白派生类到底有什么,怎么用。


且听风吟