C++ Primer 第十二章 12.1 动态内存与智能指针 练习和总结

前文

之前我们只学习过静态内存和栈内存。现在还有一个堆内存,他们保存的变量类型分别为

内存名字 该内存中保存的类型
静态内存 static局部变量、类的static数据成员、定义在函数体之外的变量
栈内存 函数体中的所有非static变量
堆内存 动态分配内存空间的变量

12.1动态内存和智能指针

我们可以使用new关键字动态的分配内存空间,并返回指向该对象的指针,我们可以使用delete关键字,传入一个动态分配内存的指针,销毁该对象,回收内存。

但是直接使用new和delete是非常容易犯错的,所以C++标准提供了两个智能指针。
shared_ptr,unique_ptr。

shared_ptr允许多个指针指向同一个对象。
unique_ptr,只允许一个指针指向一个对象。

这两个指针定义在memory头文件中

12.1.1 shared_ptr类

shared_ptr是一个模板,我们在创建一个智能指针变量时,需要指定这个指针的类型。

std::shared_ptr<string> p;

下面是unique_ptr和shared_ptr都支持的操作。
在这里插入图片描述
可以看到智能指针的用法和普通指针没有什么区别,我们可以使用get来获取,智能指针中保存的普通指针。

shared_ptr独有的操作
在这里插入图片描述
shared_ptr使用的是引用计数的方法来管理内存。我们不必在乎引用计数是如何实现了,只需要知道什么时候引用计数什么时候会+1,什么时候会-1,什么时候会回收内存空间。

引用计数情况 发生的情况
引用计数+1 创建一个智能指针+1;进行拷贝时,被拷贝者+1;进行赋值时,赋值语句右侧的智能指针+1;作为实参传入函数时+1;当做返回值返回时+1
引用计数-1 赋值语句左侧的智能指针-1;生命周期结束-1
引用计数=0 销毁指针所指向的对象,回收内存

例子

//创建p1,p1的引用计数+1,此时为1
std::shared_ptr<string> p1 = std::make_shared<string>("123");
//p2的引用计数+1,此时为1
std::shared_ptr<string> p2 = std::make_shared<string>("233");
//将p2的值赋值给p1,p2引用计数+1,p1引用计数-1,p1的引用计数为0,销毁p1指向的对象回收内存
//p1和p2现在指向同一个对象,引用计数为2.
p1 = p2;

最安全的分配和使用动态内存的方法是调用make_shared的标准库函数,他是一个模板函数,所以需要传入类型,和容器的emplace()一样,我们可以在()中,传入类型的构造函数所需的参数。

std::shared_ptr<string> p = std::make_shared<string>("123");

当引用计数为0的时候,智能指针会销毁指向的对象并回收内存,其本质是调用对象的析构函数

什么时候使用动态内存
1.程序不知道自己需要使用多少对象。(比如可以动态添加元素的容器类,就是使用动态内存)
2.程序不知道所需对象的准确类型(这个没有体会到)
3.程序需要在多个对象间共享数据。(比如问中提到了StrBlob)

练习

12.1

因为b1=b2,所以b1和b2的数据成员data指向的是同一个对象,所以他们的元素都为4个。

12.2

注意在编写fornt和back的const版本时,front和back的返回值也需要为const。不然我们依旧可以通过返回值来修改data所指向的对象的值。

class StrBlob {
public:
	using size_type = vector<string>::size_type;
	StrBlob();
	StrBlob(std::initializer_list<std::string> il);
	size_type size() const { return data->size(); };
	bool empty() const { return data->empty(); };
	void push_back(const string& str) { data->push_back(str); };
	void pop_back();
	std::string& front();
	const std::string& front()const ;
	std::string& back();
	const std::string& back() const;
private:
	std::shared_ptr<vector<string>> data;
	void check(size_type i,const string& msg) const;
};
void StrBlob::check(size_type i, const string& msg) const{
	if (i>=data->size()) {
		throw std::out_of_range(msg);
	}
}
string& StrBlob::front() {
	check(0, "front out of range");
	return data->front();
}

const string& StrBlob::front() const{
	check(0, "front out of range");
	return data->front();
}
string& StrBlob::back(){
	check(0, "back out of range");
	return data->back();
}

const string& StrBlob::back() const{
	check(0, "back out of range");
	return data->back();
}

