这一章在干嘛?

面向对象编程的一大目标是代码重用。C 函数库靠”给你一堆现成函数”来重用,但函数没法扩展;C++ 的类继承更进一步:你可以从旧类派生出新类,新类自动拥有旧类的数据和方法,再按需添加或修改。本章讲的就是公有继承的完整玩法——怎么派生、构造函数怎么传递、什么是虚方法与动态绑定、什么是抽象基类,以及继承遇到动态内存分配时的注意事项。

13.1 一个简单的基类

13.2 派生一个类:RatedPlayer

13.3 派生类与基类的特殊关系

13.4 继承:is-a 关系

13.5 多态公有继承:Brass 与 BrassPlus

13.6 虚方法登场:一个数组管理多种账户

13.7 静态绑定与动态绑定

13.8 虚函数的工作原理与注意事项

13.9 访问控制:protected

13.10 抽象基类(ABC)

13.11 继承与动态内存分配

13.12 类设计复习要点

13.13 本章自测

13.1 一个简单的基类

继承中,被继承的类叫基类,继承别人的类叫派生类。Webtown 俱乐部要记录乒乓球会员,先写一个基类 TableTennisPlayer,只记姓名和是否有球台:

// tabtenn0.h -- 乒乓球基类
#ifndef TABTENN0_H_
#define TABTENN0_H_
#include <string>
using std::string;
 
class TableTennisPlayer
{
private:
    string firstname;
    string lastname;
    bool hasTable;
public:
    TableTennisPlayer(const string & fn = "none",
        const string & ln = "none", bool ht = false);
    void Name() const;
    bool HasTable() const { return hasTable; }
    void ResetTable(bool v) { hasTable = v; }
};
#endif
// tabtenn0.cpp -- 简单基类方法
#include "tabtenn0.h"
#include <iostream>
 
TableTennisPlayer::TableTennisPlayer(const string & fn,
    const string & ln, bool ht) : firstname(fn),
    lastname(ln), hasTable(ht) {}
 
void TableTennisPlayer::Name() const
{
    std::cout << lastname << ", " << firstname;
}

两个细节值得注意:第一,存名字用的是标准 string 类而不是字符数组,更安全也更省心;第二,构造函数用了成员初始化列表语法。如果改用在函数体内赋值,firstname 会先调用 string 默认构造函数、再调用赋值 operator 重置,多走一步;初始化列表直接用拷贝构造函数一步到位。

// usett0.cpp -- 使用基类
#include <iostream>
#include "tabtenn0.h"
 
int main(void)
{
    using std::cout;
    TableTennisPlayer player1("Chuck", "Blizzard", true);
    TableTennisPlayer player2("Tara", "Boomdea", false);
    player1.Name();
    if (player1.HasTable())
        cout << ": has a table.\n";
    else
        cout << ": hasn't a table.\n";
    player2.Name();
    if (player2.HasTable())
        cout << ": has a table.\n";
    else
        cout << ": hasn't a table.\n";
    return 0;
}

输出:Blizzard, Chuck: has a table. / Boomdea, Tara: hasn't a table.

传的是 C 风格字符串 "Chuck",形参却是 const string &——类型不匹配?没关系,string 类有一个 const char * 参数的构造函数,会自动把 C 字符串转成 string 对象。

13.2 派生一个类:RatedPlayer

俱乐部里有人参加比赛了,需要记录积分。不必从零写起,从 TableTennisPlayer 派生一个 RatedPlayer

// RatedPlayer 从 TableTennisPlayer 公有派生
class RatedPlayer : public TableTennisPlayer
{
private:
    unsigned int rating;    // 新增数据成员
public:
    RatedPlayer(unsigned int r = 0, const string & fn = "none",
        const string & ln = "none", bool ht = false);
    RatedPlayer(unsigned int r, const TableTennisPlayer & tp);
    unsigned int Rating() const { return rating; }
    void ResetRating(unsigned int r) { rating = r; }
};

冒号表示 RatedPlayer 基于基类 TableTennisPlayerpublic 表示公有派生。派生对象的两大特征:

  • 派生对象中存储着基类的数据成员(继承实现);
  • 派生对象可以使用基类的方法(继承接口)。

所以 RatedPlayer 对象能存姓名、能用 Name()HasTable()。派生类需要自己补充的是:必须有自己的构造函数,并可以随意添加新成员。

构造函数:访问权限的考虑

