C++ 面向对象_4

C++ 构造函数

构造函数是一种特殊的成员函数,当创建类的对象时自动被调用。它的主要任务是初始化对象的数据成员,确保对象在创建时处于有效状态。构造函数与类同名,且没有返回类型(甚至不包含void),构造函数支持重载以接受不同的参数。

可以简单写一个构造函数,示例如下:

class Person {
private:
    std::string name;
    int age;

public:
    // 带参数的构造函数
    Person(const std::string& n, int a) {
        name = n;
        age = a;
    }
};

构造函数支持重载,比如:

class Person {
private:
    std::string name;
    int age;
    bool employed;

public:
    // 版本1:缺省构造函数
    Person(){
        std::cout << "Default constructor called" << std::endl;
    }
    
    // 版本2:接受名字的构造函数
    Person(const std::string& n)
    {
        name = n;
        std::cout << "Name constructor called" << std::endl;
    }
    
    // 版本3:接受名字和年龄的构造函数
    Person(const std::string& n, int a)
    {
        std::cout << "Name and age constructor called" << std::endl;
    }
    
    // 版本4:接受所有参数的构造函数
    Person(const std::string& n, int a, bool e)
    {
        std::cout << "Full constructor called" << std::endl;
    }
};

也可以通过从同类型的另一个对象创建新对象时通过复制构造函数,比如:

class Person {
public:
    // 复制构造函数
    Person(const Person& other) {
        name = other.name;
        age = other.age;
    }
private:
    std::string name;
    int age;
};

既然有左值引用用来复制构造函数,当然就会有右值引用用来移动构造函数,即从一个将被销毁的同类型对象创建新对象,避免了复制的内存开销:

class Person {
public:
    // 移动构造函数
    Person(Person&& other) noexcept {
        name = std::move(other.name);
        age = other.age;
    }
private:
    std::string name;
    int age;
};

代理(委托)构造函数

代理(委托)构造函数允许一个构造函数调用同一个类的另一个构造函数,避免代码重复。

class Person {
private:
    std::string name;
    int age;
    bool employed;

public:
    // 主构造函数
    Person(const std::string& n, int a, bool e)
    {
        std::cout << "Primary constructor called" << std::endl;
    }
    
    // 代理构造函数,调用主构造函数
    Person() : Person("Unknown", 0, false) {
        std::cout << "Default constructor additional setup" << std::endl;
    }
    
    // 另一个代理构造函数
    Person(const std::string& n) : Person(n, 0, false) {
        std::cout << "Name constructor additional setup" << std::endl;
    }
};

在这个例子中,缺省构造函数和只接收名字的构造函数都委托给主构造函数执行初始化,然后可以执行自己额外的设置。

初始化列表

可以看到我们上面使用委托构造函数时使用了 : 语法,这表示构造函数的创建依赖另一个构造函数的初始化,: 后面的部分就是初始化列表。初始化列表是构造函数的特殊语法,用冒号后跟成员初始化项列表。构造函数本身也是类的成员,因此在使用构造函数创建对象时,可以初始化类的成员。比如:

// 更高效:直接调用string构造函数
Person(const std::string& n) : name(n) { }

// 较低效:先调用string默认构造函数,再赋值
Person(const std::string& n) { name = n; }

在使用构造函数创建对象时,可以使用初始化列表初始化类的数据成员,比如,初始化 name 成员。

可以看到在使用初始化列表时,不论是基本类型还是类类型的成员,都是通过直接初始化的方式对成员进行初始化,这就避免了临时的内存开销,对于类类型成员,初始化列表直接会调用其构造函数,就像我们使用委托函数那样调用同类型的构造函数进行构造(直接)初始化,而构造函数内的赋值操作在内存层面会进行一系列开辟内存,内存拷贝等操作,从性能上来讲肯定是比不上直接初始化。

