Effective C++(条款41-52)总结

条款41:了解隐式接口和编译期多态

1.classes和templates都支持接口和多态(polymorphism)。

2.对classes而言接口是显式的,以函数签名(函数名称、参数类型、返回类型)为中心。多态则是通过virtual函数发生于运行期。

显式接口:由于下面代码w的类型被声明为Widget,所以w必须支持Widget接口。如w.size()的成员函数的调用,又如,temp.normalize()和temp.swap()调用。

通常显式接口由函数的签名式(也就是函数名称、参数类型、返回类型)构成。例如下面的Widget,其public接口由一个构造函数、一个析构函数、函数size,normalize,swap及其参数类型、返回类型、常量性构成。当然也包括编译期产生的copy构造函数和copy assignment操作符。

class Widget {
public:
	Widget();
	virtual ~Widget();
	virtual std::size_t size()const;
	virtual void normalize();
	void swap(Widget& other);
	...
};
 
void doProcessing(Widget& w) {
	if (w.size() > 10 && w != someNastyWidget) {
		Widget temp(w);
		temp.normalize();
		temp.swap(w);
	}
}

运行期多态就不用解释了。

3.对template参数而言,接口是隐式的,不基于函数签名式,奠基于有效表达式。多态则是通过template具现化和函数重载解析发生于编译期。

隐式接口:w必须支持哪一种接口,系由template中执行于w身上的操作来决定。但需要注意的是,由于代码中调用了.size()  .normalize()  .swap()成员函数,因此默认类型T必须支持。

加诸于template参数身上的隐式接口,就像加诸于class对象身上的显式接口一样真实,而且两者都在编译期完成检查。

template<typename T>
void doProcessing(T& w) {
	if (w.size() > 10 && w != someNastyWidget) {
		T temp(w);
		temp.normalize();
		temp.swap(w);
	}
}

编译期多态:以不同的template参数具现化function templates会导致调用不同的函数。(类比于哪 个重载函数该被调用)

凡涉及w的任何函数调用,例如operator>和operator!=,有可能造成template具现化,使这些调用得以成功。这样的具现行为发生在编译期。“以不同的template参数具现化function templates”会导致调用不同的函数,这便是所谓的编译期多态。

条款42:了解typename的双重定义

1.声明template参数时,前缀关键字class和typename可互换。

2.请使用关键字typename标识嵌套从属类型名称;但不得在base class lists(基类列)或member initialization list(成员初值列)内以它作为base class修饰符。

任何时候当你想在template中指涉一个嵌套从属类型名称,就必须在紧临它的前一个位置放置关键字typename。注意:typename只被用来证明嵌套从属类型名称;其他名称不该有它存在。

例(1):

template<typename C>  //允许使用"typenmae"(或"class")
void f(const C& container, //不允许使用"typename"
	typename C::iterator iter);  //一定要使用"typename"

上述的C并不是嵌套从属类型名称(因为它并非嵌套于任何"取决于template参数"的东西内),所以声明container时并不需要以typename为前导,但C::iterator是个嵌套从属类型名称,所以必须以typename为前导。 

例(2):

template<typename C>

C还不确定是个什么东西,依赖于template参数C。现在有C::const_iterator更不确定,两层不确定叫做嵌套从属名称。

template<typename T>
class Derived :public Base<T>::Nested {//base list中不允许使用typename,当然此处默认Base<T>是类型名啦
public:
	explicit Derived(int x):
		Base<T>::Nested(x) //在member initialization list中也不允许使用typename,当然此处也默认Base<T>是类型名啦
	{
		typename Base<T>::Nested temp;//既不在base class list中也不在member initialization list中,所以要使用typename
		...
	}
	...
};

条款43:学习处理模板化基类内的名称

1. 撰写一个程序,它能够传送信息到若干不同的公司去。信息要不译成密码,要不就是未经加工的文字。如果编译期间有足够信息来决定哪一个信息传至哪一家公司,就可以采用基于template的解法,具体代码如下:

class CompanyA {
public:
	void sendCleartext(const string& msg);
	void sendEncrypted(const string& msg);
};
 
class CompanyB {
	void sendCleartext(const string& msg);
	void sendEncryted(const string& msg);
};
 
