Effective C++学习笔记四(设计与声明)

4 设计与声明


条款18:让接口容易被正确使用,不易被误用

理想上,如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码不该通过编译;如果代码通过了编译,它的作为就该是客户所想要的。

许多客户端错误可以以导入新类型而获得预防:比如为一个用来表现日期的class设计构造函数:

    class Date{
    public:
        Date(int month,int day,int year);
        ...
    };

客户或许会以错误的次序传递参数,或传递一个无效的月份或天数,这时,我们可以导入简单的外覆类型(wrapper types)来区别天数,月数和年份.然后于Date构造函数中使用这些类型:

    class Date{
    public:
        Date(const Month& m,const Day& d,const Year& y);
        ...
    };

令Day,Month,和Year成为成熟且经充分锻炼的classes并封装其内数据,比简单使用第一种方式要好,即使第一种方式已经足够示范:明智而审慎地导入新类型对预防”接口被误用”有神奇疗效。

预防客户错误的另一个办法是,限制类型内什么事可做,什么事不能做。常见的限制是加上const。比如“以const修饰operator*的返回类型”可阻止客户因”用户自定义类型”而犯错:

    if(a*b=c)...

另一个一般性准则”让types容易被正确使用,不容易被误用”的表现形式:除非有好理由,否则应该尽量令你的types的行为与内置types一致。客户已经知道像int这样的type有什么行为,所以你应该努力让你的types在合样合理的前提下也有相同表现。例如如果a和b都是ints,那么对a*b赋值就是不合法。

避免无端与内置类型不兼容,真正的理由是为了提供行为一致的接口。很少有其他性质比得上”一致性”更能导致”接口容易被正确使用”,也很少有其他性质比得上”不一致性”更加剧接口的恶化。STL容器的接口十分一致(虽然不是完美地一致),这使它们非常容易地被使用。

任何接口如果要求客户必须记得做某些事情,就是有着”不正确使用”的倾向,因为客户可能会忘记做那件事。比如某函数返回一个指针指向一个动态分配对象,为避免资源泄漏,此函数返回的指针最终必须被删除,但那至少开启了两个客户错误机会:没有删除指针,或删除同一个指针超过一次。条款13标明客户如何将这样的函数返回值存储于一个智能指针如auto_ptr或shared_ptr内,因而将delete责任推给智能指针。但万一客户忘记使用智能指针怎么办?,许多时候,较佳接口的设计原则是先发制人,就令此函数返回一个智能指针。

实际上,返回shared_ptr让接口设计者得以阻止一大群客户犯下资源泄漏的错误,因为就如条款14所言,shared_ptr允许当智能指针被建立起来时指定一个资源释放函数(所谓删除器,”delete”绑定于智能指针身上)(auto_ptr就没有这种能耐).

shared_ptr有一个特别好的性质是:它会自动使用它的”每个指针专属的删除器”,因而消除另一个潜在的客户错误:所谓的”cross-DLL problem”。这个问题发生于“对象在动态连接程序库(DLL)中被new创建,却在另一个DLL内被delete销毁”。在许多平台上,这一类”跨DLL之new/delete成对运用“会导致运行期错误。shared_ptr没有这个问题,因为它缺省的删除器是来自”shared_ptr诞生所在的那个DLL”的delete。

请记住

  • 好的接口很容易被正确使用,不容易被误用。你应该在你所有接口中努力达到这些性质。
  • “促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
  • “阻止误用”的办法包括建立新类型,限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
  • shared_ptr支持定制型删除器(custom deleter)。这可防范DLL问题,可被用来自动解除互斥锁等等。

条款19:设计class犹如设计type

