原创
最近更新: 2021/11/03 23:45

C++学习笔记 之 面向对象篇

系列目录

C++学习笔记 之 基础篇

C++学习笔记 之 面向对象篇

C++学习笔记 之 C++11新特性


封装

封装的本质是对数据的打包,编译器提供了构造函数、析构函数等语法,帮助我们对数据的处理。

用sizeof去查看一个类的大小,会发现它的大小只包括成员变量的大小,而不包括成员函数,说明函数与类对象并不存放在一起。

  • 成员函数属于整个类,存放在代码段,调用时编译器使用函数指针调用。

编译器对成员函数进行了一定的处理,任何非静态成员函数在调用的时候,都会额外传入一个当前对象的指针,即this指针。

访问控制

编译器提供了访问控制关键字private、protected、public。

  • 访问控制仅对编译器有意义,使用指针可以随意访问受控制的内存地址。

c++中,class和struct唯一的区别在于权限。

  • class的默认权限是private,而struct的默认权限是public。

  • 此外,继承的时候,class的默认继承会将父类的public权限继承为private权限,而struct依然是public。


继承

继承的本质是对数据的复用,减少代码重复。编译器在创建子类对象的时候,会复制一份父类对象的成员变量(即调用父类的构造函数,创建了一个父类的完整对象)。

  • 对于父类的private成员,同样被复制了一份,但是编译器不允许子类直接访问。但可以通过指针进行访问对应的内存空间。

子类对象内存中成员变量分布如下:

  1. 父类成员变量
  2. 子类成员变量

多层继承的多态原理和上面一样(只是多了几层嵌套,父类指针仍然指向对象的首地址)。

多重继承(父类之间没有继承关系),编译器会自动调整不同父类指针指向的地址。不推荐这样的继承方法,因为会导致一系列问题,如菱形继承问题。

菱形继承与虚继承

假设有四个类A、B、C、D,继承结构是:

      A
     /  \
    B    C
     \  /
      D

当D要访问A的成员变量时,会出现二义性的情况——由于B和C都完整继承了A的成员变量,二者会产生命名冲突。解决方法:

  1. 使用 类名:: 来指明变量属于类B还是类C

  2. 类B和类C使用virtual虚继承机制

虚继承

class B:virtual public A{}

虚继承的派生类中会额外保留一份4字节的偏移量。

注意:此时子类D不需要virtual关键字

使用虚继承机制,子类B和C对象内存中成员变量分布如下:

  1. 一个偏移量
  2. 子类成员变量
  3. 父类成员变量

子类D对象内存中成员变量分布:

  1. 类B的偏移量
  2. 类B成员变量
  3. 类C的偏移量
  4. 类C成员变量
  5. 类D成员变量
  6. 类A成员变量

可以看出,使用虚继承机制后,子类对象的内存结构发生了变化,首地址存储偏移量,父类的成员变量放在了最后。

C++标准库中的 iostream 类就是一个虚继承的实际应用案例。

  • iostream 从 istream 和 ostream 直接继承而来,而 istream 和 ostream 又都继承自一个共同的名为 base_ios 的类,是典型的菱形继承。

  • 此时 istream 和 ostream 必须采用虚继承,否则将导致 iostream 类中保留两份 base_ios 类的成员。


多态

多态的含义:一种类型(父类)体现出不同的行为(子类虚函数重写的多样性)。

多态指的是父类指针指向子类对象的情况,编译器认为这样是合法的,因为子类对象中确实包含了完整的父类成员。

但是这样做的时候无法访问只属于子类的成员变量,因为编译器不认。

编译期绑定和运行期绑定

  • 编译期绑定/前期绑定:普通成员变量和普通成员函数,访问地址在编译期间就已经写死了。

  • 运行期绑定/动态绑定/多态:虚函数由于(指针调用时)调用对象不确定(可能存在子类重写的情况),是间接调用,需要在运行期和对应函数绑定。动态绑定是通过虚表实现的。

注意:不能用子类指针指向父类对象,除非将父类对象强制转换为子类对象,但是这样会造成非法空间的访问,不推荐。


虚函数的底层实现

C++中类的成员函数进行调用时是通过直接调用实现的,即编译器直接call函数的物理地址。

virtual虚函数如果通过类实例去调用,和普通函数没有任何区别,都是直接调用;但是用指针时,是间接调用。

  • 原因是虚函数被调用时,真正被调用的函数并不确定,这涉及继承、重写和虚表的概念。

