【GOF设计模式之路】-- Singleton

之前一直徘徊第一篇该写哪一种设计模式,最后决定还是以Singleton模式开始吧。之所以以它开始,原因在我于个人认为,相对来说它在设计上比较单一,比较简单一些。在通常情况下,它是最容易理解的。同样也正因为它容易理解,细节才更值得注意,越是简单的东西,往往会被我们忽略一些细节。关于Singleton模式的讨论和实现也非常的多,GOF设计模式也只对它进行了简单的描述,本文则打算相对全面的介绍一下Singleton模式,目的在于挖掘设计的思想。

 

Singleton模式又称单件模式,我们都知道全局变量,Singleton就相当于一个全局变量,只不过它是一种被改进的全局变量。体现在哪儿呢?普通的全局变量可以有多个,例如某个类,可以定义很多个这个类的全局对象,可以分别服务于整个应用程序的各个模块。而Singleton则只允许创建一个对象,就好比不允许有克隆人一样,哪天在大街上看见一个一模一样的你,你会是什么感受?从计算机的角度,例如键盘、显示器、系统时钟等,都应该是Singleton,如果有多个这样的对象存在,很可能带来危险,而这些危险却不能换来切实的好处。

 

在GOF设计模式里,对Singleton的描述很简单:“保证一个类只有一个实体(instance),并为其提供一个全局访问点(global access point)”。所谓全局访问点,就是Singleton类的一个公共全局的访问接口,用于获得Singleton实体,对于用户来说,只需要简单的步骤就能获得这个实体,与全局变量的访问一样简单,而Singleton对象的创建与销毁有Singleton类自己承担,用户不必操心。既然是自己管理,我们就得将其管理好,让用户放心,现在是提倡服务质量的社会,我们应该有服务的态度。

 

理论讲了一大堆,迫不及待想看看具体实现,先看一个初期的版本:

/* Singleton.h */

//-----------------------------------------------------------------------------------
//  Desc:   Singleton Header File
//  Author: masefee
//  Date:   2010.09
//  Copyright (C) 2010 masefee
//-----------------------------------------------------------------------------------

#ifndef __SINGLETON_H__
#define __SINGLETON_H__

class Singleton
{
public:
    static Singleton* Instance( void );

public:
    int  doSomething( void );

protected:
    Singleton( void ){}

private:
    static Singleton* ms_pInstance;
};

#endif

 

 

/* Singleton.cpp */

//-----------------------------------------------------------------------------------
//  Desc:   Singleton Source File
//  Author: masefee
//  Date:   2010.09
//  Copyright (C) 2010 masefee
//-----------------------------------------------------------------------------------

#include "Singleton.h"

Singleton* Singleton::ms_pInstance = 0;

Singleton* Singleton::Instance( void )
{
    if ( ms_pInstance == 0 )
        ms_pInstance = new Singleton;

    return ms_pInstance;
}

int  Singleton::doSomething( void )
{

 

}

 

 

/* main.cpp */

#include "Singleton.h"

int main( void )
{
    Singleton* sig = Singleton::Instance();
    sig->doSomething();
    delete sig;

    return 0;
}

 

Singleton类的构造函数被声明为protected,当然也可以声明为private,声明为protected是为了能够被继承。但用户不能自己产生Singleton对象,唯一能产生Singleton对象的就只有Instance成员函数,这个Instance函数即所谓的全局访问点。访问方式如上面main函数中的红色代码。如果用户没有调用Instance函数,Singleton对象就不会产生出来,这样优化的成本是Instance函数里的if检测,但是好处是,如果Singleton对象产生很昂贵,而本身有很少使用,这种“使用才诞生”的方案就会显尽优势了。

 

上面将ms_pInstance在全局初始化为0,这样做有个好处,即编译器在编译时就已经将ms_pInstance的初始值写到了可执行二进制文件里了,Singleton对象的唯一性在这个时期就已经决定了,这也正是C++实现Singleton模式的精髓所在。如果将ms_pInstance改造一下,如:

class Singleton
{
public:
    static Singleton* Instance( void );

public:
    void doSomething( void );

protected:
    Singleton( void ){}

private:
    static Singleton ms_Instance;
};

 

Singleton Singleton::ms_Instance;

Singleton* Singleton::Instance( void )
{
       return &ms_Instance;
}

 

