首页 C++ 特殊成员函数相关
文章
取消

C++ 特殊成员函数相关

拷贝构造

调用的是拷贝构造函数还是赋值运算符,主要是看是否有新的对象实例产生。

如果产生了新的对象实例,那调用的就是拷贝构造函数;如果是对已有的对象赋值,调用的是拷贝赋值。

比如

1
2
3
4
 P a = P(10); //这是拷贝构造。因为a还不存在。
//------------------------------------------------------
 P a;
 a =  P(10); //这是拷贝赋值。因为a已经存在了

拷贝构造函数必须以引用的方式传递参数。这是因为,在值传递的方式传递给一个函数的时候,会调用拷贝构造函数生成函数的实参。如果拷贝构造函数的参数仍然是以值的方式,就会无限循环的调用下去,直到函数的栈溢出。

拷贝构造有严格的函数签名限制:

T 的复制构造函数是首个形参是 T&、const T&、volatile T& 或 const volatile T&,而且要么没有其他形参,要么剩余形参均有默认值的非模板构造函数

很多种情况都会调用拷贝构造。一般来说有如下几种形式

  • 对象作为函数的参数,以值传递的方式传给函数。 
  • 对象作为函数的返回值,以值的方式从函数返回
  • 使用一个对象给另一个对象初始化
1
P p1(p2); //使用一个对象给另一个对象初始化
1
2
3
P p1 = p2; //使用一个对象给另一个对象初始化
P p1 = P(10);//使用一个临时对象给另一个对象初始化
P* p1 = new P(10); //指针也不例外 这种声明使用10初始化一个匿名对象,并将新对象的地址赋给p1指针。

这里虽然使用了=,但是实际上使用对象p来创建一个新的对象p1。也就是产生了新的对象,所以调用的也是拷贝构造函数。

这里的第一行和第二行可能会使用复制构造函数直接创建p1,也可能使用复制构造函数生成一个临时对象,然后将临时对象的内容赋给p1,这取决于具体的实现

举例:

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Person
{
public:
	Person(){}
	Person(const Person& p)
	{
		cout << "Copy Constructor" << endl;
	}

	Person& operator=(const Person& p)
	{
		cout << "Assign" << endl;
		return *this;
	}

private:
	int age;
	string name;
};

void f(Person p)
{
	return;
}

Person f1()
{
	Person p;
	return p;
}

int main()
{
	Person p;
	Person p1 = p;    // 1
	Person p2;
	p2 = p;           // 2
	f(p2);            // 3

	p2 = f1();        // 4

	Person p3 = f1(); // 5

	getchar();
	return 0;
}

下面是输出

1
2
3
4
5
6
7
8
9
10
"Copy Constructor" 	//1
    
"Assign"			//2
    
"Copy Constructor"	//3
    
"Copy Constructor" 	//4
"Assign"
    
"Copy Constructor" 	//5

分析如下:

  1. 这是虽然使用了”=”,但是实际上使用对象p来创建一个新的对象p1。也就是产生了新的对象,所以调用的是拷贝构造函数。
  2. 首先声明一个对象p2,然后使用赋值运算符”=”,将p的值复制给p2,显然是调用赋值运算符,为一个已经存在的对象赋值 。
  3. 以值传递的方式将对象p2传入函数f内,调用拷贝构造函数构建一个函数f可用的实参。
  4. 这条语句拷贝构造函数和赋值运算符都调用了。函数f1以值的方式返回一个Person对象,在返回时会调用拷贝构造函数创建一个临时对象tmp作为返回值;返回后调用赋值运算符将临时对象tmp赋值给p2.
  5. 按照4的解释,应该是首先调用拷贝构造函数创建临时对象;然后再调用拷贝构造函数使用刚才创建的临时对象创建新的对象p3,也就是会调用两次拷贝构造函数。不过,编译器也没有那么傻,应该是直接调用拷贝构造函数使用返回值创建了对象p3。

关于拷贝赋值和拷贝构造在继承中的注意事项

继承中,子类的拷贝赋值和拷贝构造不会将父类的成员变量复制。为了防止遗漏,我们一般在子类直接调用父类的拷贝赋值或者拷贝构造。但是要注意语法和一些小细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class A {
    public:
        A(){}
        A(int a) :m_a(a) {}
        virtual~A(){}
        A& operator=(const A& rhs) {
            m_a = rhs.m_a;
            return *this;
        }
	int m_a;
};

class B : public A{
    public:
        B(){}
        B(int x):m_b(x){}
        B(int no, int x):A(no),m_b(x){} //这里用到了子类调用父类构造
        ~B(){} 
        
        B& operator=(const B& rhs) {
            this->A::operator=(rhs); //这里是函数调用。
            /*
            我们函数调用可以不接受返回值。operator=的返回值的目的是返回一个自己用来进行下一步操作。是为了满足链式调用。当然也可以啥也不返回。
            所以隔壁的拷贝构造就没返回值。
            但是我们子类调用父类的拷贝赋值仅仅是把子类的父类部分调用父类的拷贝赋值来进行一个赋值。
            这里的例子就是子类的父类部分m_a使用父类的拷贝赋值给copy一遍。
            然后我们直接调用完了父类的拷贝赋值后,再自己赋值子类部分。
            时刻谨记 b1 = b2 就是b1.operator=(b2);
            */
            m_b = rhs.m_b;
            return *this;

        }
        int m_b;
};

int main(){
    B b(5,8);
    cout << b.m_a << endl;
    
    return 0;
}

我们这里没有使用带指针对象的拷贝构造和拷贝赋值做为例子。

但是我们想要深拷贝的时候,拷贝赋值要注意先检测自我赋值。

其次要先删除掉调用方自己的对应内容,然后新开辟内存,然后复制数据。

移动赋值和移动构造记得移动后需要把原来数据置空。尤其是带有指针类型的

但是不能删除。因为是转移所有权。也就是浅拷贝。删除了东西就没了。如果不置空的话会有两个问题。第一是可能会被误用。其次是如果是把一个临时对象移动构造或者是移动赋值给一个对象,临时对象的那一行过后临时对象会被析构。也就是指针指向的数据会被释放。所以你对象拿到的临时对象的对应指针数据也会被清除。

所以在有指针的时候需要格外注意。而且对应的析构函数也要进行判空,不然会多次删除。

移动构造在使用vector的情况下,一般是vector扩容的时候重新分配内存的时候使用,如果自定义类型有移动构造就会用移动构造。没有就是拷贝构造。

拷贝构造 拷贝赋值 移动构造 移动赋值的例子。此处拷贝为深拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class myobj{
    public:
        myobj(int x):val(new int(x)){}
        int& getval(){
            return *val;
        }

        myobj(const myobj& obj){ //拷贝构造 深拷贝
            val = new int(*obj.val); //以传入参数的值新开辟数据空间
        }
        myobj& operator=(const myobj & rhs){ //拷贝赋值 深拷贝
            if(this == &rhs){ //检测自我赋值
                return *this;
            }
            delete this->val; //释放自身此时的资源。
            val = new int(*rhs.val); //以传入参数的值新开辟数据空间
            return *this;
        }

        myobj(myobj&& obj){ //移动构造
            cout <<"mv" << endl;
            val = obj.val;     //浅拷贝指针
            obj.val = nullptr; //因为是所有权转移,传入对象的数据指针必须被置空
        }
        myobj& operator=(myobj&& rhs){ //移动赋值
            cout <<"mv=" << endl;
            if(this == &rhs){ //检测自我赋值
                return *this;
            }
            if(this->val != nullptr){ //先释放自身此时资源
                delete val;
            }
            val = rhs.val; //浅拷贝指针
            rhs.val = nullptr; //因为是所有权转移,传入对象的数据指针必须被置空
            return *this;

        }
        ~myobj(){
            delete val;
            val = nullptr;
        }


        int* val;
        
};

int main(){
    myobj a(5);
    myobj b(10);
    cout << a.getval() << endl;
    myobj c(a);
    cout << c.getval() << endl;
    myobj d = c;
    cout << d.getval() << endl;
    d = b;
    cout << d.getval() << endl;
    myobj e = move(a);
    cout << e.getval() << endl;
    d = move(b);
    cout << d.getval() << endl;
    return 0;
}

移动构造函数的设计

  • 参数:
    • 参数为&&类型,因为是移动操作
    • 参数不可设置为const,因为需要改变入参
  • 在移动构造函数后添加noexcept关键字,确保移动构造函数不会抛出异常。尤其是针对可能会有vector容器储存该类对象时。
  • 内容:
    • 在参数初始化列表中将参数的资源移动给自己(先执行)。
      • 把入参的资源移动给自己。
    • 然后在函数体内处理入参所拥有的资源:
      • 一般来说,对象应该置为0或默认值。
      • 指针必须置空避免不小心释放不应释放的资源。因为被移动过的对象在生命周期结束的时候依旧会调用析构。如果不给原对象指针置空,则新对象接管资源后资源也会被无意置空!
      • 这样之后就达到了资源移动的目的(后执行)
  • 注意要点:
    • 移动构造函数不分配任何内存,只是简单的资源移动而已
    • 移动构造会构造对象。所以如果使用一个对象通过移动构造来构造一个新对象。我们依旧会有两个对象。只不过只有一个对象拥有有效值而已。最后析构函数还是会被执行两次。因为虽然内容(资源)移动走了,但是壳子(对象本身)还在。所以析构函数依旧会被调用。 (杂记中搜索关键词“壳子”)

移动赋值的设计

  • 参数:
    • 参数为&&类型,因为是移动操作
    • 参数不可设置为const,因为需要改变
  • 在函数后添加noexcept关键字,确保移动赋值运算符函数不会抛出异常。原因同上
  • 与拷贝赋值运算符一样,函数返回自身引用
  • 内容:
    • 在函数执行前,应该检测自我赋值的情况
    • 先释放自身资源,再拷贝参数rhs的资源,最后再将rhs置为空。原因同上
  • 赋值运算符的重载调用的时候一定要把this先安全的析构掉(释放自身资源),绝不是构造。另外一个十分重要的点在于虚表指针的初始化时机,C++没规定虚表指针的初始化是什么时候,但是绝对不可能发生在赋值时。

https://blog.csdn.net/qq_41453285/article/details/104419356

通常来讲,我们可以给成员函数设计一个移动版本一个拷贝版本。也就是一个入参类型为const&,另一个为&&push_back就是这样设计的

注意编译器并不是在任何场合都会生成默认移动构造函数或默认移动赋值

  • 针对移动构造和移动赋值,有更严格的要求需要满足,编译期才会为我们生成默认移动构造或默认移动赋值:
    • 编译器只会针对满足如下情况的类生成移动构造和移动赋值
      • 没有用户定义的移动构造/移动赋值
      • 没有用户声明的拷贝构造/拷贝赋值
      • 没有用户声明的析构函数
  • 所以会有0/3/5法则。

特殊成员函数默认生成的条件

同时翻看 effective modern C++ 条款17

  • 六种特殊的成员函数可以声明为类似 X() = default 让编译器自动生成, 或者声明为 X() = delete 阻止编译器自动生成相应的函数定义。

  • X::X() 默认构造函数 如果没有声明(包括 =delete),编译器将会自动生成定义。但是如果用户声明了其他形式的构造函数如X(int x) , 编译器将不会自动生成,除非用户手动定义。

  • 拷贝构造X(const X&) 和 拷贝赋值X& operator=(const X&) 如果用户没有提供,且没有提供移动构造或移动赋值,在需要的时候编译器会自动生成,内容为对成员的值拷贝,如果是指针成员将是“浅复制”。

    • 注意还有一些其他限制。这个建议去网站看文档。比较细碎。比如拥有不可拷贝的成员时。

    • 注意拷贝构造和拷贝赋值不会抑制互相的生成,但是其生成会被弃用deprecated。同时,用户定义的析构函数也会导致其弃用

      • T 拥有用户定义的析构函数或用户定义的复制构造函数时,隐式定义的复制赋值运算符的生成被弃用。

      • T 拥有用户定义的析构函数或用户定义的复制赋值运算符时,隐式定义的复制构造函数的生成会被弃用。

    • 再次重申,弃用不是=delete。而是deprecated。g++不会提示报错,但是clang会。可能委员会认为如果被设置为=delete会对大量代码导致遗留问题。

    • 每个类都有自己的拷贝构造或拷贝赋值。只不过他们要么可以用要么被=delete了。来自

  • 对除构造函数之外的特殊成员函数的声明(哪怕是=defaultdelete),将会阻止编译器生成默认的移动构造和移动赋值。这将会导致该类无法使用移动语义

    • 这里是压根不生成(声明)移动构造和移动赋值。压根没有,而不是=delete
    • 针对移动构造和移动赋值,有更严格的要求需要满足,编译器才会为我们生成默认移动构造或默认移动赋值:
      • 没有用户定义的移动构造/移动赋值
      • 没有用户声明的拷贝构造/拷贝赋值
      • 没有用户声明的析构函数
    • 注意和拷贝操作不同。移动操作的两种函数不是互相独立的。也就是移动构造和移动赋值会互相抑制编译器的提供。— effective modern C++ 条款17。
  • 如果只声明了移动构造或移动赋值,则编译器隐式生成的默认拷贝构造和默认拷贝赋值将会声明为delete, 也就是该类是仅移动的。

  • 如果一个本来会隐式声明且定义为弃置的拷贝构造被用户显式预置(=default),那么这个复制构造函数被定义为弃置。

    • 也就是说如果某类有一个不可拷贝的成员,你还给这个类的拷贝构造用了=default,那么没有用,还是会被编译器换成=delete。当然了,如果这时候你没有对这个类成员进行会调用拷贝构造的操作,GCC不会报错,但是clang会有warning。

0/3/5 法则

3之法则

如果某个类需要用户定义的析构函数、用户定义的拷贝构造函数或用户定义的拷贝赋值运算符,那么它几乎肯定需要全部三者。

原因:

  • 通常,若一个类需要析构函数,则代表其合成的析构函数不足以释放类所拥有的资源,其中最典型的就是指针成员(析构时需要手动去释放指针指向的内存)。
  • 所以,若存在自定义(且正确)的析构函数,但使用合成的拷贝构造函数,那么拷贝过去的也只是指针,此时两个对象的指针变量同时指向同一块内存,指向同一块内存的后果很有可能是在两个对象中的析构函数中先后被释放两次。所以需要额外的拷贝控制函数去控制相应资源的拷贝。
  • 所以这类例子的共同点就是:一个对象拥有额外的资源(指针指向的内存),但另一个对象使用合成的拷贝构造函数也同时拥有这块资源。当一方对象被销毁后,析构函数释放了资源,这时另一个对象便失去了这块资源(但程序员还不知道)。

5之法则

因为用户定义的析构函数、拷贝构造函数或拷贝赋值运算符的存在会阻止移动构造函数和移动赋值运算符的隐式定义,所以任何想要移动语义的类必须声明全部五个特殊成员函数。

0之法则

如果不需要手动定义, 就不要定义,一切让它默认。

注意移动操作的含义

移动是一种特殊的拷贝操作。也就是移动是比拷贝更”先进“的操作。因为根据函数重载决议规则,如果我们没有提供移动构造或移动赋值,当我们使用右值进行“移动”操作时,则会匹配到拷贝构造或拷贝赋值。所以如果想显式表达某个类不能移动,则应该声明拷贝操作为=delete而不是放在那里不管。如果不写,我们的“移动”操作会变成拷贝操作。如果写了,则会报错。

构造函数中使用move 或 forward移动参数的陷阱

不要对入参为常量左值引用的对象使用move。它会被拷贝而不是移动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class myobj{
    public:
        myobj(int x):val(new int(x)){ //构造
            cout <<"const" << endl;
        }
        myobj(const myobj& obj){ //拷贝构造
            cout <<"copy const" << endl;
            val = new int(*obj.val);
        }
        myobj& operator=(const myobj & rhs){ //拷贝赋值
            cout <<"copy= const" << endl;
            if(this == &rhs){
                return *this;
            }
            delete this->val;
            val = new int(*rhs.val);
            return *this;
        }

        myobj(myobj&& obj){ //移动构造
            cout <<"mv" << endl;
            val = obj.val;
            obj.val = new int(3939);
        }
        myobj& operator=(myobj&& rhs){ //移动赋值
            cout <<"mv=" << endl;
            if(this == &rhs){
                return *this;
            }
            if(this->val != nullptr){
                delete val;
            }
            val = rhs.val;
            rhs.val = nullptr; 
            return *this;

        }
        ~myobj(){
            cout <<"dest" << endl;
            delete val;
            val = nullptr;
        }

        int* val;
        
};

上面是一个写了所有特殊成员函数的类。我们继续看

  • 第一个版本。我们值传递。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class testobj{
    public:
    myobj inner;
    testobj(myobj obj):inner(move(obj)){} //版本1 值传递
};



int main(){
    myobj temp(20);
    testobj out(temp);
    cout << *temp.val << endl; //输出什么?
    return 0;
}
1
2
3
4
5
6
7
const //构造temp对象
copy const //值传递导致的拷贝至构造函数中。
mv //构造函数中使用move把拷贝的对象转换为右值然后移动构造的inner
dest//拷贝进来的参数析构
20
dest //testobj里的myobj析构
dest//外部myobj析构