虚表与虚表指针

每个有虚函数的类或者虚继承的子类,编译器都会为它生成一个虚表。

  • 虚表是从属于类的,即所有类对象共享一个虚表和所有虚函数。

  • 虚表以数组形式按顺序存储所有虚函数的指针,表中的每一个元素都指向一个虚函数的地址。

当一个类中存在虚函数,编译器会给类增加一个成员变量,放在类的起始(首)地址位置,称为“虚表指针”,指向虚函数表(虚表)的地址。

  • 虚表指针是从属于对象的,也就是说,如果一个类含有虚表,那么类的每个对象都含有虚表指针,但是虚表指针指向的地址是一样的。

  • 调用虚函数的时候,编译器通过“虚表指针+偏移量(函数编号*4)”的方式进行调用。

值得一提的是,由于虚表指针存储在类的首地址位置,所以类对象的this指针指向的位置正好是虚表指针存放的位置。编译器正是通过this指针来寻找虚表和虚函数的调用地址。

多继承的情况

在不继承、单继承、多层单继承的情况下,一个类最多只有一个虚表,按顺序存放父类和自己的虚函数。

如果一个类具有多个直接父类(多重继承),且多个直接父类具有虚函数,则对应每个具有虚函数的直接父类,子类都会创建一个虚表。


虚函数的重写

应当将父类中任何希望被重写的函数声明为虚函数,不希望被重写的函数不声明为虚函数。

当子类中重写了父类当中的虚函数,那么子类的虚表中父类对应的虚函数的指针会被重写过的子类函数指针覆盖。

此时,当子类对象去调用对应虚函数的时候,实际调用的是重写过的函数。而父类指针指向的子类对象进行虚函数调用时,也是调用的重写过的函数。这就是运行时多态的最终实现。

函数重写覆盖的多态性只局限于虚函数,因为非虚函数不涉及虚表,是直接调用。

class Base{
public:
    virtual void func1(){
        cout << "Base->func1()" << endl;
    }
    virtual void func2(){
        cout << "Base->func2()" << endl;
    }
};

class Derived:Base{
public:
    void func1(){
        cout << "Derived->func1()" << endl;
    }
    void func2(){
        cout << "Derived->func2()" << endl;
    }
};

int main() {
    Base base;
    Derived derived, derived2;

    printf("derived的首地址:%x\n", (int*)&derived);
    //derived与derived2共享虚表和虚函数地址
    printf("derived的虚表地址:%x\n", *(int*)&derived);
    printf("derived的首个虚函数地址:%x\n", *(int*)(*(int*)&derived));
	//使用指针访问虚函数,关键是两次解引用:
	//第一次对实例指针解引用得到虚表地址
	//第二次对虚表指针解引用得到虚函数地址

    typedef void(*pFunction)(void);//typedef的复杂用法,给函数指针起别名
    //应当注意的是,此处我们并没有传入一个this指针
    pFunction pfn;

    pfn = (pFunction)*((int*)(*(int*)&base) + 0);
    //上面这行等价于 pfn = (pFunction)((int*)(*(int*)&base))[0];
    pfn();//输出:Base->func1()

    pfn = (pFunction)*((int*)(*(int*)&derived) + 1);
    //上面这行等价于 pfn = (pFunction)((int*)(*(int*)&derived))[1];
    pfn();//输出:Derived->func2()

    return 0;
}

为什么析构函数应当定义为虚函数?

应当先指明一点,不带指针的类由于没有资源需要释放,一般没必要写析构函数。不会被继承的类,也就没有必要将析构函数定义为虚函数。

这是多态的要求。父类指针指向子类对象时,如果析构函数不是虚函数,那么编译器只会调用父类的析构函数而不会调用子类的析构函数。此时子类对象中的资源可能未被完全回收,造成内存泄漏。

虚析构函数的重写方式是:先将父类析构函数声明为virtual,然后子类的析构函数会覆盖父类的析构函数。

由于子类的析构函数会调用父类的析构函数,释放父类指针指向的子类对象时,会先调用子类的析构函数,再调用父类的析构函数。

反过来,构造函数不能是虚函数。因为子类对象初始化之前,编译器会先初始化一个父类对象。如果是虚函数,编译器会先去调用子类的构造函数,然而子类的构造又需要先调用父类的构造函数,造成死循环。

评论区