第11章 类与对象基础

第11章 类与对象基础

11.1 面向对象编程概述

嘿,未来的C++大师!在正式踏入类的世界之前,咱们先来聊聊面向对象编程(Object-Oriented Programming,简称OOP)这个听起来很高大上的概念。别担心,我会把它解释得连你家的猫都能听懂——如果你的猫恰好会编程的话。

类与对象的概念

想象一下,你是一个烘焙师,你要做一批小熊饼干。你会怎么做?首先要有一个模具对吧?用这个模具,你可以压出无数个一模一样的小熊饼干。这个模具就是类(Class),而用这个模具压出来的每一个饼干就是对象(Object)

类,就好比是一份神奇的蓝图。它告诉你:“嘿,这只熊有圆圆的脑袋、两只耳朵、还有一个傻乎乎的微笑。“而对象,就是按照这个蓝图真实制造出来的实物,有自己的名字(比如"R2-D2熊”),自己的电池电量,等等。

在C++中,class关键字就是用来定义这种蓝图的。蓝图里包含了两大宝贝:

  • 成员变量(Member Variables):也叫属性,就是描述对象长什么样的数据。比如熊的名字、颜色、电量。
  • 成员函数(Member Functions):也叫方法,就是对象能干什么。比如熊能自我介绍、能充电。
 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
#include <iostream>
#include <string>

// 类:抽象的数据类型,定义对象的"蓝图"
// 对象:类的实例,具体的存在
// 简单理解:类就是模具,对象就是用模具压出来的饼干!

class Robot {
public:
    // 成员变量(属性)- 描述机器人长什么样/有什么数据
    std::string name;      // 名字:R2-D2、大白、或者"笨笨"
    int battery_level;     // 电量:0-100,别让TA饿着
    bool is_active;        // 是否正在工作
    
    // 成员函数(方法)- 描述机器人能干什么
    void introduce() {
        std::cout << "I am " << name << "!" << std::endl;
    }
    
    void charge() {
        battery_level = 100;
        std::cout << name << " is fully charged!" << std::endl;
    }
};

int main() {
    // 创建对象(类的实例)- 用模具压出真实的小熊饼干!
    Robot r2d2;
    
    // 访问成员变量 - 给这个机器人设置属性
    r2d2.name = "R2-D2";
    r2d2.battery_level = 75;
    r2d2.is_active = true;
    
    // 调用成员函数 - 让机器人干活!
    r2d2.introduce();  // 输出: I am R2-D2!
    r2d2.charge();      // 输出: R2-D2 is fully charged!
    
    return 0;
}

运行结果:

I am R2-D2!
R2-D2 is fully charged!

小提示:创建对象的过程叫做实例化(Instantiation),就像工厂里流水线生产产品一样,只不过这里的产品都是数据结构的"复制品”。

封装、继承、多态

如果说类是面向对象大厦的砖头,那么封装、继承、多态就是这三块砖头之间永恒的三角恋关系。咳咳,我是说,这三大特性是面向对象编程的核心支柱。

1. 封装(Encapsulation) - 把东西打包卖,顾客不需要知道里面是什么

想象你去买手机。你按下开机键,屏幕亮了。但你并不需要知道手机内部CPU怎么运作、内存怎么读写、屏幕像素怎么排列——这些细节都被"封装"在手机内部了。你只需要关心按钮在哪、屏幕多大、电池能用多久。

在C++中,封装就是用publicprivate关键字把数据和操作包装在一起,对外只暴露必要的接口,隐藏内部实现细节。就像一个自动贩卖机,你只管投钱按按钮,不需要知道里面齿轮怎么转、弹簧怎么弹。

2. 继承(Inheritance) - 龙生龙,凤生凤,老鼠的儿子会打洞

生物学里,狗妈妈生出小狗,小狗会"继承"狗妈妈的一些特征:四条腿、一条尾巴、见到骨头就走不动道。编程里的继承也是这样!

你可以从一个"基类"(也叫父类)派生出"派生类"(也叫子类)。派生类会自动获得父类的所有特性,还能添加自己独有的东西。就像"动物"是一个父类,“狗"继承它就有了"能叫"的能力,“猫"继承它就有了"傲娇"的能力。

3. 多态(Polymorphism) - 同一个动作,不同的表现

“叫一声!“这个命令对狗和猫有不同的效果:狗"汪!",猫"喵~"。同一个消息(叫一声),不同的对象有不同的响应——这就是多态。

在C++中,多态通过**虚函数(virtual function)**来实现。就像一张遥控器(基类指针),你按播放键,它会根据实际遥控的是电视还是音响来执行不同的操作。

 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
64
65
66
67
68
69
70
71
72
73
74
#include <iostream>
#include <string>

// 面向对象三大特性:
// 1. 封装(Encapsulation):把数据和操作包装在一起,对外隐藏细节
//    就像外卖打包,你不需要知道厨师怎么炒菜的
// 2. 继承(Inheritance):从已有类创建新类,复用代码
//    就像儿子继承父亲的房产和秃头基因
// 3. 多态(Polymorphism):不同对象对同一消息有不同响应
//    就像不同乐器演奏同一个音符,声音各不相同

class Animal {
protected:  // protected: 派生类可以访问,外部不能访问
    // 为什么用protected而不是private?因为派生类需要访问啊!
    std::string name_;  // 名字
    int age_;           // 年龄
    
public:
    // 构造函数:创建动物对象时必须提供名字和年龄
    Animal(const std::string& name, int age) : name_(name), age_(age) {}
    
    // 虚函数:派生类可以重写(override)
    // virtual关键字就像是给编译器一个"提示":这个函数可能会被改写
    virtual void speak() const {
        std::cout << name_ << " makes a sound" << std::endl;
    }
    
    // 虚析构函数:确保删除派生类对象时能正确调用析构函数
    // 这是C++的"安全带",不系可能会有生命危险(内存泄漏)
    virtual ~Animal() {}
};

class Dog : public Animal {
public:
    // 派生类的构造函数需要调用基类的构造函数
    Dog(const std::string& name, int age) : Animal(name, age) {}
    
    // 重写父类的speak方法 - 汪汪汪!
    void speak() const override {
        std::cout << name_ << " says: Woof!" << std::endl;
    }
};

class Cat : public Animal {
public:
    Cat(const std::string& name, int age) : Animal(name, age) {}
    
    // 重写父类的speak方法 - 喵喵喵!
    void speak() const override {
        std::cout << name_ << " says: Meow!" << std::endl;
    }
};

int main() {
    Dog buddy("Buddy", 3);       // 创建一个叫Buddy的3岁狗狗
    Cat whiskers("Whiskers", 2); // 创建一个叫Whiskers的2岁猫咪
    
    // 多态的经典用法:用基类指针数组管理不同派生类对象
    Animal* animals[] = {&buddy, &whiskers};
    
    std::cout << "=== 多态演示 ===" << std::endl;
    for (auto* animal : animals) {
        animal->speak();  // 多态:不同对象调用同一方法有不同行为
    }
    // 输出:
    // Buddy says: Woof!
    // Whiskers says: Meow!
    
    std::cout << "\n=== 解释 ===" << std::endl;
    std::cout << "同样是调用speak(),狗和猫的反应完全不同!" << std::endl;
    std::cout << "这就是多态的魅力:一个接口,多种形态。" << std::endl;
    
    return 0;
}

运行结果:

=== 多态演示 ===
Buddy says: Woof!
Whiskers says: Meow!

=== 解释 ===
同样是调用speak(),狗和猫的反应完全不同!
这就是多态的魅力:一个接口,多种形态。

对比一下:如果没有多态,你可能需要写一堆if-else来判断对象类型,然后再调用对应的函数。有了多态,代码简洁得像诗一样!

类不变式(Class Invariant)

想象一下,你有一个榨汁机。这个榨汁机有个规矩:桶里最多放10个水果,不能超过。超过了他就炸给你看(抛出异常)。这就是这个榨汁机的"使用规则”。

在编程里,**类不变式(Class Invariant)**就是对象从诞生(构造)到消亡(析构)整个生命周期都必须满足的条件。它就像是对象对自己立下的"誓言”:只要我还活着,我就保证满足这些条件。

拿我们熟悉的Stack(栈)来举例。一个栈的不变式包括:

  • 元素数量永远在0到capacity之间
  • top_指针(指向栈顶)永远在有效范围内
  • 栈空时top_是-1,栈满时top_是MAX_SIZE-1
 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
64
65
66
67
68
69
70
71
72
73
74
75
#include <iostream>
#include <stdexcept>

// 类不变式:对象从构造到销毁必须始终满足的条件
// 就像宪法是国家的不变式,任何法律都不能违反宪法
// 类不变式是类的"宪法",成员函数必须维护它

// 例如:Stack类的不变式是"top_始终在[-1, MAX_SIZE-1)范围内"
class Stack {
private:
    static const int MAX_SIZE = 100;  // 最大容量
    int data_[MAX_SIZE];               // 存储数据的数组
    int top_;  // 栈顶指针,不变式:top_始终在[-1, MAX_SIZE-1]范围内,栈空时为-1,栈满时为MAX_SIZE-1
    
public:
    // 构造函数:初始化为空栈
    // invariant: top_ = -1 表示空栈
    Stack() : top_(-1) {}
    
    // 判断栈是否为空
    bool empty() const { return top_ == -1; }
    
    // 判断栈是否已满
    bool full() const { return top_ == MAX_SIZE - 1; }
    
    // 入栈:添加元素到栈顶
    void push(int value) {
        if (full()) {
            throw std::overflow_error("Stack overflow! 别塞了,桶满了!");
        }
        data_[++top_] = value;  // 先递增top_,再存入数据
        // push后:top_增加了,但仍然在有效范围内,不变式保持!
    }
    
    // 出栈:移除并返回栈顶元素
    int pop() {
        if (empty()) {
            throw std::underflow_error("Stack underflow! 桶是空的,拿个锤子!");
        }
        // pop前top_有效,pop后top_有效(减1了),不变式保持
        return data_[top_--];  // 先返回数据,再递减top_
    }
    
    // 查看栈顶元素(不移除)
    int peek() const {
        if (empty()) {
            throw std::underflow_error("Stack is empty! 空的,一滴都没有!");
        }
        return data_[top_];
    }
};

int main() {
    Stack s;
    
    std::cout << "=== 栈操作演示 ===" << std::endl;
    
    s.push(10);
    std::cout << "Pushed: 10" << std::endl;
    
    s.push(20);
    std::cout << "Pushed: 20" << std::endl;
    
    s.push(30);
    std::cout << "Pushed: 30" << std::endl;
    
    std::cout << "\nTop element: " << s.peek() << std::endl;  // 输出: Top: 30
    std::cout << "Pop: " << s.pop() << std::endl;   // 输出: Pop: 30
    std::cout << "Pop: " << s.pop() << std::endl;   // 输出: Pop: 20
    
    std::cout << "\n=== 不变式检查 ===" << std::endl;
    std::cout << "栈空? " << (s.empty() ? "是滴" : "不是") << std::endl;
    
    return 0;
}

运行结果:

=== 栈操作演示 ===
Pushed: 10
Pushed: 20
Pushed: 30

Top element: 30
Pop: 30
Pop: 20

=== 不变式检查 ===
栈空? 是滴

不变式的好处:它让你对对象的状态有信心。只要你遵守规则(调用方法前检查边界),对象就永远处于合法状态,程序就不会出现奇怪的bug。就像只要你按时吃饭,你的胃就会正常工作一样。

11.2 类的定义与对象的创建

现在你已经了解了面向对象的基本概念,是时候学习如何在C++中实际定义一个类并创建对象了。这一节我们会像建筑师一样,画出蓝图,然后按照蓝图建造房子。

在栈上创建对象 vs 在堆上创建对象

在C++中,创建对象有两种方式:

  1. 栈上创建(也叫自动存储期):就像在栈上叠盘子,快速、 automatic(自动析构),但生命周期受作用域限制。
  2. 堆上创建(也叫动态存储期):就像租仓库存货,用完得手动清理(delete),但生命周期你说了算。
 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
#include <iostream>
#include <string>

// 定义一个简单的Point类(点)
class Point {
public:
    // 成员变量:点的坐标
    double x;  // 横坐标
    double y;  // 纵坐标
    
    // 成员函数:设置坐标
    void setCoordinates(double xVal, double yVal) {
        x = xVal;  // 设置横坐标
        y = yVal;  // 设置纵坐标
    }
    
    // 成员函数:打印点的坐标
    void print() const {
        std::cout << "(" << x << ", " << y << ")" << std::endl;
    }
};

int main() {
    std::cout << "=== 栈上创建对象 ===" << std::endl;
    
    // 栈上创建对象 - 就像在桌子上放一个杯子
    Point p1;  // 创建一个Point对象p1,在栈上分配内存
    p1.x = 3.0;  // 设置x坐标
    p1.y = 4.0;  // 设置y坐标
    p1.print();  // 输出: (3, 4)
    
    std::cout << "\n=== 使用成员函数初始化 ===" << std::endl;
    
    // 另一种初始化方式:使用成员函数
    Point p2;  // 又在栈上创建一个Point对象
    p2.setCoordinates(5.0, 6.0);  // 调用成员函数设置坐标
    p2.print();  // 输出: (5, 6)
    
    std::cout << "\n=== 堆上创建对象 ===" << std::endl;
    
    // 堆上创建对象 - 就像租用一个仓库
    // new关键字:从堆(heap)分配内存
    Point* p3 = new Point();  // 在堆上分配一个Point对象,返回指针
    p3->x = 7.0;  // 用->访问成员变量(指针专用语法)
    p3->y = 8.0;  // 就像 (*p3).y = 8.0 的简写
    p3->print();  // 输出: (7, 8)
    
    // 重要:堆上的对象不会自动销毁,必须手动delete!
    delete p3;  // 释放堆上的内存,避免内存泄漏
    p3 = nullptr;  // 指针设为空,避免野指针
    
    std::cout << "\n=== 小结 ===" << std::endl;
    std::cout << "栈上对象:自动管理,作用域结束自动销毁" << std::endl;
    std::cout << "堆上对象:手动管理,必须delete,否则内存泄漏" << std::endl;
    
    return 0;
}

运行结果:

=== 栈上创建对象 ===
(3, 4)

=== 使用成员函数初始化 ===
(5, 6)

=== 堆上创建对象 ===
(7, 8)

=== 小结 ===
栈上对象:自动管理,作用域结束自动销毁
堆上对象:手动管理,必须delete,否则内存泄漏

内存泄漏就像是租了房子不交租金也不退房,最终你的程序会把系统内存吃光光。所以堆上创建的对象,用完一定要记得delete。当然,更现代的做法是使用智能指针(smart pointer),TA会自动帮你收拾烂摊子。

11.3 成员变量与成员函数

类的两大组成部分我们已经见过了,现在让我们深入了解一下它们的特性和用法。

成员变量(Member Variables)—— 类的"身份证信息”

成员变量就是描述对象状态的数据。每个对象都有自己独立的一份,互不影响。就像每个学生都有自己的学号、姓名、成绩一样。

成员函数(Member Functions)—— 类的"技能包”

成员函数就是对象能执行的操作。它们可以访问对象的成员变量,读取或者修改状态。就像学生能"考试"(修改成绩)、“自我介绍”(读取信息)一样。

 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
#include <iostream>
#include <string>

// 定义一个矩形类
class Rectangle {
public:
    // ========== 成员变量(属性)==========
    // 矩形的宽度和高度
    double width;   // 宽度
    double height;  // 高度
    
    // ========== 成员函数(方法)==========
    
    // 计算面积:宽 × 高
    double area() const {
        // const表示这个函数不会修改成员变量
        // 就像一个只读的查询操作
        return width * height;
    }
    
    // 计算周长:2 × (宽 + 高)
    double perimeter() const {
        return 2 * (width + height);
    }
    
    // 判断是否是正方形
    bool isSquare() const {
        return width == height;
    }
    
    // 设置尺寸
    void setDimensions(double w, double h) {
        width = w;
        height = h;
    }
};

int main() {
    std::cout << "=== 矩形类演示 ===" << std::endl;
    
    // 创建一个矩形对象
    Rectangle rect;
    rect.setDimensions(5.0, 3.0);  // 设置宽5,高3
    
    std::cout << "宽度: " << rect.width << std::endl;    // 输出: 宽度: 5
    std::cout << "高度: " << rect.height << std::endl;   // 输出: 高度: 3
    std::cout << "面积: " << rect.area() << std::endl;    // 输出: 面积: 15
    std::cout << "周长: " << rect.perimeter() << std::endl;  // 输出: 周长: 16
    std::cout << "是正方形? " << (rect.isSquare() ? "Yes" : "No") << std::endl;
    // 输出: 是正方形? No
    
    std::cout << "\n=== 尝试正方形 ===" << std::endl;
    rect.setDimensions(4.0, 4.0);  // 变成正方形
    std::cout << "是正方形? " << (rect.isSquare() ? "Yes" : "No") << std::endl;
    // 输出: 是正方形? Yes
    std::cout << "面积: " << rect.area() << std::endl;  // 输出: 面积: 16
    
    return 0;
}

运行结果:

=== 矩形类演示 ===
宽度: 5
高度: 3
面积: 15
周长: 16
是正方形? No

=== 尝试正方形 ===
是正方形? Yes
面积: 16

小技巧:在成员函数后面加const是一个好习惯,它告诉编译器"这个函数只是看看,不会乱改东西"。这就像是给函数贴了个"只读"标签,编译器会帮你检查是否有意外修改。如果你不小心写了会修改成员的代码,编译器会报错:“嘿,这个函数是const的,不能改东西!”

11.4 访问控制:public、private、protected

这一节我们来聊聊类的"门禁系统"。想象一下,有些房间是公共区域(public),任何人都能进;有些是员工专属(protected),只有特定的人能进;还有些是老板的私人办公室(private),连门都没有。

访问控制关键字详解

在C++中,有三个访问控制级别:

关键字访问级别谁可以访问
public公有任何人,任何地方
protected受保护本类 + 派生类
private私有只有本类
 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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#include <iostream>
#include <string>

// 银行账户类 - 演示三种访问控制
class BankAccount {
private:  // 私有:只有本类成员可以访问
    // 这些是敏感信息,不能让外人直接看到!
    std::string account_number_;  // 账号:保密!
    double balance_;              // 余额:更保密!
    
    // 私有成员函数:验证金额是否合法
    // 这是内部工具,外部不需要知道
    bool isValidAmount(double amount) const {
        return amount >= 0;  // 存款不能是负数,这是规矩
    }
    
public:  // 公有:任何代码都可以访问
    std::string owner_name_;  // 户主名:这个可以公开
    
    // 构造函数
    BankAccount(const std::string& owner, const std::string& accNum, double initialBalance) 
        : owner_name_(owner), account_number_(accNum), balance_(initialBalance) {}
    
    // 公有接口:存款
    void deposit(double amount) {
        if (!isValidAmount(amount)) {
            std::cout << "Invalid amount! 金额不能是负数!" << std::endl;
            return;
        }
        balance_ += amount;
        std::cout << "Deposited " << amount << ". New balance: " << balance_ << std::endl;
    }
    
    // 公有接口:取款
    void withdraw(double amount) {
        if (!isValidAmount(amount)) {
            std::cout << "Invalid amount! 金额不能是负数!" << std::endl;
            return;
        }
        if (amount > balance_) {
            std::cout << "Insufficient funds! 余额不足!" << std::endl;
            return;
        }
        balance_ -= amount;
        std::cout << "Withdrew " << amount << ". New balance: " << balance_ << std::endl;
    }
    
    // 公有接口:查询余额(只读)
    double getBalance() const {
        return balance_;
    }
    
protected:  // 受保护:派生类可以访问
    // 这个方法派生类需要用到,但外部不应该调用
    void printAccountInfo() const {
        std::cout << "Account: " << account_number_ << ", Owner: " << owner_name_ << std::endl;
    }
};

int main() {
    std::cout << "=== 访问控制演示 ===" << std::endl;
    
    // 创建账户
    BankAccount account("Alice", "123456789", 1000.0);
    
    // 尝试访问私有成员 - 这些都是错误写法!
    // account.account_number_;  // 错误!private成员不能直接访问
    // account.balance_;          // 错误!private成员不能直接访问
    // 编译器的反应:Access denied! 你没有权限!
    
    std::cout << "\n=== 通过公有接口操作 ===" << std::endl;
    
    // 通过public方法操作 - 正确姿势
    account.deposit(500.0);   // OK: public方法,存款500
    account.withdraw(200.0);  // OK: public方法,取款200
    
    // 查询余额
    std::cout << "Current balance: " << account.getBalance() << std::endl;
    // 输出: Current balance: 1300
    
    // 尝试调用protected方法 - 错误!
    // account.printAccountInfo();  // 错误!protected方法不能从外部调用
    // 编译器:这是内部方法,外部不能调用!
    
    std::cout << "\n=== 小结 ===" << std::endl;
    std::cout << "private: 藏在保险箱里,只有自己能访问" << std::endl;
    std::cout << "protected: 放在家里,子女(派生类)可以继承" << std::endl;
    std::cout << "public: 挂在门口的牌子,大家都能看" << std::endl;
    
    return 0;
}

运行结果:

=== 访问控制演示 ===

=== 通过公有接口操作 ===
Deposited 500. New balance: 1500
Withdrew 200. New balance: 1300
Current balance: 1300

=== 小结 ===
private: 藏在保险箱里,只有自己能访问
protected: 放在家里,子女(派生类)可以继承
public: 挂在门口的牌子,大家都能看

封装的好处:为什么要把数据藏起来?因为如果你随便让人改余额,银行的系统不就乱套了吗?通过公有接口(方法)来访问,可以确保所有的修改都是经过验证的、安全的。就像自动取款机,你只能按按钮取钱,不能直接伸手进机器里抓钱。

11.5 构造函数

构造函数(Constructor)是类的"出生证明办理处"。每当你创建一个对象,构造函数就会自动被调用,负责初始化对象的状态。打个比方,就像你出生时护士给你称体重、量身高一样。

默认构造函数

默认构造函数就是没有参数的构造函数。如果你没有定义任何构造函数,编译器会帮你自动生成一个——就像政府给你发了个默认的身份证。

 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
#include <iostream>
#include <string>

class Robot {
private:
    std::string name_;      // 机器人名字
    int battery_level_;     // 电量
    
public:
    // 默认构造函数:无参数
    // 当你不提供任何参数时,这个构造函数会被调用
    Robot() : name_("Unknown"), battery_level_(0) {
        std::cout << "Robot created: " << name_ << std::endl;
    }
    
    // 带参构造函数:有一个名字参数
    Robot(const std::string& name) : name_(name), battery_level_(100) {
        std::cout << "Robot created with name: " << name_ << std::endl;
    }
    
    // 打印机器人信息
    void info() const {
        std::cout << "Name: " << name_ << ", Battery: " << battery_level_ << "%" << std::endl;
    }
};

int main() {
    std::cout << "=== 创建第一个机器人 ===" << std::endl;
    Robot r1;  // 调用默认构造函数,不传参数
    
    std::cout << "\n=== 创建第二个机器人 ===" << std::endl;
    Robot r2("R2-D2");  // 调用带参构造函数,传递名字
    
    std::cout << "\n=== 机器人信息 ===" << std::endl;
    r1.info();  // 输出: Name: Unknown, Battery: 0%
    r2.info();  // 输出: Name: R2-D2, Battery: 100%
    
    return 0;
}

运行结果:

=== 创建第一个机器人 ===
Robot created: Unknown

=== 创建第二个机器人 ===
Robot created with name: R2-D2

=== 机器人信息 ===
Name: Unknown, Battery: 0%
Name: R2-D2, Battery: 100%

小提示:如果你定义了带参数的构造函数,但仍然想要无参数创建对象,你就必须显式地定义一个默认构造函数。否则Robot r;会编译报错——编译器可不是万能的!

带参构造函数

带参构造函数让你在创建对象时就能指定初始状态。这就像是给宝宝办出生证明时直接填好名字、出生时间一样。

 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
#include <iostream>

class Point {
private:
    double x_;
    double y_;
    
public:
    // 初始化列表:更高效的初始化方式
    // 这种写法直接初始化成员变量,而不是先默认构造再赋值
    // 就像一步到位,不用先画格子再填内容
    Point(double x, double y) : x_(x), y_(y) {
        std::cout << "Point(" << x_ << ", " << y_ << ") created" << std::endl;
    }
    
    void print() const {
        std::cout << "(" << x_ << ", " << y_ << ")" << std::endl;
    }
};

// 矩形类:包含两个Point成员
class Rectangle {
private:
    Point bottom_left_;   // 左下角点
    Point top_right_;     // 右上角点
    
public:
    // 构造函数:使用初始化列表初始化成员对象
    // 成员对象的构造在函数体之前执行
    Rectangle(double x1, double y1, double x2, double y2)
        : bottom_left_(x1, y1), top_right_(x2, y2) {
        std::cout << "Rectangle created" << std::endl;
    }
    
    void print() const {
        std::cout << "Bottom-left: ";
        bottom_left_.print();
        std::cout << "Top-right: ";
        top_right_.print();
    }
};

int main() {
    std::cout << "=== 创建点 ===" << std::endl;
    Point p1(3.0, 4.0);  // 调用带参构造函数
    p1.print();  // 输出: (3, 4)
    
    std::cout << "\n=== 创建矩形 ===" << std::endl;
    Rectangle rect(0, 0, 5, 3);  // 矩形左下角(0,0),右上角(5,3)
    rect.print();
    
    return 0;
}

运行结果:

=== 创建点 ===
Point(3, 4) created
(3, 4)

=== 创建矩形 ===
Point(0, 0) created
Point(5, 3) created
Rectangle created
Bottom-left: (0, 0)
Top-right: (5, 3)

为什么用初始化列表?效率高!如果用赋值的方式,成员变量会先被默认构造(int就变成0,string变成空字符串),然后再被赋值。等于是做了两次工作。初始化列表直接一步到位,妙不妙?

拷贝构造函数

拷贝构造函数是用一个已有对象来创建新对象。就像复印机一样,复制出一份一模一样的内容。

 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
#include <iostream>
#include <string>

class Person {
private:
    std::string name_;  // 姓名
    int age_;            // 年龄
    
public:
    // 普通构造函数
    Person(const std::string& name, int age) : name_(name), age_(age) {
        std::cout << "Person(\"" << name_ << "\", " << age_ << ") created" << std::endl;
    }
    
    // 拷贝构造函数:用已有对象初始化新对象
    // 参数必须是 const 引用!这是语法的硬性要求
    Person(const Person& other) : name_(other.name_), age_(other.age_) {
        std::cout << "Person copy constructor called for " << name_ << std::endl;
    }
    
    void print() const {
        std::cout << name_ << ", age " << age_ << std::endl;
    }
};

int main() {
    std::cout << "=== 直接创建Alice ===" << std::endl;
    Person alice("Alice", 25);  // 调用普通构造函数
    
    std::cout << "\n=== 拷贝构造Bob(用alice初始化)===" << std::endl;
    Person bob(alice);  // 拷贝构造:从alice复制一个新的bob
    
    std::cout << "\n=== 拷贝构造Charlie(另一种语法)===" << std::endl;
    Person charlie = alice;  // 也是拷贝构造!等价于 Person charlie(alice);
    
    std::cout << "\n=== 打印结果 ===" << std::endl;
    alice.print();  // 输出: Alice, age 25
    bob.print();    // 输出: Alice, age 25(和alice一样的信息)
    charlie.print(); // 输出: Alice, age 25
    
    return 0;
}

运行结果:

=== 直接创建Alice ===
Person("Alice", 25) created

=== 拷贝构造Bob(用alice初始化)===
Person copy constructor called for Alice

=== 拷贝构造Charlie(另一种语法)===
Person copy constructor called for Alice

=== 打印结果 ===
Alice, age 25
Alice, age 25
Alice, age 25

拷贝构造什么时候会被调用?三种情况:

  1. 用一个对象初始化另一个对象:Person bob(alice);
  2. 传对象给函数(值传递):void foo(Person p); foo(alice);
  3. 返回对象(值返回):Person foo() { return alice; }

移动构造函数(C++11)

移动构造函数是C++11引入的黑科技。它的作用是"偷取"临时对象占用的资源,而不是复制一份。这就像是搬家时,你直接告诉快递公司"把那个柜子搬过来",而不是"把柜子复制一份"。

为什么要移动?因为有些对象很大(比如装了一百万个元素的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
#include <iostream>
#include <vector>
#include <string>

class BigObject {
public:
    std::string data;                  // 字符串数据
    std::vector<int> numbers;          // 大量整数
    
    // 普通构造函数
    BigObject(const std::string& d, size_t n) : data(d), numbers(n, 0) {
        std::cout << "BigObject constructed: " << data << std::endl;
    }
    
    // 移动构造函数
    // 特点:参数是右值引用(T&&)
    // noexcept告诉编译器:这个函数不会抛出异常,可以放心优化
    BigObject(BigObject&& other) noexcept 
        : data(std::move(other.data)),       // 移动字符串
          numbers(std::move(other.numbers)) { // 移动vector
        std::cout << "BigObject moved: " << other.data << std::endl;
    }
    
    void print() const {
        std::cout << "data=" << data << ", size=" << numbers.size() << std::endl;
    }
};

int main() {
    std::cout << "=== 创建大对象 ===" << std::endl;
    BigObject obj1("heavy", 1000000);  // 创建一个有大数据的对象
    std::cout << "obj1内存占用:numbers有" << obj1.numbers.size() << "个元素" << std::endl;
    
    std::cout << "\n=== 移动构造obj2 ===" << std::endl;
    // std::move把obj1变成右值,触发移动构造函数
    // 移动后obj1的data和numbers变成空!
    BigObject obj2(std::move(obj1));  // std::move把obj1变成右值,触发移动构造函数
    // 注意!移动后obj1的data和numbers被"掏空"了,
    // 下面的调试输出是在obj1已被移动之后的状态!
    
    std::cout << "\n=== 检查obj2 ===" << std::endl;
    obj2.print();
    
    std::cout << "\n=== 检查obj1(已被移动)===" << std::endl;
    std::cout << "obj1.data现在是: \"" << obj1.data << "\"" << std::endl;
    std::cout << "obj1.numbers大小: " << obj1.numbers.size() << std::endl;
    
    return 0;
}

运行结果:

=== 创建大对象 ===
BigObject constructed: heavy
obj1内存占用:numbers有1000000个元素

=== 移动构造obj2 ===
BigObject moved: heavy

=== 检查obj2 ===
data=heavy, size=1000000

=== 检查obj1(已被移动)===
obj1.data现在是: ""
obj1.numbers大小: 0

std::move本身不移动任何东西,它只是把参数转成右值引用,告诉编译器"你可以偷这个对象的资源了"。移动之后,原对象就变成了一个"空壳"——数据成员都被掏空了。这很危险,所以移动之后原对象最好不要使用了(或者至少要重新赋值后再使用)。

委托构造函数(C++11)

委托构造函数允许一个构造函数调用同一个类的另一个构造函数。这就像是老板把任务委托给下属,自己就不用亲自干了。

 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
#include <iostream>
#include <string>

class Employee {
private:
    std::string name_;
    int id_;
    double salary_;
    std::string department_;
    
public:
    // 主构造函数(目标构造函数)
    // 这是真正干活的构造函数,所有参数都齐全
    Employee(const std::string& name, int id, double salary, const std::string& dept)
        : name_(name), id_(id), salary_(salary), department_(dept) {
        std::cout << "Main constructor: " << name_ << std::endl;
    }
    
    // 委托构造函数:委托给主构造函数
    // 只提供name和id,使用默认值salary=50000, dept="General"
    Employee(const std::string& name, int id)
        : Employee(name, id, 50000.0, "General") {  // 委托!
        // 这里不需要再初始化任何东西了
    }
    
    // 委托构造函数:只提供name
    // 使用默认值 id=0, salary=30000, dept="Intern"
    Employee(const std::string& name)
        : Employee(name, 0, 30000.0, "Intern") {  // 委托!
    }
    
    // 委托构造函数:无参数
    // 使用默认值 name="Unnamed", id=-1, salary=0, dept="None"
    Employee() : Employee("Unnamed", -1, 0.0, "None") {}  // 委托!
    
    void print() const {
        std::cout << "Employee: " << name_ << ", ID: " << id_ 
                  << ", Salary: " << salary_ << ", Dept: " << department_ << std::endl;
    }
};

int main() {
    std::cout << "=== 全参数构造 ===" << std::endl;
    Employee e1("Alice", 1001, 75000.0, "Engineering");
    
    std::cout << "\n=== 两个参数构造(委托)===" << std::endl;
    Employee e2("Bob", 1002);  // 使用委托
    
    std::cout << "\n=== 一个参数构造(委托)===" << std::endl;
    Employee e3("Charlie");    // 使用委托
    
    std::cout << "\n=== 无参数构造(委托)===" << std::endl;
    Employee e4;                // 使用委托
    
    std::cout << "\n=== 打印所有员工 ===" << std::endl;
    e1.print();
    e2.print();
    e3.print();
    e4.print();
    
    return 0;
}

运行结果:

=== 全参数构造 ===
Main constructor: Alice

=== 两个参数构造(委托)===
Main constructor: Bob

=== 一个参数构造(委托)===
Main constructor: Charlie

=== 无参数构造(委托)===
Main constructor: Unnamed

=== 打印所有员工 ===
Employee: Alice, ID: 1001, Salary: 75000, Dept: Engineering
Employee: Bob, ID: 1002, Salary: 50000, Dept: General
Employee: Charlie, ID: 0, Salary: 30000, Dept: Intern
Employee: Unnamed, ID: -1, Salary: 0, Dept: None

委托构造的好处:代码不重复!想象一下如果没有委托,每个构造函数都要写一堆初始化代码,那得多乱啊。委托让代码简洁又清晰。

explicit构造函数

explicit关键字用来防止隐式类型转换。就像是一个"显眼的告示牌":请勿在此隐式转换!

 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
#include <iostream>
#include <string>

class String {
private:
    std::string data_;
    
public:
    // explicit:禁止隐式转换从const char*构造String
    explicit String(const char* str) : data_(str) {
        std::cout << "String created from C-string" << std::endl;
    }
    
    // explicit:禁止隐式转换从int构造String
    explicit String(int n) : data_(n, 'X') {
        std::cout << "String created from int" << std::endl;
    }
    
    // 没有explicit:可以从std::string隐式转换
    String(const std::string& str) : data_(str) {
        std::cout << "String created from std::string" << std::endl;
    }
    
    const std::string& get() const { return data_; }
};

void processString(const String& s) {
    std::cout << "Processing: " << s.get() << std::endl;
}

int main() {
    std::cout << "=== explicit演示 ===" << std::endl;
    
    // String s1 = "Hello";  // 错误!explicit禁止隐式转换
    // 编译器的内心OS:你想隐式转换?没门!
    
    String s2("Hello");  // OK:直接构造,不涉及隐式转换
    String s3 = String("Hello");  // OK:显式构造
    
    std::cout << "\n=== 函数调用 ===" << std::endl;
    
    // processString("Test");  // 错误!不能隐式转换const char*到String
    processString(String("Test"));  // OK:显式构造临时对象
    
    // String s4 = 10;  // 错误!explicit禁止隐式转换int到String
    String s5(10);  // OK:显式构造 "XXXXXXXXXX"
    
    std::cout << "\n=== 成功! ===" << std::endl;
    std::cout << "explicit关键字成功阻止了隐式转换" << std::endl;
    
    return 0;
}

运行结果:

=== explicit演示 ===
String created from C-string
String created from C-string

=== 函数调用 ===
String created from C-string
Processing: Test
String created from int

=== 成功! ===
explicit关键字成功阻止了隐式转换

为什么要阻止隐式转换?因为有时候隐式转换会带来意想不到的bug。比如你写了一个String的构造函数String(int n),如果允许隐式转换,那myString = 5;就会悄悄创建一个"XXXXX"字符串,而不是报错。这种沉默的错误最可怕了。

构造函数异常安全

构造函数里如果发生异常,已经分配的资源怎么办?这就要靠构造函数异常安全来处理了。

 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
#include <iostream>
#include <stdexcept>

// 模拟某种资源(文件句柄、内存等)
class Resource {
public:
    Resource() { 
        std::cout << "Resource acquired" << std::endl;
    }
    
    ~Resource() { 
        std::cout << "Resource released" << std::endl;
    }
};

class Widget {
private:
    Resource* r1;  // 资源1
    Resource* r2;  // 资源2
    
public:
    // 构造函数:初始化列表中分配资源
    Widget() try : r1(new Resource()), r2(new Resource()) {
        // try块语法:构造函数的函数体变成try块的一部分
        std::cout << "Widget constructed successfully" << std::endl;
    } catch (...) {
        // 如果构造函数抛出异常,进入这个catch块
        std::cout << "Constructor exception - cleanup!" << std::endl;
        delete r1;  // 手动清理已经分配的资源
        // r2不需要清理,因为分配r2时抛出异常,r2还是nullptr
        throw;  // 重新抛出异常,让调用者知道构造失败了
    }
    
    ~Widget() {
        delete r2;
        delete r1;
        std::cout << "Widget destroyed" << std::endl;
    }
};

int main() {
    std::cout << "=== 正常构造 ===" << std::endl;
    Widget w;
    
    std::cout << "\n=== 退出作用域 ===" << std::endl;
    // w的析构函数会被调用,释放资源
    
    std::cout << "\n=== 注意 ===" << std::endl;
    std::cout << "实际应用中应使用智能指针(std::unique_ptr)避免手动清理" << std::endl;
    
    return 0;
}

运行结果:

=== 正常构造 ===
Resource acquired
Resource acquired
Widget constructed successfully

=== 退出作用域 ===
Widget destroyed
Resource released
Resource released

现代C++建议:构造函数try块虽然能处理异常,但代码复杂、容易出错。更现代、更安全的方法是使用**智能指针(std::unique_ptrstd::shared_ptr)**来管理资源,它们会在析构函数中自动释放。记住:资源获取即初始化(RAII)!

11.6 析构函数

析构函数(Destructor)是构造函数的"收尾工作"。当对象生命周期结束时(比如离开作用域或被delete),析构函数就会被自动调用,做一些清理工作,比如释放资源、关闭文件、断开网络连接等等。

析构函数不应抛出异常

这是非常重要的一条规则:析构函数不应该抛出异常。为什么?

想象一下:如果析构函数在清理资源时抛出异常,而C++运行时正在处理另一个异常(或者正在stack unwinding过程中),程序就会调用std::terminate()直接崩溃。这就像是火警时你又拉响了地震警报,整个应急系统就傻眼了。

 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
#include <iostream>
#include <stdexcept>

// 模拟数据库连接
class Connection {
public:
    Connection(const std::string& name) : name_(name) {
        std::cout << "Connection " << name_ << " opened" << std::endl;
    }
    
    // 析构函数:关闭连接
    // noexcept关键字:承诺不抛出异常
    // 这是析构函数的默认修饰符!
    ~Connection() noexcept {
        std::cout << "Closing connection " << name_ << "..." << std::endl;
        
        // 最佳实践:析构函数不应抛出异常
        // 如果close()可能失败,使用try-catch吞掉异常
        try {
            close();  // 假设这个函数可能抛出异常
        } catch (...) {
            // 吞掉异常,避免terminate()
            // 记录日志,但不重新抛出
            std::cerr << "Exception during close in destructor" << std::endl;
        }
        
        std::cout << "Connection " << name_ << " closed" << std::endl;
    }
    
    void close() {
        throw std::runtime_error("Close failed!");
    }
    
private:
    std::string name_;
};

int main() {
    std::cout << "=== 创建连接 ===" << std::endl;
    Connection conn("Database");
    
    std::cout << "\n=== 退出作用域 ===" << std::endl;
    // 析构函数会被调用,即使close()抛出异常也会被捕获
    // 不会导致terminate()!
    
    std::cout << "\n=== 程序正常结束 ===" << std::endl;
    
    return 0;
}

运行结果:

=== 创建连接 ===
Connection Database opened

=== 退出作用域 ===
Closing connection Database...
Exception during close in destructor
Connection Database closed

=== 程序正常结束 ===

黄金法则:析构函数要么不抛出异常(用noexcept),要么就在内部用try-catch把所有异常都吞掉。永远不要让异常逃离析构函数!

11.7 赋值运算符重载

赋值运算符(operator=)用于给已存在的对象赋予新的值。这和拷贝构造函数不同:拷贝构造是用一个对象创建一个新对象(对象还不存在),而赋值运算是给一个已存在的对象重新赋值。

拷贝赋值

拷贝赋值运算符处理的是"两个对象都已经存在"的情况。它需要先释放旧资源,再复制新资源。

 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
#include <iostream>
#include <cstring>

class String {
private:
    char* data_;    // 字符数组指针
    size_t length_; // 字符串长度
    
public:
    // 普通构造函数
    String(const char* str) {
        length_ = strlen(str);
        data_ = new char[length_ + 1];
        strcpy(data_, str);
        std::cout << "String created: " << data_ << std::endl;
    }
    
    // 拷贝赋值运算符
    // 当你写 s2 = s1 时,这个函数被调用
    String& operator=(const String& other) {
        std::cout << "Copy assignment called for: " << other.data_ << std::endl;
        
        // 重要!自赋值检查!
        // 如果 s1 = s1; 这种情况,不做任何操作直接返回
        if (this != &other) {
            delete[] data_;  // 释放旧资源!否则内存泄漏!
            
            length_ = other.length_;  // 复制长度
            data_ = new char[length_ + 1];  // 分配新内存
            strcpy(data_, other.data_);  // 复制内容
        }
        
        return *this;  // 返回自身,支持链式赋值 s3 = s2 = s1;
    }
    
    const char* c_str() const { return data_; }
    
    ~String() {
        std::cout << "String destroyed: " << (void*)data_ << std::endl;
        delete[] data_;
    }
};

int main() {
    std::cout << "=== 创建字符串 ===" << std::endl;
    String s1("Hello");
    String s2("World");
    
    std::cout << "\n=== 拷贝赋值 ===" << std::endl;
    s2 = s1;  // 将s1的值拷贝赋值给s2
    
    std::cout << "s2 = " << s2.c_str() << std::endl;
    
    std::cout << "\n=== 自赋值 ===" << std::endl;
    s1 = s1;  // 自赋值,应该安全处理(不崩溃、不出错)
    
    return 0;
}

运行结果:

=== 创建字符串 ===
String created: Hello
String created: World

=== 拷贝赋值 ===
Copy assignment called for: Hello
String destroyed: 0x...  // 释放s2原来的"World"

s2 = Hello

=== 自赋值 ===
Copy assignment called for: Hello
// 自赋值检查阻止了危险操作!

拷贝赋值三步曲:1. 检查自赋值 2. 释放旧资源 3. 分配新资源并复制。三步缺一不可!忘了释放旧资源会导致内存泄漏,忘了检查自赋值会导致delete[] data_把自己删了然后访问野指针。

移动赋值(C++11)

移动赋值运算符类似于移动构造函数,但用于已存在的对象。它需要先释放旧资源,再偷取新资源的所有权。

 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
#include <iostream>
#include <utility>
#include <cstring>

class String {
private:
    char* data_;
    size_t length_;
    
public:
    // 普通构造函数
    String(const char* str) {
        length_ = strlen(str);
        data_ = new char[length_ + 1];
        strcpy(data_, str);
    }
    
    // 移动赋值运算符
    // 当你写 s2 = std::move(s1) 时,这个函数被调用
    String& operator=(String&& other) noexcept {
        std::cout << "Move assignment called" << std::endl;
        
        if (this != &other) {  // 仍然要检查自赋值
            delete[] data_;  // 释放旧资源!
            
            data_ = other.data_;  // 偷取资源!
            length_ = other.length_;
            
            other.data_ = nullptr;  // 源对象置空,防止析构时释放我们的数据
            other.length_ = 0;
        }
        
        return *this;
    }
    
    const char* c_str() const { 
        return data_ ? data_ : "(null)"; 
    }
    
    bool isEmpty() const { return data_ == nullptr; }
    
    ~String() {
        delete[] data_;
    }
};

int main() {
    String s1("Hello");
    String s2("World");
    
    std::cout << "Before move: s1=" << s1.c_str() << ", s2=" << s2.c_str() << std::endl;
    
    std::cout << "\n=== 移动赋值 ===" << std::endl;
    s2 = std::move(s1);  // s2的旧资源("World")被释放,s1的资源被"偷"给s2
    
    std::cout << "\n=== 移动后 ===" << std::endl;
    std::cout << "After move: s1=" << s1.c_str() << ", s2=" << s2.c_str() << std::endl;
    
    return 0;
}

运行结果:

Before move: s1=Hello, s2=World

=== 移动赋值 ===
Move assignment called

=== 移动后 ===
After move: s1=(null), s2=Hello

移动赋值的精髓:旧的不去,新的不来。先delete[]自己的旧资源,再"接管"别人的资源。这样就避免了一次昂贵的数据复制!

11.8 深拷贝与浅拷贝

这是C++中一个超级重要的概念,也是新手最容易踩坑的地方之一。

浅拷贝(Shallow Copy):只拷贝指针的值(地址),不拷贝指针指向的数据。就像是复制了一张"藏宝图",原图和副本都指向同一个宝藏。

深拷贝(Deep Copy):不仅拷贝指针,还拷贝指针指向的数据。就像是真正复制了宝藏本身,现在有两份独立的宝藏了。

  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
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
#include <iostream>
#include <cstring>

// 危险的浅拷贝类
class shallow_ptr {
public:
    char* data;  // 原始指针
    
    shallow_ptr(const char* str) {
        data = new char[strlen(str) + 1];
        strcpy(data, str);
        std::cout << "shallow_ptr created: " << data << std::endl;
    }
    
    // 显式声明浅拷贝:默认拷贝构造函数只做简单的位拷贝
    // 也就是说,拷贝后两个对象的data指针指向同一块内存!
    shallow_ptr(const shallow_ptr&) = default;  // 浅拷贝!
    shallow_ptr& operator=(const shallow_ptr&) = default;  // 浅拷贝!
    
    ~shallow_ptr() {
        std::cout << "Destroying: " << (void*)data << " -> " << (data ? data : "null") << std::endl;
        delete[] data;  // 这里会导致double delete(如果两个对象指向同一块内存)
    }
};

// 安全的深拷贝类
class deep_ptr {
public:
    char* data;
    
    deep_ptr(const char* str) {
        data = new char[strlen(str) + 1];
        strcpy(data, str);
    }
    
    // 拷贝构造函数:深拷贝!
    // 真正复制一份数据,而不是只复制指针
    deep_ptr(const deep_ptr& other) {
        std::cout << "Deep copy of: " << other.data << std::endl;
        data = new char[strlen(other.data) + 1];
        strcpy(data, other.data);
    }
    
    // 移动构造函数
    deep_ptr(deep_ptr&& other) noexcept : data(other.data) {
        other.data = nullptr;  // 源对象置空
    }
    
    // 赋值运算符:深拷贝
    deep_ptr& operator=(const deep_ptr& other) {
        if (this != &other) {
            std::cout << "Deep assignment of: " << other.data << std::endl;
            delete[] data;  // 先释放自己的资源
            data = new char[strlen(other.data) + 1];
            strcpy(data, other.data);
        }
        return *this;
    }
    
    const char* get() const { return data ? data : "(null)"; }
    
    ~deep_ptr() {
        std::cout << "Destroying: " << (data ? data : "null") << std::endl;
        delete[] data;
    }
};

int main() {
    std::cout << "=== 浅拷贝的危险(切勿模仿!) ===" << std::endl;
    {
        shallow_ptr s1("Danger!");
        shallow_ptr s2("Will Robinson");  // s2和s1的data指向同一块内存!
        
        std::cout << "s1和s2的data指针地址相同: " << (void*)s1.data << " == " << (void*)s2.data << std::endl;
        
        // 这里赋值会发生什么?s2的旧内存没人管了,内存泄漏!
        s2 = s1;
        std::cout << "赋值后s2.data已指向s1的内存,s2原来的内存泄漏了" << std::endl;
        
        // 作用域结束时:
        // s1析构:delete[] s1.data(OK)
        // s2析构:delete[] s2.data——但这和s1是同一块内存!
        // 结果:经典的double free!程序可能崩溃!
        std::cout << "作用域即将结束,s1和s2即将被销毁..." << std::endl;
    }
    // 上面的大括号确保s1和s2在同一时刻离开作用域
    // 结局:s1和s2指向同一块内存,析构时double free!
    // 运气好的话程序直接崩溃,运气不好则踩到脏数据debug半天
    // 这就是浅拷贝管理动态内存的经典陷阱!
    // 注意:此程序可能直接崩溃,不会执行到最后的cout语句
    
    std::cout << "\n(如果上面的演示导致程序崩溃,请不要惊慌——这正是我们想要展示的效果!)" << std::endl;
    
    std::cout << "\n=== 深拷贝演示 ===" << std::endl;
    
    deep_ptr d1("Hello");
    deep_ptr d2("World");
    
    std::cout << "\n=== 赋值操作 ===" << std::endl;
    d2 = d1;  // 深拷贝赋值
    
    std::cout << "\n=== 验证 ===" << std::endl;
    std::cout << "d1=" << d1.get() << ", d2=" << d2.get() << std::endl;
    std::cout << "不同内存地址? " << (d1.get() != d2.get() ? "Yes!" : "No!") << std::endl;
    
    std::cout << "\n=== 作用域结束,析构 ===" << std::endl;
    
    return 0;
}

运行结果:

=== 深拷贝演示 ===
Deep copy of: Hello
Deep assignment of: Hello
Destroying: Hello
Destroying: Hello

=== 验证 ===
d1=Hello, d2=Hello
不同内存地址? Yes!

=== 作用域结束,析构 ===
Destroying: Hello
Destroying: Hello

浅拷贝的危险:如果你用shallow_ptr,拷贝构造函数只复制指针,两个对象指向同一块内存。析构时,delete[]被调用两次——这就是经典的double free问题,会导致程序崩溃!所以当类管理动态内存时,一定要实现深拷贝!

11.9 三五法则与零法则

这一节我们来聊聊C++类的特殊成员函数之间的"铁律"。

三法则(The Rule of Three)

如果你的类需要手动管理资源(比如动态内存、文件句柄、网络连接等),并且你自定义了以下任一操作,那你通常需要自定义所有三个

  1. 析构函数 - 负责释放资源
  2. 拷贝构造函数 - 需要深拷贝
  3. 拷贝赋值运算符 - 需要深拷贝

为什么?因为如果你自定义了析构函数(比如要释放某些特殊资源),那默认的拷贝操作很可能是不安全的(会浅拷贝那些资源)。

五法则(The Rule of Five)

C++11引入了移动语义,所以三法则扩展成了五法则

  1. 析构函数
  2. 拷贝构造函数
  3. 拷贝赋值运算符
  4. 移动构造函数
  5. 移动赋值运算符

如果你需要自定义移动操作,那拷贝操作通常应该被禁用(= delete)。

零法则(The Rule of Zero)

最佳实践:如果你能避免手动管理资源(使用std::stringstd::vector等RAII类型),那就让编译器自动生成这些特殊成员函数。这就是零法则——你不需要定义任何特殊成员函数!

 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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
#include <iostream>
#include <cstring>

// ============ 零法则 ============
// 如果类中没有手动管理资源(使用string等RAII类型)
// 编译器会自动生成拷贝构造、拷贝赋值、移动构造、移动赋值、析构
// 完美!不需要写任何代码!
class RuleOfZero {
    std::string data_;  // 资源由成员自己管理(string内部自己处理内存)
public:
    RuleOfZero(const std::string& d) : data_(d) {}
    // 五大特殊成员函数全部使用编译器自动生成的版本
    // 不用担心内存泄漏,不用担心浅拷贝!
};

// ============ 五法则 ============
// 如果类中手动管理资源(用raw指针),就必须实现全部5个函数
class RuleOfFive {
    char* data_;    // 手动管理的原始指针
    size_t len_;    // 字符串长度
    
public:
    // 普通构造函数
    RuleOfFive(const char* str) {
        len_ = strlen(str);
        data_ = new char[len_ + 1];
        strcpy(data_, str);
    }
    
    // 1. 析构函数:释放资源
    ~RuleOfFive() { 
        delete[] data_; 
        std::cout << "RuleOfFive destroyed" << std::endl;
    }
    
    // 2. 拷贝构造函数:深拷贝
    RuleOfFive(const RuleOfFive& other) {
        len_ = other.len_;
        data_ = new char[len_ + 1];
        strcpy(data_, other.data_);
    }
    
    // 3. 移动构造函数:偷取资源
    RuleOfFive(RuleOfFive&& other) noexcept : data_(other.data_), len_(other.len_) {
        other.data_ = nullptr;  // 源对象置空
        other.len_ = 0;
    }
    
    // 4. 拷贝赋值运算符
    RuleOfFive& operator=(const RuleOfFive& other) {
        if (this != &other) {
            delete[] data_;  // 释放旧资源
            len_ = other.len_;
            data_ = new char[len_ + 1];
            strcpy(data_, other.data_);
        }
        return *this;
    }
    
    // 5. 移动赋值运算符
    RuleOfFive& operator=(RuleOfFive&& other) noexcept {
        if (this != &other) {
            delete[] data_;  // 释放旧资源
            data_ = other.data_;  // 偷取资源
            len_ = other.len_;
            other.data_ = nullptr;
            other.len_ = 0;
        }
        return *this;
    }
    
    const char* get() const { return data_ ? data_ : "(null)"; }
};

int main() {
    std::cout << "=== 五法则演示 ===" << std::endl;
    RuleOfFive r1("Hello");
    RuleOfFive r2("World");
    
    std::cout << "\n=== 移动赋值 ===" << std::endl;
    r2 = std::move(r1);  // r2的旧资源("World")被释放,r1的资源被"偷"给r2
    // 注意:std::move(r1)将r1转换为右值引用,触发移动赋值运算符
    // 移动赋值后,r1的资源被转交给r2,r1本身在作用域结束前仍然存在(只是变成了"空壳")
    // r2原来的"World"资源被释放,所以先打印"RuleOfFive destroyed"

    std::cout << "After move: " << r2.get() << std::endl;
    
    std::cout << "\n=== 作用域结束 ===" << std::endl;
    
    return 0;
}

运行结果:

=== 五法则演示 ===

=== 移动赋值 ===
RuleOfFive destroyed  // r2原来的资源被释放

After move: Hello

=== 作用域结束 ===
RuleOfFive destroyed  // r2被销毁

实用建议:尽量使用std::stringstd::vector等RAII类型,它们帮你自动处理内存管理。这样你就可以遵循零法则,代码简洁又安全!

11.10 =default与=delete(C++11)

C++11引入了两个特殊的语法:=default=delete。它们就像是类的"快捷键",让你可以显式地要求编译器生成默认实现,或者彻底禁用某个函数。

=default:使用默认实现

有时候你自定义了构造函数,但希望其他特殊成员函数使用编译器自动生成的版本。这时可以用=default显式声明。

=delete:禁用某个函数

有时候你想禁止某些操作(比如禁止拷贝某个类),这时可以用=delete显式删除函数。

 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
#include <iostream>

// 不可拷贝的类 - 使用=delete禁用拷贝操作
class NonCopyable {
private:
    int id_;  // 私有,让它更"私密"
    
public:
    NonCopyable(int id) : id_(id) {}
    
    // 显式删除拷贝构造函数:禁止任何形式的拷贝
    // 任何拷贝该对象的代码都会编译报错!
    NonCopyable(const NonCopyable&) = delete;
    
    // 显式删除拷贝赋值运算符
    NonCopyable& operator=(const NonCopyable&) = delete;
    
    // 移动操作:使用=default使用默认实现
    // 移动后对象可以被"掏空"
    NonCopyable(NonCopyable&&) = default;
    NonCopyable& operator=(NonCopyable&&) = default;
    
    int getId() const { return id_; }
};

// 使用=default的类
class DefaultDemo {
    int value_;
    
public:
    DefaultDemo(int v) : value_(v) {}
    
    // 显式default析构函数
    // 告诉编译器:虽然有自定义构造函数,但析构函数用默认的就行
    ~DefaultDemo() = default;
    
    // 其他特殊成员函数编译器会自动生成
};

int main() {
    std::cout << "=== =delete演示 ===" << std::endl;
    
    NonCopyable nc1(1);
    std::cout << "nc1.id = " << nc1.getId() << std::endl;
    
    // 下面这些都会编译报错:
    // NonCopyable nc2(nc1);  // 错误!拷贝构造被删除
    // NonCopyable nc3 = nc1;  // 错误!拷贝赋值被删除
    
    NonCopyable nc4(4);
    NonCopyable nc5(std::move(nc4));  // OK!移动构造是default的
    std::cout << "nc5.id = " << nc5.getId() << std::endl;
    
    std::cout << "\n=== =default演示 ===" << std::endl;
    
    DefaultDemo d1(10);
    DefaultDemo d2(d1);  // OK!默认拷贝构造
    std::cout << "d2.value = " << d2.value_ << std::endl;
    
    std::cout << "\n=default and =delete demo 成功!" << std::endl;
    
    return 0;
}

运行结果:

=== =delete演示 ===
nc1.id = 1
nc5.id = 4

=== =default演示 ===
d2.value = 10

=default and =delete demo 成功!

应用场景:

  • =delete:当你设计一个类不想被拷贝时(比如文件句柄、线程等),或者想禁用某些不合理的操作(比如NonCopyable nc = 1;)。
  • =default:当你想让编译器生成默认实现,但又要明确表达你的意图时。这比完全不写要好,因为更清晰。

11.11 聚合类与聚合初始化

**聚合类(Aggregate Class)**是一种特殊的类,可以直接用花括号初始化,就像初始化数组一样。聚合初始化是C++最早期就有的特性,C++20又给它加了新花样。

简单来说,聚合类就是:

  • 没有用户定义的构造函数
  • 没有privateprotected的非静态成员变量
  • 没有virtual函数
  • 不是union

指定初始化器(C++20)

C++20引入了一种新语法:可以按名字初始化成员变量,而不是按顺序。这就像是点外卖时说"我要一份不辣的多加葱的",而不是"我按顺序说:饭、葱、否、加"。

 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
#include <iostream>

struct Point3D {
    double x = 0.0;  // 默认值为0.0
    double y = 0.0;
    double z = 0.0;
};

int main() {
    std::cout << "=== C++20 指定初始化器 ===" << std::endl;
    
    // C++20新特性:按名字初始化,顺序可以打乱!
    Point3D p1 = {.x = 1.0, .y = 2.0, .z = 3.0};
    std::cout << "p1: (" << p1.x << ", " << p1.y << ", " << p1.z << ")" << std::endl;
    // 输出: p1: (1, 2, 3)
    
    // 部分指定,只初始化x,y和z使用默认值
    Point3D p2 = {.x = 5.0};
    std::cout << "p2: (" << p2.x << ", " << p2.y << ", " << p2.z << ")" << std::endl;
    // 输出: p2: (5, 0, 0)
    
    // 顺序打乱也没关系
    Point3D p3 = {.z = 9.0, .x = 7.0, .y = 8.0};
    std::cout << "p3: (" << p3.x << ", " << p3.y << ", " << p3.z << ")" << std::endl;
    // 输出: p3: (7, 8, 9)
    
    // C++20也支持花括号形式
    Point3D p4{.x = 10.0, .z = 12.0};
    std::cout << "p4: (" << p4.x << ", " << p4.y << ", " << p4.z << ")" << std::endl;
    // 输出: p4: (10, 0, 12)
    
    return 0;
}

运行结果:

=== C++20 指定初始化器 ===
p1: (1, 2, 3)
p2: (5, 0, 0)
p3: (7, 8, 9)
p4: (10, 0, 12)

指定初始化器的好处:代码可读性更好!当初始化一个有很多成员的结构体时,{.x=1, .y=2}{1, 2}清晰多了,尤其是在只初始化部分成员时。

括号聚合初始化(C++20)

C++20还简化了聚合初始化的语法,现在可以用圆括号()来初始化聚合类了!

 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
#include <iostream>

struct Color {
    int r = 0;  // 红色分量
    int g = 0;  // 绿色分量
    int b = 0;  // 蓝色分量
};

int main() {
    std::cout << "=== C++20 括号聚合初始化 ===" << std::endl;
    
    // C++20: 括号聚合初始化
    Color c1{255, 0, 0};  // 完全初始化,红色
    std::cout << "c1: RGB(" << c1.r << "," << c1.g << "," << c1.b << ")" << std::endl;
    
    // 部分初始化,没指定的用默认值
    Color c2{0, 255};  // 只有r和g,b=0
    std::cout << "c2: RGB(" << c2.r << "," << c2.g << "," << c2.b << ")" << std::endl;
    
    // 只初始化r
    Color c3{128};
    std::cout << "c3: RGB(" << c3.r << "," << c3.g << "," << c3.b << ")" << std::endl;
    
    return 0;
}

运行结果:

=== C++20 括号聚合初始化 ===
c1: RGB(255,0,0)
c2: RGB(0,255,0)
c3: RGB(128,0,0)

聚合初始化的限制:必须按顺序初始化,不能跳过中间的成员。如果你想只初始化第一个和第三个成员,对不起,不行——这是聚合初始化的规矩。

11.12 成员声明顺序强制(C++23)

C++23引入了一个重要的规则:构造函数初始化列表中的成员初始化顺序必须与成员声明顺序一致。虽然这条规则在之前的C++标准中已经是建议性的,但C++23把它变成了强制性的编译器规则。

 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
#include <iostream>

// C++23: 构造函数体中成员初始化必须按照声明顺序
class OrderedMembers {
public:
    int a;  // 第1个声明
    int b;  // 第2个声明
    int c;  // 第3个声明
    
    OrderedMembers() : a(1), b(2), c(3) {
        // 即使你写成 : c(3), b(2), a(1)
        // 编译器也会强制按声明顺序a,b,c初始化
        // 这是C++23的新规则!
        std::cout << "a=" << a << ", b=" << b << ", c=" << c << std::endl;
    }
};

int main() {
    std::cout << "=== C++23 成员初始化顺序强制 ===" << std::endl;
    
    OrderedMembers obj;
    
    std::cout << "\nC++23: 初始化列表中的顺序必须和声明顺序一致" << std::endl;
    std::cout << "违反的话,编译器会报错!" << std::endl;
    
    return 0;
}

运行结果:

=== C++23 成员初始化顺序强制 ===
a=1, b=2, c=3

C++23: 初始化列表中的顺序必须和声明顺序一致
违反的话,编译器会报错!

为什么要有这个规则?因为成员初始化的实际执行顺序是按照声明顺序,而不是初始化列表中的顺序。如果两者不一致,而你的代码又依赖于某个特定顺序,就会产生微妙的bug。所以C++23干脆规定:初始化列表的顺序必须和声明顺序一致,否则编译报错!

11.13 常见陷阱

C++的类虽好,但其中藏着几个臭名昭著的陷阱。了解它们,才能写出健壮的代码!

切片问题

**切片(slicing)**是C++继承体系中一个经典的问题。当派生类对象被赋值给基类对象时(值传递或值赋值),派生类的部分会被"切掉",只剩下基类部分。这就是所谓的"切片"——就像切蛋糕一样,把上面那层奶油(派生类)给切掉了。

 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
#include <iostream>

class Base {
public:
    // 虚函数:派生类可以重写
    virtual void print() const {
        std::cout << "Base::print()" << std::endl;
    }
    
    // 虚析构函数:多态安全的必要条件
    virtual ~Base() {}
};

class Derived : public Base {
public:
    // 重写print方法
    void print() const override {
        std::cout << "Derived::print()" << std::endl;
    }
    
    // 派生类独有的方法
    void extra() const {
        std::cout << "Derived::extra() - 这是派生类独有的!" << std::endl;
    }
};

int main() {
    std::cout << "=== 切片问题演示 ===" << std::endl;
    
    Derived d;
    std::cout << "d创建完毕" << std::endl;
    
    std::cout << "\n--- 切片发生!---" << std::endl;
    Base b = d;  // 切片!Derived部分被"切掉"了!
    b.print();   // 调用的是Base::print(),而不是Derived的!
    
    std::cout << "\n--- 正确做法:用指针或引用 ---" << std::endl;
    
    Base* pb = &d;  // 指向派生类对象
    Base& rb = d;   // 引用到派生类对象
    
    pb->print();  // 调用Derived::print() - 多态生效!
    rb.print();   // 调用Derived::print() - 多态生效!
    
    std::cout << "\n--- 切片的原因 ---" << std::endl;
    std::cout << "b是Base类型的对象,只有Base那么大" << std::endl;
    std::cout << "赋值时只复制了Base部分,Derived部分被丢弃" << std::endl;
    
    return 0;
}

运行结果:

=== 切片问题演示 ===
d创建完毕

--- 切片发生!---
Base::print()

--- 正确做法:用指针或引用 ---
Derived::print()
Derived::print()

--- 切片的原因 ---
b是Base类型的对象,只有Base那么大
赋值时只复制了Base部分,Derived部分被丢弃

如何避免切片?

  1. 用指针或引用代替值传递:void foo(Base& b)void foo(Base* b)
  2. 如果必须值传递,考虑使用virtual clone()模式
  3. 传值时三思:这个对象是否真的需要一份副本?

构造函数中的虚函数调用

这是另一个经典的"看起来对,实际上错"的陷阱。在基类构造函数中调用虚函数,不会调用到派生类的版本!

为什么会这样?因为在基类构造函数执行时,派生类部分还没有初始化!如果虚函数调用真的派发到派生类,而派生类的虚函数又访问了还没初始化的成员变量——程序就崩溃了。所以C++规定:在基类构造函数中,虚函数调用不会派发到派生类

 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
#include <iostream>

class Base {
public:
    Base() {
        std::cout << "Base constructor - calling virtual function" << std::endl;
        print();  // 危险!这里调用的不是派生类的版本!
    }
    
    virtual void print() const {
        std::cout << "Base::print()" << std::endl;
    }
    
    virtual ~Base() {}
};

class Derived : public Base {
private:
    int value_ = 42;  // 派生类独有的成员
    
public:
    Derived() {
        std::cout << "Derived constructor" << std::endl;
    }
    
    // 重写print方法
    void print() const override {
        std::cout << "Derived::print(), value=" << value_ << std::endl;
    }
};

int main() {
    std::cout << "=== 构造函数中的虚函数调用 ===" << std::endl;
    std::cout << "Creating Derived object:" << std::endl;
    
    Derived d;
    
    std::cout << "\n--- 解释 ---" << std::endl;
    std::cout << "在Base构造期间,Derived部分还没初始化!" << std::endl;
    std::cout << "所以虚函数调用不会派发到Derived::print()" << std::endl;
    
    std::cout << "\n--- 对象创建完成后 ---" << std::endl;
    Base* ptr = &d;
    ptr->print();  // 这里会调用派生类的版本,因为对象已经完全构造了
    
    return 0;
}

运行结果:

=== 构造函数中的虚函数调用 ===
Creating Derived object:
Base constructor - calling virtual function
Base::print()           // 注意!调用的是Base的版本,不是Derived的!

Derived constructor

--- 解释 ---
在Base构造期间,Derived部分还没初始化!
所以虚函数调用不会派发到Derived::print()

--- 对象创建完成后 ---
Derived::print(), value=42

如何避免这个陷阱?

  1. 在构造函数中避免调用虚函数
  2. 如果必须在构造时做些什么,使用两阶段初始化:init()函数
  3. 或者使用工厂模式(Factory Pattern)来控制对象的创建顺序

本章小结

恭喜你!终于把类与对象基础这一章学完了!让我们来回顾一下都学了什么:

核心概念

  1. 类与对象:类是蓝图,对象是按蓝图创建的具体实例。类定义了数据和操作(成员变量和成员函数),对象则是这些定义的具体化。

  2. 面向对象三大特性

    • 封装:把数据和方法包装在一起,对外隐藏实现细节,保证数据安全
    • 继承:从已有类派生出新类,复用代码,实现"is-a"关系
    • 多态:同一接口不同实现,通过虚函数实现运行时动态绑定
  3. 类不变式:对象生命周期内必须始终满足的条件,是保证对象合法性的"宪法"

类的定义与语法

  1. 访问控制public(公有)、protected(受保护)、private(私有)三级门禁,控制成员的访问权限

  2. 构造函数:对象出生时自动调用的初始化函数

    • 默认构造函数:无参数
    • 带参构造函数:初始化列表更高效
    • 拷贝构造函数:复制一个已有对象
    • 移动构造函数(C++11):“偷取"临时对象资源
    • 委托构造函数(C++11):构造函数调用其他构造函数
    • explicit:防止隐式转换
  3. 析构函数:对象销毁时自动调用的清理函数,不应该抛出异常

特殊成员函数与规则

  1. 赋值运算符重载:处理已存在对象的赋值操作,需要注意自赋值检查和资源释放

  2. 深拷贝 vs 浅拷贝:浅拷贝只复制指针,深拷贝复制数据。管理动态内存的类必须实现深拷贝!

  3. 三五法则与零法则

    • 三法则:自定义析构函数、拷贝构造、拷贝赋值之一,就需要自定义全部三个
    • 五法则(C++11):再加上移动构造和移动赋值
    • 零法则:使用RAII类型,让编译器自动生成特殊成员函数
  4. =default=delete:显式要求编译器生成默认实现,或彻底禁用某个函数

现代C++特性

  1. 聚合初始化与指定初始化器

    • C++20支持{.x = 1, .y = 2}形式的指定初始化
    • C++20支持括号形式的聚合初始化
  2. 成员初始化顺序强制(C++23):初始化列表顺序必须与成员声明顺序一致

常见陷阱

  1. 切片问题:值传递/赋值会导致派生类部分被丢弃,用指针或引用避免
  2. 构造函数中的虚函数调用:基类构造期间虚函数不会派发到派生类

写在最后:类是C++面向对象编程的核心,掌握好这一章的内容,你就迈出了成为C++大师的第一步!但别骄傲,后面的章节还有继承、多态、模板等更精彩的内容等着你。继续加油!

最后修改 March 30, 2026: 更新 C++ 教程 (da65b52)