派生类不能直接访问基类的私有成员,必须通过基类方法。创建派生类对象时,程序先创建基类对象——概念上应在进入派生类构造函数体之前完成。C++ 用成员初始化列表做到这一点:

RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
    const string & ln, bool ht) : TableTennisPlayer(fn, ln, ht)
{
    rating = r;
}

: TableTennisPlayer(fn, ln, ht) 就是初始化列表,它调用基类构造函数,把 "Mallory""Duck"true 存进内嵌的基类对象,然后才执行 rating = r;

基类对象与派生类对象
▲ 图 13.1 基类对象与派生类对象:派生对象中内嵌着一个基类对象

通过初始化列表把参数传给基类构造函数
▲ 图 13.2 派生类构造函数把参数”接力”传给基类构造函数

如果省略初始化列表呢?基类对象反正要先创建,程序会自动调用基类的默认构造函数。所以除非你正好想要默认版本,否则应显式写出基类构造函数调用。

第二个构造函数接收一个现成的 TableTennisPlayer 对象:

RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp)
    : TableTennisPlayer(tp), rating(r)
{
}

tp 是基类引用,调用的是基类拷贝构造函数(编译器自动生成的逐成员拷贝版本,这里够用)。

构造与析构顺序

创建派生对象:先基类构造函数,后派生类构造函数。销毁派生对象:先派生类析构函数,后基类析构函数——顺序正好相反。

13.3 派生类与基类的特殊关系

派生类对象能调用基类的公有方法;此外还有两条更重要的规则——基类指针和引用可以在不显式转换的情况下指向/引用派生类对象

RatedPlayer rplayer1(1140, "Mallory", "Duck", true);
TableTennisPlayer & rt = rplayer1;  // 基类引用派生对象:OK
TableTennisPlayer * pt = &rplayer1; // 基类指针指向派生对象:OK
rt.Name();  // 用引用调用
pt->Name(); // 用指针调用

但基类指针/引用只能调用基类方法,不能调用派生类新增的 Rating()。反过来不成立:不能把基类对象赋给派生类引用/指针——否则就能对基类对象调用 Rating(),而它根本没有 rating 成员。

这条”引用兼容”规则带来几个实用推论:

  • 函数形参是基类引用/指针时,既能接收基类对象也能接收派生类对象;
  • 可以用派生对象初始化基类对象(只拷贝基类部分);
  • 可以把派生对象赋值给基类对象(同样只涉及基类成员)。
void Show(const TableTennisPlayer & rt); // 一个函数通吃两种对象
TableTennisPlayer player1("Tara", "Boomdea", false);
RatedPlayer rplayer1(1140, "Mallory", "Duck", true);
Show(player1);   // OK
Show(rplayer1);  // OK——派生对象自动"向上"匹配基类引用

13.4 继承:is-a 关系

公有继承建模的是 is-a 关系:派生类对象也应当是一种基类对象。香蕉是一种水果,所以 Banana 可以从 Fruit 派生;但关系不必对称——水果不是香蕉,基类对象不具备派生类的全部能力。

同样重要的是认清哪些关系不该用公有继承建模:

  • has-a(有一个):午餐里有水果,但午餐不是水果。正确做法是把 Fruit 对象作为 Lunch 的数据成员(包含/组合,见第 14 章)。
  • is-like-a(像):律师像鲨鱼,但律师不是鲨鱼。继承只能给基类添加属性,不能删除属性,所以”像”不能用继承表达。
  • is-implemented-as-a(按……实现):用数组实现栈,但栈不是数组。应把 Array 对象作为 Stack 的私有成员隐藏实现。
  • uses-a(使用):电脑用打印机,但互相不是对方的一种。可用友元函数处理两者通信。

is-a 与 has-a 关系的区别
▲ 图 13.3 is-a 用公有继承表达;has-a 用”包含一个成员对象”表达

13.5 多态公有继承:Brass 与 BrassPlus

派生类直接沿用基类方法是最简单的情况。更进阶的需求是:同一个方法名,对不同类型的对象表现出不同行为——这就是多态(polymorphic,“多种形态”)。实现多态公有继承有两个关键机制:在派生类中重新定义基类方法,以及使用虚方法

银行例子:Brass 是普通支票账户,BrassPlus 是带透支保护的支票账户。BrassPlus 满足 is-a 测试吗?当然——Brass 有的它都有,还要额外存透支上限、贷款利率、欠款额;取款和显示两个操作的实现也不同。

// brass.h -- 银行账户类
#ifndef BRASS_H_
#define BRASS_H_
#include <string>
 
