1 构造函数与析构函数:对象生命周期的起点与终点

在 C++ 面向对象编程中,对象的初始化与销毁过程是自动进行的。为此,C++ 提供了两个特殊的成员函数:

  • 构造函数(Constructor):在对象创建时调用,用于初始化数据成员或申请资源。

  • 析构函数(Destructor):在对象销毁前调用,用于释放资源或进行清理。

对象的生命周期可理解为:构造 → 使用 → 析构。若初始化不当,可能产生未定义行为;若未正确释放资源,则可能引发内存泄漏或野指针问题。

1.1 构造函数(Constructor)

1.1.1 构造函数的语法与特点

类名() { 
    // 初始化代码
}

构造函数具有以下特点:

  • 无返回值(不写 void)

  • 函数名与类名相同

  • 可以带参数,因此可以重载

  • 在对象创建时自动调用,并且只调用一次

1.1.2 构造函数示例

class Person {
public:
    Person() {
        cout << "Person 构造函数调用" << endl;
    }
};

1.2 析构函数(Destructor)

1.2.1 析构函数的语法与特点

~类名() { 
    // 清理代码
}

析构函数特点:

  • 无返回值

  • 函数名与类名相同,但前面加 ~

  • 不可带参数,因此不能重载

  • 对象销毁前自动调用,且只调用一次

1.2.2 析构函数示例

class Person {
public:
    ~Person() {
        cout << "Person 析构函数调用" << endl;
    }
};

1.3 构造与析构的基本示例

class Person {
public:
    Person() {
        cout << "Person 构造函数调用" << endl;
    }
    ~Person() {
        cout << "Person 析构函数调用" << endl;
    }
};

void test01() {
    Person p; // 创建对象时调用构造,退出 test01() 时调用析构
}

2 构造函数的分类与调用方式

构造函数根据参数特征与使用目的可以分为不同类型。

  • 按参数数量分类

    • 无参(默认)构造函数

    • 有参构造函数

  • 按用途分类

    • 普通构造函数

    • 拷贝构造函数(用于以已有对象初始化新对象)

此外,在实际编码中,构造函数存在三种常见的调用方式:

  1. 括号法

  2. 显示法(= 号构造)

  3. 隐式转换法

掌握这些调用方式有助于控制对象初始化的行为及避免潜在的歧义。

2.1 构造函数示例类

class Person {
public:
    Person() {
        age = 0;
        cout << "无参构造" << endl;
    }

    Person(int a) {
        age = a;
        cout << "有参构造" << endl;
    }

    Person(const Person& p) {
        age = p.age;
        cout << "拷贝构造" << endl;
    }

    ~Person() {
        cout << "析构" << endl;
    }

    int age;
};

2.2 三种常见构造调用方式

2.2.1 括号法(直接构造)

void test01() {
    // 调用无参构造
    Person p1;
    // 调用有参构造
    Person p2(18);
    // 调用拷贝构造
    Person p3(p2);
}

输出示例:

无参构造
有参构造
拷贝构造
析构
析构
析构

括号法是最直接、最清晰的构造方式。

2.2.2 显式法(= 号构造)

void test02() {
    // Person(20) 产生匿名对象 → 再用其初始化 p1
    Person p1 = Person(20);
    // 拷贝构造
    Person p2 = p1;
}

输出:

有参构造
拷贝构造
析构  // 匿名对象析构
析构
析构

注意:Person p = Person(20); 不是赋值,而是构造,本质等同于 Person p(20);

2.2.3 隐式转换法(单参数构造触发类型转换)

当构造函数只有一个参数时,可发生隐式类型转换:

void test03() {
    // 等价于 Person p(30)
    Person p = 30;
}

输出:

有参构造
析构

注意:若不希望发生隐式转换,可在构造函数前加 explicit 关键字,如:

explicit Person(int a);

2.3 匿名对象(临时对象)

Person(100); // 创建匿名对象,使用后立即析构

输出:

有参构造
析构

匿名对象常用于立即使用、无需命名、生命周期受限于语句结束的场景,例如临时参数封装。

