第十三章 - 类继承

一,类继承

1.1,C函数库的缺点

除非厂商提供了库函数的源码,否则无法根据自己的需求对库函数进行修改。

1.2,使用类继承带来的优点

  • 面向对象编程的主要目的之一是提供可重用的代码,类继承提供了比修改源码更好的方法,不需要访问源码就可以派生出类。尤其是当项目比较庞大时,重用经过测试的代码比重新编写代码要好的多。
  • 使用继承与多态机制,可以很方便的对系统的功能进行扩展。

1.3,通过继承可以完成的工作

  • 可以在已有类的基础上添加新的功能。
  • 可以给类添加数据。
  • 可以修改类方法的行为。

1.4,如何实现继承?

自定义一个基类Person,定义一个派生类Student

class Person{
private:
    string name;
public:
    Person():name("default"){
        cout<<"Person default constructor."<<endl;
    }
    Person(const string &name):name(name){
        cout<<"Person constructor."<<endl;
    }
    void show(){
     cout<<"name: "<<name<<endl;
   }
};
class Student : public Person{
private:
    string number;
public:
    Student():number("000000"){
        cout<<"Student default constructor."<<endl;
    }
    Student(const string &name, const string &number):Person(name), number(number){
        cout<<"Student constructor."<<endl;
    }
    void show(){
     Person::show();
     cout<<"number: "<<number<<endl;
   }
};

1.5,派生类对象具有的特征

  • 派生类对象包含一个基类对象实例。
  • 派生类对象可以使用基类的公有的方法。

注意:

在派生类方法中调用基类的方法必须使用作用域解析运算符,如果代码中没有使用作用域解析运算符,编译器将认为show()是Student::show(),这将创建一个不会终止的递归调用。

1.6,派生类中调用基类的构造函数

在派生类构造函数中没有显示调用基类的构造函数,创建派生类对象时,将使用基类的默认构造函数。

Student():number("000000"){
        cout<<"Student default constructor."<<endl;
    }

在派生类构造函数中显示调用基类的构造函数。

Student(const string &name, const string &number):Person(name), number(number){
        cout<<"Student constructor."<<endl;
    }

创建派生类对象时,首先创建基类对象。派生类构造函数通过成员初始化列表调用基类的构造函数,然后再初始化派生类新增的数据成员。

1.7,派生类与基类之间的特殊关系

  1. 派生类对象可以使用基类的非私有方法。
  2. 基类指针或引用在不进行显示转换的情况下指向派生类对象,但基类指针或引用只能调用基类方法。
  3. 派生类对象是一个特殊的基类对象,任何使用基类对象的地方都可使用派生类对象替换。

1.8,访问控制

  • public:基类的public与protected属性在派生类中不变。
  • private:基类的public与protected属性在派生类中变成private。
  • protected:基类的public与protected属性在派生类中变成protected。
  • private与protected之间的区别只在基类派生的类中才会体现出来。派生类的成员可以直接访问基类的保护成员,但不能访问基类的私有成员。

二,静态编联与动态编联

2.1,什么是静态编联与动态编联?

将源代码中的函数调用解释为执行特定的函数代码块被称为函数编联。在C语言中这很简单,因为每个函数名都对应一个函数。在C++中,由于函数重载的原因,这项任务更复杂。编译器必须查看函数名与函数参数才能决定调用哪个函数。C/C++编译器可以在编译过程完成这种编联。在编译过程中进行编联称为静态编联(static binding),又称为早期编联。然而,C++中的虚函数使这项工作变的更加的复杂。使用虚函数时,使用哪一个函数不是在编译器确定的,因为编译器不知道用户选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态编联(dynamic binding),又称为晚期编联。

2.2,指针与引用类型的兼容性

通常C++不允许将一种类型的地址赋给另一种类型的指针或引用,下面的做法编译时都会出错:

int num = 10;
double *p = &num;
double *rnum = num;

但是指向基类的指针或引用可以指向派生类对象,而不必进行显示类型转换

Person *p;
Student s("student", "11041722");
p = &s;

