[读书笔记]《Hands on Design Patterns with C++》——RAII,SFINAE

第 5 章 RAII

RAII(Resource Acquisition is Initialization)构造函数获取资源,析构函数释放资源。 注意拷贝和释放,有的需要禁止拷贝移动等。

What is considered a resource in a C++ program?

每一个程序运行都需要一定的资源,最常使用的资源就是内存,但资源这个概念是多种多样的。只要是资源就会设计到一个问题,如何确保资源管理是正确的。

首先人为的管理每一个资源是很容易出错的。例如申请一个指针,却忘记释放。 并且,当我们没有忘记释放,但是在释放之前,可能函数就会其他程序中断了,导致没办法顺利调用指针的释放语句,这也会导致内存泄漏。 再或者就是使用的命令错误,数组却用了 delete(正确应该使用 delete[])。

bool process(... /*一些输入参数*/) {
    Widget* p = new Widget;
    ...
    if(!success) {delete p; return false;} // 需要记得释放资源
    ...
    delete p;
    return true;
}

又例如锁也可以看做是一种资源,当多线程的时候,对于 lock 资源,有多个锁的时候可能都要手动释放代码可能会长这样:

std::mutex m1, m2, m3;
bool process_concurrent(... /*一些输入参数*/) {
    m1.lock();
    m2.lock();
    ...
    if(!success) {
        m1.unlock(); // 如果失败需要记得 unlock
        m2.unlock();
        return false;
    }
    ...
    m2.unlock();
    m3.lock();
    if(!success) {
        m1.unlock(); // 这里故意忘记 unlock m3
        return false;
    }
    ...
    m1.unlock(); m3.unlock();
    return true;
}

可以看到整个代码随着互斥量的增加逻辑更加复杂起来,随便哪里逻辑没有对上都很难发现。

当上面的程序中途不是 return false,而是抛出异常,如果这样需要正确的释放资源,只能使用 try … catch 结构来处理了。当有多个资源需要管理,最后的逻辑也会很复杂。 这就引出了 C++ 中一个资源管理的利器 —— RAII idiom。

What is the standard approach for managing resources in C++ (RAII)?

How does RAII solve the problems of resource management?

一个 RAII 简单的例子如下:

template <typename T>
class raii {
public:
  explicit raii(T* p) : p_(p) {}
  ~raii() { delete p_; }
private:
  T* p_;
}

raii<Widget> p (new Widget); 

当然 raii 模板类也可以作为其他类的成员变量来自动管理资源的删除,我们就不用在 A 的析构函数中再手动删除对象了。其实 unique_ptr 就是类似 raii 模板类这种做法来管理资源:

class A {
public:
    A(Widget* p) : p_(p) {}
private:
    raii<Widget> p_;
}

上面的对指针的用法举例,指针并非在构造函数中创建,而是提前创建好,然后传入 raii 类中的。 还有就是资源直接在构造函数中就获取:

class mutex_guard{
public:
 mutex_guard(std::mutex& m) : m_(m) {m_.lock();}
 ~mutex_guard() {m_.unlock();}
private:
 std::mutex& m_;
};

Releasing early

如果我们不想每次资源都是在最开头获取,并且只能在当前对象作用域要结束的时候才被释放呢。有下面两种方式可以达到提前释放的目的:

  • 创建一些局部作用域
void process(...) {
    ...
    {
        mutex_guard mg(m);
        ...
    } // 会释放 lock
    ...
}
  • 添加类成员函数

提供 reset 或者 destroy 等成员函数,提供提前释放的接口。 当然为了避免会在析构函数中造成二次释放。对于指针需要及时置成 nullptr, 对于 lock 这种资源,没办法像指针一样方便判断是否已经被释放,所以可能需要额外的 bool 类型成员变量来追踪当前资源的状态。

What are the precautions that must be taken when writing RAII objects?

当上面 raii 类的实现并没禁止拷贝行为的时候,我们使用默认的拷贝构造函数。

raii<Widget> p(new Widget);
raii<Widget> p1(new Widget);
p = p1;

