从零开始 / 一次只解释眼前的一步

类与对象:
状态、接口与生命周期。

从一个只有整数成员的对象开始,追踪构造、查询、尝试更新与销毁。用计数对象和Trace,把每一次状态变化说清楚。

先备:基础01–066 小节 · 预计共 8 小时阅读、推演与编码合计

8小时是未经真人试学校准的设计估算,包含末节30分钟认读。可分多次学习,遇到不清楚的地方保留预测、实际结果和疑问,之后再回修教材。手机可读图与做预测,编译需要电脑终端。

这一章怎样学

先读一小段,写下预测,再运行程序。每次只改一个条件,最后关掉示例,从空文件独立写一次。遇到错误,把第一条报错和自己的修复记下来;不用赶着把页面滚到底。

每次分别记录哪个对象的状态变化、更新是否接受、哪些对象正在退出作用域。第07.6节只用于认读,可以自愿记下阅读时间;读完不代表掌握多态,也不进入本章练习或验收。每章学习记录单独保留,本章完成不自动通过M1或G0。

现在要记录“已经用了多少容量”。如果任何地方都能把used改成任意整数,怎样保证它始终在0和上限之间?本章把状态和允许的操作放在同一个类型里,再追踪这个类型的对象怎样建立、使用和退出。

先备是新06:指针与有效访问。六节预计8小时,仍是未经真人校准的估算。正常构造输入限定为合法的小整数;容量0–100、增加量−100–100。本章不申请动态内存,不抛异常。最后30分钟只帮助认读继承代码,不设练习,也不进入本章核心验收。

07.1 类、对象与成员接口 · 预估90分钟

同一份类型说明,怎样建立两份状态

两个读数需要同样的操作,但它们应保存不同的数。先把“有哪些成员、能做什么”写成一种类型,再分别建立具体对象。本节只放一个整数成员,不需要容器。

struct Reading { ... };定义名为Reading的类类型。花括号内写成员,定义结束后的分号不能省。int value;声明每个Reading对象都有一个名为value的int成员;类型说明本身没有创建main里的两个具体读数。

先采用一个完整但很窄的创建规则:Reading只有一个公开int数据成员,也没有自己声明构造函数。Reading a{4};创建对象a,并把4初始化给它唯一的数据成员value。Reading b{9};另外创建b,给b的value初值9。不是给一个共享格子先写4再写9,也不是复制a。07.2再扩展到多个成员及自定义初始化。

a.value用点号选出a的成员;b.value选出b的成员。前半部分决定是哪一个对象,后半部分决定那个对象的哪一个成员。写a.value = 7;只改a的整数。

把一个操作放进类里面

在类定义内写void set(int next) { ... },定义的是成员函数。参数和return规则仍沿用普通函数;新增的是调用时还有一个“当前对象”。a.set(7)以a为当前对象,把7交给本次的int参数next。类内函数体的右花括号后不需要另加普通函数声明用的分号,整个类定义末尾仍需要分号。

本节成员函数中的this是指向本次对象的指针。这里的this->value选出当前Reading对象的value;把next赋给它就是更新这一个成员。int read()返回当前成员的整数值。返回的是值,不会另外复制整个Reading对象。

这里也正式把上一章的具名借用扩展到本章已建立的类对象。Reading* p{&a};保存a的地址,不复制a。只要p指向仍可用的a,p->value就选出a.value,p->set(7)就对a调用set。箭头->将解引用和成员访问合在一起:p->value等价于(*p).value,括号说明先沿指针找到对象。空指针不能这样访问;本节p明确来自仍在main中的a。

#include <iostream>

struct Reading {
    int value;

    void set(int next) {
        this->value = next;
    }

    int read() {
        return this->value;
    }
};

int main() {
    Reading a{4};
    Reading b{9};
    Reading* p{&a};
    std::cout << a.read() << ' ' << b.read() << '\n';
    p->set(7);
    std::cout << a.read() << ' ' << b.read() << '\n';
    return 0;
}

先画a.value=4、b.value=9,第一行输出4 9。p指向a,经p调用set时this也指向a,于是a.value变7。b没有参与更新,第二行是7 9。成员函数的定义只有一份;每次调用选中的对象可以不同。

小练习:只把p的初值从&a改成&b

第一行仍是4 9。set改的是b.value,第二行变成4 7。不能因为函数体写在同一个类定义里,就把所有Reading对象一起改成7。

public、private和局部类

public:让其后的成员成为外部代码可以使用的接口;private:把其后的成员限制为本类成员函数等有访问资格的代码可直接使用,直到下一个访问标签或类结束。它们是编译期的访问规则,不是加密或内存隔离。字段名末尾的下划线只是命名习惯,不会自动使字段私有。