class CompanyZ {//此类没有sendCleartext成员函数
public:
	void sendEncrypted(const string& msg);
};
 
class MsgInfo{};//这个class用来保存信息,以备将来产生信息
 
template<typename Company>
class MsgSender {
public:
	/*构造函数、析构函数*/
	void sendClear(const MsgInfo& info) {
		string msg;
		/*在这儿,根据info产生信息*/
		Company c;
		c.sendCleartext(msg);
	}
	void sendSecret(const MsgInfo& info){}//类似sendclear,唯一不同是这里调用c.sendEncrypted
};

如果想要在每次送出信息时日记某些信息。derived class可轻易加上这样的生产力

template<typename Company>
class LoggingMsgSender :public MsgSender<Company> {
public:
	/*构造函数、析构函数*/
	void sendClearMsg(const MsgInfo& info) {
		/*将“传送前”的信息写到log*/
		sendClear(info);//调用base class函数;此段代码无法通过编译
		/*将“传送后”的信息写到log*/
	}
};

但是编译并不能通过原因是比如有个全特化的CompanyZ

template<>//一个全特化的MsgSender;它和一般template相同,差别只在于它删除了sendClear。
class MsgSender<CompanyZ> {
public:
	void sendSecret(const MsgInfo& info){}
};

由于Company是typename参数,无法确知是什么(可能是CompanyA或是CompanyZ),也就无法知道class MsgSender<Company>像什么。从而无法知道是否有sendClear成员(因为CompanyA可以有sendClear,而CompanyZ不可以有sendClear成员函数,因为是特化,这种情况有可能发生)。

2.为了防止上述C++“不进入templatized base classes观察”的行为,以下提供三种方法

(1)base class函数调用动作之前加上this->

template<typename Company>
class LoggingMsgSender :public MsgSender<Company> {
public:
	/*构造函数、析构函数*/
	void sendClearMsg(const MsgInfo& info) {
		/*将“传送前”的信息写到log*/
		this->sendClear(info);//调用base class函数,由于this->的存在,可以通过编译
		/*将“传送后”的信息写到log*/
	}
};

(2)使用using声明式

template<typename Company>
class LoggingMsgSender :public MsgSender<Company> {
public:
	using MsgSender<Company>::sendClear;//告诉编译器,请它假设sendClear位于base class内
	void sendClearMsg(const MsgInfo& info) {
		/*将“传送前”的信息写到log*/
		sendClear(info);//OK,假设sendClear将被继承下来
		/*将“传送后”的信息写到log*/
	}
};

(3)明确指出被调用函数位于base class内(利用作用域操作符)(不太好的一点是,如果被调用的是virtual函数,以下的明确资格修饰会关闭“virtual绑定行为”)

template<typename Company>
class LoggingMsgSender :public MsgSender<Company> {
public:
	void sendClearMsg(const MsgInfo& info) {
		/*将“传送前”的信息写到log*/
		MsgSender<Company>::sendClear(info);//OK,假设sendClear将被继承下来
		/*将“传送后”的信息写到log*/
	}
};

3.可在derived class templates内通过"this->"指涉base class templates内的成员名称,或籍由一个明白写出的"base class资格修饰符"完成。

条款44:将与参数无关的代码抽离templates

1.Templates生成多个classes和多个函数,所以任何template代码都不该与某个造成膨胀的template参数产生相依关系。

class templates的成员函数只有在被使用时才被暗中具现化。

Templates可以节省时间和避免代码重复。对于类似的classes或functions,可以写一个class template或function template,让编译器来做剩余的事。这样做,有时候会导致代码膨胀(code bloat):其二进制码带着重复(或几乎重复)的代码、数据,或者两者。但这时候源代码看起来可能很整齐。

先来学习一个名词:共性与变性分析(commonality and variability analysis)。比较容易理解。例如,你在编写几个函数,会用到相同作用的代码;这时候你往往将相同代码搬到一个新函数中,给其他几个函数调用。同理,如果编写某个class,其中某些部分和另外几个class相同,这时候你不会重复编写这些相同部分,只需把共同部分搬到新class中去即可,去使用继承或复合(**条款**32,38,39),让原先的classes取用这些共同特性,原classes的互异部分(变异部分)仍然留在原位置不动。
 

