ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

C++类作用域、实例化、对象模型与this指针因果链解析

C++类作用域、实例化、对象模型与this指针因果链解析 那天下午A同学把一段代码发过来问为什么编译器给出的报错信息指得七零八落。代码本身不长是类模板里引用后面才定义的成员还有一处成员函数返回类型没带类名限定。看起来都是语法背得挺熟的写法一编译就炸。我逐行拆完发现坑全挤在四个点上类的作用域、类的实例化、类对象模型、this 指针。这四个词几乎是每一本 C 教材的必讲章节但多数人只是把结论记住了一旦编译器行为跟直觉不一致就完全不知道该往哪儿查。这篇文章我想把这条链子重新捋一遍不按教材的目录顺序念而是按编译器到底怎么想的顺序讲。四个概念之间其实是因果关系类的作用域决定了名字怎么找类的实例化决定了内存什么时候给类对象模型决定了这块内存里摆了些什么而 this 指针则是对象模型的一个直接推论——成员函数怎么知道自己该操作哪个对象。搞清这条因果链很多看起来莫名其妙的报错和运行时行为反而会变得非常好解释。内容偏基础但不会停留在是什么。每个点我都会配上能直接运行的代码、sizeof 的实测结果、以及我在实际项目里踩过的具体坑。适合刚学完类和对象语法、正在被各种诡异行为折磨的同学也适合已经写了几年、但对象模型这块一直是模糊印象的开发者重新校准一遍。1. 四个概念其实是一条链先把关系理清楚1.1 从一次典型的编译失败说起先看一段能让编译器直接拒绝的代码这段代码是我用来给别人讲作用域时最常用的例子class Buffer { public: Handle get() { return Handle{}; } // 编译报错Handle 未声明 using Handle int; };报错信息大概意思是Handle在这个位置不是一个类型名。很多人第一反应是我在类里面定义了Handle啊怎么会找不到。问题就在于函数返回类型的名字查找发生在它出现的位置而不是在类定义结束之后。Handle的声明在后面此处还不可见于是查找失败。把它改成先声明类型、后写函数或者把返回类型挪到类外定义并使用尾置返回类型问题立刻消失class Buffer { public: using Handle int; Handle get() { return Handle{}; } // 正常 };这个例子说明的第一个道理是类是一个作用域但它不是一个整体生效的作用域。类作用域内部的名字可见性受声明顺序约束。而成员函数体是例外它被当作在类完全定义之后才处理所以函数体里可以直接引用后面声明的成员。这个细节差异正是类的作用域这一节要讲的核心。1.2 为什么必须先讲作用域再讲实例化我见过不少人学这块的顺序是反的先背类是模板对象是实例再背成员函数不占对象空间最后背this 是隐藏参数。三条都背下来了但串不起来。真正的依赖关系是这样的——编译器在处理类定义时先建立一个作用域把所有成员名字登记进去等到定义对象时才按这个作用域里登记的信息去算大小、分配内存算大小的规则就是对象模型而成员函数之所以能操作对象是因为编译器在函数签名里偷偷加了一个指向对象的参数也就是 this。换个说法作用域决定了有哪些成员实例化决定了什么时候真的存在一个对象对象模型决定了这个对象占用哪些字节this 决定了函数怎么找到这些字节。少了任何一环后面都会出现无法解释的现象。比如常见的疑问为什么空类 sizeof 是 1答案在对象模型为什么 const 成员函数不能改成员答案在 this 的类型为什么类外的成员函数定义要写两遍类名答案在作用域。2. 类的作用域编译器找名字的那张地图2.1 类作用域到底新增了什么每次写class X { ... };编译器就打开了一个新的作用域比它所在的外围作用域更内层。所有在类体内声明的名字——成员变量、成员函数、嵌套类型、枚举、类型别名——都登记在这个作用域里。在类内直接写名字编译器会先在类作用域找找不到再往外层找在类外想访问必须用X::限定否则它会从当前所在的作用域开始找很可能找到别的东西或者根本找不到。int count 100; // 全局变量 class Counter { public: void reset() { count 0; } // 改的是成员还是全局 int count 0; // 成员 };这段代码里reset改的是成员count全局的count毫发无损。原因就是类作用域比全局作用域更内层内层优先。如果你确实想改全局那个得写::count 0。这个规则看起来平平无奇但在实际项目里非常容易出事——某个成员名字不小心和全局函数同名或者在派生类里重名就会出现我明明改了变量行为却没变的情况排查半天。类作用域还有一个重要特点它不会像命名空间那样被using整个拉进来你只能通过using声明把单个名字暴露出去。这意味着类作用域是相对封闭的这对封装是好事但也意味着类外访问必须老老实实写限定符。2.2 声明顺序带来的两个不同世界类内部的声明顺序对名字查找的影响分成两种截然不同的情况这一点很多资料一句话带过其实值得单独说清楚。第一种是非成员函数体之外的位置包括成员变量的类型、函数参数类型、返回类型、using别名定义本身。这些位置的名字查找发生在声明点按顺序生效只能看到前面已经声明的东西。前面那个Handle的例子就是这一类。第二种是类完成定义之后才处理的位置标准里叫完整类上下文主要包括成员函数体、默认实参、noexcept说明符、默认成员初始化器。这些位置的名字查找会把整个类作用域视为可见所以可以引用后面才声明的成员class Widget { public: void show() { value 1; } // 合法value 在后面声明 int get(int k limit) const { // 合法默认实参可用后面的成员 return value k; } private: int value 0; int limit 10; };这解释了为什么有时候先引用后声明能编译通过有时候又不行——完全取决于你写在哪个位置。我自己的习惯是尽量不依赖这个特性成员变量统一写在类的最前面函数写在后面。好处是阅读时不用来回跳坏处是有人说这样不符合接口在前、实现在后的风格。这个问题没有标准答案但至少要清楚规则而不是靠试错。2.3 类外定义成员函数时返回类型为什么不在类作用域里这个问题几乎每个初学者都问过。先看代码class List { public: using Iter int*; Iter begin(); }; 要写成 Iter List::begin() { ... } // 错Iter 在这里不可见 List::Iter List::begin() { ... } // 对原因在于名字查找的方向是从左往右。编译器读到返回类型Iter的时候还没读到List::begin自然不知道这次查找应该进入List的作用域所以它只能在全局作用域里找找不到就报错。等它读到List::begin才切换到类作用域但返回类型早就解析过了。解决办法有两个。一是把返回类型写全List::Iter缺点是嵌套类型一多签名会长得没法看。二是用尾置返回类型把返回类型挪到函数名后面此时已经在类作用域内了auto List::begin() - Iter { ... } // 干净我在实际项目里更倾向第二种尤其是返回类型是模板嵌套依赖类型的时候尾置写法几乎是唯一可读的选择。这里有个小注意尾置返回类型和前置返回类型在语义上完全等价没有性能差异纯粹是可读性和名字查找的便利。2.4 嵌套类、枚举与继承带来的作用域叠加嵌套类是类作用域里再开一层。Outer::Inner这样的类型在Outer的成员函数里可以直接写Inner在类外必须写Outer::Inner。这里最容易踩的坑是嵌套类不能直接访问外层类的非静态成员。原因是嵌套类没有隐含的this它和外层类的对象之间没有绑定关系除非你把外层对象以参数或引用的形式传进去。class Outer { int secret 42; public: class Inner { public: int peek(Outer o) { return o.secret; } // 必须显式传对象 }; };继承则是在作用域层面形成一种覆盖关系。派生类的作用域被视为嵌套在基类作用域内部所以在派生类里查找名字时先找派生类找不到再找基类。这带来一个常被忽视的现象派生类的同名成员会完全隐藏基类的同名成员不管签名是否相同。基类有个void foo()派生类有个void foo(int)在派生类里写foo()会报错因为编译器在派生类作用域里发现了foo这个名字就停止向外查找而它又找不到无参版本。想用基类版本必须写Base::foo()或者在派生类里加一句using Base::foo;。枚举也有类似的历史包袱。传统enum把枚举项的名字泄漏到外层作用域两个不同的 enum 定义相同的枚举项名字就会冲突enum class把名字锁在枚举自己的作用域里用的时候必须写EnumName::Item。我在新代码里一律用enum class一是避免名字污染二是它不会隐式转换成整数能挡掉一批类型混用的错误。3. 类的实例化从图纸到实体都发生了什么3.1 类只是图纸定义它不占用任何对象内存先把一个容易被含糊过去的事实说死定义类不会为任何对象分配内存。编译器在这个阶段做的事只有检查语法、登记成员信息、构建类型描述产出的东西主要在编译器的符号表里最终体现在可执行文件的元信息里对象内存一个字节都不会给。类比一下类是建筑图纸对象是照图纸盖出来的房子。图纸本身不占地你在图纸上改多少遍只要不盖房子就没有混凝土消耗。这个比喻虽然老但能解释很多现象比如为什么可以在头文件里被无数个源文件包含却不会产生重复定义问题类定义可以在多个翻译单元中出现只要完全一致为什么静态成员变量的定义必须放到类外它是一个实体需要分配存储。实例化的动作由定义对象触发class Point { public: int x 0; int y 0; void move(int dx, int dy) { x dx; y dy; } }; Point p; // 这里才分配内存 Point* ptr nullptr; // 指针本身占内存但没有 Point 对象Point*只是存了个地址此时并没有任何Point对象存在。指针大小固定和Point的大小无关这一点在面试里被问烂了但真正理解指针不持有对象能帮你避免很多悬空访问。有一个例外需要说明类模板的实例化时机和普通类不同。模板定义本身不产生任何代码只有当它被具体类型实例化时编译器才生成对应的类和成员函数。这也是为什么模板代码出问题时报错信息常常长得离谱——错误发生在实例化点而不是你写代码的地方。解决模板报错的经验是先把模板改成具体类型试一遍确认逻辑没问题再换回模板能省掉一大半排查时间。3.2 对象大小的构成从 sizeof 反推布局对象的大小由三部分组成非静态成员变量的大小、为满足对齐要求插入的填充字节、以及存在虚函数时可能存在的虚表指针。静态成员变量、成员函数、类型别名、嵌套类型统统不计入sizeof。class A { public: void f() { } static int s; using Ptr int*; private: char c; };实测sizeof(A)是 1。成员函数f不进对象静态成员s存在静态区类型别名和嵌套类型不占空间只剩一个char。如果加上一个intclass B { char c; int i; };sizeof(B)在常见的 64 位平台上一般是 8不是 5。因为int要求起始地址是 4 的倍数char后面补了 3 个字节总大小是 8同时也是 4 的倍数结构体整体大小要补到最大对齐数的整数倍。这里有个非常实用的优化技巧很多人不知道调整成员声明顺序可以减小对象大小。class C1 { char a; int b; char c; }; // 通常 sizeof 是 12 class C2 { int b; char a; char c; }; // 通常 sizeof 是 8C1的布局是 a(1) 填充(3) b(4) c(1) 填充(3)C2是 b(4) a(1) c(1) 填充(2)。同样三个成员顺序不同差了 4 个字节。如果一个类会被创建上百万次这 4 字节乘以对象数量可能就是几十兆的差距。我现在的习惯是成员按类型大小从大到小排列double、指针、int、short、char、bool依次往下能省掉大部分填充。提示sizeof的结果取决于编译器和目标平台的对齐规则不同环境下同一个类的大小可能不同。涉及二进制序列化或者跨进程共享内存的场景必须显式确认布局不能凭 sizeof 猜。3.3 空类为什么是 1 字节这个问题几乎必被问到。空类没有任何成员sizeof却是 1原因是语言要求每个对象都必须有唯一的地址。如果空类大小为 0那么连续创建两个对象它们的内存地址就会相同a b成立这会让指针比较、数组下标、容器实现全部失去意义。为了区分不同的对象编译器给空类分配至少 1 个字节。class Empty { }; Empty a, b; // a ! b 恒成立还有一个延伸现象空类作为基类时为了不让基类子对象占据独立地址语言引入了空基类优化派生类的大小不因为继承空类而增加。但同一个空基类被继承多次就不行那属于多重继承的范畴会涉及到基类子对象的地址必须互不相同的问题。这些细节平时写业务代码用不上但在写序列化、内存池、类型擦除组件时会直接影响布局计算必须心里有数。3.4 构造顺序与实例化的完整时序一个对象从无到有发生的事按顺序是分配存储空间 → 初始化基类子对象 → 初始化非静态成员按声明顺序不是初始化列表顺序→ 执行构造函数体。这个顺序是强制规定的和你在初始化列表里写的顺序无关。class Order { int a; int b; public: Order() : b(2), a(1) { } // 实际仍是先初始化 a再 b };如果两个成员的初始化有依赖关系顺序写反了就会读到未初始化的值而且这种错误往往不会立刻崩溃只是在某些编译器优化级别下表现异常排查起来极其痛苦。我的做法是初始化列表的顺序永远和成员声明顺序保持一致一眼就能对上同时打开编译器的顺序警告。还有一点值得提只有定义对象才会调用构造函数。声明一个返回类型的对象、声明一个函数指针、定义一个引用都不会触发构造。以前有个同学问我为什么我把构造函数里加了个打印运行时一次都没输出看了代码发现他定义的是函数声明编译器把它解析成了返回对象的函数——就是最经典的 vexing parse。4. 类对象模型内存里到底摆了些什么4.1 成员函数不占对象空间这是理解对象模型的第一块砖很多人第一次听到成员函数不占对象内存会觉得不可思议既然不占那它怎么知道该操作哪个对象的成员答案就是第五节的 this 指针但在这里先把不占这件事讲透。成员函数在编译之后本质上就是普通的自由函数只不过第一个参数被编译器偷偷加上了对象指针。它存在代码段所有同类对象共享同一份指令不因为你有 100 个对象就复制 100 份。只有这样类才能不因为功能复杂而膨胀——你可以给一个类写 50 个成员函数sizeof一个字节都不会变。class Calc { int total 0; public: void add(int v) { total v; } void clear() { total 0; } };sizeof(Calc)在常见平台上就是 4和只有一个add方法时完全一样。这个特性是 C 相比某些纯面向对象语言在内存上的优势之一行为不占对象空间只有数据占。更细一点虚函数会影响对象大小但影响的方式不是把 50 个虚函数各自塞进去而是塞一个指针不同实现可能不同常见实现是每个类一张虚表每个对象一个指向虚表的指针。所以一个只有虚函数、没有数据成员的类在 64 位平台上通常是 8 字节。首次听到这个结论的人常常误以为是数虚函数的个数那是误解。4.2 访问控制不影响内存布局public、private、protected只是编译期的检查运行时的内存布局里没有任何标记。编译器不会因为成员是私有就给它加密也不会改变排列顺序。class Same1 { public: int x; int y; }; class Same2 { private: int x; int y; };这两个类的大小和布局完全一样。这意味着如果你通过某种手段绕过访问控制比如reinterpret_cast到相同布局的结构体是可以直接读写私有成员的。这种做法非常危险因为编译器是允许重新排列不同访问段成员的标准里也没保证私有成员一定放在公有成员之后一旦换编译器或者改代码布局就可能变化直接崩溃。我在实际项目里遇到过类似的事有人为了和某个外部二进制格式对齐用reinterpret_cast把对象直接写到文件里线上跑着还挺稳。后来加了一个私有成员前面所有字段全部错位读出来的数据全乱了。教训是跨边界传递对象必须显式序列化每一个字段不能依赖内存布局。这条经验我在几个项目里反复见到被违反最后都免不了重构。4.3 单继承下的布局与基类子对象的位置单继承时基类的子对象通常放在派生类对象的起始位置。所以把派生类对象的地址转换成基类指针偏移量一般是零class Base { int a; }; class Derived : public Base { int b; };Derived的对象布局是 Base 子对象在前b在后。Base* p d;不需要做任何地址调整。这个规则看似天经地义其实有前提单继承、非虚继承。多继承时第二个及以后的基类子对象会有非零偏移指针转换需要调整地址这就引出了著名的多重继承指针值不同的现象。我见过很多人调试时打印指针地址发现基类指针和派生类指针的值不一样怀疑类型转换出错。实际上这是标准允许的转换本身是对的关键在于子对象在内存里的位置不同。如果拿这个指针去做哈希或者比较很容易出 bug必须统一类型后再比较。4.4 虚函数带来的模型变化一旦类里有虚函数对象模型就多了一层间接。常见的实现方式是每个有虚函数的类生成一张虚函数表每个对象内部放一个指向这张表的指针一般放在对象起始位置。这样调用虚函数时先通过对象的指针找到虚表再按固定位置取出函数地址实现运行时多态。影响有三点值得注意。第一对象大小会增加常见的 64 位平台上是 8 字节和虚函数个数无关。第二对象的前几个字节不是你的成员数据如果依赖内存布局去读对象必须把这个指针算进去。第三构造和析构期间虚表指针会发生变化所以构造函数和析构函数里调用虚函数不会表现出多态行为——这个坑我在带新人的时候几乎每个季度都要讲一次。class Base { public: Base() { init(); } // 调的是 Base::init virtual void init() { /* ... */ } }; class Derived : public Base { public: void init() override { /* ... */ } // 构造 Base 期间不会调到这 };原因很直接构造基类子对象的时候派生类部分还没构造完对象此时就是基类对象虚表指针指向基类的表自然调用基类版本。这不是设计缺陷是语言为了保证类型安全做出的规定。5. this 指针每个非静态成员函数背后的隐形参数5.1 this 不是对象里的字段而是函数的一个隐藏参数这是关于 this 最常见的误解必须先纠正。this 不是对象内存里的一个成员它不占对象大小也不会出现在布局里。它本质上是编译器给每个非静态成员函数偷偷添加的第一个参数。class Acc { int val 0; public: void set(int v) { val v; } }; Acc a; a.set(5);在编译器眼里大致等价于struct Acc { int val; }; void Acc_set(Acc* self, int v) { self-val v; } Acc_set(a, 5);函数体里写val编译器自动翻译成self-val。这个self就是 this。所以 this 的生命周期和函数调用一致函数进来它就有了函数返回它就失效了存下来也不好使。至于它具体放在哪里标准没有规定。常见的实现是放在寄存器里有些平台参数多的时候可能压栈。所以this 在栈上或者this 在堆上这类说法都是错的——它只是函数调用的一部分不构成对象的一部分。5.2 const 成员函数改的是 this 的类型理解 this 的类型const成员函数的行为就变得顺理成章。在非 const 成员函数里this 的类型是ClassName* const——指向对象的指针本身不可变不能让它指向别处但指向的内容可以改。所以你能修改成员变量。在 const 成员函数里this 的类型变成const ClassName* const——既不能指向别处也不能修改所指内容。所以编译器禁止你修改任何非 mutable 成员。这不是什么特殊魔法就是一层的 const 限定。class Store { int data 0; mutable int hits 0; public: int get() const { hits; // 合法mutable // data; // 编译错误const 对象 return data; } };mutable的用途是标记逻辑上不改变对象状态的成员比如缓存计数、懒加载的缓存值、调试用的访问次数。但用多了会破坏 const 的语义我一般限制在性能优化场景业务字段绝不加。基于 this 的 const 差异还能实现 const 与非 const 版本的重载class Box { int v 0; public: int at() { return v; } const int at() const { return v; } };非 const 对象调用非 const 版本拿到可写引用const 对象只能拿到 const 引用从编译期就堵住了越权修改。这是 C 容器普遍采用的模式写自己的类时照着做接口的一致性会好很多。5.3 空指针调用成员函数到底会发生什么这是面试和实战里都很经典的问题。看代码class Ghost { int secret 1; public: void hello() { /* 不访问任何成员 */ } int reveal() { return secret; } }; Ghost* g nullptr; g-hello(); // 很多平台上不崩溃 g-reveal(); // 大概率崩溃为什么hello()不崩溃因为编译后它就是一个普通函数参数传进去是 nullptr函数体里没有解引用自然没毛病。而reveal()要读secret等价于self-secretself是空指针读取地址接近 0 的内存通常触发访问违例。但这绝不意味着空指针调成员函数是安全的。这是未定义行为标准层面没有任何保证。编译器完全可以在函数体开头插入对 this 的空检查或者因为优化把不会为空的指针当作前提从而删除后面的空判断。我实际遇到过同一段代码在调试构建下跑得好好的开了优化就崩排查了半天才发现是某个回调里对象已经销毁但指针没清。真正稳妥的做法只有两条一是成员函数里操作成员之前确保对象存活二是对于可能为空的对象指针调用前显式判断。指望只调用不访问成员的函数来规避崩溃是在赌编译器行为不值得。5.4 返回 *this 与链式调用this 最实用的场景之一就是让多个操作串成一句。原理很简单成员函数返回*this的引用下一个调用就作用在同一个对象上。class Builder { std::string buf; public: Builder append(const std::string s) { buf s; return *this; } Builder wrap(const std::string l, const std::string r) { buf l buf r; return *this; } const std::string get() const { return buf; } }; std::string r Builder{}.append(hello).wrap([, ]).get();返回引用而不是值是为了让链式调用作用在同一个对象上同时也是为了避免无谓的拷贝。如果返回Builder值每次链式调用都在操作临时副本最终结果就丢了。如果返回指针调用方还得写箭头可读性差。有个细节要注意返回*this的引用前提是对象在调用链期间一直存活。如果这个对象是栈上的临时对象把它返回出去就是悬空引用那就踩到生命周期问题上了。我的习惯是这类链式方法只用于局部构造不跨越函数边界传递。5.5 静态成员函数为什么没有 this静态成员函数属于类本身不属于任何对象它被调用时没有具体的对象自然就没有 this。这也导致静态成员函数里不能直接访问非静态成员——编译器找不到self。class Util { int x 0; public: static void reset(Util obj) { // x 0; // 错误没有 this obj.x 0; // 显式传对象才行 } };实践中静态成员函数常被用来做工厂函数、工具函数、与类相关的全局逻辑。它和普通函数相比只多了个作用域归属调用时不用传对象也正因为如此它不能被声明为 const——const 是给 this 加限制的没有 this也就没有 const 的意义。还有一点容易忽略静态成员函数可以被取地址成普通函数指针因为它不需要绑定对象。非静态成员函数取地址得到的不是普通函数指针要调用还得配一个对象这是类型擦除和回调注册时经常卡住的地方。6. 常见问题排查表与几条实操心法6.1 高频报错速查把我在带人过程中反复见到的现象整理成一张表遇到时可以按图索骥现象常见原因处理方式编译报不是类型名或未声明名字在当前声明点尚未可见调整声明顺序或改用尾置返回类型类外定义返回嵌套类型报错返回类型不在类作用域内写成Class::Type或尾置返回类型sizeof比成员之和预期大内存对齐填充按类型大小重排成员从大到小空类sizeof不是 0对象地址唯一性要求正常现象无需处理派生类里调不到基类同名函数派生类名字隐藏加using Base::func;或显式限定静态成员变量链接错误只声明未定义类外补定义或使用内联静态成员const 对象调成员函数报错缺少 const 版本成对提供 const 与非 const 重载构造函数里调虚函数没走派生版本构造期间虚表尚未切换把初始化逻辑移到专门的 init 方法优化后运行崩溃调试版正常空指针调成员函数未定义行为确保对象存活调用前判空派生类指针转换后地址变了多重继承子对象偏移统一类型再比较或哈希6.2 我自己坚持的几条实践原则第一条也是最重要的一条涉及对象布局的地方一律不靠猜。只要是sizeof、偏移、序列化、跨进程共享内存这类场景我都会写一段最小编译单元把sizeof和offsetof打印出来验证而不是照着记忆算。不同平台、不同编译选项、不同对齐设置都会导致结果不同凭经验算十次有八次是对的剩下两次就是线上事故。第二条成员函数返回嵌套类型时优先用尾置返回类型。这个习惯让我省掉了大量Class::Type的重复书写也让签名在代码格式化之后依然清晰。有人觉得auto f() - T不直观但只要项目统一读起来是没障碍的。第三条this相关的代码尽量写显式。比如链式调用返回*this时方法名以动词开头明确表示这是一个会修改对象状态的操作只读方法一律加 const让编译器帮我判断谁在越权修改。我合作过的一个团队有过硬性规定所有不修改成员状态的成员函数必须加 const否则代码评审不过。坚持一年下来很多逻辑错误在编译期就被拦住了。第四条对象大小变化要当成一次接口变更对待。一旦某个类被放进容器、内存池或者共享内存改动它的成员布局可能引发难以发现的越界写入。我的做法是在类旁边加一个静态断言把预期的大小写死class Rec { int id; int flags; }; static_assert(sizeof(Rec) 8, layout changed, check serialization);这条断言在 CI 上跑一旦布局变化立刻报警逼着改的人去检查所有相关的读写代码。看似多写一行实际上挡掉过好几次隐蔽的数据错位问题。还有一条关于学习路径的体会。这四个概念单拎出来都不难难的是把它们串起来用。我建议的复盘方式是随便找一段自己写过的类从编译器眼中它长什么样开始手动画出它的内存布局标出每个函数编译后的签名标出每个this的类型再看 const 在哪里起作用、名字在哪个作用域被找到。这样过一遍比把教材翻三遍有用得多。等到下一次再看到莫名其妙的报错你脑子里浮现的就不是语法怎么写的而是编译器处理到哪一步、在哪个作用域查找、手里拿的是哪个地址。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表