C++类内存布局-1:

413 阅读7分钟

多态基础

virtual table:
虚函数(virtual function) 是通过一张虚函数表 (virtual table) 实现的, 简称为 V-Table, 在 gdb 调试中查看类对象的虚函数表内容的命令是 info vtbl <obj_name>

在这个表内, 存放的是一个类的虚函数的地址, 这张表解决了继承, 覆盖的问题, 保证其真实反应实际的函数.

在包含虚函数的类的实例中 这个表被分配到该实例的内存中 (实际上类对象只包含一个指向虚函数表的指针, 真正的该类的虚函数表 vtable 在Linux/Unix中存放在可执行文件的 只读数据段中(rodata), 每个类只有一张虚函数表,所有类的对象都共用同一张虚函数表, 保证该类的所有对象都含有相同的虚函数表指针值),

意义:
当使用父类的指针 来 操作一个子类的时候, 这张虚函数表就发挥作用了, 它会像一个地图一样指明 实际应该调用的函数.

虚函数表是由编译器自动生成和维护的, virtual 关键字修饰的成员函数会被编译器 放入虚函数表中, 当存在虚函数时, 每一个对象都会有一个指向该类的虚函数表的指针 vptr, 在实现多态的过程中, 基类和派生类都有 vptr 指针.

vptr 的初始化:
当类的对象在创建时, 由编译器来对 vptr 指针进行初始化, 在定义派生类对象时, vptr 先指向基类的虚函数表, 在父类构造函数完成之后, 派生类的 vptr 才指向自己的 虚函数表.

如果 构造函数是虚函数, 那么调用构造函数时 就需要去寻找 vptr (因为 virtual 函数依赖虚函数表查询), 而此时 vptr 还没有初始化, 所以不可实现

constructor() {
    init vptr;
}

虚函数表的指针存在于 对象实例内存中最前面的位置


C++ 类中有 4 种成员:

xx
静态成员静态数据成员被提取出来放在程序的静态数据区内,为该类所有对象共享,因此只存在一份
非静态成员非静态数据成员被放在每一个对象体内 作为对象专有的数据成员
静态函数
非静态函数静态和非静态成员函数最终都被提取出来放在程序的代码段中并为该类所有对象共享,因此 每一个成员函数也只能存在一份代码实体。在c++中类的 成员函数都是保存在静态存储区中的,那静态函数也是保存在静态存储区中的,他们都是在类中保存同一个备份

因此,构成对象本身的只有数据,任何成员函数都不隶属于任何一个对象,非静态成员函数与对象的关系就是绑定,绑定的中介就是 this 指针

成员函数为该类所有对象共享,不仅是处于简化语言实现、节省存储的目的,而且是为了使同类对象有一致的行为, 同类对象的行为虽然一致,但是操作不同的数据成员

多态

父类指针指向子类对象:
virtual_destructor::Base *dynamic_ptr = new virtual_destructor::Derived();
如果基类和派生类 定义了相同名称的成员函数, 那么通过对象指针调用成员函数时, 至于到底调用的是哪个函数需要根据 指针的原型来确定, 而不是根据指针实际指向的对象类型来确定.

比如: dynamic_ptr->func_same(); 调用的就是父类Base中的 func_same();

但是, 如果定义了 virtual 虚函数, 则实际调用的是 指针实际指向的对象类型 子类Derived 的函数 func2()

dynamic_ptr->func_same();
dynamic_ptr->func2(3, 6);


Base::func_same() called ! 
Derived::func2() called ! 3*6=18

1. 单继承无覆写

2. 单继承有覆写

当使用基类指针指向派生类时,虚表指针vptr指向派生类的虚函数表, 这个机制可以保证派生类中的虚函数被调用到

图片.png

class Base {
public:

    virtual ~Base();
    virtual void func0() { ::std::cout << "Base::func0() called !" << ::std::endl; }

    virtual void func1() { ::std::cout << "Base::func1() called !" << ::std::endl; };

    virtual void func2(int a, int b) {
        ::std::cout << "Base::func2() called ! " <<
        a << "+" << b << "=" << a+b << ::std::endl;
    };

    // struct 成员访问权限默认是 public, 默认继承也是 public
    // 虚函数, 在虚函数表中的下标位置 与 虚函数在 第一次 **声明** 时的先后顺序有关,
    // virtual ~Base();

    void func_same() {
        ::std::cout << "Base::func_same() called ! " << ::std::endl;
    }

    int num2_;

private:
    int num1_;
    char c1_;
    double d1_;
};

// 单继承维护一张虚表
struct Derived : public Base {
    // 如果将基类的析构函数声明为虚函数时,
    // 由该基类所派生的所有派生类的析构函数也都自动成为虚函数,
    // 即使派生类的析构函数与基类的析构函数名字不相同
    // 因为: 析构函数的名称会被编译器统一处理成destructor,因此它们完全满足名称、参数列表、返回值类型相同的条件