编写templates时,也要做相同分析,避免重复。non-template代码中重复十分明确:你可以看到两个函数或classes之间有所重复。但是在template代码中,重复是隐晦的,因为只有一份template源码。

例如,你打算在为尺寸固定的正方矩阵编写一个template,该矩阵有个支持逆矩阵运算的函数

    template<typename T, std::size_t n>//T为数据类型,n为矩阵大小
    class SquareMatrix{
    public:
        ……
        void invert();//求逆运算
    };
    SquareMatrix<double,5> sm1;
    sm1.invert();//调用SquareMatrix<double,5>::invert
    SquareMatrix<double,10> sm2;
    sm2.invert();//调用SquareMatrix<double,10>::invert

上面会具体化两份invert。这两份函数几乎完全相同(除了一个操作5*5矩阵,一个操作10*10),也就是有许多相同的操作。这就是代码膨胀的一个典型例子。

上面两个函数除了操作矩阵大小不同外,其他相同。这时可以为其建立一个带数值的函数,而不是重复代码。于是有了对SquareMatrix的第一份修改:

    template<typename T>
    class SquareMatrixBase{
    protected:
        void invert(std::size_t matrixSize);
        ……
    };
    template<typename T, std::size_t n>
    class SquareMatrix:private SquareMatrixBase<T>{
    private:
        using SquareMatrixBase<T>::invert();//编码遮掩base中的invert,**条款**33
    public:
    ……
        void invert()//求逆运算
            {
                this->invsert(n);//稍后解释为什么用this,条款43有说明
            }
    };

注意,SquareMatrixBase和SquareMatrix之间继承关系是private,这说明base class是为了帮助derived classes实现,两者不是is-a关系。

现在还有一个问题,SquareMatrixBase::invert操作的数据在哪?它在参数中知道矩阵大小,但是特定矩阵的数据只有derived class才知道。derived class和base class如何联络?一个做法是可以为SquareMatrixBase::invert添加一个参数(例如一个指针)。这个行得通,但是考虑到其他因素(例如,SquareMatrixBase内还有其他函数,也要操作这些数据),可以把这个指针添加到SquareMatrixBase类中。

    template<typename T>
    class SquareMatrixBase{
    protected:
        SquareMatirxBase(std::size_t n,T* pMem)
        :size(n), pData(pMem){}
        void setDataPtr(T* ptr) {pData=ptr;}
        ……
    private:
        std::size_t size;
        T* pData;
    };
    template<typename T, std::size_t n>
    class SquareMatrix:private SquareMatrixBase<T>{
    public:
        SquareMatrix()
        :SquareMatrixBase<T>(n, data){}
        ……
    private:
        T data[n*n];
    };

这种类型的对象不需要动态分配内存,但是对象自身可能非常大。另一个做法是把矩阵数据放到heap

    template<typename T, std::size_t n>
    class SquareMatrix:private SquareMatrixBase<T>{
    public:
        SquareMatrix()
        :SquareMatrixBase<T>(n, 0),
        pData(new T[n*n])
        {this->setDataPtr(pData.get());}
        ……
    private:
        boost::scoped_array<T> pData;
    };

2.因非类型模板参数而造成的代码膨胀,往往可消除,做法是以函数参数或class成员变量替换template参数。

3.因类型参数而造成的代码膨胀,往往可降低,做法是让带有完全相同二进制表述的具现类型共享实现码。

条款45:运用成员函数模板接受所有兼容类型

在模板中,具体化模板参数后的类不会因为具体化类型而存在派生关系。来看一个关于指针的例子。真实指针支持隐式转换(implitic conversions);derived class指针可以隐式转换为base class指针,指向non-const对象的指针可以转换为指向const对象的指针,等等。例如:

    class Top{……};
    class Middle: public Top{……};
    class Bottom:public Middle{……};
    Top* pt1=new Middle;//Middle* 转换为Top*
    Top* pt2=new Bottom;//Bottom* 转换为Top*
    const Top* pct2=pt1;//Top* 转换为const Top*