这样操作之后, p 之前管理的资源就没办法再释放了,并且 p 和 p1 指向了同一块地址,会重复释放,这都是有问题的。 所以对于 RAII 类,拷贝行为和赋值行为都是建议直接删除的。(可以对应 Unique_ptr 指针的特性来理解), 当然有一些 RAII 类对象是可以支持拷贝行为的,这些就是通过计数来管理资源的对象(对应 shared_ptr)。 对于移动性的支持也是需要看实际情况的。

还有一个问题就是,一般初始化 RAII 类对象都是使用 resource handle,而不是资源本身,所有也要确保资源可以被正确释放。例如上面的 raii 类,传入一个数组指针,显然再用 delete 就不再合适了。可选的方法有两个,一个是再写一个 raii_array 版本, 更通用的版本则是,将删除器作为模板参数传入类中,不过删除器需要成员对象来传给 RAII 类,并存储在对象内部,所以会使对象占用内存更大。

还有一点可能需要注意的是,释放资源是在析构函数中,一般析构函数是不能报错误的,所以如果释放资源之前需要执行一些操作,并且可能会遇到异常,是需要在析构函数中处理这些异常的。

第 6 章 Type Erasure

What is type erasure?

类型擦除,是指一种在编程中不会出现明确的类型信息的编程技术。它是一种抽象类型,可确保程序不显式依赖某些数据类型。这一技巧也是想进一步增加代码的抽象等级,去表达一个 concept,例如我们写一个函数不仅仅想排序整型数组, 更想写一个函数可以排序任何数组

一个典型的例子就是智能指针对删除器的处理。 unique_ptr 和 shared_ptr 都是可以自定义删除器的,区别在两者对删除器的处理不同。

MyDeleter deleter(&Widget); // 假设是已实现的删除器
std::unique_ptr<Widget, MyDeleter> p1(new Widget, deleter);
std::unique_ptr<Widget> p2(new Widget);

p1 = std::move(p2); // 编译报错

上面是 unique_ptr 的处理,需要在类型中也携带具体的自定义删除器信息,并且即使初始化的数据部分类型相同,使用的删除器不同,两个 unique_ptr 也会被认为是两个不同的类型。

MyDeleter deleter(&Widget);
std::shared_ptr<Widget> q1(new Widget, deleter);
std::shared_ptr<Widget> q2(new Widget);

void some_function(std::shared_ptr<Widget>) {};

上面是 shared_ptr 的处理,自定义的删除器作为构造函数的输入,并不会体现在类型上的表示上,并且二者可以表示同一类型。

shared_ptr 和 unique_ptr 都必须要在对象内部存储一个可能是任意类型的删除器对象,但是 unique_ptr 对象会带上它的删除器类型,shared_ptr 对所有的删除器类型,只要指向的数据类型一致就是同一类型。上述像 std::shared_ptr 不会携带任何删除器类型的信息,这个类型就从编程中被擦除了,some_function 可以同时处理 q1 和 q2 输入。

type erasure 并不是一个新的概念,在 C 语言中,就有使用 void* 指针的例子。这里 qsort 传入的对比函数使用 void* 来表示两个输入,就不携带具体的类型信息,但是指定具体的类型是编程者责任,写对比函数代码的人需要知道明确的类型信息,并不能在 runtime 的时候让程序自动选择合适的对比函数。

int less(const void* a, const int* b) {
       return *(const int*)a - *(const int*)b;
}

void qsort(void *base, size_t nmemb, size_t size,
       int (*compar)(const void *, const void *));

int main() {
       int a[10] = { 1, 10, 2, 9, 3, 8, 4, 7, 5, 0 };
       qsort(a, sizeof(int), 10, less);
   }

在面向对象语言中,我们一直在处理抽象类型,多态的情况就可以看做是一种 type erasure。所以在 C++ 中引入了虚函数来支持多态这种 type erasure,不过虚函数会带来额外的性能负担。 C++ 中还有另一种更优雅的方式 —— templates。 不过模板是 type erasure 的反面,我们本身使用 type erasure 就是不想同样的函数,对每一个类型都书写一遍。 模板只是形式上看着像,实际上就是会对每一个类型都写一遍,只是基本是编译期帮我们做了这个事情,所以模板的方式更多地是把类型隐藏了起来,不是擦除掉了。

下面是一个简化版的 shared_ptr 实现,让我们来看一下 shared_ptr 是如何实现 type erasure 的。

