拷贝控制操作 由 拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符、析构函数组成。
其中:
拷贝构造函数和移动构造函数 定义了当用同一个类型的另一个对象初始化本对象时做什么。
拷贝赋值运算符和移动赋值运算符 定义了将一个同类型对象赋值给另一个本对象时的操作。
析构函数定义了当此对象销毁时做什么
当不写时,编译器会默认生成。
拷贝、赋值与销毁
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。并且因为拷贝构造函数常被隐式使用,所以其不应是explicit的
合成拷贝构造函数
一般来说,编译器合成拷贝构造函数会将对象中每个非static成员拷贝到正在创建的对象中。
对于类类型成员会调用其对应的拷贝构造函数来拷贝,内置类型会直接拷贝,数组类型会逐个拷贝。
拷贝初始化
一般来说,初始化时用到了等号的都是拷贝初始化,直接括号的是直接初始化。但是其与调用什么函数无关。
class CopyConstruct {
private:
string str;
int val;
public:
CopyConstruct() : val(-1), str("default") {
printf("调用了默认构造函数\n");
show();
}
CopyConstruct(int v, string s) : val(v), str(s) {
printf("调用了默认构造函数\n");
show();
}
CopyConstruct(int v) : val(v), str("default") {
printf("调用了默认构造函数\n");
show();
}
CopyConstruct(const CopyConstruct &origin) : val(origin.val), str(origin.str) {
printf("调用了拷贝构造函数\n");
show();
}
CopyConstruct &operator=(const CopyConstruct &origin){
val = origin.val;
str = origin.str;
printf("调用了拷贝赋值\n");
show();
}
void show() {
printf("%d %s\n\n", val, str.c_str());
}
};
int main() {
system("chcp 65001");
// 直接初始化,调用构造函数
CopyConstruct copyConstruct(0, "A");
// 直接初始化,但调用拷贝构造函数
CopyConstruct copyConstruct1(copyConstruct);
// 拷贝初始化,调用拷贝构造函数
CopyConstruct copyConstruct2 = copyConstruct;
// 拷贝初始化,调用构造函数
CopyConstruct copyConstruct3 = CopyConstruct(2, "c");
// 拷贝初始化,调用构造函数
CopyConstruct copyConstruct4 = 1;
}
/*
调用了默认构造函数
0 A
调用了拷贝构造函数
0 A
调用了拷贝构造函数
0 A
调用了默认构造函数
2 c
调用了默认构造函数
1 default
*/
编译器可以跳过拷贝构造函数
在拷贝初始化过程中,编译器可以跳过拷贝初始化,直接创建对象,但是拷贝构造函数必须是可以访问的。
比如上面的
// 拷贝初始化,调用拷贝构造函数
CopyConstruct copyConstruct2 = copyConstruct;
// 拷贝初始化,调用构造函数
CopyConstruct copyConstruct3 = CopyConstruct(2, "c");
// 拷贝初始化,调用构造函数
CopyConstruct copyConstruct4 = 1;
}
里面编译器做了如 CopyConstruct object(items);
的优化。
但是拷贝构造函数必须是可访问的,不能CopyConstruct(const CopyConstruct &) = delete;
或者为private。
拷贝初始化的参数和返回值
面试时的考点:为什么拷贝构造函数的第一个参数需要是引用呢 因为如果不是引用类型,那么传递非引用参数时,会需要拷贝一个副本,这时候就需要用到拷贝初始化,如果不是引用类型,那么就会发生矛盾。
拷贝赋值运算符
如上面的例子,以下声明等价于合成拷贝赋值运算符
CopyConstruct& operator=(const CopyConstruct& ori){
val = ori.val;
str = ori.str;
}
注意其不支持列表初始化
析构函数
注意其不能被声明为虚函数,原因是运行时多态机制,为了当用父类的指针指向子类new实例后,删除指针时,使子类申请的资源也会被释放,而不仅释放父类资源。以防止内存泄漏,如下:
struct Base1 {
public:
Base1() {
cout << "Construct Base" << endl;
}
~Base1() {
cout << "Delete Base" << endl;
}
};
struct AA : public Base1 {
public:
AA() {
cout << "Construct AA" << endl;
}
~AA() {
cout << "Delete AA" << endl;
}
};
int main() {
Base1 *p = new AA();
delete p;
}
/*
Construct Base
Construct AA
Delete Base
*/
因为其类型不是虚函数,所以直接执行其类型(Base)的函数。
若改为virtual ~Base1();
, 其就会查询虚函数表,先执行AA的函数,然后再执行Base的
/*
Construct Base
Construct AA
Delete AA
Delete Base
*/
三五法则
需要自定义析构函数的类一般需要拷贝构造函数和赋值运算符
需要拷贝操作的类也需要赋值操作
阻止拷贝
声明为=delete
或 private
(但友元函数和友元类仍然可以调用)
拷贝控制和资源管理
// 值行为的类,各自独享数据
class ValueClass {
public:
string *ps;
int i;
ValueClass(const string &s = string()) : ps(new string(s)), i(0) {}
ValueClass(const ValueClass &origin) : ps(new string(*origin.ps)), i(origin.i) {}
/* 赋值运算符关键:
* 能够赋值给自身
* 一般组合了析构函数和拷贝构造函数的工作
* */
ValueClass &operator=(const ValueClass &origin) {
printf("Call = \n");
// 为什么需要一个中间指针?
// 如果没有这一步,直接delete然后 ps = new string(*origin.ps);
// 如果是自赋值,那么显然会访问已释放内存,产生错误
string *newp = new string(*origin.ps);
// 释放内存,防止内存泄漏
delete ps;
ps = newp;
i = origin.i;
return *this;
}
~ValueClass() {
printf("Call destructor\n");
delete ps;
}
void show() {
printf("%d %s\n", i, (*ps).c_str());
}
};
// 指针行为的类(共享底层数据)
class PointerClass {
public:
string *ps;
int i;
// 为什么引用计数要是指针? 因为要在不同的类间共享其值
size_t *use;
PointerClass(const string &s = string()) : ps(new string(s)), i(0), use(new size_t(1)) {}
~PointerClass() {
if (--*use == 0) {
delete use;
delete ps;
}
}
PointerClass(const PointerClass &origin) : ps(origin.ps), i(origin.i), use(origin.use) {
++*use;
}
PointerClass &operator=(const PointerClass &origin) {
// 错误,过不了自赋值测试
// ps = origin.ps;
// i = origin.i;
// use = origin.use;
// ++*use;
// return *this;
++*origin.use;
if (--*use == 0) {
delete use;
delete ps;
}
ps = origin.ps;
i = origin.i;
use = origin.use;
return *this;
}
void show() {
printf("%d %s\n", i, (*ps).c_str());
}
};
int main() {
ValueClass v1("Hello");
ValueClass v2 = v1;
printf("address : v1.str->%x v2.str->%x\n", v1.ps, v2.ps);
v2.ps = new string("Change");
v1.show();
v2.show();
cout << "-----------------" << endl;
PointerClass p1("AA");
PointerClass p2 = p1;
printf("%d - %d\n", *p1.use, *p2.use);
printf("address : p1.str->%x p2.str->%x\n", p1.ps, p2.ps);
*p2.ps = "Change";
p1.show();
p2.show();
}
/*
address : v1.str->b47c18d0 v2.str->b47c1c40
0 Hello
0 Change
-----------------
2 - 2
address : p1.str->b47c5f80 p2.str->b47c5f80
0 Change
0 Change
Call destructor
Call destructor
*/
交换操作
在与重排元素顺序的算法一起使用的类,定义swap是比较重要的,算法在需要交换两个元素时调用swap。如果没有定义,默认调用标准库的swap。对于自定义类型,为了减少swap时类似
A t = a;
a = b;
b = t;
时多次发生的拷贝复制以及临时对象生成,一般可以通过自定义swap来优化
class swapTest {
private:
friend void swap(swapTest &, swapTest &);
int *p;
int val;
public:
int getVal() { return val; }
swapTest(int v = 0, int *q = new int(-1)) : val(v), p(q) {}
// 注意:不是引用
swapTest &operator=(swapTest ori) {
swap(*this, ori);
return *this;
}
void show() {
printf("%d %d", val, *p);
}
};
inline void swap(swapTest &a, swapTest &b) {
printf("Call self swap\n");
using std::swap;
swap(a.val, b.val);
swap(a.p, b.p);
}
int main() {
swapTest a(1, new int(2));
swapTest b;
b = a;
b.show();
}
/*
Call self swap
1 2
*/
swap与operator=
上面讲到过,当写拷贝赋值函数时,为了考虑周全,需要①保证自我赋值安全②一般会综合析构操作和赋值操作,比较繁复
可以这么定义赋值函数:
A& operator=(A ori){
swap(*this, ori);
return *this;
}
这样就可以完成拷贝赋值操作。其中ori需要声明为值传递,这样的话可以自动的在函数完成后析构。并且可以正确处理自赋值。并且自动是异常安全的。
应用——自定义NodeVec
NodeVec.h
#ifndef UNTITLED_NODEVEC_H
#define UNTITLED_NODEVEC_H
#include "Node.h"
#include<memory>
#include<utility>
class NodeVec {
private:
static std::allocator<Node> alloc;
Node *elements, *first_free, *cap; // 指向数组首元素、第一个空闲元素、数组尾后
// 释放内存
void free();
// 获得更多内存
void reallocate();
// 工具函数,被拷贝构造函数,拷贝赋值运算符、析构函数使用
std::pair<Node *, Node *> alloc_n_copy(const Node *, const Node *);
void chk_n_alloc() { if (size() == capacity()) reallocate(); }
public:
size_t size() { return first_free - elements; }
size_t capacity() { return cap - elements; }
Node *begin() const { return elements; }
Node *end() const { return first_free; }
void push_back(const Node &);
// allocator会被默认初始化
NodeVec() : elements(nullptr), first_free(nullptr), cap(nullptr) {}
NodeVec &operator=(const NodeVec &);
NodeVec(const NodeVec &);
~NodeVec();
};
std::allocator<Node> NodeVec::alloc;
void NodeVec::push_back(const Node &s) {
chk_n_alloc();
alloc.construct(first_free++, s);
}
std::pair<Node *, Node *> NodeVec::alloc_n_copy(const Node *b, const Node *e) {
auto data = alloc.allocate(e - b);
return {data, std::uninitialized_copy(b, e, data)};
}
void NodeVec::free() {
if (elements) {
// 调用destroy,会挨个调用Node的析构函数,来释放内存
for (auto i = first_free; i != elements;) alloc.destroy(--i);
// 释放本NodeVec分配的内存,因为deallocate的指针必须是之前allocate调用返回的指针,所以先检查其是否为空
alloc.deallocate(elements, cap - elements);
}
}
NodeVec::NodeVec(const NodeVec &s) {
auto pair = alloc_n_copy(s.begin(), s.end());
elements = pair.first;
first_free = cap = pair.second;
}
NodeVec::~NodeVec() {
free();
}
NodeVec &NodeVec::operator=(const NodeVec &s) {
if (this != &s) {
auto data = alloc_n_copy(s.begin(), s.end());
free();
elements = data.first;
first_free = cap = data.second;
}
return *this;
}
// reallocate 的工作:
// 构造一个更大的内存空间(一般double)
// 在内存空间的前半部分构造对象保存现有元素
// 销毁原来的内存空间
void NodeVec::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;
}
#endif
测试代码
#include "NodeVec.h"
int main() {
Node a(1);
Node b(2, "b");
Node c(3, "c");
Node d(4, "d");
Node e(5, "e");
NodeVec v;
printf("NodeVec size %d capacity %d\n", v.size(), v.capacity());
v.push_back(a);
printf("NodeVec size %d capacity %d\n", v.size(), v.capacity());
v.push_back(b);
printf("NodeVec size %d capacity %d\n", v.size(), v.capacity());
v.push_back(c);
printf("NodeVec size %d capacity %d\n", v.size(), v.capacity());
v.push_back(d);
printf("NodeVec size %d capacity %d\n", v.size(), v.capacity());
v.push_back(e);
printf("NodeVec size %d capacity %d\n", v.size(), v.capacity());
}
/*
NodeVec size 0 capacity 0
call copy construct
NodeVec size 1 capacity 1
call copy construct
Release default 1
call copy construct
NodeVec size 2 capacity 2
call copy construct
call copy construct
Release b 2
Release default 1
call copy construct
NodeVec size 3 capacity 4
call copy construct
NodeVec size 4 capacity 4
call copy construct
call copy construct
call copy construct
call copy construct
Release d 4
Release c 3
Release b 2
Release default 1
call copy construct
NodeVec size 5 capacity 8
*/
对象移动
标准库容器、string、shared_ptr类既支持移动也支持拷贝
unique_ptr类和IO类只支持移动不能拷贝
右值引用
右值引用是必须绑定到右值的引用。我们通过&&来获得右值引用,右值引用只能绑定到一个将要销毁的对象。
左值引用 就是常规引用,一帮用来绑定左值
右值引用 可以将右值引用绑定到表达式、字面常量或者是返回右值的表达式上,不能直接绑定到一个左值上。
int a = 1;
int &r = a;
// int &&rr = a; // 右值引用不能绑定左值
// int &r2 = a * 2; // 左值引用不能绑定右值
const int &r3 = a * 2; // const引用可以绑定右值
int &&rr2 = a * 2;
左值恒久,右值短暂
左值一般都是指向特定的对象,左值因此有持久的状态。
右值一般是字面常量或者是表达式求值中的临时对象。
右值引用的特性
- 该引用的对象将要被销毁
- 该对象没有其他用户
因此使用右值引用的代码可以自由地接管所引用对象的资源。
std::move
虽然不可以将左值直接地绑定到右值引用上,但是通过move可以显式地将左值转换为对应的右值引用类型。
int a = 1;
// int &&r = a; // error
int &&r = std::move(a);
调用move后,我们不对移后源对象地值做任何假设。我们可以销毁一个移后源对象,也可以赋予其新值,但是不可以使用其值。
移动构造函数和移动赋值运算符
NodeVec::NodeVec(NodeVec &&ori) noexcept: elements(ori.elements), first_free(ori.first_free), cap(ori.cap) {
printf("Use move construct\n");
ori.elements = ori.first_free = ori.cap = nullptr;
}
NodeVec &NodeVec::operator=(NodeVec &&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;
}
/Node Class//
Node(const Node &ori) {
printf("call copy construct\n");
val = ori.val;
s = ori.s;
}
Node(Node &&ori) : val(ori.val), s(ori.s) {
printf("call move construct\n");
ori.val = -1;
ori.s = "nullptr";
}
Node &operator=(Node &&ori) {
printf("call move =\n");
if (this != &ori) {
val = ori.val;
s = ori.s;
}
return *this;
}
/
//test///
int main(){
Node a(1, "A");
a.show();
// 调用移动构造函数
Node b = std::move(a);
b.show();
Node c;
// 调用移动赋值运算符
c = std::move(a);
c.show();
}
/*
A 1
call move construct
A 1 ··· b会构建一个自己的内存空间
call move =
nullptr -1 ··· 刚刚改变了a的地址空间
Release nullptr -1
Release A 1
Release nullptr -1
*/
定义以上两个成员后,类支持移动操作,可以从给定对象移动资源。
除了完成资源移动,还需要保证源对象是销毁无害的。当资源完成移动,源对象必须不再指向被移动的资源——这些资源的归属权已经是新对象的了。
移动构造函数不分配任何新内存,只是接管源对象的内存,可以声明noexcept
来告知编译器该函数不会产生异常。
不抛出异常的移动构造函数必须标记为noexcept
从一个对象移动数据并不会销毁此对象,但是要保证源对象可以在后续被安全销毁。
编译器一般不会为自定义类合成移动构造函数和移动赋值运算符,此时类会使用对应的拷贝操作来代替移动操作。
当一个类没有定义任何拷贝控制成员,且每个非static数据成员都可以移动时,编译器才会为其合成运动构造函数或移动赋值运算符。
如果我们显式地要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。
这样写赋值运算符既可以实现拷贝赋值运算也可以实现移动赋值运算。
Node& operator=(Node ori){
swap(*this,ori);
return *this;
}
a = b; // 调用拷贝构造函数来拷贝
c = std::move(b); // 调用移动构造函数(如果有的话)移动
右值引用和成员函数
如果我们定义两个或者两个以上的具有同名或者相同参数列表的成员函数,必须对所有函数加上引用限定符或者都不加。
class Base {
private:
int a;
public:
Base(int v) : a(v) {}
void show() const &&{
cout << "R " << a << endl;
}
void show() const &{
cout << "L " << a << endl;
}
};
int main() {
Base a(-1);
a.show();
std::move(a).show();
}
/*
L -1
R -1
*/