pusidun
← All posts

侯捷 C++ 类型转换:从 Fraction 双向转换到 bool 代理

本门课程目录 · 侯捷 C++ 六门课程总目录

一个表示五分之三的对象,能否直接参加 4 + f?直觉上当然可以:五分之三就是 0.6。但接下来还有另一种同样自然的解释:先把整数 4 看成分子为 4、分母为 1 的分数,再做分数加法。这两种便利一旦同时进入类的接口,编译器未必能够选出唯一的操作。

P2–3 用同一个 Fraction 不断增删成员,最后转向标准库的 vector<bool>。这组课真正值得学的是:隐式转换把一部分决定交给了重载解析;设计者需要知道自己开放了哪个方向,以及调用者最终得到的是数值还是仍然联系着原对象的代理。

来源档案:讲者为侯捷;片头原课名为《C++程序設計 (II) 兼談對象模型》(C++ Programming (part II), and Object Model),画面带 Boolan 博览与云课堂标识。本次依据 B 站账号 DetachmentSy2025 年上传的《侯捷 - C++面向对象高级开发(下)》整理,标识 BV1kBh5zrEWL,共 24 个上传分 P。原讲者与上传者分别署名;原始录制年份及原课程完整性未核实

本文对应 P2《conversion function》与 P3《non-explicit one argument constructor》,核对日期 2026-09-12。已通读两讲完整音轨转录并核对抽样课件;ASR 的术语与代码以画面校正。P3 原始转录的一条零长度提示词保留在原文中,从规范化 SRT 中排除,不承担本文时间证据。本文是 AI 辅助初加工的学习笔记,现代修正和原创实验单独标明;回看时间仅对应此上传版本。

先修与读法:分清表达式和接收结果的变量

需要先理解构造函数、默认实参、const 成员函数、引用参数和成员运算符重载。读本篇时,始终把 Fraction d2 = f + 4; 拆成两个问题:右边的加法是否有唯一合法解释?得到结果以后,能否用它初始化左边的 Fraction

这也是从下册导读延续下来的接口问题。类的成员不仅说明“可以主动调用什么”,还可能允许编译器在别的表达式里隐式调用它们。新增一个看似无害的转换成员,会影响原先已经存在的重载集合。

第一版:分数向外转成 double

P2 的类保存两个 int:分子和分母。构造函数接受 num 与默认值为 1 的 den,转换函数则写成这样的形式:

// 结构片段:修正了课件里的整数除法,省略其他成员。
operator double() const {
    return static_cast<double>(m_numerator) / m_denominator;
}

operator double 的名字已经说明目标类型,前面不再写一个返回类型,也没有普通参数。后面的 const 表示转换不修改这个分数,让 const Fraction 也能使用这一操作。侯捷明确提醒:这里的 const 是合理的接口设计,不是转换函数语法强制要求。

在这一版里没有分数加法成员。面对 double d = 4 + f;f 可以经过 operator double() 变成浮点数,然后参加内建算术。课堂用“编译器寻找走得通的路”建立直觉;更准确地说,是候选操作、可行性与重载选择共同决定表达式,而不是编译器按讲述顺序试错,找到第一条就停止。

P2 末尾还说明,一个类可以提供多个目标类型的转换,不局限于基本类型。设计者觉得数学上或业务上“说得通”,只是开放转换的起点;它是否带来精度损失、意外重载或隐藏成本,仍须结合使用场景判断。

课件中的括号会改变计算结果

画面核对后的修正:P2 课件实际写的是 return (double)(m_numerator / m_denominator);,两个成员又都是 int。因此先做 3 / 5 得到整数 0,再转成 double,结果是 0.0;按这段代码执行,4 + f 得到 4.0。口述在 P2 07:33–07:56 描述的 0.6 与 4.6,表达的是期望结果,和显示的代码不一致。

要得到课堂想要的结果,必须在除法之前把至少一个操作数转成浮点类型,正如上面的修正版。强转不能找回整数除法已经丢掉的小数部分。标准草案:乘除运算

另外,三分之一仍是有理数;不能把“十进制除不尽”理解为无理数。将分子、分母表示转成 double,还可能失去精确性。这是数值表示的限制,不是写上转换函数以后就消失的问题。

第二版:去掉向外转换,允许整数向内构造

P3 开头先移除 operator double(),加入接受分数参数的成员加法,同时保留非 explicit 的构造函数:

// 课堂结构片段:加法实现原课以省略号表示。
Fraction(int num, int den = 1);
Fraction operator+(const Fraction& other);

现在 Fraction d2 = f + 4; 可以理解为 f.operator+(Fraction(4))。右侧的 4 经构造函数成为 4/1,成员加法处理两个分数,返回另一个分数。按通常的通分计算,3/5 加 4/1 得到 23/5。