struct和class都能有数据、函数和访问标签。主要的入门区别是默认权限:struct成员默认public,class成员默认private。把Reading写成class并在成员前加public:,仍可以表达这里相同的公开接口。07.2会建立有私有成员、由构造函数负责初值的对象;现在不要求外部代码绕过private去赋初值。

类也可以定义在函数的块内,称为局部类。类名只在相应作用域内可见。局部类的方法不会自动捕获main中的普通局部变量;需要的数据应来自自己的成员或参数。下面的input由main里的初始化语句交给成员,read读取自己的value,没有在方法里偷用input。

#include <iostream>

int main() {
    struct LocalReading {
        int value;

        int read() {
            return value;
        }
    };

    int input{4};
    LocalReading reading{input};
    std::cout << reading.read() << '\n';
    return 0;
}

input是main的整数4;LocalReading是main中定义的类型名;用input创建的对象有自己的整数成员4,read输出4。只把input的初值改成9,成员初值和输出都变9。若把read里的成员名value直接换成外面的局部名字input,它不会因此变成一个能捕获input的函数。

编译时沿用新04的严格诊断选项。本章标为“只编译”的错误文件不运行,也不接启动旧程序的命令。正常文件则先预测,再编译运行核对。

07.2 初始化与构造 · 预估120分钟

私有成员的初值由谁负责

现在有些状态不能任由外部直接赋值。我们需要在对象建立时就给成员合适的初值,而不是等别处想起来再补。本节把成员初始化、构造函数体和普通赋值分成不同步骤。

先把07.1的一个公开成员扩展成两个:只有公开int成员left和right、没有用户声明构造函数的简单Pair,Pair pair{3, 4};按成员声明顺序初始化,left得到3,right得到4。这是本章使用的聚合初始化(aggregate initialization)。不是所有struct都符合聚合条件,也不是见到花括号就能按“第一个数给第一个成员”解释。

自定义构造函数写类名,不写普通返回类型。例如Number类内的explicit Number(int initial) : value_{initial} { ... }:Number表示这是构造函数,initial按值接收初值;冒号后的value_{initial}初始化成员value_;最后的花括号才是构造函数体。它不是一个返回Number的普通函数。

在这里,Number number{6};明确请求建立Number并把6交给这个构造函数。成员value_先取得6,再执行构造函数体,完成后才开始普通使用。构造函数可以初始化本类的私有成员,调用者不需要也不能直接写number.value_。

explicit要求这里的单参数构造由调用者明确选择。Number number{6};允许;Number number = 6;不能隐式使用这个explicit构造函数。后者位于声明中,是一种初始化写法,不是给已经存在的number赋值。这里尚不讨论类对象的复制或移动。

#include <iostream>

struct Pair {
    int left;
    int right;
};

class Number {
    int value_;

public:
    explicit Number(int initial) : value_{initial} {
    }

    int value() {
        return value_;
    }
};

int main() {
    Pair pair{3, 4};
    Number number{6};
    std::cout << pair.left << ' ' << pair.right << '\n';
    std::cout << number.value() << '\n';
    return 0;
}

聚合的两个成员依次取得3和4,第一行输出3 4。Number走自己的构造函数,私有value_取得6;公开查询交回这个整数,第二行输出6。两种创建都用了花括号,内部遵循的初始化规则却应从类型定义判断。

下面把显式构造与隐式初始化的区别单独放进一份错误文件。先指出是哪一条初始化不允许,再只编译读取诊断。

#include <iostream>

class Reading {
    int value_;

public:
    explicit Reading(int initial) : value_{initial} {
    }

    int read() {
        return value_;
    }
};

int main() {
    Reading r = 4;
    std::cout << r.read() << '\n';
    return 0;
}
修复:保留explicit,只改对象的创建写法

Reading r = 4;改为Reading r{4};,明确调用已有构造函数。修复后查询输出4。不需要为了让等号形式通过而删除explicit,也不能把编译失败解释为运行时得到0。

初始化列表不能重新排列成员

对本章没有基类的对象,数据成员按它们在类中声明的顺序初始化,全部完成后才进入构造函数体。冒号后列表的书写位置不能改变这个顺序。正常源码把列表也写得符合声明顺序,避免误导读者和触发顺序警告。

成员声明中的int first_{mark(1)};同时给出默认成员初始化。若某个构造函数没有在列表中指定first_,就采用它;如果列表已经为同一个成员指定初值,就不再执行那个成员的默认初始化。不是先默认初始化一次,再覆盖一次。

下面的普通函数mark先输出init和收到的整数,再按值返回那个整数。Ordered声明first_在前、second_在后。无参数构造函数只为second_提供mark(20),构造体输出body。Ordered value;创建名为value的对象,调用这个无参数构造函数;仍然先初始化成员,随后执行构造体。读取前先把实际会调用的mark逐个列出来。

#include <iostream>