这个20会正常输出。外部的temp对象是拷贝传值。被move变成右值的是值传递导致的拷贝的那个对象。

值传递有另一种情况

1
2
3
4
5
6
7
8
9
10
11
12
13
class testobj{
    public:
    myobj inner;
    testobj(myobj obj):inner(move(obj)){} //版本1 值传递
};



int main(){
    myobj temp(20);
    testobj out(move(temp)); //这里我们也用move
    return 0;
}
1
2
3
4
5
6
const //构造temp对象
mv //右值触发移动构造传入函数参数
mv //右值触发移动构造从函数入参构建inner
dest
dest
dest
  • 第二个版本。左值引用传递。
    • 这个版本有个毛病。输入右值不行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class testobj{
    public:
    myobj inner;
    testobj(myobj& obj):inner(move(obj)){} //左值引用
};



int main(){
    myobj temp(20);
    testobj out(temp);
    //testobj out(myobj(20)); 不行。
    cout << *temp.val << endl;
    return 0;
}
1
2
3
4
5
const //temp对象构建
mv //引用传递不发生拷贝,这里的move变成右值的是temp对象本身。所以触发移动构造testobj内的inner
3939 //外部对象已经被移动。输出3939
dest
dest

这里我们发现由于是左值引用传递,所以temp对象发生了移动。

  • 第三个版本。常量左值引用传递。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class testobj{
    public:
    myobj inner;
    testobj(const myobj& obj):inner(move(obj)){} //常量左值引用
};



int main(){
    myobj temp(20);
    testobj out(temp);
    cout << *temp.val << endl;
    return 0;
}
1
2
3
4
5
const //temp对象构建
copy const //引用传递不发生拷贝。这里的拷贝是从构造函数入参中拷贝至myobj的inner对象。
20//外部不移动
dest
dest
  • 为什么会拷贝?
    • 常量左值引用既然是常量就不可能对该参数修改。这是简单的理解。
    • 深层次的理解是这样的。我们move会把一个左值变成右值。我们obj传进来的时候是const &, 如果move就会变成const &&
    • 这时候有一个问题。我们没有const&&开头的函数。我们也知道const&可以接受一切参数包括const&&。(杂记)所以这时候会去匹配const&的拷贝构造。所以最后会发生拷贝。
    • const&&函数毫无意义。首先移动语义在有意义的时候一定要保证把被移动对象和资源进行分离。加了const无法对参数进行修改。其次,入参为右值的时候我们有&&接受。常量右值的时候会被const&接受。所以const&&没有意义。

    https://www.nextptr.com/tutorial/ta1211389378/beware-of-using-stdmove-on-a-const-lvalue

  • 第四个版本 右值引用传递。
    • 这个版本也有毛病。左值不行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class testobj{
    public:
    myobj inner;
    testobj(myobj&& obj):inner(move(obj)){} //右值引用
};



int main(){
    myobj temp(20);
    testobj out(move(temp)); //move换成右值
    cout << *temp.val << endl;
    return 0;
}
1
2
3
4
5
const //构造temp
mv //引用传递不拷贝。这个move是把temp对象转换为右值后触发拷贝构造
3939//temp已被移动。
dest
dest

这里我们发现结果和左值引用是一致的。

那么如果我们直接传入临时对象会发生什么呢?

1
2
3
4
5
6
7
8
testobj out(myobj(10));


const //构造临时对象。临时对象必须得有啊不然传的是啥?
mv //move把临时对象换成右值,触发移动构造。
dest
dest

构造函数中使用forward的正确方式

对于完美转发我们已经在杂记中介绍了。但是我们当时只展示了函数的完美转发。

我们必须要记住完美转发的核心要素之一,重中之重也就是完美转发依靠于万能引用。因为只有万能引用才能触发引用坍缩。

但是格外注意万能引用的陷阱之一:如果不涉及型别推导,那么就算是T&&也不是万能引用。(格外注意!!!!)

1
2
3
4
5
6
7
8
9
10
template <typename T>
class testobj{
    public:
    T inner;
    testobj(T&& obj) : inner(forward<T>(obj)){} // cannot bind rvalue reference of type ‘myobj&&’ to lvalue of type ‘myobj’
};
int main(){
    myobj var(10);
    testobj<myobj> a(var); //cannot bind rvalue reference of type ‘myobj&&’ to lvalue of type ‘myobj’
}

