🌶️拷贝、赋值与析构
2022-6-1
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property
 

 
C++ 的核心概念是类。C++ 类定义构造函数来控制当类对象初始化时应该做什么。类同样可以定义函数来控制如何进行拷贝、赋值、移动和销毁。在这些方面C++有别于其它语言,很多其它语言并不提供控制这些方面的基础设施。
 
类是如何控制当此类对象进行拷贝、赋值、移动和销毁时所做的事。类控制这些动作的成员函数分别是:
  • 拷贝构造函数(copy constructor)
  • 拷贝赋值操作符(copy-assignment operator)
  • 移动构造函数(move constructor)
  • 移动赋值操作符(move-assignment operator)
  • 析构函数(destructor)
拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么,拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么,析构函数定义了当此类型对象销毁时做什么。称这些操作为拷贝控制(copy control)
如果类没有定义拷贝控制成员函数,编译器会自动定义这些函数。所以很多类可以忽略拷贝控制成员函数。然而,对于某些类来说,依赖于默认定义的拷贝控制会出问题。通常,实现拷贝控制操作最难的部分就是意识到什么时候需要自己定义这些函数。
 

拷贝构造函数

定义对象A1时,通过一个已有的同类对象A0来初始化A1,这就是拷贝构造函数的作用。当没有显示定义拷贝构造函数时,编译器也会定义一个合成的拷贝构造函数。
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数
其第一个参数必须是引用类型,并且几乎总是const引用类型,尽管也可以使用非const引用。拷贝构造函数将隐式用于不少场景下,因此,拷贝构造函数不应该是explicit的。
 

合成拷贝构造函数

即使不定义拷贝构造函数,编译器也会为我们合成一个拷贝构造函数。与合成默认构造函数不同,即使定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数
对于某些类来说,合成拷贝构造函数用来阻止我们拷贝该类类型的对象。而一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中。编译器从给定对象中依次将每个非static成员拷贝到正在创建的对象中。
成员的类型决定了成员是如何进行拷贝的:类成员将调用其拷贝构造函数进行拷贝;内置类型成员则直接拷贝。尽管程序代码不能直接拷贝数组,编译器合成的拷贝构造函数通过拷贝其每一个元素来拷贝整个数组。类类型元素调用元素的拷贝构造函数进行拷贝。
Sales_data类的合成拷贝构造函数等价于:
 

拷贝初始化

当使用直接初始化时,编译器将使用常规的函数匹配来选择合适的构造函数以匹配程序员提供的参数。
当使用拷贝初始化(copy initialization)时,编译器将右边操作数拷贝到正在创建的对象中,当需要时会对操作数进行转换。
 
