您的位置:首页 > 编程语言 > C语言/C++

读书笔记:Effective C++

2016-10-05 15:01 218 查看
目录:

Const,Mutable,Define

构造、析构、赋值运算

继承和面向对象设计

定制new和delete

设计和声明

模版与泛型编程

实现

资源管理-对象管理,智能指针,copy函数等

其他

Const,Mutable,Define

尽量不要用#define预编译指令定义常量或是宏函数,而改用Const,inline代替

原因:使用Define定义常量或是宏函数,这段代码实在预编译期间处理,是采用直接替换的策略生成代码的,这会使得出错报错时,难以定位出错点,二是非常容易出错(直接代码替换,可能导致和代码逻辑不符的行为)

尽量使用Const关键字,修饰函数,变量(修饰函数时,表示该函数不能修改本类中非static成员变量的值)

原因:使用Const关键字,可以让编译器执行代码检查,而避免不必要的程序错误。如在函数内部,修改了形参,而这本是不应该出现的

另外,用const修饰指针时,有三种不同的含义,一种是表示该指针所指向的值是不能变的(const Type p,或Type const *p),一种是表示指针不可变,即不能指向别处了(Type const p),另一种是都不能边,即所指向的值和指针都不可变,const Type * const p。

在用const修饰的成员函数时,执行的是bitwise const策略,即物理的常量性检查,但是,这并不意味着不会有逻辑的检测,表示外部不能改变该保护的值,见代码:

class CTextBlock{
public:
.....
char & operator[](std::size_t position)const { return pText[position] ;}
private :  char *pText
}


在这个类中,[]重载操作符保证了不会改变pText的内容,但是这个函数可能会让用户有机可乘,改变pText的值,如

constructionCTextBlock cctb(“hello”);

char *pc=&cctb[0];

*pc=’J’

这样,用户就修改了pText的内容。

另外,补充一点:关于const的重载:

C++中:方法名,参数相同,const属性不同,但是可以重载,如:默认使用的是非const版本的:





关于const 和mutable(单词意思为可变的,易变的)

原因:在用const修饰成员函数时,表示该方法不能修改非static成员变量,但是有时候需要修改某些成员变量的值,可将这些变量用mutable关键字修饰,逃避编译器的检查,如 mutable bool length;

确定对象使用前已经初始化,并且尽量使用初始化构造列表模式初始化成员变量。区分赋值和初始化

原因:在类对象实例化的时候,会先为对象SomeType分配空间,然后调用成员变量的默认的构造函数,填入初始值,然后在调用SomeType对象的构造函数。使用初始化构造列表,可以在为SomeType对象分配空间时,就为它的成员变量填入特定的值,而不是在构造函数中重新赋值,见代码:

Class SomeType{
std::string theName;
std::string theAddress;
int num;
//使用方法一:
SomeType::SomeType(const std::string &name,const std::string &address)
{
theName=name; theAddress=address; num=0;//这些都是赋值
}
//使用方法二:
SomeType::SomeType(const std::string &name,const std::string &address)
:theName(name),theAddress(address),num(0)
{
}


使用方法二,可以在为theName,theAddress,num变量分配空间时,立马就在内存中填入了name,address,0,这些值。而使用方法一,在为theName,theAddress,num变量分配空间时,会用string的默认构造函数为theName填入值,对于theAddress,num同理。然后,在调用构造函数,重新赋值一次,这就导致了不必要的一次赋值。当然,这对于内置的类型如int的效率都是一样的。

对于const类型的成员变量,只能使用方式二为其初始化(因为它们只能赋值一次,必须在分配空间时,就进行初始化操作,否则,以后都不能赋值了)

构造、析构、赋值运算

了解C++编译器默认编写的函数

原因:当你在创建类时,如果没有实现重写这些函数,那个编译器会帮你编译出这些函数:构造函数、析构函数、拷贝构造函数、等号操作符重载函数。注意:编译器编译出的析构函数是个non-virtual版本。

注意:对于拷贝构造函数和等号操作符,编译器创建的是只是拷贝non static变量。并且如果成员变量中有const成员变量或是引用类型的成员变量,则在使用默认的函数时,会出错。

如果不想使用编译器生成的函数,则应该明确拒绝

原因:如你觉得对象A不应该有拷贝构造函数,因为每一个对象都应该是独一无二的,那个你不会实现拷贝构造函数,但是这个时候,编译器会帮你添加一个默认的版本,这就无法阻止可用使用拷贝构造函数了。为了明确拒绝这个默认的版本,具体的做法有:

方法一:自己声明一个类似与默认版本的函数,并且将这个函数声明为private,且不提供实现如:

HomeForSale(const HomeForSale&); //没有实现,只有声明

(如果只是将这些函数声明为private,可以阻止客户使用,但是类的成员函数和friend函数还是可以调用它们。而如果只提供声明,不实现,当有人调用了它们的时候,会产生链接错误)。

方法二:定义一个基本,该基类存在的目的就是提供拒绝拷贝的动作,见代码所示:

class Uncopyable{
private :
Uncopyable(const Uncopyable&);
Uncopy& operator=(const Uncopyable&);
class HomeForSale:private Uncopyable{
....
}//当调用HomeForSale的拷贝复制函数或是等号赋值操作时,因为HomeForSale没有实现,所以会使用编译器实现的版本,但是在使用编译器实现的版本时,它会调用父类的相关函数,但是父类的相关函数是private的,因为编译器会拒绝这些调用(Boost库中的noncopyable就是干这个用的)


关于virtual关键字,使用的场合和不要使用的场合

原因:在成员函数上加上了virtual关键字,则表明这某个方法调用具体版本的选择时,会看对象的实际类型(实现依赖与vptr,vtbl,保存某些信息,用来在运行期决定方法调用的具体版本,每一个含有virtual方法的类对象都会有一个vtbl)

一般来说,对于析构函数,加上virtual关键字是比较好的,它可以防止内存的泄漏(如在这种情况下,如果析构函数不是virtual的,且Base A=new Sub(),那个最后在调用delete A时,只会释放A占用的内存空间,而不会释放子类中的某些空间,导致内存的泄漏)

但是,这也并不是说析构函数必须一定要是virtual的。如果某个类有方法是virtual的,它在存储上就会占用多余的空间用来保存vtbl结构,这不仅导致内存占用的膨胀,同时导致语言的不宜移植(如C++中的定义的一些类,库在别的语言环境中就无法使用,因为它们没有这种语法特性,对接不上)

建议:当某个类具有多个子接口,而这个基类主要作为多态指针指向子类对象时,析构函数就应该是virtual的,防止子类空间的泄漏

另外:许多的标准数据结构如String,所有STL容器类的析构函数都不带有virtual关键字,因此,尽量不要去继承它们(C++中没有final关键字,不能放在不友好的继承,这个是需要记住的

不要在构造函数和析构函数中调用virtual函数

原因:那构造函数做例子:如果在Super类的构造函数中调用了virtual函数,那么,当构造子类对象Sub B时,则会调用Super的构造函数,执行virtual函数,但是在执行Super里的virtual函数时,当前的B对象还只是一个Super类型,它的Sub属性都没有构造,所以,调用的还是Super的函数版本,起不到virtual函数该有的效果,反而会让人误解或是产生错误

如:Super{
virtual logOperater();
Super(){  ..., logOperater()};
}
Sub{
virtual logOperater();
Sub();
}


当执行Sub b时,logOperater操作调用的还是父类的版本。

令operator=返回一个this的引用

原因:可能用户会使用连等的形式,如Type a=b=c=d;如果等号操作不返回,则连等操作可产生错误,标准的形式该为:

ObjectType & operator=(const ObjectType &rhs)
{   ...
return *this;
}


在operator=中处理“自我赋值”

原因:由于别名的存在,可能导致有时候赋值是自己赋值给自己(如 *px=*py,或a[i]=a[j])。处理自我赋值,不要直接操作“删除oldvalue,赋值,返回”这几部。正确的做法有:

做法一:
首先判定是否是“自我赋值”,然后,在执行普通的等号赋值操作。但是该种方法会导致代码量的增加,同时,由于存在分支语句,降低执行速度(preFectching,caching,pipeling的原因)
做法二: SomeType&  SomeType::operator=(const SomeType &rhs)
{
ObjectType * pold=pb;
pb=new OjectType(rhs.pb);
delete pold;
return *this;
}
做法三: SomeType&  SomeType::operator=(const SomeType &rhs)
{
SomeType temp(rhs);
swap(temp);//需要编写另一个函数,用来交换this,和temp
return *this;
}


复制对象时不要忘记每一个成分

原因:特别是在父类中,如果忘记复制了。而子类一般对于父类的成员变量,直接使用的父类复制函数,所以也不会管父类的成员是否复制了,导致有的值被遗忘。

区别赋值操作和拷贝构造函数

如果对象还没创建,则执行的是拷贝构造函数,如果对象存在了,则执行的是赋值操作,见下例:

SomeType T1;      //调用默认的构造函数
SomeType T2(T1); //拷贝构造函数
T2=T1 ;        //执行赋值操作
SomeType T3=T2  //执行拷贝构造函数


关于explicit关键字

指定某个函数是默认的函数,如:explicit SomeType(),表示无参的构造函数是默认的构造函数。

继承和面向对象设计

确定你的public继承表示的是一种is-a的关系

注意日常生活中说的表示是一个种的关系和程序中的public有点不一样,如:对于企鹅是一种鸟,鸟会发,所以基类Bird有fly()这个函数,但是Penguin确不会飞,但是确会继承到Bird中的fly()熟悉,所以,在考虑设计基类时,需要考虑放入到基类中的接口。因为所有的public继承的子类都会继承得到这些熟悉。

避免遮蔽继承而来的名称

在变量查找时会首先查找子类名称域在看父类名称,如果子类中的名称与父类名称同名,则会对父类名称遮蔽。如:

class Base{
public :
virtual void mf1()=0;
virtual void mf1(int );
void mf3();
void mf3(double);
}
class Derived:public Base{
public :
// using Base::mf1
// using Base::mf3

virtual void mf1();
void mf3();
}
客户代码{
Derived d;
int x;
d.mf1();  //正确的
d.mf1(x);  //错误!Derived中的mf1()遮蔽了Base中的函数名称,尽管Derived中并没有定义mf1(int)函数,但是编译器在开始查找的时候,是按照函数名查找的,不是按照函数签名查找的,所以会报错
d.mf3();
d.mf3(x);///错误!理由同上,不论是non-virtual函数还是普通函数。


为了避免遮蔽,可以在子类定义时,加上using Base::mf1,如上注释

区分接口继承和实现继承

接口继承表现的是父类只提供接口,表示子类需要要不同的实现,只是都有这一属性。实现继承表现的是大家都有,且都是这样的行为。

绝不重新定义继承而来的non-virtual函数

原因:父类中的non-virtual函数就是表示的一种共同属性、相同的行为,如果你要重新修改它,表示的是各个子类表现不同,而应该将它定义为virtual的。

并且,如果你重新定义了non-virtual函数,这会使得指向子类的父类指针和 指向子类的子类指针表现成不同的行为,虽然它们都指向同一个对象,造成不必要的‘异常’行为(virtual就是用来解决这个问题的)

绝不重新定义继承而来的缺省参数值

原因:这个原因和上面条例类似“ 绝不重新定义继承而来的non-virtual函数”。这主要都是这些东西都是静态的,不会因为你的实际类型而做成判断

慎用多重继承

原因:多重继承容易导致有些函数的二义性,如BorrowabaleItem中有一个checkout函数,用来借书登记的,ElectronicGadget中也有一个checkout函数,用来检查电子产品,而MP3Player同时继承了这两个父类,那个mp3player.checkout()代码就不知道到底是调用的那一个了,就算是它们两个版本的可见性不同,如某一个是private的,但是编译器还是会错,因为它在检查mp3player的checkout函数时,首先会检查是否有可用的名为checkout函数,再检查可见性。但是当发现有一个名称符合的函数,就不确定要检查那个函数的可见性,所以报错。

另外,就算使用virtual继承指定使用那个版本,也需要做许多工作,如指定初始化版本等,同时编译器也许要为这个特别的继承添加其他的管理,校验代码。

定制new和delete

定制new ,delete的原因

原因:

1 为了记录new,delete事件,并做相关日志

2 为了更好的效率。默认的new,delete并没有针对那种分配做优化,只是满足平均的性能。如果你觉得你的系统主要是做小的对象的分配,你可以针对这种需求做特定的优化

3.满足特殊的要求,如需要某些对象尽量分配在一起

如果有重载的new版本,则应该有重载的相同形式的delete版本

原因:在new一个对象时,其实包涵了两个动作,一是分配空间,二是调用对象的构造函数,进行空间的初始化。如果通过重载版本的new函数完成了分配空间动作,但是在调用对象的构造函数时发生异常,则系统需要恢复原来的状态,释放刚分配的内存空间,但是系统不知道当初是怎样的分配空间的,所以系统采用的策略是根据你调用的new的重载样子,调用相同形式的delete版本来释放分配的空间。

正常样子的delete版本用于删除释放通过正常运行的new分配的对象。

重写new时,应该按照new的一般的实现时的流程

new的一般的流程是:
while(true)
{
void *p=请求分配size_T大小的空间,并返回其指针;
if(p==null)
调用new_handle;
else
break;
}


正常的new流程是要么分配成功,要么抛出了异常,否则会一直的循环下去。

关于new_handle

原因:new_handle的作用:当new操作出现异常时,会调用new_handle来处理异常,它要么直接抛出bad_alloc异常,要么重复执行内存申请操作,要么通过一定的算法来释放某些内存然后重新申请本次需要的内存。系统中有一个默认的new_handle,如果你要在某个类的申请时使用自己的new_handle需要保存之前的new_handle,并在你的操作完成之后,重新设置回去(类似于MFC中的画笔,画刷这样的设置),这种机制也有点想Linux中的信号处理函数。

注意:new_handle也是一种资源,在使用时,最好满足之前说的RAII原则,使用智能指针进行管理比较好,防止资源的泄漏

设计和声明

让接口容易被正确使用,不易被误用

遵守一般的命名规则,简单明了,且尽量贴近内置类型的使用规则

设计class就是扩展了语言的类型系统

对于类型的设计,一定要考虑到类型都有那种构建方式(是否要提供默认的构造函数)、如何被销毁(是否运用智能指针管理资源)

对象的初始化和对象的赋值应该有什么区别?

新的类型被paased by value,应该怎么办?是否需要提供新的语境下的copy函数?

新对象的合法的取值?

新的类型需要什么样的继承系统?

新的类型需要什么样的转换?

在传递参数时,尽量以pass by reference,而不是pass by value

原因:一是效率问题,减少形参的拷贝效率问题

二是在值传递时,可能存在slicing(对象切割)问题,如形参是Base类型,实参是Sub类型,但是通过值传递时,可能只拷贝了实参的Base部分,导致Sub类型中的数据丢失(特别是在函数里使用了virtual函数时,导致行为不确定)

当然,如果参数是基本类型,相对于reference类型的传递,基本类型的值拷贝是效率相对较高的

当返回值是一个新对象时,不要返回reference类型

原因:返回在函数体内新构造对象的reference是非常危险的,因为reference必须依靠它所指的对象而存在,但是函数体内在栈上构造的对象在函数返回而销毁,导致不确定行为。

返回在函数体内new出来的对象的指针也并不保险,可能用户调用这个函数后,返回值指针可能会被遗忘,导致内存的泄漏,如:

一个表示有理数的类Ratinal,乘法操作返回两操作数的积,用户可能会这样使用:Rational a(1,2),Rational b(2,3),Ration(3,4)

Rational c=a*b*c,这样由a*b返回的指针就造成内存泄漏了。

总体来说,直接返回一个对象是最好的(应该反正是要构造成一个对象,直接返回它就好了)

尽量将成员变量声明为private

原因:为了保证在class改变成员变量后,它所影响到的范围最小(将它声明为protected的话,它的子类能访问到它,因此改变成员的话,子类都会收到影响)

若所有参数都需要类型转化,最好将此函数声明为non-member函数

原因:拿操作符重载来举例子,使用成员函数重载二元函数的话,只允许左值为类类型的操纵,如定义了有理数类Rational,重载了其加法运行,如果是用成员函数重载的,则Rational A,B,C,

A=B+2 //允许,这个需要默认的构造函数支持将2转化为Rational的就可以了

A=B+C //允许

A=2+B//不允许

模版与泛型编程

对于嵌套的类型使用typename关键在前面表示它是一个类型

原因:在模版中,对于嵌套类型(如std::tr1::auto_prt,则表示在std命名空间中的tr1空间的类型auto_ptr,为了让编译器认为这是类型,需要在前面加上typename,如typename std::tr1::auto_prt e)

将非类型参数提出模版外

原因:如对于矩阵模版,如定义Matrix

使用traits特性来获取类型信息

原因:所谓的traits特性就是在定义时加上typedef,且同一类信息使用同一个typeid,但是具有不同的值,则获取到typeid时,根据其值获取类型信息

还可通过编译器完成前期的类型获取(重载多种类型特定的函数,编译器在实例化时,会自动根据参数类型来选择具体的版本)

具有继承关系的模版参数模实例化成的对象的转化,类是指针的转化特性实现(类似与*pbase可转换为*pderived)

原因:如果定义模版Temple,且有类Base,子类Derived,则在实例化时Temple *pb, Temple*pd,则pb和pd并无任何关系,因为实例化时是由不同的类产生的(Temple、 Temple属于不同的类),这是如果要表示它们的继承关系,则可以用到通过重载copy构造函数、赋值操作符函数,使得pb=pd的表示式具有实际意义。

实现

尽可能延迟变量的定义出现时间

原因:特别是对象这样的复杂变量的定义时间,延迟到真正使用赋值的时候再定义它,最后延迟到要对它赋值的时候,直接在构造的时候一步完成赋值操作。

主要原因:对象的构建比较浪费时间,但是,过早的定义可能导致实际上对象并没有派上用处,导致了构建对象的开销(如发生异常,执行流发生改变)

尽量少做转型动作

原因:C++中提供了4种新的转型操作,包括:

static_cast,dynamic_cast,const_cast,reinterpret_cast四种动作。但是依赖与转型动作,可能导致某些似是而非的操作,如下面的例子:

class Window{
virtual void onResize(){.....}
}
class SpecialWindow{
virtrual void onResize(){
static_cast<Window>(*this).onResize();
...//这里进行SpeicialWindow的专属操作
}


在上面的例子中,当调用SpecialWindow对象的onResize函数时,希望先调用基类的onResize动作,然后调用SpecialWindow中的特定的操作,但是这里使用转型的操作却是错误的(将this转型,会产生一个临时的Window类型对象temp,然后在temp对象中上调用onResize函数,如果在onResize函数中有对成员变量做修改,则影响的是temp变量的状态而不是期望的 this变量。

另外一个原因:使用转型通常是费时的(看起来的普通的一条语句,编译器为了实现这些功能,会插入一些代码来保证它的操作)

当需要转型,特别是向下转型时,通常是为了调用特定子类的功能,为了避免转型操作,可以通过virtual函数实现(将这些函数提到父类中,变成virtual的就可以了)

关于异常安全性

假设有一个多线程的画图类

PrettyMenu{
public :
void changeBackground(std::istream &imgSrc);
private:
Mutex mutex;
Image *bgImage;
int imageChanges;
}
下面是一个可能的实现版本:
void PrettyMemu::changeBackground(std::istream &imgSrc)
{     lock(&mutex);
delete bgImages;
++imageChanges;
bgImage=new Image(imgSrc);
unlock(&mutex);
}


对应抛出时,带有异常安全的函数会:
1.不泄漏任何资源,如内存、锁资源等。上面的代码没有做到这点,比如如果在new Image时,出现异常,则锁就不会释放。
2.不会造成数据的损坏。上面的代码也没有做到这点,如果在new时抛出异常,则之前的老的bgImage已经被删除,并且imageChanges也已经增加,但是这些转变事实上都没有完成。

异常安全性分为三种等级:
基本承诺:如果异常抛出,则程序内任何事物仍然保证在一种有效的环境下,没有数据结构被损坏。如上面的例子,当出现异常,还保存有原来的图片或是默认的图片,而不是产生一个悬挂的指针。
强烈保证:所有操作要么都成功,要么都失败,程序保持在一种“一致性”的状态,类似与事务性
不抛掷保证:保证不会抛出任何异常。
要点:不要盲目为了表示某件事发生了就立马改变状态,而是等它真的完成了再改变状态。
使用copy and Swap策略,将原有对象复制一份,然后对它进行操作,最后将副本和原件对换,保证原件有么成功,要么保持原样,这里需要保证swap函数不会抛出异常。
使用pimp idiom策略,即将数据放入一个对象中(成为implements),然后通过指针指向它,在改变时,只改变指针指向的值。


避免返回指向对象内部数据的句柄或是指针

原因:返回内部数据的句柄或是指针的坏处,一是破坏了原本数据封装的原则,直接将保护的数据暴露出来,使得用户可以直接修改;二是直接返回内部数据可能导致数据成员的存活期比父对象的要高,如:

class Rectangle{
struct RectData{ Point ulhc; Point lrhc;};
private:
std::tr1::shared_ptr<RectData> pData;
public
RectData  processData();
}


第一种情况:如果在processData()中直接返回了pData指针或是引用,则客户可以通过返回值直接操作声明为private的成员变量pData。
第二种情况:如果客户这么使用
Rectangle rec;
......
RectData  *rect=rec.processData();
然后可能用户在某个点释放了rec(因为rec已经没有用了),但是rect指针还是存在的,但是它却指向了已经释放了的rec的内部数据,造成了悬挂指针!


尽量不要将函数inline

原因:将函数inline一方面会造成函数的膨胀,有可能某个函数调用了多个被inline的函数,造成该函数非常庞大,这样一方面造成一个内存页存放不下或是在内存紧张的环境下,无法完全缓存完该函数。另一方方面,将函数inline后,如果该函数内容修改了,则使用了该函数的地方都需要重新编译。

最后,将函数内联,则导致在调试器找不到该函数了(放到一块,调试麻烦)

最好是让编译器自己决定是否需要内联,除非你觉得这个函数确实是太简单了。

尽量降低文件之间的编译依赖程度

原因:例如:将类SomeType里套一个实现类SomeTypeImpl的指针,这样当用户使用时,直接使用SomeType,在定义SomeType变量时,不需要知道内部的内存使用情况,减低文件之间的依赖。

如果直接使用SomeType的话,里面包涵了一些成员变量,当用户使用的时候,定义了一个SomeType变量 sometype,则编译器编译该代码的时候,就需要为该临时变量计算空间,即非常依赖于SomeType的具体实现,当SomeType改变时,则该客户代码也需要重新编译(Java中没有这个问题,Java中对象都是通过引用引用对象的,而引用类型是确定的空间)

资源管理-对象管理,智能指针,copy函数等

以对象管理资源

原因:主要的用意在于对于内存对象、数据库链接对象、网络Sockets对象、锁对象、文件描述符、图形界面中的画笔字体对象,在使用这些对象完的时候,可能会忘记清理掉这些资源。或是你程序了写了这些清理代码,但是有可能由于程序在某步发生了异常,导致程序执行流发生转变,导致不会执行这些代码。而没有清理这些资源的代价是非常大的。

使用对象管理资源的优点:

对于在内存分配上分配的对象,使用智能指针(auto_ptr,shared_ptr)来管理,依赖对象的析构函数,当对象离开作用域或是引用计数为0时,自动调用对象的析构函数,清理资源。在这里,有两条原则:

1:获得资源后,立刻放入到管理对象(如智能指针,或是其他的自定义管理对象)中,该原则也被称为RAII(resources acquisition is initialization),如 std::auto_ptr ptr( new SomeType() );

2:管理对象运用析构函数确保资源被释放。

关于:auto_ptr和shared_ptr:

小心资源管理类中的copying行为

原因:在C++中,即使你没有定义复制相关函数,但是编译器也会帮你添加这些函数,因此,你必须要注意这些复制行为:是否允许存在复制,或者默认的复制行为是符合你的环境的

成对使用new和delete

原因:如果使用的new,则使用delete形式,如果new [],则delete[]形式,同时提防typedef中的new []形式,

如typedef std::string AddressLines[];

std::string *pal=new AddressLines;

则必须使用delete pal[]形式

把new对象语句放入到独立语句中

原因:如果将new对象放入到复合语句中,如process( new SomeType(),processinfo()),如果在new后,在processinfo中出现了异常,则new出来的对象可能就造成内存泄漏

其他

不要轻易忽略编译器的警告

原因:它可能表示这里可能会出错,或者是不具有移植性

了解TR1

原因:TR1为一个技术报告,也就是制定了一些新特性的实现规范,但是没有具体的实现(多数的条款是依照boost库来设计的,但是boost库中也有不属于TR1规范中的东西)

了解Boost库

一份开源的实现,其中包括TR1中的大部分条款和一些其他的高级特性

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: