Chapter 10:Objects and Classes

对齐第6版 Chapter 10:从过程式分解走向类抽象,掌握 public/private、构造与析构、this 指针、类作用域、对象数组和栈 ADT。

学习目标

  • 能实现从过程式“数据加函数”到公有接口、私有表示和可检验不变量类的重构
  • 能解释构造函数、成员初始化、this 指针、对象数组与析构的精确执行次序
  • 能把固定容量栈实现为抽象数据类型,用操作前后条件验证正常、上溢和下溢路径

从过程式步骤开始建立对象边界

过程式编程常把问题分解为函数和数据流,适合无状态计算;面向对象编程把相关状态、允许的操作和始终成立的关系放进同一边界。抽象与类的关系在于:抽象定义稳定能力和不变量,类把它实现为访问控制、状态和方法。两者不是互斥风格:类的方法内部仍由表达式、函数和控制流实现,关键变化是“谁有权改变状态、改变后必须恢复什么”。

若温度计允许调用者直接写 reading,任何值都能进入对象;若公有操作是 setCelsius(),类可以集中检查范围并保持单位一致。类不是把字段和 setter 机械包一层,而是把领域约束变成唯一合法入口。

public 承诺能力,private 保留表示自由

class declaration 同时描述 public and private。公有部分是调用者可依赖的契约;私有成员与 helper 是实现,可以在不破坏契约时替换。默认访问权限也是语言语义:class 成员默认 private,struct 成员默认 public,但二者都能有方法、构造函数和访问控制。

bank-account.hpp
#pragma once
 
#include <string>
 
class BankAccount {
public:
    BankAccount(std::string owner, double openingBalance);
 
    bool deposit(double amount);
    bool withdraw(double amount);
    double balance() const;
 
private:
    std::string owner_;
    double balance_;
};

这里的不变量至少是余额有限且非负。withdraw() 返回 bool 让余额不足成为显式业务失败,并保证失败时状态不变;balance() const 承诺不修改当前对象的可观察状态。

成员函数在某个当前对象上执行

在非 static 成员函数中,编译器提供隐式的 this pointer,指向当前对象。成员名 balance_ 可理解为 this->balance_。当方法要返回当前对象以支持连续调用时,可返回 *this 的引用,但调用者不能让该引用活得比对象更久。

BankAccount& BankAccount::rename(std::string owner) {
    owner_ = std::move(owner);
    return *this;
}
 
bool BankAccount::withdraw(double amount) {
    if (amount <= 0.0 || amount > balance_) {
        return false;
    }
    this->balance_ -= amount;
    return true;
}

BankAccount::withdraw 中的 scope resolution operator 表示该函数属于 BankAccount class scope。类外定义仍可访问该类的 private 成员,因为访问权属于成员函数身份,不取决于函数体写在类内还是类外。

构造函数打开有效生命周期

构造函数与类同名、没有返回类型,负责建立首次有效状态;析构函数名为 ~ClassName,负责结束对象拥有的资源。构造不是先把对象建好再调用普通方法:成员会先按声明顺序初始化,然后执行构造函数体。初始化列表书写顺序不会改变成员真实顺序。

BankAccount::BankAccount(std::string owner, double openingBalance)
    : owner_{std::move(owner)},
      balance_{openingBalance >= 0.0 ? openingBalance : 0.0} {
}
 
BankAccount::~BankAccount() = default;

初始化列表直接构造成员;若在函数体赋值,成员必须先默认构造再赋值,const/reference 成员甚至只能在初始化阶段绑定。生产设计还应决定无效开户余额是拒绝、异常还是规范化,不能静默改值却不给调用者信号。

对象数组会逐个构造和逆序析构

BankAccount accounts[3] 需要为每个元素找到可用构造方式。若类没有默认构造函数,就必须提供三个初始化器;构造按下标递增,离开作用域时按下标递减析构。数组保证对象连续,不代表可以越过类的 public interface 直接操作成员表示。

BankAccount accounts[] {
    {"Ada", 120.0},
    {"Bjarne", 80.0},
    {"Barbara", 200.0},
};
 
