写数据结构,主要工作就是写类: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()(需要你负责释放)。默认写法应该是栈对象。
类内初始化 + 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
✍️ 本节练习
定义 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_;
};
explicit、= default、= delete
explicit 修饰单参数构造函数,防止 Buffer b = 10; 这种隐式转换——Java 没有这个关键字,C++ 里几乎所有的单参数构造函数都应该加 explicit。= default 让编译器生成默认版本;= delete 明确禁止某个函数(例如禁止拷贝)。
✍️ 本节练习
写一个 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 最大的不同。
写一个含 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_);
}
✍️ 本节练习
写一个含裸指针的类 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; // 同名按名字升序
}
};
✍️ 本节练习
定义 struct Student(string name, int score),重载 operator<(按分数降序),读入 3 个学生,排序后从高到低输出“名字 分数”。
输入 三行,每行“名字 分数”。
输出 三行,按分数降序。
Ann 90
Bob 95
Cid 88Bob 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 s = Circle(1.0); 会把 Circle 切成 Shape,多态信息全部丢失。多态必须通过指针或引用使用,不能按值传递基类。含有纯虚函数的类是抽象类,不能实例化;C++ 没有 interface 关键字,“接口”就是“只有纯虚函数的抽象类”。
✍️ 本节练习
定义抽象基类 Shape(虚析构 + 纯虚函数 area()),派生 Circle(半径)和 Square(边长),main 里用 vector<unique_ptr<Shape>> 存两个对象,输出各自的面积(保留 2 位小数)。
输入 一行两个数:圆半径、正方形边长。
输出 两行,圆面积、正方形面积(保留 2 位小数)。
1 23.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;
}
解析 多态基类必须有虚析构;多态必须通过指针/引用使用,不能按值。
本章小结
- 栈上直接建对象,不需要 new;对象用 .、指针用 ->。
- 初始化列表是真初始化;explicit 防止隐式转换;析构确定时刻自动调用。
- 成员都是自管理类型时用零法则;含裸指针用五法则。
- operator< 必须是严格弱序;多态基类必须有虚析构;多态必须用指针/引用。
🧩 本章综合练习
这几道题把本章多个知识点串起来,建议合上资料独立完成,再展开答案对照。
实现 IntArray:裸指针 + 元素个数。构造函数分配、析构释放、拷贝构造深拷贝、拷贝赋值处理自赋值并返回 *this、at() 越界抛 std::out_of_range。main 里验证拷贝独立性和链式赋值 c = b = a。
输入 第一行整数 n;第二行 n 个整数。
输出 先输出改过 b 之后的 a、b、c 内容,再输出捕获到的异常信息。
3
7 8 9a: 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。