2.2.1,向上类型转换

把一个派生类指针,转换成基类指针,称为向上类型转换。向上类型转换是安全的,也不需要进行强制类型转换。因为在派生类对象中,包含一个基类对象实例,向上类型转化可以理解为,把指向派生类对象的指针转换成指向派生类对象中包含的基类对象。

class Base{
private:
    int b;
public:
    Base(int b){
        this->b = b;
    }
    virtual void test(){
        cout<<"Base test."<<endl;
    }
};

class Derived : public Base{
private:
    int d;
public:
    Derived(int b, int d):Base::Base(b){
        this->d = d;
    }
    virtual void test(){
        cout<<"Derived test."<<endl;
    }
};

int main(){
    Derived derivedObj(10, 100);
    Base baseObj = derivedObj;  //直接赋值
    baseObj.test();

    Derived *pDerivedObj = new Derived(10, 100);
    Base *pBaseObj = pDerivedObj;  //使用引用赋值
    pBaseObj->test();
}

输出结果

Base test.
Derived test.

Process returned 0 (0x0)   execution time : 0.014 s
Press any key to continue.

在向上强制转换过程中,使用指针和引用不会造成切割,而使用直接赋值会造成切割。

2.2.2,向下类型转换

把一个基类指针转换成派生类指针,称为向下类型转换。向下类型转换存在安全隐患,必须进行强制类型转换,因为在派生类对象中可能会包含基类对象没有的成员函数或数据成员。如果指向基类对象的指针实际指向的是派生类对象,则向下类型转换可以成功;否则,不能成功。

class Base{
private:
    int b;
public:
    Base(int b){
        this->b = b;
    }
    virtual void test(){
        cout<<"Base test."<<endl;
    }
};

class Derived : public Base{
private:
    int d;
public:
    Derived(int b, int d):Base::Base(b){
        this->d = d;
    }
    virtual void test(){
        cout<<"Derived test."<<endl;
    }
};

int main(){
    Base *pBaseObj = new Derived(10, 100);  //基类对象指针实际指向的是一个派生类对象
    Derived *pDerivedObj = dynamic_cast<Derived*>(pBaseObj);
    if(pDerivedObj){
        pDerivedObj->test();
    } else {
        cout<<"std::bad_cast"<<endl;
    }

    Base *pBase = new Base(10);  //基类对象指针指向的是一个基类对象
    Derived *pDerived = dynamic_cast<Derived*>(pBase);
    if(pDerived){
        pDerived->test();
    } else {
        cout<<"std::bad_cast"<<endl;
    }
}

输出结果

Derived test.
std::bad_cast

Process returned 0 (0x0)   execution time : 0.009 s
Press any key to continue.

如果基类指针实际指向的是派生类对象,则向下类型转换可以成功;如果基类指针实际指向的是基类对象,则向下类型转换不能成功。

2.3,虚成员函数与动态编联

Person *p;
Student s("student", "11041722");
p = &s;
p->show();

如果在基类中没有把show()函数声明为虚函数,则p->show()将根据指针类型(Person*)调用Person::show()。指针类型在编译时已知,因此编译器在编译时,可以将show()关联到Person::show()。总之,编译器对非虚函数使用静态编联。如果在基类中把show()函数声明为虚函数,则p->show()将根据对象的类型(Student)调用Student::show()。通常,只有在运行程序时才能确定对象的类型。所以编译器生成的代码将在程序执行时,根据对象类型将show()关联到Person::show()或Student::show()。总之,编译器对虚函数使用动态编联。

2.4,为什么有两种类型的编联以及为什么默认为静态编联?

动态编联让我们可以重新定义类方法,而静态编联在这方面很差,为什么不摒弃静态编联,原因有两个方面,一是效率,另一是概念模型。

效率

为使程序在运行阶段进行决策,必须采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销。例如,如果一个类不作为基类,则不需要使用动态编联。同样,如果派生类不重新定义基类的任何方法,也不需要使用动态编联。在这些情况下应使用静态编联,静态编联更合理,效率也更高。由于静态编联的效率高,因此被设置为C++的默认选择。C++的指导原则之一是,不要为不使用的特性付出代价。仅当程序确实需要虚函数时,才使用它们。