for (const BankAccount& account : accounts) {
    std::cout << account.balance() << '\n';
}

对象数组把“元素生命周期”和“容器边界”叠加在一起:索引必须合法,元素本身也必须保持不变量。元素很多或数量运行时决定时,std::vector<BankAccount> 通常能同时保存长度和管理生命周期。

类作用域把类型级名字限定在类内

成员、成员函数、嵌套类型和类内常量都位于 class scope。类外通过 BankAccount:: 限定名字;对象通过 account.balance() 选择成员。类内 static data member 属于类型而非每个对象,但其声明、定义和版本规则仍需分别处理。

class Stack {
public:
    using Item = unsigned long;
    static constexpr std::size_t capacity{8};
 
    bool push(Item value);
    bool pop(Item& out);
    bool empty() const;
 
private:
    Item items_[capacity]{};
    std::size_t size_{0};
};

Stack::ItemStack::capacity 不污染全局名字。类作用域解决名字归属,不自动证明设计合理;真正的契约仍来自操作语义和 size_ <= capacity 不变量。

栈是“操作集合”,不是一块数组

抽象数据类型(abstract data type, ADT)由允许的值和操作语义定义,与底层表示分离。栈的 LIFO 规则要求 push 把值放到新栈顶,pop 取出旧栈顶;满栈 push 和空栈 pop 必须有明确失败语义,且不能越界访问。

bool Stack::push(Item value) {
    if (size_ == capacity) {
        return false;
    }
    items_[size_] = value;
    ++size_;
    return true;
}
 
bool Stack::pop(Item& out) {
    if (size_ == 0) {
        return false;
    }
    --size_;
    out = items_[size_];
    return true;
}
 
bool Stack::empty() const {
    return size_ == 0;
}

pop 先证明非空,再把 size 减到旧栈顶索引,最后读取。失败路径不改 size_,也不读写数组。调用者必须检查 bool;若改用异常或 optional,仍要保留同样的前后条件。

先预测四种操作后的 size_、返回值和被访问索引,再切换实验面板核对;只要失败路径改变了状态或触碰了非法索引,ADT 契约就尚未成立。

三步把数据结构审计为类契约

分步1 / 3

第一步:写公有能力与不变量

列出调用者真正需要的领域操作、每个操作的失败语义,以及构造和每次返回时都成立的关系。

小结

  • 过程式与面向对象分解可以组合;类的价值是集中状态权限和不变量
  • public interface 承诺领域能力,private representation 保留实现变化空间
  • constructors and destructors 划定有效生命周期,成员按声明顺序构造、逆序销毁
  • this pointer 表示当前对象,class scope 管理成员名字归属
  • 对象数组叠加容器边界与元素生命周期,不能绕过元素契约
  • abstract data types 由操作前后条件定义;栈的上溢和下溢必须保持状态有效

练习

  1. 问题 1:重构数据加函数。 一个订单由公开字段 totalpaid 和多个随意修改它们的函数组成,怎样建立类边界?
  1. 问题 2:预测生命周期。 类中先声明 buffer_、后声明依赖其地址的 view_,初始化列表却先写 view_,真实顺序是什么?
  1. 问题 3:证明栈边界。 为容量 2 的 Stack 写最小操作序列,覆盖正常、overflow、LIFO 与 underflow。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

抽象
提取稳定能力并隐藏不相关表示细节的建模过程。
对象
类类型的实例,拥有状态、身份和受约束的生命周期。
公有接口
调用者可使用并依赖其契约的操作集合。
类不变量
构造成功后和公有操作返回时始终成立的状态关系。
构造函数
初始化成员并建立对象首次有效状态的特殊函数。
析构函数
结束对象资源责任并触发成员逆序销毁的特殊函数。
this 指针
非 static 成员函数中指向当前对象的隐式指针。
抽象数据类型
由值域和操作语义定义、与具体表示解耦的类型。

资料与写作方式声明

本章以C++ Primer Plus, Sixth Edition, Chapter 10 Objects and Classes权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…