简介:这份PDF资料面向正在学习或备考C++面向对象程序设计的本科生与自学者,以一套完整模拟试卷的形式梳理类与对象、继承、多态、函数重载、this指针、运算符重载、异常处理等核心考点。资源共1个PDF文件,压缩包约232KB,轻量便于随时打开复习,无需额外配置环境。内容包含简答题、填空题、单项选择题与读程题四类题型,每道题均附参考答案,并给出break与continue的作用区别、函数重载的实现原则、this指针的隐式与显式用法、纯虚函数与抽象类的关系、throw与catch构成的异常处理结构,以及静态数据成员、对象数组、私有继承等易错点的辨析,读者可借此检验对参数传递方式、派生类继承特性与类模板的理解程度。目前已有887人学习下载,适合在考前集中刷题、对照错题查漏补缺,也可作为课堂练习与知识点自测的补充材料。
1. 面向对象程序设计C++:先分清「用类写」和「面向对象」
不少人的第一份面向对象程序设计材料,是从 class 关键字、构造函数、public/private 讲起的,照着敲一遍能编译通过,真到改需求时还是一团乱。卡点通常不在语法,而在判断力:哪块数据值得拥有自己的类,哪块只配当一个结构体。我见过一个数据采集模块,最初用 struct 加十几个自由函数写得挺顺,后来新增一种采样格式,改到第十个函数就漏了两处,根源就是数据和操作完全分离,没有任何机制拦住你绕过规则。
面向对象程序设计在 C++ 里要解决的核心问题,是把「数据」和「对数据的合法操作」绑进同一个边界,再用访问控制守住它。构造函数负责把一块裸内存变成合法状态,析构函数负责把资源还回去,虚函数负责让同族对象对同一条消息给出不同反应。这些能力在 C 里靠命名约定和函数指针也能凑出来,但编译器不会替你检查。
这套东西适合两类人:从 C 或 Python、Java 转过来、语法会写但设计没底的人;以及已经写过几千行 C++、希望需求变化时别再全盘重写的人。后面几章按封装、多态、拷贝控制、工程编译、验证技巧的顺序推进,每一层都给出能直接编译运行的代码。
2. 封装:用 C++ 类把不变量关进边界
2.1 从 struct 加自由函数改造成类
先看最朴素的写法:一个struct Account加上deposit、withdraw两个自由函数,余额字段谁都能改。看起来方便,代价是「余额不允许为负」这条规则只存在于文档和人的记忆里。改成类之后,规则从注释变成了编译器和运行时的双重约束。
#include <string> #include <stdexcept> #include <utility> class Account { public: // 初始化列表按成员声明顺序执行,比在函数体里赋值少一次默认构造 Account(std::string owner, double balance) : owner_(std::move(owner)), balance_(balance) { if (balance_ < 0) throw std::invalid_argument("balance < 0"); } void deposit(double amount) { if (amount <= 0) throw std::invalid_argument("amount <= 0"); balance_ += amount; // 修改状态只留这一个入口 } bool withdraw(double amount) { if (amount <= 0 || amount > balance_) return false; balance_ -= amount; return true; } double balance() const noexcept { return balance_; } // 只读接口 const std::string& owner() const noexcept { return owner_; } private: std::string owner_; double balance_; };balance_和owner_是私有的,外部只能走三个公有函数,负余额这种非法状态在构造函数里就被挡住了。const noexcept后缀表示函数不修改对象、不抛异常,调用方可以放心地对const Account&使用。std::move(owner)把传入字符串的资源直接转移进成员,避免一次多余的堆拷贝,这是 C++11 之后写构造函数的常规姿势。
2.2 构造与析构顺序:一张表把生命周期钉死
对象什么时候被构造、按什么顺序、什么时候被销毁,是封装里最容易记混的部分。下表按常见场景整理,建议直接当速查用。
| 场景 | 调用顺序 |
|---|---|
| 派生类对象构造 | 基类成员 → 基类构造体 → 派生类成员 → 派生类构造体 |
| 派生类对象析构 | 派生类析构体 → 派生类成员 → 基类析构体 → 基类成员 |
| 类内成员初始化 | 严格按成员在类里的声明顺序,与初始化列表书写顺序无关 |
| 同作用域多个局部对象 | 构造按出现顺序,析构按相反顺序 |
| 静态/全局对象 | main 之前构造,main 之后析构,跨编译单元顺序不保证 |
第三行是高频错误来源:初始化列表里写成b_(1), a_(b_),实际执行顺序仍然由声明顺序决定,如果a_声明在前,它读到的是未初始化的b_。开启-Wall -Wextra后,GCC 和 Clang 都会对「初始化顺序与声明顺序不一致」给出警告,这条警告值得当错误处理。
2.3 const、static、mutable 在封装里的分工
const修饰成员函数,是接口对调用方的承诺;static成员属于类而不是对象,适合做计数器、单例入口、共享配置;mutable允许在const成员函数里修改某个字段,只应该用在缓存这类不影响逻辑状态的场景。
class Shape { public: Shape() { ++count_; } // 每个对象构造时计数 virtual ~Shape() { --count_; } static int count() noexcept { return count_; } double cachedArea() const { // const 函数里修改 mutable 成员 if (!cacheValid_) { area_ = computeArea(); // 编译器允许,因为 area_ 是 mutable cacheValid_ = true; } return area_; } protected: virtual double computeArea() const = 0; private: static int count_; mutable double area_ = 0.0; mutable bool cacheValid_ = false; }; int Shape::count_ = 0; // 静态成员必须在类外定义count_的类外定义不能漏,漏掉在 C++17 之前是链接错误,之后虽然可以用inline static省掉这一行,但混用两种风格的项目里仍会撞见。cachedArea是典型的「逻辑只读、物理可写」:外部看到的是查询,内部做了缓存写入,这正是mutable存在的理由。
2.4 封装的三个高频踩坑
第一,getter 返回非常量引用。写成std::string& owner() { return owner_; },等于把私有成员的门直接拆下来,外部一句a.owner() = ""就绕过了所有校验。返回const&或者按值返回,取决于对象大小和是否需要拷贝。
第二,在构造函数里调用虚函数。构造基类部分时,派生类部分还没建好,虚调用只会落到基类版本,不会报错但行为不符合预期。需要这种初始化时,改用两阶段构造:构造函数只做赋值,另加一个init()在对象完整后调用。
第三,头文件里写using namespace std;。它会污染每一个包含该头文件的编译单元,一旦某个源文件定义了同名符号,报错信息会指向毫无关系的行号。头文件里一律写全限定名,源文件里再用 using 才安全。
提示:类的接口设计优先考虑「能不能让非法状态无法表示」,而不是「加一个检查函数」,后者总会有人忘记调用。
3. 继承与虚函数:C++ 多态在运行期如何定位函数
3.1 虚函数表与动态绑定到底发生了什么
带虚函数的对象,内存布局里会多一个隐藏指针,指向该类的虚函数表;表里按固定顺序存放各个虚函数的地址。执行shape->area()时,编译器不直接生成「调用 Shape::area」的指令,而是生成三步:取对象的虚表指针、按固定偏移取出函数地址、间接调用。派生类重写area后,它自己的虚表里那一格指向新函数,所以同一个shape->area()表达式在不同对象上会走到不同代码。
这个机制解释了几件常被问到的现象。虚调用比普通调用多一次间接寻址,在绝大多数业务代码里可以忽略,但在极端密集的数值循环里确实有成本。虚函数无法在编译期内联,除非编译器能确定具体类型。构造函数和析构函数里调用虚函数会退化成静态绑定,因为此时对象的动态类型还没建立或已经被拆掉。
3.2 纯虚接口加 override:一份能跑的面积计算
#include <memory> #include <vector> #include <cmath> #include <cstdio> class Shape { public: virtual ~Shape() = default; // 基类必须有虚析构 virtual double area() const = 0; // 纯虚函数,接口约束 virtual const char* name() const = 0; }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.14159265358979 * r_ * r_; } const char* name() const override { return "circle"; } private: double r_; }; class Rect : public Shape { public: Rect(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_; } const char* name() const override { return "rect"; } private: double w_, h_; }; int main() { std::vector<std::unique_ptr<Shape>> shapes; shapes.push_back(std::make_unique<Circle>(1.0)); shapes.push_back(std::make_unique<Rect>(2.0, 3.0)); double total = 0.0; for (const auto& s : shapes) { // 多态在这里发生 std::printf("%s -> %.2f\n", s->name(), s->area()); total += s->area(); } std::printf("total = %.2f\n", total); }area()和name()声明为纯虚,Shape就成了抽象类,无法直接实例化,这保证了任何一个Shape*背后都真的有一份实现。派生类里的override关键字不是装饰:如果签名写错,比如漏了const,编译器会直接报错,而不是悄悄生成一个新函数把基类版本藏起来。
容器用std::unique_ptr<Shape>而不是std::vector<Shape>,原因是后者的push_back会发生对象切片,只复制基类部分,虚表指针变成基类的,调用area()得到的全是基类行为。这一点在下一节展开。
3.3 对象切片与虚析构:两条不能忘的规则
对象切片的表现很隐蔽:代码能编译、能运行、结果不对。
void printArea(Shape s) { // 按值传参,发生切片 std::printf("%.2f\n", s.area()); // 永远走基类版本 }把形参改成const Shape&或Shape*就能解决。同理,用基类对象接收派生类对象(Shape s = Circle(1.0);)也会切片,这是把「多态」误当成「赋值」的典型。
虚析构的规则是:只要类可能被基类指针删除,基类析构就必须是虚的。写成virtual ~Shape() = default;一行足够。如果漏掉,delete basePtr时只会调用基类析构,派生类里new出来的资源全部泄漏,而且 AddressSanitizer 报的栈信息会指向delete那一行,很容易误导排查方向。
3.4 继承方式与访问权限对照
| 继承方式 | 基类 public 成员在派生类中 | 基类 protected 成员 | 基类 private 成员 | 适用场景 |
|---|---|---|---|---|
| public 继承 | public | protected | 不可访问 | 「是一个」关系,接口复用 |
| protected 继承 | protected | protected | 不可访问 | 只给子类复用的实现细节 |
| private 继承 | private | private | 不可访问 | 「用…实现」关系,替代组合 |
绝大多数场景应该用 public 继承表达接口复用,用 private 继承或直接组合表达实现复用。判断标准很简单:如果一个Derived对象在语义上不能替换Base(比如Stack继承Vector,栈不该暴露按下标随机访问),那就不要用 public 继承,改成把Vector当作私有成员。这条经验能避免大量「继承层次越写越深、改基类就炸全库」的问题。
4. 拷贝控制与 RAII:对象生命周期里的拷贝、移动与资源释放
4.1 编译器默认生成的拷贝行为为什么经常是错的
只要不写,编译器就会为类自动生成拷贝构造函数、拷贝赋值运算符、析构函数,C++11 之后还加上移动版本。这套默认行为做的是逐成员拷贝,对int、double、std::string都正确,因为这些成员自己管好了资源。但一旦类里出现裸指针、文件句柄、socket 描述符,逐成员拷贝就变成「两个对象指向同一块资源」,析构时释放两次,典型的 double free。
class Buffer { public: explicit Buffer(std::size_t n) : data_(new char[n]), size_(n) {} ~Buffer() { delete[] data_; } // 拷贝构造和拷贝赋值都用默认的 -> 两个 Buffer 指向同一 data_ private: char* data_; std::size_t size_; };修复方向有两条:要么禁用拷贝(Buffer(const Buffer&) = delete;),要么手写深拷贝。选择依据是这个类型在语义上是否应该可复制。像互斥量、线程、文件句柄这类独占资源,禁用拷贝才是正确设计。
4.2 三法则与五法则:手写一个安全的深拷贝
三法则说:如果需要自定义析构、拷贝构造、拷贝赋值中的任意一个,通常三个都要写。C++11 之后扩展为五法则,再把移动构造和移动赋值算进来。
#include <cstring> #include <utility> class Buffer { public: explicit Buffer(std::size_t n) : data_(new char[n]()), size_(n) {} ~Buffer() { delete[] data_; } Buffer(const Buffer& other) // 深拷贝 : data_(new char[other.size_]), size_(other.size_) { std::memcpy(data_, other.data_, size_); } Buffer& operator=(const Buffer& other) { if (this == &other) return *this; // 自赋值保护 char* tmp = new char[other.size_]; // 先分配新内存,异常安全 std::memcpy(tmp, other.data_, other.size_); delete[] data_; data_ = tmp; size_ = other.size_; return *this; } Buffer(Buffer&& other) noexcept // 移动构造 : data_(other.data_), size_(other.size_) { other.data_ = nullptr; other.size_ = 0; } Buffer& operator=(Buffer&& other) noexcept { if (this == &other) return *this; delete[] data_; data_ = other.data_; size_ = other.size_; other.data_ = nullptr; other.size_ = 0; return *this; } private: char* data_; std::size_t size_; };几个关键点。拷贝赋值里先写自赋值保护,b = b不加判断会先delete[]掉自己的内存再去读已经释放的数据。内存分配放在删除旧内存之前,new抛异常时对象仍处于合法状态,这叫异常安全。移动构造标记noexcept很重要,否则std::vector扩容时不敢用移动,退化成逐个深拷贝,性能差一个数量级。
4.3 std::move 与右值引用:把拷贝换成资源转移
std::move本身不移动任何东西,它只是一个到右值引用的强制类型转换,作用是告诉编译器「这个对象可以被掏空」。
#include <vector> #include <string> std::vector<std::string> build(std::string prefix, int n) { std::vector<std::string> out; out.reserve(n); // 预分配,避免扩容时的搬移 for (int i = 0; i < n; ++i) out.push_back(prefix + std::to_string(i)); return out; // NRVO / 移动返回 } int main() { std::string base = "item-"; auto v = build(std::move(base), 3); // base 之后不应再被使用 }std::move(base)之后继续读base是未定义行为,编译器不会拦,代码评审时要盯住。另一个常见误区是对const对象用std::move:const右值只能匹配拷贝构造,移动语义静默失效,这是性能问题里最难查的一类。
4.4 RAII 与智能指针:让析构函数负责收尾
RAII 的做法是把资源获取放进构造函数、释放放进析构函数,靠栈对象的确定性析构保证不泄漏。标准库的智能指针正是这条思路的封装。
| 类型 | 所有权语义 | 适用场景 | 注意事项 |
|---|---|---|---|
std::unique_ptr | 独占,不可拷贝,可移动 | 默认首选,工厂函数返回值 | 用std::make_unique构造 |
std::shared_ptr | 共享,引用计数归零时释放 | 生命周期确实需要共享 | 循环引用会泄漏,配合weak_ptr |
std::weak_ptr | 不增加计数 | 观察者、缓存键、破环 | 使用前必须lock()判空 |
#include <memory> #include <cstdio> class Connection { public: Connection() { std::puts("connect"); } ~Connection() { std::puts("disconnect"); } // 无论正常返回还是抛异常都会执行 void send(const char*) {} }; void job() { std::unique_ptr<Connection> c = std::make_unique<Connection>(); c->send("ping"); // 中途 return 或抛异常,析构照样执行 }把new/delete换成make_unique之后,代码里不再出现裸的delete,同时获得了异常安全。剩下的裸指针只应该用来表达「不持有、只观察」的语义,并在命名上体现出来,比如Connection* observer。
5. 多文件工程的编译与链接排错
5.1 用 CMake 搭一个可复现的目录骨架
面向对象的代码天然要拆成头文件和实现文件,手工敲g++命令很快就会失控。常见的做法是用 CMake 管理,目录按下面这个极简结构摆:
oop_demo/ ├── CMakeLists.txt ├── include/ │ ├── account.hpp │ └── shape.hpp └── src/ ├── account.cpp ├── shape.cpp └── main.cppcmake_minimum_required(VERSION 3.16) project(oop_demo CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(oop_demo src/main.cpp src/account.cpp src/shape.cpp ) target_include_directories(oop_demo PRIVATE include) # GCC/Clang 用 -Wall -Wextra;MSVC 换成 /W4 /permissive- target_compile_options(oop_demo PRIVATE -Wall -Wextra)target_include_directories用 PRIVATE 限定头文件搜索路径只对本目标生效,避免全局include_directories污染。构建命令固定为三步:cmake -S . -B build生成构建系统、cmake --build build编译链接、./build/oop_demo运行。这套流程在 Linux、macOS 和 Windows 上一致,差别只在生成器。
5.2 VS Code 里把编译、调试串成一条链
VS Code 本身不编译 C++,它调用外部工具链。Linux 上装 g++ 和 gdb,Windows 上装 MSVC 或 MinGW,然后靠任务和调试配置接上。
{ "version": "2.0.0", "tasks": [ { "label": "cmake build", "type": "shell", "command": "cmake --build build", "group": { "kind": "build", "isDefault": true }, "problemMatcher": ["$gcc"] } ] }problemMatcher决定编译错误能否被解析进「问题」面板,选$gcc对 GCC/Clang 有效,MSVC 用$msCompile。调试侧用 cppdbg 或 cppvsdbg,program指向build/oop_demo,preLaunchTask填cmake build,这样按 F5 会自动先编译再调试,断点能落在头文件里的 inline 函数上。
注意:Windows 上如果不通过开发者命令行启动 VS Code,MSVC 的
cl.exe不在 PATH 里,CMake 会报找不到编译器。解决办法是从对应工具链的命令行里启动,或者在配置里显式指定编译器路径。
5.3 链接错误的三种典型形态
| 报错关键字 | 含义 | 常见原因 | 处理方式 |
|---|---|---|---|
undefined reference to | 声明了但找不到定义 | 忘记把实现文件加进add_executable | 检查源文件列表 |
multiple definition of | 同一符号被定义多次 | 在头文件里定义了非 inline 函数或全局变量 | 加inline或移进 .cpp |
vtable for X未定义 | 虚函数表缺失 | 某个虚函数只声明未实现 | 补齐实现或改纯虚 |
第三类报错信息最不直观,它由链接器发出,指向构造函数,真正的原因是类里有一个非纯虚函数没有实现体。定位技巧是把该类的全部虚函数列出来逐个核对,尤其是那些加了= 0又忘了删除声明体的地方。
5.4 Windows 上 MSVC 工具链与运行库的坑
在 Windows 上开发 C++ 常遇到两类环境问题。一类是找不到编译器:Python 安装带 C 扩展的包时报Microsoft Visual C++ 14.0 or greater is required,本质是缺少 MSVC 构建工具,装 Visual Studio 生成工具时勾选「使用 C++ 的桌面开发」即可。另一类是运行时缺 DLL:程序在自己机器上跑得好,拷到别人的机器上报缺少运行库,因为默认动态链接了 MSVC 运行库,需要目标机安装对应的 Microsoft Visual C++ Redistributable,或者改用/MT静态链接。
// 用预处理器区分静态断言,跨平台工程里很实用 #ifdef _MSC_VER #pragma message("building with MSVC") #else #pragma message("building with GCC/Clang") #endif_MSC_VER这类宏可以在编译期区分工具链,用来隔离平台相关的代码分支。但更推荐的做法是把平台差异收敛到少数几个源文件里,而不是在业务类里到处写#ifdef,否则面向对象的继承层次会被条件编译切得支离破碎。
6. 验证与进阶:让多态和生命周期问题提前暴露
6.1 用 typeid 与 dynamic_cast 确认多态走的是预期分支
多态写错时不会报错,只会悄悄走基类逻辑。写单元测试或临时排查时,可以用typeid打印实际动态类型名,用dynamic_cast尝试向下转型,转型失败会返回空指针,这正是检查点。
#include <typeinfo> #include <cstdio> #include <memory> void inspect(const std::shared_ptr<Shape>& s) { std::printf("dynamic type = %s\n", typeid(*s).name()); if (auto c = dynamic_cast<const Circle*>(s.get())) { std::printf("it is a circle\n"); } else if (auto r = dynamic_cast<const Rect*>(s.get())) { std::printf("it is a rect\n"); } else { std::printf("unknown shape\n"); } }typeid(*s)解引用的是对象而不是指针,写成typeid(s)只会得到shared_ptr<Shape>的类型名。dynamic_cast要求基类至少有一个虚函数,也就是所谓多态类型;对非多态类型使用会编译失败,这是很多人第一次踩的坑。GCC 输出的name()是经过修饰的名字,加c++filt或abi::__cxa_demangle才能还原成可读形式。
6.2 用编译选项和消毒器把生命周期问题提前暴露
面向对象代码最贵的一类 bug 是对象已经被销毁、代码还在用。手写测试很难覆盖,交给工具更划算。
# GCC / Clang:严格的警告加上地址消毒器 g++ -std=c++17 -Wall -Wextra -Wpedantic -g \ -fsanitize=address,undefined \ src/*.cpp -o demo_asan # 运行后一旦出现 use-after-free 或泄漏,会直接打印调用栈 ./demo_asan-fsanitize=address会在对象周围插入红区并在释放时投毒,一旦越界访问或重复释放就立即报错并给出完整调用栈;undefined覆盖整数溢出、空指针解引用、有符号移位等未定义行为。这套组合在 CI 里跑一遍的成本很低,收益是线上少一次难以复现的崩溃。
6.3 依赖倒置的一条落地规则
设计层面能立刻用上的一条规则:高层模块只依赖抽象基类或模板参数,不依赖具体派生类。具体表现是构造函数接收接口引用,而不是自己new一个具体实现。
class Logger { // 抽象接口 public: virtual ~Logger() = default; virtual void write(const std::string&) = 0; }; class Service { public: explicit Service(Logger& logger) : logger_(logger) {} // 注入接口 void run() { logger_.write("start"); } private: Logger& logger_; // 不持有所有权,只引用 };Service不包含任何FileLogger或ConsoleLogger的头文件,换实现不需要重新编译Service.cpp,单元测试里塞一个记录调用的假实现即可。Logger&而不是Logger*,配合文档约定说明生命周期由外部管理;如果确实需要共享所有权,改成std::shared_ptr<Logger>,但要在注释里写清楚,否则容易出现两个模块互相持有、谁都不释放的循环引用。检验这条规则是否落实的办法很直接:把某个具体实现的头文件删掉,工程如果还能编译通过,依赖方向就是对的。
本文还有配套的精品资源,点击获取