CPP

109 阅读21分钟

www.yuque.com/huihut/inte…

一、 C++

1、static关键字的作用

2、C++虚构函数为什么要为虚构函数

www.cnblogs.com/zsq1993/p/5…

基类指针可以指向派生类的对象(多态性),如果删除该指针delete []p;就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全。所以,将析构函数声明为虚函数是十分必要的。

如果不需要基类对派生类及对象进行操作,则不能定义虚函数,因为这样会增加内存开销.当类里面有定义虚函数的时候,编译器会给类添加一个虚函数表,里面来存放虚函数指针,这样就会增加类的存储空间.所以,只有当一个类被用来作为基类的时候,才把析构函数写成虚函数.

3、const关键字对C++成员函数的修饰

www.cnblogs.com/myseasky/p/…

const对C++成员函数的修饰分为三种:1. 修饰参数;2. 修饰返回值;3. 修饰this指针

3.1 对函数参数的修饰。

  • const只能用来修饰输入参数。输出型参数不能用const来修饰。
  • 输入参数采用“指针传递”,那么加const修饰可以防止意外地改动该指针,起到保护作用。
  • 如果输入参数采用“值传递”,函数将产生临时变量(局部变量),复制该参数的值并且压入函数栈。函数中使用该参数时,访问的是函数栈中临时变量的值,原变量无需保护,所以不要加const修饰。
  • 基本变量类型的参数作为“值传递”的输入参数,无需采用引用。自定义变量类型(class类型,struct类型)参数作为“值传递”的输入参数,最好采用"const+引用"格式,即 void func(const A& a)。原因是自定义变量类型作为值传递时,设计创建临时变量,构造,复制,析构,这些过程很消耗时间。  

3.2 对返回值的修饰。

这个应用比较少。大部分返回值采用的时“值传递”。如果将返回值修饰为const,那么接收返回值的变量也必须定义为const。

3.3 对this指针的修饰。

  • 我们知道,c++成员函数在编译时候,会传入一个this指针,指向实例本身。这个this指针默认实际上是个顶层指针。即如果有classA,那么这个指针其实类似如下的定义:

  classA * const this;

  即this指针指向实例本身并且不可以修改,但可以通过this指针修改其指向的成员变量。在成员函数内访问成员变量m_var,实际上时如下形式方位的:

  this.m_var;

  • 如果我们设计一个成员函数时,不想让其修改成员变量,那么就应该将this指针定义为底层指针。c++定义的方式就是在函数签名后面加上const,即

  void func(const A& a, int b, const int* c, int* d)const;

  显然,上述成员函数中,a为const引用传递,不可以改变原值;b为值传递;c为const指针传递,不可改变原值;d为输出参数,可以改变原值。而该函数为const成员函数,不可以修改成员变量值

 3.4 以下是const成员函数注意的几点

  • const对象只能访问const成员函数,而非const对象可以访问任意的成员函数,包括const成员函数.即对于class A,有

   const A a;

  那么a只能访问A的const成员函数。而对于:

   A b;

   b可以访问任何成员函数。

  • const对象的成员变量不可以修改。
  • mutable修饰的成员变量,在任何情况下都可以修改。也就是说,const成员函数也可以修改mutable修饰的成员变量。c++很shit的地方就是mutable和friendly这样的特性,很乱。
  • const成员函数可以访问const成员变量和非const成员变量,但不能修改任何变量。检查发生在编译时。
  • 非const成员函数可以访问非const对象的非const数据成员、const数据成员,但不可以访问const对象的任意数据成员。
  • const成员函数只是用于非静态成员函数,不能用于静态成员函数。
  • const成员函数的const修饰不仅在函数声明中要加(包括内联函数),在类外定义出也要加。
  • 作为一种良好的编程风格,在声明一个成员函数时,若该成员函数并不对数据成员进行修改操作,应尽可能将该成员函数声明为const 成员函数。

4、隐式类型转换