概念模型

在设计类时,可能包含一些不在派生类重新定义的成员函数。那么,这些函数不应定义为虚函数,有两方面的好处:一是效率更高,二是指出不要重新定义该函数。这表明,仅将那些预期将被重定义的函数声明为虚函数。

三,多态

3.1,什么是多态?

多态(polymorphism)的字面意思是多种表现形式,多态性可以简单地概括为”一个接口,多种方法”,程序在运行时才决定调用的函数,换句话说,方法的行为应取决于调用方法的对象,它是面向对象编程领域的核心概念。多态的目的是为了实现接口重用,也就是说,不论传递过来的究竟是那个类的对象,函数都能够通过同一个接口调用到对应于各自对象的实现方法。

3.2,分不清对象类型的问题

class Person{
protected:
    string name;
public:
    Person():name("default"){
    }
    Person(const string &name):name(name){
    }
    void show(){
        cout<<"name: "<<name<<endl;
    }
};
class Student : public Person{
protected:
    string number;
public:
    Student():number("000000"){
    }
    Student(const string &name, const string &number):Person(name), number(number){
    }
    void show(){
        cout<<"name: "<<name<<endl;
        cout<<"number: "<<number<<endl;
    }
};
void test(Person &p){
    p.show();
}
int main(){
    Person p("person");
    Student s("student", "11041722");
    test(p);
    test(s);
    return 0;
}

输出结果

name: person
name: student

程序分析

对象p与s分别是基类和派生类的对象,而函数test的形参是Person类的引用。按照类继承的特点,编译器把Student类对象看做是一个Person类对象。我们想利用test函数达到的目的是,传递不同类对象的引用,分别调用不同类的重载了的show成员函数,但是程序的运行结果却出乎人们的意料,编译器分不清传进来的是基类还是派生类对象,无论是基类对象还是派生类对象调用的都是基类的show成员函数。

3.3,使用多态解决上面的问题

为了要解决上述不能正确分辨对象类型的问题,c++提供了一种叫做多态性(polymorphism)的技术来解决问题,对于上面的程序,这种能够在编译时就能够确定哪个重载的成员函数被调用的情况被称做静态联编,而在系统能够在运行时,能够根据其类型确定调用哪个重载的成员函数的能力,称为多态性或叫动态联编。下面我们使用多态技术解决上面的问题,动态联编正是解决多态问题的方法。 把基类中的show成员函数声明为虚函数:

class Person{
protected:
    string name;
public:
    Person():name("default"){
    }
    Person(const string &name):name(name){
    }
    virtual void show(){
        cout<<"name: "<<name<<endl;
    }
};
class Student : public Person{
protected:
    string number;
public:
    Student():number("000000"){
    }
    Student(const string &name, const string &number):Person(name), number(number){
    }
    virtual void show(){
        cout<<"name: "<<name<<endl;
        cout<<"number: "<<number<<endl;
    }
};
void test(Person &p){
    p.show();
}
int main(){
    Person p("person");
    Student s("student", "11041722");
    test(p);
    test(s);
    return 0;
}

输出结果

name: person
name: student
number: 11041722

3.4,虚函数

虚函数是实现多态的重要机制

3.4.1,声明与定义虚函数

class Person{
protected:
    string name;
public:
    Person():name("default"){}
    Person(const string &name):name(name){}
    virtual void show(){
        cout<<"name: "<<name<<endl<<endl;
    }
};
class Student : public Person{
protected:
    string number;
public:
    Student():number("000000"){}
    Student(const string &name, const string &number):Person(name), number(number){}
    virtual void show(){
        cout<<"name: "<<name<<endl;
        cout<<"number: "<<number<<endl<<endl;
    }
};
class Worker : public Person{
private:
    string job;
public:
    Worker():job("worker"){}
    Worker(const string &name, const string &job):Person(name), job(job){}
    virtual void show(){
        cout<<"name: "<<name<<endl;
        cout<<"job: "<<job<<endl<<endl;
    }
};
void test(Person &p){
    p.show();
}
int main(){
    Person p("person");
    Student s("student", "11041722");
    Worker w("worker", "programmer");
    p.show();
    s.show();
    w.show();
    return 0;
}