// Brass 账户类
class Brass
{
private:
    std::string fullName;
    long acctNum;
    double balance;
public:
    Brass(const std::string & s = "Nullbody", long an = -1,
        double bal = 0.0);
    void Deposit(double amt);           // 存款:两个类行为相同
    virtual void Withdraw(double amt);  // 取款:虚方法
    double Balance() const;
    virtual void ViewAcct() const;      // 显示:虚方法
    virtual ~Brass() {}                 // 虚析构函数
};
 
// Brass Plus 账户类
class BrassPlus : public Brass
{
private:
    double maxLoan;   // 透支上限
    double rate;      // 贷款利率
    double owesBank;  // 当前欠银行的钱
public:
    BrassPlus(const std::string & s = "Nullbody", long an = -1,
        double bal = 0.0, double ml = 500, double r = 0.11125);
    BrassPlus(const Brass & ba, double ml = 500, double r = 0.11125);
    virtual void ViewAcct() const;      // 重新定义
    virtual void Withdraw(double amt);  // 重新定义
    void ResetMax(double m) { maxLoan = m; }
    void ResetRate(double r) { rate = r; }
    void ResetOwes() { owesBank = 0; }
};
#endif

三个要点:

  1. ViewAcct()Withdraw() 在两个类里各有一份定义,限定名分别是 Brass::ViewAcct()BrassPlus::ViewAcct(),用对象类型决定用哪份。
  2. 基类里这两个方法加了 virtual。虚方法决定:通过引用或指针调用时,按引用/指针所指向的对象类型选择方法版本,而不是按引用/指针自身的类型。不重写的 Deposit()Balance() 只在基类声明一份。
  3. 基类声明了虚析构函数——即使它什么都不做(原因见 13.8 节)。

方法实现里的两个标准技巧。派生类构造函数用初始化列表把基类数据交给基类构造函数:

BrassPlus::BrassPlus(const string & s, long an, double bal,
    double ml, double r) : Brass(s, an, bal)
{
    maxLoan = ml;
    owesBank = 0.0;
    rate = r;
}

普通派生类方法则用作用域解析运算符调用基类版本。BrassPlus::ViewAcct() 先让基类版本显示基类数据,再显示新增数据:

void BrassPlus::ViewAcct() const
{
    // ……格式设置省略
    Brass::ViewAcct();                  // 显示基类部分
    cout << "Maximum loan: $" << maxLoan << endl;
    cout << "Owed to bank: $" << owesBank << endl;
    cout << "Loan Rate: " << 100 * rate << "%\n";
}

常见坑:重定义方法里忘了作用域解析运算符

如果在 BrassPlus::ViewAcct() 里直接写 ViewAcct();,编译器会认为你在调用 BrassPlus::ViewAcct() 自己——一个没有出口的无限递归。重定义方法内调用基类版本时,必须写 Brass::ViewAcct();

BrassPlus::Withdraw() 演示了透支逻辑:余额够就走基类取款;不够但在透支额度内,就先 Deposit(advance) 垫资(把贷款存进去),再调 Brass::Withdraw(amt) 完成取款——绕开了基类取款方法里”余额不足报错”的检查:

void BrassPlus::Withdraw(double amt)
{
    double bal = Balance();
    if (amt <= bal)
        Brass::Withdraw(amt);
    else if (amt <= bal + maxLoan - owesBank)
    {
        double advance = amt - bal;
        owesBank += advance * (1.0 + rate);
        cout << "Bank advance: $" << advance << endl;
        cout << "Finance charge: $" << advance * rate << endl;
        Deposit(advance);
        Brass::Withdraw(amt);
    }
    else
        cout << "Credit limit exceeded. Transaction cancelled.\n";
}

Balance() 不用加作用域解析,因为它没被派生类重新定义。

13.6 虚方法登场:一个数组管理多种账户

前一个测试程序都是用对象直接调用方法,还体现不出虚方法的威力。想用一个数组同时管理 BrassBrassPlus 账户?数组元素必须同类型,但基类指针数组可以——每个 Brass * 既能指向 Brass 也能指向 BrassPlus

// usebrass2.cpp -- 多态示例(节选核心)
const int CLIENTS = 4;
Brass * p_clients[CLIENTS];
// ……循环里按用户输入决定:
if (kind == '1')
    p_clients[i] = new Brass(temp, tempnum, tempbal);
else
    p_clients[i] = new BrassPlus(temp, tempnum, tempbal, tmax, trate);
