移动语义和智能指针

学习目标:

理解左值和右值的区别以及右值引用;掌握移动构造函数和移动赋值运算符函数,并理解和拷贝构造和赋值运算符函数的区别,知道为什么要使用移动语义;了解RAII技术,知道RAII技术的本质;掌握四种智能指针的使用;

左值和右值:

左值是指表达式执行结束之后依旧存在的对象,右值是指表达式执行结束之后就不再存在的临时对象
区分左值和右值的方法:能够进行取地址的就是左值,不能进行取地址的就是右值
一般来说:字面值常量、临时对象、临时常量都属于右值;
注:字符串常量属于左值

void test(){
	int a = 1;
	&a;//ok
	&"hello";//ok
	&1;//error
}

右值引用

引用分为三种:非const左值引用、const左值引用、右值引用
非const左值引用只能够绑定左值,无法绑定右值;
const左值引用既可以绑定左值又可以绑定右值;
右值引用只能够绑定右值,无法绑定左值;

void test(){
	//非const左值引用
	int a = 1;
	int &b = a;//ok
	int &b1 = 1;//error
	//const左值引用
	const int &c = a;//ok
	const int &d = 1;//ok
	//右值引用
	int &&e = 1;//ok
	int &&e1 = a;//error
}

注:左值引用本身是左值;但右值引用本身既可以是右值,也可以是左值;
当右值引用作为一个有名字的变量、函数参数时,右值引用是一个左值;
当右值引用作为函数返回值时(没有名字),此时右值引用是一个右值;

int&& func(){
	return 10;
}
void test(){
	&func();//error
	int &&ref = func();//ok
	&ref;//ok
	cout << ref << endl;//error,这里会导致悬空引用
}

注:右值引用一般会有效的延长临时变量的生命周期,上面代码明显无法延长返回值的生命周期,这是为什么?
原因:func的返回值一个匿名的右值引用,其本身也是一个右值,一个右值属性的右值引用绑定一个右值,显然无法延长这个返回值的生命周期,所以这个对象销毁后,返回值其实是一个悬空引用,所以任何想要访问这个返回值的操作都将导致未定义行为;

移动构造函数

和拷贝构造函数一样,接收一个本类型的对象来初始化新对象;但与拷贝构造函数不同的是:
拷贝构造函数的形式为:Point(const Point& p);移动构造函数的形式为:Point(Point&& p);
两者的区别不仅在于形式上,拷贝构造函数的形参列表是一个const左值引用,既能够接收左值,又能够接收右值;移动构造函数的形参列表是一个右值引用,只能够接收右值,无法接收左值;

void test(){
	//String为自定义类
	//拷贝构造函数
	String (const String& str)
	:_pstr(new char[strlen(str._pstr) + 1]())
	{
		cout << "拷贝构造" << endl;
		strcpy(_pstr,str._pstr);
	}
	//移动构造函数
	String (String&& str)
	:_pstr(str._pstr)
	{
		cout << "移动构造" << endl;
		str._pstr = nullptr;
	}
}

注:想要验证是否调用了移动构造函数还是拷贝构造:
编译时加上去优化参数:-fno-elide-constructors;
有时编译器版本过高,想以C++11标准看到结果,可以在去优化参数后再加上-std=c++11看到演示效果
为什么移动构造内部函数体形式是这样的,进行一次浅拷贝,并且把实参的指针置为空?
原因:由于移动构造函数形参接收的是右值属性的对象,这些临时对象都是马上就要销毁的对象,这些对象刚刚创建时申请了堆空间,却马上就要被销毁,显然是一种浪费性能的操作,所以我们直接接管这个临时对象的堆空间资源,不再进行深拷贝,并且把临时对象的指针置为空,防止这个临时对象销毁时调用析构释放这片空间;
移动构造函数的特点:
1.如果没有显式定义构造函数、拷贝构造、赋值运算符函数、析构函数,编译器会自动生成移动构造,对右值的复制会调用移动构造。
2.如果显式定义了拷贝构造,而没有显式定义移动构造,那么对右值的复制会调用拷贝构造。
3.如果显式定义了拷贝构造和移动构造,那么对右值的复制会调用移动构造。
总结:移动构造的优先级要高于拷贝构造

移动赋值函数