同时有些情况下,我们必须使用初始化列表来初始化类的成员,比如类中的成员包含引用成员,引用要求在声明时就要绑定到一个已有的对象。比如下面的两种形式:


class testRef
{
private:
    int& refval;
public:
    testRef(int& i) : refval(i)
    {
    }
};

当声明一个类时,所有的成员变量(包括引用)在构造函数体运行之前就应该已经存。
对于引用成员(如 refval),它们必须在对象构造时被初始化,而不能等到构造函数体内再赋值。
使用初始化列表(: refval(i)),refval 在对象构造时直接绑定到参数 i 所引用的对象。这是符合引用初始化规则的。

而下面的写法:

class testRef
{
private:
    int& refval;
public:
    testRef(int& i)
    {
        refval = i;
    }
};

使用构造函数进行对象的创建时,当程序进入构造函数体 {} 时,所有的成员变量(包括 refval)应该已经完成了初始化(包括默认初始化)。然而,refval 是一个引用,如果没有在初始化列表中显式初始化,编译器会尝试默认初始化它。但引用无法被默认初始化(因为没有“空引用”这种概念),这会导致编译器报错。

同理常量成员也必须使用初始化列表初始化。

class testConst
{
private:
    const double factor;
public:
    testConst(double r) : factor(r)
    {
    }
};

在使用构造函数创建对象时,确保常量已经初始化(包括默认初始化)而不是如下在构造函数的函数体内进行修改

class testConst
{
private:
    const double factor;
public:
    testConst(double r)
    {
        factor = r;     // 常量不可被修改
    }
};

同理,如果类成员的类没有默认构造函数,也必须使用初始化列表明确指定类成员进行初始化时使用的构造函数。

总之可以知道,构造函数的函数体是在对象构造之后运行的,对象在构造时其成员需要明确初始化的值最晚就是在初始化列表中进行。

初始化顺序

成员初始化的顺序与它们在类中声明的顺序相同,与初始化列表中的顺序无关。这一点很重要,特别是当一个成员的初始化依赖于另一个成员时。

class InitOrder {
private:
    int value1;
    int value2;  // 声明顺序:先value1,后value2
    
public:
    // 尽管初始化列表中value2在前,value1在后
    // 但实际初始化顺序仍然是先value1,后value2
    InitOrder() : value2(value1 + 1), value1(10) { 
        // value1先被初始化为10
        // 然后value2被初始化为value1+1,即11
        std::cout << "value1: " << value1 << ", value2: " << value2 << std::endl;
        // 输出:value1: 10, value2: 11
    }
};

不过这并不符合阅读习惯,建议初始化列表和成员声明顺序一致。

覆盖类内成员初始化

C++11引入了类内成员初始化,但初始化列表会覆盖这些初始值:

class DefaultValues {
private:
    int value1 = 10;      // 类内初始值
    std::string name = "Default";  // 类内初始值
    
public:
    // 默认构造函数使用类内初始值
    DefaultValues() {
        std::cout << "Default values: " << value1 << ", " << name << std::endl;
    }
    
    // 初始化列表覆盖类内初始值
    DefaultValues(int v, const std::string& n) : value1(v), name(n) {
        std::cout << "Custom values: " << value1 << ", " << name << std::endl;
    }
};

缺省构造函数

如果类中没有定义任何构造函数,编译器会在条件允许时提供一个缺省构造函数。提供的构造函数会:

  • 对内置类型成员不进行初始化(在类定义之外)
  • 对类类型成员调用其缺省构造函数

前面说到在条件允许时,但如果类中有以下情况,编译器将不法提供缺省构造函数:

  • 存在用户定义的构造函数,这相当于告知编译器不需要提供缺省构造函数
  • 有没有默认构造函数的类成员,编译器不知道应该如何提供该成员的默认值
  • 有引用成员,同理
  • 有 const 成员且没有显示初始化,由于 const 成员变量必须被初始化,而编译器无法为它凭空生成一个合理的默认值,如果没有显示指定其值,则编译器将不法提供缺省构造函数
