C++Primer_Chap13_拷贝控制_笔记

一个类可以通过定义五种特殊的成员函数来控制此类型的对象拷贝、移动、赋值和销毁时的具体实现:

  1. 拷贝构造函数(copy constructor)
  2. 拷贝赋值运算符(copy-assignment operator)
  3. 移动构造函数(move constructor)
  4. 移动赋值运算符(move-assignment operator)
  5. 析构函数(destructor)

拷贝构造函数

  如果一个构造函数的第一个参数自身类类型的引用(如果不是引用,在传递参数时会调用拷贝构造函数即本身),且额外任何参数都有默认值,此构造函数为拷贝构造函数。如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。对于某些类,合成拷贝构造函数用来禁止该类型对象的拷贝。

  拷贝初始化通常使用拷贝构造函数来完成,但也会使用移动构造函数来完成。

  拷贝初始化将在如下情况下发生:

  • 用=定义变量
  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

  如果使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化有比较大的区别:

vector<int> v1(10);    //正确:直接初始化
vector<int> v2 = 10;    //错误:接受大小参数的构造函数是explicit的
void f(vector<int>);    //f的参数进行拷贝初始化
f(10);                    //错误:不能用一个explicit的构造函数拷贝一个实参
f(vector<int>(10));        //正确:从int直接构造一个临时vector

拷贝赋值运算符

    如果我们没有为一个类定义拷贝赋值运算符,编译器会为我们定义一个。对于某些类,合成拷贝拷贝赋值运算符用来禁止该类型对象的赋值。

  赋值运算符通常应该返回一个指向其左侧运算对象的引用。

class Foo{
public:
    Foo& operator=(const Foo&);
    //……
};

析构函数

  析构函数释放对象使用的对象,并销毁对象的非static数据成员。没有返回值,不接受参数。由于不接受参数,所以不能被重载。因此,对于一个给定类,只会有唯一一个析构函数。

  • 需要析构函数的类也需要拷贝和赋值操作
  • 需要拷贝操作的类也需要赋值操作,反之亦然

使用=default

  当我们在类内使用=default修饰成员的声明时,合成的海曙将隐式声明为内联的。如果不希望合成的成员是内联函数,应该只对创建的类外定义使用=default。只能对具有合成版本的成员函数使用=default(即,默认构造函数或拷贝控制成员)

阻止拷贝

struct Nocopy{
    NoCopy() = default;    //使用合成的默认构造函数
    NoCopy( const NoCopy&) = delete;    //阻止拷贝
    NoCopy &operator=(const NoCopy&) = delete;    //阻止赋值
    ~NoCopy() = default;    //使用合成的析构函数
};

  如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的数据成员函数将被定义删除的。

  对于某些类来说,编译器将这些合成的成员定义为delete的函数:

  • 类的某个成员的析构函数是delete或不可访问的(如private),则类的合成析构函数将被定义为delete的
  • 类的某个成员的拷贝构造函数是delete或不可访问的,则类的合成拷贝构造函数被定义为delete的。
  • 类的某个成员的析构函数是delete的或不可访问的,则类的合成拷贝构造函数被定义为delete的。
  • 类的某个成员的拷贝赋值运算符时delete或不可访问的,或类有一个const的成员或引用成员,则类的合成拷贝赋值运算符被定义为delete的
  • 类的某个成员的析构函数是delete或不可访问的,或类有一个引用成员,它没有类内初始化器,或类有一个const成员,它没有类内初始化器且其类型未显示定义默认构造函数,则该类的默认构造函数将被定义为delete的

拷贝控制和资源管理

  通常,管理类外资源的类必须定义拷贝控制成员,这中类需要通过析构函数来释放对象所分配的资源。一旦一个类需要析构函数,那么它肯定也需要一个拷贝函数和一个拷贝赋值运算符。一般来说有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针。

  • 类的行为像一个值,以为着它应该也有自己的状态。当我们拷贝一个像值的对象时,副本和原对象时完全独立的。
  • 行为像指针则共享状态。当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据。改变副本也会改变原对象。

行为像值的类

class HasPtr{
	public:
		HasPtr(const std::string &s = std::string()):
			ps(new std::string(s)),i(0){}
		//
		HasPtr(const HasPtr &p):
			ps(new std::string(*p.ps), i(p.i){}
		HasPtr& operator=(const HasPtr&);
		~HasPtr(){ delete ps;}
	
	private:
		std::string *ps;
		int  i;
};
  • 如果将一个对象赋予它自身,赋值运算符必须能正确工作。
  • 大多数赋值运算符组合了析构函数和拷贝构造函数的工作。

