拷贝控制


持续更新,补充C++新增feature,目录结构遵循《C++ Primer》

C++中可以定义五种函数来控制对象的拷贝,分别是

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

拷贝构造函数

拷贝构造函数是C++类的一个标配,即使不显式定义,编译器也会提供一个默认的合成拷贝构造函数,其定义如下

class Foo{
	...
	Foo(const Foo& f); //拷贝构造
	...
};

默认拷贝函数的行为是浅拷贝,对象成员通过拷贝构造函数拷贝,如果有数组成员则拷贝数组中的元素。

在C++中,拷贝不仅仅在=时发生,下面三种情况均会发生拷贝

  1. 对象作为实参传递给非引用类型的形参
  2. 返回一个非引用类型的对象
  3. 使用{}初始化数组中的元素,或者初始化聚合类
struct Foo{
	string s;
	int x;
};
Foo f = {"foo",100} //copy

值得注意的是,拷贝构造函数可能会被编译器优化为普通构造函数

string null_boook = "9-999-9999-99"; //拷贝构造
//编译器优化为普通构造函数
string null_book("9-999-9999-99"); 

拷贝赋值运算

拷贝赋值运算依赖类重载=运算符,如果一个类重载了=,相当于为自己添加了一个operator=的函数

class Foo{
	...
	Foo& operator=(const Foo& f){
		
		//...
		
		return *this;
	}
};

如果没有显式定义拷贝赋值运算符,编译器会自动生成一个,其行为和拷贝构造函数一致,如果不想提供拷贝赋值运算,则需要使用delete关键字进行显式声明

Foo& operator=(const Foo& f) = delete

如果一个类需要定义赋值拷贝运算符,那么它一定也需要定义拷贝构造函数,反之亦然

析构函数

无论何时,一个对象被销毁就会自动调用其析构函数

  1. 变量在离开作用域时被销毁
  2. 当一个对象被销毁时,其成员被销毁
  3. 容器(标准库或数组)被销毁时,其元素被销毁
  4. 对于动态内存对象,等被delete时,该对象被销毁
  5. 对于临时对象,当创建它的完整表达式结束时被销毁

在C++中,析构函数通常用来释放内存,delete指针,因此,如果一个类定需要析构函数来释放资源,那么它同样需要定义拷贝构造函数和赋值拷贝运算符来实现对指针的深拷贝

使用=default=delete

如果想要使用默认的拷贝行为,又想显式的声明这些函数,那么可以使用=default

class Foo{
	Foo() =default;
	Foo(const Foo& f) =default;
	Foo& operator=(const Foo& f);
	~Foo()=default;
};
Foo& Foo::operator=(const Foo& f) =default;

如果在类内部声明了=default则编译器会将默认实现在类内部展开(内联),如果不想在类内部展开,则可以在类外部声明=default。需要注意的是,=default只能用于编译器默认提供的函数,对于一般的成员函数,不能使用。

=default相对的是=delete=delete允许类禁用某个函数

class Foo{
	Foo() =default;
	Foo(const Foo& f) =delete; //禁止拷贝
	Foo& operator=(const Foo& f); //禁止赋值
	~Foo()=default;
};

=default不同的是, =delete允许声明除析构函数外的任何函数,这个特点在某些情况下对实现函数重载很有帮助。

深拷贝

下面给出一个深拷贝的例子

class HasPtr {
public:
	//constructor
 	HasPtr(const std::string &s = std::string()): ps(new std::string(s)), i(0) { }
 
	//copy constructor, deep copy
 	HasPtr(const HasPtr &p): ps(new std::string(*p.ps)), i(p.i) { } 

	 //copy-assign operator, deep copy
	HasPtr& operator=(const HasPtr &){
		auto newp = new string(*rhs.ps); 
 		delete ps; // free the old memory
 		ps = newp; // copy data from rhs into this object
 		i = rhs.i;
 		return *this; // return this object
	} 
	//destructor
	~HasPtr() { delete ps; }
private:
 std::string *ps; int i;
};

在上面深拷贝的例子中,需要特别注意一点的是operator=中的逻辑,一定是先从待拷贝对象身上new一个新的对象后再delete自己管理的指针。如果先delete掉自己的指针,那么自己给自己赋值时,this将会被释放,*rhs.p会报错。

如果不想自己管理指针的释放,则建议使用shared_ptrshared_ptr在被拷贝时会拷贝其所指向的指针(浅拷贝),shared_ptr类自己维护指针的引用计数,使我们不需要担心拷贝的问题。

移动内存

设想下面这个场景,有一个vector<string>类型的数组,每次push一个对象进入后,vector内部要check其容量是否已经达到上限,如果已经达到上限,则要进行内存的再分配:

void vector<string>::push_back(const string& s){
    chk_n_alloc();
    alloc.construct(first_free++,s);
};

其中chk_n_alloc()用来做内存检查,其实现如下:

if (size() == capacity()){
	reallocate(); //重新分配内存
}

