跳过正文

丙加·第11章· Alice Class in Cradle(类与简单的继承)

·1785 字·9 分钟·
目录

11.1 沉默对象的秘密
#

对象必须通过公开成员,才能被外部方法操作。但有一个对象,却打破了这个定律。七贤人 你要问哪七个?抱歉我懒得编了orz ,塞普拉王国最强的几位魔法师,其中之一是‌‌‌‌‍‌‌ ‌‌‌‌‍‌‌‬‌‌‌‌‍‬‬‬‌‌‌‌‍‬‌‍‌‌‌‌‍‌‬‌‌‌‌‍‬‬‌‌‌‌‍‬‍‍‌‌‌‌‌‬‌‌‌‌‌‌‍‍‌‌‌‌‌‍‍‌‌‌‌‌‍‌‬‌‌‌‌‍‬‌‌‌‌‍‍‍‌‌‌‌‍‌‌‌‌‌‍‍‌‌‌‌‌‍‌‬‌‌‌‌‍‍‍‌‌‌‌‍‌‌████████ ¹。TA 是有史以来第一个创造出全封装对象的年轻天才,将数据成员全部设为 private,只通过几个简洁的公有方法对外服务——就像沉默魔女无需咏唱就能发动魔法,外界永远不知道她内部究竟是如何实现的。

然而……TA 其实极度 protected,和一只 friend T 黑猫( ... ); 蜗居在深山之中,在无数 template 书籍的包围下,默默地进行着魔法研究。但天不遂人愿,TA 的同事、另一位大法师 public::Inheritance 上门拜访,向迷茫的‌‌‌‌‍‌‌ ‌‌‌‌‍‌‌‬‌‌‌‌‍‬‬‬‌‌‌‌‍‬‌‍‌‌‌‌‍‌‬‌‌‌‌‍‬‬‌‌‌‌‍‬‍‍‌‌‌‌‌‬‌‌‌‌‌‌‍‍‌‌‌‌‌‍‍‌‌‌‌‌‍‌‬‌‌‌‌‍‬‌‌‌‌‍‍‍‌‌‌‌‍‌‌‌‌‌‍‍‌‌‌‌‌‍‌‬‌‌‌‌‍‍‍‌‌‌‌‍‌‌████████ ¹ 传达了国王的旨意:“你必须潜入贵族云集的名门学校,学会如何被其他类安全地继承……”

¹ 你可以试试连同前后空格一起复制到 这里面 试试,你会发现藏了什么

11.2 摇篮中的 爱丽丝 类: 类的(基本)定义方法
#

使用 class 来定义一个类,基本的框架如下:

class 类名称 {
    // 构造函数
    类名称(参数列表);
访问修饰符:  // public; protected; private
    成员变量和方法...
访问修饰符:
    成员变量和方法...
...
};

一个实际的例子:

class ManaEntity {
    // 构造器和冒号语法,稍后会讲
    ManaEntity(EntityType t, int h, int m): type(t), HP(h), MP(m)
    {}
private:
    // 预先定义了枚举类
    EntityType type;
    int MP;
    int HP;
    // 当然你可以继续做其他事情
    // ......
private:
    // 这都是声明函数,定义在别处
    void setType(EntityType t) {
        // 当然,可以写作this->type
        // this就是当前那个类变量的指针
        // 或者说“指向调用成员函数的当前对象”
        type = t;
    }
    EntityType getType();
    
    void setHP(int h);
    int getHP();
    
    // ......
};

EntityType ManaEntity::getType() {
    return type;
}

类的使用和 struct 几乎一致,只是不像 C 那样需要 class 头(C++的构造器语法我们马上会讲到)。:

ManaEntity meNoelCornehl;

成员函数有两种定义方法,其一是直接在类内部定义(就是上面 setType 的定义方式),语法就像定义普通函数一样;其二是在类内部声明,在类外部定义(上面 getType 的定义方式),这时就需要作用域标识符 :: 来说明 getTypeManaEntity,而不是一个“野生的”外部函数了。

11.3 显巧守拙:类的访问控制
#

如前所述,class 具有三种权限控制方法:publicprotectedprivate。我们简单介绍下:

11.3.1 public:我家大门常打开
#

public 是最“公开的”访问权限,任何 public 成员(包括函数、变量等等)都可以直接使用 . 运算符访问,比如之前 ManaEntity 的例子:

meNoelCornehl.setType(EntityType::PLAYER);
meNoelCornehl.setHP(160);
meNoelCornehl.setMP(180);

