C++ 面向对象

330 阅读24分钟

1 什么是面向对象

1.1 描述一下面向对象

面向过程编程:一种以执行程序操作的过程或函数为中心编写软件的方法。程序的数据通常存储在变量中,与这些过程是分开的。所以必须将变量传递给需要使用它们的函数。缺点:随着程序变得越来越复杂,程序数据与运行代码的分离可能会导致问题。例如,程序的规范经常会发生变化,从而需要更改数据的格式或数据结构的设计。当数据结构发生变化时,对数据进行操作的代码也必须更改为接受新的格式。查找需要更改的所有代码会为程序员带来额外的工作,并增加了使代码出现错误的机会。

面向对象编程(Object-Oriented Programming, OOP):以创建和使用对象为中心。一个对象(Object)就是一个软件实体,它将数据和程序在一个单元中组合起来。对象的数据项,也称为其属性,存储在成员变量中。对象执行的过程被称为其成员函数。将对象的数据和过程绑定在一起则被称为封装。

面向对象编程进一步说明: 面向对象编程将数据成员和成员函数封装到一个类中,并声明数据成员和成员函数的访问级别(public、private、protected),以便控制类对象对数据成员和函数的访问,对数据成员起到一定的保护作用。而且在类的对象调用成员函数时,只需知道成员函数的名、参数列表以及返回值类型即可,无需了解其函数的实现原理。当类内部的数据成员或者成员函数发生改变时,不影响类外部的代码。

1.2 面向对象的三大特征?

面向对象的三大特征就是封装、继承、多态

1 封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行 交互。封装本质上是一种管理:我们如何管理兵马俑呢?比如如果什么都不管,兵马俑就被随意破坏了。那么我们首先建了一座房子把兵马俑给封装起来。但是我们目的全封装起来,不让别人看。所以我们开放了售票通 道,可以买票突破封装在合理的监管机制下进去参观。类也是一样,不想给别人看到的,我们使用protected/private把成员封装起来。开放一些共有的成员函数对成员合理的访问。所以封装本质是一种管理。 2 继承:可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展

3 多态:用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。实现多态,有二种方式,重写,重载。

1.3 C++的重写、重载和隐藏

重载:是指同一可访问区内被声明几个具有不同参数列(参数的类型、个数、顺序)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。

class A
{
public:
    void fun(int tmp);
    void fun(float tmp);        // 重载 参数类型不同(相对于上一个函数)
    void fun(int tmp, float tmp1); // 重载 参数个数不同(相对于上一个函数)
    void fun(float tmp, int tmp1); // 重载 参数顺序不同(相对于上一个函数)
    int fun(int tmp);            // error: 'int A::fun(int)' cannot be overloaded 错误:注意重载不关心函数返回类型
};

隐藏:是指派生类的函数屏蔽了与其同名的基类函数,主要只要同名函数,不管参数列表是否相同,基类函数都会被隐藏。

#include <iostream>
using namespace std;

class Base
{
public:
    void fun(int tmp, float tmp1) { cout << "Base::fun(int tmp, float tmp1)" << endl; }
};

class Derive : public Base
{
public:
    void fun(int tmp) { cout << "Derive::fun(int tmp)" << endl; } // 隐藏基类中的同名函数
};

int main()
{
    Derive ex;
    ex.fun(1);       // Derive::fun(int tmp)
    ex.fun(1, 0.01); // error: candidate expects 1 argument, 2 provided
    return 0;
}

说明:上述代码中 ex.fun(1, 0.01); 出现错误,说明派生类中将基类的同名函数隐藏了。若是想调用基类中的同名函数,可以加上类型名指明 ex.Base::fun(1, 0.01);,这样就可以调用基类中的同名函数。

重写:是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同(花括号内),派生类对象调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有virtual修饰。

#include <iostream>
using namespace std;

class Base
{
public:
    virtual void fun(int tmp) { cout << "Base::fun(int tmp) : " << tmp << endl; }
};

class Derived : public Base
{
public:
    virtual void fun(int tmp) { cout << "Derived::fun(int tmp) : " << tmp << endl; } // 重写基类中的 fun 函数
};
int main()
{
    Base *p = new Derived();
    p->fun(3); // Derived::fun(int) : 3
    return 0;
}

重载和重写的区别:

范围区别:对于类中函数的重载或者重写而言,重载发生在同一个类的内部,重写发生在不同的类之间(子类和父类之间)。 参数区别:重载的函数需要与原函数有相同的函数名、不同的参数列表,不关注函数的返回值类型;重写的函数的函数名、参数列表和返回值类型都需要和原函数相同,父类中被重写的函数需要有 virtual 修饰。 virtual 关键字:重写的函数基类中必须有 virtual关键字的修饰,重载的函数可以有 virtual 关键字的修饰也可以没有。 隐藏和重写,重载的区别

范围区别:隐藏与重载范围不同,隐藏发生在不同类中。 参数区别:隐藏函数和被隐藏函数参数列表可以相同,也可以不同,但函数名一定相同;当参数不同时,无论基类中的函数是否被 virtual 修饰,基类函数都是被隐藏,而不是重写。

2 封装

面向对象程序设计与面向过程程序设计的不同之处在于,面向过程程序设计要分析出解决问题所需要的步骤,将涉及到的动作归纳成函数并按照相应的逻辑顺序调用;而面向对象程序设计从面临的问题中抽象出多个类(class),类中描述出这个类在解决问题的过程中的属性(成员变量)和动作(成员函数)。因此,我们把客观的事物抽象成类这个行为称为封装,封装类的目的不是为了完成一个步骤,而是为了描述某个事物在解决问题中的属性和行为。类可以实例化成对象,并且通过设置访问限定符(未指明时默认为private)把属性和动作向外部进行不同程度的开放和隐藏。

举个例子:要实现一个学生管理系统,经需求分析需要封装一个student类、一个学生管理类(先忽略该类的实现),student类具有私有(private,私有的属性/方法只能在类内部访问)的成员变量m_name和公有(public,公有的属性/方法可以在类的外部进行操作)的成员函数getName()、setName()。在main()函数中构建类student的对象实例,并操作对象的属性或方法。在类的外部,例如main函数中无需关注该类内部如何实现。因此封装的意义可总结为:使代码模块化,对外部隐藏实现细节。

class stu_manager;  // 忽略实现
class student
{
private:
    std::string m_name;
public:
    std::string getName()
    {
        return m_name;
    }
    void setName(const std::string name)
    {
        m_name = name;
    }
};

int main()
{
    student s1;
    s1.setName("Evila");
    std::cout<<s1.getName()<<std::endl;  // Evila
    return 0;
}

1.1 类的构造函数

类(class)是某个事物在解决问题中属性和行为的封装产物,类的属性由成员变量表达,类的行为由成员函数表达;构造函数是类中较为特殊的成员函数,构造函数在每次类实例化对象时调用。

  • 构造函数的函数名与类名一致,没有返回值,一般用于赋值类的成员变量。若类没有定义构造函数,则编译器会自动定义默认的构造函数。进入构造函数意味着编译器已经认识了对象的内存结构并分配了存储空间,构造函数的作用是对成员变量进行赋值或申请资源,因此在进入类的构造函数之前会先构造类中的对象成员变量。
class student
{
public:
    student() { m_SchoolName = "牛客大学"; }  // 类的构造函数用于赋值成员变量m_SchoolName的值
private:
    std::string m_SchoolName;
};
  • 默认的构造函数不带有参数,但我们可以实现带参数的构造函数,这使得在实例化对象时可以通过传递参数来更方便的赋值对象的成员变量
class student
{
public:
    student() { m_SchoolName = "牛客大学"; }  // 默认的构造函数将成员变量m_SchoolName赋值为“牛客大学”
    // 带参数的构造函数将成员变量m_SchoolName赋值为传入的参数
    student(std::string schoolName) { m_SchoolName = schoolName; }  
private:
    std::string m_SchoolName;
};
  • 初始化列表用于初始化成员变量列表,在构造函数后使用冒号':'和成员变量列表进行初始化可以避免1次构造函数调用(对于类内部非内置类型成员,初始化列表:只调用一次构造;在函数体内赋值:一次默认构造+一次拷贝赋值)。相对于构造函数,初始化列表是真正的初始化动作,构造函数则是赋值,初始化列表工作在构造函数之前。类中const成员变量、引用类型、没有默认构造函数的其他类类型必须在初始化列表中进行初始化动作。
class student
{
public:
    student() { m_SchoolName = "牛客大学"; }  // 默认的构造函数将成员变量m_SchoolName赋值为“牛客大学”
    // 使用初始化列表初始化成员变量
    student(std::string schoolName) : m_SchoolName(schoolName), m_ClassName("student") {}
private:
    std::string m_SchoolName;
    const std::string m_ClassName;  // const成员需要在初始化列表中进行初始化
};

注意:成员变量在使用初始化列表初始化的顺序,与类中声明成员变量的顺序有关。这是因为类中的成员变量的内存排列顺序在编译期间就确定了,初始化列表的初始化顺序是按照成员变量内存排列顺序进行。若我们编写的初始化列表中的成员变量顺序与类中定义的顺序不一致,则可能会导致顺序错乱的成员变量初始化失败。

构造函数调用顺序:

按照派生类继承基类的顺序,即派生列表中声明的顺序,依次调用基类的构造函数;

按照派生类中成员变量的声名顺序,依次调用派生类中成员变量所属类的构造函数;

执行派生类自身的构造函数。

#include <iostream>
using namespace std;

class A
{
public:
    A() { cout << "A()" << endl; }
    ~A() { cout << "~A()" << endl; }
};

class B
{
public:
    B() { cout << "B()" << endl; }
    ~B() { cout << "~B()" << endl; }
};

class Test : public A, public B // 派生列表
{
public:
    Test() { cout << "Test()" << endl; }
    ~Test() { cout << "~Test()" << endl; }

private:
    B ex1;
    A ex2;
};

int main()
{
    Test ex;
    return 0;
}
/*
运行结果:
A()
B()
B()
A()
Test()
~Test()
~A()
~B()
~B()
~A()
*/

综上可以得出,类对象的初始化顺序:基类构造函数–>派生类成员变量的构造函数–>自身构造函数 注:

基类构造函数的调用顺序与派生类的派生列表中的顺序有关; 成员变量的初始化顺序与声明顺序有关; 析构顺序和构造顺序相反。

class Test
{
public:
    Test() : y(0), x(y + 2) 
    {
        cout<<x<<endl<<y<<endl; // x = 32768 y = 0 x初始化失败
    } 

private:
    int64_T x;
    int y;
};
  • 拷贝构造函数 拷贝构造函数是较为特殊的构造函数,一般用于复制其他对象的成员变量来构造新的对象。如果类中没有定义拷贝构造函数,则编译器会定义默认的拷贝构造函数,默认的拷贝构造函数会复制传入对象的成员变量并赋值给当前实例化的新对象。

注意:若类中定义了指针并动态申请了内存,则该类必须显示定义拷贝构造函数以表明指针如何拷贝。否则,若类未定义拷贝构造函数,编译器会自动实现默认的拷贝构造函数,默认的拷贝构造函数中对指针成员的拷贝是浅拷贝,那么当拷贝构造的对象或被拷贝的对象析构时,二者的成员指针申请的内存资源会一起被释放。

深拷贝:该对象和原对象占用不同的内存空间,既拷贝存储在栈空间中的内容,又拷贝存储在堆空间中的内容。 浅拷贝:该对象和原对象占用同一块内存空间,仅拷贝类中位于栈空间中的内容

当类的成员变量中有指针变量时,最好使用深拷贝。因为当两个对象指向同一块内存空间,如果使用浅拷贝,当其中一个对象的删除后,该块内存空间就会被释放,另外一个对象指向的就是垃圾内存。

class student
{
public:
    student()
    {
        m_SchoolName = "牛客大学"; // 默认的构造函数将成员变量m_SchoolName赋值为“牛客大学”
        score_list = new int[10]; // 指针动态申请10个int变量所需的内存
    }  
    // 使用初始化列表初始化成员变量
    student(std::string schoolName) : m_SchoolName(schoolName), m_ClassName("student"), score_list(nullptr) {}
    // 拷贝构造函数
    student(const student& stu)
    {
        m_SchoolName = stu.m_SchoolName;    // 将stu的变量值复制给当前构造的实例
        score_list = new int[10];    // 指针成员动态申请10个int变量所需的内存
        // 深拷贝
        for (int i = 0; i < 10; i++)
        {    
            score_list[i] = stu.score_list[i]; // 循环复制score_list中的值
        }
    }
private:
    std::string m_SchoolName;
    const std::string m_ClassName;  // const成员需要在初始化列表中进行初始化
    int* score_list;
};

1.2 类的析构函数

与构造函数类似的,类的析构函数也是类的成员函数,它在类的对象生命周期结束或动态分配的对象被删除时自动调用。析构函数用于释放对象所获取的资源,特别是若对象中有指针类型的成员变量,且动态申请了内存,析构函数中一定要释放动态申请的内存以避免内存泄漏。

  • 析构函数的名字同样与类名相同,但增加了(~)作为前缀。
  • 析构函数没有参数和返回值。
  • 析构函数不能被重载、不能被主动调用。
  • 若类没有定义析构函数,则编译器会定义一个默认的析构函数。在对象的析构过程中,对象的所有非static的对象类型成员变量的析构函数都会被调用。
class student
{
public:
    student()
    {
        m_SchoolName = "牛客大学"; // 默认的构造函数将成员变量m_SchoolName赋值为“牛客大学”
        score_list = new int[10]; // 指针动态申请10个int变量所需的内存
    }  
    ~student() 
    {
        delete[] score_list; // 析构函数 释放动态申请的内存资源
    }
private:
    std::string m_SchoolName;
    const std::string m_ClassName;  // const成员需要在初始化列表中进行初始化
    int* score_list;
};

1.3 this指针与成员函数调用

1.3.1 对象内存布局

类中非static成员变量被顺序置于对象内存中,对象占的内存空间则只计算非static成员变量的内存大小,static成员变量则置于全局静态存储区。static与非static成员函数不置于对象内存空间中,而是与普通函数一样置于代码段,仅有一份内存实例,而不是每个类都有成员函数的拷贝。

1.3.2 this指针

由于每个对象的存储空间只包括该对象的成员变量,不包括成员函数代码所占有的空间,即同一个类的所有对象调用的成员函数代码只是单独一份,那么成员函数是如何辨别当前调用自己的是哪一个对象呢?

C++引入了this自引用指针,当使用类实例化对象时,编译器生成this指针指向实例化对象的内存地址,this指针的值为对象的起始地址。 注意:一个对象的this指针并不是对象本身的一部分,不会影响该对象sizeof()的结果。例如,对一个空类的对象执行sizeof(),得到其内存大小是1,而不是4(一个指针的内存大小)。至于为什么空类实例化的对象占用内存大小是1,原因是每个实例对象在内存中都应该有独一无二的内存地址,空类实例化对象时被编译器插进去的一个char,使得这个类的不同对象在内存中都有有独一无二的内存地址。

this作用域是在类内部,当类的非静态成员函数被调用时,编译器会自动将对象的this指针作为隐含参数加入到参数列表中。在非静态成员函数访问非静态成员变量都是通过this指针完成。

