继承

继承是为了代码复用和简化问题,更好地把问题的层次抽象出来,降低开发难度。

C++是效率为王的语言,通过静态绑定等方式使得很多内容在编译时就确定下来,提高运行时的效率。但是这也就决定了很多地方要为此付出代价。另外C++中栈中的对象由于是直接通过变量名访问,不像Java一样都是引用,所以也会有很多不方便的地方。总的来说,C++的继承更像是在派生类中放了一个隐藏的基类成员变量,只不过可以直接访问到基类的成员。

继承方式

C++中的继承的写法是,在类声明时类型后面加: <继承方式> <基类名>。其中,继承方式有三种:publicprivateprotected

  • public。基类的所有成员在派生类中最高可见度为public,也就是可见度不变。
  • protected。基类的所有成员在派生类中最高可见度为protected,原来的public成员被压缩成保护成员。
  • private。基类的所有成员在派生类中最高可见度为private,也就是全部变成private。

需要注意的是,无论是哪种继承方式,对派生类本身能不能访问基类成员都没影响,而对于外部访问派生类,以及派生类的派生类会造成影响。可以这样理解,派生类中存在一个基类对象成员,而继承方式决定了这个基类对象成员在派生类中前面的可见性修饰。比如public继承,就是派生类中有一个public Base base对象。所以既然无论怎么继承都有这么个基类对象,那基类的成员函数访问的权限就不会受到继承方式的影响,就算是private的,派生类内部也是可以访问这个基类对象的,至于基类对象中哪些基类成员可以访问,这就取决于基类定义了。判断外界是否能访问到某个成员的时候,需要在基类对象成员的可见性和基类内成员可见性中取最小值。例如private继承的基类中有一个public成员,那么对于外界来说可见性是private(public)=private,于是不能访问。

基类中的private成员变量,无论以何种继承方式继承,都不能被派生类的成员函数访问;而基类中的protected和public成员变量,无论以何种方式继承,也都能被派生类的成员函数访问。之前说过,友元的权限会和派生类的成员函数保持一致,那么友元的权限也不会受到继承方式的影响。另外,之前说过,友元无视继承的作用。那么基类的友元自然不可能访问派生类私有和保护的成员。

如果什么都不写,默认是private继承。

构造和析构

派生类对象的初始化由基类和派生类共同完成。构造函数的执行顺序是:

  1. 基类的构造函数
  2. 派生类成员对象的构造函数(按照声明的顺序)
  3. 派生类的构造函数

其中,基类的构造函数和派生类成员对象的构造函数都是默认执行默认无参的构造函数。如果想要执行有参的构造函数,都需要在成员初始化表中进行声明。从这里也可以看出,把基类理解成一个派生类隐藏的成员对象也很贴切,他们的性质和使用方法都差不多。

#include <iostream>

class Father{
protected:
    int age;
public:
    Father(int a):age(a){
        std::cout<<"Father Constructor"<<std::endl;
    }
};

class Son: public Father{
public:
    Son(int a) : Father(a){ //调用基类的有参构造函数,要显式地在成员初始化表中指出
        std::cout<<"Son Constructor"<<std::endl;
    }
};

int main(){
    Son *son= new Son(0);
}

而析构时,次序恰好和构造时是相反的,先析构派生类,在析构基类。这就更像是派生类里面包裹住基类的感觉了。

类型相容和对象切片

除了完全同类型的对象是类型相容的,派生类能够赋值给一个基类对象,或者派生类能够作为参数拷贝构造基类,这说明派生类和基类也是类型相容的。

子类对象能够赋值给父类对象,父类对象不能直接赋值给子类对象。

在基类和派生类对象之间进行赋值的时候,如果左值使用的是基类的对象名(栈中对象的标识符就是对象名),那么赋值会导致对象切片,即只有派生类中包含的那个基类的成员变量被赋值给了基类,属于派生类的属性被丢弃。这是因为,对象在栈中已经分配了固定大小的空间,这部分空间类型由对象名类型已经决定了,不能放入属于派生类的多余的内容。不止赋值,拷贝构造的时候也会发生这样的对象切片现象。

但是,如果使用指针或者引用的方式,赋值的时候不会导致对象切片。指针或者引用只是单纯地指向了背后那块对象的储存空间,此时对象的实际类型和指针或者引用的类型分离相独立。因此,一个基类指针可以指向一个派生类对象,这是多态的根本。

需要注意的是:除了明显的构造和赋值,在函数的传参过程如果是值传递,也会导致对象切片,函数的返回值构造过程亦可。

例如:

#include <iostream>

class Father{
protected:
    int age;
public:
    Father(int a):age(a){
        std::cout<<"Father Constructor"<<std::endl;
    }
    Father(const Father& father):age(father.age){
        std::cout<<"Father Copy"<<std::endl;
    }
    virtual void print() const{
        std::cout<<age<<std::endl;
    }
};

class Son: public Father{
    int age;
public:
    Son(int a) : Father(a), age(10) {
        std::cout<<"Son Constructor"<<std::endl;
    }
    Son(const Son& son)  : Father(son),age(10) {
        std::cout<<"Son Copy"<<std::endl;
    }
    void print() const{
        std::cout<<age<<std::endl;
    }
};