拷贝构造和移动构造之间的关系也适用于赋值运算符函数和移动赋值;
赋值运算符函数的形参列表即接收左值,又接收右值;移动赋值的形参列表只接收右值;

void test(){
	//赋值运算符函数
	String& operator=(const String& str){
		if(this != &str){
			delete [] _pstr;
			_pstr = new char[strlen(str._pstr) + 1]();
			strcpy(_pstr,str._pstr);
		}
		return *this;
	}
	//移动赋值函数
	String& operator=(String&& str){
		if(this != &str){//思考这里的自赋值判断是否有必要?
			delete [] _pstr;
			_pstr = str._pstr;
			str._pstr = nullptr;
		}
		return *this;
	}
}

为什么移动赋值运算符函数内部进行浅拷贝和置空参数指针?原因和移动构造一样;
移动赋值函数的特点:
1.如果没有显式定义构造函数、拷贝构造、赋值运算符函数、析构函数,编译器会自动生成移动赋值函数。使用右值的内容进行赋值会调用移动赋值函数。
2.如果显式定义了赋值运算符函数,而没有显式定义移动赋值函数,那么使用右值的内容进行赋值会调用赋值运算符函数。
3.如果显式定义了移动赋值函数和赋值运算符函数,那么使用右值的内容进行赋值会调用移动赋值函数。
移动赋值函数的优先级高于赋值运算符函数;

std::move函数

std::move函数的作用:显式的将一个左值转换为右值;实现本质是一个强制转换;
当将一个左值转换为右值后,如果利用右值引用绑定std::move的返回值,并进行修改操作,那么原来的左值对象也会随之修改,可能无法正常工作了,需要重新赋值才可以继续使用。

void test() {
    int a = 1;
    &(std::move(a)); //error,左值转成了右值,无法进行取地址
    &a;//ok,a依旧是一个左值,并没有因为std::move函数而改变
}

回答前面移动赋值函数中的自赋值判断需不需要的问题:
依旧需要自赋值判断!
在没有学习std::move()函数之前,我们可能会认为不需要自赋值判断了,形参为一个临时对象,我需要对临时对象做自赋值判断?就算是两个临时对象做赋值操作,调用了移动赋值函数,它们的地址也是不可能是一样的

    //创建了两个内容相同的临时对象,也不是同一对象
    String("wangdao") = String("wangdao");
String s1("hello");
s1 = std::move(s1);
s1.print();

在这个代码中,我们使用std::move()函数把s1变成了一个右值属性的对象,所以它会调用到移动赋值函数,如果没有自赋值判断,s1会释放掉原有空间并置为空,会出错;

为什么要使用移动语义

在 C++ 中,移动语义是一种优化技术,用于减少对象复制操作的开销,特别是对于大型对象或资源管理类对象。
一、移动语义的概念
传统的赋值操作和拷贝构造函数通常会进行深拷贝,即创建一个新的对象并复制原始对象的所有数据。这在处理大型对象时可能非常耗时和消耗资源,因为需要分配新的内存并复制大量数据。
移动语义则允许将资源从一个对象转移到另一个对象,而不是进行复制。这可以大大提高程序的性能,特别是在处理临时对象或需要频繁进行对象赋值的情况下。
二、移动语义的实现
右值引用
C++11 引入了右值引用(用 “&&” 表示),用于识别临时对象或即将被销毁的对象。右值引用可以绑定到右值,并且只能绑定到右值。
例如:int&& rval_ref = 10;,这里rval_ref是一个右值引用,绑定到右值 10。
移动构造函数和移动赋值运算符
为了实现移动语义,需要为类提供移动构造函数和移动赋值运算符。
移动构造函数的参数是一个右值引用,它将源对象的资源转移到新创建的对象中,而不是进行复制。
三、为什么要使用移动语义的原因:

如果不使用移动语义,把左值和右值的复制和赋值都交给拷贝构造函数和赋值运算符函数;

如果是右操作数是左值属性的,那么不会有问题,实参初始化形参,调用拷贝构造或者赋值运算符函数,堆空间的申请和释放只会进行一次;