1.4 面试考点: 请介绍面向对象封装的概念、意义和实现机制。

  • 概念:类是为了描述某个事物在解决问题中的属性和行为,把客观的事物抽象成类这个行为称为封装。
  • 意义:封装使代码模块化,对外部隐藏实现细节
  • 实现机制:封装将成员变量和成员函数聚集在类中,通过访问限定符限制类成员在类内部和外部的访问权限。类的成员函数有两个特殊形式,即类的构造函数和析构函数。构造函数在类的对象实例化时被调用,一般用于赋值成员变量;析构函数在类的对象生命周期结束时被调用,一般用于释放该对象申请的资源。

1.5 C++类对象的初始化过程,有多重继承情况下的顺序?

  1. 创建派生类的对象,基类的构造函数优先被调用(也优先于派生类里的成员类);

  2. 如果类里面有成员类,成员类的构造函数优先被调用;(也优先于该类本身的构造函数)

  3. 基类构造函数如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序而不是它们在成员初始化表中的顺序;

  4. 成员类对象构造函数如果有多个成员类对象,则构造函数的调用顺序是对象在类中被声明的顺序而不是它们出现在成员初始化表中的顺序;

  5. 派生类构造函数,作为一般规则派生类构造函数应该不能直接向一个基类数据成员赋值而是把值传递给适当的基类构造函数,否则两个类的实现变成紧耦合的(tightly coupled)将更加难于正确地修改或扩展基类的实现。(基类设计者的责任是提供一组适当的基类构造函数)

  6. 综上可以得出,初始化顺序:

    父类构造函数–>成员类对象构造函数–>自身构造函数

    其中成员变量的初始化与声明顺序有关,构造函数的调用顺序是类派生列表中的顺序。

    析构顺序和构造顺序相反。

1.6 浅拷贝与深拷贝

  1. 浅拷贝:又称值拷贝,将源对象的值拷贝到目标对象中去,本质上来说源对象和目标对象共用一份实体,只是所引用的变量名不同,地址其实还是相同的。举个简单的例子,你的小名叫西西,大名叫冬冬,当别人叫你西西或者冬冬的时候你都会答应,这两个名字虽然不相同,但是都指的是你。
  2. 深拷贝,拷贝的时候先开辟出和源对象大小一样的空间,然后将源对象里的内容拷贝到目标对象中去,这样两个指针就指向了不同的内存位置。并且里面的内容是一样的,这样不但达到了我们想要的目的,还不会出现问题,两个指针先后去调用析构函数,分别释放自己所指向的位置。即为每次增加一个指针,便申请一块新的内存,并让这个指针指向新的内存,深拷贝情况下,不会出现重复释放同一块内存的错误。
  3. 深拷贝的实现:深拷贝的拷贝构造函数和赋值运算符的重载传统实现:

这种方法解决了我们的指针悬挂问题,通过不断的开空间让不同的指针指向不同的内存,以防止同一块内存被释放两次的问题。

2.7 类的大小

说明:类的大小是指类的实例化对象的大小,用 sizeof 对类型名操作时,结果是该类型的对象的大小。 计算原则:

遵循结构体的对齐原则。 与普通成员变量有关,与成员函数和静态成员无关。即普通成员函数,静态成员函数,静态数据成员,静态常量数据成员均对类的大小无影响。因为静态数据成员被类的对象共享,并不属于哪个具体的对象。 虚函数对类的大小有影响,是因为虚函数表指针的影响。 虚继承对类的大小有影响,是因为虚基表指针带来的影响。 空类的大小是一个特殊情况,空类的大小为 1,当用 new 来创建一个空类的对象时,为了保证不同对象的地址不同,空类也占用存储空间。

示例: 简单情况和空类情况

/*
说明:程序是在 64 位编译器下测试的
*/
#include <iostream>

using namespace std;

class A
{
private:
    static int s_var; // 不影响类的大小
    const int c_var;  // 4 字节
    int var;          // 8 字节 4 + 4 (int) = 8
    char var1;        // 12 字节 8 + 1 (char) + 3 (填充) = 12
public:
    A(int temp) : c_var(temp) {} // 不影响类的大小
    ~A() {}                    // 不影响类的大小
};

class B
{
};
int main()
{
    A ex1(4);
    B ex2;
    cout << sizeof(ex1) << endl; // 12 字节
    cout << sizeof(ex2) << endl; // 1 字节
    return 0;
}

带有虚函数的情况:(注意:虚函数的个数并不影响所占内存的大小,因为类对象的内存中只保存了指向虚函数表的指针。)

/*
说明:程序是在 64 位编译器下测试的
*/
#include <iostream>

using namespace std;

class A
{
private:
    static int s_var; // 不影响类的大小
    const int c_var;  // 4 字节
    int var;          // 8 字节 4 + 4 (int) = 8
    char var1;        // 12 字节 8 + 1 (char) + 3 (填充) = 12
public:
    A(int temp) : c_var(temp) {} // 不影响类的大小
    ~A() {}                      // 不影响类的大小
    virtual void f() { cout << "A::f" << endl; }

    virtual void g() { cout << "A::g" << endl; }