int mark(int value) {
    std::cout << "init " << value << '\n';
    return value;
}

class Ordered {
    int first_{mark(1)};
    int second_{mark(2)};

public:
    Ordered() : second_{mark(20)} {
        std::cout << "body" << '\n';
    }

    int first() {
        return first_;
    }

    int second() {
        return second_;
    }
};

int main() {
    Ordered value;
    std::cout << value.first() << ' ' << value.second() << '\n';
    return 0;
}

first_先用自己的默认初值:调用mark(1),输出init 1并保存1。second_使用列表中的mark(20),输出init 20并保存20;它原来的mark(2)不执行。然后构造体输出body,最后查询得到1 20

小练习:只把second_的显式初值mark(20)改成mark(30)

顺序仍是first_、second_、构造体。输出变成init 1init 30、body、1 30。没有出现init 2。改变初值不会改变成员声明顺序。

默认成员初始化也可直接写成int used_{0};。若没有自行声明任何构造函数,语言会为这里的简单类提供默认构造入口;创建无参数对象时,已经给出默认成员初值的成员采用那些初值。无论构造入口来自哪里,仍需逐个判断成员怎样初始化。没有初值的普通自动int成员不能被猜成0;本章正常例子都在第一次读取前完成明确初始化。

引用成员保存的是持续借用

一个成员不一定拥有独立的整数。int& value_;声明引用成员,构造函数的value_{value}把它绑定到int引用参数value所指的对象。这是在成员初始化阶段绑定;进入构造体后写value_ = value只能给已经绑定的目标赋值,不能用来代替首次绑定。

沿用新05的具名借用规则,目标必须在最后一次使用期间仍可用。Borrowed存在不会延长外部int的寿命,也不会复制它。这里始终由调用者提供一个已经建立的具名整数,不绑定短命形参副本或临时对象。

#include <iostream>

class Borrowed {
    int& value_;

public:
    explicit Borrowed(int& value) : value_{value} {
    }

    int read() {
        return value_;
    }

    void set(int next) {
        value_ = next;
    }
};

int main() {
    int x{4};
    {
        Borrowed view{x};
        x = 7;
        std::cout << view.read() << '\n';
        view.set(9);
        std::cout << x << '\n';
    }
    return 0;
}

外部x从4改7时,read沿引用成员观察到7;随后set通过该引用把9写给同一个x,观察x得到9。引用成员没有改绑,Borrowed也没有取得销毁x的责任。若目标比最后一次read更早退出其有效期,包装成类不会修复这份无效借用。

小练习:只把通过成员写入的9改成8

先通过原名把x改7的步骤不变,第一次观察仍为7。后续set写入8,x的观察结果变8。不要为value_画第二个独立整数格。

07.3 维护不变量 · 预估90分钟

一次更新失败后,原来的状态还在吗

容量上限10,已经用了6,现在又请求增加5。接口需要既告诉调用者这次请求是否接受,又保留一个仍可使用的对象。我们先规定成功和失败各应留下什么状态,再读实现。

本例Counter的不变量(invariant)是:对象完成合法构造后,以及每次公开操作返回后,都满足0≤used≤limit。构造输入limit限定为0–100,初始used为0。构造函数在本章只接收这个合法范围,不假装用bool返回值拒绝一次构造。

try_add(amount)的输入amount限定为−100–100。负数拒绝;非负数若大于剩余空间limit_ - used_也拒绝;其余接受,包括0。拒绝时返回false且不改used_,接受时更新一次并返回true。增加0是接受但数值不变,与拒绝是两种结果。

检查必须在更新之前。原状态合法时,剩余空间位于0–100;通过检查后,新的used也不会超过limit。不需要先把两个数相加、破坏状态后再决定要不要退回。private只限制外部访问,bool只是一种结果类型;语言不会替错误的实现维持这个不变量。

const查询与自由函数

int used() const中参数列表后面的const说明这条成员查询通过只读对象路径访问当前对象。在这里,this的类型是const Counter*,不是Counter* const:限制的是经this对对象的写入,不是把指针对象固定住。查询不能经这条路径修改普通的used_成员。

const Counter& view现在把已学的只读借用应用到仍可用的Counter对象,view不复制它。可以用view调用const查询,不能用它调用普通非const更新函数。一个非const的Counter也可以调用自己的const查询。

这不是“const函数绝无外部作用”的保证,也不使通过引用或指针能找到的一切对象永久只读。本例查询只返回当前对象的普通int成员,合同因此可以很窄、很清楚。

普通自由函数也能使用对象:report(const Counter& counter)不属于Counter类,通过公开的used查询取得值。它没有因为接受了一个类引用就获得访问private成员的权限,也没有按值复制Counter。

完整例一:接受6,拒绝5和负数

#include <iostream>