    // 编译器默认把 Derived() 添加为 虚函数, 也可以手动声明
    // 这样一来,在delete基类指针时,会调用 基类虚函数表中的析构函数,
    // 而这个表中既有基类的虚析构函数,也有派生类的析构函数,
    // 由于派生类的虚函数是  尾加到该虚函数表中的,
    // 所以会 由下而上 自动调用所有的析构函数
    // https://zhuanlan.zhihu.com/p/497882500
    /**
     * 调用子类析构会自动(隐含地)调用父类析构是因为:
     *      编译器会在编译期间 析构函数结尾加上调用父类析构的代码
     */
    virtual ~Derived() override;

    // 定义自类自有的 虚函数 func3
    virtual void func3(int c) {
        ::std::cout << "Derived::func3() called ! " <<
        "c = " << c << ::std::endl;
    }

    // 重写父类虚函数 func2(int a, int b)
    // override 是C++11引进的一个说明符,翻译为 覆盖 的意思
    // C++11 中的 override 关键字,可以显式的在派生类中声明,哪些成员函数需要被重写,如果没被重写,则编译器会报错

    // 在派生类的成员函数中使用 override 时,如果基类中无此函数,或基类中的函数并不是虚函数,编译器会给出相关错误信息

    // 在成员函数声明或定义中, override 确保该函数为虚函数并覆写来自基类的虚函数
    virtual void func2(int a, int b) override {
        ::std::cout << "Derived::func2() called ! " <<
        a << "*" << b << "=" << a*b << ::std::endl;
    }

    void func_same() {
        ::std::cout << "Derived::func_same() called ! " << ::std::endl;
    }

    // int num4_;

protected:
private:
    int num3_;
};
int main(int argc, const char * argv[]) {
    // 实例化基类
    virtual_destructor::Base *base_ptr = new virtual_destructor::Base();
    
    // 实例化派生类
    virtual_destructor::Derived *derived_ptr = new virtual_destructor::Derived();
    
    // 基类 指向 派生类
    virtual_destructor::Base *dynamic_ptr = new virtual_destructor::Derived();

    ::std::cout << "sizeof(*base_ptr)               = " << sizeof(*base_ptr) << ::std::endl;
    ::std::cout << "sizeof(*dynamic_ptr)            = " << sizeof(*dynamic_ptr) << ::std::endl;
    ::std::cout << "sizeof(*(Derived *)dynamic_ptr) = " << sizeof(*(virtual_destructor::Derived *)dynamic_ptr) << ::std::endl;

    // 没有virtual, 调用同名函数时调用 Base::func_same()
    dynamic_ptr->func_same();
    // 有 virtual, 调用的是子类的实现
    dynamic_ptr->func2(3, 6);


    // 指针类型是什么, 定义函数指针就传入对应的指针, 如 Base*
    using VFPtr = void(*)(virtual_destructor::Base*, ...);
    VFPtr **vptr = (VFPtr**)dynamic_ptr;
    VFPtr *vtbl  = *vptr;


    // call ~Base()   ---> call ~Derived()
    vtbl[0](dynamic_ptr);  // call complete object destructor
    vtbl[1](dynamic_ptr);  // call deleting destructor (real delete)


    // call func_0()
    vtbl[2](dynamic_ptr);

    // call func_1()
    vtbl[3](dynamic_ptr);

    // call func_2(int, int)
    vtbl[4](dynamic_ptr, 10, 20);

    // call func_3(int)
    // 实际上是通过偏移调用的, dynamic_ptr 的虚表中不包含
    vtbl[5](dynamic_ptr, 100);


    // 可以看出, C++的 虚析构函数 就是在类对象指针指向的虚函数表中,
    // 用派生类的 析构函数指针  覆盖掉原先的  基类虚析构函数指针
    ::std::cout << ::std::endl;
    delete base_ptr;
    delete derived_ptr;
    return 0;
}

图片.png

来看一下类内存布局:

图片.png

图片.png

3. 多继承有覆写



我吐了💹, 为什么 *derived_ptr 的虚函数表里有两个 ~Derived(); 呢 ???

实际上是因为:
在 gcc 里面虚析构函数是一对: 一个叫做 complete object destructor, 另一个叫做 deleting destructor,

区别在于: 前者只执行析构函数 而不执行 delete(), 后面的是在 析构函数之后执行 deleting 操作,

理解:(StackOverFlow)
in gcc:

So, the rationale for why there are two seems to be this: Say I have A, B. B inherits from A. When I call delete on B, the deleting virtual destructor is call for B, but the non-deleting one will be called for A or else there would be a double delete.

I personally would have expected gcc to generate only one destructor (a non-deleting one) and call delete after instead. This is probably what VS does, which is why I was only finding one destructor in my vtable and not two.