    virtual void h() { cout << "A::h" << endl; } // 24 字节 12 + 4 (填充) + 8 (指向虚函数的指针) = 24
};

int main()
{
    A ex1(4);
    A *p;
    cout << sizeof(p) << endl;   // 8 字节 注意:指针所占的空间和指针指向的数据类型无关
    cout << sizeof(ex1) << endl; // 24 字节
    return 0;
}

3 继承

3.1 继承特性

继承可以根据一个类去定义另一个类,已有的类被称为基类(父类),继承得到的类称为派生类(子类),派生类在继承后就获得了基类的成员变量和成员函数。继承使得已存在的类能够被扩展,代码得到重用。

在定义类时,使用继承列表来指明继承自的基类和继承方式,若未指明继承方式则默认为private私有继承方式。基类的构造函数、析构函数、拷贝构造函数与基本重载的运算符不会被继承。

构造/析构函数的调用顺序 派生类在继承自基类后,派生类实例化对象时会执行如下的构造步骤:(1)调用基类的构造函数;(2)派生类中对象成员的构造函数;(3)派生类的构造函数,析构步骤则将构造顺序反转。

class A
{
public:
    A()
    {
        cout<<"A construct"<<endl;
    }
    ~A()
    {
        cout<<"A destructor"<<endl;
    }
};
class person
{
public:
    person()
    {
        cout<<"person construct"<<endl;
    }
    ~person()
    {
        cout<<"person destructor"<<endl;
    }
    std::string m_name;
};
class student : public person  // 定义student类,公有继承自person类,student类天生拥有person类的成员与函数
{
public:
    student()
    {
        cout<<"student construct"<<endl;
    }
    ~student()
    {
        cout<<"student destructor"<<endl;
    }
    A a;
};
int main()
{
    {
        student stu;
    }
}
person construct
A construct
student construct
student destructor
A destruct
person destructor

3.2 多继承、菱形继承虚继承

3.2.1 继承下的对象空间布局

与非继承的类对象的内存布局类似,继承的类对象不仅包含基类的成员变量,而且包含了本类的成员变量,基类和派生类的非static成员变量被顺序置于对象内存中,而static成员变量则置于全局静态存储区。成员函数仅有一份实例在代码段。

3.2.2 多继承与对象的空间布局

  • C++支持多继承,即派生类可以继承于多个基类,派生类拥有所有基类的特性。多继承的语法与普通继承类似,在继承列表中使用逗号分割继承方式和基类名:
class base1
{
public:
    int x;
};
class base2
{
public:
    int y;
};
class derive : public base1, public base2
{
public:
    int z;
};

一般多继承下derive的对象内存简易模型如下所示:

3.2.3菱形继承及其带来的影响

  • 多继承产生的二义性:当派生类继承自多个基类时,若在这多个基类中有同名的成员,则派生类中会对继承而来的同名成员产生歧义,因此不能通过成员名直接访问,需要增加域(::)运算符加以区分。例如:
class base1 
{
public:
    int x;
};
class base
{
public:
    int x;
};
class derive : public base1, public base2
{
public:
    int de;
};

derive的两个基类都有成员变量x,因此derive类中有同名变量x,在访问x时必须通过域(::)运算符制定来自哪个基类的变量x。

  • 菱形继承:若派生类继承于多个基类,这些基类又有共同的基类,就会形成菱形继承的分布。例如:
class base

{

public:

  int x;

  int y;

  int z;

};

class base1 : public base

{

public:

  int ba1;

};

class base2 : public base

{

public:

  int ba2;

};

class derive : public base1, public base2

{

public:

  int de;

};

此时,菱形继承的派生类对象得到如下内存模型,可以发现在派生类derive中存在两份基类base的成员,尽管依然可以使用域(::)操作符来区分derive中的成员x、y和z,但这对于derive来说无疑是内存空间的浪费。

3.2.4虚继承及虚基类的特性

  • 为了解决菱形继承带来的内存和计算资源的浪费,C++提供了虚继承机制,使得派生类在间接继承共同的基类时只保留一份间接基类的成员。虚继承的语法为:
class derive : virtual public base; // 使用virtual关键字进行虚继承

此时,类base是类derive的虚基类。

  • 虚基类由它最后的派生类进行初始化,最后的派生类对象在构造时,不仅要调用直接基类的构造函数,还要调用虚基类的构造函数。由于虚基类只被最后的派生类进行初始化,不被其他中间的派生类初始化,因此避免了虚基类的成员被重复初始化。
  • 在最后的派生类中访问虚基类的成员,也不需要使用域(::)来指明来自于哪个直接基类,因为最后的派生类中只有一份虚基类成员。
#include<iostream>
#include<string>
using namespace std;
class base
{
public:
    int x;
    int y;
    int z;
    base()
    {
        cout<<"base construct"<<endl;
    }
    ~base()
    {
        cout<<"base destruct"<<endl;
    }
};
class base1 : virtual public base
{
public:
    int ba1;
    base1()
    {
        cout<<"base1 construct"<<endl;
    }
    ~base1()
    {
        cout<<"base1 destruct"<<endl;
    }
};
class base2 : virtual public base
{
public:
    int ba2;
    base2()
    {
        cout<<"base2 construct"<<endl;
    }
    ~base2()
    {
        cout<<"base2 destruct"<<endl;
    }
};
class derive : public base1, public base2
{
public:
    int de;
    derive()
    {
        cout<<"derive construct"<<endl;
    }
    ~derive()
    {
        cout<<"derive destruct"<<endl;
    }
};
int main()
{
    derive de;
    de.x = 0;
}