为了不暴露内部实现 胖次 ,一般不会直接将成员变量作为公开对象被访问——因为这容易导致只修改了变量 A 而状态 B 没有及时更新,从而出现问题。即便是简单如 ManaEntity::setHP()ManaEntity::setMP(),都是为了保持内部变量的完好 封装

语文题:透明?不透明?

在很多开发框架、产品宣发的说明中,常常见到一个词“透明”,这里的“透明”是“不可见”的意思(也就是“transparent”本身的意思),用于形容封装非常完善,开发者不需要关心其内部运作原理。而中文语境下常见的“透明”,比如“过程全透明”,反而是“公开可见”的意思,“透明”指代的是没有掩盖(可以理解为“transparent for supervision”)。一开始接触这两种“透明”法时,容易产生混乱。

11.3.2 private:不许看!(>_<)
#

private 是最保守的权限控制方式,只有 类内 个内类~ 类内部的成员能够访问,外部、子类(如果类 B“继承”了 A,那么 B 就是 A 的子类)都无法访问。比如上面的例子里,setType() 内部就可以直接访问 type,而外界并不能使用 meNoelCornehl.type; 或者 (&meNoelCornehl)->type; 来直接访问。当然,除了私有变量之外,函数也可以是私有的。私有函数通常用于实现类的内部逻辑,外部不能调用。

如果真的必须要被外部函数调用,那么就需要使用友元 friend 关键字,我们稍后介绍。

11.3.3 protected:()()传三代
#

protected 的情况稍微复杂一些,是介于 publicprivate 之间的一种继承方式。简单来说,protected 的成员对外是 private,对家人是 public——不能被外部访问,而可以被内部成员和子类访问。这一点是很多继承的核心。

11.4 魔法的前后摇:构造与销毁
#

11.4.1 构造函数:演唱,开始 ♪ ~
#

C++中类的构造相比 C 更灵活,这得益于 构造函数 的引入。构造函数是一种特殊的成员函数,它的名称和类一模一样,而且没有返回值。在类实例化(就是被创建出来)时会被调用。构造函数也可以被重载。这就是一个典型的构造函数。

class ManaEntity {
    // ......
    ManaEntity(EntityType t, int h, int m): type(t), HP(h), MP(m)
    {}  // ----> 别漏掉了函数体!
    // ...
};

你一定注意到了这样奇怪的语法 : type(t), HP(h), MP(m),这是 C++的语法糖,称为 初始化列表,相当于:

ManaEntity(EntityType t, int h, int m) {
    type = t;
    HP = h;
    MP = m;
}

虽然两者从结果上说完全等价,但由于初始化列表语法对类类型(structclass)的成员有额外优化,还是建议多使用初始化列表。

碎碎念:有啥不一样?

对于基本类型,两者效果类似;但对于类类型成员,初始化列表通常效率更高,且是初始化 const 成员或引用的唯一方式。因为 var = value; 会被认为是赋值。而 const 和引用不允许修改值/重新绑定。

C++也支持 C 风格的 {} 列表初始化语法,但是 只能按次序操作 public 成员

// 茵蒂克丝脑内的魔法禁书(大雾)
struct MajutsuSpell {
    const char* title;
    MajutsuContent content;
    bool isSealed;
private:
    int index;
};

// 某处定义了MajutsuContent类
extern MajutsuContent contentEliEli;

// 直接按次序装入
MajutsuSpell EliEli{"Eli, Eli, Lema Sabachthani", 
                    contentEliEli, true};
// index无法修改
// MajutsuSpell EliEli{"Eli, Eli, Lema Sabachthani", 
//                    contentEliEli, true, 
//                    42};

其本质上是也是构造函数的一个重载形式。所以依旧建议使用 private 并显式声明构造器。今天偷懒用 public,明天 debug 哭唧唧~

构造函数可以有多个重载,默认的构造函数没有参数,就算不写出来编译器也会自动生成,相当于:

class ManaEntity {
    ManaEntity() {}
};

如果你不希望 ManaEntity something; 这样声明而不同时初始化的代码,就可以使用 delete,告诉编译器不要生成这个函数。

ManaEntity() = delete;

碎碎念:delete

同理可以 delete 其他成员函数,这一点常用于调整继承类的行为。比如有一个类 A,你希望它和它的子类的实例(这个类具体的一个变量)是唯一的,不可被等号拷贝,就可以这样写:

class A {
public:
    // 禁用拷贝赋值运算符
    A& operator=(const A&) = delete;

