c++:类拷贝控制 - 拷贝构造函数 & 拷贝赋值运算符

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_19923217/article/details/82260456

一、拷贝控制

当定义一个类时,我们可以显式或隐式地指定此类型的对象拷贝、移动、赋值和销毁时做什么。

一个类可以通过定义五种特殊的成员函数来控制这些操作,包括:++拷贝构造函数++、++拷贝赋值函数++、++移动构造函数++、++移动复制函数++和++析构函数++。我们称这些操作为拷贝控制操作

  • 拷贝构造函数和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。
  • 拷贝赋值运算符和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。
  • 析构函数定义了当此类型对象销毁时做什么。
class Foo {
    Foo()
    Foo(const Foo&); // 拷贝构造函数
    Foo(const Foo&&); // 移动构造函数

    Foo& operator=(const Foo&); // 拷贝赋值运算符
    Foo& operator=(const Foo&&); // 移动赋值运算符

    ~Foo(); // 析构函数
    ...
}

如果一个类没有定义所有这个拷贝控制成员,编译器会自动为它定义缺失的操作。

本篇主要介绍最基本的《拷贝构造函数》和《拷贝赋值运算符》及其使用过程中需要注意的地方。

二、拷贝构造函数

2.1、什么是拷贝构造函数?

==定义:如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。==

class Foo {
public:
    Foo();          // 默认构造函数
    Foo(const Foo&) // 拷贝构造函数
}

拷贝构造函数用同类型的另一个对象初始化本对象,完成从对象之间的 复制过程

2.2、合成拷贝构造函数

如果我们没有为一个类定义拷贝构造函数,编译器会为我们默认定义一个。

通常称缺省的拷贝构造函数称为:合成拷贝构造函数。也可以按我们的习惯称之为默认拷贝构造函数。

合成的拷贝构造函数会从给定对象中依次将每个非 static 成员拷贝到正在创建的对象中。

每个成员的类型决定了它如何拷贝:
1. 对类类型的成员,会使用其拷贝构造函数来拷贝
2. 其他内置类型成员直接拷贝

以一个例子进行说明:

class Foo {
public:
    Foo();          // 默认构造函数
    Foo(const Foo&) // 拷贝构造函数

private:
    std::string str;
    int num;
}

// 与 Foo 的合成拷贝函数等价:
Foo::Foo(const Foo &foo) {
    this->str = foo.str; // 使用 string 的拷贝构造函数
    this->num = foo.num; // 直接拷贝 foo.num
}
–> 一定需要重写拷贝构造函数的情况

成员类型的拷贝需要特别强调一点:默认的合成构造函数对于指针类型,使用的是位拷贝!

位拷贝拷贝地址,值拷贝拷贝内容

试想一下,当成员的类型包含指针的情况:

class Foo {
public:
    Foo();          // 默认构造函数
    Foo(const Foo&) // 拷贝构造函数

    char *data;
}

定义 Foo 对象 A 与 B,此时 A.data 与 B.data 分别指向一段内存区域,进行如下赋值操作:

Foo A;
A.data = "hello";

Foo B(A); // 拷贝构造函数
B.data = "world";  // B.data = A.data = "world"

Foo B(A) 调用默认拷贝构造函数,对于指针类型,编译器会默认进行位拷贝(也就是浅拷贝),拷贝指针的地址 —— B.data = A.data,这样 A.data 与 B.data 就指向了同一块内存区域,因此 A.data 的内容也变成 world。

这样可能导致的问题:
1. A.data 和 B.data 指向同一块区域,任何一方改变都会影响另一方。
2. 当对象析构时,B.data 被释放两次

因此,当类成员包含指针类型,一定要重写拷贝构造函数或者拷贝赋值函数,对指针类型自定义实现值拷贝:

Foo::Foo(const Foo &foo) {
    data = new char(sizeof(foo.data));
    memcpy(data, B.data, sizeof(B.data));
}

2.3、拷贝构造函数的调用时机

(1) 对象以值传递的方式传入函数参数

class Foo
{
public:
    Foo(int a);
    Foo(const Foo&);
    ~Foo();

    int a;
};

// 构造函数
Foo::Foo(int a)
{
    this->a = a;
    std::cout << "-> create" << std::endl;
}

// 析构函数
Foo::~Foo()
{
    std::cout << "-> delete" << std::endl;
}

// 拷贝构造函数
Foo::Foo(const Foo &foo)
{
    this->a = foo.a;
    std::cout << "-> copy" << std::endl;
}

void g_fun(Foo foo)
{
}

int main()  
{  
    Foo foo(1);  
    g_fun(foo);  

    return 0;  
} 

输出如下:

-> create
-> copy
-> delete

当调用 g_fun 函数,编译器会偷偷做比较重要的几个步骤:
1. foo 对象传入函数时,产生一个临时变量 C
2. 调用拷贝构造函数使用 foo 对象初始化 C
3. 等 g_fun 方法执行完成后,析构掉 C

(2) 对象以值传递的方式从函数返回

class Foo
{
public:
    Foo(int a);
    Foo(const Foo&);
    ~Foo();

    int a;
};

// 构造函数
Foo::Foo(int a)
{
    this->a = a;
    std::cout << "-> create" << std::endl;
}

// 析构函数
Foo::~Foo()
{
    std::cout << "-> delete" << std::endl;
}

// 拷贝构造函数
Foo::Foo(const Foo &foo)
{
    this->a = foo.a;
    std::cout << "-> copy" << std::endl;
}

foo g_fun()
{
    Foo foo(1);
    return foo;
}

int main()  
{  
    g_fun();  

    return 0;  
} 

当 g_Fun() 函数执行到 return 时,会产生以下几个重要步骤:
1. 生成临时变量 C
2. 调用拷贝构造函数使用 foo 对象初始化 C
3. 函数执行到最后析构局部变量 foo
4. 函数调用结束析构临时变量 C

(3) 对象需要通过另外一个对象进行初始化

Foo foo(1); // 直接初始化
Foo foo1(foo); // 拷贝构造函数
Foo foo2 = foo; // 拷贝构造函数

(4) 标准库容器

特别的,c++ 标准库容器会对它们所分配的对象进行拷贝初始化,如:对容器类调用其 insert 或 push 成员时,对其元素进行拷贝初始化。而用 emplace 成员创建的函数都是进行直接初始化。

容器类应尽可能使用 emplace

容器类调用 insert 或 push 成员插入元素,会涉及到两次构造函数的调用,一是初始化对象时,二是插入时触发拷贝构造。这样会造成不必要的资源浪费。

c++11 标准中引入了 emplace,如 vector 容器的 emplace、emplace_back,类似于 insert,但是由于直接初始化,只需构造一次就可以了。

2.4 阻止拷贝构造函数发生

大多数类应该定义拷贝构造函数合拷贝赋值运算符,但对于某些类,这些操作没有合理的意义。在此情况下必须采取某种机制阻止拷贝或赋值。如,iostream 类阻止了拷贝,以避免多个对象写入或读取相同的 IO 缓冲。

在 c++11 新标准下,可以通过将拷贝构造函数和拷贝赋值运算符定义为 delete 来阻止拷贝。如:

class Foo
{
public:
    Foo(int a);

    Foo(const Foo&) = delete;
    Foo& operator=(const Foo&) = delete;
};

= delete 通知编译器该控制成员是删除的成员,我们不希望定义这些成员。

析构函数不能是删除的成员,我们不能删除析构函数

三、拷贝赋值运算符

区分 拷贝构造函数 & 拷贝赋值运算符

拷贝构造函数和赋值运算符的行为比较相似,都是将一个对象的值复制给另一个对象;但是其结果却有些不同,拷贝构造函数使用传入对象的值生成一个新的对象的实例,而赋值运算符是将对象的值复制给一个已经存在的实例。

这种区别从两者的名字也可以很轻易的分辨出来,拷贝构造函数也是一种构造函数,那么它的功能就是创建一个新的对象实例;赋值运算符是执行某种运算,将一个对象的值复制给另一个对象(已经存在的)。

调用的是拷贝构造函数还是赋值运算符,主要是看是否有新的对象实例产生。==如果产生了新的对象实例,那调用的就是拷贝构造函数;如果没有,那就是对已有的对象赋值,调用的是赋值运算符。==

Foo foo(10);
Foo foo1(20);

foo = foo1;     // 拷贝赋值运算符
Foo foo2(foo);  // 拷贝构造函数

重载赋值运算符

拷贝赋值运算符基于重载运算符,本质上也是函数,其名字由 operator 关键字后接要定义的运算符的符号组成。

为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。

Foo& operator=(const Foo&);

合成拷贝赋值运算符

与默认的 合成构造函数 一样,如果一个类未定义自己的拷贝赋值运算符,编译器会默认生成一个 合成拷贝赋值运算符

合成拷贝赋值运算符会将右侧对象的每个非 static 成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符完成的。对于数组类型成员,逐个赋值数组元素。

// 与 Foo 的拷贝赋值运算符等价:
Foo& Foo::operator=(const Foo &foo) {
    this->str = foo.str; // 使用 string::operator=
    this->num = foo.num; // 直接内置的 int 赋值
}

猜你喜欢

转载自blog.csdn.net/qq_19923217/article/details/82260456