www.cnblogs.com/QG-whz/p/44…

《C++ Primer》中提到:

“可以用 单个形参来调用 的构造函数定义了从 形参类型 到 该类类型 的一个隐式转换。”

这里应该注意的是, “可以用单个形参进行调用” 并不是指构造函数只能有一个形参,而是它可以有多个形参,但那些形参都是有默认实参的。

**那么,什么是“隐式转换”呢? **

——上面这句话也说了,是从 构造函数形参类型 到 该类类型 的一个编译器的自动转换。

总结一下:

  • 可以使用一个实参进行调用,不是指构造函数只能有一个形参。
  • 隐式类类型转换容易引起错误,除非你有明确理由使用隐式类类型转换,否则,将可以用一个实参进行调用的构造函数都声明为explicit。
  • explicit只能用于类内部构造函数的声明。它虽然能避免隐式类型转换带来的问题,但需要用户能够显式创建临时对象(对用户提出了要求)。

5、C++对象模型:对象内存布局详解

www.cnblogs.com/QG-whz/p/49…

6、四种转换

en.cppreference.com/w/cpp/langu…

blog.csdn.net/bajianxiaof…

6.1 const_cast

const_cast顾名思义,用来将对象的常量属性转除,使常量可以被修改。const_cast(varible)中的type必须是指针,引用,或者指向对象类型成员的指针。

6.2 dynamic_cast

用来处理一种“安全向下转换”,当我们将父类指针指向一个new出来的子类A对象时,如果该父类有多个不同子类(class A,class B,),那么可以使用dynamic_cast将该指针类型安全转换为A*,如果使用强转操作符或者下面介绍的static_cast,那么将其转换为B*理论上也是可以的,但是使用上就会有错误:比如,类A有的成员函数,类B没有。

6.3 reinterpret_cast

是特意用于底层的强制转型,导致实现依赖(就是说,不可移植)的结果,例如,将一个指针转型为一个整数。这样的强制类型在底层代码以外应该极为罕见。操作结果只是简单的从一个指针到别的指针的值的二进制拷贝。在类型之间指向的内容不做任何类型的检查和转换。

6.4 static_cast

static_cast用来处理隐式转换,等同于C语言中的(NewType)Expression强转,它可以将int转为float,也可以将char转为int,将指向基类的指针转为一个指向子类的指针,同时可以将non-const转为const对象,但是它不能将一个const对象转为non-const(这个是const_cast的功能)。

//基本数据类型的转换
int intValue = 10;
double doubleValue = static_cast<double>(intValue);

//子类指针向父类指针的转换
class Base { /*...*/ };
class Derived : public Base { /*...*/ };
Derived derivedObj;
Base* basePtr = static_cast<Base*>(&derivedObj);

//指针和整数之间的转换
int* intPtr = reinterpret_cast<int*>(0x1234);
uintptr_t addr = reinterpret_cast<uintptr_t>(intPtr);

7、static_cast和dynamic_cast

www.cnblogs.com/rednodel/p/…

7.1 static_cast

用法:static_cast < type-id > ( exdivssion ) 该运算符把exdivssion转换为type-id类型,但没有运行时类型检查来保证转换的安全性。它主要有如下几种用法: ①用于类层次结构中基类和子类之间指针或引用的转换。   进行上行转换(把子类的指针或引用转换成基类表示)是安全的;   进行下行转换(把基类指针或引用转换成子类表示)时,由于没有动态类型检查,所以是不安全的。 ②用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证。 ③把空指针转换成目标类型的空指针。 ④把任何类型的表达式转换成void类型。

注意:static_cast不能转换掉exdivssion的const、volitale、或者__unaligned属性。

7.2 dynamic_cast

用法:dynamic_cast < type-id > ( exdivssion ) 该运算符把exdivssion转换成type-id类型的对象。Type-id必须是类的指针、类的引用或者void *; 如果type-id是类指针类型,那么exdivssion也必须是一个指针,如果type-id是一个引用,那么exdivssion也必须是一个引用。

dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换。 在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的; 在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。

8、delete this 合法吗?

合法,但:

  • 必须保证 this 对象是通过 new(不是 new[]、不是 placement new、不是栈上、不是全局、不是其他对象成员)分配的

  • 必须保证调用 delete this 的成员函数是最后一个调用 this 的成员函数

  • 必须保证成员函数的 delete this 后面没有调用 this 了

  • 必须保证 delete this 后没有人使用了

9、new/delete与malloc/free区别

www.cnblogs.com/maluning/p/…

a.属性

  new/delete是C++关键字,需要编译器支持。malloc/free是库函数,需要头文件支持c。

b.参数

  使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而malloc则需要显式地指出所需内存的尺寸。

c.返回类型

  new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*指针转换成我们需要的类型。

e. 分配失败

  new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。

f.自定义类型

​ new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。

​ malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。

g.重载

  C++允许重载new/delete操作符,特别的,布局new的就不需要为对象分配内存,而是指定了一个地址作为内存起始区域,new在这段内存上为对象调用构造函数完成初始化工作,并返回此地址。而malloc不允许重载。

h.内存区域

  new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配,C语言使用malloc从堆上分配内存,使用free释放已分配的对应内存。自由存储区不等于堆,如上所述,布局new就可以不位于堆中。

PS:

在C++中,内存区分为5个区,分别是堆、栈、自由存储区、全局/静态存储区、常量存储区;

在C中,C内存区分为堆、栈、全局/静态存储区、常量存储区;

10、类的拷贝构造函数调用时机

  • 当使用一个已经存在的对象初始化另一个同类型的新对象时;
  • 当函数参数(实参和形参)的类型都是对象,形参与实参结合时(实参初始化形参) ;
  • 当函数的返回值是对象,执行return语句时_(编译器有优化)。

11、类的赋值运算符函数实现四部曲

  1. 考虑自复制

  2. 回收原本的堆空间

  3. 深拷贝(开辟空间再复制)以及其他的数据成员的复制

  4. 返回*this

    Computer & operator=(const Computer & rhs){
        if(this != &rhs){
            delete [] _brand;
            this->_brand = new char[strlen(rhs._brand) + 1]();
            strcpy(_brand,rhs._brand);
            this->_price = rhs._price;
        }
        return *this;
    }

12、类的析构函数调用时机

  • 栈对象生命周期结束时 ,会自动调用析构函数
  • 全局对象在main函数退出时 ,会自动调用析构函数
  • 静态对象在main函数退出时 ,会自动调用析构函数
  • 堆对象当执行delete表达式时 ,会自动调用析构函数

13、单例对象的创建及自动释放的四种方式

单例对象的创建
  1. 将构造、拷贝构造、赋值运算符函数私有化 (确保不使用拷贝构造、赋值运算符函数,使用delete将它们从类中明确“删除”(C++11))
  2. 提供一个静态的本类指针类型的数据成员来存储生成的单例对象,将其初始化为nullptr
  3. 在静态成员函数中创建堆上的对象,利用静态指针数据成员做判断,确保只创建一次 (需要默认构造函数可以使用default简洁地显式提供(C++11))
  4. 定义成员函数实现数据成员的自定义
  5. 提供静态的成员函数用以回收堆对象本身的空间 (非改动析构函数,析构函数是用来回收对象的数据成员申请的堆空间)
#include <string.h>
#include <iostream>
using std::cout;
using std::endl;

class Computer{
public:
    static Computer * getInstance(){
        if(_pInstance == nullptr){
            _pInstance = new Computer("App",10000);
        }
        return _pInstance;
    }

    void init(const char * brand,double price){
        if(_brand){
            delete [] _brand;
        }
        _brand = new char[strlen(brand) + 1]();
        strcpy(_brand,brand);
        _price = price;
    }