#include <iostream>

class Test {
private:
    const int value;  // const 成员变量,没有显示初始化
public:
    // 没有定义任何构造函数
};

int main() {
    Test t;  // 错误:编译器不会合成默认构造函数
    return 0;
}

Most Vexing Parse

C++中有一个著名的解析歧义问题,称为"最令人烦恼的解析"(Most Vexing Parse)。当试图使用缺省构造函数创建对象时可能遇到:

class Widget {};

void func() {
    // 意图:创建一个Widget对象
    Widget w();  // 错误!这被解析为函数声明,而不是对象定义
    
    // 正确方式:
    Widget w1;           // C++98/03风格
    Widget w2{};         // C++11列表初始化风格
    auto w3 = Widget();  // 使用赋值表达式
}

使用 default 关键字

C++11引入了default关键字,可以明确地要求编译器生成缺省构造函数:

class Explicit {
private:
    int value;
    std::string name;
    
public:
    // 显式请求编译器生成缺省构造函数
    Explicit() = default;
    
    // 定义其他构造函数
    Explicit(int v, const std::string& n) : value(v), name(n) {}
};

单一参数构造函数与隐式类型转换

单一参数构造函数(只接受一个参数的构造函数)在 C++ 不仅可以用来创建对象,还可以作为隐式类型转换的途径。

当有一个接受类型 A 的函数,但传入类型 B 的值,如果类 A 有一个接收 B 类型参数的构造函数,编译器会自动调用这个构造函数将 B 转换为 A。

比如:

class Number {
private:
    int value;
    
public:
    // 单一参数构造函数
    Number(int val) : value(val) {
        std::cout << "Converting " << val << " to Number object" << std::endl;
    }
    
    int getValue() const { return value; }
};

void processNumber(Number n) {
    std::cout << "Processing Number: " << n.getValue() << std::endl;
}

int main() {
    // 直接使用
    Number num1(42);
    processNumber(num1);
    
    // 隐式转换:int -> Number
    processNumber(100);  // 编译器自动将100转换为Number对象
    
    // 甚至可以这样
    Number num2 = 200;   // 隐式转换
    
    return 0;
}

单一参数构造函数的隐式转换允许开发者在不显示构造对象的情况下,将某种类型的值直接传递给需要目标类型的函数或赋值操作,然而,这种机制可能会引发一些意外的行为和潜在错误,比如:

  1. 意外的类型转换

    当一个类定义了单一参数构造函数时,编译器可能会在开发者未明确意图的情况下,自动将参数类型转换为目标类型。这种隐式转换可能发生在函数调用、赋值中。

    void processNumber(Number n) {
    std::cout << "Processing Number: " << n.getValue() << std::endl;
    }
    
    int main() {
        int x = 42;
        processNumber(x); // 开发者可能认为传入的是int,但实际隐式转换为Number
        return 0;
    }
    

    在这里,开发者可能根据函数名认为直接传入的是一个整数,而实际的参数是 Number 类型的对象。并且该类拥有一个接收 int 类型参数的构造函数,因此根据单一参数构造函数的隐式转换规则,编译器会自动调用这个构造函数创建一个 Number 类型的匿名对象。如果 Number 类的对象在构造过程中有资源分配、日志打印等操作,可能会导致意外的结果。

  2. 函数重载歧义

    如果存在多个函数重载,且参数类型可以通过隐式转换匹配多个重载版本,在某些编译器中可能无法决定调用哪个函数,导致编译错误。

    class Number {
    public:
        Number(int val) {}
        Number(double val) {}
    };
    
    void process(Number n) {}
    void process(double d) {}
    
    int main() {
        process(42); // 这里的歧义是转换为 Number 还是直接调用process(double)
        return 0;
    }
    

    在这里,42既可以通过Number(int)隐式转换为Number,也可以提升为double调用另一个重载版本,在某些编译器中会报错。

  3. 性能开销和副作用

    如果单一参数构造函数有副作用(比如修改全局状态、分配资源等),隐式转换可能在开发者未察觉的情况下触发这些副作用,导致难以调试的逻辑错误。

    class Number {
    public:
        Number(int val) {
            std::cout << "Creating Number from " << val << std::endl;
            // 假设这里有复杂的资源分配
        }
    };
    
    void process(Number n) {}
    
    int main() {
        int x = 10;
        process(x); // 副作用被意外触发
        return 0;
    }
    

    如果开发者未预期到隐式转换,Number构造函数的副作用(如日志输出或资源分配)可能导致程序行为不符合预期。

