3.lua Table实现

1. 数据结构的定义

table的定义

typedef struct Table {
  CommonHeader;
  lu_byte flags;  //是否存在元方法
  lu_byte lsizenode;  //hash部分的大小(这个值是以2为底的对数值)
  unsigned int alimit;  //数组大小
  TValue *array;  //数组部分
  Node *node;	  //hash部分
  Node *lastfree;  //指向hash部分最后一个未使用的节点
  struct Table *metatable;//元表
  GCObject *gclist;
} Table;

hash节点的定义

typedef union Node {
  struct NodeKey {
    TValuefields;  	//value
    lu_byte key_tt;  //key的类型
    int next;  		//next指针(保存下一个节点相对于本节点的位置)
    Value key_val;  //key value
  } u;
  TValue i_val;  //value
} Node;

快捷访问宏

#define gval(n)		(&(n)->i_val) //获取node的value
#define gnext(n)	((n)->u.next) //next指针

2. 存储方式

在这里插入图片描述

table中有两个存储部分,数组部分和hash部分。在插入整数类型的key时优先选择的是数组部分。hash部分本质也是一个数组,区别于字符串hash表的实现,table中的hash表使用的节点都是预先分配好的,不会插入时去申请内存,直到这个数组中的元素全部使用完,才会去扩容。正是以为如此node中的next指针是一个int类型,这个值表示的是下一个节点相对于本节点位置。

​ 为什么hash部分不使用之前字符串hash表中存储方式?

​ 个人认为这样子更方便去遍历table,这样子实现意味着只要遍历两个数组就可以遍历整个hash表

3. 实现

3.1 hash

//两种取余运算 (&上(size-1) 或者是 % )
#define lmod(s,size) \
	(check_exp((size&(size-1))==0, (cast_int((s) & ((size)-1)))))
#define hashpow2(t,n)		(gnode(t, lmod((n), sizenode(t))))
#define hashmod(t,n)	(gnode(t, ((n) % ((sizenode(t)-1)|1))))

//字符串 hash
#define hashstr(t,str)		hashpow2(t, (str)->hash)
//bool hash
#define hashboolean(t,p)	hashpow2(t, p)
//int hash
#define hashint(t,i)		hashpow2(t, i)
//地址hash
#define hashpointer(t,p)	hashmod(t, point2uint(p))

//float hash算法
static int l_hashfloat (lua_Number n) {
  int i;
  lua_Integer ni;
  n = l_mathop(frexp)(n, &i) * -cast_num(INT_MIN);
  if (!lua_numbertointeger(n, &ni)) {  /* is 'n' inf/-inf/NaN? */
    lua_assert(luai_numisnan(n) || l_mathop(fabs)(n) == cast_num(HUGE_VAL));
    return 0;
  }
  else {  /* normal case */
    unsigned int u = cast_uint(i) + cast_uint(ni);
    return cast_int(u <= cast_uint(INT_MAX) ? u : ~u);
  }
}

各个类型的hash算法

static Node *mainposition (const Table *t, int ktt, const Value *kvl) {
  switch (withvariant(ktt)) {
    case LUA_VNUMINT: {//int
      lua_Integer key = ivalueraw(*kvl);
      return hashint(t, key);
    }
    case LUA_VNUMFLT: {//float
      lua_Number n = fltvalueraw(*kvl);
      return hashmod(t, l_hashfloat(n));
    }
    //string
    case LUA_VSHRSTR: {
      TString *ts = tsvalueraw(*kvl);
      return hashstr(t, ts);
    }
    case LUA_VLNGSTR: {
      TString *ts = tsvalueraw(*kvl);
      return hashpow2(t, luaS_hashlongstr(ts));
    }
    //bool
    case LUA_VFALSE:
      return hashboolean(t, 0);
    case LUA_VTRUE:
      return hashboolean(t, 1);
    //下面三个都是使用地址作为hash值
    case LUA_VLIGHTUSERDATA: {
      void *p = pvalueraw(*kvl);
      return hashpointer(t, p);
    }
    case LUA_VLCF: {
      lua_CFunction f = fvalueraw(*kvl);
      return hashpointer(t, f);
    }
    default: {
      GCObject *o = gcvalueraw(*kvl);
      return hashpointer(t, o);
    }
  }
}

这个函数用于集合各个类型的hash函数,返回对应的节点

3.2 创建

3.2.1 创建空表

Table *luaH_new (lua_State *L) {
  //new sizeof(Table)
  GCObject *o = luaC_newobj(L, LUA_VTABLE, sizeof(Table));
  Table *t = gco2t(o);
    
  t->metatable = NULL;
  t->flags = cast_byte(maskflags);  //这个值表示没有元方法
  t->array = NULL;
  t->alimit = 0;
  setnodevector(L, t, 0);			//初始化hash部分
  return t;
}

前面两句类似于 Table *t = sizeof(sizeof(Table)) ,这么做只是由于lua中有gc机制,需要做一下gc相关的操作

static void setnodevector (lua_State *L, Table *t, unsigned int size) {
  if (size == 0) {  /* no elements to hash part? */
    t->node = cast(Node *, dummynode);  /* use common 'dummynode' */
    t->lsizenode = 0;
    t->lastfree = NULL;  /* signal that it is using dummy node */
  }
  ...
}

dummynode 类似于哨兵节点,和NULL意思差不多

Table大多成员初始化都为0

3.2.2 空间分配

void luaH_resize (lua_State *L, Table *t, unsigned int newasize,
                                          unsigned int nhsize) {
  unsigned int i;
  Table newt;
  unsigned int oldasize = setlimittosize(t);
  TValue *newarray;
  setnodevector(L, &newt, nhsize);//申请新的hash部分内存
  ...
  
  newarray = luaM_reallocvector(L, t->array, oldasize, newasize, TValue);//realloc 数组部分的内存
  ...
  t->array = newarray;  //更新数组部分,
  t->alimit = newasize;
  for (i = oldasize; i < newasize; i++)  //把数组部分扩容的部分初始化
     setempty(&t->array[i]);
  ...
}
static void setnodevector (lua_State *L, Table *t, unsigned int size) {
  if (size == 0) {  /* no elements to hash part? */
    ...
  }
  else {
    int i;
    int lsize = luaO_ceillog2(size);    //取以2为低的对数(向上取整的方式)
    if (lsize > MAXHBITS || (1u << lsize) > MAXHSIZE)
      luaG_runerror(L, "table overflow");
    size = twoto(lsize);                //2^lsize
    t->node = luaM_newvector(L, size, Node);//申请 (sizeof(Node) * size) 的内存
    for (i = 0; i < (int)size; i++) {   //初始化node数组
      Node *n = gnode(t, i);
      gnext(n) = 0;
      setnilkey(n);
      setempty(gval(n));
    }
    t->lsizenode = cast_byte(lsize);    //保存以2为低的对数值(用于表示node的大小)
    t->lastfree = gnode(t, size);       //指向最后一个node节点
  }
}

空间分配直接调用luaH_resize函数, newasize为数组部分的大小,nhsize为hash部分的大小

3.3 获取

const TValue *luaH_get (Table *t, const TValue *key) {
  switch (ttypetag(key)) {
    case LUA_VSHRSTR: return luaH_getshortstr(t, tsvalue(key));
    case LUA_VNUMINT: return luaH_getint(t, ivalue(key));
    case LUA_VNIL: return &absentkey;
    case LUA_VNUMFLT: {//转换为int,如果失败会去default
      lua_Integer k;
      if (luaV_flttointeger(fltvalue(key), &k, F2Ieq)) //尝试转换成int,如果有小数会转换失败
        return luaH_getint(t, k);  /* use specialized version */
      /* else... */
    }  /* FALLTHROUGH */
    default:
      return getgeneric(t, key, 0);
  }
}

table获取值的总入口,根据key的类型去使用不同类型的hash方法,

string

const TValue *luaH_getshortstr (Table *t, TString *key) {
  Node *n = hashstr(t, key); //找到对应的槽
  lua_assert(key->tt == LUA_VSHRSTR);
  for (;;) {  //查找key(遍历链表)
    if (keyisshrstr(n) && eqshrstr(keystrval(n), key))//比较
      return gval(n);  /* that's it */
    else { //next
      int nx = gnext(n);
      if (nx == 0)
        return &absentkey;  /* not found */
      n += nx;
    }
  }
}