3.2.5虚继承的实现机制

事实上,虚继承的实现是通过虚表指针(virtual base table pointer, vbtp)和虚基类(virtual base table, vbt)表实现的,每个虚继承得到的派生类对象内部都有一个虚表指针(4字节)和一个虚基类表(不占对象的内存空间)。最后的派生类在实例化对象时对虚基类进行了一次初始化,虚基类表中记录了派生类的对象与虚基类对象的偏移地址,派生类对象通过偏移地址访问虚基类对象的成员。

虚基类的派生类被当做基类继承时,虚表指针也会被继承。虚继承方式下菱形继承的内存模型如图所示:

3.2.6 面试考点:什么是菱形继承?菱形继承有什么影响?如何避免菱形继承产生的影响?

  • 菱形继承:若派生类继承于多个基类,这些基类又继承于共同的基类,就会形成菱形继承的分布。
  • 菱形继承带来的影响:在菱形继承中最后的派生类会包含共同基类的多份成员副本,不仅会产生访问共同基类成员的歧义,而且会带来内存空间的浪费。
  • 使用虚继承避免菱形继承产生的成员访问歧义和内存浪费。虚继承的派生类对象会产生一个虚基类指针,指向虚基类表。虚基类表记录了派生类相对于基类成员的内存偏移,派生类在访问基类成员时,根据虚基类表的内存偏移访问基类成员,因此基类成员只有一份实例,派生类对象的内存空间没有基类成员的副本。

4 多态

多态通常指同一个接口实现不同的功能,其目的是实现接口的重用。在C++中,多态按照编译器的实现机制可分为静态多态和动态多态,静态多态是编译器在编译期间就可以确定的接口重用,表现形式为:函数重载和模板,在调用函数时编译系统根据实参的具体情况确定函数的实现。动态多态是在运行过程中编译器根据对象动态绑定的实际类型调用相应的函数,表现形式为:虚函数。

编译时多态:在程序编译过程中出现,发生在模板和函数重载中(泛型编程)。 运行时多态:在程序运行过程中出现,发生在继承体系中,是指通过基类的指针或引用访问派生类中的虚函数。

编译时多态和运行时多态的区别:

时期不同:编译时多态发生在程序编译过程中,运行时多态发生在程序的运行过程中; 实现方式不同:编译时多态运用泛型编程来实现,运行时多态借助虚函数来实现。

4.1 虚函数及其相关特性

在类中声明函数时增加virtual关键字将该函数声明为虚函数。若该类被当作基类进行继承得到派生类时,虚函数被继承到派生类中,派生类对继承得到的虚函数可以重新实现,这被称为虚函数覆盖(override)。当我们通过指针或引用调用虚函数时,编译器会根据动态绑定判断实际调用对象的函数实现。

总结:构成动态多态需要两个必备条件

  • 1.调用函数必须通过对象的指针或者对象的引用。
  • 2.被调用的函数必须是虚函数,且派生类进行了虚函数的覆盖。

如下例所示,首先定义基类person,声明printAge函数为虚函数;接着定义了person的派生类adult和young并覆盖了printAge函数;最后使用基类指针动态创建派生类对象调用printAge函数时,编译器根据动态绑定机制调用指针指向的实际对象覆盖后的虚函数。

#include<iostream>
#include<string>
using namespace std;
class person
{
public:
    virtual void printAge()
    {
        cout<<"年龄-未知"<<endl;
    }
};
class adult : public person
{
public:
    virtual void printAge()
    {
        cout<<"年龄>=18岁"<<endl;
    }
};
class young : public person
{
public:
    virtual void printAge()
    {
        cout<<"年龄<18岁"<<endl;
    }
};
int main()
{
    person* pAdult = new adult();
    pAdult->printAge();  // "年龄>=18岁"
    person* pYoung = new young();
    pYoung->printAge();  // "年龄<18岁"
}

4.2 抽象类

4.2.1 虚函数与纯虚函数

虚函数:被 virtual 关键字修饰的成员函数,就是虚函数。

#include <iostream>
using namespace std;

class A
{
public:
    virtual void v_fun() // 虚函数
    {
        cout << "A::v_fun()" << endl;
    }
};
class B : public A
{
public:
    void v_fun()
    {
        cout << "B::v_fun()" << endl;
    }
};
int main()
{
    A *p = new B();
    p->v_fun(); // B::v_fun()
    return 0;
}

纯虚函数:

纯虚函数在类中声明时,加上 =0; 含有纯虚函数的类称为抽象类(只要含有纯虚函数这个类就是抽象类),类中只有接口,没有具体的实现方法; 继承纯虚函数的派生类,如果没有完全实现基类纯虚函数,依然是抽象类,不能实例化对象。

说明:

抽象类对象不能作为函数的参数,不能创建对象,不能作为函数返回类型; 可以声明抽象类指针,可以声明抽象类的引用; 子类必须继承父类的纯虚函数,并全部实现后,才能创建子类的对象。

加上virtual关键字的函数,虚就虚在所谓"推迟联编"或者"动态联编"上 一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。 由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被称为"虚"函数。

虚函数和纯虚函数的区别

虚函数和纯虚函数可以出现在同一个类中,该类称为抽象基类。(含有纯虚函数的类称为抽象基类) 使用方式不同:虚函数可以直接使用,纯虚函数必须在派生类中实现后才能使用; 定义形式不同:虚函数在定义时在普通函数的基础上加上 virtual 关键字,纯虚函数定义时除了加上virtual 关键字还需要加上 =0; 虚函数必须实现,否则编译器会报错; 对于实现纯虚函数的派生类,该纯虚函数在派生类中被称为虚函数,虚函数和纯虚函数都可以在派生类中重写; 析构函数最好定义为虚函数,特别是对于含有继承关系的类;析构函数可以定义为纯虚函数,此时,其所在的类为抽象基类,不能创建实例化对象。

4.2.2 虚函数实现机制

实现机制:虚函数通过虚函数表来实现。虚函数的地址保存在虚函数表中,在类的对象所在的内存空间中,保存了指向虚函数表的指针(称为“虚表指针”),通过虚表指针可以找到类对应的虚函数表。虚函数表解决了基类和派生类的继承问题和类中成员函数的覆盖问题,当用基类的指针来操作一个派生类的时候,这张虚函数表就指明了实际应该调用的函数。

虚函数表相关知识点:

  • 虚函数表存放的内容:类的虚函数的地址。
  • 虚函数表建立的时间:编译阶段,即程序的编译过程中会将虚函数的地址放在虚函数表中。
  • 虚表指针保存的位置:虚表指针存放在对象的内存空间中最前面的位置,这是为了保证正确取到虚函数的偏移量。

注:虚函数表和类绑定,虚表指针和对象绑定。即类的不同的对象的虚函数表是一样的,但是每个对象都有自己的虚表指针,来指向类的虚函数表。

示例: 无虚函数覆盖情况

#include <iostream>
using namespace std;

class Base
{
public:
    virtual void B_fun1() { cout << "Base::B_fun1()" << endl; }
    virtual void B_fun2() { cout << "Base::B_fun2()" << endl; }
    virtual void B_fun3() { cout << "Base::B_fun3()" << endl; }
};

class Derive : public Base
{
public:
    virtual void D_fun1() { cout << "Derive::D_fun1()" << endl; }
    virtual void D_fun2() { cout << "Derive::D_fun2()" << endl; }
    virtual void D_fun3() { cout << "Derive::D_fun3()" << endl; }
};
int main()
{
    Base *p = new Derive();
    p->B_fun1(); // Base::B_fun1()
    return 0;
}

基类和派生类的继承关系:

基类的虚函数表:

派生类的虚函数表:

主函数中基类的指针 p 指向了派生类的对象,当调用函数 B_fun1() 时,通过派生类的虚函数表找到该函数的地址,从而完成调用。

4.3 虚析构函数

通常情况下,虚函数在覆盖时不允许修改该函数的函数名、参数类型和返回值。但是有一个例外:基类中的析构函数如果是虚函数,那么派生类的析构函数尽管与基类的析构函数名不相同,编译器也会通过特殊的机制认为派生类的虚析构函数覆盖了基类的虚析构函数。

事实上基类的析构函数被声明为虚函数是被推荐的行为。一般情况下,析构函数的用于释放对象申请的资源,例如指针成员动态申请的内存需要去主动delete释放掉。当我们使用基类指针去指向派生类对象并delete该基类指针时,会发生两种情况:(1)若基类和派生类的析构函数不是虚函数,那么delete基类指针只会调用基类的析构函数,不会调用派生类的析构函数,此时若该对象成员中有指针类型且动态申请了内存,那么就不会在派生类的析构函数中被释放,从而造成了内存泄漏。(2)若基类和派生类的析构函数为虚函数,那么delete基类指针时会先调用派生类的析构函数、再调用基类的析构函数,因此安全的释放了对象资源。