上面的就是错误例子。在这里,T是类模板参数。因为我们使用的时候已经显式制定了Tmyobj。这就代表了T不涉及型别推导。所以T在这里不是万能引用,而是右值引用。不涉及万能引用的时候自然不涉及引用坍缩,也就不涉及完美转发。

什么是正确例子呢?我们知道必须要让含有完美转发的函数涉及到万能引用也就是型别推导。所以就需要成员函数模板。

1
2
3
4
5
6
class testobj{
    public:
    myobj inner;
    template <typename Arg>
    testobj(Arg&& obj):inner(forward<Arg>(obj)){}
};

这个时候函数的入参的模板参数会涉及到型别推导。至于该类是否是类模板,则不重要。只是看是否有需求罢了。

构造函数中 想要触发蕴含的某个其他对象的移动构造,则必须使用move或forward

我们延续上面的例子。

1
2
3
4
5
6
7
class testobj{
    public:
    myobj inner;
    //testobj(myobj&& obj):inner(move(obj)){}
    testobj(myobj&& obj):inner((obj)){} //我们这里不使用move
};

我们发现,我们这里使用了move。如果不用move会发生什么呢?

1
2
3
4
5
testobj our(myobj(10));
const //构造临时对象
copy const //哇哦,发生了拷贝。
dest
dest

为什么会拷贝?我们不是传入了临时对象吗?

我们一定知道:具名的右值引用是左值。

1
2
3
void func(myobj&& rhs){
    //这里rhs是具名右值引用。是左值。所以如果这时候有函数接受左值或右值引用形式的rhs,则会匹配至左值
}

这也是为什么存在完美转发。这就是另一个要点!在构造函数中,如果想要显式对某一个对象进行移动,仍需要使用move

如果我们这里不使用move,则此时右值引用接住的临时对象会变为具名对象,会变成左值。传入后就会匹配拷贝构造。

为了正确的传递其右值特性,或强制转换为右值,就需要使用forwardmove

所以我们可以这样使用move

1
2
3
4
5
6
class testobj{
    public:
    myobj inner;
    testobj(myobj&& obj):inner(move(obj)){}//使用move
};

或使用forward

1
2
3
4
5
6
class testobj{
    public:
    myobj inner;
    template <typename Arg>
    testobj(Arg&& obj):inner(forward<Arg>(obj)){} //使用forward
};

同样的,想要从头移到尾,则外部调用和内部构造函数中都要使用move

1
2
3
4
5
6
7
8
9
10
class ano{
    public:
        myobj val;
        ano(myobj ano):val(move(ano)){}; // 注意此时入参是值传递而不是右值引用。
};
int main(){
    myobj a(20);
    ano pro(move(a));
    return 0;
}

在调用过程中,我们会触发两次移动构造。第一次是传入函数中。第二次是使用入参初始化类成员的时候。只要有一个地方没有move就会触发拷贝构造

更深入的可以看看视频

具体在构造函数中,我们应该传值,传递两个版本还是完美转发呢?

  • 方案1:写出两个版本。&&const &
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class testobj2{
    public:
    myobj inner;
    testobj2(const myobj& obj):inner(obj){ //针对左值。记住,针对入参为常量左值引用,不要move
        cout <<"called testobj2" << endl;
    }
    testobj2(myobj&& obj):inner(move(obj)){
        cout <<"called&& testobj2" << endl;
    }
};

void test2L(){ //左值入参
    cout << "start test2L" << endl;
    myobj obj1(20);
    testobj2 v1(obj1);
    cout << "end test2L" << endl;
}
void test2R(){ //右值入参
    cout << "start test2R" << endl;
    myobj obj1(20);
    testobj2 v1(move(obj1));
    cout << "end test2R" << endl;
}
/*
----------
start test2L
const 构建对象
copy const 引用传递不拷贝。此处是拷贝构造至类内的inner对象
called testobj2
end test2L
dest
dest
----------
start test2R
const 构建对象
mv 引用传递不拷贝。又因为是右值所以触发了移动构造至类内的inner对象
called&& testobj2
end test2R
dest
dest
----------
*/
  • 方案2:使用完美转发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class testobj1{
    public:
    myobj inner;
    template <typename Arg>
    testobj1(Arg&& obj):inner(forward<Arg>(obj)){ //完美转发应该怎么写,上面提到了。
        cout <<"called testobj1" << endl;
    }
};