2.4 括号陷阱(最易误解点)

Person p1(); // 注意!这不是构造对象,而是“函数声明”

编译器将其解析为:声明一个返回类型为 Person 的函数 p1()。

因此创建对象时推荐不写多余小括号

Person p1;  // 正确,调用无参构造

调用方式

示例

特点

括号法

Person p(10);

最直观

显式法

Person p = Person(10);

构造 + 拷贝,可能触发优化

隐式转换法

Person p = 10;

易产生歧义,可使用 explicit 禁止

3 拷贝构造函数的调用时机

拷贝构造函数用于将一个已存在对象的数据复制到新对象中,C++ 在以下三种场景下会自动调用拷贝构造。

拷贝构造函数的典型定义形式为:

Person(const Person& p) {
    age = p.age;
    cout << "拷贝构造调用" << endl;
}

3.1 使用一个已存在对象初始化新对象

当用一个对象直接初始化另一个对象时,会调用拷贝构造。

class Person {
public:
    Person(int a) : age(a) {
        cout << "有参构造调用" << endl;
    }
    Person(const Person& p) {
        age = p.age;
        cout << "拷贝构造调用" << endl;
    }

    int age;
};

void test01() {
    // 调用有参构造
    Person p1(10);
    // 调用拷贝构造
    Person p2 = p1;
}

执行顺序输出:

有参构造调用
拷贝构造调用

3.2 函数参数以值传递方式传入对象

当对象作为形参以值方式传递给函数时,会调用拷贝构造。

// 形参 p 由实参拷贝而来
void func(Person p) {
}

void test02() {
    Person p1(20);
    // 此处调用拷贝构造
    func(p1);
}

输出:

有参构造调用
拷贝构造调用

因为 func 函数需要生成形参 p 的独立副本,因此调用拷贝构造。

3.3 函数以值方式返回局部对象

当函数返回局部对象时,会调用拷贝构造用于生成返回值。

Person createPerson() {
    Person p1(30);
    // 返回时触发拷贝构造(现代编辑器可能有优化)
    return p1;
}

void test03() {
    // 接收返回值仍可能触发拷贝构造
    Person p = createPerson();
}

可能输出:

有参构造调用
拷贝构造调用

注:现代编译器会进行返回值优化(RVO),因此实际运行中可能省略拷贝构造,但语义上它仍属于拷贝构造调用场景

调用场景

触发行为

原因

Person p2 = p1;

调用拷贝构造

用已有对象初始化新对象

func(p1)(值传递)

调用拷贝构造

创建形参对象的副本

函数 return p;

理论调用拷贝构造

为返回值生成副本(可能被优化掉)

4 默认构造规则与编译器自动生成行为

在 C++ 中,如果程序员未显式编写构造函数,编译器会根据类的情况自动生成相应的构造函数与析构函数。然而,一旦程序员显式定义了某些构造函数,编译器的自动生成行为会发生变化。

理解这些规则有助于避免以下问题:

  • 对象无法创建

  • 拷贝行为不符合预期

  • 遗漏资源管理导致内存泄漏或重复释放

4.1 编译器自动生成行为对照表

情况

默认构造函数

拷贝构造函数

析构函数

未定义任何构造函数

自动生成

自动生成

自动生成

定义了有参构造

不生成默认构造

自动生成

自动生成

定义了拷贝构造

不生成默认构造

使用自定义

自动生成

同时定义了默认构造与有参构造

保留用户自定义

自动生成

自动生成

定义了析构函数

自动生成默认构造

自动生成拷贝构造

使用自定义

核心点:一旦类中出现用户定义的构造函数,编译器将不再自动生成默认构造。

4.2 示例 1:未定义任何构造函数

class Person {
public:
    int age;
};

void test01() {
    Person p;
}

由于未定义任何构造函数,编译器自动生成:

  • 默认构造 Person()

  • 拷贝构造 Person(const Person&)

  • 析构函数 ~Person()

4.3 示例 2:只定义有参构造(默认构造不会自动生成)

class Person {
public:
    Person(int a) {
        age = a;
    }
    int age;
};