但如果右操作数是右值属性的,实际上在调用拷贝构造或赋值运算符函数之前进行了一次隐式转换,把这个右值转换成了一个临时对象,再把这个临时对象作为实参去初始化形参完成拷贝构造和赋值运算符函数;在创建临时对象的过程中进行了一次new申请堆空间,在调用拷贝构造或赋值运算符函数时又进行了一次new申请堆空间;实际上这个临时对象是马上要销毁的,它所申请的空间也被回收,申请了又马上被回收,这是一种不必要的开销,我们可以直接在拷贝构造和赋值运算符函数中去接管这个临时对象的堆空间,而不用再去new一片新的空间;但我们又不能破坏原有的拷贝构造和赋值运算符函数,因为它们在接收左值属性实参时,是需要去new申请堆空间的;为了把左值和右值分开,此时移动构造和移动赋值运算符函数应运而生,这就是为什么要使用移动语义的原因

RAII技术

所谓RAII,是C++提出的资源管理的技术,全称为Resource Acquisition Is Initialization,由C++之父Bjarne Stroustrup提出。其本质是利用对象的生命周期来管理资源(内存资源、文件描述符、文件、锁等),因为当对象的生命周期结束时,会自动调用析构函数。

RAII类的常见特征

RAII技术,具备以下基本特征:

  • 在构造函数中托管资源;(在给构造函数传参时初始化资源)

  • 在析构函数中释放资源;

  • 一般不允许进行复制或者赋值(对象语义);

  • 提供若干访问资源的方法(如:读写文件)。

RAII技术的模拟

template <class T>
class RAII
{
public:
    //1.在构造函数中初始化资源(托管资源)
    RAII(T * data)
    : _data(data)
    {
        cout << "RAII(T*)" << endl;
    }

    //2.在析构函数中释放资源
    ~RAII(){
        cout << "~RAII()" << endl;
        if(_data){
            delete _data;
            _data = nullptr;
        }
    }

    //3.提供若干访问资源的方法
    T * operator->(){
        return _data;
    }
    
    T & operator*(){
        return *_data;
    }

    T * get() const{
        return _data;
    }

    void reset(T * data){
        if(_data){
            delete _data;
            _data = nullptr;
        }
        _data = data;
    }

    //4.不允许复制或赋值
    RAII(const RAII & rhs) = delete;
    RAII& operator=(const RAII & rhs) = delete;
private:
    T * _data;
};
void test0() {
	Point * pt = new Point(1, 2);
	//智能指针的雏形
	RAII<Point> raii(pt);
	raii->print();
	(*raii).print();
}

此时raii这个对象不是指针,却可以进行指针一样的操作,因为我们定义了operator*,operator->的访问资源的方法,这个对象销毁时会自动调用析构函数进行释放资源,而不用像普通指针一样申请资源后需要进行手动释放资源;
RAII技术的本质:利用栈对象的生命周期管理资源,因为栈对象在离开作用域时候,会执行析构函数。

智能指针

auto_ptr

auto_ptr是最简单的智能指针,使用上存在缺陷,已经被C++17弃用了。
auto_ptr的复制和赋值函数的内部实现中会把形参列表的对象的指针置为空,导致传进去的对象再赋值和赋值操作结束后无法使用;这就是auto_ptr的最大的缺陷

    auto_ptr<int> ap2(ap);
    cout << "*ap2:" << *ap2 << endl; //ok
    cout << "*ap:" << *ap << endl;//error,ap的指针再ap2的复制函数中被置为空,此时访问会报错

也就是说,auto_ptr<int> ap2(ap); 这一步表面上执行了拷贝操作,但是底层已经将右操作数ap所托管的堆空间的控制权交给了新对象ap2,并且将ap底层的指针数据成员置空,该拷贝操作存在隐患,所以auto_ptr被弃用了。

unique_ptr