这时我们可以思考一下,reallocate()函数应该做什么,不难想到,它应该完成以下三个任务

  1. 为一个新的,更大的string数组分配内存
  2. 在内存空间的前一分部拷贝已有的元素进去
  3. 销毁原内存空间中的元素,并释放这块内存

观察上述步骤可以看出,其后两步存在一些冗余,即要拷贝原对象到新的内存空间,之后还要释放原对象的内存。这在数组数量大的时候开销是很大的,如果我们可以将原来的对象直接移动到新开辟的内存而不是拷贝,那么后两步则可以省略,效率将可以大大提升。在C++ 11中引入了两种机制,分别是移动构造函数和std::move()函数。

包括string在内的所有标准库类都定义了所谓的”移动构造函数”,由于实现细节尚未公开,但是我们够确定的有两点,一是移动构造函数将“资源”移动给了目标对象而不是拷贝,二是移动后的string对象仍然是一个有效的,可被析构的对象。

std::move定义在utility的头文件中,目前关于move我们只需要关注两点,一是在reallocate函数中我们要用std::move函数告诉string使用移动构造函数,第二点是在使用std::move时,我们通常保留命名空间

void StrVec::reallocate(){
    auto newsize = size()? size()*2 : 1;
    //alloc new space
    auto newdata = alloc.allocate(newsize);
    auto begin_new = newdata;
    auto begin_old = elements;
    for(int i=0;i<size();++i){
        alloc.construct(begin_new,std::move(*begin_old));
        begin_new++;
        begin_old++;
    }
	free()
    elements = newdata;
    first_free = begin_new;
    cap = elements + newsize;
}

上面代码中,我们使用std::movebegin_old中指向的string对象逐个”移动”到了新的内存区域,随后释放了原先的内存空间,但被移动后string对象内存仍是有效的。

右值引用

为了支持移动操作,C++11引入了右值引用(rvalue reference)的概念,语法上用&&表示,右值引用有一个重要的性质是它只能绑定到即将销毁的对象上,因此,我们可以自由的将一个右值资源移动到另一个对象中。

回忆前面的文章可知,等号左右均可使用表达式进行求值,得到的结果分别为左值和右值,其中左值表示一个对象的身份,右值往往是一个,所谓右值引用就是对这个值的引用

所谓左值引用,也就是旧标准中引用,它作用在等号左边,是等号左边表达式的求值结果,左值引用只能和一个确定的值进行绑定(即引用必须初始化),无法和一个表达式进行绑定:

int i=42;
int &r = i; //正确,i是左值
int &r = i*2; //错误,等号右边是一个表达式,是一个“值“

相反的,右值引用,是指等号右边的表达式求值结果,引用的是一个值,它可以绑定到某个表达式上,但却不能和左值绑定

int i=42;
int &t = i;
int &&r = i*2; //正确,r绑定到表达式上
int &&r = t; //错误,右值不能绑定到左值上
Test &&r = Test(); //正确,等号右侧是一个匿名表达式

对于左值和右值的另一个判断方法是左值可以进行取地址操作,右值则不可以。为了加深理解,我们看一个经典的例子:

int value = 0;
int *p1 = &++value; //正确
int *p2 = &value++; //错误

这个例子中,p1绑定的是一个左值,因此可以进行取地址操作,++value实际上就是对value自身的值加1,而对于p2,则会编译出错,编译器给出的错误是error: cannot take the address of an rvalue of type 'int',这说明此时value为右值,实际上,p2绑定的并不是value,而是一个临时变量,我们可以将上述代码展开,它等价于

int tmp = value; //1
int *p2 = &tmp;  //2
value = value +1;

这里为了理解方便,增加了一个tmp的变量,而实际编译过程中,1,2步是合在一起的,因此不存在tmp这个左值,只有一个临时的右值,p2绑定的是这个临时的右值,因此编译报错。

但是在有一种情况下,左值确实可以绑定到右值上,这种情况下,需要使用const关键字来修饰左值

Dummy func(){
	return Dummy(); //返回了一个临时变量作为右值
}
Dummy &ld = func(); //错误,左值引用不能绑定右值
const Dummy &ld = func(); //const左值医用可以绑定右值

另一个例子是拷贝构造函数,在本文开始的时候我们曾介绍了拷贝构造函数,结合右值的概念,我们可以重新理解一下

class Foo{
	Foo() =default;
	Foo(const Foo& f); //拷贝构造
};

Foo f2(Foo()); //正确

这段代码看似很简单,但实际上用到了上面提到的左右值绑定的特性,由于Foo的拷贝构造函数是一个const左值引用,因此它可以被绑定到一个右值上,而Foo()是一个表达式,其求值结果是一个临时变量,因此它是一个右值,可以被绑定到f上。

对比左值和右值可发现,左值有持久的状态,右值要么是字面常量,要么是表达式求值过程中创建的临时变量。由于右值引用只能绑定到临时对象,因此可知

  1. 右值所引用的对象将要被销毁
  2. 该对象没有其它使用者