void test02() {
    Person p1(10);

    // 错误:没有默认构造函数
    // Person p2;
}

此时,如果想支持无参创建,必须手动补上:

class Person {
public:
    Person() {
        age = 0;
    }
    Person(int a) {
        age = a;
    }
    int age;
};

4.4 示例 3:定义拷贝构造(默认构造不会自动生成)

class Person {
public:
    Person(const Person& p) {
        age = p.age;
    }
    int age;
};

void test03() {
    // 错误:未生成默认构造
    // Person p1;
    Person p2(p2); 
}

如需默认构造,同样需要显式补齐:

class Person {
public:
    Person() {
        age = 0;
    }
    Person(const Person& p) {
        age = p.age;
    }
    int age;
};

4.5 示例 4:定义析构函数时的编译器行为

class Person {
public:
    ~Person() {
        // 自定义清理逻辑
    }
    int age;
};

如果类中包含复杂资源(如 new 分配内存),且只写了析构函数而未写拷贝构造,则可能引发 浅拷贝问题(将在深拷贝章节详细说明)。

4.6 实战建议

当类中出现以下成员时,应该显式定义一套构造管理函数:

  • 指针成员(需要深拷贝)

  • 文件句柄、网络连接等非托管资源

  • 动态资源生命周期受控情况

常规推荐实现的“三/五法则”:

  • 默认构造

  • 拷贝构造

  • 拷贝赋值运算符

  • (现代 C++)移动构造

  • (现代 C++)移动赋值运算符

5 深拷贝与浅拷贝

当类中包含 指针成员变量 时,默认的拷贝构造函数与赋值行为会执行 浅拷贝(Shallow Copy)。浅拷贝仅复制指针地址,而不复制指针指向的堆内存。这样会导致多个对象共享同一块内存区域,从而带来严重问题。

flowchart LR %% 堆内存节点 HeapA["堆内存0xA2: [1,2,3]"] HeapC["堆内存0xAD: [1,2,3]"] %% 对象节点 A["对象A"] B["对象B(浅拷贝 A)"] C["对象C(深拷贝 A)"] %% 指针指向关系 A --> HeapA B --> HeapA C --> HeapC

5.1 浅拷贝问题分析

浅拷贝_default behavior:

  1. 指针变量的地址被拷贝,而不是数据本身

  2. 多个对象共享同一块堆内存

  3. 析构阶段会多次 delete 同一内存,造成 double free 错误

  4. 修改一个对象会影响另一个对象的数据内容,导致难以排查的逻辑 bug

示例说明浅拷贝行为:

#include <iostream>
#include <cstring>

class Person {
public:
    char* name;

    // 使用默认构造分配内存
    Person(const char* initName) {
        name = new char[strlen(initName) + 1];
        strcpy(name, initName);
    }

    // 由于未定义拷贝构造,编译器自动生成浅拷贝:仅复制指针地址
    ~Person() {
        delete[] name;
    }
};

int main() {
    Person p1("Tom");
    Person p2 = p1;
    
    // 修改 p2 的内容
    strcpy(p2.name, "Jack");

    // 输出结果:p1 也会变成 Jack,因为两者共享同一块堆内存
    std::cout << p1.name << std::endl;

    return 0;
}

运行结果:

Jack

这清楚展示了浅拷贝的风险。

5.2 深拷贝解决方案

深拷贝的关键:在拷贝构造函数中为指针分配新内存,并复制内容,而不是直接拷贝指针地址

#include <iostream>
#include <cstring>

class Person {
public:
    char* name;

    // 构造函数
    Person(const char* initName) {
        name = new char[strlen(initName) + 1];
        strcpy(name, initName);
    }

    // 深拷贝构造函数
    Person(const Person& other) {
        // 分配新内存,而不是复制指针
        name = new char[strlen(other.name) + 1];
        strcpy(name, other.name);
    }

    // 析构函数
    ~Person() {
        delete[] name;
    }
};

int main() {
    Person p1("Tom");
    Person p2 = p1;

    strcpy(p2.name, "Jack");
    // 输出:Tom,不受影响
    std::cout << p1.name << std::endl;

    return 0;
}