将ms_pInstance由指针改成了对象,这样做未必是件好事,原因在于ms_Instance是被动态初始化(在程序运行期间调用构造函数进行初始化)的,而ms_pInstance在前面已经说过,它是属于静态初始化,编译器在编译时就将常量写入到二进制文件里,在程序装载到内存时,就会被初始化。我们都知道,在进入main之前,有很多初始化操作,对于不同编译单元的动态初始化对象,C++并没有规定其初始化顺序,因此上面的改造方法存在如下隐患:

#include "Singleton.h"

int g_iRetVal = Singleton::Instance()->doSomething();

 

由于无法确保编译器一定先将ms_Instance对象初始化,所以全局变量g_iRetVal在被初始赋值时,Singleton::Instance()调用可能返回一个尚未构造的对象,这也就意味着你无法保证任何外部的对象使用的ms_Instance对象都是一个已经被正确初始化的对象。危险也就不言而喻了。

 

Singleton模式意为单件模式,于是保证其唯一性就成了关键,看看上面的第一种实现,ms_pInstance虽然是Instance成员函数所创建,它返回了一个Singleton对象的指针给外界,并且将这个指针的销毁权利赋予了外界,这就存在了第一个隐患,倘若外界将返回的指针给销毁了,然后再重新调用Instance函数,则前后对象内存地址通常将发生变化,如:

Singleton* sig1 = Singleton::Instance();
sig->doSomething();
delete sig;

Singleton* sig2 = Singleton::Instance();

 

如上,sig1和sig2的指向的Singleton对象的内存地址通常是不一样的,例如前面的sig1指向的对象保存了一些状态,这样销毁之后再次创建,状态已经被清除,程序也就容易出错了。所以为了避免这样类似的情况发生,我们再改进一下Instance函数:

static Singleton& Instance( void );

传回引用则不用担心被用户释放掉对象了,这样就比较安全了,Singleton对象都由其自身管理。

 

在C++类中,还有一个copy(复制)构造函数,在上面的Singleton类里,我们并没有显示声明copy构造,于是如果有以下写法:

Singleton  sig( Singleton::Instance() );

 

如果我们不显示声明一个copy构造,编译器会帮你生成一个默认的public版本的copy构造。上面的写法就会调用默认的copy构造,从而用户就能在外部声明一个Singleton对象了,这样就存在了第二个隐患。因此,我们将copy构造也声明为protected保护成员。

 

另外还有一个成员函数,赋值(assignment)操作符。因为你不能将一个Singleton对象赋值给另外一个Singleton对象,这违背了唯一性,不允许存在两个Singleton对象。因此我们将赋值操作符也声明为保护成员,同时对于Singleton来说,它的赋值没有意义,唯一性的原则就使它只能赋值给自己,所以赋值操作符我们不用去具体实现。

 

最后一个是析构函数,如前面所说,用户会在外界释放掉Singleton对象,为了避免这一点,所以我们也将析构函数声明为保护成员,就不会意外被释放了。

 

上述所有手段统一到一起之后,Singleton类的接口声明如下:

class Singleton
{
public:
    static Singleton& Instance( void );

public:
    void doSomething( void );
   

protected:
    Singleton( void );
    Singleton( const Singleton& other );
    ~Singleton( void );
    Singleton& operator =( const Singleton& other ); 

 

private:
    static Singleton* ms_pInstance;
};

 

这样似乎已经完美了,Singleton对象的创建完全有Singleton类自身负责了,再看前面的创建过程,ms_pInstance是一个指针,Singleton对象是动态分配(new)出来的,那么释放过程就得我们手工调用delete,否则将发生内存泄露。然而析构函数又被我们定义为保护成员了,因此析构问题还没有得到解决。

 

这成了一个比较棘手的问题,既要保证程序运行时整个范围的唯一性,又要保证在销毁Singleton对象时没有人在使用它,所以销毁的时机显得尤为重要,也比较难把握。

 

于是有人想到了一个比较简单的方案,不动态分配Singleton对象便可以自动销毁了,但销毁的最好时期是在程序结束时最好,于是想到了如下方案:

Singleton& Singleton::Instance( void )
{
    static Singleton _inst;
    return _inst;

_inst是一个静态的局部变量,它的初始化是在第一次进入Instance函数时,这属于执行期初始化,而与编译期间常量初始化不同,_inst对象初始化要调用构造函数,这不可能在编译期间完成,与:

int func( void )
{
    static int a = 100;  // 编译期间常量初始化
    return a;
}

不同。a的值在编译期间就已经决定了,在应用程序装载到内存时就已经为100了,而非在第一次执行func函数时才被赋值为100。

 

_inst对象的销毁工作由编译器承担,编译器将_inst对象的销毁过程注册到atexit,它是一个标准C语言库函数,让你注册一些函数得以在程序结束时调用,调用次序与栈操作类似,后进先出的原则。atexit的原型:

int __cdecl atexit( void ( __cdecl* pFunc )( void ) );

 

在Instance函数的反汇编代码上有所体现(VS2008 Release 禁用优化(/Od)):

Singleton& Singleton::Instance( void )
{
00CB1040  push        ebp 
00CB1041  mov         ebp,esp
    static Singleton _inst;
00CB1043  mov         eax,dword ptr [$S1 (0CB3374h)]
00CB1048  and         eax,1
00CB104B  jne         Singleton::Instance+33h (0CB1073h)
00CB104D  mov         ecx,dword ptr [$S1 (0CB3374h)]
00CB1053  or          ecx,1
00CB1056  mov         dword ptr [$S1 (0CB3374h)],ecx
00CB105C  mov         ecx,offset _inst (0CB3370h)
00CB1061  call        Singleton::Singleton (0CB1020h)
00CB1066  push        offset `Singleton::Instance'::`2'::`dynamic atexit destructor for '_inst'' (0CB1880h)
00CB106B  call        atexit (0CB112Eh)
00CB1070  add         esp,4
    return _inst;
00CB1073  mov         eax,offset _inst (0CB3370h)
}
00CB1078  pop         ebp 
00CB1079  ret   

红色的一句汇编代码即是得到_inst的析构过程地址压入到atexit的参数列表,红色粗体则调用了atexit函数注册这个析构过程。这里所谓的析构过程并不是Singleton类的析构函数,而是如下过程:

`Singleton::Instance'::`2'::`dynamic atexit destructor for '_inst'':
00CB1880  push        ebp 
00CB1881  mov         ebp,esp
00CB1883  mov         ecx,offset _inst (0CB3370h)
00CB1888  call        Singleton::~Singleton (0CB1030h)
00CB188D  pop         ebp 
00CB188E  ret

这也是一个函数,在此函数里再调用Singleton的析构函数,如蓝色那句汇编代码。道理很简单,由于Singleton的析构函数是__thiscall,需要传递类对象,所以不是直接call析构函数的地址。

 

这种方式销毁在大多数情况下是有效的,在实际中,这种方式也用得比较多。可以根据实际的情况,选择不同的机制,Singleton没有定死只能用哪种方式。

 

既然上述方式在大多数情况下是有效的,那么肯定就有一些情况会有问题,这就引出了KDLkeyboard、display、log问题,假设我们程序中有三个singletons:keyboard、display、log,keyboard和display表示真实的物体,log表示日志记录,可以是输出到屏幕或者记录到文件。而且log由于创建过程有一定的开销,因此在有错误时才会被创建,如果程序一直没有错误,则log将不会被创建。

 

假如程序开始执行,keyboard顺利创建成功,而display创建过程中出现错误,这是需要产生一条log记录,log也就被创建了。这时由于display创建失败了,程序需要退出,由于atexit是后注册的先调用,log最后创建,则也是最后注册atexit的,因此log最先销毁,这没有问题。但是log销毁了,如果随后的keyboard如果销毁失败需要产生一条log记录,而这是log已经销毁了,log::Instance会不明事理的返回一个引用,指向了一个log对象的空壳,此后程序便不能确定其行为了,很可能发生其他的错误,这也称之为"dead - reference"问题。

 

从上面的分析来看,我们是想要log最后销毁,不管它是在什么时候创建的,都得在keyboard和display之后销毁,这样才能记录它们的析构过程中发生的错误。于是我们又想到,可以通过记录一个状态,来作为"dead - reference"检测。例如定义一个static bool ms_bDestroyed变量来标记Singleton是否已经被销毁。如果已经销毁则置为true,反之置为false。

/* Singleton.h */

class Singleton
{
public:
    static Singleton& Instance( void );

public:
    void doSomething( void );

protected:
    Singleton( void ){};
    Singleton( const Singleton& other );
    ~Singleton( void );
    Singleton& operator =( const Singleton& other );  

private:
    static Singleton* ms_pInstance;
    static bool           ms_bDestroyed;
};

 

/* Singleton.cpp */

#include <iostream>
#include "Singleton.h"

Singleton* Singleton::ms_pInstance  = 0;
bool       Singleton::ms_bDestroyed = false;

Singleton& Singleton::Instance( void )
{
    if ( !ms_pInstance )
    {
        if ( ms_bDestroyed )
            throw std::runtime_error( "Dead Reference Detected" );
        else
        {
            static Singleton _inst;
            ms_pInstance = &_inst;
        }
    }
    return *ms_pInstance;
}

Singleton::~Singleton( void )
{
    ms_pInstance  = 0;
    ms_bDestroyed = true;
}

void Singleton::doSomething( void )
{

}

 

这种方案能够准确的检测"dead - reference",如果Singleton已经被销毁,ms_bDestroyed成员被置为true,再次获取Singleton对象时,则会抛出一个std::runtime_error异常,避免程序存在不确定行为。这种方案相对来说比较高效简洁了,也可适用于一定场合。

 

但这种方案在有的时候也不能让我们满意,虽然抛出了异常,但是KDL问题还是没有被最终解决,只是规避了不确定行为。于是我们又想到了一种方案,即是让log重生,一旦发现log被销毁了,而又需要记录log,则再次创建log。这就能保证log至始至终一直存在了,我们只需要在Singleton类里添加一个新的成员函数:

class Singleton
{
    ... ... other member ... ...

private:
    static void destroySingleton( void );
};

 

实现则为:

void Singleton::destroySingleton( void )
{
    ms_pInstance->~Singleton();
}

Instance成员就得在改造一下了:

Singleton& Singleton::Instance( void )
{
    if ( !ms_pInstance )
    {
        static Singleton _inst;
        ms_pInstance = &_inst;

        if ( ms_bDestroyed )
        {
            new( ms_pInstance ) Singleton;
            atexit( destroySingleton );

            ms_bDestroyed = false;
        }
    }
    return *ms_pInstance;
}

destroySingleton与前面汇编那段相似,相当于这个工作让我们自己来做了,而不是让编译器来做。destroySingleton手动调用Singleton的析构函数,而destroySingleton又被我们注册到atexit。当ms_pDestroyed为真时,则再次在ms_pInstance指向的内存出创建一个新的Singleton对象,这里使用的是placement new操作符,它并不会新开辟内存(参见:利用C++的operator new实现同一对象多次调用构造函数)。之后,注册destroySingleton为atexit,在程序结束时调用并析构Singleton对象。如此而来,便保证了Singleton对象的生命期跨越整个应用程序。log如果作为这样一个Singleton对象,那么无论log在什么时候被销毁,都能记录所有错误日志了。

 

似乎到此已经就非常完美了,但是还有一点不得不提,使用atexit具有一个未定义行为:

void func1( void )
{

}

void func2( void )
{
    atexit( func1 );
}

void func3( void )
{

}

int main( void )
{
    atexit( func2 );
    atexit( func3 );
    return 0;
}

CC++标准里并没有规定上面这种情况的执行次序,按前面的后注册先执行的说法,按理说func1被最后注册,则应该最先执行它,但是它的注册是由func2负责的,这就得先执行func2,才能注册func1。这样就产生了矛盾,所以以编译器的次序为准,在VS2008下,上面的例子中,最先执行func3,再执行func2,最后执行func1。看起来像是一个层级关系,main函数的注册顺序是一层,后来的又是一层,也可以认为在被注册为atexit的函数里再注册其它函数时,其它函数的执行次序在当前函数之后,如果注册了多个,则最后注册的在当前函数执行后立即执行。

 

上面这种机制是通过延长Singleton的声明周期,它破坏了其正常的生命周期。很可能带来不必要的迷惑,于是我们又想到了一种机制:是否能够控制Singleton的寿命呢,让log的寿命比keyboard和display的寿命长,便能够解决KDL问题了。控制寿命还可以针对不同的对象,不单单只是Singleton,它可以说是一种可以移植的概念。

 

我们实现一个生命期管理器,如下:

---LifeTime.hpp-- --由于代码编辑器的问题,省略了include-- class BaseLifetimeTracker { public: BaseLifetimeTracker( unsigned int longevity ) : m_iLongevity( longevity ) { } virtual ~BaseLifetimeTracker( void ) { } friend inline bool Compare( const BaseLifetimeTracker* x, const BaseLifetimeTracker* y ) { return y->m_iLongevity < x->m_iLongevity; } protected: static unsigned int elements; static BaseLifetimeTracker** pTrackerArray; private: unsigned int m_iLongevity; }; unsigned int BaseLifetimeTracker::elements = 0; BaseLifetimeTracker** BaseLifetimeTracker::pTrackerArray = 0; template< class T > class Deleter { public: static void Delete( T* pObj ) { delete pObj; } }; template< class T, class Destroyer = Deleter< T > > class LifetimeTracker : public BaseLifetimeTracker { public: static void SetLongevity( T* pDynObj, unsigned int longenvity ) { BaseLifetimeTracker** pNewArray = static_cast< BaseLifetimeTracker** >( std::realloc( pTrackerArray, sizeof( BaseLifetimeTracker* ) * ( elements + 1 ) ) ); if ( !pNewArray ) throw std::bad_alloc(); pTrackerArray = pNewArray; BaseLifetimeTracker* pInsert = new LifetimeTracker< T >( pDynObj, longenvity ); BaseLifetimeTracker** pos = std::upper_bound( pTrackerArray, pTrackerArray + elements, pInsert, Compare ); std::copy_backward( pos, pTrackerArray + elements, pTrackerArray + elements + 1 ); *pos = pInsert; ++elements; std::atexit( AtExitFn ); } static void AtExitFn( void ) { assert( elements > 0 && pTrackerArray != 0 ); BaseLifetimeTracker* pTop = pTrackerArray[ elements - 1 ]; pTrackerArray = static_cast< BaseLifetimeTracker** >( std::realloc( pTrackerArray, sizeof( BaseLifetimeTracker* ) * --elements ) ); delete pTop; } public: LifetimeTracker( T* pDynObj, unsigned int longevity ) : BaseLifetimeTracker( longevity ), m_pTracked( pDynObj ) { } ~LifetimeTracker( void ) { Destroyer::Delete( m_pTracked ); } private: T* m_pTracked; };   

 

有了这个管理器,就能设置Singleton对象的寿命了,pTrackerArray是按生命周期的长度进行升序排列的,最前面的就是最先销毁的,这与前面的销毁规则是一致的。 对于前面的KDL问题,我们就可以将keyboard和display的寿命设置为1,log的寿命设置为2,keyboard和display不存在先后问题,寿命相同也不影响。log寿命为2,大于keyboard和display就行,保证在最后销毁。这样一来,在单个线程下的KDL问题就完美解决了。

 

既然上面说了是在单线程里,言外之意就会存在多线程问题,例如:

Singleton& Singleton::Instance( void )
{
    if ( !ms_pInstance )
    {
        ms_pInstance = new Singleton;
    }
    return *ms_pInstance;
}

假如有两个线程要访问这个Instance,第一个线程进入Instance函数,并检测if条件,由于是第一次进入,if条件成立,进入了if,执行到红色代码。此时,有可能被OS的调度器中断,而将控制权交给另外一个线程。

 

第二个线程同样来到if条件,发现ms_pInstance还是为NULL,因为第一个线程还没来得及构造它就已经被中断了。此时假设第二个线程完成了new的调用,成功的构造了Singleton,并顺利的返回。

 

很不幸,第一个线程此刻苏醒了,由于它被中断在红色那句代码,唤醒之后,继续执行,调用new再次构造了Singleton,这样一来,两个线程就构建两个Singleton,这就破坏了唯一性。

 

我们意识到,这是一个竞态条件问题,在共享的全局资源对竞态条件和多线程环境而言都是不可靠的。怎么避免上面的这种情况呢,有一种简单的做法是:

Singleton& Singleton::Instance( void )
{
    _Lock holder( _mutex );
    if ( !ms_pInstance )
    {
        ms_pInstance = new Singleton;
    }
    return *ms_pInstance;
}

_mutex是一个互斥体,_Lock类专门用于管理互斥体,在_Lock的构造函数中对_mutex加锁,在析构函数中解锁。这样保证同一在锁定之后操作不会被其它线程打断。holder是一个临时的_Lock对象,在Instance函数结束时会调用其析构,自动解锁。这也是著名的RAII机制。

 

似乎这样做确实能够解决竞态条件的问题,在一些场合也是可以的,但是在需要更高效率的环境下,这样做缺乏效率,比起简单的if ( !ms_pInstance )测试要昂贵很多。因为每次进入Instance函数都加锁解锁一次,即使需要加锁解锁的只有第一次进入时。所以我们想要有这样的解法:

Singleton& Singleton::Instance( void )
{
    if ( !ms_pInstance )
    {
        _Lock holder( _mutex );
        ms_pInstance = new Singleton;
    }
    return *ms_pInstance;
}

这样虽然解决了效率问题,但是竞态条件问题又回来了,打不到我们的要求,因为两个线程都进入了if,再锁定还是会产生两个Singleton对象。于是有一个比较巧妙的用法,即“双检测锁定”Double-Checked Locking模式。直接看效果吧:

Singleton& Singleton::Instance( void )
{
    if ( !ms_pInstance )
    {
        _Lock holder( _mutex );
        if ( !ms_pInstance )

            ms_pInstance = new Singleton;
    }
    return *ms_pInstance;
}

 

非常美妙,这样就解决了效率问题,同时还解决了竞态条件问题。即使两个线程都进入了第一个if,但第二个if只会有一个线程进入,这样当某个线程构造了Singleton对象,其它线程因为中断在_Lock holder( _mutex )这一句。等到唤醒时,ms_pInstance已经被构造了,第二个if测试就会失败,便不会再次创建Singleton了。第一个if显得很粗糙快速,第二个if显得清晰缓慢,第一个if是为了第二次进入Instance函数提高效率不再加锁,第二个if是为了第一次进入Instance避免产生多个Singleton对象,各施其职,简单而看似多余的if测试改进,显得如此美妙。

 

本文从开头到现在,一次又一次感到完美,又一次一次发现不足。到此,又似乎感到了完美,但完美背后还真容易有阴霾。虽然上面的双检测锁定已经在理论上胜任了这一切,趋近于完美。但是,有经验的程序员,会发现它还是存在一个问题。

 

对于RISC(精简指令集)机器的编译器,有一种优化策略,这个策略会将编译器产生出来的汇编指令重新排列,使代码能够最佳运用RISC处理器的平行特性(可以同时执行几个动作)。这样做的好处是能够提高运行效率,甚至可以加倍。但是不好之处就是破坏了我们的“完美”设计“双检测锁定”。编译器很可能将第二个if测试的指令排列到_Lock holder( _mutex )的指令之前。这样竞态条件问题又出现了,哎!

 

碰到这样的问题,就只有翻翻编译器的说明文档了,另外可以在ms_pInstance前加上volatile修饰,因为合理的编译器会为volatile对象产生恰当而明确的代码。

 

到此,常见的问题都基本解决了,不管是多线程还是单线程,在具体的环境我们再斟酌选择哪一种方式,因此,本文并没有给出一个统一的解决方案。你还可以将上面的机制组装到一起,写成一个SingletonHolder模板类,在此就不实现了。Singleton还能根据具体进行扩展,方法也不止上面这些,我们只有一个目的,让它正确的为我们服务。

 

在本文开头说Singleton是一个相对好理解的一种设计模式,但从整篇下来,它也并不是那么单纯。由简单到复杂,每一种设计方案都有它的用武之地,例如,我们的程序里根本就不会出现KDL问题,那么就可以简单处理。再者我们有的Singleton不可能在多线程环境里运行,那么我们也没有必要设计多线程这一块,而只需要在考虑问题时意识到就可以了。做到一切尽在掌握之中即可。

 

好吧!本文就到此结束,重在体会这些细节的机制和挖掘问题然后解决问题的乐趣。在此感谢《Modern C++ Design》,望大家多提意见,感谢!!

 

【GOF设计模式之路】目录

【GOF设计模式之路】-- 开篇

【GOF设计模式之路】-- Singleton

【GOF设计模式之路】-- Factory

【GOF设计模式之路】-- Observer

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值