// ……
for (int i = 0; i < CLIENTS; i++)
{
    p_clients[i]->ViewAcct();   // 按对象类型选择版本!
    cout << endl;
}
for (int i = 0; i < CLIENTS; i++)
    delete p_clients[i];        // 释放内存

同一个循环语句 p_clients[i]->ViewAcct(),指向 Brass 时执行 Brass::ViewAcct(),指向 BrassPlus 时执行 BrassPlus::ViewAcct()。如果 ViewAcct() 不是虚方法,则所有情况都只会执行 Brass::ViewAcct()——因为程序按指针类型(Brass *)做选择。这就是多态的实质。

这里也体现了虚析构函数的必要性:delete p_clients[i] 时,若析构函数不是虚的,则只按指针类型调用 ~Brass();是虚的,才按对象类型先调 ~BrassPlus() 再自动调 ~Brass()。本例析构函数都为空看不出差别,但若派生类析构函数要释放 new 来的内存,漏掉 virtual 就是内存泄漏。

13.7 静态绑定与动态绑定

把源代码里的函数调用解释为执行某一段函数代码,叫绑定(binding)。编译阶段就完成的绑定叫静态绑定(或早期绑定);虚方法导致必须运行时才能决定的绑定叫动态绑定(或晚期绑定)。

为什么编译器决定不了?因为编译器不知道用户运行时会往基类指针里放哪种对象。

指针与引用的类型兼容:向上/向下转换

  • 向上转换(upcasting):派生类指针/引用 → 基类指针/引用。公有继承下总是允许、无需显式转换,且可传递(BrassPlusPlus 也能转给 Brass *)。这是 is-a 关系的直接体现。
  • 向下转换(downcasting):基类指针/引用 → 派生类指针/引用。不允许隐式进行——派生类可能新增了成员,对基类对象调用派生类方法毫无意义。

向上转换与向下转换
▲ 图 13.4 向上转换总是安全;向下转换必须显式进行且可能不合理

按值传参会发生”截断”:把 BrassPlus 对象按值传给 void fv(Brass b),只有 Brass 部分被拷贝,多态失效。而按引用/指针传递时隐式向上转换发生,虚方法仍能正确选择版本——这就是”处理类对象应尽量按引用传递”的又一理由

为什么不把动态绑定设为默认?

两个原因:效率概念模型。动态绑定要求程序额外跟踪指针指向的对象类型(虚函数表等机制),有内存与速度开销。C++ 的设计原则是”不为用不到的特性付费”;同时,把方法声明为非虚也是对编译器和使用者的一种声明:这个方法不打算被重定义(如 Brass::Balance())。

什么时候用 virtual

基类方法若预期在派生类中被重新定义,就声明为 virtual;不希望被重定义的,就别加。

13.8 虚函数的工作原理与注意事项

实现机制:虚函数表

编译器给每个对象添加一个隐藏成员——指向**虚函数表(vtbl)**的指针。vtbl 是该类所有虚函数地址组成的数组:基类对象指向基类的表;派生类若重定义了某虚函数,自己的表里就存新地址;没重定义就存原地址。调用虚函数时,程序经对象的表指针查表、按位置取地址、跳转执行。

虚函数机制示意图
▲ 图 13.5 虚函数机制:对象 → vptr → vtbl → 函数地址

代价有三:每个对象多存一个地址;每个类多一张表;每次调用多一次查表。

几条必须记住的规则

  • 构造函数不能是虚的:派生类不继承基类构造函数,创建对象时调用的是派生类自己的构造函数。
  • 析构函数应当是虚的:只要类可能作基类,就给析构函数加 virtual——哪怕它什么都不做(virtual ~BaseClass() {})。这样 delete 基类指针时才能触发正确的析构序列。
  • 友元不能是虚的:友元不是类成员,只有成员才能是虚函数;可让友元内部改调虚成员函数绕开。
  • 重新定义将隐藏方法:派生类重定义基类方法时,隐藏的是基类所有同名方法,不管参数签名是否相同——重定义不是重载!

常见坑:重定义隐藏了基类的重载版本

基类有 showperks(int)showperks(double)showperks() 三个重载,派生类只重定义了无参版本,那么另外两个对派生对象就不可用了。要么把基类所有版本都重新定义(不需要改动的版本可以只转调基类:void Hovel::showperks() const { Dwelling::showperks(); }),要么干脆别改参数签名。唯一例外是返回类型协变:基类返回 Dwelling &,派生类可改为返回 Hovel &,前提是 HovelDwelling 的派生类。