    static void destroy(){
        if(_pInstance){
            delete _pInstance;
            _pInstance = nullptr;
        }
        cout << "堆上的对象空间被回收" << endl;
    }
    void print(){
        cout << "brand:" << _brand << endl;
        cout << "price:" << _price << endl;
    }
private:
    Computer(const Computer &) = delete;
    Computer& operator=(const Computer &) = delete;

    Computer() = default;
    Computer(const char * brand,double price)
    : _brand(new char[strlen(brand) + 1]())
    , _price(price)
    { 
        strcpy(_brand,brand);
        cout << "Computer(const char*, double)" << endl;
    }

    ~Computer(){
        if(_brand){
            delete [] _brand;
            _brand = nullptr;
        }
        cout << "~Computer()" << endl;
    }
private:
    char * _brand;
    double _price;
    static Computer * _pInstance;
};
Computer * Computer::_pInstance = nullptr;

void test0(){
    Computer::getInstance()->init("ASUS",7000);
    Computer::getInstance()->print();

    Computer::destroy();
}

int main(void){
    test0();
    return 0;
}

自动释放的四种方式

方式一:定义一个类托管指向单例对象的指针

方式二:在单例类中定义内部类AutoRelease,直接管理 _pInstance

方式三:atexit函数 + destroy函数

  1. 保留单例类的destroy函数

  2. 用atexit注册destroy函数,进程终结时被注册过的函数被调用,完成回收

  3. _pInstance初始化有两种方式(饱汉式、饿汉式)

#include <iostream>
using std::cout;
using std::endl;

class Singleton{
public:
    static Singleton * getInstance(){
        //当多个线程同时进入if语句,可能创建出多个堆对象
        //但是只有一个收到了_pInstance的管辖
        //其他的都被泄漏
        if(_pInstance == nullptr){
            //在第一次创建单例对象时注册回收函数
            atexit(destroy);
            _pInstance = new Singleton(1,2);
        }
        return _pInstance;
    }

    void init(int x,int y){
        _ix = x;
        _iy = y;
    }

    void print(){
        cout << "(" << _ix << ","
            << _iy << ")" << endl;
    }


    Singleton(const Singleton &) = delete;
    Singleton & operator=(const Singleton &) = delete;

    static void destroy(){
        if(_pInstance){
            delete _pInstance;
            _pInstance = nullptr;
        }
        cout << "堆上的单例对象回收啦" << endl;
    }
private:
    Singleton(){
        cout << "Singleton()" << endl;
    }

    Singleton(int x,int y)
        : _ix(x)
          , _iy(y)
    {
        cout << "Singleton(int,int)" << endl;
    }

    ~Singleton(){
        cout << "~Singleton()" << endl;
    }
private:
    int _ix;
    int _iy;
    static Singleton * _pInstance;
};
//饱汉式(懒汉式),懒加载,不到使用该对象就不创建
/* Singleton * Singleton::_pInstance = nullptr; */
//饿汉式,最开始就把对象创建完毕
Singleton * Singleton::_pInstance = getInstance();


void test0(){

    Singleton::getInstance()->init(100,200);
    Singleton::getInstance()->print();

    Singleton::getInstance()->init(500,600);
    Singleton::getInstance()->print();

    Singleton::destroy();
}

int main(void){
    test0();
    return 0;
}

方式四:atexit函数 + pthread_once函数(Linux平台相关)

  1. 单例类添加静态数据成员 static pthread_once_t _once

  2. 单例类另外创建一个初始化的成员函数

  3. 在原本的接口函数getInstance函数中使用pthread_once函数

    (第一个参数:指向静态数据成员_once的指针, 第二个参数:新的初始化函数的函数名)

  4. 编译指令加上多线程参数 -lpthread

  5. 注意

    (1)不允许在类外手动调用destroy函数,如果手动回收了单例对象,之后再通过getInstance函数无法创建出单例对象

    (2)不允许在类外手动调用init_r函数,否则会创建出多个堆上的对象