class Counter {
    int limit_;
    int used_{0};

public:
    explicit Counter(int limit) : limit_{limit} {
    }

    int used() const {
        return used_;
    }

    int limit() const {
        return limit_;
    }

    bool try_add(int amount) {
        if (amount < 0) {
            return false;
        }
        if (amount > limit_ - used_) {
            return false;
        }
        used_ = used_ + amount;
        return true;
    }
};

void report(const Counter& counter) {
    std::cout << counter.used() << '\n';
}

int main() {
    Counter counter{10};
    bool first{counter.try_add(6)};
    std::cout << first << ' ';
    report(counter);
    bool second{counter.try_add(5)};
    std::cout << second << ' ';
    report(counter);
    bool negative{counter.try_add(-1)};
    std::cout << negative << ' ';
    report(counter);
    return 0;
}
调用 调用前used / 剩余空间 返回值 调用后used
try_add(6) 0 / 10 true,输出1 6
try_add(5) 6 / 4 false,输出0 6
try_add(-1) 6 / 4 false,输出0 6

所以三行是1 60 60 6。每次先实际执行更新调用,再读取结果与状态;不能只打印一个预先写好的“成功”。失败的分支在写used_之前已经返回。

停下来,看一次变化

失败请求不能提前改变used

limit10used0剩余101 / 5 · 状态与剩余容量limit10used0剩余101 / 5
1 · 合法构造

Counter接收上限10;默认成员初值把used设为0。不变量从这个合法状态开始。

1 / 5
查看所有步骤的文字与数值
  1. 1 · 合法构造

    limit:10;used:0;剩余:10

    Counter接收上限10;默认成员初值把used设为0。不变量从这个合法状态开始。

  2. 2 · 接受增加6

    请求:6 → true;used:0 → 6;剩余:4

    6非负且不超过剩余10;检查通过后才写used。返回true,观察为1 6。

  3. 3 · 检查增加5

    请求:5 > 4;used:仍为6;下一步:拒绝并返回

    当前剩余是4,不是总上限10。5超过剩余,尚未进行加法更新。

  4. 4 · 拒绝后仍可查询

    结果:false;used:6,保持;剩余:4

    返回false;used未写入,观察为0 6。它仍满足0≤used≤limit,可继续接受后续请求。

  5. 5 · 负数先拒绝

    请求:−1 → false;used:6,保持;完整输出:1 6 / 0 6 / 0 6

    −1在第一个条件处被拒绝,状态仍为6,观察0 6。增加0则会通过两个检查,返回true但保持数值;本步的实际调用是−1。

小练习:只把第二次增加5改成4

第一次后剩余空间正好4,第二次请求4被接受,used变10。三行是1 61 100 10。若第二次只改成0,则返回true但used保持6,三行是1 61 60 6

两个应由编译器拒绝的更新

第一份错误代码在main里直接写私有used_;应通过已规定的公开更新接口。第二份把查询写成return ++used_;,企图在const成员函数里改变普通成员;它不能因为最后返回的是int就绕过只读路径。

#include <iostream>

class Counter {
    int limit_;
    int used_{0};

public:
    explicit Counter(int limit) : limit_{limit} {
    }

    int used() const {
        return used_;
    }

    bool try_add(int amount) {
        if (amount < 0) {
            return false;
        }
        if (amount > limit_ - used_) {
            return false;
        }
        used_ = used_ + amount;
        return true;
    }
};

int main() {
    Counter counter{10};
    counter.used_ = 7;
    std::cout << counter.used() << '\n';
    return 0;
}
#include <iostream>

class Counter {
    int used_{0};

public:
    int used() const {
        return ++used_;
    }
};

int main() {
    const Counter counter;
    std::cout << counter.used() << '\n';
    return 0;
}
小练习:保留private和const,分别修哪一处

第一份把直接写成员的语句改为调用公开try_add(7),初始0在上限10内变7,查询输出7。第二份查询改为return used_;,不修改状态,初值0的查询输出0。删除访问限制虽然可能改变编译结果,却没有保住所要求的接口合同。

07.4 析构和销毁顺序 · 预估90分钟

离开一个块,会依次经过哪些对象

对象建立的次序已经能追踪。现在main里面再放一个内层块,分别建立有编号的对象:内层结束时会发生什么,外层又会在什么时候结束?先只观察正常控制流,不引入资源或异常。

类内的~Trace()定义析构函数(destructor)。波浪号接类名,不写普通返回类型,也不写本章普通函数那样的参数。对已经完成构造的普通自动对象,正常离开其作用域时会自动进入析构过程,不需要手动调用析构函数。

这里Trace只有一个int编号id_。构造体输出build和编号;析构体输出destroy和编号。这些文字由我们写的输出语句产生,不是int成员会自动打印日志,也不是在模拟取得和归还某种资源。

完整例二:外层1,内层2

