资讯动态

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

发布时间:2026/10/27 6:56:56 来源:尧图企业网站定制
写了几年 C回头看类的作用域、类的实例化、类对象模型和 this 指针这四个词我最大的感受是它们单独拎出来都能背两句结论合在一起却经常互相拆台。前阵子帮一位朋友看代码他维护了半年的一个后台模块编译能过运行到一个看似无害的地方直接崩掉。问题出在类外定义成员函数时漏了类名和作用域解析符编译器把它当成了全新的全局函数类里那条声明成了只有声明没有定义的壳子链接期报了一堆未定义符号。他盯着屏幕看了半小时说了句“我明明写了啊”。“我明明写了”这五个字差不多概括了初学这部分内容时的大部分困惑。比如为什么我在类里定义的函数类外还要再写一遍类名为什么我加了一堆成员函数sizeof出来的数字纹丝不动为什么用空指针调用成员函数有时候竟然能“跑通”这些问题不搞清楚后面学继承、多态、虚函数表的时候会一路踩坑。这篇想做的事情是把这四个点当成一个整体来讲而不是四段孤立的八股。它们本来就是同一套机制的不同侧面类域决定了编译器去哪儿找名字实例化决定了内存里到底有没有东西对象模型决定了那堆东西怎么摆this 指针则是把对象和成员函数缝在一起的那根线。理解其中任何一个都绕不开另外三个。1. 类域是一道看不见的墙名字查找为什么总在墙内先发生1.1 类外定义成员函数时那个双冒号省不掉class关键字后面那对大括号除了圈出一堆成员还在语言层面创造了一个新的作用域。这个作用域和命名空间、函数体、块作用域是同等级别的概念只是它没有运行期实体只活在编译器的符号表里。编译器在整个翻译单元里处理名字时会先确定“当前在哪个作用域”再按规则往上找。类外定义成员函数是这条规则最直观的体现。void Counter::Add(int n)里的Counter::不是装饰它是在告诉编译器接下来这个Add属于Counter的类域不是全局的。少了它编译器会认为你在全局作用域里声明并定义了一个全新的函数和类里那个Add毫无关系。class Counter { public: void Add(int n); int Get() const; private: int _value; }; // 正确Add 属于 Counter 类域 void Counter::Add(int n) { _value n; } // 错误这是一个全新的全局函数 void Add(int n) { _value n; } // _value 未声明直接编译失败第二种写法之所以失败不是因为“缺少限定符”这个提示而是因为函数体里找不到_value。编译器此时根本不知道Add和Counter有半毛钱关系_value在全局作用域里当然不存在。更有迷惑性的一种情况是类里声明了Add但没定义类外又写了void Add(int n) {}函数体里不碰任何成员编译能过全绿链接期才冒出unresolved external symbol public: void __cdecl Counter::Add(int)。这条报错链路特别绕很多新手会顺着链接器提示去翻头文件其实问题就出在那个漏掉的双冒号上。还有一种写法是内联在类里直接定义void Add(int n) { _value n; }这时不需要限定符因为它本来就在类域里。类里定义和类外定义在语义上等价inline属性略有差别但和对象大小完全无关后面会讲到。1.2 成员函数体里的名字查找顺序决定了同名冲突谁赢成员函数体内出现的名字查找顺序是先看函数局部作用域包括参数和局部变量再进类域最后才到全局作用域和外围命名空间。这个顺序是硬性的不随你写代码的习惯改变。int value 100; class Box { public: void Show() { std::cout value; // 输出的是成员的 value不是全局的 100 } int value 1; };这段代码很多人第一反应会以为打印 100实际打印 1。因为类域比全局作用域“更近”找到了就停不会继续往外翻。如果确实想用全局那个得显式写::value。同理如果全局有一个void f()类里也有一个void f()成员函数里直接调f()调的一定是成员版本想调全局版本同样要加::。这条规则还能解释一个初学者常觉得神奇的现象在类内定义成员函数时可以直接使用后面才声明的成员变量。class Order { public: int Total() const { return _price * _count; } // _count 在后面才声明 private: int _price 0; int _count 0; };能这么写是因为类的完整作用域对成员函数体是“延迟可见”的编译器会先把整个类扫一遍再回头处理函数体。专业的说法是成员函数体处于 complete-class context。这个特性在跨语言对比时很容易让人困惑别的语言大多要求先声明后使用C 在类内给了这个例外。1.3 嵌套类、友元与类域的边界关系嵌套类是定义在另一个类内部的类它的名字挂在外围类的类域下所以外部使用时必须写Outer::Inner。嵌套类对象和外围类对象之间没有任何自动绑定它没有隐含的指向外围对象的指针想访问外围对象的非静态成员必须自己把对象传进来。class Outer { private: int _x 0; static int _s; public: class Inner { public: void Touch(Outer o) { o._x 1; // 合法Inner 是 Outer 的成员拥有同等访问权限 _s 2; // 合法静态成员不需要对象 } }; };这里容易搞混的是访问权限的来源。Inner能碰Outer的私有成员不是因为“嵌套”这个语法糖而是因为Inner本身就是Outer的一个成员成员之间本来就可以互访私有内容。但它依然需要对象因为非静态成员属于具体实例。友元是另一个极端。友元函数不是类的成员它的名字根本不在类域里只是被授予了访问私有成员的权限。写friend void Peek(A a);的时候你是在全局作用域或者它所在的命名空间里声明了一个普通函数只不过这个声明写在了类的大括号内。class A { friend void Peek(A a); // 注意Peek 不属于 A 的类域 int _x 1; }; // A::Peek 是错的Peek 就是全局函数 void Peek(A a) { std::cout a._x; }定义在类内的友元函数还有个特殊之处它在类域之外是不可见的只能通过实参依赖查找ADL被找到。如果你的调用点实参类型不匹配就会报“找不到标识符”而不会提示你去类里看看。这个坑我踩过不止一次后来养成的习惯是友元函数尽量在类外单独声明一遍让它在全局可见。2. 类不占内存这件事很多人只是背下来了2.1 类定义为什么只产生图纸而不产生房子类是一个类型类型在运行期没有实体。编译器在编译阶段会从类定义里抽取一堆信息对象大小、对齐要求、每个成员变量的偏移量、成员函数的地址和签名。这些信息被写进符号表、调试信息、常量池里属于编译产物不占程序运行时的堆栈空间。打个比方类定义像一张建筑图纸图纸画得再详细也不会在地皮上自动长出一栋楼。只有定义对象的那一刻编译器才按图纸去要地、打地基、填材料。所以写下class Point { ... };之后内存里什么都没有sizeof(Point)能算出一个数但那个数描述的是“如果有一个 Point 对象它该占多少”不是“现在占了多少”。有一个例外需要单独拎出来类内的静态成员变量。它属于整个类不属于任何对象必须在类外定义一次才会分配存储位置在静态数据区。class Counter { static int _total; // 只是声明不分配存储 }; int Counter::_total 0; // 定义这时候才有实体C17 之后可以用inline static int _total 0;直接写在类里编译器会保证只留一份定义省掉类外那一行。这个改动在写头文件库的时候特别有用不然每个包含头文件的翻译单元都得想办法处理重复定义问题。2.2 实例化的那一刻编译器往内存里塞了什么实例化就是定义对象。编译器在这一步做几件事先按类的大小申请空间栈上、堆上或者静态区再调用构造函数按声明顺序初始化成员如果有虚函数还要把虚表指针填成对应类型的虚表地址。class Point { public: Point(int x, int y) : _x(x), _y(y) {} int Sum() const { return _x _y; } private: int _x; int _y; }; Point p(1, 2); // 栈上分配 8 字节p这块内存里躺着两个int别的什么都没有。Sum的机器码在代码段整个进程里只有一份。调用p.Sum()的时候编译器把p的地址作为隐藏参数传给SumSum内部通过这个地址加上偏移量找到_x和_y。这个隐藏参数就是后面要讲的 this 指针。还有一个容易忽略的点成员变量的初始化顺序由声明顺序决定跟构造函数初始化列表里的书写顺序无关。如果初始化列表里写了_y(y), _x(_y)这种交叉依赖实际执行时_x会先被初始化拿到的_y还是未初始化值。编译器一般会给出-Wreorder之类的警告别把它当噪音关掉。2.3 把成员函数写进类里为什么对象没有变大成员函数不占对象空间不管是定义在类内还是类外。这个结论背后的机制是成员函数编译后就是普通函数只是多了一个隐式的 this 参数。所有对象共享同一份函数代码靠传入不同的 this 来区分操作对象。class A { public: void f1() {} void f2() {} void f3() {} // 再写一百个成员函数 private: int _a; }; static_assert(sizeof(A) 4, 成员函数不该改变对象大小);这段断言在主流编译器上都能通过。你在调试器里看一个A对象的内存只有那 4 个字节的_a一个函数指针都找不到。成员函数指针A::f1确实是个有大小的东西通常 4 或 8 字节但它是你在表达式里临时构造出来的值不存储在对象内部。唯一的例外是虚函数。类里只要有一个虚函数每个对象就会多一个虚表指针通常放在对象开头32 位下 4 字节64 位下 8 字节。class Base { public: virtual void Run() {} int _a; }; // 32 位下 sizeof(Base) 是 864 位下是 16这是多态要付的账不是成员函数本身占空间。判断一个类有没有虚表指针最直接的方法就是看它有没有虚函数或者虚基类有就是有没有就是没有跟成员函数数量无关。3. 类对象模型sizeof 结果背后的完整账本3.1 空类的 1 字节是编译器留下的占位符class Empty {}; static_assert(sizeof(Empty) 1);一个成员都没有的类大小是 1 不是 0。原因不在语法在对象身份。如果大小允许为 0那么Empty arr[10]里十个元素的地址会全部重合指针算术arr 1和arr指向同一处取地址e1 e2也会成立。整个“每个对象有唯一地址”的约定就崩了容器、指针比较、引用语义全部受影响。标准规定空类大小至少为 1编译器通常就取 1够用来区分地址就行。这个字节没有任何实际数据纯粹是占位。有一个反直觉的例外叫空基类优化。当一个空类作为基类时编译器可以把它优化掉不占派生类的空间。class Empty {}; class Derived : public Empty { int _a; }; // 大多数编译器下 sizeof(Derived) 4这个优化在标准里是允许而非强制的主流编译器都做了因为标准库里的空分配器、空删除器依赖它来避免浪费。但它只在基类位置生效成员位置不会有这个待遇。class Holder { Empty e; int a; };的大小通常是 8那个 1 字节的e要占位置还要对齐。3.2 成员变量的排列顺序与内存对齐的叠加计算对象布局的两条核心规则每个成员相对对象起始地址的偏移量必须是它自身对齐数的整数倍不够就补空洞对象总大小必须是最大成员对齐数的整数倍不够就在尾部补齐。class A { char _c; // 偏移 0 int _i; // 偏移 4前面补 3 字节 short _s; // 偏移 8 }; // 最大对齐 4总大小 12 class B { int _i; // 偏移 0 char _c; // 偏移 4 short _s; // 偏移 6 }; // 最大对齐 4总大小 8同样的三个成员换个声明顺序大小差了 4 字节。这就是“成员按对齐从大到小排列”这条经验法则的来源。它在结构体量大的时候确实能省内存尤其是那种会被分配上百万个的小对象省下来的不是小数。再看一个跨度更大的例子class C { char _c; // 偏移 0 double _d; // 偏移 8前面补 7 字节 int _i; // 偏移 16 }; // 最大对齐 8总大小 24成员实际数据只占 13 字节补齐后是 24。如果用#pragma pack(1)强制按 1 字节对齐大小能压到 13但访问_d时可能产生非对齐访问在某些架构上直接触发异常在 x86 上则是性能损失。除非你在做二进制协议解析、网络包对接这类必须固定布局的场景平时不要动对齐设置。| 类型 | 常见大小64 位 | 对齐要求 | | char | 1 | 1 | | short | 2 | 2 | | int | 4 | 4 | | long | 8Linux/ 4Windows | 8 / 4 | | 指针 | 8 | 8 | | double | 8 | 8 |表格最后一行那个long的差异值得单独提醒。跨平台做序列化的时候直接对结构体做memcpy再落盘换平台读出来字段全错位这种事故我在实际项目里见过不止一次。涉及持久化或跨进程传输的结构体成员类型要么用定宽整数要么老老实实手工序列化。3.3 静态成员、成员函数、虚表指针分别住在哪把对象里可能出现的各种东西摊开看位置一目了然| 内容 | 存储位置 | 是否占用对象空间 | | 非静态成员变量 | 对象内存内部 | 是 | | 静态成员变量 | 静态/全局数据区 | 否 | | 非静态成员函数 | 代码段 | 否 | | 静态成员函数 | 代码段 | 否 | | 虚函数表 | 只读数据段 | 否 | | 虚表指针 vptr | 对象内存内部 | 是 |表格里最后两行要一起看。虚函数表本身是全局共享的每个有虚函数的类一张但它没法凭空找到对象里得存一个指向它的指针这就是虚表指针。所以“虚函数不占对象空间”这个说法是对的“有虚函数的类不占额外空间”就是错的多出来的是指针不是函数。虚表指针的位置标准没有规定属于实现细节。GCC 和 MSVC 通常把它放在对象起始位置所以如果你把一个有虚函数的对象地址强转成void*再转回去拿到的还是对象地址不影响。但如果你用memcpy拷贝一个有虚函数的对象虚表指针也会被一起复制如果源对象和目标是同一个类型这通常没问题跨类型就是灾难。3.4 用地址偏移和 offsetof 亲手验证布局嘴上说布局不如自己打印一遍。最稳的方式是拿对象地址去减成员地址#include iostream class Layout { public: char a; int b; double c; }; int main() { Layout obj; std::cout (char*)obj.a - (char*)obj std::endl; // 0 std::cout (char*)obj.b - (char*)obj std::endl; // 4 std::cout (char*)obj.c - (char*)obj std::endl; // 8 std::cout sizeof(Layout) std::endl; // 16 }这种写法不依赖任何库函数指针相减得到的就是真实偏移最可靠。offsetof宏也能用但对非标准布局类型有虚函数、有私有成员且跨访问控制的支持是有条件的C17 之后才明确了一些场景纯数据类用它没问题复杂类型还是用地址相减稳妥。想看填充字节里到底是什么可以先memset成一个特征值再赋值Layout obj; memset(obj, 0xCC, sizeof(obj)); obj.a 1; obj.b 2; obj.c 3.0; // 在调试器里看内存0xCC 的位置就是填充用调试器的内存窗口对着看一遍比看十篇文章都直观。填充字节里如果是 0xCC说明那块确实没被写过如果被写成别的值要么是构造函数的初始化列表碰了它要么是编译器做了什么优化两种情况都值得深究。4. this 指针每个非静态成员函数都偷偷收下的第一个参数4.1 从调用约定看 this 是怎么被塞进去的编译器把成员函数void A::f(int x)在概念上处理成void f(A* const this, int x)这样的函数。注意A* const这个类型指针本身是 const不能改指向但指向的内容可以改。这解释了为什么成员函数里不能写this nullptr;但可以写this-_a 1;。class A { public: void f(int x) { _a x; } int _a; }; A a; a.f(1);这段调用在 x86 上大概对应lea ecx, [a]再call A::fthis 走 ecx 寄存器x压栈。x64 下统一用第一个整型参数寄存器MSVC 是 rcxGCC 是 rdi。这套约定叫 thiscall是 ABI 层面的东西标准里没规定但平台之间大体一致所以一般不需要关心。真正需要关心的是this 是一个隐式参数它不是一个对象成员。4.2 this 存在哪里为什么反汇编里看不到它问“this 指针存在哪里”答案有点反直觉很可能哪儿都不存在。它大多数时候只活在寄存器里函数一返回就没了从来没有落到内存。只有几种情况编译器会把它溢出到栈上函数内部取了 this 的地址比如std::cout this、优化级别很低-O0、成员函数里调用了别的函数而 this 需要跨调用存活。所以你在反汇编里能看到mov [rbp-8], rcx这种把 this 存栈的指令也可能完全看不到取决于函数体做了什么。void A::Show() { std::cout this; // 这里取了地址this 会被放到栈上 } void A::Set(int v) { _a v; // 只用来算偏移this 一直在寄存器里 }这种“可能存在也可能不存在”的状态和 Java 里 this 是一个明确的对象引用完全不同。C 的 this 更接近一个隐式形参理解成形参比理解成成员更准确。这也是为什么静态成员函数没有 this因为它不属于任何对象编译器不会给它传这个参数。4.3 空对象指针调用成员函数时的真实边界class A { public: void SayHi() { std::cout hi std::endl; } void Touch() { _a 1; } int _a; }; A* p nullptr; p-SayHi(); // 在很多编译器上看起来正常 p-Touch(); // 崩溃写 0 地址附近SayHi能“跑通”是因为函数体里没有解引用 this。传进去的确实是 nullptr但函数只是往标准输出写字符串压根没碰*this。Touch就不行了_a 1等于this-_a 1this 为 0 时地址接近 0一写就触发访问违例。这里必须说清楚p-SayHi()在标准层面是未定义行为。对空指针做成员访问哪怕函数体不读内存表达式本身的规则已经被破坏。实践中它可能“跑通”换个编译器、换个优化级别、开了 UBSan结果都可能不同。判断一段代码能不能这么写标准是它是不是 UB不是“我这台机器上跑没跑通”。依赖“空指针调用不崩”来写代码等于把可移植性和正确性押在编译器的仁慈上。这类代码在 Debug 下活得好好的Release 开了优化后行为可能完全变样。顺带提一个相关的坑delete this。某些设计里会这么写前提是对象必须是new出来的而且 delete 之后不能再碰任何成员。这种写法在框架代码里偶尔出现业务代码里能不用就不用可读性和安全性都不划算。4.4 const 成员函数动的是 this 的类型不是函数体const 成员函数修饰的从来不是函数体而是 this 的类型。class A { public: void f() { _a 1; } // this 类型A* const void g() const { _a 1; } // 编译错误this 类型 const A* const int _a; };g之所以不能改_a是因为 this 变成了指向 const 对象的指针通过它访问到的成员全都是 const 的。这个理解方式比“const 成员函数不能改成员”这句口诀有用得多因为它能解释更多现象比如 const 成员函数里不能调用非 const 成员函数因为那相当于把一个const A*传给一个要求A*的参数类型对不上。有时候逻辑上不改变对象状态、但需要修改某些成员比如缓存、命中计数、互斥锁这时候用 mutableclass Cache { public: int Get() const { _hits; // 合法_hits 是 mutable return _value; } private: int _value 0; mutable int _hits 0; };mutable 的语义是“这个成员的修改不改变对象的逻辑状态”。用的时候要克制别把它当成绕过 const 的后门把一堆本该受保护的数据标成 mutableconst 的约束力就形同虚设了。还有一点值得提醒const 成员函数可以被 const 对象和非 const 对象调用非 const 成员函数只能被非 const 对象调用。所以同一个函数名可以写 const 和非 const 两个版本编译器根据对象的 const 属性挑class Buffer { public: char operator[](size_t i) { return _data[i]; } const char operator[](size_t i) const { return _data[i]; } private: char _data[64]; };这是标准库容器的常见写法返回引用而不是值避免拷贝。如果只写了非 const 版本const 对象就没法用下标访问编译直接报错。5. 四个概念串起来后的易混点与排查清单5.1 一张对照表理清作用域、实例化、对象模型、this 的分工这四个概念之所以容易混是因为它们都在回答“对象和类是什么关系”但切入角度完全不同。把它们各自负责的问题列清楚脑子里的模型就立起来了。| 概念 | 回答的核心问题 | 主要生效阶段 | 最常见误区 | | 类的作用域 | 名字去哪儿找 | 编译期 | 以为友元函数属于类域 | | 类的实例化 | 内存什么时候分配 | 运行期构造时 | 以为类定义本身就占内存 | | 类对象模型 | 对象里有什么、怎么摆 | 编译期决定运行期体现 | 以为成员函数占对象空间 | | this 指针 | 成员函数怎么找到对象 | 运行期 | 以为 this 是对象里的一个成员 |按这张表去对照自己写的代码很多疑惑会当场消解。比如“为什么静态成员函数不能访问非静态成员”查第四行就明白了静态成员函数没有 this没有对象可指非静态成员自然无从谈起。5.2 编译期报错与运行期异常的定位顺序真出了问题按阶段排查效率最高。先看报错发生在哪个阶段再顺着往下收窄。编译错误基本都指向作用域和语法问题。典型特征是_value : undeclared identifier或者f : is not a member of A这类问题九成是类域写错了或者成员名拼错了。嵌套类忘记写Outer::前缀也会报这个。链接错误的典型特征是unresolved external symbol public: void __cdecl A::f(void)几乎全是类外定义时漏了A::。还有一种情况是只声明没定义头文件里写了成员函数声明源文件里忘了实现或者实现写在了条件编译的分支外。这类错误信息里带的修饰名mangled name很有用直接搜修饰名能定位到具体是哪个函数。运行期崩溃先看崩溃地址。访问0x00000000附近基本可以断定 this 是空指针顺着调用栈找哪个指针没判空。访问0xCCCCCCCC或0xCDCDCDCD这类特征值说明读了未初始化的内存多半是成员没在构造函数里赋值。访问一个看起来像正常地址但数据全乱的地方八成是成员偏移算错了常见于强制类型转换、union 复用、跨模块传结构体这几种场景。大小对不上排查顺序建议是先怀疑内存对齐是不是成员顺序不好再怀疑虚函数是不是某个基类悄悄加了虚函数再怀疑空基类优化是不是某个空基类被优化掉了导致预期偏差最后才怀疑编译器实现差异。这个顺序能覆盖九成以上的“为什么 sizeof 和我算的不一样”。还有几个零散但高频的经验。跨模块传递结构体两边编译选项必须一致尤其是对齐设置和/Zp、-fpack-struct这类全局选项一边开了另一边没开字段全部错位而且编译器不会报任何错。用#pragma pack时一定要成对出现 push 和 pop忘了 pop 会把后面的所有结构体都带偏这种问题往往在改了一大片代码之后才暴露。继承层次里的对象布局也要留意单继承下派生类对象前面是基类子对象多继承下会有多个基类子对象挨着排虚继承还多一层偏移表做指针转换的时候编译器会自动调整地址但用reinterpret_cast强转就绕过了这个调整拿到的是错位的指针。最后说一个我自己的习惯。只要涉及对象布局的疑惑别在脑子里推直接写十行代码打印地址差或者开调试器看内存窗口两分钟就能验证。这类问题靠背结论记不牢动手看一遍印象深得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