特点:
1、不可以进行复制和赋值操作
拷贝构造和赋值运算符函数都被删除
2、独享所有权的智能指针
由于拷贝构造和赋值运算符函数被删除,所以无法和其它对象共享这片堆空间的管理权,
3、作为容器元素
unique_ptr想要作为容器元素,就必须利用移动语义的特点,把自身转变为右值属性的unique_ptr;如果传入左值属性的unique_ptr,会进行复制操作,而复制操作是被删除的;
构建右值的方式:
1、std::move函数
2、可以直接使用unique_ptr的构造函数,创建匿名对象(临时对象),构建右值

	vector<unique_ptr<Point>> vec;
    unique_ptr<Point> up4(new Point(10,20));
    //up4是一个左值
    //将up4这个对象作为参数传给了push_back函数,会调用拷贝构造
    //但是unique_ptr的拷贝构造已经删除了
    //所以这样写会报错
    vec.push_back(up4);  //error,这里传参会调用unique_ptr的拷贝构造函数
    
	//up4这个对象对堆空间(10,20)的管理权被移交给了vec[0],此时up4被置为空了,不再具有(10,20)的管理权
    vec.push_back(std::move(up4));  //ok
    vec.push_back(unique_ptr<Point>(new Point(1,3))); //ok

4、具备移动语义的特点

shared_ptr

shared_ptr就是共享所有权的智能指针,可以进行复制或赋值,但复制或赋值时,并不是真正拷贝了被管理的对象,而只是将引用计数加1了。

//但只有赋值或复制时,引用计数才会增加
Point* pt = new Point(10,20);
shared_ptr<Point> sp(pt);
shared_ptr<Point> sp2(pt);
cout << sp.use_count() << endl;//1
cout << sp2.use_count() << endl;//1
//这种情况下引用计数还是1;且会出现double free的问题

特点:
1、共享所有权的智能指针
使用引用计数记录共享对象的个数
2、可以进行赋值或者复制
3、可以作为容器元素
既可以传递左值,又可以传递右值(unique_ptr只能传递右值)
4、具备移动语义的特点
说明底层实现中具备移动构造和移动复制

shared_ptr的循环引用问题
我们建立一个Parent和Child类的一个结构

class Child;

class Parent
{
public:
	Parent()
	{ cout << "Parent()" << endl; }
	~Parent()
	{ cout << "~Parent()" << endl; }
	//只需要Child类型的指针,不需要类的完整定义
	shared_ptr<Child> _spChild;
};

class Child
{
public:
	Child()
	{ cout << "child()" << endl; }
	~Child()
	{ cout << "~child()" << endl; }
	shared_ptr<Parent> _spParent;
};
parentPtr->_spChild = childPtr;
childPtr->spParent = parentPtr;
//获取到的引用计数都是2
cout << "parentPtr.use_count():" << parentPtr.use_count() << endl;
cout << "childPtr.use_count():" << childPtr.use_count() << endl;

——程序结束时,发现Parent和child的析构函数都没有被调用

因为childPtr和parentPtr会先后销毁,但是堆上的Parent对象和Child对象的引用计数都变成了1,而不会减到0,所以没有回收
解决思路:

——希望某一个指针指向一片空间,能够指向,但是不会使引用计数加1,那么堆上的Parent对象和Child对象必然有一个的引用计数是1,栈对象再销毁的时候,就可以使引用计数减为0

shared_ptr无法实现这一效果,所以引入了weak_ptr.

weak_ptr

weak_ptr是一个弱引用的智能指针,不会增加引用计数。

shared_ptr是一个强引用的智能指针。

强引用,指向一定会增加引用计数,只要有一个引用存在,对象就不能释放;

弱引用并不增加对象的引用计数,但是它知道所托管的对象是否还存活。

——循环引用的解法,将Parent类或Child类中的任意一个shared_ptr换成weak_ptr类型的智能指针

比如:将Parent类中的shared_ptr类型指针换成weak_ptr

栈上的childPtr对象先销毁,会使堆上的Child对象的引用计数减1,因为这个Child对象的引用计数本来就是1,所以减为了0,回收这个Child对象,造成堆上的Parent对象的引用计数也减1

再当parentPtr销毁时,会再让堆上的Parent对象的引用计数减1,所以也能够回收。

std::weak_ptr是一种弱引用智能指针,它不控制所指向对象的生命周期。当一个std::weak_ptr被销毁时,不会影响所指向对象的内存分配和释放。所以这里不会导致double free 问题

weak_ptr是弱引用的智能指针,它是shared_ptr的一个补充,使用它进行复制或者赋值时,并不会导致引用计数加1,是为了解决shared_ptr的问题而诞生的。

weak_ptr知道所托管的对象是否还存活,如果存活,必须要提升为shared_ptr才能对资源进行访问,不能直接访问。
初始化

weak_ptr<int> wp;//无参的方式创建weak_ptr

//也可以利用shared_ptr创建weak_ptr 
weak_ptr<int> wp2(sp);

将weak_ptr提升为shared_ptr
weak_ptr也能够托管这片空间,但weak_ptr仍不能够去管理,甚至连访问都不允许(weak_ptr不支持直接解引用)
想要真正地去进行管理需要使用lock函数将weak_ptr提升为shared_ptr

shared_ptr<int> sp2 = wp.lock();
if(sp2){
	cout << "提升成功" << endl;
	cout << *sp2 << endl;
}else{
	cout << "提升失败,托管的空间已经被销毁" << endl;
}

如果托管的资源没有被销毁,就可以成功提升为shared_ptr,否则就会返回一个空的shared_ptr(空指针)
expired函数
该函数返回true等价于use_count() == 0.

bool flag = wp.expired();
if(flag){
	cout << "托管的空间已经被销毁" << endl;
}else{
	cout << "托管的空间还在" << endl;
}

删除器

//auto_ptr和shared_ptr默认的删除器是delete,不是delete[],所以它们默认的使用new
//若shared_ptr使用了new[],则我们应该自定义delete[]给shared_ptr使用来正确释放
//unique_ptr有delete和delete[]两个版本
unique_ptr<int> up(new int());//ok,默认的删除器为delete
unique_ptr<int[]> up1(new int[10]());//ok,默认的删除器为delete[]
unique_ptr<int> up2(new int[10]());//error,类型不匹配,new[]和delete

很多时候我们都用new来申请空间,用delete来释放。库中实现的各种智能指针,默认也都是用delete来释放空间。

但如果我们是用fopen打开文件,这时智能指针的默认处理方式就不能解决了,必须为智能指针定制删除器,也就是定制化释放资源的方式。

unique_ptr对应的删除器

定义unique_ptr时,如果没有指定删除器参数,就会使用默认的删除器。
默认删除器类型重载了函数调用运算符,底层是利用函数对象实现资源回收。

根据参考文档的说明,无论接管的是什么类型的资源,回收时都是会执行delete语句或delete [ ]

如果使用unique_ptr托管文件资源

//用unique_ptr托管文件资源,回收时有问题
void test1(){
string msg = "hello,world\n";
unique_ptr<FILE> up(fopen("res2.txt","a+"));
//get函数可以从智能指针中获取到裸指针
fwrite(msg.c_str(),1,msg.size(),up.get());
//fclose(up.get()); 
}

fclose函数用于关闭文件流,底层其实是调用了free函数,delete底层也调用了free函数;那么为什么会有问题?
实际上fclose函数不只承担了关闭文件流的责任,它还有冲入任何未写入的缓冲数据到os的责任;如果使用unique_ptr默认的delete删除器,那么执行结束后hello,world并没有被写入到文件res2.txt中;因为delete删除器没有冲入未写入的缓冲数据;此时需要我们自定义删除器来代替默认删除器;

struct FILECloser{
void operator()(FILE * fp){
  if(fp){
      fclose(fp);
      cout << "fclose(fp)" << endl;
  }
}
};

创建unique_ptr接管文件资源时,删除器参数使用我们自定义的删除器

void test1(){
string msg = "hello,world\n";
unique_ptr<FILE,FILECloser> up(fopen("res2.txt","a+"));
//get函数可以从智能指针中获取到裸指针
fwrite(msg.c_str(),1,msg.size(),up.get());
}

总结:

如果管理的是普通的资源,不需要写出删除器,就使用默认的删除器即可,只有针对FILE或者socket这一类创建的资源,才需要改写删除器,使用fclose之类的函数。

shared_ptr对应的删除器

unique_ptr 和 shared_ptr区别:

对于unique_ptr,删除器是模板参数;
对于shared_ptr,删除器是构造函数参数;
所以传入删除器参数的位置不同

void test1(){
    string msg = "hello,world\n";
    //在unique_ptr的模板参数中加入删除器类
    unique_ptr<FILE,FILECloser> up(fopen("res2.txt","a+"));
    fwrite(msg.c_str(),1,msg.size(),up.get());
}