这两个特性意味着,使用右值引用的代码可以自由地接管所引用对象的资源。

move函数

虽然不能直接的将一个右值引用绑定到一个左值上,但可以使用std::move显式的将一个左值转化成一个对应的右值引用类型

void func(string&& msg){
    cout<<msg<<endl; //abc
}

string ls = "abc";
func(std::move(ls));
cout<<ls<<endl; //abc

move调用告诉编译器,我们有一个左值,但我们希望像处理右值一样处理它。调用move就意味着,除了对ls赋值或者销毁之外,我们将不再使用它

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

和拷贝构造函数一样,移动构造函数的第一个参数是一个右值引用。它除了要完成资源移动,还要负责确保交出资源后的对象处于可销毁状态,使源资源不再指向被移动的资源。

class HasPtr{
private:
    string* ptr = nullptr;
    int i =0;
public:
	HasPtr(HasPtr&& other) noexcept:ptr(other.ptr),i(other.i){
		//release temp value
		//负责释放源资源
        other.ptr = nullptr;
    }
	HasPtr& operator=(HasPtr&& other) noexcept{
		if(this != &other){
			delete ptr;
			ptr = other.ptr;
			i = other.i;
			other.ptr = nullptr;
		}
		return *this;
	}
};
HasPtr get(){
	return HasPtr();
}

HasPtr ptr1("ptr1"); //普通构造函数
HasPtr ptr2(std::move(ptr1)); //移动构造函数
HasPtr ptr3 = std::move(ptr1); //错误,ptr1已经被释放
ptr3 = get(); //正确,移动赋值函数

上面代码可观察到,对于移动构造函数或者赋值运算,通常是不抛出异常,其原因比较复杂,这里不展开讨论。

对于移动构造函数和移动赋值运算符,编译器并不会自动生成,尤其是如果某个类已经实现了拷贝构造函数后,则编译器不会再为其生成默认的移动构造函数。只有当一个类没有定义任何自己版本的拷贝控制成员,且它所有的数据成员都能移动构造或者移动赋值时,编译器才会为其生成默认的移动构造函数或移动赋值函数。

如果一个类既定义了拷贝构造函数和移动构造函数,则会先匹配移动构造函数,其匹配的核心规则是const左值可以绑定右值,即const Foo& 等价于Foo&&。因此,如果传入的是右值,会先匹配右值控制函数,如果没有再去寻找左值拷贝控制函数。

右值引用和成员函数

右值引用不仅仅可以用于构造函数中,对于普通的成员函数也适用,对于成员函数,比较好的做法是同时提供两个版本,一个版本是的参数是左值,一个版本的参数是右值

void push_back(const T& val);
void push_back(T&& val);

vector<string> vec;
string s="123";
vec.push_back(s); //左值copy
vec.push_back("abc"); //右值移动

对于copy的版本,参数最好声明为const,因为我们不想在copy的过程中改变原对象,而对于右值的版本,由于我们需要负责释放源数据,因此不能将其声明为const

对于左值和右值,在C++中有时界限不是很明显,比如

string s1 = "a";
string s2 = "b";
auto n = (s1+s2).find('a');

上述代码中,我们对一个右值s1+s2进行了函数的调用,有时右值的使用方式也会让人困惑:

s1 + s2 = "ab"; 

上述代码可以正常编译,我们对s1+s2这个右值进行了赋值操作,这显然是不符合我们对右值的理解。这种向右赋值的方式在C++11之前是允许的,为了向后兼容,在新标准中,也保留了这个特性,但这显然不是一个很好的做法,我们希望被赋值的对象是一个左值。C++ 11提供了一种引用限定符reference qualifier)来标识某个函数是否可被右值调用

class Foo{
private:
	int x;
public:
	Foo(int d):x(d){} //构造函数
	Foo set(int d) &&; //该成员函数可被右值调用
	Foo set(int d) const & //该成员函数只能被左值调用
}
void Foo::set(int d) && {
	//该方法只会被右值调用,说明没有其他用户使用x,因此可以直接操作x
	x = d;
	return *this;
}
void Foo:set(int d) const &{
	//该方法有const修饰,不能直接改变x, 返回一个copy
	return Foo()
}

static Foo& retFooRef(){ //返回左值
	static Foo f(1);
	return f;
}
static Foo retFooVal(){ //返回右值
	return Foo(1);
}

如果一个成员函数是const,同时又只能被左值访问,那么&写在const之后。上面代码中,定义了两个set函数,一个可以被右值访问,用&&修饰,另一个只能被左值调用,用&修饰。编译器会自动根据对象是左值还是右值来匹配调用函数。

Foo::retFooRef().set(20).x; //20, 调用 const &版本
Foo::retFooVal().set(10).x; //10, 调用 && 版本

最后需要说明一点的是,如果一个类中某一个函数声明了&&&符号,其它和它同名的函数也要声明其引用限定类型。

Resources