  当编写一个赋值运算符时,一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的。

HasPtr& HasPtr::operator=(const HasPtr& rhs)
{
	auto newp = new std::string( *rhs.ps );
	delete ps;        //delete this->ps
	ps = newp;
	i = rhs.i;
	return *this;
} 

定义行为像指针的类

  对于行为类似指针的类,我们需要为其定义拷贝构造函数和拷贝赋值运算符,来拷贝指针成员本身而不是它指向的而对象。最好的方法是使用shared_ptr来管理类中的资源。

引用计数

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,引用计数器初始化为1
  • 拷贝构造函数不分配新的计数器,拷贝数据成员,包括计数器。拷贝构造函数递增共享计数器、
  • 析构函数递减计数器。如果计数器为0,析构函数释放状态
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。
class HasPtr{
	public:
		HasPtr(const std::string &s = std::string()):
			ps(new std::string(s)),i(0), use(new std::size_t(1){}
		//
		HasPtr(const HasPtr &p):
			ps(new std::string(*p.ps), i(p.i), use( p.use ){ ++*use;}
		HasPtr& operator=(const HasPtr&);
		~HasPtr(){ delete ps;}
	
	private:
		std::string *ps;
		int  i;
		std::size_t *use;
}; 

HasPtr& HasPtr::operator=(const HasPtr& rhs)
{
	++*rhs.use;
	if( --*use == 0)
	{
		delete ps;
		delete use;
	}
	ps = rhs.ps;
	i = rhs.i;
	use = rhs.use;
	return *this;
}

~HasPtr::HasPtr()
{
	if( --*use == 0)
	{
		delete ps;
		delete use;
	}
}

交换操作

  除了定义拷贝控制成员,管理资源的类通常还定义一个名叫swap的函数。对于那些与重排元素顺序的算法一起使用的类,定义swap是非常重要的。

  如果一个类定义了自己的swap,那么算法将不使用标准库定义的swap。通常交换需要一次拷贝和两次赋值。理论上,我们希望swap交换指针而不是分配string的新副本

class HasPtr{
    friend void swap( HasPtr&, HasPtr&);
    //
};

inline
void swap( HasPtr &lhs, HasPtr &rhs)
{
    using std::swap;
    swap( lhs.ps, rhs.ps);     //交换指针,而不是string数据
    swap( lhs.i, rhs.i);
}

swap函数应该调用swap,而不是std::swap

  每个swap调用都应该是未加限定的。即,每个调用都应该是swap,而不是std::swap。如果存在类型特定的swap版本,其匹配程度会优于std中定义的版本。因此,如果存在类型特定的swap版本,swap调用会与之匹配。如果不存在类型特定的版本,则会使用std的版本(假定作用域中有using声明)

void swap(Foo &lhs, Foo &rhs)
{
    using std::swap;
    swap( lhs.h, rhs.h);    //使用HasPtr版本的swap
}

在赋值运算符中使用swap

  定义swap的类通常用swap来定义它们的赋值运算符。这些运算符使用一种名叫拷贝并交换的技术

HasPtr& HasPtr::operator=(HasPtr rhs)
{
    swap( *this, rhs );
    return *this;
}

  拷贝控制示例

  通过使用新标准库引入的两种机制,可以避免string的拷贝:

  • 移动构造函数通常是将资源从给定对象“移动”而不是拷贝到正在创建的对象。且标准库保证“移后源”string人保持一个有效、可析构的状态
  • 定义在utility头文件中的move标准库函数。
class StrVec{
public:
	StrVec():element(nullptr), first_free(nullptr), cap(nullptr){}
	StrVec( const StrVec&);
	StrVec& operator=( const StrVec &);
	~StrVec();
	void push_back( const std::string&);
	size_t size() const { return first_free - elements;}
	size_t capacity() const {return cap - elements;}
	std::string *begin() const { return elements;}
	std::string *end() const {return first_free;}
	//……

private:
	static std::allocator<std::string> alloc;
	void chk_n_alloc() 
		{ if(size() == capacity()) reallocate();}
	std::pair<std::sting*, std::string*> alloc_n_copy
		(const std::string*, const std::string*);
	void free();
	void reallocate();
	
	std::string elements;
	std::string first_free;
	std::string cap;
};

void StrVec::StrVec( const StrVec &s)
{
	auto newdata = alloc_n_copy( s.begin(), s.end());
	elements = newdata.first;
	first_free = newdata.second;
}

StrVec& StrVec::operator=(const StrVec &rhs)
{
	auto data = alloc_n_copy( rhs.begin(), rhs.end());
	free();
	elements = data.first;
	first_free = data.second;
	return *this;
}

StrVec::~StrVec()
{
	free();
}

void StrVec::push_back(const string &s)
{
	chk_n_alloc();    //确保有空间容纳所有元素
	alloc.construct(first_free++, s);
}

pair<std::string*, std::string*>
StrVec::alloc_n_copy(const std::string *b, const std::string *e)
{
	auto data = alloc.allocate(e - b);
	return {data, uninitialized_copy(b, e, data)};
}

void StrVec::free()
{
	//
	if(element)
	{
		for( auto p = first_free; p != elements; )
			alloc.destroy(--p);
		alloc.deallocate( elements, cap - elements);
	}
}

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::move(*elem++));
	free();
	elements = newdata;
	first_free = dest;
	cap = elements + newcapacity;
}

对象移动