void test2(){
    string msg = "hello,world\n";
    FILECloser fc;
    //在shared_ptr的构造函数参数中加入删除器对象
    shared_ptr<FILE> sp(fopen("res3.txt","a+"),fc);
    fwrite(msg.c_str(),1,msg.size(),sp.get());
}

智能指针的误用

智能指针被误用的情况,原因都是将一个原生裸指针交给了不同的智能指针进行托管,而造成尝试对一个对象销毁两次

对于shared_ptr与unique_ptr都会产生这个问题。

void test0(){
//需要人为注意避免
Point * pt = new Point(1,2);
unique_ptr<Point> up(pt);
unique_ptr<Point> up2(pt);//double free
}

shared_ptr是可以共享堆空间资源的,但这个共享是通过复制和赋值操作来进行共享的,直接使用堆空间的地址来初始化两个shared_ptr会让两个shared_ptr认为自己是第一个管理这片空间的对象,都会把引用计数初始化为1;但实际上是有两个shared_ptr对象管理,会导致double free 问题

void test2(){
Point * pt = new Point(10,20);
shared_ptr<Point> sp(pt);
shared_ptr<Point> sp2(pt);//sp和sp2共用一个裸指针,但双方的引用计数都为1;
}

不明显的误用
给Point类加入了这样的成员函数

Point * addPoint(Point * pt){
	_ix += pt->_ix;
	_iy += pt->_iy;
	return this;
}

使用时,这样还是使得sp3和sp同时托管了同一个堆对象

shared_ptr<Point> sp(new Point(1,2));    
shared_ptr<Point> sp2(new Point(3,4));

//创建sp3的参数实际上是sp所对应的裸指针
//效果还是多个智能指针托管了同一块空间
shared_ptr<Point> sp3(sp->addPoint(sp2.get()));
cout << "sp3 = ";
sp3->print();

这里也会导致double free问题,原因还是裸指针,addPoint函数中return this,这个this就是裸指针,这个裸指针在返回后被sp3利用创建对象,现在是sp和sp3管理同一片堆空间,但互相都不知道,因为引用计数为1;
解决思路:

通过this指针获取本对象的shared_ptr

可以修改Point中的addPoint函数

shared_ptr<Point> addPoint(Point * pt){
	_ix += pt->_ix;
	_iy += pt->_iy;
 //这里this指针代表着Point*本身,利用地址本身创建出的临时对象,sp的引用计数并不会+1;
	return shared_ptr<Point>(this); //匿名的shared_ptr对象与sp共用同一个裸指针,两方的引用计数都为1;
}

/
shared_ptr<Point> sp(new Point(1,2));    
shared_ptr<Point> sp2(new Point(3,4));

shared_ptr<Point> sp3(sp->addPoint(sp2.get()));//创建了一个临时对象,又return复制了一份临时对象,最后又复制给了sp3,此时sp3的引用计数应该为3;但sp的引用计数为1;等到这两个临时对象都销毁了,sp3的引用计数又变成1;
cout << "sp3 = ";
sp3->print();//到此sp3和sp共用一个裸指针,双方的引用计数都为1

这样写,在addPoint函数中创建的匿名智能指针对象接收的还是sp对应的裸指针,那么这个匿名对象和sp所托管的空间还是同一片空间。匿名对象在函数return时调用移动构造创建出副本,副本与sp还是共用一个裸指针。

addPoint函数返回的副本对象是一个右值属性,此时会调用sp3的移动构造函数,把副本的堆空间管理权移交给了sp3,sp,sp3销毁时又会尝试delete一次。导致double free 问题;
解决方法:

——使用智能指针辅助类enable_shared_from_this的成员函数shared_from_this

在Point的addPoint函数中需要使用shared_from_this函数返回的shared_ptr作为返回值,要想在Point类中调用enable_shared_from_this的成员函数,最佳方案可以让Point类继承enable_shared_from_this类。(无法在enable_shared_from_this中声明友元Point)

这样修改addPoint函数后,问题解决。

class Point 
: public std::enable_shared_from_this<Point> 
{
public:
	//...
	shared_ptr<Point> addPoint(Point & pt) {
		_ix += pt._ix;
		_iy += pt._iy;
		return shared_from_this();
	}
};

总结:智能指针的误用全都是使用了不同的智能指针托管了同一块堆空间(同一个裸指针)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值