int

const TValue *luaH_getint (Table *t, lua_Integer key) {
  if (l_castS2U(key) - 1u < t->alimit)  //如果这个key 在 1到t->alimit之间,是数组部分的key
    return &t->array[key - 1];
  else if (!limitequalsasize(t) &&  /* key still may be in the array part? */
           (l_castS2U(key) == t->alimit + 1 ||
            l_castS2U(key) - 1u < luaH_realasize(t))) {
    t->alimit = cast_uint(key);  /* probably '#t' is here now */
    return &t->array[key - 1];
  }
  else {
    Node *n = hashint(t, key); //找到对应的槽
    for (;;) {  //查找key(遍历链表)
      if (keyisinteger(n) && keyival(n) == key)//比较
        return gval(n);  /* that's it */
      else {//next
        int nx = gnext(n);
        if (nx == 0) break;
        n += nx;
      }
    }
    return &absentkey;
  }
}

默认的方法

static const TValue *getgeneric (Table *t, const TValue *key, int deadok) {
  Node *n = mainpositionTV(t, key);
  for (;;) {  /* check whether 'key' is somewhere in the chain */
    if (equalkey(key, n, deadok))
      return gval(n);  /* that's it */
    else {
      int nx = gnext(n);
      if (nx == 0)
        return &absentkey;  /* not found */
      n += nx;
    }
  }
}

可以看到三个方法的算法是类似的

  • 使用hash算法得到node头
  • 查找这个key,找到返回,没找到返回哨兵节点

3.4 添加

void luaH_finishset (lua_State *L, Table *t, const TValue *key,
                                   const TValue *slot, TValue *value) {
  if (isabstkey(slot)) //这个key不存在
    luaH_newkey(L, t, key, value);
  else //存在直接设置值
    setobj2t(L, cast(TValue *, slot), value);
}

void luaH_set (lua_State *L, Table *t, const TValue *key, TValue *value) {
  const TValue *slot = luaH_get(t, key);//获取这个key
  luaH_finishset(L, t, key, slot, value);
}

luaH_set 这个是添加key的入口,实现主逻辑也相对简单

  • 使用luaH_get 获取这个key对应的node
  • 如果这个key不存在就创建,存在则重新赋值

3.4.1 创建key

void luaH_newkey (lua_State *L, Table *t, const TValue *key, TValue *value) {
  Node *mp;
  TValue aux;
  if (l_unlikely(ttisnil(key)))//判空
    luaG_runerror(L, "table index is nil");
  else if (ttisfloat(key)) {//如果是float
    lua_Number f = fltvalue(key);
    lua_Integer k;
    if (luaV_flttointeger(f, &k, F2Ieq)) {  //把float尝试转换成int,使用F2Ieq这个值,如果float有小数转换会失败
      setivalue(&aux, k);
      key = &aux;  /* insert it as an integer */
    }
    else if (l_unlikely(luai_numisnan(f)))//转换失败
      luaG_runerror(L, "table index is NaN");
  }
  if (ttisnil(value))
    return;  /* do not insert nil values */
  mp = mainpositionTV(t, key);//hash,返回对应的槽
  if (!isempty(gval(mp)) || isdummy(t)) {  //如果table是空的,或者当前位置已经被使用
    Node *othern;
    Node *f = getfreepos(t);  //获取一个未被使用的node
    if (f == NULL) {  //表示node都已经使用完
      rehash(L, t, key);  //调整table
      /* whatever called 'newkey' takes care of TM cache */
      luaH_set(L, t, key, value);  /* insert key into grown table */
      return;
    }
    lua_assert(!isdummy(t));
    othern = mainposition(t, keytt(mp), &keyval(mp));//找到这个node的hash头
    if (othern != mp) {  //如果这个node不是本身,说明这个节点已经变为别的节点的链表节点
      //用一个为使用的节点替换
      while (othern + gnext(othern) != mp)  //找到node在这个槽的位置
        othern += gnext(othern);
      gnext(othern) = cast_int(f - othern);  //node的前一个节点指向新的节点
      *f = *mp;  //节点值拷贝
      if (gnext(mp) != 0) {//如果node还有后续节点
        gnext(f) += cast_int(mp - f);  //更新next指向
        gnext(mp) = 0;  /* now 'mp' is free */
      }
      setempty(gval(mp));
    }
    else {  //如果这个node是头
      //插入未使用的节点
      if (gnext(mp) != 0)//如果node有后续链表
        gnext(f) = cast_int((mp + gnext(mp)) - f);  //新节点指向后续节点
      else lua_assert(gnext(f) == 0);
      gnext(mp) = cast_int(f - mp);//当前节点指向新节点(头插法完成)
      mp = f;//更新mp,用于函数的默认处理
    }
  }
  setnodekey(L, mp, key);//赋值key
  luaC_barrierback(L, obj2gco(t), key);
  lua_assert(isempty(gval(mp)));
  setobj2t(L, gval(mp), value);//赋值value
}