template <typename T>
class smartptr
{
    struct deleter_base
    {
        virtual void apply(void *) = 0;
        virtual ~deleter_base() {}
    };

    template <typename Deleter>
    struct deleter : public deleter_base
    {
        deleter(Deleter d) : d_(d) {}
        void apply(void *p) override { d_(static_cast<T *>(p)); }
        Deleter d_;
    };

public:
    template <typename Deleter>
    smartptr(T *p, Deleter d) : p_(p), d_(new deleter<Deleter>(d)) {}

    ~smartptr()
    {
        d_->apply(p_);
        delete d_;
    }
    T *operator->() { return p_; }
    const T *operator->() const { return p_; }

private:
    T *p_;
    deleter_base *d_;
};

这里使用了一个嵌套的 smartptr::deleter 模板来承接输入的删除器。而要做到 smartptr 对所有不同删除器都可以表示同一类型,这些删除器就需要使用多态,都表示成基类。 在一些其他标准库中 C++ 使用了同样的方法。

对于何时使用 type erasure,何时不用主要需要从两方面考虑 —— 代码设计 以及 性能。

目前真正的 type erasure 就是 C 中的 void*,然后需要编码者知道确定的类型,然后 cast 过去(reification)。或者就是例如上面 smartptr 的实现,使用虚函数来实现。 type erasure 归根结底还是需要内部使用多态来实现删除器的选择。 unique_ptr 则没有这个问题。

  1. What is type erasure, really?
  2. How is type erasure implemented in C++?
  3. What is the difference between hiding a type behind auto and erasing it?
  4. How is the concrete type reified when the program needs to use it?
  5. What is the performance overhead of type erasure?

第 7 章 SFINAE

Substitution Failure Is Not An Error (SFINAE)。

What are function overloading and overload resolution?
What are type deduction and substitution?
What is SFINAE, and why was it necessary in C++?
How can SFINAE be used to write insanely complex, and sometimes useful, programs?

函数重载(Function overloading)是指多个不同的函数有相同的名字,但是参数的类型,个数或者顺序不同。 并且 C++ 在重载函数匹配时有一套自己的规则。

void f(int i){...};
void f(double i){...};
void f(long i){...};

f(5.0f);  // 会匹配 double 类型
f(2u); // 编译报错

unsigned int i = 5u;
f(static_cast<int>(i)); // 显式指定类型可以

更复杂一点的,多参数时:

void f(int i){...}  //  1
void f(long i, long j){...}  // 2
void f(double i, double j = 0) {...}  // 3

f(3l); // 编译失败
f(2, 3); // 编译失败
f(5); // 1
f(5l, 5); // 2
f(5.0) // 3

可以看到当同时有两个函数可以通过隐式转换得到,并且隐式转换的代价相同时(代价指更少次数或者更简单进行的转换),编译器就会报错。

这时我们再将模板函数的内容加入进来。

void f(int i) {};  // 1
void f(long i) {};  // 2
template <typename T> void f(T i) {};  // 3
template <typename T> void f(T* i) {};  // 4
void f(...); // 5 可变参数

f(1); // 1
f(3l); // 2
f(5.0); // 3

int i = 0;
f(&i); // 4
  • 如果非模板函数可以完美匹配则会优先选择非模板函数, 否则如果模板函数可以成功实例化一个对应类型,才会选择模板函数。
  • 如果有多个模板函数可以匹配,例如上面 f(&i) 可以同时匹配 3 和 4,则会优先选择更加特定的版本。
  • 如果没有模板函数可以匹配,但是有非模板函数可以通过隐式转换匹配上,则会调用这个非模板函数
  • 如果上面的条件都没有匹配,但是有一个函数接的是可变长参数(函数5),则最后会匹配到这个函数上。

模板参数会涉及到类型推导(Type deduction)和类型替换(Type substitution)。这两个概念看着很相似,但是不完全一样,类型推导更像是一个猜的过程,当然实际上类型推导是按照一套背后的逻辑去匹配的。并且在类型推导的过程中,不会出现类型转换。

template <typename T> void f(T i, T* j) {}

int i = 2;
f(5l, &i); // 编译失败