#include <pthread.h>
#include <iostream>
using std::cout;
using std::endl;

class Singleton{
public:
    static Singleton * getInstance(){
        //确保实际创建单例对象的函数init_r只会被调用一次
        pthread_once(&_once,init_r);
        return _pInstance;
    }


    static void init_r(){
        _pInstance = new Singleton();
        atexit(destroy);
    }

    void init(int x,int y){
        _ix = x;
        _iy = y;
    }

    void print(){
        cout << "(" << _ix << ","
            << _iy << ")" << endl;
    }


    Singleton(const Singleton &) = delete;
    Singleton & operator=(const Singleton &) = delete;

private:
    static void destroy(){
        if(_pInstance){
            delete _pInstance;
            _pInstance = nullptr;
        }
        cout << "堆上的单例对象回收啦" << endl;
    }

    Singleton(){
        cout << "Singleton()" << endl;
    }

    Singleton(int x,int y)
        : _ix(x)
          , _iy(y)
    {
        cout << "Singleton(int,int)" << endl;
    }

    ~Singleton(){
        cout << "~Singleton()" << endl;
    }
private:
    int _ix;
    int _iy;
    static Singleton * _pInstance;
    static pthread_once_t _once;
};
Singleton * Singleton::_pInstance = nullptr;
pthread_once_t Singleton::_once = PTHREAD_ONCE_INIT;


void test0(){

    Singleton::getInstance()->init(100,200);
    Singleton::getInstance()->print();

    Singleton::getInstance()->init(500,600);
    Singleton::getInstance()->print();

}

int main(void){
    test0();
    return 0;
}

14、vector 和 malloc 底层原理实现

15、野指针与悬空指针

1. 什么是野指针(wild pointer)?

A pointer in c which has not been initialized is known as wild pointer.

野指针(wild pointer)就是没有被初始化过的指针。

o foo1.c

1 int main(int argc, char *argv[])
2 {
3     int *p;
4     return (*p & 0x7f); /* XXX: p is a wild pointer */
5 }

如果用"gcc -Wall"编译, 会出现如下警告:

1 $ gcc -Wall -g -m32 -o foo foo.c
2 foo.c: In function ‘main’:
3 foo.c:4:10: warning: ‘p’ is used uninitialized in this function [-Wuninitialized]
4   return (*p & 0x7f); /* XXX: p is a wild pointer */
5           ^

2. 什么是悬空指针(dangling pointer)?

If a pointer still references the original memory after it has been freed, it is called a dangling pointer.