这个函数是table中最重要的函数之一,是hash表的实现逻辑

  • 使用mainpositionTV获取这个key散列的位置
  • 如果这个位置没有被使用,直接赋值
  • 如果这个位置已经被使用
    • 使用的这个节点,hash(key)是本身,说明这个节点是头节点,只需要找到一个未使用的节点插入。
    • 使用的这个节点hash(key)不是本身,说明这个节点已经成为别的节点的链表节点,后续的操作就是找一个未使用的节点替换当前节点

3.4.2 rehash

static void rehash (lua_State *L, Table *t, const TValue *ek) {
  unsigned int asize;  //数组部分的大小
  unsigned int na;  //当前表中所有的int key
  unsigned int nums[MAXABITS + 1];
  int i;
  int totaluse;//统计所有的已经使用的key
  for (i = 0; i <= MAXABITS; i++) nums[i] = 0;  /* reset counts */
  setlimittosize(t);
  na = numusearray(t, nums);  //统计key在 (2^(i - 1),2^i]的数量输出在nums[i],返回int key的数量
  totaluse = na;  
  totaluse += numusehash(t, nums, &na);  //na+=hash部分的int key
  /* count extra key */
  if (ttisinteger(ek)) //如果新的key是int
    na += countint(ivalue(ek), nums);
  totaluse++;
  /* compute new size for array part */
  asize = computesizes(nums, &na); //计算数组部分的大小.na返回的当数组部分大小为asize时,使用int key的数量
  //重新设置数组部分和hash部分的大小
  luaH_resize(L, t, asize, totaluse - na);//totaluse - na  all_key - 新数组部分int key的数量
}

1.遍历所有key,把int key输出在nums数组中。数组每个元素都是用于计算,统计(2^(i - 1),2^i]的数量

2.na变量用于记录所有的int key的数量,totaluse用于记录所有已使用的key。

3.最后通过computesizes去计算数组部分的大小。计算完一个之后hash部分就是totaluse - 新数组部分int key的数量

computesizes函数的实现

static unsigned int computesizes (unsigned int nums[], unsigned int *pna) {
  int i;
  unsigned int twotoi;  /* 2^i (candidate for optimal size) */
  unsigned int a = 0;  /* number of elements smaller than 2^i */
  unsigned int na = 0;  /* number of elements to go to array part */
  unsigned int optimal = 0;  /* optimal size for array part */
  /* loop while keys can fill more than half of total size */
  for (i = 0, twotoi = 1;
       twotoi > 0 && *pna > twotoi / 2;
       i++, twotoi *= 2) {
    a += nums[i];
    if (a > twotoi/2) {  //两倍关系
      optimal = twotoi;  /* optimal size (till now) */
      na = a;  /* all elements up to 'optimal' will go to array part */
    }
  }
  lua_assert((optimal == 0 || optimal / 2 < na) && na <= optimal);
  *pna = na;		//返回新数组部分key的数量
  return optimal;	//新数组部分的大小
}

可以看到循环中有一个很显眼的比较。说明使用数量和空间数量是尽量满足两倍的关系

luaH_resize的实现