如果使用模板定义智能指针,上面的转换就有点麻烦了

    template<typename T>
    class SmartPrt{
    public:
        explicit SmartPtr(T* realPtr);
        ……
    };
    SmartPtr<Top> pt1=SmartPtr<Middle>(new Middle);//SmartPtr<Middle>转换为SmartPtr<Top>
    SmartPrt<Top> pt2=SmartPrt<Bottom>(new Bottom);
    SmartPrt<const Top> pct2=pt1;

同一个template的不同具体化之间不存在什么关系,即使具体化的两个类型之间有继承、派生关系。编译器把SmartPtr和SmartPtr视为完全不同两种类型的classes。为了让上面代码编译通过,获得SmartPtr classes之间的转换能力,必须明确的把它们编写出来。

要想实现转换,可以在智能指针的构造函数中完成,但是如果派生类有继续派生,那么构造函数又要添加,这显然不合理。因此,我们需要的不是简简单单为SmartPtr写构造函数,而是编一个构造模板。这么的模板是所谓的member function template(简称member templates),作用是为class生成函数

    template<typename T>
    class SmartPrt{
    public:
        template<typename U>
        SmartPtr(const SmartPrt<U>& other);//member template,为了生成copy cotr
        ……
    };

以上代码意思是,对任何类型T和任何类型U,可以根据SmartPrt<U>生成一个SmartPtr<T>。copy cotr没有声明为explicit,因为转换可能是隐式的。

这个为SmartPtr而写的泛化构造函数提供的东西比我们需要的更多。我们希望根据一个SmartPtr<Bottom>创建一个Smartprt<Top>,却不希望根据一个SmartPtr<Top>创建一个SmartPtr<Bottom>,因为对于public继承来说是矛盾的。

假设SmartPtr像auto_ptr和tr1::shared_ptr一样,提供get成员函数,返回智能指针对象,那么就可以在构造模板中约束转换行为

    template<typaname T>
    class SmartPtr{
    public:
        template<typename U>
        SmartPrt(const SmartPrt<U>& other)
        :heldPrt(other.get()){};
        T* get() const{return heldPrt;}
        ……
    private:
        T* heldPrt;
    };

在上述代码中,存一个隐式转换:将U* 转换为 T*,这限制了转换行为。

member function templates作用不仅仅在于构造函数,还有一个重要作用是支持赋值操作。例如TR1的shared_ptr支持所有来自兼容之内置指针、tr1::shared_ptrs、auto_ptrs和tr1::weak_ptrs的构造行为,以及来自上述各物(tr1::weak_ptr除外)的赋值操作。来看一下TR1规范中关于tr1::shared_ptr的一份摘录

    template<class T>
    class shared_ptr{
    public:
        template<class Y>
            explicit shared_ptr(Y* p);
        template<class Y>
            shared_ptr(shared_ptr<Y> const& r);
        template<class Y>
            explicit shared_ptr(weak_ptr<Y> const& r);
        template<class Y>
            explicit shared_ptr(auto_ptr<Y> const& r);
        template<class Y>
            shared_ptr& operator=(shared_ptr<Y> const& r);
        template<class Y>
            shared_ptr& operator=(auto_ptr<Y> const& r);
        ……
    };

上面除了泛化copy构造函数外,其他构造函数都是explicit,表示shared_ptr类型隐式转换被允许,但是从其他智能指针隐式转换为shared_ptr不被允许。

member function templates并不改变语言基本规则,和编译器产生copy构造函数以及copy assignment不冲突。tr1:shared_ptr声明了一个泛化的copy构造函数,如果T和Y相同,泛化的copy构造函数会被具体化为正常的copy构造函数。编译器会暗自为tr1::shared_ptr生成一个copy构造函数?还是当tr1::shared_ptr对象根据另一个同类型的tr1::shared_ptr对象展开构造行为时,编译器会将泛化的copy构造函数模板具体化呢?

member templates没有改变语言规则,如果程序需要一个copy构造函数,你却没有声明它,编译器就会替你生成。在class内声明泛化copy构造函数并不阻止编译器生成它们自己的copy构造函数(non-template)。如果想要控制copy构造函数的方方面面,就要声明正常的copy构造函数。相同的规则也适用于赋值assignment操作。

1.请使用member function templates(成员函数模板)生成“可接受所有兼容类型”的函数。

2.如果你声明member templates用于“泛化copy构造”或“泛化assignment操作”,你还是需要声明正常的copy构造函数和copy assignment操作符。

