Lecture 7 (Week 4 - Tuesday): 类 (Classes)

目录 · ← l6 · l8 →

Lecture 7 (Week 4 - Tuesday): 类 (Classes)

概述

本讲是 C++ 面向对象编程的起点:C 语言没有对象,无法把”数据”与”操作这些数据的函数”封装在一起,而 C++(最初就叫 “C with Classes”)通过 类(class) 这一用户自定义类型实现了封装、继承与多态。学习目标是掌握 structclass 的区别、public/private 访问控制、头文件(.h)与源文件(.cpp)的分离、构造器(默认/带参/成员初始化列表/重载)、this 指针、getter/setter、析构函数、类型别名,以及继承的三种方式(public/protected/private)、虚函数与 vtable、菱形继承与虚继承。这些是后续讲”类模板”“const 正确性”以及作业 A3 的地基。

核心特性与语法详解

1. struct vs class:默认访问级别

  • 定义与目的structclass 几乎完全相同,唯一区别是默认访问级别struct 的成员默认 publicclass 的成员默认 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 的对象需要 namesunetidNumber 三个字段,构造函数负责把它们设好。
  • 核心语法(语法就是类名):
    // 带参构造器(.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 只改一行。usingtypedef 更清晰且支持模板别名。

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(默认)
    publicpublicprotectedprivate
    protectedprotectedprotectedprivate
    private不可访问不可访问不可访问

    经典判断(课堂 Pop Quiz):实现 class MyStack : ______ MyVector 时应选 private——因为用户不该用 vector 的 insert 破坏栈的约束,子类也不需要 vector 的接口;而 Player : public Entity 必须是 public,因为”玩家真的是一个实体”,必须完整暴露实体的公共接口。注意:class 的继承默认是 privateclass 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)与虚继承

  • 定义与目的:当 BC 都继承 A,而 D 同时继承 BC 时,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
    
  • 设计意图与最佳实践:虚继承让 BC 共享同一份 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) 生效;最后打印两人信息。对象 defaultStudentsmain 结束离开作用域时自动调用析构函数。

特性机制解说

  • 构造器重载StanfordID defaultStudent;StanfordID s{...}; 实参个数不同,编译器在重载决议(overload resolution)中选出对应版本。
  • 成员初始化列表name{name} 中,花括号左侧是成员、右侧是参数。成员在进入函数体之前就已初始化(直接构造,而非先默认构造再赋值),这正是它优于 name = name; 的原因。
  • this 指针this->idNumber = idNumber;thisStanfordID*,指向”正在被调用的那个对象”。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()),CircleRectangle 继承并覆盖 area()main 用基类指针数组统一调用,每次调用都会执行真实类型area

特性机制解说

  • 纯虚函数= 0 告诉编译器”基类只声明接口,不提供实现”,同时使 Shape 成为抽象类,Shape s; 无法编译。
  • 动态分派s->area() 的静态类型是 const Shape*,但运行时通过对象的 vpointer → vtable 查到应该调 Circle::area() 还是 Rectangle::area()virtual 关键字给 Shape 的对象悄悄附加了一个 vtable 指针,这使对象内存变大、调用多一次间接跳转——这是 C++ 中虚函数不是默认开启的原因(”opt in”)。
  • overridedouble 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();
}

代码做什么BC 通过 virtual public A 继承 AD 同时继承 BC,最终 D 里只有一份 A 子对象,obj.hello() 直接可用。

特性机制解说:如果不加 virtualD 会包含两份 A 子对象(一份经 B、一份经 C),obj.hello() 就歧义到必须写 obj.B::hello()。虚继承让 BC 共享同一个 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、autoNULL/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。
  • 菱形继承不加 virtualD 得到两份 Aobj.hello() 歧义无法编译。
  • 多态基类析构函数不是 virtualShape* 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 成员函数部分作答。