C++初阶(封装+多态–整理的自认为很详细)

继承

概念:继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用

语法:

//基类(父类)
class Base
{
private:
    int m1;
    int m2;
}
//派生类
class Son:public Base
{
private:
    int v3;
    int v4;
}

继承方式

访问限定符:

  • public访问
  • protected访问
  • private访问

1.公有继承

  • 父类的公有属性和成员,到子类还是公有
  • 父类的私有属性和成员,到子类还是私有,但是子类成员不可以访问这些属性和成员
  • 父类的保护属性和成员,到子类还是保护

2.保护继承

  • 父类的公有属性和成员,到子类是保护
  • 父类的私有属性和成员,到子类还是私有,但是子类成员不能访问这些属性和成员
  • 父类的保护属性和成员,到子类还是保护

3.私有继承

  • 父类的公有属性和成员,到子类是私有
  • 父类的私有属性和成员,到子类还是私有,但是子类成员不能访问这些属性和成员
  • 父类的保护属性和成员,到子类是私有

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public成员 派生类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 派生类中不可见 派生类中不可见 派生类中不可见

总结:

  • 基类的private成员在派生类中都是不可见的,这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
  • 基类成员在父类中的访问方式=min(成员在基类的访问限定符,继承方式),public>protected>private。
  • 一般会把基类中不想让类外访问的成员设置为protecd成员,不让类外访问,但是让派生类可以访问。

基类和派生类对象之间的赋值转换

派生类对象会通过 “切片”“切割” 的方式赋值给基类的对象、指针或引用。但是基类对象不能赋值给派生类对象。

注意:

  • 从父类继承过来的成员变量,本质还是原来父类的成员变量,两个变量是一个地址
  • 如果子类和父类出现两份一模一样的成员变量,要访问父类中的变量,必须使用作用域分辨符,如果不想使用作用域分辨符,对这个名字修改默认修改的就是子类的成员变量,不想使用作用域分辨符,那就在设计类的时候让两个名字不要冲突
  • 记住,成员变量只要父子不同名,那么用的都是同一块地址,子类中的那个成员变量就是父类的,但是如果子类和父类成员变量重名,那么就会隐藏掉父类的成员变量,除非用::访问

C++初阶(封装+多态–整理的自认为很详细)插图

class Person
{
public:
    Person(const char* name = "")
        :_name(name)
    {}
    void Print()
    {
        cout Print();
    p3.Print();

    // 基类的指针可以通过强制类型转换赋值给派生类的指针
    Student* ps = (Student*)p2;

    ps->Print();

    return 0;
}

运行结果如下:
C++初阶(封装+多态–整理的自认为很详细)插图1
总结:

  • 派生类对象可以“切片”或“切割”的方式赋值给基类的对象,基类的指针或基类的引用,就是把基类的那部分切割下来。
  • 基类对象不能给派生类对象赋值。
  • 基类的指针可以通过强制类型转换赋值给派生类的指针。但必须是基类的指针指向派生类的对象才是安全的,因为如果基类是多态类型,可以使用RTTI来进行识别后进行安全转换。
  • 子类对象可以赋值给父类的对象、指针和引用,会将子类对象多出的部分进行分割或切片处理。

继承中的作用域

在继承体系中,基类和派生类对象都有独立的作用域,子类中的成员(成员变量和成员函数)会对父类的同名成员进行隐藏,也叫重定义。

class Father
{
public:
    Father()
    {
        a = 10;
    }
    void func()
    {
        cout 

运行结果如下:
C++初阶(封装+多态–整理的自认为很详细)插图2
得出结论: 子类中的成员(成员变量和成员函数)会对父类的同名成员进行隐藏,如果相要访问父类的同名成员,必须指定类域访问。

子类的内存布局

例子:

#include 

class Father
{
public:
    Father()
    {
        std::cout 

结果:

I am father,this is 0xffffcc14
I am Mother,this is 0xffffcc18
I am Son,this is 0xffffcc14

传入 Fahter::func_father() 的 this 指针是 0xffffcc14
传入 Mother::func_mother() 的 this 指针是 0xffffcc18
传入 Son::func_Son() 的 this 指针是 0xffffcc14

解释:

子类的内存布局如下图所示
C++初阶(封装+多态–整理的自认为很详细)插图3
由于“Son”继承顺序是“Father”、“Mother”,所以内存布局中 Father 类排布在起始位置,之后是 Mother 类,最后才是 Son 类自身的变量(当然,初始化顺序也是 Father 、Mother,最后才是 Son )。

最后还有一个问题,为什么 Son 的对象调用可以调用 Father 和 Mother 类的函数呢?

因为编译器在调用 Father 和 Mother 的函数时,调整了传入到 Func_Father 和 Func_Mother 函数的 this 指针,使 this 指针是各个函数所在类的对象的 this 指针,从而达到了调用各个类的函数的目的。
换句话说,每个子类对象都有自己的this指针,在自己的内存空间中先初始化父类的成员变量,最后初始化自己的成员变量,如果有重名的成员变量依旧会往下排,但是调用的时候默认调用子类的成员变量,并且在子类的内存空间中会为每个基类对象分配一个指针,这样保证了我们在调用基类的成员函数的时候,传入的this指针不同。

继承中的构造和析构

  • 先调用父类的构造,然后调用成员对象的构造,最后调用本身的构造
  • 先调用本身的析构,再调用成员函数的析构,最后调用父类的析构
  • 本质就是个入栈的问题,先入栈的后析构

派生类的默认成员函数

C++中的每个对象中会有6个默认成员函数。默认的意思就是我们不写,编译器会生成一个。那么在继承中,子类的默认成员函数是怎么生成的呢?

class Person
{
public:
    Person(const char* name = "", int age = 1) :_name(name), _age(age)
    {
        cout _name = p._name;
        this->_age = p._age;
        return *this;
    }
    void Print()
    {
        cout _name _age 

测试1:构造函数和析构函数

void test1()
{
    Student s("小明", 18, 10);
    s.Print();
}

运行结果如下:
C++初阶(封装+多态–整理的自认为很详细)插图4
总结1: 子类的构造函数必须调用基类的构造函数初始化基类的那一部分成员,如果基类没有默认构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。子类的析构函数会在被调用完成后自动调用基类的析构函数清理基类的成员。不需要显示调用。这里子类和父类的析构函数的函数名会被编译器处理成destructor,这样两个函数构成隐藏。
测试2:拷贝构造函数

void test2()
{
    Student s1("小明", 18, 10);
    Student s2(s1);
}

运行结果如下:
C++初阶(封装+多态–整理的自认为很详细)插图5
总结2: 子类的拷贝构造必须代用父类的拷贝构造完成父类成员的拷贝(自己手动调用)

测试3:operator=

void test3()
{
    Student s1("小明", 18, 10);
    Student s2("小花",19,20);
    s1 = s2;
}

运行结果如下:
C++初阶(封装+多态–整理的自认为很详细)插图6
结论3: 子类的operator=必须调用基类的operator完成基类的赋值。

思考:如何设计一个不能被继承的类

把该类的构造函数设为私有。如果基类的构造函数是私有,那么派生类不能调用基类的构造函数完成基类成员的初始化,则无法进行构造。所以这样设计的类不可以被继承。(后面还会将加上final关键字的类也不可以被继承)

总结:

  • 子类的构造函数必须调用基类的构造函数初始化基类的那一部分成员,如果基类没有默认构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  • 子类的拷贝构造必须代用父类的拷贝构造完成父类成员的拷贝。
  • 子类的operator=必须调用基类的operator完成基类的赋值。
  • 子类的析构函数会在被调用完成后自动调用基类的析构函数清理基类的成员。不需要显示调用。
  • 子类对象会先调用父类的构造再调用子类的构造。
  • 子类对象会先析构子类的析构再调用父类的析构。

继承和友元

友元关系不能被继承。也就是说基类的友元不能够访问子类的私有和保护成员。

继承和静态成员

基类定义的static静态成员,存在于整个类中,不属于某个类,无论右多少个派生类,都这有一个static成员

class Person
{
public:
    Person()
    {
        ++_count;
    }
    // static成员存在于整个类  无论实例化出多少对象,都只有一个static成员实例
    static int _count;
};

int Person::_count = 0;

class Student :public Person
{
public:
    int _stuid;
};

int main()
{
    Student s1;
    Student s2;
    Student s3;

    // Student()._count = 10;
    cout 
  • 继承中的静态成员变量一样会被同名的子类成员变量隐藏
  • 继承中的静态成员函数中,当子类有和父类同名静态函数的时候,父类的所有同名重载静态函数都会被隐藏
  • 改变从基类继承过来的静态函数的某个特征值、返回值或者参数个数,将会隐藏基类重载的函数
  • static成员存在于整个类 无论实例化出多少对象,都只有一个static成员实例

单继承和多继承

单继承:一个子类只有一个直接父类的时候称这个继承关系为单继承
C++初阶(封装+多态–整理的自认为很详细)插图7
多继承:一个子类有两个或以上的直接父类的时候称这个继承关系为多继承

  • 多继承的问题是,当父类有同名成员的时候,子类会产生二义性,不建议使用多继承

C++初阶(封装+多态–整理的自认为很详细)插图8
菱形继承:多继承的一种特殊情况
C++初阶(封装+多态–整理的自认为很详细)插图9

虚拟继承

概念:为了解决菱形继承带来的数据冗余和二义性的问题,C++提出来虚拟继承这个概念。虚拟继承可以解决前面的问题,在继承方式前加一个virtual的关键字即可。

简单原理理解

class Person
{
public:
    string _name;
};
// 不要在其他地方去使用。
class Student : virtual public Person
{
public:
    int _num; //学号
};
class Teacher : virtual public Person
{
public:
    int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
    string _majorCourse; // 主修课程
};

虚拟继承的原理

class A
{
public:
    int _a;
};

class B :virtual public A
{
public:
    int _b;
};

class C :virtual public A
{
public:
    int _c;
};

class D : public B, public C
{
public:
    int _d;
};
int main()
{
    D d;
    d.B::_a = 1;
    d.C::_a = 2;
    d._b = 4;
    d._c = 5;
    d._d = 6;

    return 0;
}

我们通过内存窗口查看它的对象模型:
C++初阶(封装+多态–整理的自认为很详细)插图10
原理: 从上图可以看出,A对象同时属于B和C,B和C中分别存放了一个指针,这个指针叫虚基表指针,分别指向的两张表,叫虚基表,虚基表中存的是偏移量,B和C通过偏移量就可以找到公共空间(存放A对象的位置)。
C++初阶(封装+多态–整理的自认为很详细)插图11

复杂原理理解

查看普通多继承的子类内存分布

class A
{
public:
    int _a;
};

class B : public A
{
public:
    int _b;
};

class C :public A
{
public:
    int _c;
};

class D : public B, public C
{
public:
    int _d;
};

子类D的内存布局如下:
C++初阶(封装+多态–整理的自认为很详细)插图12
从子类D的内存布局可以看到,会先初始化父类的成员变量,最后初始化自己的成员变量,B和C中都包含A的成员_a,此时D包含了B和C的成员,这样D中总共出现了两个A的成员,这样就会造成二义性,子类D在调用成员变量A的时候,不知道调用哪一个,除非加上::,于是引入虚拟继承,解决了这个问题,接下来我们看看虚拟继承中D内存的分布情况。

class A
{
public:
    int _a;
};

class B :virtual public A
{
public:
    int _b;
};

class C :virtual public A
{
public:
    int _c;
};

class D : public B, public C
{
public:
    int _d;
};

子类D的内存布局如下:
C++初阶(封装+多态–整理的自认为很详细)插图13
从代码中我们可以知道B和C在继承A的时候使用了virtual关键字,也就是虚拟继承。

可以看到,虚拟继承的子类在内存缝补上和普通的菱形继承的子类有很大的区别。类B和C中多了一个vbptr指针,并且A类的_a也不再存在两份,而是只存在一份。

那么类D对象的内存布局就变成如下的样子:

vbptr:继承自父类B中的指针
int _b:继承自父类B的成员变量
vbptr:继承自父类C的指针
int _c:继承自父类C的成员变量
int _d:D自己的成员变量
int _a:继承父类A的成员变量

显然,虚继承之所以能够实现在多重派生子类中只保存一份共有基类的拷贝,关键在于vbptr指针。那vbptr到底指的是什么?又是如何实现虚继承的呢?其实上面的类D内存布局图中已经给出答案:
C++初阶(封装+多态–整理的自认为很详细)插图14
实际上,vbptr指的是虚基类表指针,该指针指向了一个虚表,虚表中记录了vbptr与本类的偏移地址;第二项是vbptr到共有基类元素之间的偏移量。在这个例子中,类B中的vbptr指向了虚表D::$vbtable@B@,虚表表明公共基类A的成员变量距离类B开始处的位移为20,这样就找到了成员变量_a,而虚继承也不用像普通多继承那样维持着公共基类的两份同样的拷贝,节省了存储空间。

多态

概念: 从字面意思来看,就是事物的多种形态。用C++的语言说就是不同的对象去完成同一个行为会产生不同的效果

多态发生的三个条件:

多态是在不同继承关系的类对象,去调用同一个函数,产生了不同的行为

  • 有继承
  • 被调用的函数必须是虚函数,其派生类必须重写基类的虚函数
  • 必须有基类的指针或者引用调用

虚函数

virtual关键字修饰的类成员函数叫做虚函数。

class Person
{
public:
    // 虚函数
    virtual void BuyTicket()
    {
        cout 

虚函数重写是什么?

虚函数的重写(覆盖): 派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。(重写是对函数体进行重写)

class Person
{
public:
    virtual void BuyTicket()
    {
        cout 

虚函数重写的两个例外:

1.协变:基类和派生类的虚函数的返回类型不同。
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(也就是基类虚函数的返回类型和派生类的虚函数的返回类型是父子类型的指针或引用)

// 协变  返回值类型不同,但它们之间是父子或父父关系  返回类型是指针或者引用
// 基类虚函数   返回类型  是  基类的指针或者引用  
// 派生类虚函数 返回类型  是  基类或派生类的返回类型是基类的指针或引用

class A {};
class B : public A {};
class Person {
public:
    virtual A* f() { return new A; }
};
class Student : public Person {
public:
    virtual A* f() { return new B; }
};

2.析构函数的重写 :基类与派生类的析构函数的函数名不同
基类和派生类的析构函数的函数名会被编译器统一处理成destructor,所以只要基类的析构函数加了关键字virtual,就会和派生类的析构函数构成重写。

实例演示:

class Person
{
public:
    virtual void BuyTicket()
    {
        cout BuyTicket(); }
void Func3(Person p) { p.BuyTicket(); }

int main()
{
    Person p;
    Student s;

    // 满足多态的条件:与类型无关,父类指针指向的是谁就调用谁的成员函数
    // 不满足多态的条件:与类型有关,类型是谁就调用谁的成员函数
    cout 

运行结果如图:
C++初阶(封装+多态–整理的自认为很详细)插图15
思考:析构函数是否需要加上virtual?答案是需要的。

例子:

class Person
{
public:
    /*virtual*/ ~Person()
    {
        cout 

析构函数不加上virtual的运行结果:
C++初阶(封装+多态–整理的自认为很详细)插图16
析构函数加上virtual的运行结果:

C++初阶(封装+多态–整理的自认为很详细)插图17

可以看出,不加virtual关键字时,第二个对象delete时没有调用子类的析构函数清理释放空间。为什么呢?因为不加virtual关键字时,两个析构函数不构成多态,所以调用析构函数时是与类型有关的,因为都是都是父类类型,所以只会调用父类的析构函数。加了virtual关键字时,因为两个析构函数被编译器处理成同名函数了,所以完成了虚函数的重写,且是父类指针调用,所以此时两个析构函数构成多态,所以调用析构函数时是与类型无关的,因为父类指针指向的是子类对象,所以会调用子类的析构函数,子类调用完自己的析构函数又会自动调用父类的析构函数来完成对父类资源的清理。
所以总的来看,基类的析构函数是要加virtual的。

区分一下下面几个概念:

名称 作用域 函数名 其他
重载 两个函数在同一作用域 相同 参数类型不同
重写 两个函数分别再基类和派生类的作用域 相同 函数返回类型和参数类型一样
重定义(隐藏) 两个函数分别再基类和派生类的作用域 相同 两个基类和派生类的同名函数不是构成重写就是重定义

override和final

final: 修饰虚函数,表示该虚函数不可以被重写(还可以修饰类,表示该类不可以被继承)

overide:如果派生类在虚函数声明时使用了override描述符,那么该函数必须重载其基类中的同名函数,否则代码将无法通过编译

抽象类

概念: 在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化象纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承

总结出几个特点:

  1. 虚函数后面加上=0
  2. 不能实例化出对象
  3. 派生类如果不重写基类的纯虚函数那么它也是抽象类,不能实例化出对象
  4. 抽象类严格限制派生类必须重写基类的纯虚函数
  5. 体现了接口继承
class Car
{
public:
    virtual void Drive() = 0;
};
class Benz : public Car
{
public:
    virtual void Drive()
    {
        cout Drive();

    Car* pBMW = new BMW;
    pBMW->Drive();

    delete pBenZ;
    delete pBMW;
    return 0;
}

运行结果:

Benz

BMW

抽象类的意义?

  1. 强制子类完成父类虚函数的重写
  2. 表示该类是抽象类,没有实体(例如:花、车和人等)

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

多态的原理

概念: 一个含有虚函数的类中至少有一个虚函数指针,这个指针指向了一张表——虚函数表(简称虚表),这张表中存放了这个类中所有的虚函数的地址。

class Animal
{
public:
    virtual void speak()
    {
        cout 

C++初阶(封装+多态–整理的自认为很详细)插图18
C++初阶(封装+多态–整理的自认为很详细)插图19
当编译器中发现有虚函数的时候,会创建一张虚函数的表,里面保存了所有的虚函数,但是这个虚函数表不属于类,虚函数指针才是属于类的,这个虚函数指针指向了虚函数表的入口地址,而在类中会存在这个虚函数指针,这个虚函数表中包含了所有的虚函数,Animal::$vftable@就是Animal类的虚函数表,里面放了Animal::speak函数的首地址。
C++初阶(封装+多态–整理的自认为很详细)插图20
C++初阶(封装+多态–整理的自认为很详细)插图21
当子类继承父类的时候,父类的虚函数表会拷贝一份,这个拷贝的虚函数表是子类独有的虚函数表,而不是父类的虚函数表,但是该表中的虚函数地址还是父类的,因为是从父类那里拷贝过来的,子类的虚函数指针会指向自己的虚函数表C++初阶(封装+多态–整理的自认为很详细)插图22

当子类重写了父类的虚函数,那么子类的重写的虚函数的地址就会把从父类那里拷贝过来的地址覆盖掉,换成自己的地址。

class Animal
{
public:
    virtual void speak()
    {

    };
};
class Dog : public Animal
{

};

如果子类不重写父类的虚函数,可以再看看内存的存储情况,子类的虚函数表(Dog::$vftable@)中还是存储的是父类的虚函数地址
C++初阶(封装+多态–整理的自认为很详细)插图23
总结几点:

  • 子类对象由两部分构成,一部分是父类继承下来的成员,虚表指针指向的虚表有父类的虚函数,也有子类新增的虚函数
  • 子类完成父类虚函数的重写其实是对继承下来的虚表的中重写了的虚函数进行覆盖,把地址更换了,语法层是称为覆盖
  • 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr
  • 虚表生成的过程:先将基类中的虚表内容拷贝一份到派生类虚表中,如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数,派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后

下面我们来讨论一下虚表存放的位置和虚表指针存放的位置

虚表指针肯定是存在类中的,从上面的类对象模型中可以看出。其次虚表存放的是虚函数的地址,这些虚函数和普通函数一样,都会被编译器编译成指令,然后放进代码段。虚表也是存在代码段的,因为同类型的对象共用一张虚表

原理

多态是在运行时到指向的对象中的虚表中查找要调用的虚函数的地址,然后进行调用

为什么要实现多态必须是父类的指针或引用,不可以是父类对象?

子类对象给父类对象赋值时,会调用父类的拷贝构造对父类的成员变量进行拷贝构造,但是虚表指针不会参与切片,这样父类对象无法找到子类的虚表,所以父类对象不能够调用子类的虚函数。但是子类对象给父类的指针或引用赋值时,是让父类的指针指向父类的那一部分或引用父类的那一部分,这样父类还是可以拿到子类的虚表指针,通过虚表指针找到子类的虚表,从而可以调用虚表中的虚函数。

总结:

  1. 多态满足的两个条件:一个是虚函数的覆盖,一个是对象的指针和引用调用
  2. 满足多态后,函数的调用不是编译时确认的,而是在运行时确认的。

单继承的虚表

class Base
{
public:
    virtual void func1() { cout 

观察它的对象模型:
C++初阶(封装+多态–整理的自认为很详细)插图24

多继承的虚表

class Base1 
{
public:
    virtual void func1() { cout 

观察它的对象模型:
C++初阶(封装+多态–整理的自认为很详细)插图25
可以看到子类会继承父类的两个指针和两个虚函数的表,而且虚表Base2中的func1函数很奇怪,有一个goto指令,这是因为func1函数的地址和虚表2中func1函数其实不是func1函数的真实地址。这两个地址都分别指向一个jump指令,两个地址中的jump指令最终都会跳转到同一个func1中。

几个值得思考的问题

1.内联函数可以是虚函数吗?
答:可以,但是编译器会忽略inline属性(inline只是一种建议),因 为内联(inline)函数没有地址,且虚函数要把地址放到虚表中去。
2.构造函数可以是虚函数吗?
答:不可以,因为对象中虚函数指针是在构造函数初始化列表阶段才初始化的。
3.析构函数可以是虚函数吗?
答:可以,且建议设计成虚函数,具体原因前面说了。
4.对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
5.虚函数表是在什么阶段生成的?
答:在编译阶段生成的,存在于代码段。
6.静态成员可以是虚函数吗?
答:不可以。因为静态成员没有this指针,使用类域(::)访问成员函数的调用方式无法访问到虚表,所以静态成员函数无法放进虚表。

文章来源于互联网:C++初阶(封装+多态--整理的自认为很详细)

THE END
分享
二维码