C++之深入解析虚函数和虚继承的内存模型及其原理

一、多态类型

  • 在 C++ 中,多态类型是指声明或者继承了至少一个虚函数的类型,反之则为非多态类型。
  • 对于非多态类型的变量,其类型信息都可以在编译时确定。例如:
struct A {
    void foo() {}
};

...

A a;

std::cout << typeid(a).name();  // 可以在编译时确定a的类型为A
a.foo();     					// 可以在编译时确定A::foo在内存中的地址
sizeof(a);   					// 尽管A为空,但由于需要在内存中确定a的地址,因此A类型对象所占空间为1个字节
  • 而对于多态类型,一些信息必须延迟到运行时才可以确定,例如它的实际类型、所调用的虚函数的地址等。如下所示的示例中,类型 B 继承声明有虚函数的类型 A,因此 A 和 B 都是多态类型:
struct A {
    virtual void foo() {} // 声明虚函数
};

struct B : public A {
    // 隐式继承虚函数
};

...

B b{};
A& a_rb = b; 					// 将b绑定到A的左值引用a_rb上

typeid(decltype(a_rb)).name();  // decltype产生的是编译时即可确定的声明类型,因此为A
typeid(a_rb).name()// 由于a_rb是多态类型的glvalue,typeid在运行时计算,因此为B

a_rb.foo();  					// 这里调用的是B中的foo,其函数地址是运行时确定的
sizeof(b); 					    // 这里的sizeof是编译器决定的,通常为8 (64位)

二、虚函数内存模型

  • 我们可以用基类型 A 的引用或者指针持有实际类型为派生类 B 的对象,这意味着,编译时我们无法通过其声明类型来确定其实际类型,也就无法确定应该调用哪个具体的虚函数。
  • 考虑到程序中的每个函数都在内存中有着唯一的地址,可以将具体函数的地址作为成员变量,存放在对象之中,这样就可以在运行时,通过访问这个成员变量,获取到实际类型虚函数的地址。

① 单继承内存模型

  • 现代的 C++ 编译器都采用了表格驱动的对象模型。具体来说,对于每一个多态类型,其所有的虚函数的地址都以一个表格的方式存放在一起,每个函数的偏移量在基类型和导出类型中均相同,这使得虚函数相对于表格首地址的偏移量在可以在编译时确定。
  • 虚函数表格的首地址储存在每一个对象之中,称为虚(表)指针(vptr)或者虚函数指针(vfptr),这个虚指针始终位于对象的起始地址。使用多态类型的引用或指针调用虚函数时,首先通过虚指针和偏移量计算出虚函数的地址,然后进行调用。
  • 例如,有如下所示的类型 A 和 B:
struct A {
    int ax; // 成员变量
    virtual void f0() {}
    virtual void f1() {}
};

struct B : public A {
    int bx; // 成员变量
    void f0() override {}; // 重写f0
};
  • 它们的对象模型和虚表模型如下所示:
struct A
 object                                            A VTable (不完整)
     0 - vptr_A -------------------------------->  +--------------+
     8 - int ax                                    |    A::f0()   |
sizeof(A): 16    align: 8                          +--------------+
                                                   |    A::f1()   |
                                                   +--------------+

struct B
 object                                         
     0 - struct A                                  B VTable (不完整)
     0 -   vptr_A ------------------------------>  +--------------+
     8 -   int ax                                  |    B::f0()   |
    12 - int bx                                    +--------------+
sizeof(A): 16    align: 8                          |    A::f1()   |
                                                   +--------------+
  • 不难注意到,由于 B 重写了方法 f0(),因此它的虚表在同样的位置,将 A::f0() 覆盖为 B::f0()。当发生 f0() 函数调用时,对于实际类型为 A 的对象,其 VTable 偏移量为 offset0 的位置为 A::f0(), 对于实际类型为 B 的对象,对应位置为 B::f0(),这样就实现了运行时虚函数函数地址的正确选择:
A a;
B b;
A &a_ra = a;
A &a_rb = b;
a_ra.f0(); // call (a_ra->vptr_A + offset0) --> A::f0()
a_rb.f0(); // call (a_rb->vptr_A + 0ffset0) --> B::f0()
  • 在以上的例子中,B 中虚函数都已经在 A 中声明过,如果类型B中出现了基类型 A 中没有的虚函数,新的虚函数将会被附加在虚函数表的最后,不会对与基类重合的部分造成影响。例如 B 中新增加函数 f2(),虚函数表变化如下:
 struct B
 object                                         
     0 - struct A                                  B VTable (不完整)
     0 -   vptr_A ------------------------------>  +--------------+
     8 -   int ax                                  |    B::f0()   |
    12 - int bx                                    +--------------+
sizeof(A): 16    align: 8                          |    A::f1()   |
                                                   +--------------+
                                                   |    B::f2()   |
                                                   +--------------+
  • 对于多态类型,除了要在运行时确定虚函数地址外,还需要提供运行时类型信息(Run-Time Type Identification, RTTI)的支持。一个显然的解决方案是,将类型信息的地址加入到虚表之中。为了避免虚函数表长度对其位置的影响,g++ 将它放在虚函数表的前,所示如下:
struct B                                          B VTable (不完整)
 object                                            +--------------+
     0 - struct A                                  |  RTTI for B  |
     0 -   vptr_A ------------------------------>  +--------------+
     8 -   int ax                                  |    B::f0()   |
    12 - int bx                                    +--------------+
sizeof(A): 16    align: 8                          |    A::f1()   |
                                                   +--------------+
                                                   |    B::f2()   |
                                                   +--------------+
  • 现在的虚表中,不仅含有函数地址,还含有 RTTI 的地址,之后还会加入许多新项目虚表中的每一项都称作一个实体(entity)。
  • 上述的解决方案,可以很好的处理单链继承的情况,在单链继承中,每一个派生类型都包含了其基类型的数据以及虚函数,这些虚函数可以按照继承顺序,依次排列在同一张虚表之中,因此只需要一个虚指针即可。并且由于每一个派生类都包含它的直接基类,且没有第二个直接基类,因此其数据在内存中也是线性排布的,这意味着实际类型与它所有的基类型都有着相同的起始地址。
  • 例如,B 继承 A,C 继承 B,它们的定义和内存模型如下所示:
struct A {
    int ax;
    virtual void f0() {}
};

struct B : public A {
    int bx;
    virtual void f1() {}
};

struct C : public B {
    int cx;
    void f0() override {}
    virtual void f2() {}
};
  • 内存模型为:
                                                     C VTable(不完整)
struct C                                              +------------+
object                                                | RTTI for C |
    0 - struct B                            +-------> +------------+
    0 -   struct A                          |         |   C::f0()  |
    0 -     vptr_A -------------------------+         +------------+
    8 -     int ax                                    |   B::f1()  |
   12 -   int bx                                      +------------+
   16 - int cx                                        |   C::f2()  |
sizeof(C): 24    align: 8                             +------------+
  • 从上面的代码可以看出,使用一个类型 A 或 B 的引用持有实际类型为 C 的对象,它的起始地址仍然指向 C 的起始地址,这意味着单链继承的情况下,动态向下转换和向上转换时,不需要对 this 指针的地址做出任何修改,只需要对其重新“解释”。然而,并非所有派生类都是单链继承的,它们的起始地址和其基类的起始地址不一定始终相同。

② 多继承内存模型

  • 假设类型 C 同时继承了两个独立的基类 A 和 B, 它们的定义关系如下:
struct A {
    int ax;
    virtual void f0() {}
};

struct B {
    int bx;
    virtual void f1() {}
};

struct C : public A, public B {
    int cx;
    void f0() override {}
    void f1() override {}
};
  • 与单链继承不同,由于 A 和 B 完全独立,它们的虚函数没有顺序关系,即 f0 和 f1 有着相同对虚表起始位置的偏移量,不可以顺序排布。 并且 A 和 B 中的成员变量也是无关的,因此基类间也不具有包含关系,这使得 A 和 B 在 C 中必须要处于两个不相交的区域中,同时需要有两个虚指针分别对它们虚函数进行索引。其内存布局如下所示:
                                                C Vtable (7 entities)
                                                +--------------------+