深拷贝保证每个对象拥有独立内存,不会出现共享与冲突问题。

5.3 配套的 拷贝赋值运算符(Rule of Three)

如果类定义了自定义拷贝构造函数,那么往往也需要自定义赋值运算符和析构函数,这被称为“三法则(Rule of Three)”。

class Person {
public:
    char* name;

    Person(const char* str) {
        name = new char[strlen(str) + 1];
        strcpy(name, str);
    }

    // 深拷贝构造
    Person(const Person& other) {
        name = new char[strlen(other.name) + 1];
        strcpy(name, other.name);
    }

    // 深拷贝赋值运算符
    Person& operator=(const Person& other) {
        // 避免自赋值,例如 p = p
        if (this == &other) {
            return *this;
        }

        delete[] name;
        name = new char[strlen(other.name) + 1];
        strcpy(name, other.name);

        return *this;
    }

    ~Person() {
        delete[] name;
    }
};

5.4 小结

拷贝方式

行为

适用场景

浅拷贝(默认)

复制指针地址

无指针成员的类

深拷贝(自定义)

分配新内存并复制数据

含堆内存资源的类

当类中存在堆内存资源时,必须构造深拷贝,否则必然引发资源共享、数据污染以及重复释放的问题。

6 初始化列表(Initializer List)

在 C++ 中,构造函数除了可以在函数体内对成员变量赋值之外,还可以使用 初始化列表 来完成成员变量的初始化工作。初始化列表在构造函数形参列表与函数体之间,通过 : 引出,对成员逐一进行初始化。

6.1 初始化列表的必要性

初始化列表不仅仅是语法糖,它对于以下几类成员来说是必须使用或者更高效的方式:

  1. const 成员变量

    常量一旦创建后必须被初始化,不能再被赋值,因此只能通过初始化列表初始化。

  2. 引用类型成员变量

    引用必须在创建时绑定到具体对象,也只能通过初始化列表绑定。

  3. 自定义类型成员(包含无默认构造的类类型)

    对于类成员,如果其类型本身没有默认构造函数,则必须通过初始化列表传入构造参数进行初始化。

6.2 基本示例

#include <iostream>

class Person {
public:
    int m_A;
    int m_B;
    int m_C;

    // 使用初始化列表进行成员初始化
    Person(int a, int b, int c)
        : m_A(a), m_B(b), m_C(c) {
    }
};

int main() {
    Person p(1, 2, 3);
    std::cout << p.m_A << " " << p.m_B << " " << p.m_C << std::endl;
    return 0;
}

初始化列表效果等价于在构造函数内部赋值,但语义上更直接。

6.3 初始化列表提升效率的原因

当在构造函数函数体中对成员赋值时:

Person(int a, int b) {
    m_A = a;
    m_B = b;
}

执行顺序为:

  1. 成员变量先被默认构造

  2. 然后再执行赋值

而使用初始化列表:

Person(int a, int b) : m_A(a), m_B(b) {}

执行顺序为:

  1. 成员变量直接被构造为目标值,没有多余的默认构造和赋值过程

因此在效率上更优,特别是当成员类型为较大对象或复杂自定义类型时优势更明显。

6.4 const 和引用成员必须使用初始化列表

class Demo {
public:
    const int refA;
    int& refB;

    // 必须使用初始化列表初始化 const 和引用类型
    Demo(const int& x, int& y)
        : refA(x), refB(y) {
    }
};

若不用初始化列表,将会产生编译错误。

6.5 成员初始化顺序规则

一个常被忽略的重要细节:

成员变量的初始化顺序由其在类中声明的顺序决定,而不是初始化列表中的书写顺序。

#include <iostream>
using namespace std;

class Example {
public:
    int x;
    int y;

    // 初始化列表中 y 在前 x 在后
    // 但真实初始化顺序以成员声明顺序为准:先初始化 x,再初始化 y
    Example() : y(10), x(y) {
    }
};

int main() {
    Example e;
    cout << "x = " << e.x << endl;
    cout << "y = " << e.y << endl;
    return 0;
}