    // 通常我们也会一起禁用拷贝构造函数
    A(const A&) = delete;
    
    // ...
};

这样写之后,任何试图使用等号进行拷贝的操作都会被编译器拦截:

A obj1;
A obj2;

obj1 = obj2;        // 编译错误!拷贝赋值运算符已被删除
A obj3 = obj1;      // 编译错误!拷贝构造函数已被删除

如果 BA 的子类,那么 B 也不能如此拷贝。这对统一管理一套唯一、不可复制的变量体系十分有益。Qt 开发框架的 QObject 体系就如此显式删除了 = 运算符。

构造函数有几种使用方式:

  1. 直接初始化:最常见的方式,用圆括号。

    ManaEntity player(EntityType::PLAYER, 100, 50);
    
  2. 拷贝初始化:使用等号。

    // 编译器会先构造一个临时的ManaEntity,再赋值
    ManaEntity monster = ManaEntity(EntityType::PORIFERA, 200, 0);
    

    这两种方式看起来差不多,但在 C++里,后者会触发一个叫做“隐式转换”的机制,这有时会带来意想不到的“魔法”。

explicit:拒绝不请自来!‎
#

想象一下,你的 ManaEntity 类如果有一个只接收 EntityType 的构造函数:

class ManaEntity {
public:
    // 只需要EntityType就能创建
    // 剩下的保持100
    ManaEntity(EntityType t) : type(t), HP(100), MP(100) {}
    // ...
private:
    EntityType type;
    int HP, MP;
};

这时候,C++编译器会发现 EntityType 有一条路径可以转换 ManaEntity,于是它会非常 热心 地帮你做一些自动转换。下面这些代码都是可以编译通过的:

void spawnEnemy(ManaEntity m) {
    // ...
    // 生成一个怪物
}

// 直接用EntityType当作参数
// 编译器会自动合调用最合理的构造函数
// 也就是ManaEntity::ManaEntity(EntityType t)
spawnEnemy(EntityType::SLIME); 

// 甚至赋值也行
ManaEntity newEnemy;
newEnemy = EntityType::PUPPET; 

这种“隐式转换”虽然方便,但很容易掩盖程序的真实意图,甚至引入难以发现的 bug。你可能只想传递一个类型,结果却莫名其妙地构造了一个新对象。

为了杜绝这种 热心过头 的行为,我们可以给构造函数加上 explicit 关键字,明确告诉编译器:“这个构造函数,不许你自作主张进行隐式转换!

class ManaEntity {
public:
    // 加上 explicit,禁止隐式转换
    explicit ManaEntity(EntityType t) : type(t), HP(100), MP(100) {}
    // ...
};

加上 explicit 之后,上面那两行“魔法代码”就会立刻报错:

spawnMonster(EntityType::SLIME); // 错误: 无法将EntityType隐式转换为ManaEntity
newMob = EntityType::PUPPET;       // 错误: 同上

如果你确实想转换,必须“显式”地写出来,让代码意图一目了然,这就是所谓的 explicit 了:

// 必须手动调用构造函数,清清楚楚
spawnMonster(ManaEntity(EntityType::SLIME)); 
newMob = ManaEntity(EntityType::PUPPET);

温馨提示 Warm Tips: 除了拷贝构造函数外,几乎所有只接受一个参数的构造函数最好都标记为 explicit。以避免 = var1; 这样的语句意外创建了新的对象(而覆盖掉原来的数据)。这是一个非常好的编程习惯,能避免很多坑,让你的代码更健壮、更易读。

关于“右值引用”和“移动复制”的简单介绍,详见 8.4.3 结尾。前面的这个例子就特别适合移动拷贝来变废为宝。

ManaEntity monster = ManaEntity(EntityType::PORIFERA, 200, 0);

这里 ManaEntity() 创建了一个临时变量,然后调用 monster 的拷贝构造函数再拷贝一次——如果 ManaEntity 很庞大,即还包含包含指针这类需要深拷贝的东西,反复拷贝势必会造成效率降低。但是不难想到,既然那个临时变量也没人管,我们不如直接拿来用!直接接管它占据的资源而不销毁,这就是右值引用。使用两个 & 表示右值引用(和与运算一样)。

注意:移动构造函数 “窃取” 资源后,原对象 otherentityName 被置为 nullptr,这意味着原对象虽然还存在,但已经变成了“光杆司令”——不再拥有有效的资源。这种状态是**"“有效但未指定(valid but unspecified)”** 的,通常我们不应该再使用这个光杆司令。更确切地说,你可以安全地对其执行析构函数或重新赋值(但是这跟重新 new 一个有啥区别啊喂!),但在未重新赋值前,不应读取其值