void luaH_resize (lua_State *L, Table *t, unsigned int newasize,
                                          unsigned int nhsize) {
  unsigned int i;
  Table newt;  /* to keep the new hash part */
  unsigned int oldasize = setlimittosize(t);
  TValue *newarray;
  /* create new hash part with appropriate size into 'newt' */
  setnodevector(L, &newt, nhsize);//申请新的hash部分内存
  if (newasize < oldasize) {      //如果数组部分有缩减
    t->alimit = newasize;         //先赋值为新的,后续使用luaH_setint函数时key不会在数组部分
    exchangehashpart(t, &newt);   //交换hash部分
    //把数组部分多余的部分移到hash部分
    for (i = newasize; i < oldasize; i++) {
      if (!isempty(&t->array[i]))
        luaH_setint(L, t, i + 1, &t->array[i]);
    }
    //恢复原来的状态
    t->alimit = oldasize;
    exchangehashpart(t, &newt);
  }
  /* allocate new array */
  newarray = luaM_reallocvector(L, t->array, oldasize, newasize, TValue);//realloc 数组部分
  if (l_unlikely(newarray == NULL && newasize > 0)) {  /* allocation failed? */
    freehash(L, &newt);  /* release new hash part */
    luaM_error(L);  /* raise error (with array unchanged) */
  }
  /* allocation ok; initialize new part of the array */
  exchangehashpart(t, &newt);  /* 't' has the new hash ('newt' has the old) */
  t->array = newarray;  //更新数组部分,
  t->alimit = newasize;
  for (i = oldasize; i < newasize; i++)  //把数组部分扩容的部分初始化
     setempty(&t->array[i]);
  /* re-insert elements from old hash part into new parts */
  reinsert(L, &newt, t);  //newt现在是老的hash表。把原来表的数据插入新表
  freehash(L, &newt);   //释放原来表的hash部分
}

1.如果数组部分缩容把越界的int key添加到新表hash部分

2.把老表的hash部分移到新表的hash部分中

注意:这个新版只是hash部分的。最后通过交换换调了老的hash部分的表

3.5 遍历

static unsigned int findindex (lua_State *L, Table *t, TValue *key,
                               unsigned int asize) {
  unsigned int i;
  if (ttisnil(key)) return 0;  //如果是空,返回0,这个是遍历的起点
  i = ttisinteger(key) ? arrayindex(ivalue(key)) : 0;
  if (i - 1u < asize)  //key 在数组部分
    return i;  /* yes; that's the index */
  else {//key 在hash部分
    const TValue *n = getgeneric(t, key, 1);
    if (l_unlikely(isabstkey(n)))
      luaG_runerror(L, "invalid key to 'next'");  /* key not found */
    i = cast_int(nodefromval(n) - gnode(t, 0));  /* key index in hash table */
    /* hash elements are numbered after array ones */
    return (i + 1) + asize;
  }
}

int luaH_next (lua_State *L, Table *t, StkId key) { //遍历
  unsigned int asize = luaH_realasize(t);
  unsigned int i = findindex(L, t, s2v(key), asize);  //查找key
  for (; i < asize; i++) {  /* try first array part */
    if (!isempty(&t->array[i])) {  /* a non-empty entry? */
      setivalue(s2v(key), i + 1);
      setobj2s(L, key + 1, &t->array[i]);//value的值设置在key的下一个位置,意味着key必须是有两个元素的数组(一般使用lua栈)
      return 1;
    }
  }
  for (i -= asize; cast_int(i) < sizenode(t); i++) {  /* hash part */
    if (!isempty(gval(gnode(t, i)))) {  /* a non-empty entry? */
      Node *n = gnode(t, i);
      getnodekey(L, s2v(key), n);
      setobj2s(L, key + 1, gval(n)); //value的值设置在key的下一个位置,意味着key必须是有两个元素的数组(一般使用lua栈)
      return 1;
    }
  }
  return 0;  /* no more elements */
}

luaH_next的使用要注意两点

  1. key必须是有两个元素的数组
  2. 最开始传入的是一个空值

4. table的使用

1.创建 include 、lib、src、bin目录

mkdir include lib src bin

2.将编译好的liblua.a放到lib、源码中的头文件放入include