13.9 访问控制:protected

protectedprivate 对外界一样不可访问;区别只在派生类内部:派生类成员可以直接访问基类的 protected 成员,却不能直接访问 private 成员

如果把 Brass::balance 改为 protected,BrassPlus::Withdraw() 可以直接写 balance -= amt;,代码更简短。但这是有设计代价的——BrassPlus 可以写一个 Reset(double amt) { balance = amt; },绕开 Deposit()/Withdraw() 的全部安全检查,等于把 balance 变相公开了。

常见坑:滥用 protected 数据成员

Stroustrup 的建议是:数据成员宁可 private 也不要 protected;派生类要访问数据,请走基类方法。protected 更适合用在成员函数上——给派生类提供内部工具,又不暴露给公众(13.10 节的 AcctABC 就是范例)。

13.10 抽象基类(ABC)

有些时候 is-a 不好套:圆是椭圆的特例,但从 Ellipse 派生 Circle 很别扭——圆只需要半径,却被迫背上长轴、短轴、旋转角;Scale() 还可能把圆缩放成非圆。更好的思路是把两者的公共部分(圆心坐标、Move()Area())抽出来放进抽象基类,让 CircleEllipse 都从它派生。

问题是:ABC 里没有数据,Area() 没法实现。C++ 的答案是纯虚函数——声明末尾加 = 0

class BaseEllipse // 抽象基类
{
private:
    double x;   // 圆心 x 坐标
    double y;   // 圆心 y 坐标
public:
    BaseEllipse(double x0 = 0, double y0 = 0) : x(x0), y(y0) {}
    virtual ~BaseEllipse() {}
    void Move(int nx, int ny) { x = nx; y = ny; }
    virtual double Area() const = 0; // 纯虚函数
};

类中只要有一个纯虚函数,它就是抽象类,不能创建对象——它存在的唯一意义是作基类。纯虚函数甚至可以提供定义(在实现文件里写 BaseEllipse::Move(...)),= 0 只是声明”本类是抽象的、函数可以不定义”。能创建对象的派生类(如 EllipseCircle)称为具体类

银行例子重构:把 BrassBrassPlus 的公共部分抽成 AcctABC。它提供 protected 方法 FullName()AcctNum()SetFormat()Restore() 供派生类访问数据与格式化,Withdraw()ViewAcct() 声明为纯虚函数:

// acctabc.h -- 银行账户类(节选)
class AcctABC
{
private:
    std::string fullName;
    long acctNum;
    double balance;
protected:
    struct Formatting { std::ios_base::fmtflags flag; std::streamsize pr; };
    const std::string & FullName() const { return fullName; }
    long AcctNum() const { return acctNum; }
    Formatting SetFormat() const;
    void Restore(Formatting & f) const;
public:
    AcctABC(const std::string & s = "Nullbody", long an = -1,
        double bal = 0.0);
    void Deposit(double amt);
    virtual void Withdraw(double amt) = 0; // 纯虚函数
    double Balance() const { return balance; }
    virtual void ViewAcct() const = 0;     // 纯虚函数
    virtual ~AcctABC() {}
};
 
class Brass : public AcctABC
{
public:
    Brass(const std::string & s = "Nullbody", long an = -1,
        double bal = 0.0) : AcctABC(s, an, bal) {}
    virtual void Withdraw(double amt);
    virtual void ViewAcct() const;
    virtual ~Brass() {}
};
 
class BrassPlus : public AcctABC { /* 新增 maxLoan/rate/owesBank 及重定义 */ };
#endif

使用方式几乎不变:把管理数组的类型换成 AcctABC * p_clients[CLIENTS];,其余代码原样运行。ABC 的哲学是强制接口:纯虚函数逼迫每个具体派生类给出自己的实现,相当于组件设计中”所有派生类必须兑现的接口契约”。

通关标准:

能独立回答并演示:①派生对象的构造/析构顺序及初始化列表的写法;②基类指针/引用指向派生对象后,虚与非虚方法分别调用哪个版本;③为什么基类析构函数要 virtual;④纯虚函数与抽象类的关系;⑤重新定义方法为什么会隐藏基类重载版本。

13.11 继承与动态内存分配

基类用了 new(因而定义了析构函数、拷贝构造函数、赋值运算符)时,派生类怎么办?分两种情况。

情况一:派生类不用 new