explicit 关键字

为了防止意外的隐式转换,C++ 提供了 explicit 关键字。使用 explicit 修饰的构造函数只能用于直接初始化,不能用于隐式转换。

class Number {
private:
    int value;
    
public:
    // 使用explicit防止隐式转换
    explicit Number(int val) : value(val) {
        std::cout << "Converting " << val << " to Number object" << std::endl;
    }
    
    int getValue() const { return value; }
};

void processNumber(Number n) {
    std::cout << "Processing Number: " << n.getValue() << std::endl;
}

int main() {
    // 直接初始化,正确
    Number num1(42);
    processNumber(num1);
    
    // 隐式转换,错误!
    // processNumber(100);  // 编译错误
    
    // 正确方式,显式转换
    processNumber(Number(100));
    
    // 赋值初始化,错误!
    // Number num2 = 200;   // 编译错误
    
    // 正确方式
    Number num2(200);
    // 或者C++11列表初始化
    Number num3{300};
    
    return 0;
}

explicit 关键字使得代码意图更加明确,避免上面提到的各种问题和错误。

拷贝构造函数

拷贝构造函数是一种特殊的构造函数,用于从同类型的另一个对象创建新对象。它接收一个同类型对象的常量引用作为参数。

class MyClass {
private:
    int* data;
    
public:
    // 普通构造函数
    MyClass(int value) : data(new int(value)) {
        std::cout << "Normal constructor: " << *data << std::endl;
    }
    
    // 拷贝构造函数
    MyClass(const MyClass& other) : data(new int(*other.data)) {
        std::cout << "Copy constructor: " << *data << std::endl;
    }
    
    // 析构函数
    ~MyClass() {
        std::cout << "Destructor: " << *data << std::endl;
        delete data;
    }
    
    int getValue() const { return *data; }
};

为什么需要传入引用 & 而不能直接传入对象?

如果拷贝构造函数的参数是按值传递(如MyClass other),那么在调用拷贝构造函数时,参数 other 本身需要通过拷贝构造的方式从实参创建。这会触发对拷贝构造函数的另一次调用,从而导致无限递归,导致栈溢出。

#include <iostream>

class MyClass {
private:
    int* data;
public:
    // 普通构造函数
    MyClass(int value) : data(new int(value)) {
        std::cout << "Normal constructor: " << *data << std::endl;
    }

    // 拷贝构造函数(错误设计:按值传递)
    MyClass(MyClass other) : data(new int(*other.data)) {
        std::cout << "Copy constructor: " << *data << std::endl;
    }

    // 析构函数
    ~MyClass() {
        std::cout << "Destructor: " << *data << std::endl;
        delete data;
    }
};

int main() {
    MyClass obj1(42);    // 创建第一个对象
    MyClass obj2(obj1);  // 试图通过拷贝构造函数创建第二个对象
    return 0;
}

在创建 obj1 时调用普通构造函数 MyClass(int value),分配内存并初始化data为42。

