拷贝控制
类通过五种特殊的成员函数来控制对象的拷贝,移动,赋值和销毁操作时,应该做些什么;分别是拷贝构造函数,拷贝赋值运算符,移动构造函数,移动赋值运算符以及析构函数;
- 拷贝以及移动构造函数是用来当用同类型的对象来初始化本对象时,需要做些什么;
- 拷贝和移动赋值构造函数一个对象赋予给同类型的另一个对象时,需要做些什么;
- 拷贝构造函数:如果一个函数的第一个参数是自身类类型的引用,并且任何额外的参数都有默认值;
- 如果没有手动的定义拷贝构造函数,编译器会自动生成,但是如果我们定义了其他的构造函数,编译器仍然会生成拷贝构造函数;
- 合成构造哈数可以用来阻止该类类型对象的拷贝;
- 对于内置类型的拷贝的成员可以使用直接拷贝,对于类类型的成员需要使用其拷贝构造函数来进行拷贝;
- 使用直接初始化时,实际上是要求编译器使用普通的函数匹配来选择和提供参数最匹配的构造函数,本质上就是通过类本身提供的函数重载来完成的;
- 当使用拷贝初始化时,实际上要求编译器将右侧运算对象拷贝到正在创建的对象中,甚至还需要进行类型转换;
- 拷贝初始化一般通过拷贝构造函数来完成,但是在某些情况下,也可以通过移动构造函数来完成;
- 拷贝构初始化发生的几种情况:
- 1.将一个对象传递给一个非引用类型的实参;
- 2.从一个返回类型为引用类型的函数返回一个对象;
- 3.使用
{}
列表初始化一个数组中的元素或一个聚合类中的成员; - 4.大多数情况是在使用
=
定义变量时.
- 一些特殊的情况使用拷贝初始化:
- 1.初始化标准库容器或者调用
insert
或者push
成员时;
- 1.初始化标准库容器或者调用
- 但是使用
emplace
创建的元素都进行直接初始化; - 在函数调用的过程中,具有非引用类型的参数要执行拷贝初始化,当函数具有一个非引用的返回值类型时,返回值用来初始化调用方的结果;
- 这样来总结一下直接初始化和拷贝初始化:
- 使用
=
进行初始化的方式一定是拷贝初始化,拷贝初始化可以使用拷贝构造函数有时也可以通过移动构造函数来完成; - 不适用
=
进行初始化的方式一定是直接初始化,直接初始化是通过构造函数的重载来完成的,也就是存在按照参数进行匹配的过程; - 除了进行初始化时的区别,拷贝构造函数在上面的三种情况也会进行调用;
- 拷贝构造函数用来初始化非引用类类型参数,但是要求自己的参数必须是引用类型,否则就会陷入实参拷贝调用拷贝,有进行实参拷贝的循环中;
- 解释一下”拷贝构造函数用来初始化非引用类类型的参数”:
- 首先参数必须是类类型,否则就需要初始化为类类型;
- 参数不应该是一个引用类类型,如果是引用类类型,就不需要进行拷贝构造;
- 当构造函数只有一个形参时,就有一套默认的隐式转换规则,然后关键字
explict
恰好可以抑制这种隐式转换,并且隐式转换只能够转换一步; - 在进行拷贝初始化的过程中,编译器可以但是不必跳过拷贝或者移动构造函数;
- 使用
- 拷贝赋值运算符
- 按照拷贝的方式进行对象的赋值运算,需要注意的是初始化和赋值的区别,是不一样的;
- 重载赋值运算符本质上就是函数重载,而且重载包括成员函数重载和友元函数重载两种;
- 赋值运算符接受的实参的类型必须和所在类的类类型相同,并且赋值运算符通常返回一个指向左侧运算对象的引用
- 默认的合成拷贝赋值运算符:合成的拷贝赋值运算符是通过内置的类型一步一步来进行赋值运算的;
- 析构函数
- 构造函数和析构函数都无法对
static
进行构造或者销毁工作,这一点应该是默认; - 析构函数不接受参数,没有返回值,不能够被重载,并且类里面只能够有唯一的析构函数;
- 析构函数首先执行函数体,然后销毁成员,成员的销毁顺序是按照初始化的顺序逆序进行销毁,构造函数首先按照成员在类里面出现的顺序进行初始化操作,然后执行函数体;
- 析构函数释放对象在成存期分配的所有资源.
- 销毁类类型成员需要执行成员自己的析构函数,内置类型没有析构函数,因此销毁内置类型成员不需要通过析构函数来完成;
- 隐式销毁一个内置指针类型的成员不会
delete
它所指向的对象; - 智能指针属于类类型,所以具有析构函数,智能指针成员会在析构阶段自动进行销毁;
- 析构函数的调用时机:
- 1.变量离开其作用域时;
- 2.当一个对象被销毁时,其成员被销毁.
- 3.容器(无论是标准库容器还是数组)被销毁时,其元素被销毁.
- 4.对于动态内存分配的对象,当指向它的指针应用
delete
运算符时被销毁; - 5.对于临时对象,当创建它的完整表达式结束时被销毁.
- 无论何时一个对象被销毁,就会自动调用其析构函数;
- 析构函数是自动运行的,不需要担心何时释放这些资源;
- 需要注意是,当指向一个对象的引用或者指针离开作用域时,析构函数不会被执行;
- 析构函数也可以由编译器自己进行生成,通常合成析构函数体为空,特殊情况例外;
- 加强理解:
- 析构函数自身并不直接销毁成员,成员是在析构函数体之外隐含的析构阶段被销毁的;
- 在对象的销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分进行的;
- 三/五法则
- 控制类的拷贝操作:拷贝构造函数,拷贝赋值运算符,析构函数;
- 新标准下,类可以定义一个移动构造函数和一个移动赋值运算符;
- 基本原则之一: 如果一个类需要一个析构函数,那么他肯定也需要一个拷贝构造函数和一个拷贝赋值运算符;
- 如果仅仅定义了析构函数,那么在使用合成的拷贝构造函数以及赋值运算符函数时,可能会出现指针的浅拷贝现象,导致指针释放出现错误;
- 在一些情况下,只需要拷贝或者赋值操作,并不一定需要析构函数,书里面给出了一种特殊情况;
- 基本原则之二: 如果一个类需要一个拷贝构造函数,几乎肯定他也需要一个拷贝赋值运算符,反之亦然,但是并不意味着一定需要析构函数;
- 关于
=default
使用的两种情况:
- 如果在自己定义了默认构造函数或者拷贝构造函数等编译器会自动生成的函数的情况下,仍然需要编译器自动生成默认的构造函数,就可以使用
=default
来进行声明; - 情况一:在类里面使用
=default
修饰成员函数,编译器生成的成员函数将声明为内联函数; - 情况二:如果在类的外面使用
=default
来定义时使用,那么成员函数就不是内联的; - 需要注意的是,只能够对具有合成版本的成员函数使用
=default
,包括默认的构造函数,以及拷贝控制成员函数;
- 如果在自己定义了默认构造函数或者拷贝构造函数等编译器会自动生成的函数的情况下,仍然需要编译器自动生成默认的构造函数,就可以使用
- 阻止错误的拷贝,不应该通过不定义控制拷贝成员,因为编译器会自动生成;
- 在
C++11
标准中可以通过拷贝构造函数以及赋值运算符定义为删除函数来阻止拷贝,使用=delete
,表示的含义是首先通过自己声明拷贝构造函数以及赋值运算符防止编译器自动生成,但是在声明时,使用=delete
表示这个函数被禁止使用; =delete
必须出现在函数第一次声明时,编译器需要在声明时知道一个函数是删除的,以便于组织任何使用该函数的操作;=default
仅仅影响的是为这个成员生成的代码,因此直到编译器生成代码时才需要;- 我们几乎可以对任何函数指定
=delete
,删除函数的主要作用是用来禁止拷贝控制成员,但是也是可以用来引导函数匹配过程; =default
只能够用于编译器可以合成的默认构造函数或拷贝控制成员;- 析构函数是不能够定义为
=delete
的,如果存在这样的定义,编译器不允许定义该类型的变量或者创建该类型的临时对象,虽然可以动态的分配这种类型的对象,但是不能够释放这些对象; - 对于析构函数已经删除的类型,不能够定义该类型的变量或释放指向该类型动态分配对象的指针;
- 一些可以删除的拷贝控制成员函数(指的是编译器自动生成的)
- 1.如果类的某个成员的析构函数是删除的或者不可访问的,那么类的合成析构函数被定义为删除的;
- 2.如果类的某个成员的拷贝构造函数时删除的或者不可访问的,则合成拷贝构造函数被定义为删除的,如果类的某个成员的析构函数是删除的或不可访问的,则合成的拷贝构造函数也被定义为删除的;
- 3.如果类的某个成员的拷贝赋值运算符是删除的或者不可访问的,或者类有一个
const
或引用成员,那么合成拷贝赋值运算符被定义为删除的; - 4.如果类的某个析构函数是删除的或者不可访问的,或是类有一个引用成员,它没有类里面的初始化器,或者类有一个
const
成员,它没有类内初始化器且类型没有显示定义默认构造函数,那么这个类的默认构造函数被定义为删除的; - 上述规则的本质是:如果一个类有数据成员不能够默认构造,拷贝,复制,或者销毁,则对应的成员函数将被定义为删除的;
- 尝试解释上述几条规则:
- 1.第四条:如果一个类里面的成员具有删除的或者不可访问的析构函数会导致合成的默认和拷贝构造函数被定义为删除的,因为创建的对象无法被销毁;
- 2.第四条:如果一个类具有引用或这无默认构造的
const
的类,编译器无法为其创建默认构造函数;类里面如果有const
成员,那么是不需要使用合成的拷贝赋值运算符的,const
成员是不允许改变的; - 3.第四条:如果将新值赋值给一个引用成员,但是这样做改变的是引用所指向对象的值,并不会改变引用本身的值;假设有合成的拷贝赋值运算符,那么在赋值后,左侧对象指向的仍然是左侧对象原来指向的对象,右侧对象指向的仍然是右侧对象指向的对象,只不过左侧对象的值发生了改变;
- 4.剩下的原因以后在解释了;
- 构造函数和析构函数都无法对
- 在新标准之前,类是通过将拷贝构造函数和拷贝赋值运算符声明为
private
来阻止拷贝的; - 一个好的建议是:希望阻止拷贝的类应该使用
=delete
来定义他们自己的拷贝构造函数和拷贝赋值运算符,而不是将其声明为private
; 拷贝管理和资源管理
- 通常来说,这里提到的拷贝表示的含义是类对象之间的拷贝;
- 值拷贝:类似于值拷贝,原始对象和副本之间是没有必然的联系的,改变副本的值,不应该改变原始的值,反之亦然;
- 指针拷贝:更加类似于指针,副本和原始对象适用相同的底层数据,改变副本也会改变原始对象的值,反之亦然;
- 类似于值的类的设计:
- 定义一个拷贝构造函数,用于完成
string
的拷贝; - 定义一个析构函数用来释放
string
; - 定义一个拷贝赋值运算符来释放当前的
string
并且从右侧对象拷贝string
;
- 定义一个拷贝构造函数,用于完成
赋值运算符结合了析构函数和构造函数的特点,类似于析构函数,组织运算符会销毁左侧运算对象的资源,类似于拷贝构造函数,赋值运算符会从右侧拷贝数据;
深拷贝实例:HashPtr& HasPtr::operator=(const HasPtr &rhs){ auto newp = new string(*rhs.ps); delete ps; ps = newp; i = rhs.i; return *this; }
首先拷贝右侧运算对象,然后处理自身赋值的情况,可以保证在异常发生时,代码执行仍然是安全的;
- 赋值运算符注意:
- 如果将一个对象赋值给本身,赋值运算符也是需要可以进行工作的;
- 大多数赋值运算符组合了析构函数和拷贝构造函数的工作;
- 编写赋值运算符的过程建议:首先将右侧对象拷贝到一个临时的局部对象里面,然后销毁左侧运算对象的现有成员,然后将数据从临时对象拷贝到左侧的运算对象中;
行为更像指针的类
- 需要定义的包括:拷贝构造函数以及拷贝运算符,同时还需要定义析构函数;
- 为了管理共享资源的分配,这里引入引用计数的知识,也可以通过
shared_ptr
共享指针来完成 引用计数器的工作方式:
- 除了初始化对象之外,每个构造函数(除了拷贝构造函数之外)还需要创建一个引用计数,用来记录有多少个对象与正在创建的对象共享状态,当创建一个对象时,只有一个共享状态,引用计数器为1;
- 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器.拷贝构造函数递增共享计数器,指出给定对象的状态又被一个新的用户共享.
- 析构函数递减计数器,指出共享状态的用户少了一个.如果计数器变为0,则析构函数释放状态.
- 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧对象的计数器.如果左侧运算对象的计数器变为0,意味着它的共享状态没有用户了,拷贝运算符就必须销毁状态.
- 因为计数器必须时唯一的,那么就不能够包含在对象里面,而应该使用动态内存;
引用计数器类: class HasPtr{ public: //构造函数分配新的`string`和新的计数器,将计数器置为1; HasPtr(const string &s=string()):ps(new string(s),i(0),use(new size_t(1)){ } //拷贝构造函数拷贝所有三个数据成员,并且递增计数器; HasPtr(const &p):ps(p.ps),i(p.i),use(p.use){ ++*use; } HasPtr& operator=(const HasPtr &){ ++*rhs.use; if(--*use ==0){ delete ps; delete use; } ps = rhs.ps; i = rhs.i; use = rhs.use; return *this; } ~HasPtr(){ if(--*use == 0){ delete ps; delete use; } } private: string *ps; int i; size_t *use; //用来记录有多少个对象共享*ps的成员; };
- 在上面代码的基础上面添加交换操作,交换操作本质上是指针的交换,而不是元素副本的交换;
- 一般情况下,应该使用类里面本身的
swap
函数,如果类里面已经有定义的swap
函数,那么就不应该使用标准库提供的swap
函数; - 使用拷贝和交换的赋值运算符默认就是异常安全的,并且能够出资自复制现象;
- 拷贝控制
- *
- 动态内存管理
- 动态内存管理一般来说可以使用标准库容器来进行管理,但是大多数情况下需要定义自己的拷贝控制成员来管理所分配的内存;
- 在这里书上面实现了一个简易的
StrVec
类,在书的490页
开始; - 移动构造函数应该是使用指针进行拷贝,而不是将字符进行拷贝,这样效率会高;
- 还可以使用
move
标准库函数;
对象移动:
移动可以大幅度的提升性能;并且有些对象是不能够拷贝的,比如
IO
类,或者unique_ptr
这样的类,这些类不包含可以被共享的资源,不能够拷贝,但是可以被移动;void StrVec::reallocate(){ auto newcapacity = size() ? 2* size() : 1; auto newdata = alloc.allocate(newcapacity); auto dest = newdata; auto elem = elements; for(size_t i =0;i!= size();++i) alloc.construct(dest++,std::mov(*elem++)); free(); elements = newdata; first_free = dest; cap = elements + newcapacity; }
通常来说,标准库容器,
string
类和shared_ptr
类既支持移动也支持拷贝,IO
类和unique_ptr
类可以移动但是不能够拷贝;- 右值引用:为了支持移动操作引入,表示的含义是必须绑定到右值的引用,需要通过
&&
来获得右值引用.
- 右值引用的一个重要的性质:右值引用只能绑定到一个将要被销毁的对象,这样就可以自由的将一个右值引用的资源移动到另一个对象中;
- 一般来说,一个左值表达式表示的是一个对象的身份,然而一个右值表达式表示的是对象的值;
- 左值的特性:左值不能够被绑定到要求被转换的表达式,字面值常量,或者是返回右值的表达式;
- 右值特性:可以将右值引用绑定到上面提到的三种表达式上面,但是不能够将一个右值直接绑定到一个左值上面;
- 返回左值引用的函数,连同赋值,下标,解引用和前置递增递减运算符,返回的都是左值表达式,可以使用左值引用进行绑定;
- 返回非引用类型的函数,连同算术,关系,位,以及后置递增,递减运算符,都可以生成右值,可以使用
const
左值引用或者右值引用绑定到这类表达式上面; - 左值一般来说具有持久的状态,右值要么是字面常量,要不是表达式求值过程中创建的临时变量;
- 使用右值引用的代码可以自由的接管所引用的对象的资源;
- 变量可以看做是只有一个运算对象二没有运算符的表达式,但是类似于任何表达式,变量表达式也有左值和右值属性,变量表达式都是左值.
- 不能够将一个右值引用绑定到一个右值引用类型的变量上面;
- 变量是左值,因此我们不能够将一个右值引用直接绑定到一个变量上,及时变量是右值引用也不行;
move
函数: 虽然不能够将一个右值引用直接绑定到一个左值上面,但是可以显示的将一个左值转换为对应的右值引用类型,容纳后通过move
的标准库函数来获得绑定到左值上的右值引用;移动构造函数以及移动赋值运算符
对于移动构造函数来说,基本结构和拷贝构造函数类似,但是第一个参数必须是一个右值引用,并且之外的所有额外的参数都必须有实参;
StrVec::StrVec(StrVec &&s) noexcept:elements(s.elements),first_free(s.first_free),cap(s.cap){ s.elements = s.first_free = s.cap = nullptr; //也就是或在进行资源移动操作后,资源应该达到可以被析构函数释放资源; }
移动构造函数并不分配任何新的内存,但是接管原先存在的内存,,并且将给定的指针置为
nullptr
,这样就完成了移动构造函数的移动操作;- 移动构造函数是不会抛出异常的,所以可以使用
noexpect
来避免一些额外的处理异常的工作. - 不抛出异常的移动构造函数和移动赋值运算符必须标记未
noexpect
; - 标准库容器是可以处理自身可能出现的异常的,从而保证容器正常工作;
- 如果移动构造函数抛出异常,就可能在移动的过程中导致数据丢失,所以通过将移动构造函数以及移动赋值运算符标记为
noexcept
来做到这一点; 移动赋值运算符指向和析构函数以及移动构造函数相同的工作,移动赋值运算符同样需要处理自身赋值的情况;
StrVec &strVec::operator=(StrVec &rhs)noexcept{ if(this != &rhs){ free(); elements = ths.elements; first_free = rhs.first_free; cap = rhs.cap; rhs.elements = rhs.first_free = rhs.cap = nullptr; } return *this; }
在执行移动操作之后,源对象必须能够进行析构,同事还需要保证新对象是有效的,并且是不能够依赖于前值的;
- 对于标准库
string
或者容器对象移动数据时,移动后的源对象仍然保持有效,但是并不保证执行操作的结果,期望源对象的值应该是空的; - 在执行移动操作后,移后源对象必须保持是有效的,可析构的状态,并且不能够期望其值仍然是原来的值;
- 同样的,编译器支持合成的移动构造函数以及移动赋值运算符;
- 编译器不会合成移动操作的几种情况:
- 如果一个类定义了自己的拷贝构造函数,拷贝赋值运算符以及析构函数,因为类可以通过匹配通过对应的拷贝操作来代替移动操作;
- 编译器会自动生成移动构造函数的几种情况:
- 只有当一个类没有定义自己的任何版本的拷贝控制成员,并且类的每个非
static
数据成员都可以移动时,编译器才会为他合成移动构造函数或者移动赋值运算符; - 编译器可以移动内置类型的成员,以及类类型成员,但是该类需要有移动操作;
- 只有当一个类没有定义自己的任何版本的拷贝控制成员,并且类的每个非
- 总结上述规则:只有当一个类没有定义任何自己版本的拷贝控制成员,并且它的所有的数据成员都能移动构造或者移动赋值时,编译器才会为它合成移动构造函数或者移动赋值运算符;
移动构造函数的几大特点:
- 移动构造函数永远不会隐式定义为删除的函数,但是可以要求编译器生成
=default
的移动操作,且变一起不能够移动所有的成员时,编译器会将移动操作定义为删除的函数; - 合成的移动操作定义为删除的函数:
- 有类成员成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数,移动赋值运算符类似;
- 如果类成员的移动构造函数或者移动赋值运算符被定义为删除的或者是不可访问的,那么移动构造函数或者移动赋值运算符就会被定义为删除的;
- 如果类的析构函数被定义为删除的或者是不可访问的,那么类的移动构造函数被定义为删除的;
- 类似拷贝赋值运算符,如果有类成员是
const
或者是引用,那么移动赋值运算符会被定义为删除的; - 一个类是否定义了自己的移动操作对拷贝操作如何合成有影响,如果类定义了一个移动构造函数和/或一个移动赋值运算符,那么该类合成的拷贝构造函数和拷贝赋值运算符会被定义为删除的;
- 定义了一个移动构造函数或者移动赋值运算符的类必须也定义自己的拷贝操作,否则这些成员默认的被定义为删除的;
- 移动操作移动的是右值,拷贝操作拷贝的是左值;
- 如果没有移动构造函数,但是定义了拷贝构造函数,函数匹配规则保证这类对象将会被拷贝,而不是被移动,即使尝试进行移动,也会被拷贝;
- 使用拷贝构造函数或者拷贝赋值运算符来代替移动构造函数以及移动赋值运算符几乎是安全的;
- 拷贝构造函数是不会改变原来对象的值;
如果一个类有一个可用的拷贝构造函数二没有移动构造函数,则其对象是通过拷贝构造函数来移动的,拷贝赋值运算符和移动赋值运算符是类似的;
class HasPtr { public: //添加移动构造函数 HasPtr(HasPtr &&p)noexcept:p(p.ps),i(p.i){ p.ps = 0; } //赋值运算符既是移动赋值运算符,也是拷贝赋值运算符; HasPtr& operator=(HasPtr rhs){ swap(*this,rhs); return *this; } };
- 比较特殊的是第二个实现了移动赋值运算符以及拷贝赋值运算符两种功能,可以根据传入的值是右值还是左值,来选择进行拷贝赋值或者是移动赋值操作;
- 所有的五个拷贝控制成员应该是一个整体,一般来说,如果一个类定义了任何一个拷贝操作,他就应该该定义所有的五个操作,同时在拷贝并非是必须要的情况下,还应该通过移动构造函数和一栋在赋值运算符来避免开销;
- 移动构造函数永远不会隐式定义为删除的函数,但是可以要求编译器生成
- 移动迭代器适配器,一个移动迭代器适配器通过改变给定迭代器的解引用运算符的行为来适配此迭代器,移动迭代器解引用运算符生成一个右值引用,可以通过标准库的
make_move_iterator
函数将一个普通迭代器转换为一个移动迭代器,这个函数接受一个迭代器参数,返回一个移动迭代器; 新生成的移动迭代器支持正常的迭代器操作;
uninitialized_cpoy; void StrVec::reallocate(){ //分配大小两倍于当前规模的内存空间 auto newcapacity = size() ? 2 * size() :1; auto first = alloc.allocate(newcapacity); //移动元素 auto last = uninitilized_copy(make_move_iterator(begin()),make_move_iterator(end()),first); free(); elements = first; first_free = last; cap = elements + newcapacity; }
移动迭代器和移动操作有着相同的特性,在移动操作之后源对象无法保证还是期望的值;
- 不可以随意使用移动操作,在移动构造函数和移动赋值运算符这些类实现代码之外的的地方,只有确定需要进行移动操作,并且移动操作是安全的,才可以使用
std::move
;
右值引用和成员函数
用于区分移动和拷贝的重载函数通常又一个版本接受一个
const T&
,而另一个版本接受一个T&&
;
提供一个例子:class StrVec{ public: void push_back(const std::string&); void push_back(cosnt std::string&&); }; void StrVec::push_back(const string& s){ chk_n_alloc(); alloc.construct(first_free++,s); } void StrVec::push_back(string &&s){ chk_n_alloc(); alloc.construct(sirst_free++,std::move(s)); }
新的标准是允许向右值进行赋值的,如果希望阻止这种赋值行为,可以强制左侧对象是一个左值;
class Foo{ public: Foo &operator=(const Foo&) &; }; Foo &Foo::operator=(const Foo &rhs) &{ return *this; }
通过在成员函数后面添加
&
符号可以用来阻止将右值进行赋值操作,必须同时出现再声明和定义中- 对于
&
限定的函数,只能够将其用于左值,对于&&
限定的函数只能够用于右值; - 一个函数是可以同时拥有
const
和引用限定的,并且引用限定符必须跟在const
之后; - 成员函数可以根据是否有
const
来区分重载版本,也可以根据引用限定符来区分重载版本; - 当定义
const
成员函数时,可以定义两个版本,可以通过const
来进行区分而重载; - 在定义两个或者两个以上具有相同名称和相同参数列表的成员函数,就必须对所有成员函数加上引用限定符,或者都不加;
- 如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符;