mv luapath/src/liblua.a lib
mv luapath/src/*h include

3.在src目录创建table.c写入代码

#include <stdio.h>
#include <string.h>
#include <ctype.h>

#include <lua.h>
#include <lualib.h>
#include <lauxlib.h>
#include <ltable.h>
#include <lstring.h>
#include <lobject.h>


static void PrintString(const TString* ts)
{
    const char* s=getstr(ts);
    size_t i,n=tsslen(ts);
    printf("\"");
    for (i=0; i<n; i++)
    {
        int c=(int)(unsigned char)s[i];
        switch (c)
        {
            case '"':
                printf("\\\"");
                break;
            case '\\':
                printf("\\\\");
                break;
            case '\a':
                printf("\\a");
                break;
            case '\b':
                printf("\\b");
                break;
            case '\f':
                printf("\\f");
                break;
            case '\n':
                printf("\\n");
                break;
            case '\r':
                printf("\\r");
                break;
            case '\t':
                printf("\\t");
                break;
            case '\v':
                printf("\\v");
                break;
            default:
                if (isprint(c)) printf("%c",c); else printf("\\%03d",c);
                break;
        }
    }
    printf("\"");
}

static void print_value(const TValue *o)
{
    switch (ttypetag(o))
    {
        case LUA_VNIL:
            printf("nil");
            break;
        case LUA_VFALSE:
            printf("false");
            break;
        case LUA_VTRUE:
            printf("true");
            break;
        case LUA_VNUMFLT:
            {
                char buff[100];
                sprintf(buff,LUA_NUMBER_FMT,fltvalue(o));
                printf("%s",buff);
                if (buff[strspn(buff,"-0123456789")]=='\0') printf(".0");
                break;
            }
        case LUA_VNUMINT:
            printf(LUA_INTEGER_FMT,ivalue(o));
            break;
        case LUA_VSHRSTR:
        case LUA_VLNGSTR:
            PrintString(tsvalue(o));
            break;
        default:				/* cannot happen */
            printf("?%d",ttypetag(o));
            break;
    }
}

struct kv_data
{
    char *key;
    int  value;
};

static struct kv_data data[] = {
    {"a",1},
    {"b",2},
    {"c",3},
    {"d",4},
    {"e",5},
    {"f",6},
    {"g",7},
    {"h",8},
    {"i",9},
    {"j",10},
    {"k",12},
    {"l",13},
    {"m",14},
    {"n",15},
    {"o",16},
    {"p",17},
    {NULL,-1},
};

int main(int argc,char *argv[])
{
    lua_State *L = luaL_newstate();	//lua虚拟机

    Table *t = luaH_new(L);			//创建一个table

    luaH_resize(L,t,3,4);			//分配内存

    TValue key,val;
	//遍历添加
    struct kv_data *it = data;
    for(;it->key;it++) {
        setsvalue(L, &key, luaS_new(L,it->key));
        setivalue(&val, it->value);
        luaH_set(L,t,&key,&val);
        //setivalue(p, it->value);
    }
	//遍历输出
    setnilvalue(s2v(L->top));
    while(luaH_next(L,t,L->top)) {
        //printf("%s   %f\n",getstr(tsvalue(s2v(L->top))),nvalue(s2v(L->top+1)));
        print_value(s2v(L->top));
        printf("\t");
        print_value(s2v(L->top+1));
        printf("\n");
    }

    printf("sizeof(node) = %ld\n",sizeof(Node));

    return 0;
}

4.Makefile编写

SRC=$(wildcard src/*.c)
SRC_OBJ=$(patsubst %.c,%.o,$(SRC))
OBJ=$(patsubst src%,obj%,$(SRC_OBJ))
SRC_OUT=$(patsubst %.c,%,$(SRC))
TARGET=$(patsubst src/%,%,$(SRC_OUT))

CC=gcc
CFLAGS=-g2 -gdwarf-2

LIBPATH=-L./lib
LIBS=-llua -lm -ldl
INCLUDE=-I./include

all:$(TARGET)

$(TARGET):%:src/%.c
	$(CC) $(CFLAGS) -o bin/$@ $^ $(INCLUDE) $(LIBPATH) $(LIBS)

clean:
	rm -rf $(OBJS) bin/*

test:
	echo $(SRC) && echo $(OBJ)

  1. make
  2. cd /bin && ./table

这部分的源码注释可以参考本人的
github地址:https://github.com/huoyang11/read_lua

参考

<lua设计与实现> codedump

基于lua5.4.3源码

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值