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 的定义方式),这时就需要作用域标识符 :: 来说明 getType 是 ManaEntity,而不是一个“野生的”外部函数了。
11.3 显巧守拙:类的访问控制 #
如前所述,class 具有三种权限控制方法:public、protected 和 private。我们简单介绍下:
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 的情况稍微复杂一些,是介于 public 和 private 之间的一种继承方式。简单来说,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;
}
虽然两者从结果上说完全等价,但由于初始化列表语法对类类型(struct、class)的成员有额外优化,还是建议多使用初始化列表。
碎碎念:有啥不一样?
对于基本类型,两者效果类似;但对于类类型成员,初始化列表通常效率更高,且是初始化
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; // 编译错误!拷贝构造函数已被删除如果
B是A的子类,那么B也不能如此拷贝。这对统一管理一套唯一、不可复制的变量体系十分有益。Qt 开发框架的QObject体系就如此显式删除了=运算符。
构造函数有几种使用方式:
-
直接初始化:最常见的方式,用圆括号。
ManaEntity player(EntityType::PLAYER, 100, 50); -
拷贝初始化:使用等号。
// 编译器会先构造一个临时的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 很庞大,即还包含包含指针这类需要深拷贝的东西,反复拷贝势必会造成效率降低。但是不难想到,既然那个临时变量也没人管,我们不如直接拿来用!直接接管它占据的资源而不销毁,这就是右值引用。使用两个 & 表示右值引用(和与运算一样)。
注意:移动构造函数 “窃取” 资源后,原对象
other的entityName被置为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 玩游戏 ♂?:什么是友元? #
友元是一种打破封装的特权关系,让指定的外部函数或类能够直接访问当前类的 private 和 protected 成员。就像给你给了你的好朋友
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 你的 吧)
碎碎念:友元的注意事项
- 单向性:友元关系是单向的。上面的例子中,
MyFriend可以访问Home的私有成员,但Home不能访问MyFriend的私有成员(除非也插入友元声明)究极不平等友谊!!- 不传递:友元关系不继承。如果
MyFriendsSon继承自MyFriend,MyFriendsSon不能 自动访问Home的私有成员- 破坏封装:友元就像在封装墙上开的后门,虽然方便,但破坏了面向对象的封装性原则。要像对待现实中的朋友一样——慎重选择,不宜过多!
- 实际用途:友元常用于运算符重载、某些设计模式、测试框架等需要紧密协作的场景。比如
iostream的<<和>>流运算符,为了让它们能直接访问类的私有数据,通常需要声明为友元。想象一下cout << myObject(myObject是你自定义的类),如果operator<<不能直接读取myObject的私有成员,这个优雅的语法就无法实现。所以如果要输出自定义类,就至少要将ostream::operator <<作为友元重载(输入同理)。
总之,友元就像魔法契约——签订一时爽,违约火葬场!不违约也可能是火葬场: 上一个随便就把契约签了的已经在0721了(逃) 。
11.6 龙生龙,凤生凤:继承 #
前面章节铺垫了这么久,“继承”的概念终于呼之欲出了。继承(Inheritance) 是 C++提供的强大派生功能,子类通过继承父类,可以直接获得父类的成员变量和方法——这大大减少重复代码的编写。
11.6.1 继承一个类:血脉传承 #
要继承一个类,语法上很简单:
class 派生类 : 继承方式 父类1 {
// ...
// 没了,剩下的就是类内部的代码
};
C++支持多继承 三姓家奴 ,但是实际管理起来比较复杂
class 派生类 : 继承方式 父类1, 继承方式 父类2, ... {
// ...
};
11.6.2 继承方式:“仿照上例显然” #
和类成员的访问控制一样,继承有三种继承方式,public,protected 和 private。这三种继承的具体效果如下表(改自 菜鸟教程):
| 访问 | public | protected | private |
|---|---|---|---|
| 同一个类 | yes | yes | yes |
| 派生类 | yes | yes | no |
| 外部的类 | yes | no | no |
表格还是太抽象了,而且信息不太全面(缺乏继承方式对父类成员在子类中的访问权限的改变),于是我作了三张图:
Public:
Protected:
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 的副本,而不会从 B、C 各拿到一份
碎碎念:动态绑定
(想了想还是简述一下)
简单说,虚函数(动态绑定)的本质,可以理解为给每个对象配了一张“函数菜单”。
当你声明
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)。
课后习题:
-
魔法少女扩展包
为美咕噜设计
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~ (∠・ω< )⌒★ ----->第二次变身 -
胰脏物语:珍贵的友谊
山内樱良有一本名为“共病文库”的日记,记录着她身患胰脏绝症的秘密和独白。她希望这本日记能被“对的人”发现,但绝不愿被 TA 随意对待。最终,那个“没有名字”的同班同学——志贺春树,成为了她唯一的知己与这本日记的守护者。
任务
-
设计一个
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" } {}
-
-
设计一个
BestFriend类。- 拥有
void read(const KyoubyouBunko& diary)方法:按顺序(按下标)逐页打印出日记的所有内容。 - 拥有
void write(KyoubyouBunko& diary, const std::string& newPage)方法:在日记的末尾追加一页新的留言。
- 拥有
-
使用友元的特性实现对
KyoubyouBunko实例的自由读写。
-
-
“0 原则”实践:重构
ManaEntity类重写上面的这个 [
ManaEntity](# 移动构造函数:资源“窃取”的艺术),要求:- 实现“0 原则”,删除手动的 C 字符串内存管理
- 使用
std::string管理名字 - 增加移动语义构造函数
- 验证移动语义是否自动生效
-
多态实践:魔法少女
乐队创建
MahouShoujo*指针数组,包含UnluckyNene、SmartNene、InabaMeguru(来自习题 1)各一个实例化对象。然后循环调用它们的battle()观察多态效果,尝试用自己的语言解释虚函数表如何实现 “不同对象不同行为”。PS:使用
new 类型名得到分配的指针,使用后需用delete 指针释放内存,避免泄漏”。参见第六章 6.2.1 结尾部分。