#include <iostream>

class Trace {
    int id_;

public:
    explicit Trace(int id) : id_{id} {
        std::cout << "build " << id_ << '\n';
    }

    ~Trace() {
        std::cout << "destroy " << id_ << '\n';
    }
};

int main() {
    Trace outer{1};
    {
        Trace inner{2};
    }
    return 0;
}

先完成outer的建立,再进入内层块建立inner。内层右花括号先退出inner的作用域,输出destroy 2;最后main结束才轮到outer,输出destroy 1。四行依次是build 1build 2destroy 2destroy 1

提前return也沿着实际退出的作用域退场

return不是跳过所有析构的捷径。对本章正常的函数返回,已经完成构造、且本次返回会离开其作用域的自动对象,按构造完成的逆序销毁。没有执行到声明的对象不会被销毁;调用者中仍在自己作用域内的对象也不会因为被调用函数return而一起结束。

#include <iostream>

class Trace {
    int id_;

public:
    explicit Trace(int id) : id_{id} {
        std::cout << "build " << id_ << '\n';
    }

    ~Trace() {
        std::cout << "destroy " << id_ << '\n';
    }
};

void run(bool stop) {
    Trace outer{1};
    {
        Trace inner{2};
        if (stop) {
            std::cout << "stop" << '\n';
            return;
        }
        std::cout << "work" << '\n';
    }
    std::cout << "after" << '\n';
}

int main() {
    run(true);
    return 0;
}

true路径先输出build 1、build 2,再输出stop并执行return。虽然没有走到正常路径的work和after,已经建立的inner与outer仍依次输出destroy 2、destroy 1。若只把调用参数改为false,则输出build 1、build 2、work、destroy 2、after、destroy 1。

这一规则限定正常离块与return。不能扩写成“程序无论怎样终止,所有析构都会执行”。本章不把其他终止方式或异常处理塞进这个例子。

包装对象的析构体,在成员退场之前

类的数据成员也可以是另一个类对象,称为成员子对象(member subobject)。下面Wrapper按声明顺序包含编号1、2的两个Trace。它们先完成构造,再执行Wrapper构造体。销毁Wrapper时顺序不是简单地把所有构造输出倒放:先执行Wrapper自己的析构体,再逆序销毁这两个成员。

#include <iostream>

class Trace {
    int id_;

public:
    explicit Trace(int id) : id_{id} {
        std::cout << "build " << id_ << '\n';
    }

    ~Trace() {
        std::cout << "destroy " << id_ << '\n';
    }
};

class Wrapper {
    Trace first_;
    Trace second_;

public:
    Wrapper() : first_{1}, second_{2} {
        std::cout << "wrapper body" << '\n';
    }

    ~Wrapper() {
        std::cout << "wrapper end" << '\n';
    }

    void report() {
        std::cout << "use" << '\n';
    }
};

int main() {
    Wrapper wrapper;
    wrapper.report();
    return 0;
}

输出依次为build 1、build 2、wrapper body、use、wrapper end、destroy 2、destroy 1。wrapper end出现时,两个Trace成员尚未按顺序退出;接下来才分别进入它们的析构。

严格说,类对象的生命周期在析构调用开始时结束,而构造和析构阶段中的访问另有语言规则。析构函数体仍可以在规定范围内访问自身尚未销毁的成员,例如Trace读取id_。不要把“完整对象已经开始销毁”误画成“它的所有成员已经不能访问”,也不要把普通使用阶段一直延长到最后一个成员销毁之后。

停下来,看一次变化

先完成成员构造,先执行包装对象析构体

Wrapper构造中first_build 1second_尚未构造1 / 7 · Wrapper的阶段顺序Wrapper构造中first_build 1second_尚未构造1 / 7
1 · 构造第一个成员

按声明顺序先建立first_,其Trace构造体输出build 1。Wrapper仍在构造过程中。

1 / 7
查看所有步骤的文字与数值
  1. 1 · 构造第一个成员

    Wrapper:构造中;first_:build 1;second_:尚未构造

    按声明顺序先建立first_,其Trace构造体输出build 1。Wrapper仍在构造过程中。

  2. 2 · 构造第二个成员

    Wrapper:构造中;first_:已构造;second_:build 2

    接着建立second_,输出build 2。初始化列表不能把两个成员的声明顺序调换。

  3. 3 · Wrapper构造体

    Wrapper:wrapper body;first_:已构造;second_:已构造

    两个成员完成初始化后才进入Wrapper构造体,输出wrapper body。构造完成后才转入普通使用阶段。

  4. 4 · 普通使用

    Wrapper:use;first_:可用;second_:可用

    main调用report,输出use。此时没有任何销毁事件。

  5. 5 · 开始销毁:先执行Wrapper析构体

    Wrapper:wrapper end;first_:尚未销毁;second_:尚未销毁

    退出main,进入Wrapper析构体并输出wrapper end。完整对象的生命周期已到析构开始点;构造/析构期间另有访问规则,此刻两个成员尚未销毁。

  6. 6 · second_先退场

    Wrapper:成员销毁阶段;first_:尚未销毁;second_:destroy 2

    Wrapper析构体结束后,成员按构造逆序处理。先进入second_的Trace析构体,读取自己的编号并输出destroy 2。

  7. 7 · first_随后退场

    Wrapper:销毁流程结束;first_:destroy 1;second_:已销毁

    接着进入first_的析构体,输出destroy 1。正常销毁流程结束;不要把完整对象的普通使用阶段延长到这一步。