void test1L(){ //左值入参
    cout << "start test1L" << endl;
    myobj obj1(20);
    testobj1 v1(obj1);
    cout << "end test1L" << endl;
}
void test1R(){ //右值入参
    cout << "start test1R" << endl;
    myobj obj1(20);
    testobj1 v1(move(obj1));
    cout << "end test1R" << endl;
}
/*
start test1L
const 构建对象
copy const 完美转发发现是左值,调用拷贝构造
called testobj1
end test1L
dest
dest
----------
start test1R
const 构建对象
mv 完美转发发现是右值,调用移动构造
called testobj1
end test1R
dest
dest
*/
  • 方案3 值传递
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class testobj3{
    public:
    myobj inner;
    testobj3(myobj obj):inner(move(obj)){
        cout <<"called testobj3" << endl;
    }
};

void test3L(){ //左值入参
    cout << "start test3L" << endl;
    myobj obj1(20);
    testobj3 v1(obj1);
    cout << "end test3L" << endl;
}
void test3R(){ //右值入参
    cout << "start test3R" << endl;
    myobj obj1(20);
    testobj3 v1(move(obj1));
    cout << "end test3R" << endl;
}

/*
----------
start test3L
const 构建对象
copy const 值传递发生一次多余拷贝,拷贝至函数入参。此时是临时对象
mv 调用移动构造
called testobj3
dest
end test3L
dest
dest
----------
start test3R
const 构建对象
mv 右值传递发生一次多余移动。移动至函数入参。此时是临时对象
mv 调用移动构造
called testobj3
dest
end test3R
dest
dest
*/

额外注意为何值传递传递右值的时候是多了一次移动而不是多了一次拷贝?

因为传递至函数参数,相当于进行了一次以入参为值的直接初始化。也就是临时对象是直接初始化出来的。在杂记中我们提到了直接初始化会考虑全部的构造函数寻找最佳匹配,所以如果满足移动构造就会使用移动构造

  • 最后我们发现了,写两种版本和完美转发效果相同。但是使用值传递会在左值的时候多一次拷贝,右值的时候多一次移动。尤其是拷贝或移动开销较大的时候应该避免。所以既可以写两种版本,也可以使用完美转发。

拷贝/移动构造/赋值只要写了,就需要写出完整的

无论成员变量是什么类型的,堆的还是栈的,都需要写出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class myobj{
    public:
    int val;
    myobj() = default;
    myobj(int x):val(x){};
    myobj(const myobj& rhs){
        cout << "copy const" << endl;
        val = rhs.val;
    }
};
int main(){
    myobj a(20);
    myobj b(a);
    cout << a.val << endl; //20
    cout << b.val << endl; //20
}

上面是我们提供的,一切正常。

要么就是不写:

1
2
3
4
5
6
7
8
9
10
11
12
13
class myobj{
    public:
    int val;
    myobj() = default;
    myobj(int x):val(x){};
    myobj(const myobj& rhs) = default;
};
int main(){
    myobj a(20);
    myobj b(a);
    cout << a.val << endl; //20
    cout << b.val << endl; //20
}

但是绝对不可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class myobj{
    public:
    int val;
    myobj() = default;
    myobj(int x):val(x){};
    myobj(const myobj& rhs){
        cout <<"copy const" << endl;
    }
};
int main(){
    myobj a(20);
    myobj b(a);
    cout << a.val << endl;//20
    cout << b.val << endl;//乱七八糟
}
  • 我们知道编译器默认生成的是浅拷贝。但是那是因为你没写才生成默认拷贝构造。你要是写了编译器就不会生成。那如果你不写完整那就是连浅拷贝都没有。这里我们的拷贝构造就是连浅拷贝都没有。所以val是垃圾值。

类静态成员变量不需要写入拷贝构造或拷贝赋值和析构函数

本来静态成员变量就是类的所有对象共有的。也不能在类内初始化。所以写入拷贝构造或拷贝赋值是无意义的。

同样,静态成员变量生存周期是至程序结束。所以析构函数并不关心他们。

重新梳理赋值运算符的语义

赋值运算符operator=算作运算符重载里。

  • 如果这个operator=的形参恰好接受一个 TT&const T&volatile T&const volatile T& 类型的形参,且它不是模板函数也不是静态成员函数,那么它就是拷贝赋值运算符
  • 如果这个operator=的形参恰好接受一个 T&&const T&&volatile T&& const volatile T&& 类型的形参,且它不是模板函数也不是静态成员函数,那么它就是移动赋值运算符

所以一个类可以有多个operator=重载。他们都是赋值运算符。但是如果不符合上面的要求,则不能称之为拷贝赋值或移动赋值。