悬空指针是指针最初指向的内存已经被释放了的一种指针。 典型的悬空指针看起来是这样的,(图片来源是这里

img

如果两个指针(p1和p2)指向同一块内存区域, 那么free(p1)后,p1和p2都成为悬空指针。如果进一步将p1设置为NULL, 那么p2还是悬空指针。诚然,使用p1会导致非法内存访问,但是使用p2却会出现无法预料的结果,可谓防不胜防。例如:

o foo2.c

复制代码

1 #include <stdlib.h>
2 int main(int argc, char *argv[])
3 {
4         int *p1 = (int *)malloc(sizeof (int));
5         int *p2 = p1;        /* p2 and p1 are pointing to the same memory */
6         free(p1);            /* p1 is       a dangling pointer, so is p2  */
7         p1 = NULL;           /* p1 is not   a dangling pointer any more   */
8         return (*p2 & 0x7f); /* p2 is still a dangling pointer            */
9 }

复制代码

3. 使用野指针和悬空指针的危害

无论是野指针还是悬空指针,都是指向无效内存区域(这里的无效指的是"不安全不可控")的指针。 访问"不安全可控"(invalid)的内存区域将导致"Undefined Behavior"。

4. 如何避免使用野指针和悬空指针?

如何避免使用野指针? 好办! 养成在定义指针后且在使用之前完成初始化的习惯就好。

然而,如何避免使用悬空指针,就比较麻烦了。 Solaris引入了ADI(Application Data Integrity)技术避免访问已经释放的内存区域,例如: 最新的SPARC平台已经支持KADI,一旦访问某个已经释放掉的内核内存区域,就会引发操作系统panic。这里简单介绍一下什么是ADI。

ADI (Application Data Integrity) is a software layer built on
MCD (Memory Corruption Detection), a SPARC hardware feature that provides
statistical protection against memory corruption errors such as buffer
overflows, use-after-frees, and use-after-reallocs.

KADI allows kernel memory to use ADI.

这有效的避免了foo2.c示例代码中p2变成悬空指针还被使用的情况。 那么问题来了,如果没有ADI/KADI这种高大上的技术,如何避免使用悬空指针?

办法还是有的,直接避免不了就间接避免,那就是所谓的智能指针(smart pointer)。智能指针的本质是使用引用计数(reference counting)来延迟对指针的释放。

16、虚析构函数

虚析构函数是为了解决基类的指针指向派生类对象,并用基类的指针删除派生类对象。虚析构函数使用

class Shape
{
public:
    Shape();                    // 构造函数不能是虚函数
    virtual double calcArea();
    virtual ~Shape();           // 虚析构函数
};
class Circle : public Shape     // 圆形类
{
public:
    virtual double calcArea();
    ...
};
int main()
{
    Shape * shape1 = new Circle(4.0);
    shape1->calcArea();    
    delete shape1;  // 因为Shape有虚析构函数,所以delete释放内存时,先调用子类析构函数,再调用基类析构函数,防止内存泄漏。
    shape1 = NULL;
    return 0;
}
虚函数、纯虚函数

CSDN . C++ 中的虚函数、纯虚函数区别和联系

  • 类里如果声明了虚函数,这个函数是实现的,哪怕是空实现,它的作用就是为了能让这个函数在它的子类里面可以被覆盖,这样的话,这样编译器就可以使用后期绑定来达到多态了。纯虚函数只是一个接口,是个函数的声明而已,它要留到子类里去实现。

  • 虚函数在子类里面也可以不重载的;但纯虚函数必须在子类去实现。

  • 虚函数的类用于 “实作继承”,继承接口的同时也继承了父类的实现。当然大家也可以完成自己的实现。纯虚函数关注的是接口的统一性,实现由子类完成。

  • 带纯虚函数的类叫抽象类,这种类不能直接生成对象,而只有被继承,并重写其虚函数后,才能使用。抽象类和大家口头常说的虚基类还是有区别的,在 C# 中用 abstract 定义抽象类,而在 C++ 中有抽象类的概念,但是没有这个关键字。抽象类被继承后,子类可以继续是抽象类,也可以是普通类,而虚基类,是含有纯虚函数的类,它如果被继承,那么子类就必须实现虚基类里面的所有纯虚函数,其子类不能是抽象类。

虚函数指针、虚函数表
  • 虚函数指针:在含有虚函数类的对象中,指向虚函数表,在运行时确定。

  • 虚函数表:在程序只读数据段(.rodata section,见:目标文件存储结构),存放虚函数指针,如果派生类实现了基类的某个虚函数,则在虚表中覆盖原本基类的那个虚函数指针,在编译时根据类的声明创建。

虚继承

虚继承用于解决多继承条件下的菱形继承问题(浪费存储空间、存在二义性)。

底层实现原理与编译器相关,一般通过虚基类指针虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);当虚继承的子类被当做父类继承时,虚基类指针也会被继承。

实际上,vbptr 指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

虚继承、虚函数
  • 相同之处:都利用了虚指针(均占用类的存储空间)和虚表(均不占用类的存储空间)

  • 不同之处:

    • 虚继承
      • 虚基类依旧存在继承类中,只占用存储空间
      • 虚基类表存储的是虚基类相对直接继承类的偏移
    • 虚函数
      • 虚函数不占用存储空间
      • 虚函数表存储的是虚函数地址