f<int>(5l, &i); // 匹配成功

上面的例子中,在类型推导阶段,只会推导出 f(int, int*) 或者 f(long, long*), 不会因为long 可以转成 int,就推导出 f(long, int*) 的类型, 所以上面的匹配就会失败。不过可以通过显式的固定类型来匹配成功。

类型推导可以 ambiguous, 而类型替换则不会出现 ambiguous 的情况。

template <typename T> void f(T i, typename T::t& j) {}
template <typename T> void f(T i, T j) {}

struct A{
  struct t { int i;}
  t j;
};

A a(5);
f(a, a.j); // T == A
f(5, 7) // T == int

上面两个模板函数,f(5, 7) 时, 第一个模板函数 T 会被类型推导(Type deduction)成 int,就会有 类型替换发生,替换的结果是 void f(int, int::t&); 但是明显没有 int::t 这个类型,所以类型替换会失败,但是类型替换失败不会报错(SFINAE),程序会继续寻找是否有其他更匹配的函数

当然这样的 SFINAE 仅限于函数声明中,即仅在参数,默认参数,返回类型中。一旦函数体中出现错误的用法,则属于语法错误,会正常报错的。

template <typename T> void f(T i) { std::cout << T::j << "\n";} // 1
void f(...) {} // 2

f(0);  // 语法错误

显然这里 f(0) 中 T 会成功推导成 int, 然后会进行类型替换,将模板中的 T 都替换成 int , 但是明显没有 int::j 这个用法,发生在函数体内就会报语法错误。

上面大概了解了 SFINAE,我们就可以重载模板函数来控制最后模板函数匹配的结果,以达到我们想要的效果。 例如我们想写一个模板函数只对自定义的类起作用,而不对内置类型起作用。首先就要找什么东西是自定义类有,但是内置类型没有的。它们之间很明显的区别就是成员指针。

template <typename T>
void f(int T::*) { std::cout << " T is a class; \n"; } // 1

template <typename T>
void f(...) { std::cout << " T is not a class; \n"; } // 2

struct A;

f<int>(0);  // 2
f<A>(0); // 1

上面的判别方式,完全可以包装一下,根据匹配的结果返回一个 true_type 或者 false_type 来实现 is_class 的判断功能。

下面介绍一些 SFINAE 的更高阶用法。

现在我们想要写一个通用函数,可以作用在容器中包含的任意 T 类型, 例如排序。我们假设如果容器自己提供了 sort 成员函数,则这个成员函数是最优的选择,否则容器也可以通过 begin() 和 end() 成员函数来访问,并且通过 std::sort 来实现排序。

最开始的想法可能是加一个标志位,如果为 true 则调用成员 sort 函数,否则用 std::sort, 实现可能如下:

template <typename T>
void best_sort(T& x, bool use_member_sort) {
    if(use_member_sort) x.sort();
    else std::sort(x.begin(), x.end());
}

逻辑上看着没啥问题,但是这个代码当 T 没有 sort 成员函数时会编译错误,即使 use_member_sort 为 false。为了部分包含 sort 成员函数的对象能调用自己的成员函数,我们必须在代码的某处书写 x.sort() 这行代码,但是一旦写了,传入的类型对象没有 sort 成员函数,就会编译报错。

C++ 模板中不会产生语法错误,除非它们被实例化了。例如下面的例子:

class Base {
  public:
    Base() : i_() {}
    virtual void increment(long v) {i_ += v;}
  private:
    long i_;
};

template <typename T>
class Derived : public T {
  public:
    Derived() : T(), j_() { }
    void increment(long v) { j_ +=v; T::increment(v);}
    void mlutiply(long v) { j_ *=v; T::mlutiply(v);}
  private:
    long j_;
};

Derived<Base> d;
d.increment(5);  // 正常编译运行

这里在 Derived 类中调用了基类 T 中的 increment() 和 mlutiply(),即使基类 T 中没有 mlutiply(),代码也可以正常编译和运行,只要我们不调用 Derived::mlutiply(),潜在的语法错误就不会发生。编译器不会检查合法性,直到模板被实例化。 所以这对上面问题的解决带来一丝希望。

template<typename T> struct fast_sort_helper; // 辅助类型

