C++学习(9):继承与多态-auto、引用、const变量和const函数
//2. 继承与多态(虚函数) //基类 `LogicGate`,派生 `AndGate` 和 `OrGate`。 #include <iostream> #include <vector> #include <memory> class LogicGate { public: virtual ~LogicGate() = default; virtual bool compute(bool a, bool b) const = 0; // 纯虚函数 virtual const char* name() const = 0; }; class AndGate : public LogicGate { public: bool compute(bool a, bool b) const override { return a && b; } const char* name() const override { return "AND"; } }; class OrGate : public LogicGate { public: bool compute(bool a, bool b) const override { return a || b; } const char* name() const override { return "OR"; } }; int main() { std::vector<std::unique_ptr<LogicGate>> gates; gates.push_back(std::make_unique<AndGate>()); gates.push_back(std::make_unique<OrGate>()); bool a = true, b = false; for (const auto& gate : gates) { std::cout << gate->name() << ": " << a << " " << b << " -> " << gate->compute(a, b) << std::endl; } return 0; }零、序言
Q:来教我读这个代码。我能明白这是关于函数继承,也就是,在原本那个类里面写一个虚函数的空壳,然后继承那个类以后在虚函数里填入自己的函数,相当于在更基础的类里包装了接口。虽然我懂这个东西,但是我不理解里面的一些很小的语法知识点,例如auto、make_unique这种c语言里没有或不常见的东西
A:理解的概念层面完全正确:LogicGate定义了一套“接口契约”,AndGate和OrGate履行了这套契约。这确实是面向对象多态的核心思想。
1.
auto(自动类型推导)C语言视角:在C语言中,你声明变量时必须明确写出类型,比如
int i = 0;。C++ 的
auto:它的作用是让编译器根据初始化表达式的类型,自动推断出变量的类型。它不是“动态类型”(运行时改变),而是“编译时自动填充”。在你的代码里:
cpp
for (const auto& gate : gates) { ... }编译器看到
gates是一个std::vector<std::unique_ptr<LogicGate>>,它会自动推断出gate的类型应该是:const std::unique_ptr<LogicGate>&(也就是一个指向LogicGate的智能指针的常量引用)。
你可以把它理解为“打字省力工具”,但更重要的作用是让代码在模板编程中更通用。你不需要记住那个长长的类型名,编译器会帮你填好。2.
std::make_unique<AndGate>()(创建智能指针)C语言视角:在C语言中,你会在堆上分配结构体,比如
struct AndGate* p =(struct AndGate*)malloc(sizeof(struct AndGate));,然后手动free。C++ 的
make_unique:它是一种安全、简洁地在堆上创建对象并返回“独占所有权”智能指针的函数。
new AndGate()相当于malloc+ 调用构造函数。
std::make_unique<AndGate>()则直接在堆上构造一个AndGate对象,并把它包装进一个std::unique_ptr<AndGate>智能指针里返回。为什么要这么写?因为
std::unique_ptr是一个“独占指针”,它离开作用域时,会自动delete它指向的对象。你永远不需要手动写delete(对应C语言的free),从而杜绝了内存泄漏。这里的
std::make_unique是 C++14 引入的,如果是C++11,通常会用std::unique_ptr<AndGate>(new AndGate()),但前者更安全(避免某些异常导致的泄漏)。3.
override(显式重写标记)C语言视角:C语言没有类的继承,自然也没有这个概念。
C++ 的
override:这是一个编译时检查的关键字。它的作用是告诉编译器:“我明确声明,这个函数是要重写(覆盖)基类里的那个虚函数。”。
如果你的函数名拼错了,或者参数类型写错了(导致没有匹配到基类的虚函数),编译器就会直接报错,而不是悄悄地把它当作一个全新的独立函数。
在你的代码里:cpp
bool compute(bool a, bool b) const override { ... }这确保了
compute确实匹配了基类LogicGate中的compute。如果不写override,代码也能运行,但如果你不小心把参数bool b写成了bool c,编译器不会提示你,你会得到一个奇怪的“隐藏”错误。加了override,编译器会立刻拦住你。4.
virtual ~LogicGate() = default;和= 0C语言视角:C语言没有析构函数的概念。
= default:告诉编译器“使用默认生成的析构函数版本”。因为基类有虚函数,析构函数必须声明为virtual,这样才能保证当你删除一个指向子类的基类指针时,子类的析构函数也能被正确调用。这里= default就是“我不需要写特殊清理代码,编译器按最标准的方式生成就好”。
= 0:这是纯虚函数的标志。就是你在概念上理解的“空壳接口”。它告诉编译器“这个函数在这个类里没有实现,任何继承我的具体类必须自己实现它”。这也使得LogicGate成了一个“抽象类”,你不能直接创建LogicGate的对象(如new LogicGate()),只能创建它的子类对象。5.
for (const auto& gate : gates)(范围 for 循环)C语言视角:C语言里你要遍历数组,得写
for (int i = 0; i < n; i++) { ... }。C++ 的范围 for 循环:这是 C++11 引入的语法糖,专门用来遍历容器(如
vector、数组)。
gates:要遍历的容器。
gate:每一次循环拿到的元素。
constauto&:表示gate是元素的常量引用(不拷贝,只读)。如果写成auto gate,它会复制一份智能指针,虽然无伤大雅,但浪费性能。使用const&(常量引用)是 C++ 的惯用法,表示“只读取,不改动,且不复制”。
它在底层翻译成的逻辑就是:cpp
for (auto it = gates.begin(); it != gates.end(); ++it) { const auto& gate = *it; // ... }但范围 for 循环把样板代码省掉了,可读性更高。
6. 成员函数后面跟
const这是一个 C++ 特有的小细节:
cpp
virtual bool compute(bool a, bool b) const = 0;末尾的
const表示这个函数承诺不会修改类的成员变量。当你有一个const LogicGate&引用时,你只能调用那些带const后缀的成员函数。这在 EDA 代码里经常见到,用来确保“读数据”的操作不会意外改动电路对象的状态,起到了“自文档化”和“编译时约束”的作用。总结:把这些点串起来
语法点 你可以理解为(C语言的近似) auto编译器帮你写 int/string/指针类型make_uniquemalloc+ 自动在末尾插入free的保姆override给编译器的一个“请帮我检查我有没有拼错父类函数名”的指令 = 0这个函数在父类里没代码,子类必须自己写 for (auto& x : list)不用自己数下标的 for循环函数后的 const保证这个函数不会修改对象内部数据 你之前的“虚函数空壳”概念完全正确。上面这些只是围绕这个机制存在的现代 C++ 工具箱。写 EDA 算法时,你几乎天天都会和
auto、unique_ptr、override打交道,但它们的用法基本就是你现在看到的这个样子。你第一次接触能看懂到这个程度,已经非常好了。
一、auto-自动类型推导;引用
Q:我们首先来看看auto吧,你的意思是说,auto是一个自动类型识别?给我举一些简单的例子看看。
A:理解得完全正确:auto就是“自动类型推导”(Automatic Type Deduction)。它让编译器根据你赋给它的值,自动推断出这个变量的类型。
但是请记住一个最重要的区别:它是编译时确定的,不是运行时。也就是说,一旦编译器推断出类型,这个变量就和手写类型的变量一模一样,没有任何运行时性能开销。它只是帮我们省去了打字的功夫。
1. 基础例子:代替基本类型
cpp
#include <iostream> #include <string> int main() { // 传统写法:手动写类型 int a = 5; double b = 3.14; std::string c = "hello"; // auto 写法:让编译器猜 auto x = 5; // x 的类型是 int auto y = 3.14; // y 的类型是 double auto z = "hello"; //注意:这里 z 的类型是 const char*(字符串字面量),不是 std::string// 但如果是从 std::string 变量初始化,则是 std::string std::string s = "world"; auto t = s; // t 的类型是 std::string std::cout << x << " " << y << " " << t << std::endl; return 0; }结论:它就像填空,你把值放右边,编译器把类型填左边。
2. 指针和引用的区别(这点很重要)
cpp
int main() { int num = 10; int* p = # // 传统指针 auto p1 = # // p1 的类型是 int* auto* p2 = # // p2 的类型也是 int*(明确写了 *,更清晰) int& ref = num; // 传统引用 auto ref1 = num; // ref1 的类型是 int(不是引用!因为auto 默认会去掉引用,直接复制值) auto& ref2 = num; // ref2 的类型是 int&(加上 & 才能保留引用特性) ref2 = 20; // 修改 ref2 会直接改变 num // ref1 = 30; // 如果 ref1 是 int,修改它不会影响 num return 0; }关键规则:
auto默认会剥离引用(&)和顶层const(常量属性)。如果你想要引用,必须显式写成auto&。3. 在遍历容器时(你代码里的用法)
cpp
#include <vector> #include <string> int main() { std::vector<std::string> names = {"Alice", "Bob", "Charlie"}; // 写法 1:复制一份(如果字符串很长,拷贝会浪费性能) for (auto name : names) { // name 是 std::string,是 names 里元素的副本 } // 写法 2:只读引用(不拷贝,高效,且不能修改原数据) for (const auto& name : names) { // name 是 const std::string&,指向容器里的原始字符串 std::cout << name; } // 写法 3:可修改的引用(可以修改容器里的数据) for (auto& name : names) { name = "Mr. " + name; // 直接修改原数据 } return 0; }4.
auto最有用的场景:告别极其冗长的类型名如果你不用
auto,在处理容器迭代器时,你需要写很长一串:cpp
#include <vector> #include <map> int main() { std::map<std::string, std::vector<int>> complex_data; // 不用 auto 的痛苦写法: std::map<std::string, std::vector<int>>::iterator it1 = complex_data.begin(); // 用了 auto 的快乐写法: auto it2 = complex_data.begin(); // 类型完全一样,但代码短了一大截 return 0; }
Q:奇怪,所以引用是什么样的?当你写auto& p=a,它是把p的地址也指向a的地址了吗?
A:当你写auto& p = a;时,是“p 和 a 共享同一个地址”。
为了让你彻底明白,我们分三步来看:概念上、代码上、底层上。
1. 概念上的区别:引用是“别名”,不是“指针”
指针:
int* p = &a;——p是一个独立的变量,它的值是a的地址。要拿到a,你必须解引用(*p)。引用:
int& p = a;——p不是一个独立的变量,它是a的别名。使用p就是直接使用a,不需要解引用。当你写
auto& p = a;,编译器只是记住了:“以后提到p,就等同于提到a”。它并没有创建一个新的变量来存储a的地址。2. 代码验证:取地址运算符
&会告诉你真相我们写一段代码来验证引用和指针的差异:
cpp
#include <iostream> int main() { int a = 10; int* ptr = &a; // 指针:存储 a 的地址 int& ref = a; // 引用:a 的别名 // 1. 打印它们的值 std::cout << "a 的值: " << a << std::endl; // 10 std::cout << "*ptr 的值: " << *ptr << std::endl; // 10(需要解引用) std::cout << "ref 的值: " << ref << std::endl; // 10(直接当 a 用) // 2. 关键证据:打印它们的地址! std::cout << "&a = " << &a << std::endl; // 假设地址是 0x7fff... std::cout << "&ref = " << &ref << std::endl; // 和 &a 完全一样!!! std::cout << "&ptr = " << &ptr << std::endl; // 指针自己的地址,和 &a 不同! // 3. 修改 ref,观察 a ref = 20; std::cout << "修改 ref 后,a = " << a << std::endl; // 变成 20 了 // 因为 ref 就是 a,所以修改 ref 就是修改 a return 0; }运行你会发现:
&a和&ref打印出的地址一模一样。这就证明了引用不是“指向”目标,而是“就是”目标本身。3. 底层实现(汇编/内存视角)
在底层(机器码层面),编译器确实是用地址来实现引用的。当你写
ref = 20时,编译器生成的机器码很可能是“将数值 20 写入地址&a所在的内存位置”。区别在于:
指针
ptr:它占一块内存(存地址),它有自己独立的地址(&ptr)。引用
ref:它不占用独立的内存空间(严格来说是它“没有”自己的地址)。它的出现,在底层就会被编译器解释为“直接操作a的地址”。所以回到你的原话“把 p 的地址也指向 a 的地址”:
如果你把
p想象成“存放地址的盒子”,那么这个说法在底层近似成立(编译器确实会用地址去处理)。但从 C++ 语法上说,这个说法不成立,因为
p本身不是一个“存放地址的盒子”,它就是a的另一个名字。你没有办法像修改指针那样,让p去指向另一个变量(引用一旦绑定,终身不变)。4. 回到你的代码
auto& p = a
auto在这里只是推导出了a的类型(int),加上&后,p就变成了int&(整型引用)。
所以auto& p = a;等价于int& p = a;。它就是给a起了一个新名字叫p,两个名字共享同一块内存。当你在循环里写const auto& gate : gates时,gate就是gates里那个元素的别名,所以不会发生拷贝,并且只读。
Q:也就是说,并没有建立一个新的p指针单元用于存放a的地址?
A:是的。
底层实现:那编译器和 CPU 怎么做到的?
你可能会想:“既然没有存放地址的单元,那计算机运行时怎么知道
ref对应哪个内存位置?”
答案是:编译器在编译时把“引用”翻译成了“直接操作原变量”。当你在代码里写
ref = 20;时,编译器生成的机器码相当于直接写a = 20;。它在底层可能会使用地址寻址,但它不会为ref单独分配一块内存来存储地址。唯一的例外:当引用作为函数参数时,编译器底层确实是通过传递“地址”来实现的(避免拷贝大对象)。但在语法层面上,你依然不需要把它当成指针来用——你不需要解引用(
*ref),也不需要用&去取地址才能赋值。它依然表现得像“我就是那个对象本身”。一句话总结
指针:是一个独立变量,存放地址。
引用:是已有变量的别名,没有独立的存储空间。你把
auto&看作“我只是给这个变量贴了个新标签”就完全对了。
二、const char* name() const-常量指针和常量函数
Q:const char* name() const
我想知道这一小段是什么意思,以及再给我详细讲讲这两个const吧
A:
这行代码
const char* name() const,是 C++ 里一个非常经典的声明,充满了“契约精神”。它混合了“返回值类型”和“函数行为约束”两个维度。别怕,我们把它拆成两部分来看:返回值的
const和函数末尾的const。1. 返回值部分的
const(函数名左边的):const char*这是函数的返回类型,意思是:这个函数返回一个指向“常量字符”的指针。
如果没有
const:返回char*,调用者可以通过这个指针去修改它指向的内存内容。有了
const:返回const char*,告诉调用者:“我给你这个字符串的地址,但你只能读它,绝对不能修改它。”在 EDA 代码里,这个函数返回的是
"AND"或"OR"。这些是字符串字面量(比如"AND"),它们通常被存放在程序的只读数据段里。如果你试图通过指针去修改它们,程序会崩溃。所以这里写
const char*是一个双重保护:
物理上:字符串字面量本身是只读的,改不了。
语法上:即使你有坏心思,编译器也会因为
const而直接报错,不让你编译通过。2. 函数末尾的
const(函数名右边的):... name() const这是 C++ 特有(C语言完全没有)的语法,叫做
const成员函数。它的作用是:承诺这个函数不会修改类的任何成员变量。当你写下
const在括号后面时,编译器会强制执行这个承诺:
在这个函数体内部,你不能修改
m_name(假设存在)、m_value或任何其他非静态成员变量。你只能读取它们。
为什么要加这个尾巴?
因为 C++ 允许你创建const对象(比如const LogicGate& gate)。只有带有末尾const的成员函数,才能被const对象调用。在你的代码里:
cpp
for (const auto& gate : gates) { // gate 是常量引用 std::cout << gate->name(); // 调用了 name() const // 如果 name() 没有末尾的 const,这一行就会编译报错! }因为我们是只读遍历(
const auto&),不打算修改任何门电路的信息。所以name()必须加上末尾的const,才能配合这种“只读访问”的用法。3. 两个
const的区别(对比表格)
特征 左边的 const(返回值)右边的 const(成员函数)作用于谁 影响函数外部的调用者 影响函数内部的行为 约束什么 约束“返回值”本身能否被修改 约束“成员变量”能否在这个函数里被修改 谁会被限制 拿到返回值的调用者 写这个函数的开发者(你自己) C语言有吗? 有, const char*在 C 里也有没有,这是 C++ 专有的类特性 记忆口诀 “我给你的字符串,你只能看,不能改” “我叫 name 的时候,绝不会动类的内部状态” 4. 用对比代码看清楚它们的分工
假设你的类里有一个
std::string m_name;:cpp
class Example { private: std::string m_name; public: // 错误示范:返回值没有 const,末尾也没有 const char* badName() { return &m_name[0]; } // 调用者可以改 m_name,且不能用于 const 对象 // 正确示范:返回常量指针 + 末尾 const const char* goodName() const { // 1. 末尾 const 保证这里不能写 m_name = "xxx"; // 2. 返回 const char*,保证调用者不能改 m_name return m_name.c_str(); } };在 EDA 工具里,你经常需要把电路的信息(比如门类型)打印出来,但绝不允许打印的时候意外改掉电路的状态。所以这种
const char* name() const的模式是业内标准写法。总结一句话:左边的
const保护数据不被外部改,右边的const保护对象内部不被函数改。两个合在一起,就是“我安全地告诉你我的名字,你安心读,我保证不改自己”。
Q:没太理解。你的意思是说,我们这里函数的返回值是一个const常量的char指针,指针的名字叫name?而后,如果我们写了一个成员函数的const,我们就不能在这个函数里面写name="一串字符"?是这个意思吗?主要是,你说保护变量不被修改,这点我理解,因为你举的例子确实需要变量不被修改,这个代码也没修改and门or门的名字。但是,函数体内,return "AND"又是什么呢,它并不是在对char进行赋值吗?如果是不就是修改了吗,如果不是那这个实例里关于对象的名字变量又是什么呢?还是说,这种写法避开了写出一个显示的名字变量,就为了名字变量不被更改?
A:
1. 纠正一个小误解:
name是函数名,不是指针名你问“指针的名字叫
name”——不,name是函数名,不是变量名。这行声明应该按顺序读:
const char*(返回类型) +name(函数名)+( )(无参数)+const(尾部修饰符)它返回的那个指针是临时的,没有名字。在
main里gate->name()拿到这个指针后,直接送给std::cout打印,这个指针用完就消失了,没有名字。2.
return "AND"是在“赋值”吗?如果不是,那它的本质是什么?不是赋值,是“返回地址”。
"AND"是一个字符串字面量(String Literal)。它不是一个普通的变量,它在编译时就被写进了你的可执行文件里(放在只读数据段,比如.rodata区域)。当程序运行时,这段内存已经存在了,它有一个固定的内存地址(比如
0x400123)。
return "AND";所做的,就是把这个固定的内存地址返回给调用者。它没有创建新字符串,没有复制字符,只是在传递一个地址。所以,我们并没有修改任何东西,也没有往一个变量里“写”字符串。我们只是说:“
name()这个函数的返回值,就是"AND"这个字符串的地址。”3. 那个“对象的名字变量”在哪里?
在这个代码里,根本没有一个叫
m_name的成员变量。这是一个纯接口类(
LogicGate),它只规定了“你必须有name()这个函数”,但没有规定“你必须用一个变量来存名字”。
AndGate选择直接返回字面量"AND"。
OrGate选择返回"OR"。它们没有在对象内部存名字,而是把名字“固化”在了代码里。这节省了内存(每个对象少了一个
std::string的开销),并且让代码极简。所以你说“避开了写出一个显式的名字变量”——完全正确!这就是一种设计策略。4. 为什么返回值必须加
const?(这就是你直觉里最核心的部分)既然
"AND"在只读数据段,如果你返回char*(不带const),调用者可能会尝试去写它:cpp
char* p = gate->name(); // 假设没有 const p[0] = 'X'; // 尝试把 "AND" 改成 "XND"这会导致程序崩溃(因为往只读内存里写数据)。
所以,
const char*是在保护这块底层物理内存不被意外篡改。它告诉调用者:“这块内存是只读的,你如果想写,编译器会直接拒绝你。”5. 末尾的
const(尾巴上的)到底在保护什么?尾巴上的
const(... name() const)保护的是类里的成员变量。在这个例子里,
LogicGate类没有成员变量,所以这个尾巴看起来“没用”。但是,如果未来我们给AndGate加了一个int m_fanout成员变量,情况就变了:cpp
class AndGate : public LogicGate { private: int m_fanout; // 假设有了这个变量 public: const char* name() const { // m_fanout = 10; // 错误!因为有尾巴 const,这里不能修改成员变量return "AND"; } void setFanout(int f) { m_fanout = f; // 正确!没有尾巴 const,这里可以修改 } };尾巴上的
const保证了:当你通过一个常量引用(const auto& gate)去调用name()时,这个函数绝对不会偷偷改动m_fanout或任何其他状态。这使得只读访问变得绝对可靠。6. 用一句话总结你的困惑
“
return "AND"并不是在赋值,而是在提交一块已经存在的只读内存的地址。因为这块内存是只读的,所以返回类型必须是const char*来声明这一点。尾部的const则是用来确保这个函数在执行过程中不会改动类的内部数据。”在这个场景里,对象里没有名字变量,名字被“硬编码”在代码段里。写
const不是为了保护变量(因为压根没变量),而是为了保护数据段里那块只读内存,以及保证只读调用的安全性。你的直觉“避开了写出一个显式的名字变量”非常准确——这正是工程师为了性能和极简设计所做的选择。