接下来希望通过 MyClass obj2(obj1) 调用拷贝构造函数 MyClass(MyClass other),用 obj1 作为参数使用其内容初始化 obj2。这里参数是按值传递的,意味着在进入拷贝构造函数之前,other 需要从 obj1 拷贝构造出来。为了构造 other,编译器需要将 obj1 作为参数调用拷贝构造函数 MyClass(MyClass other),在这次调用中,新的 other 又需要从参数 obj1 拷贝构造,因此陷入无限循环。类似:

MyClass(MyClass other) -> MyClass(MyClass other) -> MyClass(MyClass other) -> ...

究其原因 C++ 的构造函数调用是基于参数类型的,在MyClass obj2(obj1)中,obj1是MyClass类型,编译器会寻找一个能接受 MyClass 类型参数的构造函数。只有 MyClass(MyClass other) 接受MyClass类型,因此被选中。

这里会问为什么 other 不能直接赋值为 obj1 的副本而不触发构造函数?

在C++中,当函数参数是按值传递时(比如MyClass other),参数other是一个新的局部对象,它必须在函数调用时被创建并初始化。

这个新对象 other 的创建过程不能简单地“赋值”或“内存拷贝”,对象构造必须通过构造函数,这是 C++ 规定的,任何对象的创建都必须通过调用某个构造函数来完成。这是语法的基本规则,确保对象在生命周期开始时被正确初始化。按值传递意味着 other 是 obj1 的一个独立副本。这个副本的创建过程由编译器自动管理,而编译器会选择拷贝构造函数来完成这项工作。

假如 C++ 允许 other 直接通过内存复制(bitwise copy)或赋值的方式从obj1创建,而不调用构造函数,这就会导致很多问题。比如,对于包含指针的类(如int* data),直接复制会导致 obj1 和 other 的 data 指向同一块内存,这种情况是非常危险的,我们不知道可能会从哪个临时对象中修改同一块内存中的值,会导致未定义行为,而且释放内存时会多次 delete。同时,这也违反封装性,C++的类设计依赖构造函数来初始化对象状态。如果绕过构造函数直接赋值,会破坏类的封装性和初始化逻辑。

即使我们假设无限递归的问题能被某种神奇的方式解决(实际上不可能),按值传递仍然会带来很多性能问题,比如,临时对象创建:每次按值传递都会创建一个完整的对象副本。如果类有动态分配的资源,需要深拷贝(分配新内存并复制数据),这会增加时间和空间开销。
析构开销:临时对象在函数结束后会被销毁,又会触发析构函数,增加额外开销。


正确的方式是使用引用(浅拷贝)来避免拷贝构造函数递归调用的问题:

#include <iostream>

class MyClass {
private:
    int* data;
public:
    // 普通构造函数
    MyClass(int value) : data(new int(value)) {
        std::cout << "Normal constructor: " << *data << std::endl;
    }

    // 拷贝构造函数(正确设计:引用传递)
    MyClass(MyClass& other) : data(new int(*other.data)) {
        std::cout << "Copy constructor: " << *data << std::endl;
    }

    // 析构函数
    ~MyClass() {
        std::cout << "Destructor: " << *data << std::endl;
        delete data;
    }
};

int main() {
    MyClass obj1(42);    // 创建第一个对象
    MyClass obj2(obj1);  // 通过拷贝构造函数创建第二个对象
    return 0;
}

创建obj1,调用普通构造函数。调用拷贝构造函数 MyClass(const MyClass& other)。other是一个对 obj1 的引用(内存地址),不需要拷贝obj1,只是直接访问它的内容。构造obj2,分配新内存并将*other.data(即42)复制过去。

但是这样依然存在上面说过的问题,可能通过引用误操作 obj1 中的内容。

为什么需要加const修饰符?

为此引入 const 修饰符,保护原始对象不被修改,拷贝构造函数的目的是从源对象创建一个新对象,而不应修改源对象的内容。使用const确保参数在拷贝构造函数中是只读的,避免意外修改。
同时,支持从临时对象拷贝,C++中临时对象(右值)只能绑定到const引用。如果没有const,拷贝构造函数无法接受临时对象作为参数,这会限制其使用场景。