条款47:请使用tarits classes表现类型信息

看一下书上的讲解加深印象,这部分stl已经完成过。

1.Traits classes使得“类型相关信息”在编译期可用。它们以templates和templates特化完成实现。

2.整合重载技术后,traits classes有可能在编译期对类型执行if..else测试。

条款48:认识template元编程

 

1.Template metaprogramming(TMP,模板元编程)是编写template-based C++程序,编译的过程。template metaprogramming是用C++写的模板程序,编译器编译出具体化的过程。也就是说,TMP程序执行后,从templates具体化出来C++源码,不再是模板了。

TMP有两个作用,一是它让某些事更容易。例如编写STL容器,使用模板,可是存放任何类型元素。二是将执行在运行期的某些工作转移到了编译期。还有一个结果是使用TMP的C++程序可能在其他方面更高效:较小的可执行文件、较短的运行期、较少的内存需求。但是将运行期的工作转移到了编译期,编译期可能变长了。

 

为了再次认识下事物在TMP中如何运作,来看下循环。TMP没有真正循环,循环由递归(recursion)完成。TMP递归甚至不是正常的递归,因为TMP递归不涉及递归函数调用,而是涉及递归模板化(recursive template instantiation)。

TMP的起手程序是在编译期计算阶乘。TMP的阶乘运输示范如何通过递归模板具体化实现循环,以及如何在TMP中创建和使用变量

    template<unsigned n>
    struct Factorial{
        enum {value=n*Factorial<n-1>::value};
    };
    template<>
    struct Factorial<0>{ //特殊情况,Factorial<0>的值是1
        enum {value=1};
    };

 

有了这个template metaprogram,只要指涉Factorial::value就可以得到n阶乘值。循环发生在template具体化Factorial内部指涉另一个template具体化Factorial之时。特殊情况的template特化版本Factorial<0>是递归的结束。

每个Factorial template具体化都是一个struct,每个struct都声明一个名字为value的TMP变量,用来保存当前计算所获得的阶乘值。TMP以递归模板具体化取代循环,每个具体化有自己一份value,每个value有其循环内适当值。

2.TMP(模板元编程)可将工作由运行期迁往编译期,因而得以实现早期错误侦测和更高的执行效率。

条款49:了解new-handler的行为

知识储备:

new 、operator new 和 placement new 区别

(1)new :不能被重载,其行为总是一致的。它先调用operator new分配内存,然后调用构造函数初始化那段内存。

new 操作符的执行过程:
1. 调用operator new分配内存 ;
2. 调用构造函数生成类对象;
3. 返回相应指针。

(2)operator new:要实现不同的内存分配行为,应该重载operator new,而不是new。

operator new就像operator + 一样,是可以重载的。如果类中没有重载operator new,那么调用的就是全局的::operator new来完成堆的分配。同理,operator new[]、operator delete、operator delete[]也是可以重载的。

(3)placement new:只是operator new重载的一个版本。它并不分配内存,只是返回指向已经分配好的某段内存的一个指针。因此不能删除它,但需要调用对象的析构函数。

如果你想在已经分配的内存中创建一个对象,使用new时行不通的。也就是说placement new允许你在一个已经分配好的内存中(栈或者堆中)构造一个新的对象。原型中void* p实际上就是指向一个已经分配好的内存缓冲区的的首地址。

/

当operator new无法满足某一内存分配需求时,它会先调用一个客户指定的错误处理函数,(默认情况下这个错误处理函数是空,operator new会直接抛出异常)。为了指定这个”用以处理内存不足”的函数,客户必须调用set_new_handler,那是声明于的一个标准库函数,他将执行一个new-handler类型的函数指针。如下:

namespace std{
    typedef void (*new_handler)();
    new_handler set_new_handler(new_handler p) throw();
}

new_handler是一个typedef,它的类型是参数和返回值都为void的函数的函数指针,set_new_handler的new_handler型参数用于指定当无法分配足够内存时调用的函数,set_new_handler返回的函数指针指向在此之前用处理内存不足当马上就要被替换的函数。

new_handler的具体用法如下:

//下面是无法分配足够内存时,应该被调用的函数
void outOfMem(){
    std::cerr<<"Unable to satisfy  request for mempry/n"<<endl;
    std::abort();
}
int main(){
    std::set_new_handler(outOfMem);
    int* pBigDataArray=new int[1000000000];
    ...
}

如果operator new无法分配1000000000个int变量所需空间,它不会立即抛出异常,而是调用outOfMem,因为outOfMem已经被设置为默认的new-handler。并且在以后发生异常后,同样也会调用这个outofMem,因为原本的内存处理函数已经不在了。

 

当我们调用set_new_handler时候,设置的是全局内存错误处理函数。C++自身也不支持class专属的new-handlers。但是我们可以实现如下:

需要每一个class提供自己的set_new_handler和operator new。其中set_new_handler用于设定class专属的new-handler,operator new确保在分配class内存时以class专属的new-handler替换global new-handler,并在class专属的new-handler完成职责后将默认new-handler替换为global new-handler。

class Widget{
public:
    static std::new_handler set_new_handler(std::new_handler p) throw();
    static std::operator new(std::size_t size) throw(std::bad_alloc);
private:
    static std::new_handler currentHandler;
};

std::new_handler Widget::set_new_handler(std::new_handler p) throw(){
    std::new_handler oldHandler=currentHandler;  //保存旧的new-handler
    currentHandler=p;
    return oldHandler;
}

std::new_handler Widget::currentHandler=0;

其中Widget的operator new做以下事情:

(1)调用set_new_handler,将Widget专属的new-handler安装为global new-handler.
(2)调用global operator new用来实际分配内存,如果分配失败,global operator new会调用Widget的new-handler,因为那个函数才刚被安装为new-handler.如果global operator new最终无法分配足够内存,会抛出一个bad_alloc异常,在此情况下Widget的operator new必须恢复原本的global new-handler,然后再传播该异常,这就需要将采用条款13的思想——以对象管理资源。

(3)最后,如果global operator new成功分配一个Widget对象所用内存,Widget的operator new应该返回一个指针,指向分配所得。并且也要恢复原来的new-handler。

结合条款13来实现相应的资源管理类来实现new-handler的自动管理。

class NewHandlerHolder{
public:
    explict NewHandlerHolder(std::new_handler nh):handler(nh){}
    ~NewHandler(){std::set_new_handler(handler);}
private:
    std::new_handler handler;  //用于保存global new-handler
    NewHandlerHolder(const NewHandlerHolder&);
    NewHandlerHolder& operator=(const NewHandlerHolder&);
}
void* Widget::operator new(std::size_t size) throw(std::bad_alloc){
    NewHandlerHolder h(std::set_new_handler(currentHandler));//安装new-handler并使h保存global new-handler
    return ::operator new(size);  //h析构时恢复global new-handler
}

Widget的客户类似这样使用其new-handler

void ourOfMem();
Widget::set_new_handler(outOfMem);
Widget* pw1=new Widget;  //如果内存分配失败,调用的是outOfMem
std::string *ps=new string;   //如果内存分配失败,调用的是global new-handler
Widget::set_new_handler(0);
Widget* pw2=new Widget;  //如果内存分配失败,直接抛出异常

当然,也可以将class专属的new-handler机制泛化为模板,见P245

1.set_new_handler允许客户指定一个函数,在内存分配无法获得满足时被调用。

Nothrow new(在内存不足时,new (std::nothrow)并不抛出异常,而是将指针置NULL)是一个颇为局限的工具,因为它只适用于内存分配;后继的构造函数调用还是可能抛出异常。

条款50-52现阶段还用不着,不过还需要理解,看书

条款50:了解new和delete的合理替换时机

有许多理由需要写个自定的new和delete,包括性能改善、对heap运用错误进行调试、收集heap使用信息。

条款51:编写new和delete时需固守常规

1.operator new应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用new-handler。它也应该有能力处理0bytes申请。

2.operator delte应该在收到null指针时不做任何事。

条款52:写了placement new也要写placement delete

1.当你写一个placement operator new,请确定也写出了对应的placement operator delete。如果没有这样做,你的程序可能发生隐微而时断时续的内存泄漏。

2.当你声明placement new和placement delete,请确定不要无意识(非故意)地遮掩了它们的正常版本。

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值