标题里的 one argument 指“一份实参就够用”:声明有两个形参,但第二个有默认值。这与“只能声明一个形参”不同。现代术语可称它为转换构造函数;非 explicit 构造函数所能涉及的初始化规则比这一个历史标题更广,不应把规则永久限定为单实参这一种形式。标准草案:构造函数转换

还有一个容易漏掉的不对称:这个版本只有成员 operator+,因此 f + 4 能让右操作数转成 Fraction,不表示 4 + f 也会先把左侧的整数变成对象,再寻找它的成员。要提供对称的数值接口,可以另行设计非成员加法;这属于后续接口设计选择,不能当作此版课堂代码已经具备的能力。

第三版:两条方向一起开放,为什么加法二义

接下来,课件把 operator double() 加回来。此时 f + 4 同时遇到两组候选:

路径 左侧 f 右侧 4 计算结果
分数成员加法 直接使用分数对象 用户定义转换成 Fraction Fraction
内建算术 用户定义转换成 double 按内建算术规则参与计算 double

一条路在左侧更直接,另一条路避免右侧的用户定义转换,没有一条在所有参数上都不差于另一条并满足胜出条件,因此该例无法选出唯一最佳操作。并不是只要有两个可行候选,就一定二义;通常的重载解析本来就会比较多个候选。标准草案:最佳可行函数

课堂在 P3 09:38–10:21 也主动提醒:不能只看类里同时存在两个转换方向,就断言所有用法都错误,是否有加法成员、使用的表达式是什么,都会改变结论。

这里还不能靠左边写着 Fraction d2,就要求编译器替我们优先选择“返回 Fraction 的加法”。本例必须先解决右侧 f + 4 的重载,目标变量类型不会替这两个加法候选裁决。实验把整句简化成 auto x = f + 4;,仍然得到加法二义的诊断,直接验证了错误位置。

第四版:explicit 关闭一个入口,但没有关闭出口

课件随后只把构造函数改成 explicit Fraction(int num, int den = 1),仍保留非 explicitoperator double() 和成员加法。

于是,4 不能再隐式构造成成员加法所需的 Fraction。但 f 仍可以转成 double,所以内建加法还能执行,f + 4 的类型是 double。此时 Fraction d2 = f + 4; 出错,是因为这个浮点结果不能通过 explicit 构造函数隐式初始化为 Fraction

对口述的精确修正:P3 11:55–12:33 用“加法失败”解释这一页;但同页显示的报错已经指向从 doubleFraction 的转换。应区分“成员分数加法这条路不可行”和“整个加法表达式不可行”。前者成立,后者在这一版代码中不成立。

下面是整理者原创的完整 C++20 实验,补齐了课堂省略的分数加法,并保留该页的转换方向。它验证浮点加法和显式分数加法分别成立。

#include <cassert>
#include <cmath>
#include <stdexcept>
#include <type_traits>

class Fraction {
    int numerator_;
    int denominator_;
public:
    explicit Fraction(int n, int d = 1) : numerator_(n), denominator_(d) {
        if (d == 0) throw std::invalid_argument("zero denominator");
    }
    operator double() const {
        return static_cast<double>(numerator_) / denominator_;
    }
    Fraction operator+(const Fraction& other) const {
        return Fraction{numerator_ * other.denominator_ +
                        other.numerator_ * denominator_,
                        denominator_ * other.denominator_};
    }
};

int main() {
    const Fraction f{3, 5};
    static_assert(std::is_same_v<decltype(f + 4), double>);
    const double approximate = f + 4;
    assert(std::abs(approximate - 4.6) < 1e-12);

    const Fraction exact = f + Fraction{4};
    assert(std::abs(static_cast<double>(exact) - 4.6) < 1e-12);
    static_assert(!std::is_convertible_v<double, Fraction>);

    assert(static_cast<double>(3 / 5) == 0.0);
    bool caught = false;
    try { Fraction invalid{1, 0}; }
    catch (const std::invalid_argument&) { caught = true; }
    assert(caught);
}

这个类型只用于小整数实验,没有约分、规范化符号或整数溢出检查,不是生产级有理数库。零分母检查用于避免无效状态;它并不能解决所有数值边界。浮点断言使用误差范围,也不把 double 近似等同于精确分数。

上述程序已用 Apple clang 21、-std=c++20 -Wall -Wextra -pedantic-errors 编译运行。另两个独立负例分别验证:移除构造函数的 explicit 后,f + 4 报二义;保留它并加入 Fraction d2 = f + 4;,报无法从 double 转成 Fraction

现代接口还可以怎样收紧

如果设计目标是让调用者主动决定何时丢失分数表示,可以把出口也写成 explicit operator double() const,或提供 to_double()。这时 f + 4 不再拥有刚才的浮点退路,需要明确写 f + Fraction{4}static_cast<double>(f) + 4。构造方向与转换方向须分别设计,explicit 不是对整个类施加的总开关。标准草案:转换函数

