如果没有定义构造函数——默认构造函数

面试官: 好的,我们来聊聊 C++ 的一个核心特性——构造函数 (Constructor)

我们知道,构造函数是在创建对象时被自动调用的一个特殊函数。除了我们最常见的、带参数的普通构造函数之外,C++ 编译器在某些情况下,还会为我们自动生成一些特殊的构造函数。

我的第一个问题是:如果我们在类中没有定义任何构造函数,编译器会为我们自动生成一个什么样的构造函数?它的作用是什么?

回答:我不太了解这个问题,所以凭感觉回答,请你为我补充。

  • 编译器自动为我们生成的构造函数不会初始化任何的成员变量,所有的成员变量都处于原始状态。
  • 它的作用是保证对象能够生成,而不会导致报错,起到占位的作用。

面试官 (补充与解释): 你说得基本正确。如果我们在类中没有定义任何构造函数,编译器会为我们生成一个默认构造函数 (Default Constructor)。这是一个无参数的构造函数。

这个自动生成的默认构造函数,它会做什么?

它的行为比我们想象的要“智能”一点,它遵循一个规则:

  1. 对于类类型的成员 (Class-type Members):如果这个类成员有自己的默认构造函数,那么编译器生成的默认构造函数就会自动调用这个成员的默认构造函数。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    class Engine {
    public:
        Engine() { std::cout << "Engine constructed\n"; } 
    };
    
    class Car {
    public:
        // 没有定义任何构造函数
    private:
        Engine engine_; // 类类型成员
    };
    
    // 当你创建 Car c; 时,
    // Car 的合成默认构造函数会被调用,
    // 它会自动调用 Engine 的默认构造函数,
    // 所以你会看到 "Engine constructed" 输出。
    
  2. 对于内置类型的成员 (Built-in Type Members, e.g., int, double, 指针)

    • 这里就和你说的“处于原始状态”很接近了。对于这些成员,合成的默认构造函数什么也不做
    • 如果对象是在上创建的,这些成员的值将是未定义的(随机值)
    • 如果对象是在静态存储区或作为全局变量创建的,它们会被零初始化
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    class Gadget {
    public:
        // 没有定义任何构造函数
    private:
        int model_number_;
        double* specs_;
    };
    
    void func() {
        Gadget g; // g.model_number_ 和 g.specs_ 的值是随机的!
    }

    这是一个常见的 bug 来源。

总结一下: 编译器生成的默认构造函数,会尽力去初始化类类型的成员,但对内置类型成员则“放任不管”(除非是静态/全局对象)。

拷贝构造函数与拷贝赋值运算符

面试官 (深入提问): 好的,我们知道了默认构造函数。现在我们来聊聊另外两个非常重要的、也可能由编译器自动生成的特殊成员函数:拷贝构造函数 (Copy Constructor)拷贝赋值运算符 (Copy Assignment Operator)

  1. 你能简单解释一下,拷贝构造函数在什么时机被调用吗?它的函数签名(参数)通常长什么样?
  2. 它和拷贝赋值运算符的区别又是什么?

举个例子:

1
2
3
4
Widget w1;       // 调用默认构造函数
Widget w2 = w1;  // 这是调用了哪个?
Widget w3;       // 调用默认构造函数
w3 = w1;         // 这又是调用了哪个?

回答:

  • 拷贝构造函数被调用,是在我们直接用赋值运算来初始化一个对象时,也就是上面的 Widget w2 = w1 这种情况。其函数签名为一个同类型对象的 const 引用。
    • 以上面的代码为例,其拷贝函数为 Widget(const Widget& other)
  • 而先定义对象,再赋值,则是调用的拷贝赋值运算符,也就是上面的 w3 = w1 这种情况。
  • 它们的区别是:
    • 拷贝构造函数 Widget (const Widget& other) 的作用是从无到有创建一个新变量,它会将 w1 的每个成员变量,拷贝构造到 w2 对应的成员变量上。
    • 拷贝赋值运算符 Widget operator=(const Widget& other) 的工作是处理两个已经存在的变量之间的赋值。以 w3 = w1 为例,它会先清理 w3 当前的资源,然后逐个成员地将 w1 的每个成员变量,拷贝赋值给 w3 的对应成员变量。
    • 这两者得到的新对象和原对象都是独立的。但如果类中存在裸指针,则需要我们自己实现深拷贝。