Father getFather1(Son &person){ //函数值传递返回时发生切片
    person.print();
    return person;
}

Father getFather2(Father person){ //函数参数值传递时切片
    person.print();
    return person;
}

int main(){
    Son *son= new Son(30);
    son->print();
    std::cout<<std::endl;
    // output:
    // Father Constructor
    // Son Constructor
    // 10

    //赋值时切片
    Father father(40);
    father=*son;
    father.print();
    std::cout<<std::endl;
    // output:
    // Father Constructor
    // 30

    //拷贝构造时切片
    Father father3=*son;
    father3.print();
    std::cout<<std::endl;
    // output:
    // Father Copy
    // 30

    //函数值传递返回时发生切片
    Father father1=getFather1(*son);
    father1.print();
    std::cout<<std::endl;
    // output:
    // 10
    // Father Copy
    // 30

    //函数参数值传递时切片
    Father father2=getFather2(*son);
    father.print();
    // output:
    // Father Copy
    // 30
    // Father Copy
    // 30
}

上面这段代码展示了四种对象切片发生的情况,平时使用时务必需要注意。

大多数情况都需要尽可能避免对象切片的发生,因此在函数传参的时候尽量使用引用传递,返回值时也应该考虑是否能够使用引用,可以避免不必要的拷贝和意外的切片。

静态绑定和动态绑定

C++中为了提高效率,很多能够在编译时能够确定的就不留到运行时。在编译时根据变量名、指针或者引用类型,将函数调用提前确定,就是静态绑定。需要到运行时查看对象的实际类型,再调用实际的函数版本叫做动态绑定。

C++中,如果使用的是对象名调用函数,而不是引用或者指针,则一定是静态绑定,因为对象的实际类型和对象名一定是一致的,编译时可确定。如果使用的是引用或者指针,这时变量的实际类型和引用或指针类型分离,也默认是静态绑定,即直接根据引用或者指针的类型调用相应版本的函数,这是为了效率考虑的。这种情况下,调用函数时从不考虑对象的实际类型,无法实现所谓的多态。

virtual关键字就是专门用来声明动态绑定的。方法被virtual关键字声明后称为虚函数,如果通过指针或者引用调用,则会采用动态绑定,运行时根据对象的实际类型选择函数版本。

下表指出了静态和动态绑定的条件。

代码形式 对于虚函数 对于非虚函数
作用 绑定方式 作用 绑定方式
类名::函数() 不能将静态函数声明为虚函数 不适用 调用指定类的指定静态函数 静态绑定
对象名.函数() 调用指定对象的指定函数 静态绑定 调用指定对象的指定函数 静态绑定
引用变量.函数() 调用引用对象所属类的指定函数 动态绑定 调用引用变量所属类的指定函数 静态绑定
指针->函数() 调用引用对象所属类的指定函数 动态绑定 调用指针变量所属类的指定函数 静态绑定

例如:

#include <iostream>

class Father{
public:
    virtual void sayHello() const{
        std::cout<<"Father: Hello!"<<std::endl;
    }

    void sayBye() const{
        std::cout<<"Father: Bye!"<<std::endl;
    }
};

class Son: public Father{
public:
    void sayHello() const{
        std::cout<<"Son: Hello!"<<std::endl;
    }

    void sayBye() const {
        std::cout<<"Son: Bye!"<<std::endl;
    }
};

int main(){
    Son *son= nullptr;
    // son->sayHello(); 这样是不行的,因为运行时找不到实际类型
    son->sayBye(); // Son: Hello! 因为是静态绑定,所以即使实际类型为空也没关系

    Father *person=new Son();
    person->sayHello(); //Son: Hello! 动态绑定,调用实际类型Son的sayHello()
    person->sayBye(); //Father: Bye! 静态绑定,调用指针基类型,也就是Father的sayBye()
}

可见,静态绑定如果没有处理好会造成意外的结果:实际为Son的对象说出了Father该说的话。因此,对于静态和动态绑定,C++中有以下原则必须遵守:

  • 绝对不要重新定义继承而来的非虚(non-virtual)函数
  • 绝对不要重新定义一个继承而来的virtual函数的缺省参数值,因为缺省参数值都是静态绑定(为了执行效率),而virtual函数却是动态绑定。

另外,如果重定义继承而来的非虚函数,还会导致所有这个函数在基类中的重载版本均隐藏。这是因为派生类中有了同名函数覆盖基类的函数之后,默认便只会在当前的命名空间寻找重载函数。这样一来,只能通过显式指定基类的命名空间来调用基类的函数版本。因此第一个原则应必须遵守。

虚函数

在成员函数之前使用virtual声明,就可以定义为虚函数。虚函数不会在编译器绑定,而是在运行时,根据实际类型为对象建立虚函数表。然后每次调用对象的成员函数时查表调用,实现通过实际类型来调用函数。如下图所示:

虚函数原理