Class的设计就是type的设计。在定义一个新type之前,请确定你已经考虑过下面这些内容:

  • 新type的对象应该如何被创建和销毁?这会影响到你的class的构造函数和析构函数以及内存分配函数和释放函数(operator new,operator new[],operator delete和operator delete[])的设计
  • 对象的初始化和对象的赋值该有什么样的差别?这个答案决定你的构造函数和赋值(assignment)操作符的行为,以及其间的差异。
  • 新type的对象如果被passed by value(以值传递),意味着什么?记住,copy构造函数用来定义一个type的pass-by-value该如何实现。
  • 什么是新type的”合法值”?对class的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你的class必须维护的约束条件(invariants),也就决定了你的成员函数(特别是构造函数,赋值操作符和所谓”seleter”函数)必须进行错误检查工作。它也影响函数抛出的异常,以及(极少被使用的)函数异常明细列(exception specifications)。
  • 你的新type需要配合某个继承图系(inheritance graph)吗?如果你继承自某些既有的class,你就受到那些classes的设计的束缚,特别是受到”它们的函数是virtual或non-virtual”的影响(见条款34和条款36)。如果你允许其他classes继承你的class,那会影响你所声明的函数—-尤其是析构函数—–是否为virtual(见条款7)
  • 你的新type需要什么样的转换?如果你允许类型T1之物被隐式转换为类型T2之物,就必须在class T1内写一个类型转换函数(operator T2)或在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。如果你只允许explicit构造函数存在,就得写出专门负责执行转换的函数,且不得为类型转换操作符(type conversion operators)或non-explicit-one-argument构造函数。
  • 什么样的操作符和函数对此新type而言是合理的?这个问题的答案决定你将为你的class声明哪些函数。其中某些该是member函数,某些则否(见条款23,24,46)。
  • 什么样的标准函数应该驳回?那些正是你必须声明为private者(在C++11下可以声明为delete者)(见条款6)。
  • 谁该取用新type的成员?这个提问可以帮助你决定哪个成员为public,哪个为protected,哪个为private。它也帮助你决定哪一个class和/或functions应该是friends,以及将它们嵌套于另一个之内是否合理。
  • 什么是新type的”未声明接口”(undeclared interface)?它对效率,异常安全性(见条款29)以及资源运用(例如多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的class实现代码加上相应的约束条件。
  • 你的新type有多么一般化?或许你其实并非定义一个新type,而是定义一整个types家族。果真如此你就不该定义一个新class,而是应该定义一个新的class template。
  • 你真的需要一个新type吗?如果只是定义新的derived class以便为既有的class添加机能,那么说不定单纯定义一或多个non-member函数或template,更能达到目标。

这些问题不容易回答,所以定义出高效的classes是一种挑战。然而如果能够设计出至少像C++内置类型一样好的用户自定义(user-defined)classes,一切汗水便都值得。


条款20:宁以pass-by-reference-to-const替换pass-by-value

默认情况下,函数参数都是以实际实参的复件(副本)为初值,而调用端所获得的亦是函数返回值的一个复件。这些复件(副本)系由对象的copy构造函数产出,且函数结束时,又会调用这些复件的析构函数。这可能使得pass-by-value成为昂贵的(费时的)操作。有什么方法可以回避所有那些构造和析构动作就太好了。有的,就是pass by reference-to-const。这种传递方式的效率高的多:没有构造函数或析构函数被调用,因为没有任何新对象被创建。声明为const是必要的,因为不这样做的话调用者会忧虑被调用函数会不会改变他们传入的那个实参。

以by reference方式传递参数也可以避免slicing(对象切割)问题。当一个derived class对象被以by value方式传递,并被视为一个base class对象,base class的copy构造函数会被调用,而”造成此对象的行为像个derived class对象”的那些特化性质全被切割掉了,仅仅留下一个base class对象。这实在不怎么让人惊讶,因为正是base class构造函数建立了它。但这几乎绝不会是你想要的。例如下面:

    class Window{
    public:
        ...
        std::string name() const;
        virtual void display() const;
    };
    class WindowWithScrollBars:public Window{
    public:
        ...
        virtual void display() const;
    };

display是个virtual函数,这意味着简朴的base class Window对象的显示方式和华丽高贵的WindowWithScrollBars对象的显示方式不同

      void printNameAndDisplay(Window w)      //不正确!参数可能被切割
      {
          std::cout<<w.name();
          w.display();
      }

当你调用上述函数并交给它一个WindowWithScrollBars对象,会发生什么事呢?

    WindowWithScrollBars wwsb;
    printNameAndDisplay(wwsb);

哦,参数w会被构造成为一个Window对象;它是passed by value。造成wwsb”之所以是个WindowWithScrollBars对象”的所有特化信息都会被切除。在printNameAndDisplay函数内不论传递过来的对象原本是什么类型,参数w就像一个Window对象(因为其类型是Window)。因此在printNameAndDisplay内调用display调用的总是Window::display,绝不会是WindowWithScrollBars::display

解决切割(slicing)问题的办法,就是以by reference-to-const的方式传递w;

     void printNameAndDisplay(const Window &w)
     {
     ....
     }

如果窥视C++编译器的底层,会发现references往往以指针实现出来,因此pass by reference通常意味真正传递的是指针。因此如果你有个对象属于内置类型(例如int),pass by value往往比pass by reference的效率高些。对内置类型而言,当你有机会选择采用pass-by-value或pass-by-reference-to-cosnt时,选择pass-by-value并非没有道理。这个忠告也适用于STL的迭代器和函数对象,因为习惯上它们都被设计为passed by value.迭代器和函数对象的实践者有责任看清它们是否高效且不受切割问题(slicing problem)的影响。

一般而言,你可以合理假设”pass-by-value并不昂贵”的唯一对象就是内置类型和STL的迭代器和函数对象。至于其它任何东西都请尽量以pass-by-reference-to-const替换pass-by-value。

请记住

  • 尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高效,并可避免切割问题(slicing problem).
  • 以上规则并不适用与内置类型,以及STL的迭代器和函数对象。对它们而言,pass-by-value往往比较适当。

条款21:必须返回对象时,别妄想返回其reference

任何函数如果返回一个reference指向某个local对象,都将一败涂地。(如果函数返回指针指向一个local对象,也是一样。)
当你必须在”返回一个reference和返回一个object”之间抉择时,你的工作就是挑选出行为正确的那个。就让编译器厂商为”尽可能降低成本”鞠躬尽瘁吧,你可以享受你的生活。

请记住

  • 绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款4已经为”在单线程环境中合理返回reference指向一个local static对象”提供了一份设计实例。

条款22:将成员变量声明为private

什么不采用public成员变量:

  1. 如果成员变量不是public,客户唯一能够访问对象的办法就是通过成员函数。如果public接口内的每样东西都是函数,客户就不需要在打算访问class成员时迷惑地试着记住是否该使用小括号(圆括号);
  2. 使用函数可以让你对成员变量的处理有更精确的控制。比如”不准访问”,“只读访问”,以及”读写访问”。甚至”惟写访问”;
  3. 便于封装,将成员变量隐藏在函数接口的背后,可以为”所有可能的实现”提供弹性。例如这可使得成员变量被读或被写时轻松通知其他对象,可以验证class的约束条件以及函数的前提和事后状态,可以在多线程环境中执行同步控制……等等。
  4. 如果你对客户隐藏成员变量(也就是封装它们),你可以确保class的约束条件总是会获得维护,因为只有成员函数可以影响它们。

protected成员变量的论点十分类似。实际上它和public成员变量的论点相同,虽然或许最初看起来不是一回事。”语法一致性”和”细微划分之访问控制”等理由显然也适用于protected数据,就像对public一样适用。但封装呢?条款23会告诉你,某些东西的封装性与”当其内容改变时可能造成的代码破坏量”成反比。因此,成员变量的封装性与”成员变量的内容改变时所破坏的代码数量”成反比。所谓改变,也许是从class中移除它。

假设我们有一个public成员变量,而我们最终取消了它。多少代码可能会被破坏呢?唔,所有使用它的客户代码都会被破坏,而那是一个不可知的大量。因此public成员变量完全没有封装性。假设我们有一个protected成员变量,而我们最终取消了它,有多少代码被破坏?唔,所有使用它的derived class都会被破坏,那往往也是个不可知的大量。因此,protected成员变量就像public成员变量一样缺乏封装性,因为在这两种情况下,如果成员变量被修改,都会有不可预知的大量代码受到破坏。其实只有两种访问权限:private(提供封装)和其他(不提供封装)。

请记住

  • 切记将成员变量声明为private。这可赋予客户访问数据的一致性,可细微划分访问控制,允诺约束条件获得保证,并提供class作者以充分的实现弹性。
  • protected并不比public更具封装性。

条款23:宁以non-member,non-friend替换member函数

如果有个class用来表示网页浏览器。这样的class提供的众多函数中,有一些用来清除下载元素高速缓存区,清除访问过的URLs的历史记录,以及移除系统中的所有cookies:

    class WebBrowser{
    public:
        ...
        void clearCache();
        void clearHistory();
        void removeCookie();
        ...
    };

许多用户回想一整个执行所有这些动作,因此webBrowser也提供这样一个函数;

    class WebBrowser{
    public:
        ...
        void clearEverything();//调用clearCache,clearHistory,和removeCookies
        ...
    };

当然,这一机能也可由一个non-member函数调用适当的member函数而提供出来:

    void clearBrowser(WebBrowser & wb)
    {
        wb.clearCache();
        wb.clearHistory();
        wb.removeCookie();
    }

那么,哪一个比较好呢?是member函数clearEverything还是non-member函数clearBrowser?

我们首先推崇封装的原因:它使我们能够改变事物而只影响有限客户。

现在考虑对象内的数据。愈少代码可以看到数据(也就是访问它),愈多的数据可被封装,而我们也就愈能自由地改变对象数据。如何量测”有多少代码可以看到某一块数据”呢?我们计算能够访问该数据的函数数量,作为一种粗糙的量测。愈多函数可访问它,数据的封装性就愈低。

条款22曾说过,成员变量应该是private,因为如果它们不是,就有无限量的函数可以访问它们,它们也就毫无封装性。能够访问private成员变量的函数只有class的member函数加上friend函数而已。如果要在一个member函数(它不只可以访问class内的private数据,也可以取用private函数,enums,typedefs等等)和一个non-member,non-friend函数(它无法访问上述任何东西)之间做抉择,而且两者提供相同机能,那么,导致较大封装性的是non-member non-friend函数,因为它并不增加”能够访问class内之private成分”的函数数量。

在这一点上有两件事值得注意:第一这个论述只适用于non-member non-friend函数。friends函数对class private成员的访问权利和member函数相同,因此两者对封装性的冲击力道也相同。从封装的角度看,这里的抉择关键并不在member和non-member函数之间,而是在membe和non-member non-friend函数之间;第二件值得注意的事情是,只因在意封装性而让函数”成为class的non-member”并不意味它”不可以是另一个class的member”。

在C++,比较自然的做法是让clearBrowser成为一个non-member函数并且位于WebBrowse所在的同一个namespace(命名空间)内:

    namespace WebBrowserStuff{
        class WebBrowser{ ... };
        void clearBrowser(WebBrowser & wb);
        ....
    }

注意这不只是为了看起来自然而已。要知道,namespace和classes不同,前者可跨越多个源码文件而后者不能。这很重要,因为像clearBrowser这样的函数是个”提供便利的函数”,如果它既不是member也不是friends,就没有对WebBrowser的特殊访问权力,也就不能提供”WebBrowser客户无法以其他方式取得”的机能。

一个像WebBrowser这样的class可能拥有大量便利函数,某些与书签(bookmarks)有关,某些与打印有关,还有一些与cookie的管理有关….通常大多数客户只对其中某些感兴趣。比如只对书签相关遍历函数感兴趣,不对cookie相关遍历函数产兴趣。分离它们的最直接做法就是将书签相关便利函数声明于一个头文件,将cookie相关便利函数声明于另一个头文件,再将打印相关便利函数声明于第三个头文件,以此类推:

    //头文件"webbrowser.h"——这个头文件针对class WebBrowser自身
    //及WebBrowse核心机能。
    namespace WebBrowserStuff{
    class WebBrowser{ ... };
        ... //核心机能,例如几乎所有客户都需要的non-member函数
    }
    namespace WebBrowserStuff{
        ... //与书签相关的便利函数
    }
    namespace WebBrowserStuff{
        ... //与cookie相关的便利函数
    }
    ...

注意,这正是C++标准程序库的组织方式。标准程序库并不是拥有单一,整体,庞大的<C++StandardLibrary>头文件并在其中内含std命名空间内的每一样东西,而是有数十个头文件(<vector>,<algorithm>,<memory>等等),每个头文件声明std的某些机能。如果客户不想使用list,不需要#include <list>.这允许客户只对他们所用的那一小部分系统形成编译相依。以这种方式切割机能并不适用于class成员函数,因为一个class必须整体定义,不能被切割为片片段段。

将所有便利函数放在多个头文件内但隶属同一个命名空间,意味客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多non-member non-friend函数到此命名空间内。

请记住

  • 宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性,包裹弹性(packaging flexibility)和机能扩充性。

条款24:若所有参数皆需类型转换,请为此采用non-member函数

假设你这样开始你的有理数类Rational class:

    class Rational{
    public:
        Rational(int numerator=0,int denominator=1);//构造函数刻意不为explicit允许int-to-Rational隐式转换。目的见下面
        int numerator() const;
        int denominator() const;
    private:
        ...
    };

你想支持算术运算比如乘法:你知道有理数相乘和Rational class有关,因此很自然地似乎该在Rational class内为有理数实现operator *。于是研究下将operator *写成Rational成员函数的写法:

    class Rational{
    public:
        ...
        const Rational operator *(const Rational & rhs) const;
    };

(如果你不确定为什么这个函数返回一个const by-value结果但接受一个reference-to-const实参,请参考条款3,20,和21.)

这个设计使你能够将两个有理数以最轻松自在的方式相乘:

    Rational oneEight(1,8);
    Rational oneHalf(1,2);
    Rational result=oneHalf*oneEight;   //很好
    result=result*oneEight;             //很好

但你还不满足。你希望支持混合运算——这很自然,也就是拿Rationals和比如ints相乘,然而当你尝试混合算术,你发现只有一半行得通:

    result=oneHalf*2;             //很好
    result=2*oneHalf;           //错误!

这是为什么呢?当以对应的函数形式重写上述两个式子时:

    result=oneHalf.operator*(2);              //很好
    result=2.operator*(oneHalf);            //错误!

第一个语句oneHalf是一个内含operator*函数的class的对象,所以编译器调用该函数。然而整数2并没有相应的class,也就是没有operator*成员函数。编译器也会尝试寻找可被以下这般调用的non-member operator*(也就是在命名空间内或在global作用域内):

    result=operator*(2,oneHalf);        //错误!

但本例并不存在这样一个接受int和Rational作为参数的non-member operator *,因此查找失败。

但是再看看先前成功的那一个调用。注意其第二参数是整数2,但是Rational::operator*需要的实参却是个Rational对象。这里为什么2可以被接受,在另一个调用中却不被接受?因为这里发生了所谓的隐式类型转换(implicit type conversion).换句话说此一调用动作在编译器眼中有点像这样:

    const Rational temp(2);     //根据2建立一个暂时性的Rational对象。
    result=oneHalf*temp;        //等同于oneHalf.operator*(temp);

当然,只因为涉及non-explicit构造函数,编译器才会这样做。如果Rational构造函数是explicit,以下两个语句将会没有一个可通过编译:

    result=oneHalf*2;               //错误!(在explicit构造函数的情形下)无法将2转换为一个Rational.
    result=2*oneHalf;           //一样的错误,一样的问题。

这就很难让Rational class支持混合式算术运算了。为什么即使Rational构造函数不是explicit,仍然只有一个可通过编译,另一个不可以:

结论是只有当参数被列于参数列(parameter list)内,这个参数才是隐式类型转换的合格参与者。而2*oneHalf语句中的2,其地位相当于”被调用之成员函数所隶属的那个对象”——即this对象——的那个隐喻参数,绝不是隐式转换的合格参与者。这就是为什么上述第一次调用可通过编译,第二次调用则否,因为第一次调用伴随一个放在参数列内的参数,第二次调用则否。

然而你一定想要支持混合式算术运算。那就让operator *成为一个non-member函数,俾允许编译器在每一个实参身上执行隐式类型转换:

    class Rational{
        ...     //不包括operator *
    };
    const Rational operator *(const Rational & lhs,const Rational & rhs)  //现在成了一个non-member函数
    {
        return Rational(lhs.numerator()*rhs.numerator(),
                      lhs.denominator()*rhs.denominator());
    }

注意member函数的反面是non-member函数,不是friend函数。大多程序员假设,如果一个”与某class相关”的函数不该成为一个member,就该是个friend,这样的理由过于牵强。无论何时如果你可以避免friend函数就该避免,因为就像真实世界一样,朋友带来的麻烦往往多过其价值。当然有时候friend有其正当性。

本条款内含真理,但却不是全部的真理。当你从Object-Oriented C++跨进Template C++(见条款1)并让Rational成为一个class template而非class,又有一些需要考虑的新争议,新解法,以及一些令人惊讶的设计牵挂。这些争议,解法和设计牵连形成了条款46。

请记住

  • 如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member。

条款25:考虑写出一个不抛出异常的swap函数

缺省情况下swap动作可由标准程序库提供的swap算法完成。其典型实现完全如你所预期:

    name std {
    template<typename T>
        void swap<T& a,T& b)
        {
            T temp(a);
            a=b;
            b=temp;
        }
    }