void StrBlob::pop_back() {
	check(0, "pop_back() out of range");
	data->pop_back();
}
StrBlob::StrBlob():data(std::make_shared<vector<string>>()) {
	
}


StrBlob::StrBlob(std::initializer_list<string> il) : data(std::make_shared<vector<string>>(il)) {
	
}
12.3

不需要,因为如果一个对象为常量,我们认为他是不能够改变数据成员的。push_back()和pop_back()都会data所指向的对象的数据成员,所以当常量调用push_back,pop_back()时,应该编译报错

12.4

因为i是stirng::size_type类型,而data->size()也是type()类型,size_type类型是size_t,而size_t无符号整型,它永远都不会为负数。

另一方面,我们写的check,只需要判断data所指向的对象是否有元素,所以可以直接传入0,如果0>=data->size()则表示容器为空,而不需要考虑是否大于0.其实我觉得用==也可以。

12.5

如果我们加入了explict。

优点是:
我们可以避免隐式转换,以免发生意料之外的事情

缺点:
如果加explicit,意味着我们需要显式传入一个类的对象,这增加的编码的负担,降低了灵活性。

12.1.2 直接管理内存

之前说到直接使用new和delete关键字是非常麻烦的,那么为什么这么棘手呢

使用new动态分配和初始化对象

我们可以使用new直接动态分类对象,因为new出来的对象是没有名字的,但是它会返回一个指向对象的指针,所以我们可以写

int * p = new int(10);

vector<int>* p  =new vector<int>();

new一个对象,就和创建一个变量一样,只不过不用写变量名

vector<int> p();
new vector<int>();//没写变量名字

在new一个对象时候,如果我们没有在类型名之后加上()则会执行默认初始化,如果加上()则会执行值初始化。
这对于自定义的类类型没有什么问题,但是对于内置类型,默认初始化得到的值时未定义的。

int* p1 = new int;//默认初始化,得到的结果时未定义的
int* p2 = new int();//值初始化

1.这里就是一个坑点容易出错,但是使用智能指针的make_shared创建对象时,我们是一定要加()的,不然函数不完整。

动态分配const对象

我们可以使用new来创建一个常量

const int* p = new const int(10);

p是一个低层const,所指向的变量是不能改变的。

内存耗尽

new是动态分配内存的,所以计算机可能没有多余的内存可以分配了,这个时候new会抛出bad_alloc()异常。

但是我们可以通过传入 nothorw 让new不报错,返回一个nullptr。

//如果内存不足了,抛出bad_alloc()异常
int * p1 = new int(10);
//不会报错,返回nullptr
int *p2 = new (nothrow) int(10);

delete

将指向new出的对象的指针传给delete可以销毁指针指向的对象并收回内存空间。

int * p = new int(10);
delete p;
p = nullptr;

注意传给delete的指针一定要是new出来的对象的指针或者是nullptr,传入其他的指针将导致未定义的行为。

2.这里是一个易错点,我们需要知道传入的指针是否是指向new的指针

动态对象的生存期直到被释放时为止

使用new分配的对象,直到使用delete 收回之前,其一直存在,但是我们往往忘记收回从而导致内存泄漏。

比如

void func(){
	int* p = new int(10);//函数结束之后,如果没有回收内存,那么会造成内存泄漏
}

int* func(){
int*p = new int(10);
return p;
}
func();//调用者忘记回收内存

3.这里又是一个坑点,我们往往会忘记回收new出的对象的空间

delete之后,指针的值还在

我们为new出来的对象使用delete之后,指针的值还存在。这意味着如果我们再次访问将导致未定义的行为。
所以在回收内存之后之后,需要将指针赋值为nullptr。

但是如果有多个指针指向同一个对象,我们对其中一个指向delete p。并赋值为nullptr。其他的指针仍然指向这块已经被回收的内存。这将导致未定义的行为。

4.这里又是一个易错点,即我们可能会忘记delete之后将指针的值赋值为nullptr,以及有多个指针指向同一个对象,如果使用其中一个指针,回收对象,其他的指针也要赋值为nullptr。这是非常困难的