小练习:只把第二个Trace成员的编号2改成3

成员身份标签和对应两条输出变成build 3、destroy 3。声明顺序没有改变:先建1再建3,Wrapper析构体后先销毁3再销毁1。编号只是观察标记,不负责决定执行顺序。

四阶梯:把接口和退场顺序一起用起来

现在07.1–07.4需要的构造已全部讲过,再依次做下面四步。它们合在本节的练习时间里;前面Counter的完整讲解不是提前代做“补全”这一步。

第一步,盖住Trace例子的运行结果,按外层1、内层2列出四条构造/析构事件。先标出内层右花括号,再标main结束,最后才运行核对。

第一步提示与答案

建1→建2→销毁2→销毁1。代码对应build 1、build 2、destroy 2、destroy 1。只有内层对象应在内层块结束时退场。

第二步,从空函数体补Counter的try_add。保留合法上限10、初始used=0,要求加6成功、再加5拒绝、再加负数拒绝;每次都观察返回bool和查询结果。增加0应接受且状态不变。

第二步提示与答案

先拒绝负数,再比较amount是否大于limit_−used_,拒绝分支在写状态之前return false。其余才更新used_并返回true。三个固定调用对应1 60 60 6,不是只输出最后一个6就算检查了失败合同。

第三步,下面的错误实现检查的是总上限,不是剩余容量。先不用运行,算出加6、再加5之后的状态,再只改这一项检查。

#include <iostream>

class Counter {
    int limit_;
    int used_{0};

public:
    explicit Counter(int limit) : limit_{limit} {
    }

    int used() const {
        return used_;
    }

    bool try_add(int amount) {
        if (amount < 0) {
            return false;
        }
        if (amount > limit_) {
            return false;
        }
        used_ = used_ + amount;
        return true;
    }
};

int main() {
    Counter counter{10};
    bool first{counter.try_add(6)};
    bool second{counter.try_add(5)};
    std::cout << first << ' ' << second << ' ' << counter.used() << '\n';
    return 0;
}
第三步找错与答案

6和5分别都不超过总上限10,所以错误程序两次都接受,used成为11,输出1 1 11。正确比较amount与limit_−used_;第二次只剩4,必须拒绝,输出1 0 6。这是小整数范围内结果确定的逻辑错误,不靠运行未定义行为来说明问题。

第四步,关掉计数器源码,独立写上限固定10的Score。构造输入只接受题目约定的合法初始分数0–10;提供const查询和返回bool的try_add,负数及超出剩余空间的增加拒绝,0接受。初始0,依次增加3、0、8,每次查询应得到3、3、3。另用普通if检查三次返回值为true、true、false;失败返回1,不用尚未讲的测试框架。

第四步提示与参考:拒绝后留下的仍是同一个合法对象

先写构造与const查询,再写拒绝分支,最后才加更新语句。第一次后剩7;增加0不消耗它;增加8被拒绝,分数仍3。先保存自己的实现和结果,再对照完整参考。

#include <iostream>

class Score {
    int value_;

public:
    explicit Score(int initial) : value_{initial} {
    }

    int value() const {
        return value_;
    }

    bool try_add(int amount) {
        if (amount < 0) {
            return false;
        }
        if (amount > 10 - value_) {
            return false;
        }
        value_ = value_ + amount;
        return true;
    }
};

int main() {
    Score score{0};
    bool first{score.try_add(3)};
    std::cout << score.value() << '\n';
    bool second{score.try_add(0)};
    std::cout << score.value() << '\n';
    bool third{score.try_add(8)};
    std::cout << score.value() << '\n';
    if (first != true || second != true || third != false || score.value() != 3) {
        std::cout << "wrong result" << '\n';
        return 1;
    }
    return 0;
}

参考输出三行3,正常退出码0。相同的最终分数不能代替三个bool结果检查:接受0与拒绝0会留下相同分数,却不是同一个合同。

07.5 类型限定与命名状态 · 预估60分钟

有些查询为什么不需要先建立对象

一个固定的上限查询不依赖“当前用了多少”。我们怎样说明它属于某个类型的接口,又不假装它需要某个具体对象?随后再给程序的状态取名字,减少猜数字含义的负担。