class person
{
public:
    person() {}
    virtual ~person() {}  // 虚析构函数
};
class student : public person
{
public:
    student() 
    {
        p = new char[256];
    }
    virtual ~student() // 虚析构函数
    {
        delete p;
    }  
    char *p;
}
int main()
{
    person* ptr = new student();  // 基类指针指向派生类对象
    delete ptr;  // 析构时先调用派生类的析构函数,释放动态申请的资源,避免了内存泄漏 

4.4 虚函数的实现机制

至此我们已经认识到,基类中声明的虚函数在派生类中进行覆盖后,在通过指针或引用调用虚函数时编译器会调用对象的实际函数实现,从而实现了动态多态:同一接口在不同对象中具有不同的实现。那么虚函数是如何实现的动态多态机制呢?

前文讲解了虚继承的派生类中会生成一个虚表指针,它指向虚基类表,该表记录了派生类对象相对于虚基类对象的地址偏移。虚函数的实现机制与虚继承具有相似之处:声明了虚函数的对象会生成一个虚指针(Virtual table pointer, vtp),它指向虚函数表(Virtual Table,虚表),虚表中存储了该类声明的所有虚函数的入口地址。当该类被当做基类继承时,派生类同样会继承基类的虚表,且派生类覆盖继承得到的虚函数时,会在派生类的虚表中的相应位置将虚函数替换。

通常声明了虚函数的类实例化的对象的内存空间前4个字节为虚指针,在调用对象的虚函数时,通过该对象的虚指针访问虚表中函数的入口地址进行函数调用。声明了虚函数的类被当做基类继承时,派生类会继承基类的虚表。当派生类覆盖了基类的虚函数时,覆盖的新虚函数会在虚表中替换已有的函数入口地址,在实际调用时若调用对象为派生类对象,从而在实际调用时可以根据调用对象中的虚指针找到其指向的虚表中的正确函数入口。

单继承下声明了虚函数的基类与派生类对象的内存示意图:

可以发现,派生类覆盖了基类的虚函数func1,并在派生类的虚表中替换了基类继承而来的func1。

多继承时,若多个基类都声明了虚函数,那么派生类同样会继承多个基类的虚指针和虚表。

4.5 C++ 中哪些函数不能被声明为虚函数?

常见的不不能声明为虚函数的有:普通函数(非成员函数),静态成员函数,内联成员函数,构造函数,友元函数。

  1. 为什么C++不支持普通函数为虚函数?

    普通函数(非成员函数)只能被overload,不能被override,声明为虚函数也没有什么意思,因此编译器会在编译时绑定函数。

  2. 为什么C++不支持构造函数为虚函数?

    这个原因很简单,主要是从语义上考虑,所以不支持。因为构造函数本来就是为了明确初始化对象成员才产生的,然而virtual function主要是为了再不完全了解细节的情况下也能正确处理对象。另外,virtual函数是在不同类型的对象产生不同的动作,现在对象还没有产生,如何使用virtual函数来完成你想完成的动作。(这不就是典型的悖论)

    构造函数用来创建一个新的对象,而虚函数的运行是建立在对象的基础上,在构造函数执行时,对象尚未形成,所以不能将构造函数定义为虚函数

  3. 为什么C++不支持内联成员函数为虚函数?

    其实很简单,那内联函数就是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后对象能够准确的执行自己的动作,这是不可能统一的。(再说了,inline函数在编译时被展开,虚函数在运行时才能动态的绑定函数

    内联函数是在编译时期展开,而虚函数的特性是运行时才动态联编,所以两者矛盾,不能定义内联函数为虚函数

  4. 为什么C++不支持静态成员函数为虚函数?

    这也很简单,静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,他也没有要动态绑定的必要性。

    静态成员函数属于一个类而非某一对象,没有this指针,它无法进行对象的判别

  5. 为什么C++不支持友元函数为虚函数?

    因为C++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。

4.6 虚函数表中的内容什么时间被写进去的?

  1. 虚函数表是一个存储虚函数地址的数组,以NULL结尾。虚表(vftable)在编译阶段生成,对象内存空间开辟以后,写入对象中的 vfptr,然后调用构造函数。即:虚表在构造函数之前写入
  2. 除了在构造函数之前写入之外,我们还需要考虑到虚表的二次写入机制,通过此机制让每个对象的虚表指针都能准确的指向到自己类的虚表,为实现动多态提供支持。

5 面试考点

5.1 请介绍C++中的多态。

多态是指多个同名的函数执行不同二点操作,使得同一个函数名具有不同的行为,其目的是实现接口的重用。 在C++中,多态按照编译器的实现机制可分为静态多态和动态多态;

  • 静态多态是编译器在编译期间就可以确定的接口重用,表现形式为:函数重载和模板,在调用函数时编译系统根据实参的值进行类型推导情况确定最适配的函数重载实现。
  • 动态多态是在运行过程中编译器根据动态绑定的实际类型调用相应的函数,表现形式为:虚函数。基类中声明的虚函数被继承到派生类中,派生类对继承得到的虚函数可以重新实现,被称为虚函数覆盖(override)。当我们通过指针或引用调用虚函数时,编译器会根据动态绑定判断实际调用对象的函数实现。声明了虚函数的类会产生一个虚表,虚表中记录了类的所有虚函数入口地址。当发生继承时,虚表会继承到派生类中,若派生类覆盖了某个虚函数,则派生类的虚表会替换覆盖的虚函数。每个声明了虚函数的类的对象都有一个虚指针指向虚表,当虚函数被调用时,编译器根据实际调用对象的虚指针访问类的虚表,从而实现匹配的函数调用。

5.2 class与struct的区别与联系?

  • 共同点:C++中的struct是在C语言struct的基础上增加了许多面向对象的特性,在C++中struct表现与class十分相似。例如:class与struct都可以包含成员变量与成员函数,都支持继承和派生,都可以实现多态。
  • 区别:在C++中struct与class最大的区别在于成员的默认访问权限和默认继承权限,struct中默认的成员访问权限是和继承权限是public,而class中默认的成员访问权限是和继承权限是private。此外,struct和class的继承关系默认权限也不同,struct默认以public的方式继承基类,而class默认以private的方式继承基类。