移动构造函数:资源“窃取”的艺术
#

为了实现这种“窃取”行为,我们需要为一个特殊的构造函数——移动构造函数(Move Constructor)。它接收一个右值引用作为参数,表示“准备接管一个即将消亡的临时对象”。

我们刻意给 ManaEntity 加上一个动态分配的名称成员,让深拷贝的问题更明显:

#include <cstring> // for strcpy

class ManaEntity {
public:
    // 普通构造函数
    ManaEntity(EntityType t, int h, int m, const char* name)
        : type(t), HP(h), MP(m), entityName(nullptr) {
        // 假设我们动态分配内存来存储实体名称
        // 虽然一般会使用std::string
        // 详见后面的3/5/0法则的介绍
        // 实际开发请 **务必** 优先遵守0法则
        // 也就是尽量用现成的标准库
        // 把内存管理交给STL
        entityName = new char[strlen(name) + 1];
        strcpy(entityName, name);
    }

    // 拷贝构造函数(深拷贝)
    ManaEntity(const ManaEntity& other)
        : type(other.type), HP(other.HP), MP(other.MP) {
        std::cout << "copying" << std::endl;
        entityName = new char[strlen(other.entityName) + 1];
        strcpy(entityName, other.entityName);
    }

    /* ********* 移动构造函数 ********* */
    // 1. 接收右值引用
    ManaEntity(ManaEntity&& other) noexcept
        // 2. 直接“窃取”指针 ----------------------------------↓
        : type(other.type), HP(other.HP), MP(other.MP) { 
        std::cout << "stealing" << std::endl;
        entityName = other.entityName;
        // 3. 将“窃取”对象的指针置空
        // 这是为了防止析构时释放内存
        other.entityName = nullptr;
        // 这个是建议/可以当成习惯
        other.HP = 0;
        other.MP = 0;
    }

    // 析构函数
    ~ManaEntity() {
        delete[] entityName;
    }

private:
    EntityType type;
    int HP;
    int MP;
    char* entityName;
};

移动语义:轮椅,火箭,发动机
#

现在,我们再回头看这个例子:

ManaEntity monster = ManaEntity(EntityType::PORIFERA, 200, 0, "Porifera");

编译器发现右边是一个临时对象(右值),并且 ManaEntity 类有合适的移动构造函数,它会 自动地、优先地 选择调用移动构造函数,而不是拷贝构造函数。于是输出是:

stealing

整个过程,没有发生任何内存的重新分配和内容复制,我们只是简单地复制了一个指针,就把“Porifera”这个名字的所有权从临时对象转移到了 monster 对象身上。效率天差地别,如同轮椅装上了火箭发动机(仅限带有深拷贝的情况,基础类型不会有大差异)。

碎碎念:noexcept 是什么? 你可能注意到了移动构造函数后面有个 noexcept。这是在告诉编译器:“这个函数保证不会抛出异常。” 这很重要,因为标准库(比如 std::vector 在扩容时)在重新排列元素时,如果移动构造函数是 noexcept 的,它会优先使用移动语义来提升性能;否则,为了安全起见,它会退而求其次使用更慢的拷贝语义。

通过移动构造函数,C++很好地解决了深拷贝临时对象时性能问题,让资源管理变得既安全又高效。这可以说是现代 C++最核心、最强大的特性之一。

11.4.2 析构函数:沉入「忘却」的海洋吧 ♭
#

仙骸有终。

当一个对象的生命周期结束时(比如离开作用域,或者被 delete),C++会自动调用一个特殊的成员函数来清理“战场”,这就是 析构函数

析构函数的语法非常固定:在类名前加上一个波浪号 ~既没有参数,也没有返回值既无伯叔 终鲜兄弟(串台)

// 我们省略其他成员函数
class ManaEntity {
public:
    // 构造函数:申请资源
    ManaEntity(const char* name) {
        entityName = new char[strlen(name) + 1];
        strcpy(entityName, name);
        std::cout << entityName << " constructed. " << std::endl;
    }

    // 析构函数:释放资源
    ~ManaEntity() {
        std::cout << entityName << " destroying..." << std::endl;
        // 释放构造函数中申请的内存
        delete[] entityName;
        entityName = nullptr;
    }

private:
    char* entityName;
};