  标准库容器、string和shared_ptr类即支持移动也支持拷贝。unique_ptr和IO类只可以移动不支持拷贝。

右值引用

  为了支持移动操作,新标准引入一种新的引用类型——右值引用(rvalue reference)。所谓右值引用就是必须绑定到右值的引用,通过&&而不是&来获得右值引用。右值引用有一个重要的性质:只能绑定到一个将要销毁的对象.因此,可以自由将右值引用资源“移动”到另一个对象中。一般来说,一个左值表达式表示的是一个对象的身份,而一个右值表达式表达的是对象的值。

  返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。

  返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或者一个右值引用绑定到这类表达式上。

int i = 42;
int &&rr = i;    //错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42;    //错误:i*42是一个右值
const int &r3 = i * 42;    //正确:可以将一个const引用绑定到一个右值上
int &&rr2 = i * 42;    //正确

int &&rr1 = 42;        //正确
int &&rr2 = rr1;        //错误:表达式rr1是左值
int &&r3 = std::move(rr1);    //正确

   move调用告诉编译器:我们有一个左值,但我们希望像一个右值一样处理它。我们必须认识到,调用move就意味着承诺:除了对rr1赋值或销毁它之外,我们将不再使用它。

  与大多数标准库名字的使用不同,对move我们不提供using声明,直接调用std::move而不是move

移动构造函数和移动赋值运算符

  移动构造函数的第一参数是该类类型的一个右值引用,且其他任何额外的参数必须有默认实参。

StrVec::StrVec(StrVec &&s) noexcept
	: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
	s.elements = nullptr;
	s.first_free = nullptr;
	s.cap = nullptr;
}

  noexcept通知标准库,构造函数不抛出任何异常。我们必须在类头文件的声明中和定义中(如果定义在类外的话)都指定noexcept

class StrVec{
public:
    StrVec(StrVec &&) noexcept;    //移动构造函数
};
StrVec::Strvec(StrVec &&s) noexcept: /*成员初始化器*/
{/*构造函数体*/}

  一个移动操作不抛出异常,这是因为两个相关联的事实:

  1. 虽然移动操作通常不抛出异常,但抛出异常也是允许的
  2. 标准库容器能怼异常发生时自身的行为提供保障。例如:vector保障,如果我们调用push_back时发生异常,vector自身不会发生改变

为了避免在重新分配内存的过程中使用拷贝构造函数而不是移动构造函数,需要显示的告诉标准库我们的移动构造函数可以安全使用。(不知道为什么,总感觉这种说法是一种类似“为了告诉客户选择我们而不是其他公司,所以要显示的告诉甲方我们非常安全,而实际情况未可知”)

移动赋值运算符

StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
	if( this != &rhs)
	{
		free();
		elements = rhs.elements;
		first_free = rhs.first_free;
		cap = rhs.cap;
		
		rhs.elements = rhs.first_free = rhs.cap = nullptr;
	}
	return *this;
}

  进行检查的原因是此右值可能是move调用的返回结果。关键点在于不能在使用右侧运算对象的资源之前就是否左侧运算对象的资源(可能是相同的资源) 

移后源对象必须可析构

  在移动操作之后,移后源对象必须保持有效的、可析构的状态,但用户能对其值进行任何假设

合成的移动操作

  如果我们不声明自己的拷贝构造函数或拷贝赋值运算符,编译器总会为我们合成这些操作。拷贝操作要么被定义成逐成员拷贝,要么被定义为对象赋值,要么定义为delete的函数。

  与拷贝操作不同,编译器根本不会为某些类合成移动操作。特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值函数或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符。因此,某些类就没有移动构造函数或 移动赋值运算符。如果一个类没有移动操作,通过正常的函数匹配,类会使用对应的拷贝操作来带起移动操作。

  只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员:

struct X{
    int i;    //内置类型可以移动
    std::string s;    //string定义了自己的移动操作
};

struct hasX{
    X mem;    //X有合成的移动操作
};

X x, x2 = std::move(x);    //使用合成的移动构造函数
hasX hx, hx2 = std::move(hx);    //使用合成的移动构造函数