所以operator=的返回值压根没有明确要求。包括拷贝赋值和移动赋值。

拷贝赋值运算符也不是强制返回T&的。如果你不想链式调用,完全可以写成这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct myclass{
    int val = 0;
    myclass(int x):val(x){};
    myclass operator+(const myclass& rhs){
            return myclass(val + rhs.val);
    }
    void operator=(const myclass& rhs){
        val = rhs.val;
        cout <<"copy assignment" << endl;
    }
};
int main(){
    myclass obj(200);
    myclass obj2(300);
    cout << obj2.val << endl;
    obj2 = obj;
    cout << obj2.val << endl;
}

a.operator=(b)压根不是返回值作用于a。是先修改了a然后同时返回自己而已。目的就是链式调用。所以a = b这个表达式,返回值压根就没用上。

非常好的资料

https://youtu.be/9BM5LAvNtus

各种成员函数是否应该是explicit的

https://quuxplusone.github.io/blog/2024/06/25/most-stl-ctors-arent-explicit-but-yours-still-should-be/

移动赋值和 use-after-move

本文参考资料来自这里的Errors in object lifetime: use-after-move章节

每一个vector都有我们可选的分配器对象。这点我们在PMR介绍过。

标准库要求分配器类型定义propagate_on_container_move_assignment属性,该属性会影响移动分配的行为。如果我们写A = std::move(B),我们有三个选项:

  1. propagate_on_container_move_assignment{} == true 是的,这不是常量,而是一个结构,如false_type / true_type)。A被释放,分配器被移动(再次使用移动分配,因此我们需要在这里注意一些保证),然后从B中获取内容,最后将B置空
  2. propagate_on_container_move_assignment{} == false并且AB中的分配器相同 ( A.get_allocator() == B.get_allocator() )。A被释放,分配器保持原位。 内容从A转移到B
  3. propagate_on_container_move_assignment{} == false并且A.get_allocator() != B.get_allocator()。这是最有趣的部分开始的地方:A不能拿走分配器或数据。唯一的选择是分别移动每个元素。但是,清空和释放B不是必要的。我们需要做的就是移动元素。在这种情况下,我们还可以得到一个由移出的元素组成的完整vector。

在第三种情况下,vector的 libc++ 实现中,不会对其调用clear(),但是libstdc++会。

libc++:

微信图片_20241022005312

微信图片_20241022005316

libstdc++:

微信图片_20241022005322

微信图片_20241022005319

我们从图中可以看到libc++和libstdc++的源码实现差别。但是注意,就算不对B调用clear,也不代表B的元素是有效的。经过移动后,B的元素仅仅是可访问的。所以说它没有破坏语义也没有颠覆我们先前的认知。我们用个人的例子试一下,会得到如下结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
  {
    VectorString v = {
      1,2,3,4
    };
    VectorString vv;
    cout <<"---"<<endl;
    vv = std::move(v);
    std::cout << v.size() << "\n";
  }
/*
libstdc++
---
myobj move constructor
myobj move constructor
myobj move constructor
myobj move constructor
myobj destructor
myobj destructor
myobj destructor
myobj destructor
0
myobj destructor
myobj destructor
myobj destructor
myobj destructor

libc++
---
myobj move constructor
myobj move constructor
myobj move constructor
myobj move constructor
4
myobj destructor
myobj destructor
myobj destructor
myobj destructor
myobj destructor
myobj destructor
myobj destructor
myobj destructor
*/

可以看出明显差别。

所以我们针对移动对象的状态保证有四个级别:

  1. 仅析构。被移动的对象被销毁并且不再使用。如果决定向其对象添加移动语义,则应提供这项基本保证,以便整个析构函数自动调用机制不会出现任何超出人预期外的问题
  2. 析构和赋值。这种情况下指的某个对象被移动后,可以通过为对象分配新值来重用它(然后正常使用它)。可以移动但不能重新分配的对象非常罕见。因此,通常这种保证与前一种保证相结合。
  3. 有效但是未指明效果。我们可以使用已经被移动的对象,比如调用不需要先决条件的成员函数。但是里面有什么?天知道
  4. 有效且定义明确。这是我们想要的。

copy and swap

https://stackoverflow.com/questions/3279543/what-is-the-copy-and-swap-idiom

move and swap基本是一个意思

本文由作者按照 CC BY 4.0 进行授权

C++ 杂记

王道操作系统笔记-内存(24)-- 内存管理6