类内的static int maximum()定义静态成员函数。本例函数直接返回10,不读取某个对象的普通成员。Limits::maximum()从Limits的类作用域寻找并调用它,::是作用域限定,不是取地址;调用前不需要创建Limits对象。

static成员函数没有this,不能凭空使用某个对象的value。它仍要遵守public/private访问规则。本章统一使用Type::function的写法,并不宣称语言禁止经对象表达式访问静态成员。这里只教静态函数,不提前加入静态数据成员的定义和链接问题。

enum class State { ready, busy };定义一种名为State的独立枚举类型,ready和busy是其中的命名值。写State::ready说明使用哪个类型范围内的名字。State state{State::ready};建立状态变量,之后可以赋为State::busy。

同类状态之间的比较,例如state == State::ready,产生已学的bool。本章先比较再输出文字,不直接把枚举值交给cout,也不把状态当作可随意混用的int。enum class不会隐式变成int或bool;不能把本例只使用两个名字误说成所有情况下枚举值都只能来自列出的名字。

#include <iostream>

class Limits {
public:
    static int maximum() {
        return 10;
    }
};

enum class State {
    ready,
    busy
};

int main() {
    std::cout << Limits::maximum() << '\n';
    State state{State::ready};
    if (state == State::ready) {
        std::cout << "ready" << '\n';
    }
    state = State::busy;
    if (state == State::busy) {
        std::cout << "busy" << '\n';
    }
    return 0;
}

静态查询输出10。state先为ready,对应分支输出ready;赋成busy后,对应分支输出busy。三个输出来自不同的明确操作,不需要猜ready或busy背后使用什么整数。

小练习:只把状态的初值改成State::busy

静态查询仍输出10;初始ready分支不进入。随后赋busy并通过busy比较,只输出一次busy。没有读取一个“默认状态数字”。

下面故意要求一个int直接接收State值。先判断这个转换是否在本节合同内,再只编译。

#include <iostream>

enum class State {
    ready,
    busy
};

int main() {
    State state{State::ready};
    int code{state};
    std::cout << code << '\n';
    return 0;
}
修复:如果任务只是判断是否ready

建立bool same{state == State::ready};,输出same。这里state确实为ready,所以输出1。没有把枚举强行变成int;我们改为使用问题真正需要的相等判断。

07.6 继承与运行时多态简介 · 仅认读30分钟

别人的代码为什么通过基类调用另一个实现

这一小节只帮助你在已有代码里认出继承、virtual和override。它不要求你独立编写继承体系,不设改代码练习,也不出现在本章六道核心复述题或G0要求中。公开教参仍由作者编译核对,技术校验不等于你的学习验收。

class Derived : public Base声明Derived从Base公开派生;这里的public明确允许下文从派生对象建立基类访问路径。virtual int read() const声明一个虚成员函数。Derived中同一签名的int read() const override提供覆盖实现;override请编译器核对它确实覆盖了基类的虚函数,不是一个让任意同名函数都能覆盖的开关。

对下面仍存活的Derived对象,可以用Base引用借用它的基类部分。这没有复制对象,也没有把实际对象变成另一份Base。经该引用调用虚函数read时,采用实际派生对象的覆盖实现。参数或const限定不匹配的同名函数,不能仅靠名字就认作这里的覆盖。

#include <iostream>

class Base {
public:
    virtual int read() const {
        return 1;
    }
};

class Derived : public Base {
public:
    int read() const override {
        return 2;
    }
};

int main() {
    Derived object;
    Base& view{object};
    std::cout << view.read() << '\n';
    return 0;
}

Base中的实现返回1,Derived中的实现返回2。main里的实际对象是Derived,所以这一次通过Base引用调用得到2。这里所有对象都是普通局部对象,没有动态分配或通过基类删除对象;不把虚表、纯虚函数或多态销毁扩展进本节。

回到本章主问题:合法的构造建立有效状态,公开更新在允许范围内改变它,失败时保留原值;普通退出时还要能按实际对象与成员顺序解释销毁过程。完成计数器与分数对象的独立练习,才有了下一章使用容器成员接口的基础。阅读过这页本身不会自动增加学习验收结果。

关掉参考,再做一次

把理解说出来

先完成正文的独立迁移,再回答下面六题。它们只检验第07.1–07.5节已授内容,不考核第07.6节的继承与多态简介。打开答案、编译成功或填写用时,都不会自动通过本章,更不代表通过 G0。

对象与成员预测

Reading a{4}、b{9},p指向a。经p调用set(7)时this是谁?若只把p初值改成&b,结果如何?

对照推理与英文回答

第一次this指向a,只把a.value从4改7,b保持9;对象各有自己的成员。改为指向b后,a保持4、b变7。成员函数只有一份定义不等于所有对象共享数据成员;指针借用也没有复制对象。

