C++学习(9):继承与多态-auto、引用、const变量和const函数

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::vectorstd::unique_ptrLogicGate gates; gates.push_back(std::make_uniqueAndGate()); gates.push_back(std::make_uniqueOrGate()); 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它的作用是让编译器根据初始化表达式的类型自动推断出变量的类型。它不是“动态类型”运行时改变而是“编译时自动填充”。在你的代码里cppfor (const auto gate : gates) { ... }编译器看到gates是一个std::vectorstd::unique_ptrLogicGate它会自动推断出gate的类型应该是const std::unique_ptrLogicGate也就是一个指向LogicGate的智能指针的常量引用。你可以把它理解为“打字省力工具”但更重要的作用是让代码在模板编程中更通用。你不需要记住那个长长的类型名编译器会帮你填好。2.std::make_uniqueAndGate()创建智能指针C语言视角在C语言中你会在堆上分配结构体比如struct AndGate* p (struct AndGate*)malloc(sizeof(struct AndGate));然后手动free。C 的make_unique它是一种安全、简洁地在堆上创建对象并返回“独占所有权”智能指针的函数。new AndGate()相当于malloc 调用构造函数。std::make_uniqueAndGate()则直接在堆上构造一个AndGate对象并把它包装进一个std::unique_ptrAndGate智能指针里返回。为什么要这么写因为std::unique_ptr是一个“独占指针”它离开作用域时会自动delete它指向的对象。你永远不需要手动写delete对应C语言的free从而杜绝了内存泄漏。这里的std::make_unique是 C14 引入的如果是C11通常会用std::unique_ptrAndGate(new AndGate())但前者更安全避免某些异常导致的泄漏。3.override显式重写标记C语言视角C语言没有类的继承自然也没有这个概念。C 的override这是一个编译时检查的关键字。它的作用是告诉编译器“我明确声明这个函数是要重写覆盖基类里的那个虚函数。”。如果你的函数名拼错了或者参数类型写错了导致没有匹配到基类的虚函数编译器就会直接报错而不是悄悄地把它当作一个全新的独立函数。在你的代码里cppbool 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 循环这是 C11 引入的语法糖专门用来遍历容器如vector、数组。gates要遍历的容器。gate每一次循环拿到的元素。constauto表示gate是元素的常量引用不拷贝只读。如果写成auto gate它会复制一份智能指针虽然无伤大雅但浪费性能。使用const常量引用是 C 的惯用法表示“只读取不改动且不复制”。它在底层翻译成的逻辑就是cppfor (auto it gates.begin(); it ! gates.end(); it) { const auto gate *it; // ... }但范围 for 循环把样板代码省掉了可读性更高。6. 成员函数后面跟const这是一个 C 特有的小细节cppvirtual 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. 指针和引用的区别这点很重要cppint main() { int num 10; int* p num; // 传统指针 auto p1 num; // p1 的类型是 int* auto* p2 num; // 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::vectorstd::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::mapstd::string, std::vectorint complex_data; // 不用 auto 的痛苦写法 std::mapstd::string, std::vectorint::iterator it1 complex_data.begin(); // 用了 auto 的快乐写法 auto it2 complex_data.begin(); // 类型完全一样但代码短了一大截 return 0; }Q奇怪所以引用是什么样的当你写auto pa它是把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 aauto在这里只是推导出了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-常量指针和常量函数Qconst 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对象调用。在你的代码里cppfor (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;cppclass 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进行赋值吗如果是不就是修改了吗如果不是那这个实例里关于对象的名字变量又是什么呢还是说这种写法避开了写出一个显示的名字变量就为了名字变量不被更改A1. 纠正一个小误解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调用者可能会尝试去写它cppchar* p gate-name(); // 假设没有 const p[0] X; // 尝试把 AND 改成 XND这会导致程序崩溃因为往只读内存里写数据。所以const char*是在保护这块底层物理内存不被意外篡改。它告诉调用者“这块内存是只读的你如果想写编译器会直接拒绝你。”5. 末尾的const尾巴上的到底在保护什么尾巴上的const... name() const保护的是类里的成员变量。在这个例子里LogicGate类没有成员变量所以这个尾巴看起来“没用”。但是如果未来我们给AndGate加了一个int m_fanout成员变量情况就变了cppclass 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不是为了保护变量因为压根没变量而是为了保护数据段里那块只读内存以及保证只读调用的安全性。你的直觉“避开了写出一个显式的名字变量”非常准确——这正是工程师为了性能和极简设计所做的选择。