C++《类和对象》(下)
在之前类和对象(中)我们学习了类当中的6大默认成员函数,我们了解了6大成员函数的结构特征和特点以及在不同情况各个成员函数是如何调用的,那么接下来我们在本篇当中将继续学习之前在学习构造函数中未了解的初始化列表,并且另外还要学习类和对象当中支持的支持的一些功能:类型转换、友元、static成员和函数、内部类和匿名对象。接下来开始本篇的学习吧!
1.再探构造函数
在之前C++《类和对象》(中)我已经将类当中的构造函数大体已经了解,但是在当时我们还有一个问题没有解决,就是若一个类的成员变量有自定义类型时,但如果这个自定义类型无对应的构造函数时在这个类实例化时编译器就会报错。因此在之前的篇章中就提到了这种情况下要使用初始化列表才能解决,那么接下来就先来了解初始化列表的结构特征
之前我们实现构造函数时,初始化成员变量主要使用函数体内赋值,构造函数初始化还有一种方
式,就是初始化列表
例如以下示例:
#include<iostream>
using namespace std;
class Time
{
public:
Time(int hour,int minute)
:_hour(hour)
,_minute(minute)
{
cout << "Time()" << endl;
}
private:
int _hour;
int _minute;
};
通过以上的示例就可以看出初始化列表的使用方式是以一个冒号开始,接着是⼀个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。
在了解了初始化列表该如何实现接下来就来实现之前的MyQuue中的类Stack无默认构造函数时,MyQueue的构造函数形式
#include<iostream> using namespace std; typedef int STDataType; class Stack { public: Stack(int n) { _a = (STDataType*)malloc(sizeof(STDataType) * n); if (nullptr == _a) { perror("malloc申请空间失败"); return; } _capacity = n; _top = 0; } // ... private: STDataType* _a; size_t _capacity; size_t _top; }; class MyQueue { public: MyQueue(int x,inty) :pushst(x) , popst(y) { } private: Stack pushst; Stack popst; }; int main() { MyQueue st(10,10); return 0; }
其实除了以上的无默认构造的自定义类型必须使用初始化列表外,还有const修饰的成员变量和引用成员变量也是必须要在构造函数的初始化列表内定义的,而不能再构造函数的函数体内定义,这是因为这两种类型的成员变量都必须在定义的时候初始化,并且在定义之后就无法再修改了,所以如果我们在初始化列表内初始化,而是在构造函数内定义的话由于函数体内可以多次赋值这就无法判断那一条赋值语句才是初始化
在此你可以理解为每个成员变量在初始化列表中只能出现一次,语法理解上初始化列表可以认为是每个成员变量定义初始化的地方。
例如以下示例
#include<iostream> using namespace std; class Time { public: Time(int hour) :_hour(hour) { cout << "Time()" << endl; } private: int _hour; }; class Date { public: Date(int& x, int year = 1, int month = 1, int day = 1) :_year(year) , _month(month) , _day(day) , _t(12) , _ref(x) ,_n(1) { } private: int _year; int _month; int _day; // 没有默认构造 Time _t; // 引⽤ int& _ref; // const const int _n; }; int main() { int i = 0; Date d1(i); return 0; }
在我们之前声明类的成员变量时,都是只声明了变量的名,其实在C++11支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显示在初始化列表初始化的成员使用的。在此给成员函数给缺省值就可以看成是将该成员变量在初始化列表内进行定义,在此的初始化值就是缺省的参数。
注意:在此成员函数的缺省值只有在没有显示在初始化列表初始化的成员才会在实例化出的类对象时在初始化时才会使用缺省值来初始化对应的成员变量,而如果初始化列表内有将该成员函数显示的初始化这时我们之前创建的缺省值就不会起作用。其实我们可以把成员变量的缺省值看作备胎
通过以上的讲解以及示例的说明我们需要在之后的创建类的构造函数时尽量使用初始化列表初始化,因为那些你不在初始化列表初始化的成员也会走初始化列表,如果这个成员在声明位置给了缺省值,初始化列表会用这个缺省值初始化。如果你没有给缺省值,对于没有显示在初始化列表初始化的内置类型成员是否初始化取决于编译器,C++并没有规定。对于没有显示在初始化列表初始化的⾃定义类型成员会调用这个成员类型的默认构造函数,如果没有默认构造会编译错误。
以下流程图就是成员变量走初始化列表的逻辑:
注意初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的的先后顺序无关。建议声明顺序和初始化列表顺序保持一致。
来看以下示例:
#include<iostream>
using namespace std;
class A
{
public:
A(int a)
:_a1(a)
, _a2(_a1)
{}
void Print() {
cout << _a1 << " " << _a2 << endl;
}
private:
int _a2 = 2;
int _a1 = 2;
};
int main()
{
A aa(1);
aa.Print();
}
以上程序的运行结果是什么()
A. 输出 1 1
B. 输出 2 2
C. 编译报错
D. 输出 1 随机值
E. 输出 1 2
F. 输出 2 1
首先我们看以上类A的成员变量的声明顺序是先_a2、_a1,那么就可以得出在初始化列表成员变量的初始化顺序也是_a2、_a1,也就是在初始化列表一开始先执行_a2(_a1)语句,由于_a1这时未定义,那么_a2初始化就为随机值。在此之后就执行_a1(a),因为a形参接收的是值1,因此_a1初始化就为1
因此以上程序的输出结果就为D
初始化列表总的来说就有以下的特征:
• 之前我们实现构造函数时,初始化成员变量主要使用函数体内赋值,构造函数初始化还有⼀种方式,就是初始化列表,初始化列表的使用方式是以⼀个冒号开始,接着是⼀个以逗号分隔的数据成员列表,每个"成员变量"后面跟⼀个放在括号中的初始值或表达式。
• 每个成员变量在初始化列表中只能出现⼀次,语法理解上初始化列表可以认为是每个成员变量定义初始化的地方。
• 引用成员变量,const成员变量,没有默认构造的类类型变量,必须放在初始化列表位置进行初始化,否则会编译报错。
• C++11支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显示在初始化列表初始化的成员使用的。
• 尽量使用初始化列表初始化,因为那些你不在初始化列表初始化的成员也会⾛初始化列表,如果这个成员在声明位置给了缺省值,初始化列表会用这个缺省值初始化。如果你没有给缺省值,对于没有显示在初始化列表初始化的内置类型成员是否初始化取决于编译器,C++并没有规定。对于没有显示在初始化列表初始化的⾃定义类型成员会调用这个成员类型的默认构造函数,如果没有默认构造会编译错误。
• 初始化列表中按照成员变量在类中声明顺序进行初始化,跟成员在初始化列表出现的的先后顺序无关。建议声明顺序和初始化列表顺序保持一致。
初始化列表总结:
无论是否显示写初始化列表,每个构造函数都有初始化列表
无论是否在初始化列表显示初始化,每个成员变量都要⾛初始化列表初始化
2. 类型转换
在之前C语言阶段我们就了解过不同类型之间是可以转换的,例如int和double,这时在转换过程中需要两个转换的类型有一定的关联,例如指针和整型就有关联,这是因为指针表示的是地址也是就是内存单元的编号
其实在C++中支持内置类型隐式类型转换为类类型对象, 类类型的对象之间也可以隐式转换,需要有相关的构造函数。
注:当一个类当中如果你不想构造函数支持隐式类型转换就在构造函数前面加explicit在此之后就不再支持隐式类型转换。
来看以下示例:
#include<iostream>
using namespace std;
class A
{
public:
A(int a1)
:_a1(a1)
{}
A(int a1, int a2)
:_a1(a1)
, _a2(a2)
{}
A(const A& aa)
:_a1(aa._a1)
,_a2(aa._a2)
{
}
void Print()
{
cout << _a1 << " " << _a2 << endl;
}
int Get() const
{
return _a1 + _a2;
}
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
public:
B(const A& a)
:_b(a.Get())
{}
private:
int _b = 0;
};
int main()
{
//原本要进行以下的操作才能将1拷贝给对象aa1
//A aa(1);
// A aa1=aa;
//有了隐式类型转换就可以变为以下形式
// 1构造⼀个A的临时对象,再⽤这个临时对象拷⻉构造aa3
// 编译器遇到连续构造+拷⻉构造->优化为直接构造
A aa1 = 1;
aa1.Print();
const A& aa2 = 1;
// C++11之后才⽀持多参数转化
A aa3 = { 2,2 };
// aa3隐式类型转换为b对象
// 原理跟上⾯类似
B b = aa3;
const B& rb = aa3;
return 0;
}
在以上的示例当中我们创建的对象aa1可以直接使用A aa1 = 1;这一条语句来实现将对象aa1内的成员变量_a1初始化为1这是因为在此整型1先通过调用A的构造函数生成一个临时对象,之后再调用拷贝构造就将对象aa1实现了初始化
除此之外也可通过语句B b = aa3;可以看出类类型和类类型变量之间也支持隐式转换
注:并且通过语句const A& aa2=1;和语句const B& rb=aa3;就可以看出在进行隐式类型转换时由于生成的是临时变量具有常性,那么在引用时也要使用const引用否则就会出现权限放大的问题
3. static成员
在之前C语言的学习当中我们就了解了static修饰全局变量;修饰成员函数;修饰函数时被修饰的变量或者函数会产生什么样的效果,那么接下来我们就将继续了解static修饰类当中的成员时,类成员结构和功能上产生的改变
首先来看以下代码
若我们要创建一个变量来存储类示例话出对象的个数 ,这时你可能就会想到以下创建全局变量的方法来实现,但是这种方式就会有很大的局限性就是我们创建的变量_count是全局变量这时该变量在程序当中无论在什么位置都有权限修改其的值,这就会使得其健壮性不足
#include<iostream>
using namespace std;
int _count;
class A
{
public:
A()
{
++_count;
}
A(const A& a)
{
++_count;
}
~A()
{
--_count;
}
private:
};
int main()
{
A a1;
A a2(a1);
cout << _count;
return 0;
}
为了解决以上的问题要让只能在类A当中对变量_count进行修改这就需要将该变量存放在类当中的并且设为私有,但这时又有一个问题了,就是如果将该变量存放在类A当中就会使得每个实例化出的A类型的对象都会有自己的_count变量,这就不符合我们的要求了,那要怎么解决呢?
在此要解决这个问题就要用到static修饰类当中的成员变量,在此用static修饰的成员变量,称之为静态成员变量,静态成员变量为所有类对象所共享,不属于某个具体的对象,不存在对象中,存放在静态区。
注意:静态成员变量一定要在类外进行初始化,并且静态成员变量不能像普通的成员变量在声明位置给缺省值初始化,因为缺省值是给构造函数初始化列表的,静态成员变量不属于某个对象,不走构造函数初始化列表。
在此以上的代码就可以改写为以下形式:
#include<iostream>
using namespace std;
class A
{
public:
A()
{
++_count;
}
A(const A& a)
{
++_count;
}
~A()
{
--_count;
}
//返回静态成员变量的值
int Getcount()
{
return _count;
}
private:
//声明静态成员变量
static int _count;
};
//在类外初始化静态成员变量
int A::_count = 0;
int main()
{
A a1;
A a2(a1);
cout << a1.Getcount();
return 0;
}
注意:静态成员也是类的成员,受public、protected、private 访问限定符的限制。
在以上的代码当中由于将_count修改为类的成员变量后并且为私有,那么在类A外我们就不能在直接访问变量_count那么在此我们就通过再创建一个函数再类当中来将类的成员变量_count的值返回,之后在类外要得到_count的值就可以通过调用成员函数Getcount来实现
但在以上的代码中我们是通过对象.静态成员 来访问静态成员变量的,如果我们通过类名::静态成员就会出现以下的报错
要解决以上的问题就需要我们再来了解静态成员函数
首先来了解静态成员变函数的结构特征:
静态成员函数相比普通的成员函数只需要在函数的返回值类型前加上关键字static即可
接下来来了解静态成员函数相比普通成员函数在功能和函数参数上有什么区别:
静态成员函数,静态成员函数参数是没有this指针的,这也就使得静态成员函数中可以访问其他的静态成员,但是不能访问非静态的,而非静态的成员函数由于拥有this指针,这就可以访问任意的静态成员变量和静态成员函数。
以上的代码加上静态成员函数,就变为以下形式
#include<iostream>
using namespace std;
class a
{
public:
a()
{
++_count;
}
a(const a& a)
{
++_count;
}
~a()
{
--_count;
}
static int getcount()
{
return _count;
}
private:
static int _count;
};
int a::_count = 0;
int main()
{
a a1;
a a2(a1);
cout << a::getcount();
return 0;
}
这样就可以直接通过类名::静态函数来访问静态成员变量和静态成员函数。
练习1:
在学习了static成员之后接下来来看一个有趣的题
求1+2+3+...+n_牛客题霸_牛客网 (nowcoder.com)
通过以上的题目描述就可以了解到该题要我们实现的是将1到n的数字累加,但是不能使用以上乘除法、for、while等,那么我们要使用什么方法才能实现呢?
我们知道在类每一次实例化过程中都会调用一次构造函数,其实有了这个特点再加上类的static成员就可以解决该题了
以下就是代码:
class Sum
{
public:
Sum()
{
_sum+=_i;
++_i;
}
static int Getsum()
{
return _sum;
}
private:
static int _i;
static int _sum;
};
int Sum::_i=1;
int Sum::_sum=0;
class Solution
{
public:
int Sum_Solution(int n)
{
Sum arr[n];
return Sum::Getsum();
}
};
以上通过创建n个Sum类类型的对象,在此也就是创建一个长度为n的变长数组,就会调用n次Sum的构造函数。并且再类Sum内创建静态成员变量_i和_sum这样就可以保证每个对象的这两个变量都存储在同一块内存空间
注:像VS等不支持变长数组这就需要使用到之后内存管理章节会学习到的new
练习2:
设已经有A,B,C,D 4个类的定义,程序中A,B,C,D构造函数调用顺序为?()
设已经有A,B,C,D 4个类的定义,程序中A,B,C,D析构函数调用顺序为?()
A:D B A C
B:B A D C
C:C D B A
D:A B D C
E:C A B D
F:C D A B
C c;
int main()
{
A a;
B b;
static D d;
return 0;
}
在以上的当中会先将main函数外的c对象调用构造函数,之后再在main函数内进行对象的初始化,先初始化a,之后初始化b,最后的static变量由于是局部变量因此会在定义的位置初始化,因此A,B,C,D 4个类的构造函数调用顺序为C A B D
之后在析构时由于在函数内后定义的会先析构因此会先调用b的析构,再调用a的析构,之后由于d对象是static修饰的静态对象因此d的生命周期是全局的在main函数结束时也不会被销毁,之后在整个程序结束时会先调用静态对象的析构,因此d和c会先调用d的析构,之后再调用c的析构,因此综上所述A,B,C,D4个类的析构函数调用顺序是B A D C
因此以上的题目选择E B
4. 友元
在之前类和对象(中)的学习中在实现日期类时我们就已经使用到了友元,但是在之前我们没有系统的来了解友元的相关概念,只是片面的了解了友元的作作用,接下来我们将详细的了解友元的概念和使用方法
友元提供了一种突破类访问限定符封装的方式在函数声明或者类,声明的前面加friend,并且把友元声明放到一个类的里面。
友元函数会有以下的特征:
在友元函数当中外部友元函数可访问类的私有和保护成员,友元函数仅仅是一种声明,他不是类的成员函数。友元函数可以在类定义的任何地方声明,不受类访问限定符限制,因此友元函数在类内的声明可以在public内也可以在private内也可以都不在这两个访问限定符内。并且一个函数可以是多个类的友元函数。
例如以下示例:
#include<iostream>
using namespace std;
// 前置声明,否则A的友元函数声明编译器不认识B
class B;
class A
{
// 友元声明
friend void func(const A& aa, const B& bb);
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
// 友元声明
friend void func(const A& aa, const B& bb);
private:
int _b1 = 3;
int _b2 = 4;
};
void func(const A& aa, const B& bb)
{
cout << aa._a1 << endl;
cout << bb._b1 << endl;
}
int main()
{
A aa;
B bb;
func(aa, bb);
return 0;
}
在以上代码中函数func就既是类A的友元函数又是类B的友元函数,因此就可以在函数func内部访问类A和类B的成员变量
在友元当中其实除了友元函数外还存在友元类
在一个类的友元类当中类的函数都可以看作是原来类的友元函数,都可以访问原来类当中的成员变量
例如以下示例:
#include<iostream>
using namespace std;
class A
{
// 友元声明
friend class B;
private:
int _a1 = 1;
int _a2 = 2;
};
class B
{
public:
void func1(const A& aa)
{
cout << aa._a1 << endl;
cout << _b1 << endl;
}
void func2(const A& aa)
{
cout << aa._a2 << endl;
cout << _b2 << endl;
}
private:
int _b1 = 3;
int _b2 = 4;
};
int main()
{
A aa;
B bb;
bb.func1(aa);
bb.func1(aa);
return 0;
}
在以上代码中类B就是类A的友元类,因此在类B当中的成员函数func1和func2函数内部都就可以访问A的成员成员变量
注意:友元类的关系是单向的,不具有交换性,比如A类是B类的友元,但是B类不是A类的友元。且友元类关系不能传递,如果A是B的友元, B是C的友元,但是A不是C的友元。
以上在使用了友元函数或者友元类后可以让不在类内部的函数或者类也可以访问类私有的成员,这就在一些情况下能很便捷的解决我们的问题,但使用了友元后会使得各个部分增加耦合度也就是使得各个类或者函数的关系变得更加密切,这就破坏了装,所以友元不宜多用。
友元总的来看有以下的特征:
• 友元提供了⼀种突破类访问限定符封装的方式,友元分为:友元函数和友元类,在函数声明或者类声明的前⾯加friend,并且把友元声明放到一个类的里面。
• 外部友元函数可访问类的私有和保护成员,友元函数仅仅是一种声明,他不是类的成员数。
• 友元函数可以在类定义的任何地方声明,不受类访问限定符限制。
• 一个函数可以是多个类的友元函数。
• 友元类中的成员函数都可以是另一个类的友元函数,都可以访问另一个类中的私有和保护成员。
• 友元类的关系是单向的,不具有交换性,比如A类是B类的友元,但是B类不是A类的友元。
• 友元类关系不能传递,如果A是B的友元, B是C的友元,但是A不是C的友元。
• 有时提供了便利。但是友元会增加耦合度,破坏了封装,所以友元不宜多用。
5. 内部类
首先来了解内部类的定义:
如果一个类定义在另一个类的内部,这个内部类就叫做内部类。
接下来先来看以下的示例:
#include<iostream>
using namespace std;
class A
{
private:
static int _k;
int _h = 1;
public:
class B // B默认就是A的友元
{
public:
void foo(const A& a)
{
cout << _k << endl;
//OK
cout << a._h << endl;
//OK
}
};
};
int A::_k = 1;
int main()
{
cout << sizeof(A) << endl;
A::B b;
A aa;
b.foo(aa);
return 0;
}
在以上的代码中类B就是类A的内部类,那么这时计算类A的大小sizeof的输出结果是什么呢?
要解决这个问题就要先了解内部类的相关的性质:内部类是一个独立的类,跟定义在全局相比,他只是受外部类类域限制和访问限定符限制,所以外部类定义的对象中不包含内部类
在类A当中类B不是其的对象,因此在计算类的A的大小时我们不需要计算类B,那么在计算A的大小时就只包含成员变量_h,再结合内存对齐的规则那么sizeof(A)的结果就为4字节
在此我们还要了解到的是在一个类当中其内部类默认是外部类的友元类
那么在了解了内部类的定义以及使用方法后那么接下来你可能就会有疑问内部类有什么作用呢?
内部类本质也是一种封装,当A类跟B类紧密关联,A类实现出来主要就是给B类使用,那么可以考
虑把A类设计为B的内部类,如果放到private/protected位置,那么A类就是B类的专属内部类,其
他地方都用不了。
例如之前的算法题求1+2+3+...+n_牛客题霸_牛客网 (nowcoder.com)中就可以使用到内部类,在使用了内部类后就不再需要Getsum函数来得到类Sum中的成员变量_ret的值
class Solution {
// 内部类
class Sum
{
public:
Sum()
{
_ret += _i;
++_i;
}
};
static int _i;
static int _ret;
public:
int Sum_Solution(int n) {
// 变⻓数组
Sum arr[n];
return _ret;
}
};
int Solution::_i = 1;
int Solution::_ret = 0;
6. 匿名对象
首先来了解匿名对象的定义:
用类型 (实参) 定义出来的对象叫做匿名对象,而之前我们定义的类型 对象名(实参) 定义出来的
叫有名对象
例如以下示例:
#include<iostream>
using namespace std;
class A
{
public:
A(int a = 0)
:_a(a)
{
cout << "A(int a)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
class Solution {
public:
int Sum_Solution(int n) {
//...
return n;
}
};
int main()
{
A aa1;
// 不能这么定义对象,因为编译器⽆法识别下⾯是⼀个函数声明,还是对象定义
//A aa1();
// 但是我们可以这么定义匿名对象,匿名对象的特点不⽤取名字,
A();
A(1);
A aa2(2);
// 匿名对象在这样场景下就很好⽤,当然还有⼀些其他使⽤场景,这个我们以后遇到了再说
Solution().Sum_Solution(10);
return 0;
}
在以上代码中我们创建了类A和类Solution,在之前我们将这两个类定义对象时都要在对象名后加上括号,如果写成A aa1();编译器无法识别这是一个函数的声明还是对象的定义
而正如以上代码所示当我们创建匿名对象时就不用再类的类型之后再创对象的名字。并且通过匿名对象我们还可以调用类的成员函数,例如以上的Solution().Sum_Solution(10);
以上代码的输出结果如下所示:
通过以上的输出结果就可以看出匿名对象的特点还有匿名对象生命周期只在当前一行,一般临时定义一个对象当前用一下即可,就可以定义匿名对象。
7.对象拷贝时的编译器优化
• 现代编译器会为了尽可能提高程序的效率,在不影响正确性的情况下会尽可能减少⼀些传参和传返回值的过程中可以省略的拷贝。
• 如何优化C++标准并没有严格规定,各个编译器会根据情况⾃⾏处理。当前主流的相对新⼀点的编译器对于连续⼀个表达式步骤中的连续拷⻉会进⾏合并优化,有些更新更"激进"的编译器还会进行跨行跨表达式的合并优化。
例如以下示例:
#include<iostream>
using namespace std;
class A
{
public:
A(int a = 0)
:_a1(a)
{
cout << "A(int a)" << endl;
}
A(const A& aa)
:_a1(aa._a1)
{
cout << "A(const A& aa)" << endl;
}
A& operator=(const A& aa)
{
cout << "A& operator=(const A& aa)" << endl;
if (this != &aa)
{
_a1 = aa._a1;
}
return *this;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a1 = 1;
};
void f1(A aa)
{}
A f2()
{
A aa;
return aa;
}
int main()
{
// 传值传参
A aa1;
f1(aa1);
cout << endl;
// 隐式类型,连续构造+拷⻉构造->优化为直接构造
f1(1);
// ⼀个表达式中,连续构造+拷⻉构造->优化为⼀个构造
f1(A(2));
cout << endl;
cout << "***********************************************" << endl;
// 传值返回
// 返回时⼀个表达式中,连续拷⻉构造+拷⻉构造->优化⼀个拷⻉构造 (vs2019 debug)
// ⼀些编译器会优化得更厉害,进⾏跨⾏合并优化,直接变为构造。(vs2022 debug)
f2();
cout << endl;
// 返回时⼀个表达式中,连续拷⻉构造+拷⻉构造->优化⼀个拷⻉构造 (vs2019 debug)
// ⼀些编译器会优化得更厉害,进⾏跨⾏合并优化,直接变为构造。(vs2022 debug)
A aa2 = f2();
cout << endl;
// ⼀个表达式中,连续拷⻉构造+赋值重载->⽆法优化
aa1 = f2();
cout << endl;
return 0;
}
通过以上的示例就可以看出在一条语句中出现构造+拷贝构造或者是出现多次的构造或者拷贝构造时编译器通常会进行优化,但最终是否优化还是要看编译器,不同的编译器情况是不确定的。
但有有一点确定的是构造或者是拷贝构造在同一条语句当中和赋值运算符重载是不会被优化的
以上就是本篇的全部内容了,希望能得到你的点赞、收藏!