The current object is the one selected by the call. With p pointing to a, this points to a and only a's value becomes seven. Retargeting the initial pointer to b instead changes b. Sharing a member function definition does not make the data members shared.

初始化与追问

first_默认mark(1)、second_默认mark(2),构造列表只指定second_{mark(20)}。哪些调用实际发生?explicit单参数构造为什么不能用等号隐式初始化?

对照推理与英文回答

按成员声明顺序调用mark(1)、mark(20),然后执行构造体;mark(2)被该成员的显式初始化替代,不会先执行再覆盖。explicit构造可由Number n{6}明确选择,不能隐式用于Number n = 6;声明中的等号仍是初始化,不是给现有对象赋值。

Members initialize in declaration order. The explicit initializer for the second member replaces its default member initializer, so mark(2) is not called. An explicit constructor can be selected by direct initialization but cannot provide the implicit conversion in Number n = 6.

不变量与失败路径

上限10、已用6时,增加5、0、−1分别应该怎样返回?只把成员设为private能保证这个合同吗?

对照推理与英文回答

分别拒绝、接受、拒绝;三者都留下used=6,但bool应为false、true、false。必须比较剩余容量并在更新前拒绝。private限制谁能直接访问,不能纠正成员函数内部的错误条件。

Adding five is rejected, adding zero is accepted, and adding minus one is rejected. All three leave the value at six, but their boolean results differ. Private access does not prove an invariant; the implementation must validate before changing state.

const与借用找错

为什么const查询里的++used_应被拒绝?引用成员能否延长调用者整数的寿命?

对照推理与英文回答

本章const查询中的this是const Counter*,不能经该路径写普通used_成员;应只返回它。引用成员绑定调用者原有整数,不复制、不改绑,也不延长目标寿命;包装对象还在并不能证明目标可用。const路径和目标寿命是不同约束。

In this const member function, this points to a const Counter, so incrementing its ordinary data member through that path is not allowed. A reference member borrows its existing target without extending the target's lifetime. Read-only access and lifetime validity are separate conditions.

销毁顺序与边界

Wrapper按声明顺序包含Trace 1、2。销毁时wrapper end、destroy 1、destroy 2怎样排序?提前return会跳过已建立局部对象的析构吗?

对照推理与英文回答

先执行Wrapper析构体输出wrapper end,再逆序销毁成员输出destroy 2、destroy 1。正常return也销毁本次实际退出作用域内已完成构造的自动对象;这不意味着任何程序终止方式都会运行所有析构。完整对象开始析构与成员随后逐个销毁是不同阶段。

The Wrapper destructor body runs first, followed by its members in reverse construction order. The output is wrapper end, destroy 2, then destroy 1. A normal return also destroys constructed automatic objects in the scopes it exits. This is not a guarantee for every way a process can terminate.

限定名与命名状态

Limits::maximum()为何不需要Limits对象?State::ready为什么不能直接初始化int,该怎样判断是否ready?

对照推理与英文回答

maximum是静态成员函数,没有this,本例返回固定上限;::指定类作用域。enum class是独立类型,不隐式转换为int或bool。用state == State::ready取得bool,再按已学方式分支或输出比较结果。

The static member function has no current object, so it can be called through the class name. A scoped enum is a distinct type and does not implicitly convert to int or bool. Compare state with State::ready to obtain the boolean answer the task actually needs.

和正文是同一份源码

示例文件

先自己输入和预测,卡住时再下载对照。文件名相同不代表内容相同;把它们放在单独的练习目录中,避免覆盖自己的作品。

标为故意编译报错的程序只检查对应诊断,不运行危险访问,也不从偶然输出猜结果。完整验证记录随本地构建保留;这里的程序没有调用加速卡。

可选的学习反馈

记下你真正花的时间

每完成一个学习时段,再填实际分钟。环境准备、阅读推演、独立编码和卡点排查分别记录,避免同一段时间重复计算。离开吃饭或做其他事情的时间不算进去。

记录只保存在你的浏览器,可导出给我复盘。留空表示尚未记录,不等于零耗时;页面停留时间不会自动计为学习。不要把开发者检查时间填进来。

尚无真实试学用时。

    按开始学习本章前的情况选择;已经会 C++ 时选择“会 C++”。起点随每条时段保存,之后改选不会重标旧记录。旧记录缺少起点时单独保留;已有基础者的用时不用于校准零基础预算。

      换设备:导入记录,或取回损坏的旧记录

      导入会合并时段,相同编号不重复累加;发生冲突会保留现有记录。

      阅读记录与课程验收分别保存。

      本章资料与查证

      本章独立解释所需读法;资料用于核对与补充。工具版本、操作系统和实际执行状态见自己的运行记录。