这也修正了课上关于 explicit 使用位置的历史性概括:C++11 已允许显式转换函数,C++20 还支持条件 explicit。布尔语境另有细节,explicit operator bool() 可以用于 if (obj),却不因此允许普通的 bool b = obj 复制初始化。先把两种语境分清,不必为了记忆方便把它们合并为“都可以隐式转”。标准草案:explicit 说明符布尔语境转换

从数值转换走到代理:vector 为什么不直接返回 bool&

P3 最后没有再发明一个玩具数值类,而是打开 vector<bool> 的旧实现课件:operator[] 返回名为 reference 的类型,这个名字通过 typedef 指向 __bit_reference 类;后者出现 operator bool() const。课堂借此说明,转换函数可以让一个代理在读取时表现得像被代理的值。

现代补充vector<bool> 是为节省空间而提供的特化,标准不要求它按连续的 bool 对象存储。若把多个真假值压进同一个存储单元,一个位没有独立的 bool 对象地址,普通 bool& 就不能直接表达对它的访问。代理对象既可以读取该位并转成 bool,也可以通过赋值更新它。标准草案:vector

课件显示的内部名字、指针类型和掩码字段属于所展示的库实现,不能写成标准规定的布局。这里应当记住公开的 std::vector<bool>::reference 契约,而不是在自己的程序里使用 __bit_reference

本站接口隔离:代理、适配器等边界讨论的服务代理,通过相同的服务接口控制真实对象的访问。本例把同一思路压缩到一次元素访问:返回一个代表底层位的对象。不过它无需虚函数,也不意味着所有代理都应该提供隐式转换。能读成 bool 只是这个代理服务于位访问的具体需要。

auto 取到的可能是代理,bool 才是此刻的值

下面这个原创 C++20 完整实验已编译运行,关键是中途不让容器重分配,也不销毁或移除被代理的元素。

#include <cassert>
#include <type_traits>
#include <vector>

int main() {
    std::vector<bool> bits{true, false};
    auto proxy = bits[0];
    bool snapshot = bits[0];
    static_assert(!std::is_same_v<decltype(proxy), bool>);

    proxy = false;
    assert(!bits[0]);
    assert(snapshot);

    bits[0] = true;
    assert(static_cast<bool>(proxy));
}

auto proxy 复制的是代理对象,仍代表原来的位;bool snapshot 则触发转换,保存这一刻的真假值。第一次赋值经代理把底层位改为假,快照仍为真;第二次通过容器改回真,再读代理也能观察到真。这里不存在“auto 总会复制独立值”的保证。

保存代理时必须考虑容器与元素的有效期;需要独立结果就明确保存 bool。要求真实 bool& 或连续 bool 缓冲区的接口,也不能仅因为 bits[i] 看起来像普通元素访问,就假定能够接入。这与服务代理的边界一致:保持部分调用形式,不意味着底层行为、有效期和成本完全相同。

重点回看

时间区间依据相应分 P 的音轨时间轴,关键词另与课件核对;秒级显示方便定位,不代表 ASR 边界无误差。链接携带分 P 与秒数参数;播放器若未自动定位,可按表中时间手动回看,播放验证限制见本门导读。

讲次与时间 课件或讨论线索 值得回看的原因
P2 · 01:53–04:45 黄色 operator double() const 理解转换函数的名字、参数与 const 接口
P2 · 05:23–07:56 double d = 4 + f、口述 0.6 对照整数除法括号,区分意图与显示代码
P3 · 00:36–02:49 den = 1、one argument 区分实参数量和形参数量
P3 · 03:40–06:29 Fraction d2 = f + 4 追踪整数经构造函数成为分数的过程
P3 · 07:20–10:21 两条转换与 ambiguous 看候选竞争,也看讲者对“并存必错”的限定
P3 · 10:26–12:33 explicit、double→Fraction 诊断 把成员加法不可行与结果初始化失败分开
P3 · 14:00–16:59 reference、__bit_referenceoperator bool 从语法走到标准库代理的读取接口

可检验的思考题

  1. 把完整 Fraction 例子的出口也改成 explicit operator double(),先预测 f + 4f + Fraction{4}static_cast<double>(f) + 4 三行的结果,再分别编译。检验重点是区分两个转换方向,不能只统计 explicit 的个数。
  2. 在“非 explicit 构造、只有成员加法、没有 double 转换”的第二版中尝试 4 + f。若编译失败,增加一个非成员加法是否能表达对称性?先画出两个参数各自的转换,再决定哪些隐式转换真的符合你的接口目标。
  3. 在代理实验中把 auto proxy 改成 bool proxy。预测修改 proxy 后哪一个断言改变,再运行验证。扩展问题:函数返回一个 auto 推导的代理,却让局部容器先销毁,会失去什么前提?不要通过执行失效代理来“验证”未定义行为。

上一篇:下册导读。继续阅读本门目录时,可把同一个问题带到后续类接口:对象模仿一种熟悉的东西时,调用形式相似究竟承诺了哪些行为?

下一篇:指针式类与函数对象