emm 不太明白, 拿出基本功来:(《More Effective C++》, 《C++ Primer》):

  1. 一条 new 表达式的执行过程总是先调用 operator new 函数以获取内存空间,然后在得到内存空间中构造对象。与之相反,一条delete表达式的执行过程总是 先销毁对象然后调用operator delete函数释放对象所占的空间

需要探索的是delete expression的第二个部分operator delete,就是内存释放的部分(第一个部分 销毁对象部分 其实就是传统的析构函数), operator delete是允许用户自定义的部分,只要符合固定的形式,用户可以自定义内存释放的操作



如果:

#include <iostream>

// 如果类中没有数据成员的话, 不会出现问题
class Base1 {
public:
    ~Base1() { std::cout << "~Base1()" << std::endl; }

private:
    int num1_;
};

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

private:
    int num2_;
};

class Derived : public Base1, public Base2 {
public:
    ~Derived() { std::cout << "~Derived()" << std::endl; }

    int num3_;
};

int main(int argc, const char * argv[]) {
    //
    Base2 *obj_b = new Derived();
    delete obj_b;  // 运行报错:  free(): invalid pointer
    return 0;
}

图片.png

(gdb) set p pretty on
(gdb) p *obj_b
$3 = {
  num2_ = 0
}

打印只有 num2_  是因为 obj_b 的类型是 Base2 *,  所以只能按照 Base2 来解析

为什么会错误 ?
Base2 *obj_b = new Derived(); 会对 new 得到的内存地址进行调整 (这里调整的偏移量是4个字节),
由于 new 和 delete 的内存地址不匹配,所以会产生上述错误。但是如果上述多继承体系使用虚析构函数,就不会在delete时产生上述问题



其实 libcxx 中的 new 和 delete:

// https://code.woboq.org/llvm/libcxxabi/src/cxa_new_delete.cpp.html#_ZdlPv
/*
[new.delete.single]
* Executes a loop: Within the loop, the function first attempts to allocate
  the requested storage. Whether the attempt involves a call to the Standard
  C library function malloc is unspecified.

* Returns a pointer to the allocated storage if the attempt is successful.
  Otherwise, if the current new_handler (18.6.2.5) is a null pointer value,
  throws bad_alloc.

* Otherwise, the function calls the current new_handler function (18.6.2.3).
  If the called function returns, the loop repeats.	

* The loop terminates when an attempt to allocate the requested storage is
  successful or when a called new_handler function does not return.
*/
__attribute__((__weak__, __visibility__("default")))
void *
operator new(std::size_t size)
#if !__has_feature(cxx_noexcept)
    throw(std::bad_alloc)
#endif
{
    if (size == 0)
        size = 1;
    void* p;
    while ((p = std::malloc(size)) == 0)
    {
        std::new_handler nh = std::get_new_handler();
        if (nh)
            nh();
        else
            throw std::bad_alloc();
    }
    return p;
}

/* 
[new.delete.single]
If ptr is null, does nothing. Otherwise, reclaims the storage allocated by the earlier call to opertor new.
*/
__attribute__((__weak__, __visibility__("default")))
void
operator delete(void* ptr)
#if __has__feature(cxx_noexcept)
    noexcept
#else
    throw()
#endif
{
    if (ptr)
        std::free(ptr);
}

其实都是通过调用C库中的 malloc 和 free 实现的,发现 new 的时候, 当 new 的对象大小为 0, 还是会强行分配一个 size=1 的内存块, 这个和空对象在栈上会占用 1个字节空间相似.

第二个需要的注意的地方是类型 new_handler,new在分配内存失败时会调用该类型的方法,用户可以自定义这个 handler.

@聪明的你可能会想要写一个 operator new 和 delete 来试一下 ?


现在的问题就变成了, 当free的内存地址和malloc返回的内存地址不相同时会发生什么, 在调用 malloc(size) 时, 实际分配的内存大小大于 size 字节, 这是因为在分配的内存区域头部有类似的结构:

struct control_block {
    unsigned size;
    int used;
};

如果 malloc 函数内部得到的内存区域的首地址为 void *p, 那么 malloc 返回的就是 p + sizeof(control_block), 而调用 free(p) 的时候, 该函数把p减去sizeof(control_block),然后就可以根据 ((control_blcok*)p)->size 得到要释放的内存区域的大小,

这也就是为什么 free只能用来释放malloc分配的内存,如果用于释放其他的内存,会发生未知的错误

所以提出了一种 deleting destructor 的概念,使用该destructor将delete操作包裹起来,以便在delete object的时候,不仅第一步能够实现正确的析构操作,也能在第二步实现正确的delete操作



技巧

子类在其析构函数后添加关键字 override, 这样就可以保证其父类如果缺少关键字 virtual 就会被编译器发现并报错