Lecture 7 (Week 4 - Tuesday): 类 (Classes)
Lecture 7 (Week 4 - Tuesday): 类 (Classes)
概述
本讲是 C++ 面向对象编程的起点:C 语言没有对象,无法把”数据”与”操作这些数据的函数”封装在一起,而 C++(最初就叫 “C with Classes”)通过 类(class) 这一用户自定义类型实现了封装、继承与多态。学习目标是掌握 struct 与 class 的区别、public/private 访问控制、头文件(.h)与源文件(.cpp)的分离、构造器(默认/带参/成员初始化列表/重载)、this 指针、getter/setter、析构函数、类型别名,以及继承的三种方式(public/protected/private)、虚函数与 vtable、菱形继承与虚继承。这些是后续讲”类模板”“const 正确性”以及作业 A3 的地基。
核心特性与语法详解
1. struct vs class:默认访问级别
- 定义与目的:
struct和class几乎完全相同,唯一区别是默认访问级别:struct的成员默认public,class的成员默认private。结构体适合”纯数据聚合”(如std::pair),类适合”封装 + 行为”。 - 核心语法:
struct Point { int x; int y; }; // 成员默认 public class Point { int x; int y; }; // 成员默认 private! - 设计意图与最佳实践:类的设计哲学是信息隐藏:数据成员放在
private区,只通过public接口(成员函数)间接访问,这样可以在 setter 里做合法性校验(例如拒绝负数 id),防止用户把对象弄成无效状态。
2. public / private 访问控制
- 定义与目的:
public成员对所有人可见(用户接口);private成员只对本类可见(实现细节);protected介于两者之间——对子类可见、对外部不可见(详见本讲”继承”一节)。 - 核心语法:
class ClassName { private: // 只有本类的成员函数能访问 public: // 所有人都能访问 }; - 设计意图与最佳实践:”用户能碰 public,摸不到 private”。公开的应该是做什么(接口),隐藏的应该是怎么做(实现)。后续若改实现,只要接口不变,调用方代码不用动。
3. 头文件(.h)与源文件(.cpp)分离
- 定义与目的:
.h定义接口(类声明、函数原型、类型定义、常量),被多个源文件共享;.cpp实现接口(成员函数的函数体),被单独编译成目标文件。 - 核心语法:
// StanfordID.h —— 接口 class StanfordID { public: StanfordID(std::string name, std::string sunet, int idNumber); std::string getName(); private: std::string name; };// StanfordID.cpp —— 实现 #include "StanfordID.h" StanfordID::StanfordID(std::string name, std::string sunet, int idNumber) : name{name}, sunet{sunet} {} std::string StanfordID::getName() { return this->name; } - 设计意图与最佳实践:
.cpp里定义成员函数时必须用类名::作为作用域(就像std::之于标准库),告诉编译器”这个函数属于哪个类”。注意:class StanfordID {...};结尾要加分号;StanfordID::StanfordID(...)中::前面的名字是类、后面的名字是构造器名。
4. 构造函数(Constructor)
- 定义与目的:构造函数在对象创建时初始化其状态(成员变量)。课堂例子
StanfordID的对象需要name、sunet、idNumber三个字段,构造函数负责把它们设好。 - 核心语法(语法就是类名):
// 带参构造器(.cpp 中实现) StanfordID::StanfordID(std::string name, std::string sunet, int idNumber) : name{name}, sunet{sunet}, idNumber{idNumber} {} - 设计意图与最佳实践:
- 成员初始化列表(
name{name},注意花括号):直接初始化成员,是首选方式(避免先默认构造再赋值)。 - 构造器重载:编译器根据实参个数/类型自动选择调用哪一个(
StanfordID s1;调默认构造器,StanfordID s2{"a","b",1};调带参构造器)。 - 构造函数体内还可以做参数校验(例如
if (idNumber > 0) ...),保证对象一出生就是合法的。
- 成员初始化列表(
5. this 指针
- 定义与目的:
this是指向”当前对象”的指针,用于在参数名与成员名相同时消除歧义。 - 核心语法:
void StanfordID::setName(std::string name) { this->name = name; // 左边是成员,右边是参数 } - 设计意图与最佳实践:当参数和成员同名时,
name = name;只是”参数赋给自己”,成员根本没被修改——这是经典 bug。写this->name明确”我要的是这个对象的 name 成员”。
6. Getter / Setter
- 定义与目的:私有字段不直接暴露,通过 getter(读)和 setter(写)受控访问;setter 内部可以做校验(如
setID拒绝负数)。 - 核心语法:
std::string StanfordID::getName() { return this->name; } void StanfordID::setID(int idNumber) { if (idNumber >= 0) { this->idNumber = idNumber; } } - 设计意图与最佳实践:把”数据的合法性规则”收进 setter 一处,所有调用方自动受益。现代 C++ 还要求只读 getter 标记为
const(见 Lecture 9 的 const 正确性)。
7. 析构函数(Destructor)
- 定义与目的:对象生命周期结束时(离开作用域)自动调用,负责释放动态分配的资源(
delete [] my_array;)。不写也可以——只要类里没有new出来的资源,编译器会隐式生成析构函数。 - 核心语法:
StanfordID::~StanfordID() { // free/deallocate any data here(本例无动态资源,可为空) } - 设计意图与最佳实践:析构函数不能手动调用(除非极特殊情况),它在对象离开作用域时自动触发。凡是构造器里
new过的,析构函数里必须对应delete(这引出了后面的”三/五法则”与 RAII 思想)。
8. 类型别名(Type Aliasing)
- 定义与目的:为类型起一个同义标识符,提高可读性、便于统一修改。
- 核心语法(C++11 起用
using,等价于 C++98 的typedef):using String = std::string; // 之后 String 就是 std::string String name; - 设计意图与最佳实践:在类内部
using String = std::string;后,整个类可以统一用短名,将来想换成std::u8string只改一行。using比typedef更清晰且支持模板别名。
9. 继承(Inheritance):public / protected / private
- 定义与目的:动态多态(不同对象共用同一接口)与可扩展性(通过子类为基类增加特定属性)。例如
Circle : public Shape、”Player is an Entity”。 - 核心语法:
class Circle : public Shape { ... }; // is-a 关系 class B : protected A { ... }; // 接口只对子类可见 class B : private A { ... }; // 默认!接口只对本类可见 设计意图与最佳实践:三种继承方式决定基类成员的访问级别如何”传递”:
基类成员 class B: public Aclass B: protected Aclass B: private A(默认)public public protected private protected protected protected private private 不可访问 不可访问 不可访问 经典判断(课堂 Pop Quiz):实现
class MyStack : ______ MyVector时应选 private——因为用户不该用 vector 的insert破坏栈的约束,子类也不需要 vector 的接口;而Player : public Entity必须是 public,因为”玩家真的是一个实体”,必须完整暴露实体的公共接口。注意:class 的继承默认是 private(class B : A等价于class B : private A),这是最常见的错误之一。
10. 虚函数、纯虚函数与 vtable
- 定义与目的:
virtual开启动态分派——通过基类指针/引用调用时,实际执行的是对象真实类型(运行时类型)的版本。virtual的机制是给对象附加一个vpointer,指向一张vtable(虚函数表),表里记录了每个虚函数该调用哪个实现。 - 核心语法:
class Shape { public: virtual double area() const = 0; // 纯虚函数:无默认实现 }; class Circle : public Shape { public: double area() const override { return 3.14 * _radius * _radius; } }; - 设计意图与最佳实践:纯虚函数(
= 0)在基类”声明但不在基类实现”,强制子类必须覆盖;含纯虚函数的类是抽象类,不能实例化。override关键字让编译器帮你检查”是否真的覆盖了基类的虚函数”,防止拼写错误静默地创建新函数。多态基类还应把析构函数声明为virtual(见陷阱部分)。
11. 菱形继承(Diamond Problem)与虚继承
- 定义与目的:当
B、C都继承A,而D同时继承B和C时,D会得到两份A的子对象(D里有两个hello()),obj.hello()产生歧义——这就是菱形问题。 - 核心语法:
class B : virtual public A { ... }; // 虚继承:共享一份 A class C : virtual public A { ... }; class D : public B, public C { ... }; // D 中只有一份 A - 设计意图与最佳实践:虚继承让
B、C共享同一份A子对象,D obj; obj.hello();不再歧义。幻灯片给的定义:”Virtual — existing in essence, but not literally”——virtual意味着创建 vtable、意味着把静态类型判断推迟到运行时。菱形继承在实际工程中应尽量避免(接口设计复杂、布局开销大),但理解它能帮助理解多重继承的本质。
代码示例与逐步解说(核心)
示例 1:为什么需要 class——struct 的无力(C++11)
代码:
// C++11
#include <iostream>
#include <string>
struct StanfordID {
std::string name;
std::string sunet;
int idNumber; // 所有字段默认 public
};
int main() {
StanfordID s;
s.name = "Preston Seay";
s.sunet = "pseay";
s.idNumber = 12345;
s.idNumber = -12345; // 💀 用户可以直接破坏数据
std::cout << s.name << " " << s.idNumber << "\n";
}
代码做什么:构造一个 StanfordID 结构体并逐字段赋值,然后演示用户可以随意把 idNumber 改成负数——结构体对数据毫无保护。
特性机制解说:struct 的成员默认是 public,编译器对成员访问不做任何检查,s.idNumber = -12345; 与 s.name = ... 完全等价。这正是”没有访问控制”的体现:数据与操作它的规则没有绑定在一起,任何代码都能绕过约束。类的引入就是为了把 idNumber >= 0 这类”规则”与数据本身封装起来。
示例 2:完整的 StanfordID 类——封装、构造器重载、this、getter/setter、析构(C++11)
代码:
// C++11
#include <iostream>
#include <string>
class StanfordID {
private:
std::string name;
std::string sunet;
int idNumber;
public:
// 默认构造器(重载之一)
StanfordID()
: name{"John Appleseed"}, sunet{"jappleseed"}, idNumber{1} {}
// 带参构造器(成员初始化列表 + 参数校验)
StanfordID(std::string name, std::string sunet, int idNumber)
: name{name}, sunet{sunet}, idNumber{idNumber} {
if (idNumber < 0) this->idNumber = 0; // this-> 区分参数与成员
}
// getter
std::string getName() const { return name; }
std::string getSunet() const { return sunet; }
int getID() const { return idNumber; }
// setter(带校验)
void setID(int idNumber) {
if (idNumber >= 0) this->idNumber = idNumber;
}
// 析构函数:本类没有 new 资源,可为空
~StanfordID() {}
};
int main() {
StanfordID defaultStudent; // 调用默认构造器
StanfordID s{"Preston Seay", "pseay", 12345}; // 调用带参构造器
s.setID(-1); // 被拒绝:负数无效
s.setID(999); // 生效
std::cout << s.getName() << " " << s.getSunet()
<< " " << s.getID() << "\n";
std::cout << defaultStudent.getName() << "\n";
// 输出: Preston Seay pseay 999
// John Appleseed
}
代码做什么:先构造默认学生与带参学生;setID(-1) 被校验逻辑拒绝,setID(999) 生效;最后打印两人信息。对象 defaultStudent 与 s 在 main 结束离开作用域时自动调用析构函数。
特性机制解说:
- 构造器重载:
StanfordID defaultStudent;与StanfordID s{...};实参个数不同,编译器在重载决议(overload resolution)中选出对应版本。 - 成员初始化列表:
name{name}中,花括号左侧是成员、右侧是参数。成员在进入函数体之前就已初始化(直接构造,而非先默认构造再赋值),这正是它优于name = name;的原因。 - this 指针:
this->idNumber = idNumber;中this是StanfordID*,指向”正在被调用的那个对象”。s.setID(999)时this指向s,于是改写的是s的成员。成员函数调用其实等价于setID(&s, 999)——this是隐藏的第一个参数。 - 析构函数:
~StanfordID() {}没有显式调用点,main结束时两个对象自动析构。
示例 3:Shape 抽象基类与多态——纯虚函数、override、动态分派(C++11)
代码:
// C++11
#include <iostream>
class Shape {
public:
virtual double area() const = 0; // 纯虚函数:抽象类
virtual ~Shape() = default; // 多态基类必须虚析构
};
class Circle : public Shape {
private:
double _radius;
public:
explicit Circle(double radius) : _radius{radius} {}
double area() const override { return 3.14 * _radius * _radius; }
};
class Rectangle : public Shape {
private:
double _width, _height;
public:
Rectangle(double w, double h) : _width{w}, _height{h} {}
double area() const override { return _width * _height; }
};
int main() {
Circle c{2.0};
Rectangle r{3.0, 4.0};
const Shape* shapes[] = {&c, &r}; // 基类指针数组
for (const Shape* s : shapes) {
std::cout << s->area() << "\n"; // 输出 12.56 与 12
}
// Shape s; // ❌ 抽象类不能实例化
}
代码做什么:定义抽象基类 Shape(纯虚 area()),Circle 与 Rectangle 继承并覆盖 area();main 用基类指针数组统一调用,每次调用都会执行真实类型的 area。
特性机制解说:
- 纯虚函数:
= 0告诉编译器”基类只声明接口,不提供实现”,同时使Shape成为抽象类,Shape s;无法编译。 - 动态分派:
s->area()的静态类型是const Shape*,但运行时通过对象的 vpointer → vtable 查到应该调Circle::area()还是Rectangle::area()。virtual关键字给Shape的对象悄悄附加了一个 vtable 指针,这使对象内存变大、调用多一次间接跳转——这是 C++ 中虚函数不是默认开启的原因(”opt in”)。 - override:
double area() const override若与基类签名不匹配(比如漏写const),编译器直接报错而不是静默新建函数。
示例 4:菱形继承与虚继承的修复(C++11)
代码:
// C++11
#include <iostream>
class A {
public:
void hello() const { std::cout << "hello from A\n"; }
};
class B : virtual public A {}; // 虚继承
class C : virtual public A {}; // 虚继承
class D : public B, public C {}; // D 只共享一份 A
int main() {
D obj;
obj.hello(); // ✅ 不再歧义
obj.B::hello();// 仍可显式选择路径
obj.C::hello();
}
代码做什么:B、C 通过 virtual public A 继承 A,D 同时继承 B 与 C,最终 D 里只有一份 A 子对象,obj.hello() 直接可用。
特性机制解说:如果不加 virtual,D 会包含两份 A 子对象(一份经 B、一份经 C),obj.hello() 就歧义到必须写 obj.B::hello()。虚继承让 B、C 共享同一个 A 子对象——编译器会把共享基类子对象放到派生对象末尾,并通过偏移量访问,实现”一份拷贝”。代价是对象布局更复杂、访问稍慢,所以只在确实需要时才用虚继承。
与旧标准(如 C++98)的对比
| 本讲新特性(C++11+) | C++98 的做法 | 新特性优势 |
|---|---|---|
成员初始化列表用花括号 : name{name} | 圆括号 : name(name) | 统一初始化(uniform initialization)防止窄化转换(如 double 隐式截断为 int 会编译报错) |
using String = std::string; | typedef std::string String; | 语法更直观、支持模板别名(template<class T> using Vec = std::vector<T>;) |
= delete / = default(如 ~Shape() = default) | 把拷贝构造/拷贝赋值声明为 private 且不实现来”禁用” | 意图清晰、报错信息友好;= default 保留编译器生成的版本 |
override 关键字 | 没有;靠命名约定(如 area()) | 编译器校验覆盖关系,拼写/签名错误立即报错 |
类内成员默认初始化 int idNumber{1}; | 只能在构造器初始化列表里写 | 每个构造器自动获得默认值,减少重复与漏初始化 |
nullptr、基于范围的 for、auto | NULL/0、手写循环 | 类型安全、代码更短(示例 3 中的 for (const Shape* s : shapes) 在 C++98 要写迭代器或下标循环) |
关键要点
class默认 private,struct默认 public;封装是类的灵魂——数据私有、接口公开、规则收进 setter。- 构造函数用成员初始化列表(花括号)初始化成员,比在函数体内赋值更高效、更安全,还能做参数校验。
this是隐藏的”当前对象指针”;参数与成员同名时必须this->member = param;。class B : A默认是 private 继承;表达 is-a 关系必须显式class B : public A,否则基类公共接口对外不可见。- 多态基类要写
virtual析构函数,纯虚函数(= 0)制造抽象类;菱形继承用virtual public虚继承修复歧义。
常见陷阱与注意事项
name = name;参数遮蔽成员:不带this->时只是”参数赋给参数”,成员从未被修改,且编译器通常不报错——初始化列表或this->是正解。- 默认私有继承:
class MyStack : MyVector会让用户无法调用 vector 的公共接口;课堂答案是private(栈不该暴露 vector),但 is-a 场景(Player : public Entity)忘写public就是 bug。 - 菱形继承不加
virtual:D得到两份A,obj.hello()歧义无法编译。 - 多态基类析构函数不是
virtual:Shape* p = new Circle(...); delete p;只调用Shape::~Shape(),Circle的资源泄漏——未定义行为。 - 纯虚函数签名不一致:子类
area()忘了const或参数不同,会静默”新建”函数而不覆盖;用override让编译器抓住它。
关联作业提示
本讲对应 A3: Make a Class!(Lecture 7-8 的知识)。作业要求你在 class.h / class.cpp 中自创一个类,硬性要求包括:①带一个或多个参数的构造器;②默认(无参)构造器——即构造器重载;③至少一个 private 字段;④至少一个 private 成员函数(相当于”引擎盖下的实现细节”,不暴露给用户);⑤至少一个 public getter(建议标 const);⑥至少一个 public setter。
运用本讲知识:用 class.h 写声明(private: 字段 + public: 接口)、class.cpp 用 类名:: 作用域写实现;构造器用成员初始化列表并在体内做参数校验;setter 里做约束检查(像 setID 拒绝负数);getter 用 this-> 或直接返回成员;sandbox.cpp 里用统一初始化构造实例。加分项是把它写成类模板(template <typename T>)——那就必须按 Lecture 9 的规则去掉 class.cpp 的编译并让 .h 在底部 #include 实现文件。short_answer.txt 的两道题(什么是 const 正确性、你的类是否 const 正确)请参考 Lecture 9 的 const 成员函数部分作答。