class MyClass {
private:
    int* data;
public:
    MyClass(int value) : data(new int(value)) {}
    MyClass(MyClass& other) : data(new int(*other.data)) {
        *other.data = 0; // 可以修改原始对象
    }
    ~MyClass() { delete data; }
};

int main() {
    MyClass obj1(42);
    MyClass obj2(obj1); // obj1的内容可能被意外修改
    return 0;
}

如上例,obj1的data可能被改为0,违背了拷贝构造函数的初衷。

MyClass getObject() { return MyClass(42); }
int main() {
    MyClass obj = getObject(); // 错误:非const引用不能绑定到临时对象
    return 0;
}

临时对象无法绑定到非const引用,导致编译错误。

编译器合成的拷贝构造函数

如果开发者没有显示定义拷贝构造函数,编译器会自动提供一个“合成的拷贝构造函数”。合成的拷贝构造函数会逐个成员调用其类型的拷贝构造函数(对于内置类型如int是直接复制,对于类类型使用其构造函数)。

#include <iostream>

class MyClass {
public:
    int value;
    MyClass(int v) : value(v) {}
    // 未定义拷贝构造函数,编译器合成
};

int main() {
    MyClass obj1(42);
    MyClass obj2(obj1); // 合成的拷贝构造函数
    std::cout << obj2.value << std::endl; // 输出 42
    return 0;
}

如果类有指针成员,合成的拷贝构造函数只进行浅拷贝(复制指针值)

#include <iostream>

class MyClass {
public:
    int* data;
    MyClass(int v) : data(new int(v)) {}
    ~MyClass() { delete data; }
    // 未定义拷贝构造函数
};

int main() {
    MyClass obj1(42);
    MyClass obj2(obj1); // 合成的拷贝构造函数:浅拷贝
    // obj1.data 和 obj2.data 指向同一内存
    return 0; // 双重释放崩溃
}

新对象和源对象的指针成员指向同一块内存,如果类的析构函数释放这块内存,会导致未定义行为。

为了避免浅拷贝的问题,可以显示定义拷贝构造函数,并给出深拷贝的定义。

#include <iostream>

class MyClass {
public:
    int* data;
    MyClass(int v) : data(new int(v)) {
        std::cout << "Normal constructor: " << *data << std::endl;
    }
    MyClass(const MyClass& other) : data(new int(*other.data)) {  // 深拷贝
        std::cout << "Copy constructor (deep): " << *data << std::endl;
    }
    ~MyClass() {
        std::cout << "Destructor: " << *data << std::endl;
        delete data;
    }
};

int main() {
    MyClass obj1(42);
    MyClass obj2(obj1);
    std::cout << "obj1.data: " << obj1.data << ", *obj1.data: " << *obj1.data << std::endl;
    std::cout << "obj2.data: " << obj2.data << ", *obj2.data: " << *obj2.data << std::endl;
    return 0; // 正常析构
}

什么场景下会用到拷贝构造函数

  1. 显式拷贝初始化:
    • 使用已有对象直接构造新对象。
    • 示例:MyClass obj2(obj1); 或 MyClass obj2 = obj1;
  2. 参数按值传递:
    • 当对象作为函数参数按值传递时,需要创建参数的副本。
    • 示例:void func(MyClass param); func(obj1);
  3. 函数返回对象:
    • 函数返回一个对象时,返回值的副本会被创建(尽管现代编译器可能通过返回值优化RVO避免拷贝)。
    • 示例:MyClass func() { MyClass obj; return obj; }

移动构造函数

移动构造函数是 C++11 引入的一种特殊构造函数,用于从一个右值引用(rvalue reference,通常写作T&&)参数创建新对象。它的核心目的是通过转移输入对象的资源来初始化新对象,而不是进行拷贝,从而提高性能。

ClassName(ClassName&& other)