拷贝初始化通常使用拷贝构造函数。如果一个函数有移动构造函数(move constructor),拷贝初始化在特定条件下将使用移动构造函数。
拷贝初始化不仅仅发生在定义变量时使用 =,也会出现在一下情形中:
  • 以非引用方式传递对象给函数
  • 从函数中以非引用方式返回值
  • 花括号列表初始化一个数组中的元素或一个聚合类中的成员
    有些类会使用拷贝初始化来构建其分配的动态对象,如容器初始化时提供的元素将被拷贝初始化,用pushinsert方式插入的元素亦是拷贝初始化的。相反,用emplace的则是直接初始化的。
     

    参数和返回值

    在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。
    拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功——为了调用拷贝构造函数,必须拷贝它的实参,但为了拷贝实参,又需要调用拷贝构造函数,如此无限循环。
     

    拷贝初始化的限制

    如果使用的初始化值要求通过一个explicit的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:
    直接初始化v1 是合法的,但看起来与之等价的拷贝初始化v2 则是错误的,因为vector 的接受单一大小参数的构造函数是explicit 的。出于同样的原因,当传递一个实参或从函数返回一个值时,不能隐式使用一个explicit 构造函数。如果希望使用一个explicit 构造函数,就必须显式地使用。
     

    编译器可以绕过拷贝构造函数

    在拷贝初始化中,编译器允许跳过拷贝/移动构造函数,而直接创建对象,这被称为拷贝消除(copy elision),也叫做返回值优化(Return Value Optimization(RVO)),调用函数直接在栈上给返回值分配内存,然后将其地址传递给被调用者,被调用者直接在这个空间上构建对象,从而消除了从里边往外边的拷贝需求。但是即便是编译器可以消除拷贝/移动构造函数的调用,这两个函数本身必须存在,并且时可访问的。
     

    拷贝-赋值操作符

    与类控制其对象如何初始化一样, 类也可以控制其对象如何赋值:
    跟拷贝构造函数一样,如果没有定义拷贝赋值操作符,编译器会自动隐式合成一个。
     

    重载赋值操作符

    重载操作符是一个函数,函数名字是 operator后跟操作符的符号,重载赋值操作符就是 operator= ,操作符函数同样有返回值和参数列表。
    重载操作符的参数表示操作符的操作数。其中一些操作符必须被定义为成员函数。当一个操作是成员函数,其做操作数被隐式绑定为 this指针,而右操作数则作为参数显式传入。
    拷贝赋值操作符接受一个与其所在类相同类型的参数:
    为了与内置类型的赋值操作符一致,重载赋值操作符通常返回其左操作数的引用。注:标准库通常要求存储在容器中的类对象有一个返回其左操作数的赋值操作符。
     

    合成拷贝赋值操作符

    如果类没有重载赋值操作符,编译器会合成拷贝赋值操作符(synthesized copy-assignment operator),与拷贝构造函数一样,一些类的合成拷贝赋值操作符会被定义为 delete ,从而禁用其赋值。否则,将使用每个所有非静态成员的拷贝赋值操作符将其从右边操作数赋值到左边操作数。数组成员以赋值每个成员的方式来赋值。合成的拷贝操作符返回其左操作数的引用。
    下面等价于Sales_data的合成拷贝赋值运算符:
     
     

    析构函数

    析构函数执行与构造函数相反的操作:构造函数负责初始化所有非static数据成员,并做一些别的工作;析构函数负责释放对象使用的资源,并且销毁其所有非static数据成员。
    析构函数是类的一个成员函数,名字是~后接类名,其没有返回值,没有参数。由于其没有参数,所以不能被重载,一个类只有一个析构函数
     

    析构函数所做的事

    析构函数分为两部份:函数体和析构部分。在构造函数中,成员先于函数体被初始化。在析构函数中,函数体先执行,然后成员按初始化顺序的逆序销毁。
    析构函数体可以执行类设计者需要对象在生命的最后阶段需要做的事,通常,析构函数体释放一个对象分配的资源。析构函数中没有对应于构造函数的初始化列表的部分来控制成员怎样被销毁。析构部分是隐式的,析构时发生什么将由成员的类型决定,类成员执行其自己的析构函数,内置类型成员则不执行任何析构操作。
    特别是,动态分配的对象所返回的内置指针作为成员,在被析构时不会自动delete掉其指向的对象。与之不同的是,智能指针是类类型并且有析构函数。所以,智能指针将自动销毁其指向的动态对象。
     

    何时调用析构函数

    析构函数将在对象被销毁时自动调用,以下情况下将销毁对象:
    • 变量离开作用域时将被销毁
    • 对象成员将在对象被销毁时跟着被销毁
    • 容器或数组中的元素将在其容器被销毁时跟着被销毁
    • 动态分配的对象将在 delete 时被销毁
    • 临时量将在其所在的整个表达式结束时被销毁
    由于析构函数是自动运行的,所以,程序可以分配资源并且不必考虑何时应该释放资源。
    当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
     

    合成析构函数

    编译器为所有没有定义析构函数的类定义一个合成析构函数。与拷贝构造函数和拷贝赋值操作符一样,有些类的合成析构函数被定义为不允许类对象被析构。如果不是这种情况,合成析构函数的函数体是空的。
    在(空)析构函数体执行完毕后,成员会被自动销毁。特别的,string 的析构函数会被调用,它将释放bookNo成员所用的内存。
    注:析构函数体自身并不直接销毁成员。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。
     
     

    三/五法则

    有三个基本操作可以控制类的拷贝操作:拷贝构造函数、拷贝赋值操作符和析构函数。在新标准下,类还可以定义移动构造函数和移动赋值操作符。
    C++并不要求我们定义所有这些操作,可以定义其中一两个。但是通常这些操作需要被当作一个整体,仅仅只需要其中一个的情况很少发生。
     
    需要析构函数的类通常需要拷贝和赋值函数
    首要原则是如果一个类需要析构函数,那么几乎可以肯定需要拷贝构造函数和拷贝赋值操作符。通常,考察一个类是否需要析构函数是一个容易的事。通常一个类申请了资源,它就会需要一个析构函数。
     
    需要拷贝构造函数的通常意味着需要拷贝赋值操作符,反之一样
    一些类可以只定义拷贝和赋值对象所需要的操作函数,比如:每个对象都有自己的唯一 id 。所以,第二原则就是:如果一个类需要拷贝构造函数,它一定需要拷贝赋值操作符,并且相反是一样的。然而,需要这两者并不一定意味着需要析构函数。
     

    使用 =default

    当显式要求编译器为我们合成拷贝控制成员时,将它们定义为 =default 形式。当将 =default 放在类体中的声明处时,合成的函数隐式是内联的。如果不希望合成的成员是内联的,可以将 =default 放在成员定义处。只能将 =default 放在有合成版本的成员函数后。
     
     

    阻止拷贝

    某些类并不需要拷贝控制函数,如:iostream类。这些类必须定义成禁用拷贝控制函数。不定义这些函数是不行的,因为,编译器会隐式合成这些函数。

    将函数定义为 delete

    在新标准下可以通过将函数定义为删除的函数(deleted functions),来禁用拷贝。删除的函数是被声明但是不能被使用的函数。通过在函数后放置 = delete 来定义被删除的函数。被删除的函数不是未定义的函数,被删除的函数依然出现在函数匹配的候选函数中。但是,当其被选为最优函数时,将产生编译错误。
    =delete 只能放在类定义内的成员函数声明处,不能放在定义处。原因在于,调用成员函数通常需要知道成员函数的声明。而类外的定义处则是生成函数代码的地方。
    所有成员函数都可以被定义为被删除的函数。
     
    析构函数不应该被定义为被删除的函数
    如果将析构函数定义为被删除的,那么将毫无机会来销毁对象了。编译器将不允许程序定义这种类的变量或者创建临时量。并且,不能定义其成员类型有被删除的析构函数的类的变量或者临时量。尽管不能定义变量或者临时量,但是可以动态分配这种对象,除了不能删除这种动态对象。
    对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型动态分配对象的指针
     
    合成的拷贝控制成员可能是被删除的
    对于某些类,编译器合成的拷贝控制函数是被删除的函数:
    • 合成的析构函数是被删除的,如果类有一个成员,其析构函数是被删除的或者不可访问(private)
    • 合成的拷贝构造函数是被删除的,如果其成员自己的拷贝构造函数是被删除的或者不可访问。或者其成员的析构函数是被删除的或者不可访问
    • 合成的拷贝赋值操作符是被删除的,如果其成员的拷贝赋值操作符是被删除的或者不可访问,或者类有一个 const 或引用成员
    • 合成的默认构造函数是被删除的,如果其成员的析构函数是被删除的或者不可访问,或者有一个引用成员并且没有类内初始值,或者有一个 const 成员其类没有定义默认构造函数,并且没有类内初始值
    如果一个类的成员没有默认构造函数、拷贝、赋值、析构函数,那么对应的成员将是被删除的函数。
    也许成员的析构函数被删除或不可访问将导致合成的默认和拷贝构造函数被定义为被删除的是令人诧异的,原因是,如果对象不能被销毁,那么它就不能被创建。
    尽管可以给引用赋值,但是这样做将改变引用绑定的对象的值,如果为这样的类合成拷贝赋值操作符,那么左操作数将继续绑定相同的对象,它不会绑定到右操作数所绑定的对象。这种行为通常不是所希望的那样,所以,这种合成的拷贝赋值操作符将被定义为被删除的。
    换一句话说,当一个类的成员不能被拷贝、赋值或销毁时,其对应的拷贝控制成员将被合成为被删除的。
     

    private 拷贝控制

    在新标准之前,类通过将拷贝构造函数和拷贝赋值操作符定义为private来阻止类被拷贝。
    由于拷贝构造函数和拷贝赋值操作符是 private 的,用户代码不能拷贝此对象。然而,友元和成员函数依然可以进行拷贝。为了阻止友元和成员对其进行拷贝,可以将拷贝控制成员声明为 private 的,并且不提供定义。只声明但是不定义一个成员函数是合法的,尝试去使用这个未定义的成员将导致链接错误。新标准中应当尽量使用 =delete 来阻止拷贝,而不是将成员声明为 private 的。
  • C++
  • 动态数组拷贝控制和资源管理
    目录