只要类型T支持copying(通过copy构造函数和copy assignment操作符完成),缺省的swap实现代码就会帮你置换类型为T的对象,你不需要为此另外再做任何工作。

首先,如果swap的缺省实现码对你的class或class template提供可接受的效率,你不需要额外做任何事。任何尝试置换(swap)那种对象的人都会取得缺省版本,而那将会有良好的运作。

其次,如果swap的缺省实现版本的效率不足(那几乎总是意味你的class或template使用了某种pimpl手法),试着做以下事情:

  1. 提供一个public swap成员函数,让它高效地置换你的类型的两个对象值。稍后,我将解释,这个函数绝不该抛出异常。
  2. 在你的class或template所在的命名空间内提供一个non-member swap,并令它调用上述swap成员函数。
  3. 如果你正编写一个class(而非class template),为你的class特化std::swap.并令它调用你的swap成员函数。

最后,如果你调用swap,请确定包含一个using声明式,以便让std::swap在你的函数内曝光可见,然后不加任何namesapce修饰符,赤裸裸地调用swap。

成员版swap绝不可以抛出异常。那是因为swap的一个最好的应用是帮助classes(和class template)提供强烈的异常安全性(exception-safety)保障。条款29对此主题提供了所有细节,但此技术基于一个假设:成员版的swap绝不抛出异常。这一约束只施行于成员版!不可施行于非成员版,因为swap缺省版本是以copy构造函数和copy assignment操作符为基础,而一般情况下两者都允许抛出异常。因此当你写下一个自定义版本的swap,往往提供的不只是高效置换对象值的办法,而且不抛出异常。一般而言这两个swap特性是连在一起的,因为高效率的swaps几乎总是基于对内置类型的操作(例如pimpl手法的底层指针),而内置类型上的操作绝不会抛出异常。

请记住

  • 当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
  • 如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于classes(而非template),也请特化std::swap。
  • 调用swap时应针对std::swap使用using声明式,然后调用swap并且不带任何”命名空间资格修饰”。
  • 为”用户定义类型”进行std template全特化是好的,但千万不要尝试在std内加入某些对std而言全新的东西。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值