struct C                                        | offset_to_top (0)  |
object                                          +--------------------+
    0 - struct A (primary base)                 |     RTTI for C     |
    0 -   vptr_A -----------------------------> +--------------------+       
    8 -   int ax                                |       C::f0()      |
   16 - struct B                                +--------------------+
   16 -   vptr_B ----------------------+        |       C::f1()      |
   24 -   int bx                       |        +--------------------+
   28 - int cx                         |        | offset_to_top (-16)|
sizeof(C): 32    align: 8              |        +--------------------+
                                       |        |     RTTI for C     |
                                       +------> +--------------------+
                                                |    Thunk C::f1()   |
                                                +--------------------+
  • 在如上所示的布局中,C 将 A 作为主基类,也就是将它虚函数“并入” A 的虚函数表之中,并将 A 的虚指针作为 C 的内存起始地址。而类型 B 的虚指针 vptr_B 并不能直接指向虚表中的第 4 个实体,这是因为 vptr_B 所指向的虚表区域,在格式上必须也是一个完整的虚表,因此,需要为 vptr_B 创建对应的虚表放在虚表 A 的部分之后,可以看到,出现了两个“新”的实体,一个是 offset_to_top,另一个是 Thunk。
  • 在多继承中,由于不同的基类起点可能处于不同的位置,因此当需要将它们转化为实际类型时,this 指针的偏移量也不相同。由于实际类型在编译时是未知的,这要求偏移量必须能够在运行时获取。实体 offset_to_top 表示的就是实际类型起始地址到当前这个形式类型起始地址的偏移量,在向上动态转换到实际类型时,让 this 指针加上这个偏移量即可得到实际类型的地址。
  • 需要注意的是,由于一个类型即可以被单继承,也可以被多继承,因此即使只有单继承,实体 offset_to_top 也会存在于每一个多态类型之中。而实体 Thunk 又是什么呢?如果不考虑这个 Thunk,这里应该存放函数 C::f1() 的地址。然而,dump 虚表可以看到,Thunk C::f1() 和 C::f1() 的地址并不一样。
  • 为了弄清楚 Thunk 是什么,首先要注意到,如果一个类型 B 的引用持有了实际类型为 C 的变量,这个引用的起始地址在 C+16 处,当它调用由类型 C 重写的函数 f1() 时,如果直接使用 this 指针调用 C::f1() 会由于 this 指针的地址多出16字节的偏移量导致错误。因此在调用之前,this 指针必须要被调整至正确的位置,这里的 Thunk 起到的就是这个作用:首先将 this 指针调整到正确的位置,即减少 16 字节偏移量,然后再去调用函数 C::f1()。

③ 构造与析构过程

  • 在多态类型的构造和析构过程中,所调用的虚函数并不是最终的实际类型的对应函数,而是当前已经创建了的(或尚未析构的)类型的对应函数。如下所示的两个类型 A 和 B, 它们在构造和析构时都会调用对应的虚函数:
struct A {
    virtual void f0() { 
    	std::cout << "A\n"; 
	}
    A() { 
    	this->f0(); 
	}
    virtual ~A() { this->f0(); }
};

struct B : public A {
    virtual void f0() { 
    	std::cout << "B\n"; 
	}

    B() { 
    	this->f0(); 
	}

    ~B() override { 
    	this->f0(); 
    }
};

int main() {
    B b;
    return 0;
} // 输出:ABBA
  • 运行上述程序,可以得到输出“ABBA”,表明程序依次调用了A::A()、B::B()、B::~B()、A::~A()。直观上理解,在构造 A 时,B 中的数据还没有创建,因此 B 重写的虚函数当然不可使用,因此应该调用 A 中的版本;反过来,析构的时候,由于 B 先析构,在 B 析构之后,B 中的函数当然也不可用,因此也应该调用 A 中的版本。在程序运行中,这一过程是通过动态的修改对象的虚指针实现的。
  • 根据 C++ 中继承类的构造顺序,首先基类 A 被构造,在构造 A 时, 对象自身的虚指针指向 A 的虚表,由于 A 的虚表中,f0() 的位置保存着 A::f0() 的地址,因此 A::f0() 被调用。在 A 的构造结束后,B 的构造启动,此时虚指针被修改为指向 B 的虚表,析构过程与此相反。

三、虚继承的内存模型

  • 上述的模型中,对于派生类对象,它的基类相对于它的偏移量总是确定的,因此动态向下转换并不需要依赖额外的运行时信息。
  • 而虚继承破坏了这一条件,它表示虚基类相对于派生类的偏移量可以依实际类型不同而不同,且仅有一份拷贝,这使得虚基类的偏移量在运行时才可以确定。因此,需要对继承了虚基类的类型的虚表进行扩充,使其包含关于虚基类偏移量的信息。

① 菱形继承的内存模型

  • 如下,展示了一个经典的菱形虚继承关系,为了避免重复包含 A 中的成员,类型 B 和 C 分别虚继承 A,类型 D 继承了 B 和 C。依据其继承方式的不同,D 中的 B、C 的偏移量可以在编译时确定,而 A 的偏移量在运行时确定:
struct A {
    int ax;
    virtual void f0() {}
    virtual void bar() {}
};

struct B : virtual public A           /****************************/
{                                     /*                          */
    int bx;                           /*             A            */
    void f0() override {}             /*           v/ \v          */
};                                    /*           /   \          */ 
                                      /*          B     C         */
struct C : virtual public A           /*           \   /          */
{                                     /*            \ /           */
    int cx;                           /*             D            */ 
    void f0() override {}             /*                          */
};                                    /****************************/

struct D : public B, public C {
    int dx;
    void f0() override {}
};
  • 首先对类型 A 的内存模型进行分析,由于虚继承影响的是子类,不会对父类造成影响,因此 A 的内存布局和虚表都没有改变:
                                                   A VTable
                                                   +------------------+
                                                   | offset_to_top(0) |
struct A                                           +------------------+
 object                                            |    RTTI for A    |
     0 - vptr_A -------------------------------->  +------------------+
     8 - int ax                                    |      A::f0()     |
sizeof(A): 16    align: 8                          +------------------+
                                                   |      A::bar()    |
                                                   +------------------+
  • 类型 B 类和类型 C 没有本质的区别,因此只分析类型 B,如下所示为类型 B 的内存模型:
                                          B VTable
                                          +---------------------+
                                          |   vbase_offset(16)  |
                                          +---------------------+
                                          |   offset_to_top(0)  |
struct B                                  +---------------------+
object                                    |      RTTI for B     |
    0 - vptr_B -------------------------> +---------------------+
    8 - int bx                            |       B::f0()       |
   16 - struct A                          +---------------------+
   16 -   vptr_A --------------+          |   vcall_offset(0)   |x--------+
   24 -   int ax               |          +---------------------+         |
                               |          |   vcall_offset(-16) |o----+   |
                               |          +---------------------+     |   |
                               |          |  offset_to_top(-16) |     |   |
                               |          +---------------------+     |   |
                               |          |      RTTI for B     |     |   |
                               +--------> +---------------------+     |   |
                                          |     Thunk B::f0()   |o----+   |
                                          +---------------------+         |
                                          |       A::bar()      |x--------+
                                          +---------------------+
  • 对于形式类型为B的引用,在编译时,无法确定它的基类 A 它在内存中的偏移量。 因此,需要在虚表中额外再提供一个实体,表明运行时它的基类所在的位置,这个实体称为 vbase_offset,位于 offset_to_top 上方。
  • 除此之外,如果在 B 中调用 A 声明且 B 没有重写的函数,由于 A 的偏移量无法在编译时确定,而这些函数的调用由必须在 A 的偏移量确定之后进行, 因此这些函数的调用相当于使用A的引用调用。也因此,当使用虚基类 A 的引用调用重载函数时 ,每一个函数对 this 指针的偏移量调整都可能不同,它们被记录在镜像位置的 vcall_offset 中。
  • 例如,调用 A::bar() 时,this 指针指向的是 vptr_A,正是函数所属的类 A 的位置,因此不需要调整,即 vcall_offset(0);而 B::f0() 是由类型 B 实现的, 因此需要将 this 指针向前调整 16 字节。对于类型 D,它的虚表更为复杂,但虚表中的实体都已熟悉,如下为 D 的内存模型:
                                          D VTable
                                          +---------------------+
                                          |   vbase_offset(32)  |
                                          +---------------------+