接收当前类的右值引用,表示一个即将被销毁或不再需要的临时对象,原理和右值引用是一致的,只不过是换成类的对象,通过延长右值所使用的内存空间的生命周期,避免内存拷贝的开销。通过 std::move()
将一个左值标记为右值,或者开始就是匿名对象或临时对象,不具备持久的内存地址空间,通过延长内存空间的生命周期,让新对象保存原始对象的内容,对于基本数据类型成员比如int,直接复制其值,对于动太内存分配的大内存,通过拷贝保存这块内存的指针或者引用,并将源对象中的内容置为 nullptr,即合法的空状态。

传统的拷贝构造函数(如ClassName(const ClassName&))在自定义深拷贝操作中会执行深拷贝,即复制源对象的所有数据(包括动态分配的资源),而移动构造函数更像是浅拷贝操作,但又不同于浅拷贝,而是转移所有权,这种操作通过将源对象的成员(如指针)被置为nullptr或类似状态,但这并不意味着整个对象的内存空间被清空,只是资源被转移后,源对象的状态被调整为“无资源”,而浅拷贝会共享资源。

移动构造函数的目标是高效转移资源,而不是销毁源对象。它的设计初衷是优化性能,避免深拷贝的开销,和浅拷贝的资源共享。
移动后,源对象并没有被“删除”或“释放”,而是被置于一个合法但通常为空的状态(valid but unspecified state)。这是C++移动语义的关键原则:源对象仍然可以被安全使用或析构。

 // 移动构造函数
    MyString(MyString&& other) noexcept : data(other.data), length(other.length) {
        std::cout << "Move constructor: stealing " << data << "\n";
        other.data = nullptr;    // 清空源对象的指针
        other.length = 0;        // 重置源对象的状态
    }

合成的移动构造函数

如果类没有显式定义移动构造函数(或其他特殊成员函数,如拷贝构造函数等),且满足一定条件,编译器会自动生成一个默认的移动构造函数。

对类的每个成员执行逐成员移动(调用其移动构造函数或直接转移)。

如果没有类类型成员没有移动构造函数,有些编译器会使用拷贝构造函数,或者报错。

struct Str2
{
    Str2() = default;
    Str2(const Str2&)
    {
        cout << "Str2's copy constructor is called" << endl;
    }
    // Str2(Str2&&)
    // {
    //     cout << "Str2's move constructor is called" << endl;
    // }
};

struct Str
{
    Str() = default;
    Str(const Str&) = default;
    Str(Str&&) = default;

    int val = 0;            // 基本数据类型直接拷贝
    std::string a = "abc";  // 使用 string 的移动构造函数
    Str2 m_str;             // 有移动构造函数调用移动构造函数,没有移动构造函数,有些编译器会使用拷贝构造函数
};

int main()
{
    Str m;
    Str m2 = std::move(m);  // 会依次对 m 中的成员使用 move
}

通常声明为noexcept

移动构造函数通常被声明为noexcept,表示它不会抛出异常。

移动操作通常只是指针转移,不涉及资源分配,不应失败。
标准库容器(如std::vector)在重新分配内存时会优先使用noexcept的移动构造函数,以确保强异常安全性。如果移动构造函数可能抛异常,容器会退回到拷贝构造函数,降低性能。

右值引用对象用作表达式时是左值

在移动构造函数中,参数是右值引用(T&&),但它本身是一个命名的变量,因此在函数体内被视为左值。也就是说可以通过具名对象对内存进行访问。

如果需要继续将其作为右值使用,必须显式调用std::move。

#include <iostream>

class MyClass {
public:
    MyClass() { std::cout << "Constructor\n"; }
    MyClass(MyClass&& other) {
        std::cout << "Move constructor\n";
        // other是左值,不能直接再次移动
        MyClass temp(std::move(other));  // 必须用std::move
    }
};

int main() {
    MyClass obj1;
    MyClass obj2(std::move(obj1));
    return 0;
}