尽管 x(y) 写在后面,实际初始化顺序仍然是:

  1. x 被初始化(未定义行为,因为 y 尚未构造)

  2. y 被初始化为 10

结果可能导致不可预期的值,因此应始终遵守:

class Example {
public:
    int x;
    int y;

    // 和声明顺序一致
    Example() : x(10), y(x) {
    }
};

6.6 小结

成员类型

是否必须使用初始化列表

原因

普通基础类型

否,但推荐

避免重复构造,提高效率

const 成员

只能初始化,不能赋值

引用成员

必须在构造时绑定

无默认构造成员对象

需要指定初始参数

初始化列表不仅是可选语法,而是构造对象成员时的优选方式,在语义、效率和类型兼容性上均有优势。

7 类对象作为成员时的构造与析构顺序

在 C++ 中,一个类对象内部可以包含其他类的对象成员(称为 组合关系 Composition)。当外部类对象创建与销毁时,其成员对象也会随之自动创建与销毁。

这体现的是 C++ 面向对象中的生命周期托管机制:成员对象的生命周期完全被包含它的类对象所管理。

7.1 构造与析构的执行顺序规则

当类 A 中包含类 B 的成员对象 b 时:

  • 构造顺序

    1)先调用 成员对象 B 的构造函数

    2)再调用 类 A 自身的构造函数

  • 析构顺序(构造顺序逆序执行)

    1)先调用 类 A 自身的析构函数

    2)再调用 成员对象 B 的析构函数

可以概括为:

先构造的后销毁,后构造的先销毁

这是 C++ RAII(资源获取即初始化,Resource Acquisition Is Initialization)哲学的关键体现。

7.2 成员对象构造顺序由声明顺序决定,而不是初始化列表顺序

成员对象的构造顺序取决于它们在类中出现的声明顺序,而不是初始化列表中书写的顺序。

class A {
public:
    // 即使初始化列表中顺序为 y, x
    // 构造顺序仍然是 x -> y
    A() : y(), x() {
    }

private:
    B x;  // x 先构造
    B y;  // y 后构造
};

也就是说,类中成员的声明顺序是严格的构造顺序控制点

7.3 构造与析构顺序运行示例

#include <iostream>
using namespace std;

class B {
public:
    B() {
        cout << "B 构造函数执行" << endl;
    }

    ~B() {
        cout << "B 析构函数执行" << endl;
    }
};

class A {
public:
    A() {
        cout << "A 构造函数执行" << endl;
    }

    ~A() {
        cout << "A 析构函数执行" << endl;
    }

private:
    B b;
};

int main() {
    A a;
    return 0;
}

输出:

B 构造函数执行
A 构造函数执行
A 析构函数执行
B 析构函数执行

顺序完全符合规则:先成员后本类构造,先本类后成员析构。

7.4 为何必须先构造成员对象

原因在于:

  • 类 A 的构造函数中可能访问成员对象 b

  • 如果 b 未初始化就访问,会导致 未定义行为

  • 因此 C++ 要求 先把成员对象构造好,确保可用性

这就像你造车时:

先造好发动机(B)

再组装整车(A)

7.5 更复杂情况:多个成员对象 + 初始化列表

class Engine {
public:
    Engine() { cout << "Engine init" << endl; }
    ~Engine() { cout << "Engine destroy" << endl; }
};

class Wheel {
public:
    Wheel() { cout << "Wheel init" << endl; }
    ~Wheel() { cout << "Wheel destroy" << endl; }
};

class Car {
public:
    Car() : wheel(), engine() {
        cout << "Car init" << endl;
    }

    ~Car() {
        cout << "Car destroy" << endl;
    }

private:
    Engine engine; // 先构造 engine
    Wheel wheel;   // 再构造 wheel
};

输出:

Engine init
Wheel init
Car init
Car destroy
Wheel destroy
Engine destroy

对比:

阶段

执行顺序

原因

构造

engine → wheel → Car

按成员声明顺序

析构

Car → wheel → engine

构造逆序,保证依赖安全释放

7.6 与指针成员的区别