总结起来,为什么直接使用new和delete不好,有以下几个问题。
1.new创建对象时,如果不加()执行默认初始化,而内置类型的默认初始化是未定义的。
2.使用delete回收内存时,我们需要知道传入的指针是否是指向new的指针或者是nulltpr,delete 其他的指针将导致未定义的行为
3.我们往往会忘记回收new出的对象的空间,导致内存泄漏
4.我们可能会忘记delete之后将指针的值赋值为nullptr,以及有多个指针指向同一个对象,如果使用其中一个指针,回收对象,其他的指针也要赋值为nullptr。不然对这些指针解引用将产生未定义的行为

练习

12.6
void output_vec_value_v1(vector<int>* vec_p) {
	for (const auto &item:*vec_p) {
		cout << item << endl;
	}
}
void input_vec_value_v1(vector<int>* vec_p) {
	int num;
	while (cin>>num) {
		vec_p->push_back(num);
	}
	output_vec_value_v1(vec_p);
}


vector<int>* vec_p = new vector<int>();
	input_vec_value_v1(vec_p);
	//刚刚就忘记了。。
	delete vec_p;
12.7

编写这两个代码的时候就能深深体会到,使用new和delete真的很容易忘记一些操作。

void output_vec_value_v2(std::shared_ptr<vector<int>> vec_p) {
	for (const auto &item : *vec_p) {
		cout << item << endl;
	}
}
void input_vec_value_v2(std::shared_ptr<vector<int>> vec_p) {
	int num;
	while (cin >> num) {
		vec_p->push_back(num);
	}
	output_vec_value_v2(vec_p);
}

std::shared_ptr<vector<int>>  vec_p = std::make_shared<vector<int>>();
	input_vec_value_v2(vec_p);
12.8

有错误,返回值是bool,但是在函数中返回了new出来的对象的指针,接收对象无法对该对象进行delete操作,所以导致了内存泄漏

12.9

第1、2行,将q的值赋给r,覆盖了r原来保存的地址,所以内存泄漏

第3、4行,将q2的值赋值为r2,q2的引用计数+1,r2 的引用计数-1,此时引用计数为0,所以r2原本指向的对象被销毁,内存空间被回收。没有造成内存泄漏。

12.1.3 shared_ptr和new结合使用

我们可以使用new返回的指针来初始化shared_ptr对象,
如果我们没有传入任何值,则shared_ptr会被初始化一个空指针。

std::shared_ptr<int> p(new int(10));
std::shared_ptr<int> p1();//空指针

使用内置指针来创建shared_ptr对象的构造函数是explict的,所以只能直接初始化,不能隐式的初始化

默认情况下,使用内置指针来初始化shared_ptr,这个指针必须指向动态创建的对象,因为shared_ptr默认使用delete来释放关联的对象,如果我们想传入其他类型的指针,则需要自己定义delete的行为。