派生类(如 lacksDMA,只有 char color[40] 数组成员)什么都不用做:默认析构函数执行完自己的代码后会自动调用基类析构函数;默认拷贝构造函数对新增成员逐成员拷贝,对继承的基类部分则调用基类的拷贝构造函数;默认赋值运算符同样自动调用基类赋值运算符。

情况二:派生类也用 new

派生类(如 hasDMA,有 char * style)必须显式定义三件套,且都要”照顾到基类部分”:

// dma.h -- 继承与动态内存分配(节选)
class baseDMA
{
private:
    char * label;
    int rating;
public:
    baseDMA(const char * l = "null", int r = 0);
    baseDMA(const baseDMA & rs);
    virtual ~baseDMA();
    baseDMA & operator=(const baseDMA & rs);
    friend std::ostream & operator<<(std::ostream & os, const baseDMA & rs);
};
 
class hasDMA : public baseDMA
{
private:
    char * style;
public:
    hasDMA(const char * s = "none", const char * l = "null", int r = 0);
    hasDMA(const hasDMA & hs);
    ~hasDMA();
    hasDMA & operator=(const hasDMA & rs);
    friend std::ostream & operator<<(std::ostream & os, const hasDMA & hs);
};

析构函数:各管各的,基类部分自动处理——

baseDMA::~baseDMA() { delete [] label; }  // 管基类资源
hasDMA::~hasDMA()   { delete [] style; }  // 只管派生类新增资源

拷贝构造函数:派生类拷贝构造函数只能访问派生类数据,基类部分要靠初始化列表显式调用基类拷贝构造函数

hasDMA::hasDMA(const hasDMA & hs) : baseDMA(hs) // 调用基类拷贝构造
{
    style = new char[std::strlen(hs.style) + 1];
    std::strcpy(style, hs.style);
}

baseDMA(hs) 能编译是因为基类引用可以引用派生对象——基类拷贝构造函数用 hs 的基类部分来构造新对象的基类部分。

赋值运算符:用作用域解析显式调用基类赋值运算符。注意必须用函数表示法而不能写 *this = hs;——后者会被解释为 hasDMA::operator=(),无限递归:

hasDMA & hasDMA::operator=(const hasDMA & hs)
{
    if (this == &hs)
        return *this;
    baseDMA::operator=(hs);       // 拷贝基类部分(函数表示法)
    delete [] style;              // 释放旧内存
    style = new char[std::strlen(hs.style) + 1];
    std::strcpy(style, hs.style);
    return *this;
}

友元:派生类的友元 operator<<() 想输出基类部分,可以把派生引用强制转换为基类引用,让原型匹配选中基类的友元版本:

std::ostream & operator<<(std::ostream & os, const hasDMA & hs)
{
    os << (const baseDMA &) hs;   // 转换以匹配基类友元版本
    os << "Style: " << hs.style << std::endl;
    return os;
}

(更好的写法是第 15 章的 dynamic_cast<const baseDMA &>(hs)。)

13.12 类设计复习要点

  • 编译器自动生成的特殊成员:默认构造函数(没定义任何构造函数时生成);拷贝构造函数(按值传参、按值返回、初始化同型对象时使用,逐成员初始化);赋值运算符(已有对象间赋值,逐成员赋值)。用了 new 的类三者都可能要自己写。
  • 构造函数不继承、析构函数不继承、赋值运算符不继承:基类构造器负责基类部分,派生类构造器通过初始化列表调用它;赋值运算符的参数类型随类而变,天然无法继承。
  • 转换与 explicit:单参数构造函数会充当隐式转换函数,必要时用 explicit 关闭隐式转换;反向转换靠转换函数(如 operator double()),也要慎用、避免二义性。
  • 按引用传参、按引用返回:省去拷贝构造与析构的开销,还保留多态。例外:函数内创建的临时对象不能返回引用(函数一结束对象就没了),必须按值返回,如 Vector operator+(const Vector & b) const { return Vector(x + b.x, y + b.y); }
  • const 的三处使用:保证不修改指向的数据(const char * s)、保证不修改调用对象(void show() const)、保证返回的引用不被用于修改对象(const Stock & topval(...) const)。
  • 按值传递派生对象会切片:形参 void inadequate(Brass ba) 收到 BrassPlus 时只拷贝 Brass 部分,虚方法也失效——传引用才能保住多态。
  • 派生→基类赋值:允许,只拷贝基类部分;基类→派生类赋值:默认不允许,除非派生类有转换构造函数 BrassPlus(const Brass &, ...) 或专门定义了赋值运算符。

13.13 本章自测