只有当一个类没有定义任何自己版本的拷贝控制成员,且所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。

  移动操作永远不会隐式定义为delete的函数。但是,如果我们显示地要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为delete的函数。除了一个例外,其他delete的移动操作均遵循如下原则:

  • 有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数,则移动构造函数被定义为delete的。移动赋值运算符类似
  • 如果有类成员的移动构造函数或移动赋值运算符被定义为delete的或不可访问的,则移动构造函数被定义为delete的。
  • 如果类的析构函数被定义为delete的或不可访问的,则移动构造函数被定义为delete的。
  • 如果类成员是const的或是引用,类的移动赋值运算符被定义为delete的。

  移动操作和合成的拷贝控制成员间还有一个最后一个相互关系:一个类是否定义了自己的移动操作对拷贝操作如何合成有影响。即,如果一个类定义了一个移动构造函数和/或一个移动赋值运算符,则该类的合成拷贝构造函数和拷贝赋值运算符将被定义为delete的。即,定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作,否则拷贝操作将默认被定义为delete的。

移动右值,拷贝左值,但如果没有移动构造函数,右值也被拷贝

  移动操作接受一个右值引用,因此只能用于实参是(非static)右值的情形:

StrVec v1, v2;
v1 = v2;    //拷贝赋值:因为v2是左值
StrVec getVec(istream &);    //返回右值
v2 = getVec( cin );    //移动赋值:getVec(cin)返回右值

  对于第二个赋值的情况,两个赋值运算符都是可行的。调用拷贝赋值运算符要进行一次到const的转换,而StrVec&&精准匹配。

class HasPtr{
public:
	HasPtr(HasPtr &&p) noexcept: ps(p.ps), i(p.i) { p.ps = nullptr;}
	HasPtr &operator=(HasPtr rhs)
	{
		swap( *this, rhs);
		return *this;
	}
    //……
};
hp = hp2;	//拷贝构造函数拷贝:hp2是左值
hp = std::move(hp2);	// 移动构造函数移动hp2

  赋值运算符有一个非引用参数,意味此参数要进行拷贝初始化。依赖实参的类型,拷贝初始要么使用拷贝构造函数要么使用移动构造函数(左值拷贝,右值移动)。因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能。

  三/五法则

  三(拷贝构造函数、拷贝赋值运算符、析构函数)

  五(拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数)

  所有五个拷贝控制成员应该看作一个整体:一般来说,如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作。

移动迭代器

  新标准库汇总定义了一种移动迭代器(move iterator)适配器。一个移动迭代器通过改变给定迭代器的解引用运算符的行为来适配此迭代器,与其他迭代器不同的是移动迭代器的解引用运算符生成一个右值引用。

  可以通过调用标准库的make_move_iterator函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。

void StrVec::reallocate()
{
    auto newcapacity = size()? 2*size(): 1;
    auto first = alloc.allocate(newcapacity);
    auto last = uninitialized_copy( make_move_iterator( begin() ),
                                    make_move_iterator( end() ),
                                    first);
    free();
    elements = first;
    first_free = last;
    cap = elements + newcapacity;
}

  此算法使用迭代器的解引用运算符从输入序列中提取元素。由于传递的是移动迭代器,一次解引用运算符生成一个右值引用,意味construct将使用移动构造函数来构造元素。

右值引用和成员函数

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( first_free++, std::move(s));
}

StrVec vec;
string s = "some string or another";
vec.push_back( s);    //push_back(const string &)
vec.push_back( "done");    //push_back( string &&)

  通常我们在一个对象上调用成员函数,不管对象是左值还是右值,但由于老标准库允许向右值赋值:

s1 + s2 = "wow";

为了维持向后兼容性,新的标准库仍允许向右值赋值。但我们希望在自己的类中阻止这种用法,可以强制左侧运算符对象(即,this指向的对象)是一个左值,即通过在参数列表后放置一个引用限定符

class Foo{
public:
    Foo &opretor=(const Foo &) &;
};

Foo& Foo::operator=(const Foo &rhs) &
{
    return *this;
}

  同时使用const限定符和引用限定符时,const限定符必须在前。而且可以综合使用引用限定符和const来区分一个成员函数的重载版本

重载和引用函数

  我们定义const成员函数时,可以定义两个版本,唯一的差别是一个版本有const限定而另一个没有。

  但如果定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加(即,如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符):

class Foo{
public:
	Foo sorted() &&;
	Foo sorted() const;    //错误:必须加上引用限定符,&或&&
	
	using Comp = bool(const int&, const int&);
	Foo sorted( Comp*);    //正确:不同参数列表
	Foo sorted( Comp*)  const;    //正确:两个版本都没有引用限定符

};

猜你喜欢

转载自blog.csdn.net/accumulating_mocai/article/details/83273779