除了使用make_shared,我们还可以使用以下方法来
在这里插入图片描述
在这里插入图片描述
对于reset()就目前的理解来看,就是释放掉p所指向的对象的内存,如果传入了内置指针q,则将原本所指向的对象的内存释放掉,将p指向q(或者说将q的所有权交给p。

不要混用普通指针和智能指针

尽量不要使用new对象的方法来初始化shared_ptr,而是使用make_shared;如果要使用new的方式来初始化shared_ptr,那么初始化之后就尽量不要再用普通指针来操纵new出来的对象

以下的方法将导致p变成一个空悬指针。

void process(std::shared_ptr<int> p){};

int * p = new int(10);
process(std::shared_ptr<int>(p));

因为用普通指针p初始化一个临时变量,意味着管理p的责任落到了shared_ptr上,而这个shapred_ptr是一个临时变量,函数调用一结束,销毁,而其指向的对象也就被销毁,从而p成为悬空指针。

不要使用shared_ptr的get()中的指针来初始化一个shared_ptr,这意味着两个shared_ptr,用两个独立的引用计数在维护同一个对象,所以当其中一个引用计数为0,它将销毁对象并回收内存,而另一个智能指针仍然指向那块被回收的内存。当我们试图访问这个智能指针所指向的对象时,将产生未定义的行为,同时,如果我们没有访问,而是得到引用计数为0时,delete掉它所指向的对象,同样会导致未定义行为。

那么get()存在的意义是什么?
因为可能有些代码不能使用智能指针来调用,这个时候我们就可以使用get()返回智能指针内部维护的普通指针。**但是要确保调用的函数里面不会delete掉这个指针。**否则将产生未定义的行为。

尽量不要混合普通指针和智能指针,使用make_shared来代替new

练习

12.10

正确,传入的虽然是临时变量,但是p所指向的对象引用计数+1了。执行process之后,引用计数-1,返回之后引用计数为1.

12.11

错误,使用p的内置指针来创建一个shared_ptr将导致两个独立的shared_ptr维护两个不同的引用计数,所以执行完process之后。p所指向的对象以及被匿名对象给销毁了。

12.12

除了第一个,其余的全部不合法。
b。c。不能使用内置指针来隐式的初始化shared_ptr
d。 能够进入process函数,但是构建的对象会将p的所有权转移到构建的shared_ptr中,指向完process之后,无名对象的生命周期结束,销毁p指向的对象,回收内存。p变成了一个空悬指针所以后面访问p,将导致未定义的行为

12.13

导致sp所指向的对象已经被释放,使用sp访问对象将导致未定义的行为。

12.1.4 智能指针和异常

我们在编写代码的时候,有时代码会发生异常,如果发生异常程序推出,异常后面的语句将无法执行。

如果在函数中使用new动态创建对象,而在delete对象的指针之前发生了异常(假设我们没有写异常处理语句),那么此时new出来的对象将无法被回收。

void f(){	
	int* i = new int(10);
	throw exception("error");
	delete i;//
}

当然我们可以这些,不过上面的情况已经说明了,没有使用异常处理语句。

void f(){	
try{
int* i = new int(10);
	throw exception("error");
		delete i;//
}catch(exception& e){
		delete i;//
}
}

一种更好的方法是使用智能指针。首先我们要记住不管程序是正常退出还是异常退出,局部对象都会被销毁

所以如果使用智能指针,即使程序异常退出,智能指针对象也需要被销毁,而销毁时需要检查引用计数,如果引用计数为0,则对象的内存回收,在下面这的这种情况下,引用计数为1,所以销毁p,引用计数就为0
了。所以内存也会被回收。

void f(){	
	std::shared_prt<int> p(new int(10));
	throw exception("error");
	
}
智能指针和哑类

这里所说的哑类就是,没有析构函数的类。这些类通常是为C和C++两种语言设计的。

就像书上的例子,如果在函数结束前没有调用disconnect,c永远无法关闭。
在这里插入图片描述
我们可以使用智能指针来让函数结束后,调用disconnect.因为c是一个普通变量。前面说过如果想要使用其他类型的指针来初始化一个智能指针则需要自己定义delete的操作。

所以我们可以自己定义一个函数
在这里插入图片描述
在这里插入图片描述
这个函数可以使用lambda表达式来代替。

这样,就算函数体内发生异常,函数还是可以连接还是可以终止 。

正确的使用智能指针可以为我们提供很多遍历的操作,注意前提是正确的使用,那么有哪些操作是属于不正常的操作呢。
1.使用一个普通指针初始化多个智能指针
2.使用智能指针的get来初始化智能指针
3.delete get()返回的指针
4.在智能指针销毁之后,还在使用智能指针get()返回的普通的指针
5.传递给指针指针一个非new对象的指针,没有自定义delete操作。

在这里插入图片描述

练习

12.14 and 12.15
struct desination {
	int a;
};
struct connection {
	int a;
};

connection connect(desination*) {
	return connection();
};
void disconnect(connection) {
	cout << "disconnection" << endl;
};
void do_disconnect(connection* p) {
	cout << "do disconnect" << endl;
	disconnect(*p);
}
void f(desination &d) {
	connection c = connect(&d);
	
	//std::shared_ptr<connection> p(&c, do_disconnect);
	std::shared_ptr<connection> p(&c, [](connection* p) {
		cout << "do delete" << endl;
		disconnect(*p);
	});
	throw std::exception("one error");
}

12.1.5 unique_ptr

unique_ptr不同于shared_ptr它只允许一个智能指针指向一个对象。

所以unique_ptr对象不能够赋值和拷贝。

unique_ptr<int> p1(new int(10));
unique_ptr<int> p2 = p1;//报错
unique_ptr<int> p3(p2);//报错
unique_ptr<int> p4(p1.get());//在vs2017下也报错

在创建unique_ptr对象时,必须使用直接初始化。

下面时unique_ptr独有的操作。
在这里插入图片描述
他也支持自定义删除器,但是和unique_ptr不一样的是,在unique_ptr中,需要在<>中指定删除器的类型。

u.release()操作可以放弃让u对指针的控制权,将u置为空。
我们通常使用u.release()来为一个新的unique_ptr进行赋值和拷贝。

unique_ptr<int> u(new int(10));
unique_ptr<int> u1(u.release());//将控制权转移到u1中

如果我们release()了unique_ptr,但是没有没有用另一个智能指针接收,那么我们需要自己管理这个指针所指向的对象。

传递unique_ptr参数和返回unique_ptr

unique_ptr是不能赋值和拷贝,但是有例外,即这个传递或者拷贝的参数即将被销毁。

unique_ptr<int> <int> f(){
//即将被销毁可以使用
	return unique_ptr<int>(new int(10));
}


unique_ptr<int> <int> f(){

auto p =  unique_ptr<int>(new int(10));
//即将被销毁可以使用
	return p; 
}

练习

12.16
std::unique_ptr<int> p(new int(10));
	std::unique_ptr<int> p1(p);//报错
	std::unique_ptr<int> p3;
	p3 = p;//报错
12.17

在这里认为程序编译不报错就认为是合法。

程序运行的时候报错就认为后续程序错误。

a。错误,ix不是指针类型
b。合法,后续错误,因为pi不是new动态创建的对象的指针
c。正确
d。合法,但是后续错误,因为&ix不是new动态创建的对象的指针
e。正确
f。合法,后续出现错误,
f这里我一开始认为是直接抱错,这是看错了,get()返回的是普通指针,但是这个普通指针已经用于初始化了一个unique_ptr,这里再次使用该指针来初始化一个unique_ptr,在VS2017上的表现能编译通过,但是运行时崩溃

值得

int ix = 1024, *pi = &ix, *pi2 = new int(2048);
	typedef std::unique_ptr<int> IntP;
	//IntP p0(ix);
	//IntP p1(pi);
	//IntP p2(pi2);
	//IntP p3(&ix);
	//IntP p4(new int(2048));
	//IntP p5(p2.get());
12.18

因为shared_ptr允许多个智能指针指向同一个对象,如果允许有release()操作,一个智能指针调用了release(),其他指向该对象的智能指针没有调用话,其他指针依旧可以访问所指向的对象如果把release()返回的指针传给一个新的shared_ptr,那样就会导致一个对象有两套引用计数在维护它,这样在程序运行过程中可能会出现未定义的行为。

12.1.6 weak_ptr

weak_ptr是c++提供的“弱”共享对象,智能指针。所谓的弱共享对象,就是weak_ptr绑定到一个shared_ptr管理的对象上,但是它不会增加shared_ptr的引用计数。

所以就算weak_ptr指向了一个shared_ptr的对象,但是这个shared_ptr后来因为引用计数变为0,回收内存了。这个weak_ptr可能还只向那个对象。

因为weak_ptr可能指向一个不存在的对象,所以我们在使用weak_ptr来获取一个shared_ptr时,需要判断这个对象是否还存在。

在这里插入图片描述
使用lock()来判断是否存在,如果use_count()为0,则返回空指针,如果不为0则返回一个指向对象的shared_ptr;

我主要不太理解这个weak_ptr可以做什么。根据文中写的例子,可以猜测,weak_ptr在一个类写指针类或者迭代器类的时候用。

至于为什么为一个对象定义指针类而不是直接使用这个对象的指针,我猜原因可能是为了封装和减少重复代码。

练习

12.19

在声明友元时,把StrBlobPtr错写成了StrBlobPrt,导致在StrBlobPtr中访问不了StrBlob的成员,结果找了一个多小时的错误。。。我也是醉了

class StrBlobPtr;
class StrBlob {
	friend class StrBlobPtr;
public:
	using size_type = vector<string>::size_type;
	StrBlob();
	StrBlob(std::initializer_list<std::string> il);
	size_type size() const { return data->size(); };
	bool empty() const { return data->empty(); };
	void push_back(const string& str) { data->push_back(str); };
	void pop_back();
	std::string& front();
	const std::string& front()const;
	std::string& back();
	const std::string& back() const;

	StrBlobPtr begin();
	StrBlobPtr end();
private:
	std::shared_ptr<vector<string>> data;
	void check(size_type i, const string& msg) const;
};

StrBlob::StrBlob() :data(std::make_shared<vector<string>>()) {

}


StrBlob::StrBlob(std::initializer_list<string> il) : data(std::make_shared<vector<string>>(il)) {

}

class StrBlobPtr {
	friend bool my_equal(const StrBlobPtr& p1, const StrBlobPtr&p2);
public:
	StrBlobPtr() :curr(0) {};
	StrBlobPtr(StrBlob &a, size_t sz = 0) :wptr(a.data), curr(sz) {};
	std::string& deref()const;
	StrBlobPtr& incr();

private:
	std::shared_ptr<vector<string>> check(size_t, const std::string&)const;
	std::weak_ptr<vector<string>> wptr;
	size_t curr;
};
void StrBlob::check(size_type i, const string& msg) const {
	if (i >= data->size()) {
		throw std::out_of_range(msg);
	}
}
string& StrBlob::front() {
	check(0, "front out of range");
	return data->front();
}

const string& StrBlob::front() const {
	check(0, "front out of range");
	return data->front();
}
string& StrBlob::back() {
	check(0, "back out of range");
	return data->back();
}

const string& StrBlob::back() const {
	check(0, "back out of range");
	return data->back();
}

void StrBlob::pop_back() {
	check(0, "pop_back() out of range");
	data->pop_back();
}


StrBlobPtr StrBlob::begin() {
	return StrBlobPtr(*this);
};
StrBlobPtr StrBlob::end() {
	auto ret = StrBlobPtr(*this, data->size());
	return ret;
};


std::shared_ptr<vector<string>> StrBlobPtr::check(size_t i, const std::string&msg) const {
	auto ret = wptr.lock();
	if (!ret) {
		throw std::runtime_error("wptr not exit");
	}
	if (i >= ret->size()) {
		throw std::out_of_range(msg);
	}
	return ret;
}
std::string& StrBlobPtr::deref()const {
	auto p = check(curr, "dereference past end");
	return (*p)[curr];
}

StrBlobPtr& StrBlobPtr::incr() {
	check(curr, "incrment past end of StrBlobPtr");
	++curr;
	return *this;
}

bool my_equal(const StrBlobPtr& p1,const StrBlobPtr&p2) {
	return p1.curr == p2.curr;
}
12.20

因为要遍历元素,涉及到比较元素,StrBlobPtr没有重载!=运算符,而且目前也没有学习相关的知识,所以使用友元函数来完成。

ifstream f_in("data.txt");
	string words;
	StrBlob blob;
	while (f_in>>words){
		blob.push_back(words);
	}
	for (auto iter = blob.begin();!my_equal(iter,blob.end());) {
		cout<<iter.deref()<<endl;
		iter = iter.incr();
	}
12.21

原来的版本更好,因为可读性更高

12.22

需要记住的是,如果是常量类型,则指针指向的对象不可以改变。

所以要把数据成员wptr指向的对象变为const类型。
其余的函数根据返回的类型修改就好了。

注意智能指针就没有低层const和顶层const之分了,const就是顶层const,要实现低层const类型的功能,在定义变量时,将类型声明为const类型就可以了

class ConstStrBlobPtr {
	friend bool my_equal(const ConstStrBlobPtr& p1, const ConstStrBlobPtr&p2);
public:
	ConstStrBlobPtr() :curr(0) {};
	//如果是常量则要把传入的变量也变为常量
	ConstStrBlobPtr(const StrBlob &a, size_t sz = 0) :wptr(a.data), curr(sz) {};
	const std::string& deref()const {
		auto ret = check(curr, "deref error");
		return (*ret)[curr];
	};
	ConstStrBlobPtr& incr() {
		check(curr, "incrment past end of ConstStrBlobPtr");
		++curr;
		return *this;
	};

private:
	const std::shared_ptr<const vector<string>> check(size_t sz, const std::string& msg)const {
		auto ret = wptr.lock();
		if (!ret) {
			throw std::runtime_error("wptr not exit");
		}
		if (sz >= ret->size()) {
			throw std::out_of_range(msg);
		}
		return ret;
	};
	//指针所指向的对象也不可以改变
	std::weak_ptr<const vector<string>> wptr;
	size_t curr;
};

发布了54 篇原创文章 · 获赞 6 · 访问量 3308

猜你喜欢

转载自blog.csdn.net/zengqi12138/article/details/104390585