Lecture 8: 面向对象编程:类、对象与封装(OOP: Classes, Objects & Encapsulation)(对应课程真实讲座 L15)
Lecture 8: 面向对象编程:类、对象与封装(OOP: Classes, Objects & Encapsulation)(对应课程真实讲座 L15)
概述
本讲是课程的重要转折:此前我们一直以”客户端”视角使用别人写好的 ADT(Vector、Stack 等),从这一讲开始第一次亲手”造”一个类,从使用者变成建造者。核心问题是:如何用类把”数据 + 操作数据的函数”打包成一个整体,并通过接口(.h)与实现(.cpp)分离、私有成员与 getter/setter 等手段划清抽象边界,让对象从生到死都处于合法状态。(官方对应:2026 夏季学期 L15,Monday, July 20 — Object-Oriented Programming,课上用一只只可爱的小袋鼠 Quokka 从零搭起一个类,本讲也是作业 5 Tone Matrix 的知识铺垫。)
核心概念与算法原理
1. 范式转变:从”操作数据”到”对象收发消息”
问题定义:在 C 这类过程式语言里,数据结构与操作它的函数是分离的,写出来往往是 goToFloor(elevator, 5) 这种”把对象塞给外部函数”的样子。OOP 要回答的问题是:能不能让”能力”长在”数据”身上,让电梯自己会”去 5 楼”?
直观解释:坐电梯时你不会想”把电梯交给一个外部函数处理”,而是直接按电梯里的按钮——按钮和电梯是一体的。OOP 把这个直觉搬进代码:我们对对象”发消息”(调用它的成员函数),对象自己决定如何改变自己的内部状态。官方称这是一次根本性的范式转变(paradigm shift)。
2. 类 = 蓝图/类型,对象 = 实例
它是什么:类是”图纸”,也等价于声明一种新的数据类型;对象是按图纸造出来的具体”房子”(变量)。官方特别指出:这一季用过的 Vector、Stack、Set、Map 全都是类——Vector<int> v; 中 Vector 是类型,v 就是 Vector 类的一个对象(实例,instance)。同一张蓝图可以造出无数座结构相同、内部各异的房子。
Date 类(蓝图 / 模板) ← 只画一次,定义一种"新类型"
┌───────────────────────────────────┐
│ 成员变量(状态 state): _year _month _day ... │
│ 成员函数(行为 behavior): 构造函数、printInfo() │
└───────────────────────────────────┘
│ 实例化 instantiation(按图施工,可造无数个)
┌────┼──────────┬─────────────┐
▼ ▼ ▼ ▼
┌─────┐┌─────┐ ┌─────┐ ┌─────┐
│ d1 ││ d2 │ │ d3 │ │ … │ ← 每个对象各有自己的一份成员变量
└─────┘└─────┘ └─────┘ └─────┘ (给 d1 改值不影响 d2,如同在
一间房子里放家具不会影响别的房子)
为什么要自己造类:官方给的理由是”扩充抽象词汇表”。把常用概念(如”日期”)封装成类型后,调用方不再需要盯着 v.remove(v.size()-1) 这类细节去猜意图,直接说”pop 一个”即可——抽象让代码更好读、更好交流、更难写错。
3. 接口(.h)与实现(.cpp)分离
问题定义:一个类有两类信息——外面的人需要知道”能调用哪些函数”(what);而”具体怎么实现”(how)不必、也不应全部暴露。
它是什么:接口文件(.h,header)里放类声明:成员变量、成员函数原型,客户端 #include 它就够用了;实现文件(.cpp)里放每个成员函数的定义。官方把接口比作”从外面看到的类长什么样”,把实现比作”这些行为背后真正的代码”。
.h(接口:what,对外合同) .cpp(实现:how,幕后车间)
┌────────────────────────────┐ ┌────────────────────────────────┐
│ #ifndef DATE_H │ │ #include "date.h" │
│ class Date { │ │ Date::Date(...) { ... } │
│ public: │ │ void Date::printInfo() {...} │
│ Date(...); │ │ ... 每个函数定义都写 Date:: │
│ void printInfo() const; │ └────────────────────────────────┘
│ private: ... }; │ 编译时合体:
│ #endif │ g++ -std=c++17 date.cpp main.cpp
└────────────────────────────┘
▲ 客户端只看见这一半
头文件顶部的三行守卫 #ifndef DATE_H / #define DATE_H / … / #endif 叫 include guard,防止同一头文件被间接包含多次造成重复声明错误(官方在 L15 指出 Qt Creator 新建类时会自动生成)。
4. 构造函数与析构函数:对象的一生
它是什么:与类同名的函数叫构造函数,任何时刻创建对象都会自动调用它,最适合做初始化;参数不同可以写多个构造函数,这叫构造函数重载。名字为 ~类名 的函数叫析构函数,对象离开作用域死亡时自动调用。
对象的一生:局部对象在声明处出生(栈帧里分配空间 + 调构造函数),在函数返回或到达代码块末尾时死亡(自动调析构)。官方在 L15 用”会打印 R.I.P. 的析构函数”做了证明:createQuokka() 一返回,销毁信息立刻出现——局部变量确实随函数退出而消亡。这个”死亡”事实是下一讲(动态内存)的出发点。
5. 封装:private 成员与 getter/setter
问题定义:如果客户端能直接改内部数据,就可能把对象弄进”破损状态”。官方举例:假如 Vector 的 size 是公开变量,客户端写 v.size = 1;,底层明明还有 3 个元素却对外声称 1 个,遍历和删除全部错乱。
它是什么:把成员变量声明为 private(私有),只留受控的公共出入口:getter 只读返回某个私有成员的值;setter 是修改私有成员的唯一通道,可在里面加合法性校验。私有化不等于”锁死”,而是”所有改变都必须经过把门人”。官方归纳的理由:防止对象进入破损状态、能对写入值设限制、能保证对象一生状态可预期。
6. 匿名临时对象
有时我们不想给对象起名字(造出来立刻塞进容器或传给函数),可以直接调用构造函数,例如 Quokka("Muffinface", 5, "muffinface.jpg")——这种没有变量名的对象叫匿名临时对象,它只活在当前这一条语句里,语句结束随即析构(官方在 L15 演示了把 7 只匿名 Quokka 直接 add 进 Vector 的写法)。
代码示例与实现详解
示例 1:接口与实现分离的 Date 类(date.h + date.cpp + main.cpp 三文件项目)
这是官方推崇的工程组织方式:三个文件一起编译(g++ -std=c++17 date.cpp main.cpp -o date_demo)。注意头文件里全程使用 std:: 前缀而不写 using namespace std;——头文件会被任意客户端包含,把整个 std 命名空间”泄漏”给别人是很糟糕的做法。
// ================= date.h:接口(what)=================
#ifndef DATE_H
#define DATE_H
#include <string>
class Date {
public:
Date(); // 默认构造函数
Date(int year, int month, int day,
const std::string& label); // 重载构造函数(4 参数版)
~Date(); // 析构函数:死亡时自动调用
int year() const; // getter;const 承诺"不改状态"
int month() const;
int day() const;
std::string label() const;
void setYear(int year); // setter:带范围校验
void setMonth(int month);
void setDay(int day);
void printInfo() const;
private:
int _year; // 私有成员:客户端无法直接读写
int _month;
int _day;
std::string _label;
};
#endif // DATE_H
// ================= date.cpp:实现(how)=================
#include <iostream>
#include "date.h"
Date::Date() { // 每个成员函数定义都要写 Date::
_year = 2026; _month = 1; _day = 1; _label = "(未命名)";
}
Date::Date(int year, int month, int day, const std::string& label) {
_year = year; _month = month; _day = day; _label = label;
}
Date::~Date() {
std::cout << "【销毁】" << _label << " 离开这个世界" << std::endl;
}
int Date::year() const { return _year; }
int Date::month() const { return _month; }
int Date::day() const { return _day; }
std::string Date::label() const { return _label; }
void Date::setYear(int year) {
if (year < 1900 || year > 2100) {
std::cout << "年份 " << year << " 非法,已忽略" << std::endl;
return; // 拒绝非法值,对象保持原状态
}
_year = year;
}
void Date::setMonth(int month) {
if (month < 1 || month > 12) {
std::cout << "月份 " << month << " 非法,已忽略" << std::endl;
return;
}
_month = month;
}
void Date::setDay(int day) {
if (day < 1 || day > 31) {
std::cout << "日期 " << day << " 非法,已忽略" << std::endl;
return;
}
_day = day;
}
void Date::printInfo() const {
std::cout << _label << ": " << _year << "-" << _month << "-" << _day << std::endl;
}
// ================= main.cpp:客户端视角 =================
#include <iostream>
#include "date.h" // 只用 #include 接口即可
using namespace std;
int main() {
Date d1; // 默认构造
Date d2(2026, 7, 23, "考试日"); // 重载构造
d2.setMonth(12); // setter 正常更新
d1.printInfo();
d2.printInfo();
return 0; // d2、d1 逆序析构(先各打印一行销毁信息)
}
【代码做什么】 date.h 只含类声明:public 区列出接口原型,private 区列出 4 个成员变量;date.cpp 用 Date:: 前缀逐个给出定义。默认构造把日期初始化为 2026-01-01(避免未初始化垃圾值),4 参数构造按参数填充;析构打印”告别语”;三个 setter 先校验范围,非法输入直接拒绝。
【实现机制解说】
- 成员函数定义必须写成
返回类型 类名::函数名(...),Date::告诉编译器”这个函数属于 Date 类”;漏掉它会被当成与类无关的全局函数而报错。 printInfo() const、getter 的 const 放在函数名后,等于向编译器承诺”本函数不修改任何成员变量”,一旦违反编译直接失败——这是防止未来误改的好习惯。- 构造与析构成对触发:执行到
Date d2(...)这行时,先为 d2 在栈上分配空间,再调用 4 参数构造函数;main 结束时 d2、d1 按声明逆序(后声明先销毁)自动析构并打印。生命周期由作用域决定,程序员不用手动”清场”。
示例 2:单文件等价可编译版(.h + .cpp + main 合并)
把示例 1 的三个文件合并成一个文件就是下面的样子——内容完全等价,适合初学者先把注意力放在语法上,之后再练习拆分。main() 额外演示 setter 校验、局部对象生命周期、匿名临时对象与按值拷贝现象。
#include <iostream>
#include <string>
using namespace std;
class Date {
public:
Date() { _year = 2026; _month = 1; _day = 1; _label = "(未命名)"; }
Date(int y, int m, int d, const string& label) {
_year = y; _month = m; _day = d; _label = label;
}
~Date() { cout << "【销毁】" << _label << " 离开这个世界" << endl; }
int year() const { return _year; }
int month() const { return _month; }
int day() const { return _day; }
string label() const { return _label; }
void setYear(int y) {
if (y < 1900 || y > 2100) { cout << "年份 " << y << " 非法,已忽略" << endl; return; }
_year = y;
}
void setMonth(int m) {
if (m < 1 || m > 12) { cout << "月份 " << m << " 非法,已忽略" << endl; return; }
_month = m;
}
void setDay(int d) {
if (d < 1 || d > 31) { cout << "日期 " << d << " 非法,已忽略" << endl; return; }
_day = d;
}
void printInfo() const {
cout << _label << ": " << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year, _month, _day;
string _label;
};
// 生命周期演示:局部对象在函数返回那一刻死亡
void demoLifecycle() {
cout << "--- 进入 demoLifecycle() ---" << endl;
Date local(2026, 7, 20, "本地聚会");
local.printInfo();
cout << "--- 即将 return,local 将在此死亡 ---" << endl;
} // ← local 在这里被析构(打印销毁信息)
// 参数用 const 引用:不产生拷贝
void announce(const Date& d) {
cout << "公告:" << d.label() << " 的活动在 "
<< d.year() << " 年 " << d.month() << " 月 " << d.day() << " 日举行" << endl;
}
int main() {
cout << "=== main 开始 ===" << endl;
Date d1; // 默认构造
Date d2(2026, 7, 23, "考试日"); // 重载构造
d1.setYear(9999); // 非法!setter 拒绝并保持原状态
d1.setDay(30); // 合法,写入成功
d1.printInfo();
d2.printInfo();
demoLifecycle(); // 观察 local 随函数返回而销毁
announce(Date(2026, 8, 3, "临时讲座")); // 匿名临时对象:本语句结束即析构
Date d3 = d2; // 按值"拷贝构造",d3 是 d2 的独立副本
cout << "d3 标签 = " << d3.label() << endl;
cout << "=== main 即将结束(d3、d2、d1 将逆序销毁)===" << endl;
return 0;
}
【代码做什么】 main() 依次验证:默认构造与重载构造都能用;非法年份 9999 被 setter 拦下(d1 保持原值,随后 setDay(30) 正常生效);demoLifecycle() 内的局部对象在 return 那一刻析构,早于 main 继续执行;announce(Date(...)) 用匿名临时对象当参数,临时对象在该语句结束时析构;Date d3 = d2; 触发默认拷贝构造,产生一个内容相同但各自独立的副本(因此结束时”考试日”会打印两次销毁信息:一次是 d3、一次是 d2)。
【实现机制解说】
- 构造函数重载的调度规则与函数重载一致:编译器按实参个数与类型挑选匹配版本,
Date()与Date(int,int,int,const string&)互不干扰。 - 输出顺序是观察对象生命周期的利器:先看到 printInfo 的结果;接着 demoLifecycle 内部”构造 → 打印 → 析构”一气呵成;匿名对象在 announce 语句结束后立刻析构;最后 d3、d2、d1 按”后进先出”的栈式顺序销毁。
- 按值传参会拷贝:若把
announce的参数类型改成Date d,每次调用都会多一次拷贝构造和一次析构。当对象内部持有堆内存时,默认拷贝只是”浅拷贝”,会让两个对象共享同一块内存、析构时双重释放——这正是指针与动态内存那一讲的核心动机,届时会引入”拷贝规则”(Rule of Three)来根治。 - 封装的收益在此可见:客户端永远无法把 Date 弄到”月份 = 13”的破损状态,因为唯一入口 setter 把守着合法性。
复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 默认 / 重载构造 | O(1) | 仅对少量成员赋值(含 string 时取决于串长) |
| getter / setter | O(1) | 读一个成员 / 一次范围比较后写入 |
| printInfo 等输出成员函数 | O(1) | 输出固定数量的字段 |
| 析构 | O(1) | 成员逐个消亡(普通数据类型) |
| 按值拷贝(默认拷贝构造) | O(成员个数) | 逐成员复制;拷贝含 string 的成员要复制其内容 |
要点:类操作的开销几乎都来自”拷贝”;封装本身不增加任何复杂度,它买来的是正确性与可维护性。
关键要点
- 类是蓝图/新类型,对象是按蓝图造出的实例:每个对象各有自己的一份成员变量,互不干扰。
- 接口(.h)回答 what、实现(.cpp)回答 how;头文件不放
using namespace std;,并记得 include guard。 - 构造函数与类同名、创建对象时自动执行、可重载;析构函数形如
~类名,对象离开作用域自动调用——生命周期由作用域决定。 - 成员变量一律 private,通过 getter/setter 受控访问;setter 内做校验,保证对象永不进入破损状态。
- 匿名临时对象”造完即用、用完全毁”,适合一次性塞进容器或传给函数。
常见陷阱与注意事项
- 类定义结尾忘写分号:
};必不可少,漏掉会引发一连串莫名其妙的编译错误。 - .cpp 忘
#include自己的 .h:实现文件必须先包含头文件才能引用类声明。 - 定义成员函数忘写
Date:::会被当成全局函数,编译器立即报”未定义”或签名不匹配。 - 在 .h 里写
using namespace std;:会污染所有包含该头文件的代码;头文件里请写全称std::string。 - 构造函数漏初始化某些成员:成员会留下垃圾值;默认构造应给出安全初值(如 2026-01-01)。
- 把 setter 校验写成”悄悄纠错”而非”拒绝”:比如月份 13 想当然地取模;正确做法是拒绝非法值并保持原状态(或报错),让调用方知情。
- 析构函数名漏
~:Date()与~Date()长得像但完全不同,前者是构造、后者是析构。 - 以为 getter 必须叫
getXxx:官方约定只读属性可省略 get(如size()、year()),需要写回时才配套setXxx。
思考题(带答案)
问题 1:void f() { Date d(2026, 7, 1, "内部"); } 中,d 的构造函数与析构函数分别在什么时刻被调用?若 f 被调用 100 次,构造与析构各执行几次? 答案:构造在声明 d 的那一行执行(f 的栈帧里创建对象);析构在 f 返回、d 离开作用域那一刻自动执行。调用 100 次则构造、析构各 100 次,永远成对——这正是”对象生命周期由作用域管理”的含义,也是本季第 16–17 讲讨论动态内存时的前提。
问题 2:为什么把 _year 设为 private 后,客户端反而”更安全”?请仿照官方”直接改 size”的例子说明。 答案:private 意味着内部数据只有类自己的成员函数能碰。若 size 是公开变量,客户端写 v.size = 1; 会让对象陷入”实际有 3 个元素、对外声称 1 个”的自相矛盾,遍历与删除全部出错;而 setter 是唯一入口,可以加校验(如年份必须在 1900~2100),从而保证对象从构造到析构始终合法、可预期。
问题 3:vector<Date> v; v.push_back(Date(2026, 8, 1, "会议")); 中那个匿名 Date 何时析构?为什么运行时常会看到不止一次析构打印? 答案:匿名临时对象在本条语句结束时析构(打印一次);但 push_back 会把它按值拷贝进容器,之后容器扩容重排时还会对已有元素做拷贝构造/析构。官方在 L15 演示 Vector 装 Quokka 时就观察到每只 Quokka 被析构多次——那些”额外”的析构都源于按值拷贝与扩容搬移。等到掌握拷贝规则(Rule of Three)后,就能精确预测每一次构造/析构的来龙去脉。