当对象被创建时,构造函数被调用;当对象生命周期结束时,析构函数会被自动调用:

void createMonster() {
    ManaEntity puppet("puppet_trader");
    // ...
} // <-- 函数结束,puppet离开作用域,自动调用析构函数~ManaEntity()

int main() {
    createMonster();
    return 0;
}

运行结果会是:

puppet_trader constructed.
puppet_trader destroying...

析构函数是 C++ 核心思想 RAII(Resource Acquisition Is Initialization,资源获取即初始化) 的基石。它的核心价值在于 自动化 全自动轮椅 :你不需要记着手动去释放资源,只要把资源的获取放在构造函数里,释放放在析构函数里,就能保证资源不会泄漏。 黑魔法除外

重要提醒

如果你在类里管理了任何需要手动释放的资源(如动态内存、文件句柄(一种指针)、网络连接等),必须 编写一个正确的析构函数。否则,就会发生内存泄漏。也就是所谓“占着茅坑不拉屎”——没有任何操作能够释放那片内存。如果这个类又被频繁使用,那么这种泄露就会越来越严重。

都讲到这里了,那么著名的 3/5/0 就呼之欲出了:

碎碎念:3/5/0 原则(Rule of Three/Five/Zero)

当你为一个类手动定义了 拷贝构造函数、拷贝赋值运算符或析构函数 (这三个函数被称为“特殊成员函数”)中的任何一个时(通常是因为你需要管理某种资源,比如 C 字符串或者动态内存的 C 数组),这往往意味着编译器自动生成的默认函数不再适用——因为你需要亲自管理内存。这时,你应该考虑 “Rule of Three”

  • 如果需要定义其中一个,那么很可能需要同时定义 全部三个,以确保资源的正确拷贝和释放。

而 C++11 及以后中,由于移动语义的引入,这个规则进化为了 “Rule of Five”

  • 除了上述三个函数,你通常还需要考虑定义 移动构造函数移动赋值运算符,以充分发挥移动语义带来的性能优势。

然而,最理想的境界是 “Rule of Zero”尽量让你的类不直接管理资源,而是使用 轮椅 现成的工具——标准库中的类(如 std::string, std::vector 等)。这样,编译器自动生成的拷贝/移动构造函数和赋值运算符通常就能正确工作,而无需手动定义。这避免了手动管理内存,使代码更简洁、更安全。比如“移动构造函数”一节中的 ManaEntity 类就完全可以使用 std::string 自动管理内存,而避免 char* 带来的诸多麻烦。

11.5 朋友 ,好吃! 赛高!:友元
#

在 C++的权限体系中,private 成员就像更 ♂ 衣室上了锁的衣柜,外界无法窥 ♂ 探。但有时候,某些特定的 “朋友” 需要直接访问你的私密空间,这时候就需要用到 ユユユユユユユユユユユユ 友元(friend) 机制了。

11.5.1 Do you like 玩游戏 ♂?:什么是友元?
#

友元是一种打破封装的特权关系,让指定的外部函数或类能够直接访问当前类的 privateprotected 成员。就像给你给了你的好朋友 boy next door♂ 一把家门钥匙。

11.5.2 友元函数:函数,朋友!‎
#

class Home {
private:
    // COLOR可以是某个枚举类
    enum class COLOR {RED, ORANGE, YELLOW,
                      GREEN, BLUE, INDIGO, VIOLET};
    vector<COLOR> pantColors;       // 胖次颜色收藏
    vector<string> browserHistory;  // 浏览记录(危)
    
public:
    // 一系列方法
    // ......
    
    // 声明友元函数
    friend void bestFriendRaid(const Home& home);
};

// 友元函数可以直接访问私有成员
void bestFriendRaid(const Home& home) {
    const std::string colorNames[] = 
    {"RED", "ORANGE", "YELLOW", "GREEN", 
     "BLUE", "INDIGO", "VIOLET"};
    
    std::cout << "The colors of pants are: \n";
    for(auto pant : home.pantColors) {
        std::cout << colorNames[static_cast<int>(pant)] << " ";
    }
    
    std::cout << "\nThe websites they viewed are: \n";
    for(auto website : home.browserHistory) {
        std::cout << website << " ";
    }
}

友元函数虽然声明在类内部,但它 不是 类的成员函数,定义时不需要 Home:: 作用域。它是独立的 “自由函数”,只是拥有直接访问类私有成员的特权,而不需要使用对象 . 来访问。像下面这样直接调用即可:

Home commandprompt_wang;
// 装入一些内容
// ......