template <typename T>
struct fast_sort_helper<???> {
    static void fast_sort(T& x) {
        std::cout << "Sorting with T::sort" << std::endl;
        x::sort();
    }
};

template <typename T>
struct fast_sort_helper<???> {
  static void fast_sort(T& x) {
      std::cout << "Sorting with std::sort" << std::endl;
      std::sort(x.begin(), x.end());
  }
};

这里首先定义一个模板辅助结构 fast_sort_helper, 然后对它进行实例化,暂时我们没有确定的类型,如果可以正确确定实例化类型,那么直接调用 fast_sort_helper::fast_sort(x) 将会根据条件,自动执行 T::sort 或者是 std::sort。 所以现在的问题是我们需要声明一个模板函数,如果类有 sort 成员函数,书中提到这里使用 decltype 来确认。

struct yes {char c;};
struct no {char c; yes c1;};

template <typename T>
yes test_sort(decltype(&T::sort)); //确定是否有 sort 成员函数

template <typename T>
no test_sort(...);

template<typename T, size_t s> struct fast_sort_helper; // 辅助类型

template <typename T>
struct fast_sort_helper<T, sizeof(yes)> {
    static void fast_sort(T& x) {
        std::cout << "Sorting with T::sort" << std::endl;
        x.sort();
    }
};

template <typename T>
void fast_sort(T& x) {
    fast_sort_helper<T, sizeof(test_sort<T>(nullptr))>::fast_sort(x);
}

class A {
public:
  void sort() {
        std::cout << "AA::sort()\n";
    }
};

class C {
public:
  void f();
};

A a; fast_sort(a); // 正常调用 a.sort()
C c; fast_sort(c); // 编译错误

接下来,如果要确定使用 std::sort( ) 我们需要要求类有 begin() 和 end() 两个成员函数才可以。因此我们需要两个输入参数:

 tempalte <typenmae T>
??? test_sort(decltype(&T::begin), decltype(&T::end));

为了 test_sort 来自同一个模板,跟上面调用成员函数的统一,所以需要将 test_sort 从一个输入参数需要编程两个输入参数,并且添加可以承接既没有 T::sort() 也没有 T::begin 和 T::end 的情况。 下面给出相对比较完整的示例代码。

struct have_sort { char c;};
struct have_range{ char c; have_sort c1;};
struct have_nothing { char c; have_range c1;};

template <typename T>
have_sort test_sort(decltype(&T::sort), decltype(&T::sort));

template <typename T>
have_range test_sort(decltype(&T::begin),decltype(&T::end));

template <typename T>
have_nothing test_sort(...);

template <typename T, size_t s>
struct fast_sort_helper; // 辅助类型

template <typename T>
struct fast_sort_helper<T, sizeof(have_sort)> {
    static void fast_sort(T &x) {
        std::cout << "Sorting with T::sort" << std::endl;
        x.sort();
    }
};

template <typename T>
struct fast_sort_helper<T, sizeof(have_range)> {
  static void fast_sort(T& x) {
      std::cout << "Sorting with std::sort" << std::endl;
      std::sort(x.begin(), x.end());
  }
};

template <typename T>
struct fast_sort_helper<T, sizeof(have_nothing)> {
  static void fast_sort(T& x) {
      static_assert(sizeof(T)<0, "No sort avalible");
  }
};

template <typename T>
void fast_sort(T &x) {
    fast_sort_helper<T, sizeof(test_sort<T>(nullptr, nullptr))>::fast_sort(x);
}

上述问题基本可以得到满足。

C++11 中提供了更加便利的条件对比,std::enable_if,它是一个类模板,当SFINAE 失败时会返回一个 bool 值 false,这里功能就像是 fast_sort_helper 模板类的作用。 一般 std::enable_if 的用法是作为重载函数的返回值类型,如果 std::enable_if 中的表达式是 false,返回类型替换失败,这个函数就会直接从重载函数中被删除,所以使用 std::enable_if 可以简化上面的代码为:

struct have_sort { char c;};
struct have_range{ char c; have_sort c1;};
struct have_nothing { char c; have_range c1;};

template <typename T>
have_sort test_sort(decltype(&T::sort), decltype(&T::sort));

