← C++ 教材目录(共 13 章)

第 5 章 类与对象

red wenzi · 2026-09-15 · 编程语言 · C++ · 📖 预计阅读 38 分钟 · 共 7 道练习
🎯 本章你会学到:构造析构、拷贝控制、运算符重载与多态。建议边读边敲代码,每节的练习先自己做,再展开答案对照。

写数据结构,主要工作就是写类:Vector、LinkedList、Stack、Queue、BinaryTree……每一个都是类或结构体。本章讲清楚三件事:对象怎么创建和销毁、拷贝意味着什么、多态怎么工作。

5.1 struct 与 class

先说结论:struct 和 class 几乎是同一个东西,唯一的区别是默认访问权限(struct 默认 public,class 默认 private)和默认继承方式。

不需要 new 才能创建对象

Point p{1.0, 2.0}; 就在当前作用域的栈上创建了一个真实对象,离开作用域自动销毁,没有任何运行时代价。这是从 Java 过来必须立刻纠正的直觉:Java 的 Point p = new Point() 在 C++ 里对应堆上的 Point* p = new Point()(需要你负责释放)。默认写法应该是栈对象。

⚠️ 易错点 成员访问:对象用 .(p.x),指针用 ->(p->x)。Java 统一用 .,C++ 必须区分手上是对象还是指针。

类内初始化 + const 成员函数

成员可以直接给默认值:double x = 0.0;(类内初始化,C++11 起)。const 成员函数(double dist() const)承诺不修改对象,是最重要的接口约定:int size() const 告诉调用方“你放心调用,我不会动你的容器”。

示例代码
struct Point {
    double x = 0.0;             // 类内初始化
    double y = 0.0;
    double dist() const { return std::sqrt(x * x + y * y); }
};
Point p{3.0, 4.0};              // 栈上创建,没有 new
⚠️ 易错点 private 成员变量名习惯加下划线后缀(owner_、balance_),区分参数。

✍️ 本节练习

5.1.1必做Point 类

定义 struct Point(double x, y,带类内初始化 0.0),写成员函数 double dist() const 返回到原点的距离。main 里创建 Point p{3.0, 4.0} 输出 dist()。

输入 无

输出 5

样例输入
(本题无输入)
样例输出
5

💡 提示 需要 #include <cmath>,用 std::sqrt(x*x + y*y)。

✅ 查看参考答案与解析
#include <cmath>
#include <iostream>
struct Point {
    double x = 0.0;
    double y = 0.0;
    double dist() const { return std::sqrt(x * x + y * y); }
};
int main() {
    Point p{3.0, 4.0};
    std::cout << p.dist() << '\n';
    return 0;
}

解析 C++ 里 Point p{...} 直接在栈上建对象,不需要 new;const 成员函数承诺不修改对象。

5.2 构造函数与析构函数

构造函数负责把对象“初始化到位”,析构函数负责“收尾”。Java 没有析构函数的概念(有 finalize 但基本不用),这里全靠编译器在确定时刻调用。

初始化列表:真正的初始化

成员写在初始化列表里才是初始化,写在函数体里是赋值。三类成员必须在初始化列表里初始化:const 成员、引用成员、没有默认构造函数的类类型成员。

示例代码
class Buffer {
public:
    explicit Buffer(std::size_t n)
        : data_(n, 0), size_(n), name_("buf") { }   // 初始化列表
private:
    std::vector<int> data_;
    std::size_t size_ = 0;
    std::string name_;
};
⚠️ 易错点 成员初始化顺序由声明顺序决定,和初始化列表书写顺序无关。把列表顺序写成和声明一致,否则有 -Wreorder 警告的隐患。

explicit、= default、= delete

explicit 修饰单参数构造函数,防止 Buffer b = 10; 这种隐式转换——Java 没有这个关键字,C++ 里几乎所有的单参数构造函数都应该加 explicit。= default 让编译器生成默认版本;= delete 明确禁止某个函数(例如禁止拷贝)。

⚠️ 易错点 析构函数不能有参数、不能重载、不能抛异常。它在三个时刻被自动调用:栈对象离开作用域、delete 堆对象、临时对象所在表达式结束。

✍️ 本节练习

5.2.1挑战Counter 计数

写一个 Counter 类:构造函数 ++instances,析构函数 --instances,静态成员函数 count() 返回当前对象数。main 里建 3 个对象输出计数,再用一个花括号作用域建第 4 个并让它析构,再输出计数。

输入 无

输出 两行:3、3。

样例输入
(本题无输入)
样例输出
3
3

💡 提示 静态成员变量要在类外定义一次:int Counter::instances = 0;

✅ 查看参考答案与解析
#include <iostream>
class Counter {
public:
    Counter() { ++instances; }
    ~Counter() { --instances; }
    static int count() { return instances; }
private:
    static int instances;
};
int Counter::instances = 0;
int main() {
    Counter a, b, c;
    std::cout << Counter::count() << '\n';   // 3
    { Counter d; }                            // d 在这里析构
    std::cout << Counter::count() << '\n';   // 3
    return 0;
}

解析 析构在对象离开作用域时自动调用——确定性销毁是 C++ 与 Java 最大的不同。

5.2.2必做拷贝的独立性

写一个含 std::vector<int> 成员的类 VecBox,默认拷贝即可。main 里创建 a 放入 {1,2,3},用 VecBox b = a; 拷贝,然后修改 b 的元素,输出 a 的元素验证 a 没被影响。

输入 无

输出 1 2 3

样例输入
(本题无输入)
样例输出
1 2 3

💡 提示 b = a 是逐成员拷贝,vector 拷贝是深拷贝,各自独立。

✅ 查看参考答案与解析
#include <iostream>
#include <vector>
class VecBox {
public:
    std::vector<int> data;
};
int main() {
    VecBox a;
    a.data = {1, 2, 3};
    VecBox b = a;          // 拷贝:b 有自己的 vector
    b.data[0] = 99;        // 改 b 不影响 a
    for (int x : a.data) { std::cout << x << ' '; }
    std::cout << '\n';     // 1 2 3
    return 0;
}

解析 成员都是自管理资源的类型(vector/string)时,默认拷贝就正确——这就是零法则。

5.3 拷贝控制:深浅拷贝与五法则

这是 C++ 相对 Java 差别最大、也最容易写出 bug 的地方。Java 里 Foo b = a; 是让 a、b 指向同一个对象;C++ 里 Foo b = a; 默认逐成员拷贝出一个新对象——这叫值语义。

零法则 vs 五法则

成员都是 int、std::string、std::vector 这类“自己管好自己资源”的类型时,默认拷贝就是正确的,你一个特殊函数都不用写(零法则)。问题出在类里含裸指针:默认拷贝只复制指针的值,两个对象指向同一块内存,析构时释放两次——浅拷贝问题。

⚠️ 易错点 如果你的类需要自己写析构函数(管理了裸资源),那它通常也需要自己写拷贝构造、拷贝赋值、移动构造、移动赋值——这就是五法则。

深拷贝怎么写

拷贝构造:分配同样大的新内存并复制元素;拷贝赋值:处理自赋值 a = a、先分配新内存再释放旧的(异常安全)、返回 *this 支持链式 a = b = c。

示例代码
IntArray(const IntArray& other)
    : size_(other.size_), data_(new int[other.size_]) {
    std::copy(other.data_, other.data_ + size_, data_);
}
⚠️ 易错点 工程上更省事的做法:不写裸资源,直接用 vector/string 等成员,让零法则替你解决。
浅拷贝:两个对象指向同一块内存a.data_b = aint[3]析构两次同一块内存 → double free深拷贝:各自一份a.data_b.data_int[3]int[3]互不影响,析构各释放各的规则:类里有裸指针就要写五法则;用 vector/string 就等于白拿深拷贝(零法则)
图 3:浅拷贝为什么会 double free,深拷贝为什么安全

✍️ 本节练习

5.3.1挑战浅拷贝的代价

写一个含裸指针的类 BadBox(构造函数 new 一个 int,析构 delete),main 里 BadBox b = a; 拷贝后再析构——程序会崩(双重释放)。要求:运行一次观察崩溃,然后把成员换成 std::vector<int> 或补上深拷贝拷贝构造,让程序正常。

输入 无

输出 7

样例输入
(本题无输入)
样例输出
7

💡 提示 这是“观察未定义行为”的实验题:先写坏的版本跑一次,再写好的版本。

✅ 查看参考答案与解析
#include <iostream>
#include <vector>
// 好的版本:用 vector 代替裸指针(零法则)
class GoodBox {
public:
    std::vector<int> data;
};
int main() {
    GoodBox a;
    a.data.push_back(7);
    GoodBox b = a;      // 深拷贝,安全
    std::cout << b.data[0] << '\n';
    return 0;
}
// 坏的版本(自己敲一遍再删掉):
// class BadBox {
// public:
//     int* p;
//     BadBox() : p(new int(0)) { }
//     ~BadBox() { delete p; }   // 拷贝后两个对象共享 p,析构两次 = 崩溃
// };

解析 含裸指针的类默认拷贝是浅拷贝,两个对象析构时双重释放。解法:零法则(用 vector)或五法则(写深拷贝)。

5.4 运算符重载:让结构体能排序

在数据结构课里,运算符重载的实际用途主要是三个:让结构体能比较大小(用于排序和放进 set/map)、让容器能用 [] 访问、让自己写的类能直接输出。

operator<:排序的钥匙

定义 operator< 之后,std::sort 和 std::set/std::map 就能直接用你的类型。比较器必须是严格弱序:相等就返回 false。写成 return a.score <= b.score; 会让 std::sort 的行为未定义,甚至越界崩溃。

示例代码
struct Student {
    std::string name;
    int score;
    bool operator<(const Student& o) const {
        if (score != o.score) { return score > o.score; }  // 分数降序
        return name < o.name;                              // 同名按名字升序
    }
};
⚠️ 易错点 不能发明新运算符、不能改变优先级、至少一个操作数是你自己的类型;=、[]、()、-> 必须是成员函数,<< 输出运算符必须是自由函数(左操作数是 std::ostream)。

✍️ 本节练习

5.4.1必做学生按分数排序

定义 struct Student(string name, int score),重载 operator<(按分数降序),读入 3 个学生,排序后从高到低输出“名字 分数”。

输入 三行,每行“名字 分数”。

输出 三行,按分数降序。

样例输入
Ann 90
Bob 95
Cid 88
样例输出
Bob 95
Ann 90
Cid 88

💡 提示 operator< 返回 score > o.score 实现降序;std::sort 直接用。

✅ 查看参考答案与解析
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct Student {
    std::string name;
    int score;
    bool operator<(const Student& o) const { return score > o.score; }
};
int main() {
    std::vector<Student> v(3);
    for (int i = 0; i < 3; ++i) { std::cin >> v[i].name >> v[i].score; }
    std::sort(v.begin(), v.end());
    for (const auto& s : v) { std::cout << s.name << ' ' << s.score << '\n'; }
    return 0;
}

解析 有 operator< 就能直接排序;注意比较器必须是严格弱序(相等返回 false)。

5.5 继承与多态

继承在数据结构课里用得不算多,但几个概念必须清楚,否则你会在“为什么析构函数要加 virtual”这类问题上卡住。

virtual:C++ 默认静态绑定

只有虚函数才是动态绑定。不写 virtual,p->area() 调用的是指针声明类型 Shape 的版本,而不是实际对象类型的版本——Java 实例方法默认动态绑定,从 Java 过来最容易忘的就是这个 virtual。派生类加 override 检查是否正确覆盖。

示例代码
class Shape {
public:
    virtual ~Shape() = default;            // 多态基类必须有虚析构
    virtual double area() const = 0;       // 纯虚函数:子类必须实现
};
class Circle : public Shape {
public:
    double area() const override { return 3.14159265 * r_ * r_; }
};
⚠️ 易错点 多态基类的析构函数必须是虚的。否则 Shape* p = new Circle(); delete p; 只调 Shape 的析构,Circle 的资源就泄漏了。

对象切片与抽象类

Shape s = Circle(1.0); 会把 Circle 切成 Shape,多态信息全部丢失。多态必须通过指针或引用使用,不能按值传递基类。含有纯虚函数的类是抽象类,不能实例化;C++ 没有 interface 关键字,“接口”就是“只有纯虚函数的抽象类”。

⚠️ 易错点 漏写 override 又写错参数类型(比如忘记 const)时,程序能编译通过但行为完全不对——这是非常经典的坑。

✍️ 本节练习

5.5.1挑战形状多态

定义抽象基类 Shape(虚析构 + 纯虚函数 area()),派生 Circle(半径)和 Square(边长),main 里用 vector<unique_ptr<Shape>> 存两个对象,输出各自的面积(保留 2 位小数)。

输入 一行两个数:圆半径、正方形边长。

输出 两行,圆面积、正方形面积(保留 2 位小数)。

样例输入
1 2
样例输出
3.14
4.00

💡 提示 纯虚函数 = 0 让类抽象化;派生类加 override。

✅ 查看参考答案与解析
#include <iomanip>
#include <iostream>
#include <memory>
#include <vector>
class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;
};
class Circle : public Shape {
public:
    explicit Circle(double r) : r_(r) { }
    double area() const override { return 3.14159265 * r_ * r_; }
private:
    double r_;
};
class Square : public Shape {
public:
    explicit Square(double side) : side_(side) { }
    double area() const override { return side_ * side_; }
private:
    double side_;
};
int main() {
    double r, side;
    std::cin >> r >> side;
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(r));
    shapes.push_back(std::make_unique<Square>(side));
    std::cout << std::fixed << std::setprecision(2);
    for (const auto& s : shapes) { std::cout << s->area() << '\n'; }
    return 0;
}

解析 多态基类必须有虚析构;多态必须通过指针/引用使用,不能按值。

本章小结

🧩 本章综合练习

这几道题把本章多个知识点串起来,建议合上资料独立完成,再展开答案对照。

5.A综合五法则实战:写一个能安全拷贝的 IntArray

实现 IntArray:裸指针 + 元素个数。构造函数分配、析构释放、拷贝构造深拷贝、拷贝赋值处理自赋值并返回 *this、at() 越界抛 std::out_of_range。main 里验证拷贝独立性和链式赋值 c = b = a。

输入 第一行整数 n;第二行 n 个整数。

输出 先输出改过 b 之后的 a、b、c 内容,再输出捕获到的异常信息。

样例输入
3
7 8 9
样例输出
a: 7 8 9
b: 100 8 9
c: 7 8 9
caught: IntArray::at

💡 提示 拷贝赋值:先分配新内存再释放旧内存(异常安全);用 if (this == &other) 处理自赋值。

✅ 查看参考答案与解析
#include <cstddef>
#include <iostream>
#include <stdexcept>

class IntArray {
public:
    explicit IntArray(std::size_t n)
        : size_(n), data_(n > 0 ? new int[n]{} : nullptr) { }

    IntArray(const IntArray& other)                  // 拷贝构造:深拷贝
        : size_(other.size_), data_(other.size_ > 0 ? new int[other.size_]{} : nullptr) {
        for (std::size_t i = 0; i < size_; ++i) { data_[i] = other.data_[i]; }
    }

    IntArray& operator=(const IntArray& other) {     // 拷贝赋值
        if (this == &other) { return *this; }         // 自赋值
        int* fresh = other.size_ > 0 ? new int[other.size_]{} : nullptr;
        for (std::size_t i = 0; i < other.size_; ++i) { fresh[i] = other.data_[i]; }
        delete[] data_;                               // 新内存准备好再释放旧的
        data_ = fresh;
        size_ = other.size_;
        return *this;                                 // 支持 c = b = a
    }

    ~IntArray() { delete[] data_; }

    std::size_t size() const { return size_; }

    int& at(std::size_t i) {
        if (i >= size_) { throw std::out_of_range("IntArray::at"); }
        return data_[i];
    }
    int at(std::size_t i) const {
        if (i >= size_) { throw std::out_of_range("IntArray::at"); }
        return data_[i];
    }

private:
    std::size_t size_ = 0;
    int* data_ = nullptr;
};

int main() {
    int n{};
    std::cin >> n;
    IntArray a(static_cast<std::size_t>(n));
    for (int i = 0; i < n; ++i) { std::cin >> a.at(static_cast<std::size_t>(i)); }

    IntArray b = a;          // 拷贝构造
    IntArray c(1);
    c = b = a;               // 链式赋值
    b.at(0) = 100;           // 改 b 不影响 a、c

    auto print = [](const char* tag, const IntArray& v) {
        std::cout << tag << ':';
        for (std::size_t i = 0; i < v.size(); ++i) { std::cout << ' ' << v.at(i); }
        std::cout << '\n';
    };
    print("a", a);
    print("b", b);
    print("c", c);

    try { a.at(99); }
    catch (const std::out_of_range& e) { std::cout << "caught: " << e.what() << '\n'; }
    return 0;
}

解析 拷贝赋值三条纪律:处理自赋值、先分配后释放、返回 *this。写完用 -fsanitize=address,undefined 跑一遍,确认没有 double free。