// 直接调用即可
bestFriendRaid(commandprompt_wang);

11.5.3 友元类:类,也是朋友!‎
#

友元类与友元函数类似,只不过生效范围变成了整个类的成员函数。换言之整个友元类都可以访问另一个类的私有成员,我们可以改进上面的程序:

class Home {
private:
    vector<COLOR> pantColors;
    vector<string> browserHistory;
    
public:
    // 一系列方法
    // ......
    
    // 插入友元类
    friend class MyFriend;
};

class MyFriend {
private:
    COLOR pantColor;
    vector<string> browserHistory;
public:
    void bestFriendRaid(const Home& home);
    void bestFriendShareYourLike(const Home& home);
};

// 友元类的成员函数就是友元函数了
void MyFriend::bestFriendRaid(const Home& home) {
    const std::string colorNames[] = 
    {"RED", "ORANGE", "YELLOW", "GREEN", 
     "BLUE", "INDIGO", "VIOLET"};
    
    std::cout << "The colors of pants are: \n";
    for(auto pant : home.pantColors) {
        std::cout << colorNames[static_cast<int>(pant)] << " ";
    }
    
    std::cout << "\nThe websites they viewed are: \n";
    for(auto website : home.browserHistory) {
        std::cout << website << " ";
    }
}

void MyFriend::bestFriendShareYourLike(const Home& home){
    if (!home.pantColors.empty()) {
    	this->pantColor = home.pantColors[0];
	} else {
        std::cout << "Huh? Don't you have a pant? \n";
    }
    
    if (!home.browserHistory.empty()) {
        // 养成好习惯,位于不同类的相似/相同变量用this区分
    	this->browserHistory = home.browserHistory;
	} else {
        std::cout << "Huh? Don't you have a life? \n";
    }
}

这样看看朋友的浏览器记录你就知道以后可以看什么好康的了(笑):

Home commandprompt_wang;
// 装入一些内容
// ......

// 直接调用即可

MyFriend someone;
someone.bestFriendRaid(myHome);
someone.bestFriendShareYourLike(myHome);

不过要记住:朋友之谊,贵在信任,滥用友元伤感情!(你也不想朋友天天看 cl 你的 吧)

碎碎念:友元的注意事项

  1. 单向性:友元关系是单向的。上面的例子中,MyFriend 可以访问 Home 的私有成员,但 Home 不能访问 MyFriend 的私有成员(除非也插入友元声明) 究极不平等友谊!!
  2. 不传递:友元关系不继承。如果 MyFriendsSon 继承自 MyFriendMyFriendsSon 不能 自动访问 Home 的私有成员
  3. 破坏封装:友元就像在封装墙上开的后门,虽然方便,但破坏了面向对象的封装性原则。要像对待现实中的朋友一样——慎重选择,不宜过多
  4. 实际用途:友元常用于运算符重载、某些设计模式、测试框架等需要紧密协作的场景。比如 iostream<<>> 流运算符,为了让它们能直接访问类的私有数据,通常需要声明为友元。想象一下 cout << myObjectmyObject 是你自定义的类),如果 operator<< 不能直接读取 myObject 的私有成员,这个优雅的语法就无法实现。所以如果要输出自定义类,就至少要将 ostream::operator << 作为友元重载(输入同理)。

总之,友元就像魔法契约——签订一时爽,违约火葬场!不违约也可能是火葬场: 上一个随便就把契约签了的已经在0721了(逃)

11.6 龙生龙,凤生凤:继承
#

前面章节铺垫了这么久,“继承”的概念终于呼之欲出了。继承(Inheritance) 是 C++提供的强大派生功能,子类通过继承父类,可以直接获得父类的成员变量和方法——这大大减少重复代码的编写。

11.6.1 继承一个类:血脉传承
#

要继承一个类,语法上很简单:

class 派生类 : 继承方式 父类1 {
    // ...
    // 没了,剩下的就是类内部的代码
};

C++支持多继承 三姓家奴 ,但是实际管理起来比较复杂

class 派生类 : 继承方式 父类1, 继承方式 父类2, ... {
    // ...
};

11.6.2 继承方式:“仿照上例显然”
#

和类成员的访问控制一样,继承有三种继承方式,publicprotectedprivate。这三种继承的具体效果如下表(改自 菜鸟教程):

访问 public protected private
同一个类 yes yes yes
派生类 yes yes no
外部的类 yes no no

表格还是太抽象了,而且信息不太全面(缺乏继承方式对父类成员在子类中的访问权限的改变),于是我作了三张图: Public:

第 11 章-正文-继承示意图-public

Protected:

第 11 章-正文-继承示意图-protected

Private:

第 11 章-正文-继承示意图-private

简单来说就是 设置派生类对基类成员的访问上限,也就是更宽松的方式“压缩”为那个更严格的上限,此外,private 成员是永远无法触及的彼岸~

11.6.3 虚函数和覆写:魔女的夜宴 继承的惨案
#

问:为什么宁宁成为魔法少女后会随时随地地 发情 ? 答曰:因为 不小心 继承了父类的方法。

(下面的例子都不长所以子方法都写在类内部了,长函数建议还是像前面那样分离开)
#include <iostream>
#include <random>
#include <string>

class MahouShoujo {
protected:
    int magicPower;
    std::string transformationPhrase;
    // 梅森旋转算法(Mersenne Twister),周期为梅森素数: 2^19937 - 1
    std::mt19937 rng{std::random_device{}()};
    
public:
    MahouShoujo() : magicPower(100) {}
    
    // battle逻辑固定,不可override
    void battle() final {
        transform();
        std::cout << "Assault Launched!" << std::endl;
        
        // 战斗中有概率触发*魔力失控*
        std::uniform_real_distribution<double> dist(0.0, 1.0);
        if (dist(rng) < 0.3) {
            onanii();
        }
        
        std::cout << "MP: " << magicPower << std::endl;
    }
    
private:
    virtual void transform() {
        std::cout << transformationPhrase << std::endl;
    }

    virtual void onanii() {
        std::cout << "Uhhh...So hot..." << std::endl;
        magicPower -= 50; // MahouShoujo的原始版本损失50MP
    }
};

class UnluckyNene : public MahouShoujo {
public:
    UnluckyNene() {
        transformationPhrase = "NeNeNeNeNe!";
    }
};

int main() {
    UnluckyNene nene;
    nene.battle();
    
    return 0;
}

由于签订了契约,于是绫地宁宁继承了 MahouShoujo 类,拥有了战斗能力 battle()(大雾),而不需要额外学习(再写一遍)(弥天大雾)。但是,因为 battle() 内部有概率触发 魔力失控,所以可能的输出如下:

NeNeNeNeNe!
Assault Launched!
Uhhh...So hot...
MP: 50

于是保科就这样在图书馆遇见了援桌骑士,开启了一段孽缘(bushi)

聪明的宁宁很快想到了覆写(override),虽然 final 标注了 battle() 不可覆写,但是 onanii() 可以,于是她迅速找到了解决方案

跟我一起喊:Otaku override Save The World!(逃)

// 第二版:
class SmartNene : public MahouShoujo {
private:
    int toyEnergy = 0;
    
public:
    SmartNene() {
        transformationPhrase = "NeNeNeNeNe";
    }
    
    // overrideの救赎
    void onanii() override {
        std::cout << "Uhhh...So hot..." << std::endl;
        useToy();
        magicPower -= 30; // 改写为只损失30MP
    }
    
    void useToy() {
        toyEnergy += 10;
        std::cout << "Toy charging, currently: " << toyEnergy << std::endl;
    }
};

虚函数使用 virtual 标记,用以表示它可以 override——被覆写,被覆写后,子类便默认直接使用自己的方法,而不会再使用基类(也就是父类)的方法了。如果你一定需要父类的方法,就需要作用域标识符来说明是哪一个方法:

MahouShoujo::onanii();

多重继承且有重名方法也是相似的道理,不再赘述。

final 则表明该函数是“终稿”,不可再被更改,因此,绫地宁宁不能直接覆写 battle() 来一劳永逸地解决问题。而是“曲线救国”,使用小玩具 覆写了 onanii() 方法,缓解了矛盾。这就是类和魔法的究极奥义啊! 究极在哪?奥义在哪?!

关于虚函数的具体内部实现,以及多重继承、菱形继承(A-> B,A-> C, B+C-> D,画出关系图就像菱形)等复杂方案,这里已经将基础部分铺垫扎实,希望读者能自主搜索和学习。他们本质上是对基础方法的扩展和延伸,但是都离不开单继承的根基。深入了解,可以很好地锻炼类比推理以及实践分析能力,所以这里不再赘述。

其实就是我不想写了

多继承最大的缺陷是极其难以管理,所以 Java,C#等干脆就没有多继承

虚继承可以部分缓解多继承的问题,它保证在继承体系(A-> B,A-> C, B+C-> D)中,虚基类(A)的子对象只存在一份副本。举例:

class A {};
class B : virtual public A { ... }; 
class C : virtual public A { ... };
class D : public B, public C { ... };

这样就只会有 1 份 A 的副本,而不会从 BC 各拿到一份

碎碎念:动态绑定

(想了想还是简述一下)

简单说,虚函数(动态绑定)的本质,可以理解为给每个对象配了一张“函数菜单”

当你声明 virtual void onanii() 时,编译器就会为 MahouShoujo 类生成一张 虚函数表(vtable),表中记录着不同类中的 onanii 该调用哪个具体的实现。而每个对象内部也藏着一个指针,指向这张表。

于是你完全可以使用父类的指针,而将子类实例传入并把它当作子类调用!

(下面 InabaMeguru 也是一个 MahouShoujo 子类)

MahouShoujo* girl1 = new SmartNene();
MahouShoujo* girl2 = new InabaMeguru();   // Ciallo~(∠・ω< )⌒★
girl1->onanii();  // 运行时查表:哦,要调用 SmartNene 的版本!
girl2->onanii();  // 运行时查表:哦,要调用 InabaMeguru 的版本!

这就是“动态绑定”——运行时才决定调用谁。也正因如此,我们才能在父类指针上调用子类 override 的方法,实现所谓的 多态(Polymorphism)


课后习题:

  1. 魔法少女扩展包

    为美咕噜设计 MahouShoujo 的派生类,变身语句为 "Ciallo~(∠・ω< )⌒★",并重写变身行为:第一次变身询问名字 Senpai, o namae o oshiete itadakemasu ka?,得到回答后,输出 [名字], Ciallo~(∠・ω< )⌒★(如果代码页有问题导致乱码可以替换成 Ciallo~

    Senpai, o namae o oshiete itadakemasu ka?
    Hoshina Shuuji
    Hoshina Shuuji, Ciallo~ (∠・ω< )⌒★   ----->第一次变身
    Hoshina Shuuji, Ciallo~ (∠・ω< )⌒★   ----->第二次变身
    
  2. 胰脏物语:珍贵的友谊

    山内樱良有一本名为“共病文库”的日记,记录着她身患胰脏绝症的秘密和独白。她希望这本日记能被“对的人”发现,但绝不愿被 TA 随意对待。最终,那个“没有名字”的同班同学——志贺春树,成为了她唯一的知己与这本日记的守护者。

    任务

    1. 设计一个 KyoubyouBunko(共病文库) 类。

      • 包含一个私有的 std::vector<std::string> pages;,用于存储日记页的内容。这是必须严格保护的秘密。

      • 在构造函数中,使用以下 3 篇预设的日记内容来初始化 pages

        "20XX/11/29\nI don't want to write dark things, but I must... First, I apologize to my family."
        "20XX/12/04\nI've decided not to hate my fate. That's why I named this 'Kyoubyou Bunko'."
        "20XX/10/12\nI started dating someone new. I don't want to tell him about my illness."
        

        PS:你可以在初始化列表里面这样快速初始化 pages

        KyoubyouBunko() : pages{ "日记1", "日记2", "日记3" } {}
        
    2. 设计一个 BestFriend 类。

      • 拥有 void read(const KyoubyouBunko& diary) 方法:按顺序(按下标)逐页打印出日记的所有内容。
      • 拥有 void write(KyoubyouBunko& diary, const std::string& newPage) 方法:在日记的末尾追加一页新的留言。
    3. 使用友元的特性实现对 KyoubyouBunko 实例的自由读写。

  3. “0 原则”实践:重构 ManaEntity

    重写上面的这个 [ManaEntity](# 移动构造函数:资源“窃取”的艺术),要求:

    • 实现“0 原则”,删除手动的 C 字符串内存管理
    • 使用 std::string 管理名字
    • 增加移动语义构造函数
    • 验证移动语义是否自动生效
  4. 多态实践:魔法少女 乐队

    创建 MahouShoujo* 指针数组,包含 UnluckyNeneSmartNeneInabaMeguru(来自习题 1)各一个实例化对象。然后循环调用它们的 battle() 观察多态效果,尝试用自己的语言解释虚函数表如何实现 “不同对象不同行为”。

    PS:使用 new 类型名 得到分配的指针,使用后需用 delete 指针 释放内存,避免泄漏”。参见第六章 6.2.1 结尾部分。

命令提示符@CommandPrompt-Wang
作者
命令提示符@CommandPrompt-Wang