struct D                                  |   offset_to_top(0)  |
object                                    +---------------------+
    0 - struct B (primary base)           |      RTTI for D     |
    0 -   vptr_B  ----------------------> +---------------------+
    8 -   int bx                          |       D::f0()       |
   16 - struct C                          +---------------------+
   16 -   vptr_C  ------------------+     |   vbase_offset(16)  |
   24 -   int cx                    |     +---------------------+
   28 - int dx                      |     |  offset_to_top(-16) |
   32 - struct A (virtual base)     |     +---------------------+
   32 -   vptr_A --------------+    |     |      RTTI for D     |
   40 -   int ax               |    +---> +---------------------+
sizeof(D): 48    align: 8      |          |       D::f0()       |
                               |          +---------------------+
                               |          |   vcall_offset(0)   |x--------+
                               |          +---------------------+         |
                               |          |   vcall_offset(-32) |o----+   |
                               |          +---------------------+     |   |
                               |          |  offset_to_top(-32) |     |   |
                               |          +---------------------+     |   |
                               |          |      RTTI for D     |     |   |
                               +--------> +---------------------+     |   |
                                          |     Thunk D::f0()   |o----+   |
                                          +---------------------+         |
                                          |       A::bar()      |x--------+
                                          +---------------------+     

② 构造与析构过程

  • 与非虚继承相似,通过虚继承产生的派生类在构造和析构时,所调用的虚函数只是当前阶段的的虚表中对应的函数。一个问题也就由此产生,由于在虚基类的不同的派生类中,虚基类相对于该类型的偏移量是可以不同的,如果直接使用上面二中③的方法,直接用继承虚基类的类型自身的虚表作为构建该类时使用的虚表,会由于偏移量的不同,导致无法正确获取虚基类中的对象。
  • 这个描述比较抽象,通过上面三中的①的菱形继承的例子进行解释,四个类型 A,B,C 和 D 的继承关系如下所示:
struct A {
    int ax;
    virtual void f0() {}
    virtual void bar() {}
};

struct B : virtual public A           /****************************/
{                                     /*                          */
    int bx;                           /*             A            */
    void f0() override {}             /*           v/ \v          */
};                                    /*           /   \          */
                                      /*          B     C         */
struct C : virtual public A           /*           \   /          */
{                                     /*            \ /           */
    int cx;                           /*             D            */
    virtual void f1() {}              /*                          */
};                                    /****************************/


struct D : public B, public C {
    int dx;
    void f0() override {}
};
  • 观察实际类型为 B 和实际类型为 D 对象的内存布局可以发现,如果实际类型为 B,虚基类 A 对 B 的首地址的偏移量为 16;若实际类型为 D,则其中 A 对 B 首地址的偏移量为 32,这明显与 B 自身的虚表冲突,如果构建 D::B 时还采用的是 B 自身的虚表,会由于偏移量的不同导致错误。这一问题的解决方法其实很粗暴,那就是在对象构造、析构阶段,会用到多少种虚表,会用到多少种虚指针就生成多少种虚指针,在构造或析构时,“按需分配”。
  • 例如,类型 D 是类型 B 和 C 的子类,而 B 和 C 虚继承了类型 A,这种继承关系会导致 D 内部含有的 B(称作 B-in-D)、C(称作 C-in-D)的虚表与 B、C 的虚表不同,因此,这需要生成两张新的虚表,即 B-in-D 和 C-in-D 的虚表。由于 B-in-D 也是 B 类型的一种布局,B 的一个虚表对应两个虚指针,分别是 vptr_B 和 vptr_A,因此它也有两个着两个虚指针,在构造或析构 D::B 时,其对 象的内存布局和虚表布局如图所示:
                                          B-in-D VTable
                                          +---------------------+
                                          |   vbase_offset(32)  |
                                          +---------------------+