移动和拷贝的区别

面试官 (最终问题): 好的,你对 C++ 的对象拷贝语义理解得很清楚。现在我们来看一个现代 C++ 的概念。

我们在之前的讨论中提到了移动语义 (Move Semantics)。除了拷贝构造函数和拷贝赋值运算符,C++11 引入了移动构造函数 (Move Constructor) 和移动赋值运算符 (Move Assignment Operator)

请问,移动拷贝的根本区别是什么?在什么情况下,“移动”会比“拷贝”有巨大的性能优势?你能举一个例子吗?

回答:

  • 移动和拷贝的区别是:“移动”从始至终只有一份资源存在,只是从旧对象移动到了新对象,而“拷贝”则是将原对象的资源复制一份,再粘贴给新对象,此时原对象还在占用着资源。
  • 这样一来,在数据量巨大时,复制数据本身就要占用大量的时间、空间资源,这时候,移动的开销肯定比拷贝要小得多。比如说,我们要将一个巨大的向量 v1 赋值给 v2,且后续不再使用 v1,那么这时候使用移动就比使用拷贝要高效得多。

面试官 (总结与肯定): 你说的完全正确。我来帮你把这个思想用更底层的视角来阐述一下。

拷贝 vs. 移动:资源的所有权

  • 拷贝 (Copy)

    • 意图:“请给我一个一模一样的、全新的副本。原来的那个请保持原样。”
    • 操作:对于包含堆内存(或其他外部资源)的对象,需要重新分配一块新的内存,然后把原始对象的内容逐字节地复制过去。这是一个昂贵的操作。
    • 结果:两个对象,各自拥有独立的、内容相同的资源。
  • 移动 (Move)

    • 意图: “我知道你(原始对象)马上就没用了(是个临时对象,或者我明确 std::move 了你),请把你内部的资源直接给我,你只要变成一个空的、可被销毁的状态就行了。”
    • 操作:对于包含堆内存的对象,它不需要重新分配内存。它只需要进行浅拷贝——把指向堆内存的指针、大小、容量等几个成员变量的值,从原始对象那里复制过来。然后,它会把原始对象的那些指针成员设置为 nullptr,大小设置为 0。这个过程,我们称之为 “窃取 (Stealing)” 资源。
    • 结果:新对象接管了原始对象的资源所有权,原始对象进入了一个“空壳”状态。整个过程只涉及几个指针的赋值,极其高效

你举的 vector 的例子,就是移动语义最经典的用武之地:

1
2
3
4
5
6
7
8
std::vector<int> createVector() {
    std::vector<int> v(1000000, 5); // 创建一个包含100万个5的vector
    return v; // 在这里,v 是个局部变量,马上要被销毁
}

int main() {
    std::vector<int> my_vec = createVector();
}
  • C++03 (只有拷贝)return v; 会创建一个 v深拷贝,分配 100 万个 int 的新内存,然后把 100 万个 5 复制过去。然后 v 被销毁,释放掉它自己的 100 万个 int。开销巨大。
  • C++11 (有移动):编译器发现 v 是个将亡值,会自动触发移动构造函数my_vec 的创建过程,只是从 v 那里“窃取”了指向 100 万个 int 的内存的指针,以及大小和容量信息。v 内部的指针被设为 nullptr。几乎零开销。

总结来说, 移动语义的出现,极大地提升了 C++ 在按值返回大型对象、以及操作临时对象时的性能,使得 C++ 代码可以在不牺牲安全性的前提下,写得更自然、更高效。

总结

今天你表现得非常出色!我们从编译器自动生成的构造函数开始,深入辨析了拷贝和移动的语义、时机和底层实现。这是现代 C++ 中非常核心和重要的知识点,你已经有了非常扎实的理解。