虚函数有以下特点:

  • 如果基类被定义为虚成员函数,则派生类中对其重定义的成员函数均为虚函数。如果是多重继承,则只需要最初的基类有virtual的声明即可,后代(派生类、派生类的派生类……)全部都为虚函数。
  • 类的成员函数才可以是虚函数
  • 静态成员函数不能是虚函数
  • 内联成员函数不能是虚函数
  • 构造函数不能是虚函数
  • 析构函数可以(往往)是虚函数

另外,使用虚函数还有以下需要注意的地方:

如果在派生类的构造函数的函数体中使用到虚函数,调用的实际版本仍然是基类的版本。这是因为在构造函数完成之前,类没有完成构造,虚函数的函数入口还停留在基类的版本。同理,在析构函数中调用的虚函数,往往会因为派生类已经析构,所以调用的版本会是基类的版本。

如果在虚函数中调用非虚函数,调用的会优先是派生类的新版本。事实上,虚函数中调用的所有函数都是遵循优先调用派生类版本的,实现了真正的多态。原因是,成员函数总是有一个隐藏的参数T* const this(指针常量),函数中凡是访问自身的成员,前面也都有一个this.,而这个this的静态类型和实际的类型都是派生类。因此,虚函数中调用的非虚函数也是满足多态要求的。

如果在非虚函数中调用虚函数,调用的也优先是派生类的新版本,但是,调用的非虚函数却还是基类的。则同理也有一个this参数,不过这个this参数的静态类型是基类的,而实际类型可以是派生类的。因此,如果基类定义了一个非虚函数,派生类来调用,则这个非虚函数内部调用的虚函数看实际类型,所以仍满足多态要求;而调用的非虚函数则只看this的静态类型,始终调用基类的非虚函数。这样,可以在基类中定义一个非虚函数的接口去调用另一个虚函数,然后在派生类中重定义虚函数,实现非虚接口

final和override关键字

final:如果在虚函数末尾加上final关键字,则表示这个函数是最终版本,不能被派生类重定义。

override:如果在函数末尾加上override关键字,则表示这个函数是基类中一个虚函数的重定义版本,可以协助检查错误,类似于Java中的@override注解。如果基类中没有同名、同参数的虚函数,则会报错。

例如:

struct B {
    virtual void f1(int) const ;
    virtual void f2 ();
    void f3 () ;
    virtual void f5 (int) final;

};
struct D: B {
    void f1(int) const override ; // right
    void f2(int) override ; // wrong 基类中没有f2(int), do you mean "f2()"?
    void f3 () override ; // wrong 基类f3()不是虚函数
    void f4 () override ; // wrong 基类没有f4()
    void f5 (int) ; //wrong 有final,不能重定义
}

纯虚函数和抽象类

纯虚函数定义时,只需要在函数签名后面加上=0,不能在这个类中写该函数的实现了。由于函数名本身也可以看做是函数在代码区的地址指针,因此=0表示,这个函数在此类中没有定义。此时基类中必须去重定义这个函数,给出具体的实现。类似Java中的abstract抽象函数。

如果一个类中包含至少一个纯虚函数,那么这个类就是一个抽象类。抽象类不能被实例化,抽象类的派生类必须实现抽象方法。

虚析构函数

析构函数往往应当声明为虚函数。这是因为在派生类中往往可能有自己定义的成员变量,这部分内容仅凭基类的析构函数无法释放。把析构函数做成虚函数,然后子类根据自己的需要去重定义析构函数,可以确保所有资源被释放。

值得一提的是,子类重定义虚函数的时候不需要在函数体中显式调用基类的析构函数。因为子类析构函数执行之后,会自动去调用基类的析构函数,释放基类的资源。

虚函数总结

虚函数总结

私有继承

私有继承事实上并不意味着两个类之间有is-a的关系,而仅仅是实现上想要复用代码。因此私有继承的派生类在使用时,不会被在需要的时候被默认转化为基类。基类的所有成员在派生类中都变为私有。

例如:

class Person { ... };
class Student: private Person { ... };     // inheritance is now private

void eat(const Person& p);                 // anyone can eat

void study(const Student& s);              // only students study

Person p;                                  // p is a Person
Student s;                                 // s is a Student

eat(p);                                    // fine, p is a Person

eat(s);                                    // error! a Student isn't a Person

这里的s为Student类型,因为是私有继承Person所以不能被隐式转化为Person类型代入eat函数中。

多继承

C++中支持多继承。一个类可以派生自多个基类,同时继承所有基类的成员。

菱形继承。一个基类先派生出两个子类,然后另一个类同时继承这两个子类,就构成了菱形继承。如果没有特殊处理,则会出现最终的派生类中有两个基类副本,造成数据不一致的情况,如下图所示:

多继承1

这个时候可以通过声明虚基类的方法进行修正:

多继承2

声明虚基类继承的时候virtual和public关键字顺序无所谓。

可见,虚基类的方式可以让派生类使用指针指向共同的一个基类版本,就不会出现两个副本的情况了。

两点注意:

  • 虚基类的构造函数由最新派生出的类的构造函数调用
  • 虚基类的构造函数优先非虚基类的构造函数执行