3.4.2,虚函数的特点

  1. 如果方法是通过引用或指针而不是对象调用的,它将确定使用哪一种方法。如果没有使用关键字virtual,程序将根据引用类型或指针类型选择方法。如果使用了关键字virtual,程序将根据指针或引用实际指向的对象的类型来选择方法。
  2. 在基类的方法声明中使用关键字virtual可使该方法在基类以及所有的派生类中是虚的。

注意

关键字virtual只用于类声明的方法原型中,而没有用于方法的定义中。

3.5,虚函数的工作原理

通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table)。虚函数表中存储了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址。如果派生类没有重新定义虚函数,该虚函数表将保存基类中同名虚函数的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到虚函数表中。调用虚函数时,程序将通过对象中的vptr指针,找到虚函数表,然后在虚函数表中查找要调用的函数的地址。

3.6,虚函数带来的额外开销

使用虚函数时,在内存与执行速度方面都有一定的开销。虽然非虚函数的效率比虚函数高,但是不具有动态编联功能。

  1. 每个对象都将增大,增大量为存储隐藏成员(是一个指针)的空间。
  2. 对于每一个类,编译器都将创建一个虚函数表。
  3. 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。

3.7,使用虚函数应注意的问题

  1. 构造函数不能是虚函数。创建派生类对象时,将调用派生类的构造函数,派生类的构造函数将调用基类的构造函数,这种顺序不同于继承机制。因此,派生类不继承基类的构造函数,所以将类的构造函数声明为虚的没什么意义。
  2. 析构函数应当是虚函数,除非类不用作基类。
  3. 友元不能是虚函数,友元不是类成员,而只有成员函数才能是虚函数。
  4. 重新定义将隐藏方法。重新定义继承的方法并不是重载。如果重新定义派生类中的函数,无论参数列表是否相同,该操作将隐藏所有的同名基类方法。
class Person{
protected:
    string name;
public:
    virtual void show(){
        cout<<"name: "<<name<<endl;
    }
};
class Student : public Person{
protected:
    string number;
public:
    virtual void show(int num){
        cout<<"name: "<<name<<endl;
        cout<<"number: "<<number<<endl;
    }
};

重新定义覆盖基类的所有版本

Student s("student", "11041722");
s.show(1);  //valid
s.show();  //invalid

3.8,继承时的虚函数表的样子

3.8.1,一般继承(无虚函数覆盖)

下面是一个类的继承图:

这里写图片描述

Derive d的虚函数表如下:

这里写图片描述

可以看到下面两点:

  1. 虚函数按照其声明顺序放于表中。
  2. 父类的虚函数在子类的虚函数前面。

3.8.2,一般继承(有虚函数覆盖)

覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。

这里写图片描述

覆盖了父类的一个函数,那么,对于派生类的实例,其虚函数表如下:

这里写图片描述

可以看到下面两点:

  1. 覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
  2. 没有被覆盖的函数依旧。

对于下面这段程序

Base *b = new Derive();
b->f();

由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了,这就实现了多态。

3.8.3,多重继承(无虚函数覆盖)

下面是类的继承图

这里写图片描述

派生类虚函数表的结构

这里写图片描述

可以看到下面两点:

  1. 每个父类都有自己的虚表。
  2. 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。

3.8.4,多重继承(有虚函数覆盖)

下面是类的继承图

这里写图片描述

派生类虚函数表的结构

这里写图片描述

三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,就可以任一静态类型的父类来指向子类,并调用子类的f()了。如:

Derive d;

Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;

b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()

b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()

猜你喜欢

转载自blog.csdn.net/cloud323/article/details/81043816
今日推荐