template <typename T>
have_range test_sort(decltype(&T::begin),decltype(&T::end));

template <typename T>
have_nothing test_sort(...);

template <typename T>
typename std::enable_if<sizeof(test_sort<T>(nullptr, nullptr)) == 
                        sizeof(have_sort)>::type fast_sort(T& x) {
        std::cout << "Sorting with T::sort" << std::endl;
        x.sort();
}

template <typename T>
typename std::enable_if<sizeof(test_sort<T>(nullptr, nullptr)) == 
                        sizeof(have_range)>::type fast_sort(T& x) {
        std::cout << "Sorting with T::sort" << std::endl;
        std::sort(x.begin(), x.end()); 
}

template <typename T>
typename std::enable_if<sizeof(test_sort<T>(nullptr, nullptr)) == 
                        sizeof(have_nothing)>::type fast_sort(T& x) {
				static_assert(sizeof(T)<0, "No sort avalible");
}

上面的实现还有两个方面的问题可以思考:

  • 如果一个类同时有 sort(),begin(), end() 这三个成员函数时, test_sort() 就会有两个合法的重载结果,这样编译器会不知道应该选哪一个 test_sort(), 就会编译失败。

    这个问题的解决就需要增加明确增加相关情况的处理。增加新的判断条件,如果两个都为真时,应该采用何种实现。

  • 如果一个类中有多个 sort() 重载,例如 std::list 中就有两个 sort 函数,一个由参数,另一个没有参数。这样情况会导致类型替换时 T::sort 会出现两个选择,编译就会失败。

    这个问题我们更倾向于没有参数的一个,所以可以将 T::sort 替换成更加特定的函数指针形式, void (T:: *) ()。

template <typename T> have_sort test_sort(
    decltype(static_cast<void(T::*)()>(&T::sort)));

template <typename T> have_range test_sort(
    decltype(static_cast<typename T::iterator (T::*)()>(&T::begin)),
    decltype(static_cast<typename T::iterator (T::*)()>(&T::end)));

这里通过函数指针,限制返回类型和参数类型,防止多个重载函数不知道选择哪个。

经过上面的修改,结合 SFINAE 默默处理类型替换失败的问题,我们基本实现了只留下一种我们期望的调用。

(注意这里的提问思维)前面的实现,当我们调用一个 sort 成员函数时,自然就将我们引导了一个问题:是否存在 sort 成员函数? 当然这个问题可以有两种提问方式,一种是,类中是否有名字叫 sort 的成员函数,指向它的函数指针是什么样的? 另一种提问方式为, 类中有叫 void sort() 的成员函数么。

进一步,假设现在我们想写一个函数可以将任何类型的值乘以一个给定的整数值。可能的形式是:

template <typename T>
??? increase(const T& x, size_t n);

T 是一个用户定义的类,其中实现了一些数值操作的定义。 最简单的情况就是类 T 中有一个 operator* 的实现,这个函数也不是一定就会返回 T 类型,所以上面暂时不确定返回的是什么类型,先用 ??? 代替。 具体功能的实现我们可以尝试调用 x*= n, 如果 operator*= 调用失败,当类型 T 有 operator+ 可以调用的时候就要计算 x + x,然后重复 n 次。

上面的条件可以转化为问题: T 有 xx 成员函数存在吗? 这样的思考角度,有太多的方式可以提供所需的功能了。 我们应该问的问题是 x*n 是合法的吗?

针对 xn 是否合法?这个问题,在 C++11 中, decltype 操作符可以替我们去判断。如果 xn 表达式合法,则会返回它应该的返回类型。如果表达式不成立,作为返回类型,SFINAE 可以让编译期忽视这个错误。

template <typename T>
auto increase(const T& x, size_t n) -> decltype(x*n) {
   return x * n;
}

The more elaborate use of SFINAE is to create an artificial substitution failure, and thus take control of the overload resolution by removing some of the overloads.

  1. What is overload set?
  2. What is overload resolution?
  3. What are type deduction and type substitution?
  4. What is SFINAE?
  5. In what contexts can potentially invalid code be present and not trigger a
    compilation error, unless that code is actually needed?
  6. How can we determine which overload was chosen without actually calling it?
  7. How is SFINAE used to control conditional compilation?
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值