struct D (Constructing/Deconstructing B)  |   offset_to_top(0)  |
object                                    +---------------------+
    0 - struct B (primary base)           |      RTTI for B     |
    0 -   vptr_B -----------------------> +---------------------+
    8 -   int bx                          |       B::f0()       |
   16 - struct C                          +---------------------+
   16 -   vptr_C                          |   vcall_offset(0)   |x--------+
   24 -   int cx                          +---------------------+         |
   28 - int dx                            |   vcall_offset(-32) |o----+   |
   32 - struct A (virtual base)           +---------------------+     |   |
   32 -   vptr_A --------------+          |  offset_to_top(-32) |     |   |
   40 -   int ax               |          +---------------------+     |   |
sizeof(D): 48    align: 8      |          |      RTTI for B     |     |   |
                               +--------> +---------------------+     |   |
                                          |     Thunk B::f0()   |o----+   |
                                          +---------------------+         |
                                          |       A::bar()      |x--------+
                                          +---------------------+
  • 同样的,在 C-in-D 中也会有两个虚指针,分别是 vptr_C 和 vptr_A。此外,在最终的 D 中还有三个虚指针,总计 7 个不同的虚指针,它们指向 3 张虚表的 7 个不同位置。因此编译器为类型 D 总共生成了 3 个不同的虚表,和 7 个不同的虚指针,将这 7 个虚指针合并到一个表中,这个表就是虚表的表(Virtual Table Table, VTT)。显然,只有当一个类的父类是继承了虚基类的类型时,编译器才会为它创建 VTT。
  • 在构造和析构过程中,子类的构造函数或析构函数向基类传递一个合适的、指向 VTT 某个部分指针,使得父类的构造函数或析构函数获取到正确的虚表。

四、扩展

  • C++ 的运行时多态的内存模型是一个相对较复杂的问题,只是看一两遍很难理解,最好的理解方法是 dump 出内存中对象的内存模型和类型的虚表的结构。
  • 使用 Clang++ 编译器,可以通过下面的命令导出 main.cpp 中类型的内存模型和虚表模型:
clang++ -cc1 -emit-llvm -fdump-record-layouts -fdump-vtable-layouts  main.cpp
  • 需要注意,类型至少定义了一个变量,否则会被编译器优化掉。例如,有继承关系 A<-B<-C,需要 至少定义一个 C 类型的对象。使用 g++ 导出继承结构的指令如下:
g++ -fdump-class-hierarchy -c main.cpp
  • 由于 g++ 的 dump 出的名称是其内部表示,因此还需要使用 c++filt 导出具有一定可读性的文档:
cat [g++导出的文档] | c++filt -n > [具有一定可读性的输出文档]
  • 此外,还可以通过 gdb 跟踪内存、寄存器的变化,观察虚函数、Thunk的寻址过程,以及 this 指针的变化。
  • 对于 g++,它采用的是安腾 ABI(Application Binary Interface),如果想了解其详细的内存布局,可以参考:Itanium C++ ABI。而对于 vc++,内存的布局稍有不同,它将虚基类的偏移量单独用一个额外的指针进行索引,因此对于虚继承的类,除了指向虚函数表的 vfptr 外,还会在它的后面紧随有一个指向虚基类偏移量表的指针 vbptr。
  • 除此之外,vc++ 将空子类的虚指针,或者具有与基类相同虚函数接口的派生类的虚指针与虚基类的虚指针进行合并,这意味着有的时候,对象的首个地址存放的可能是 vbptr 而非 vfptr。

五、总结

  • 虚函数地址通过虚指针索引的虚函数表在运行时确定;
  • 虚表中不仅储存了虚函数的地址,还储存了类型 RTTI 的地址、距实际类型首地址偏移量等信息;
  • 虚函数的调用可能涉及到 this 指针的变更,需要 Thunk 等方式实现;
  • 对于虚基类的派生类,虚基类的偏移量由实际类型决定,因此在运行时才可以确定虚基类的地址;
  • 在多态类型的构造和析构过程中,通过修改虚指针使其指向不同的虚表,可以实现在不同的阶段调用不同的虚函数;
  • 对于虚继承的情况,由于同一类型虚表的虚表在不同具体类型中可以不同,在构造和析构时,需要通过 VTT 传递正确的虚表。
  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

╰つ栺尖篴夢ゞ

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值