如果成员是对象本体,生命周期由类自动管理:

B b;   // 构造/析构自动管理

但如果成员是对象指针,则不会自动构造和释放:

B* pb;  // 只存指针,资源需程序员手动管理

因此:

成员类型

生命周期管理

是否自动释放

B b;

自动

自动析构

B* pb;

手动

需 delete

8 静态成员

在 C++ 中,类的成员变量和成员函数在默认情况下是属于对象实例的,每创建一个对象都会获得自己的成员副本。然而,在某些情况下,我们希望不同对象之间共享相同的数据或功能,此时就可以使用 static(静态)成员。

静态成员属于类本身,而不是具体对象。无论创建多少对象,静态成员都只存在一份拷贝。

8.1 静态成员的分类

静态成员分为两类:

类型

说明

生命周期

访问方式

所属关系

静态成员变量

用 static 修饰的类内变量

程序运行期间存在

对象 / 类均可访问

属于类

静态成员函数

用 static 修饰的成员函数

程序运行期间存在

对象 / 类均可访问

属于类

静态成员与普通成员的根本区别:

  • 静态成员属于类,不属于对象

  • 静态成员不存储在对象内存中

  • 静态成员在编译阶段分配空间

因此,静态成员常用于:

  • 计数(统计对象数量)

  • 全局变量替代(提供更好的封装)

  • 多对象共享状态同步

8.2 静态成员变量

静态成员变量具有以下特征:

  1. 所有对象共享同一份数据

  2. 在编译阶段分配内存(存储于全局/静态区)

  3. 必须在类外进行初始化

8.2.1 示例:静态成员变量共享性验证

#include <iostream>
using namespace std;

class Person {
public:
    static int m_A;

private:
    static int m_B;
};

// 类外初始化
int Person::m_A = 10;
int Person::m_B = 10;

void test01() {
    // 通过对象访问
    Person p1;
    p1.m_A = 100;
    cout << "p1.m_A = " << p1.m_A << endl;

    Person p2;
    p2.m_A = 200;
    cout << "p1.m_A = " << p1.m_A << endl;
    cout << "p2.m_A = " << p2.m_A << endl;

    // 通过类名访问
    cout << "Person::m_A = " << Person::m_A << endl;

    // Person::m_B 无法访问,因为其为 private
}

int main() {
    test01();
    return 0;
}

运行输出表明:m_A 在所有对象间共享同一份存储空间。

8.3 静态成员函数

静态成员函数同样属于类,而不是对象。其主要特征:

  1. 所有对象共享同一个函数实例

  2. 可以通过对象或类名访问

  3. 只能访问静态成员变量,无法访问非静态成员变量

原因在于:

  • 非静态成员函数内部隐含 this 指针,表示调用该函数的对象地址

  • 而静态成员函数不依赖对象存在,不生成 this

8.3.1 示例:静态成员函数访问规则

#include <iostream>
using namespace std;

class Person {
public:
    static void func() {
        cout << "func 调用" << endl;
        m_A = 100;
        // m_B = 100; // 错误,无法访问非静态成员
    }

    static int m_A;
    int m_B;

private:
    static void func2() {
        cout << "func2 调用" << endl;
    }
};

int Person::m_A = 10;

void test02() {
    Person p1;
    p1.func();

    Person::func();

    // Person::func2(); // 无法访问,private 限制
}

int main() {
    test02();
    return 0;
}

8.4 静态成员的内存结构与对象模型关系

静态成员不存储在对象实例中,也就是说:

sizeof(Person)

不会包含 static 成员的空间。

静态成员实际存储于:

  • 静态存储区 / 数据段 (.data / .bss)

这使静态成员具有类似全局变量的生命周期,但又具备类作用域的封装性,因此既安全又结构清晰。

8.5 工程实践中静态成员常见用途

场景

示例

说明

对象计数

统计当前存活对象数量

构造++,析构–

共享配置

类级全局设置、调试开关

防止各对象值不一致

数据缓存

如连接池、图像缓存表

多对象复用资源

静态成员是实现类级别状态共享的核心手段。