资讯详情

C++ this指针深度剖析:从隐藏参数到对象模型

📅 2026/10/21 7:36:39 | 华诺云谱 👁 阅读
C++ this指针深度剖析:从隐藏参数到对象模型
写了两年代码之后我回过头来看C里最基础的this指针才发现大学课堂上很多细节当时根本没有讲透。我最早接触this是在写链表的时候用this-next访问当前节点当时的理解停留在this是一个指向对象自身的指针直到后来有一次我在成员函数里打印this和外部变量的地址突然卡住了这个指针是哪里来的函数签名里没有它调用的时候也没有传它为什么函数体内就能直接用如果你也在某个瞬间产生过类似的疑问这篇应该能帮你把this彻底弄清楚。我会从隐藏参数的本质讲起一路深入寄存器、const语义、空指针调用、回调函数最后延伸到多继承下的对象模型。这篇文章不打算再重复this是指向对象自身的隐含指针这一句口头禅而是想把它拆开看看编译器到底在背后替我们做了什么。1. 一个隐藏参数编译器偷偷塞进每个成员函数1.1 把成员函数改写成普通函数一切豁然开朗我后来想通this的关键一步是动手把成员函数改写成了普通函数。假设我们有这样一个很简单的类class Foo { int value 0; public: void Set(int v) { value v; } };这个Set函数表面上只有一个int v参数。但是当我们写foo.Set(42)的时候编译器眼里看到的其实并不是给foo调用Set这个方法而是把foo的地址传进去调用一个多了一个隐藏参数的函数。把它翻译成C语言风格的代码大概是这个样子struct Foo { int value; }; void Foo_Set(struct Foo* this, int v) { this-value v; }也就是说成员函数体内出现的value v在编译之后的等价形式其实是this-value v。那个this参数就是编译器自动补充的隐藏参数。C规范里给它起了一个很准确的名字implicit object parameter隐含对象参数。这也正好解释了标题里的隐含二字——它一直都在但不是由你写出来的而是编译器悄悄塞进函数里的。理解了这一层很多看似玄乎的行为就有了答案。比如为什么同一个类的多个对象调用同一个成员函数却能各自修改自己的成员因为成员函数的机器码只有一份真正区分你调的是哪个对象的是每次调用时传入的this地址不同。你打印这个地址会发现它恰好等于obj就是对象在内存里的位置。1.2 编译器自动做的三件事如果我们总结一下编译器处理一个非静态成员函数时一共做了三件事把函数原型翻译成带this指针的版本this被当作第一个隐藏参数把函数体内所有对成员变量的直接访问改写成this-成员名的形式在调用点自动把对象地址作为this传入。这三件事听起来简单但对理解C对象模型很关键。因为this根本不属于对象的一部分它和对象的关系更像是快递单上的地址和包裹的关系——地址告诉你包裹在哪但地址本身不在包裹里面。我见过不少初学者误以为成员函数就是对象的一部分每个对象都存了一份这是完全错误的理解。类的方法只有一份所有对象共享让每个对象在使用时都感觉自己独占了一份方法的正是这个每次调用都不同、函数体内又始终隐式存在的this指针。1.3 静态成员函数为什么没有this顺带提一个经常被问到的点为什么静态成员函数里不能使用this因为静态成员函数属于类本身不依赖任何具体对象调用时不需要传对象地址。既然没有对象参与自然也就没有this。你可以把静态成员函数理解成一个被放进类命名空间里的普通函数它没有隐藏参数这也是它无法访问非静态成员的根本原因。2. 从寄存器到偏移量this指针在运行时的真实形态2.1 寄存器里的thisecx与rcx只看伪代码还是不够我们直接看汇编。以x86 32位平台为例使用某种常见的调用约定编译器可能会把this放进ecx寄存器其余参数继续从右到左压栈。前面那个Set函数的汇编大致长这样; 下面只是理解用的示意不同编译器和平台会有差异 Foo_Set: mov eax, [esp4] ; 取出int v mov [ecx], eax ; 将v写入this-value ret注意第一条指令函数真正开始干活之前第一个被使用的寄存器不是放参数的而是放对象地址的ecx。到了x64平台这个角色通常由rcx承担。所以你可以这样理解对象参与了函数调用但它的参与方式不是值拷贝而是地址传递。这个细节对性能优化也有启发。既然this是一个额外的隐藏参数每次成员函数调用都会多一次地址传递那么频繁调用的短小成员函数成本多半消耗在参数传递和调用开销上。这也是为什么很多追求极致性能的代码会把热路径上的小函数设为inline。内联不仅能去掉调用开销还能让编译器直接看到this指向的是哪一个对象从而做更激进的优化。2.2 成员访问的本质this加偏移量另一个值得想明白的点是this指向的是对象首地址但这不代表对象首地址放的就是你想访问的那个成员。访问成员变量时CPU会拿this加上一个编译期就定好的偏移量去寻址。还是用上面的Foo举例它的成员value位于偏移0的位置所以写入value的汇编是mov [ecx], eax。如果类里再加一个int other而且声明顺序是value在前、other在后那么访问other时汇编大概就会变成mov eax, [ecx4]这种形式。这些偏移量在编译期就全部确定了。所以编译器在编译成员函数时不需要知道对象具体在哪它只需要在运行时拿到this再按这个固定的偏移量去访问成员。很多做调试的人喜欢看地址偏移来找内存布局问题靠的就是这个基本原理。还有一点初学时容易搞混既然this是编译器自动传进来的它能不能取地址比如写int** p this答案是不能。this是一个右值不是左值不存在一个存放this的变量可供你取地址。标准里它的本质是一个prvalue在教学语境下我们喜欢说它是T* const类型的指针意思是这个指针本身不能改但通过它可以修改指向的对象。2.3 sizeof对象和this无关基于上面的理解一个结论就顺理成章了this不占用对象的内存。sizeof(Foo)只计算成员变量以及可能的对齐填充不会因为成员函数的存在而变大更不会因为this的存在而变大。如果你做过实验会发现一个空类的大小是1但给它加上十来个成员函数后大小依然是1。原因很简单成员函数是一份共享代码对象里不存函数this是函数调用时的临时地址不是对象的固有属性。这就好比邮局有一本操作手册每件包裹都按同一本手册处理手册不会塞进包裹里。包裹上唯一体现处理对象是谁的是快递员扫地址那一下。3. 加const之后this的身份会变成另一副模样3.1 一个编译错误引发的思考我以前带过一个同学他写出过这样一段代码class Foo { int value; public: void Change() const { value 10; // 编译错误 } };他当时很不服气说我加const只是不想让这个函数修改对象为什么连给成员赋值都不行这个问题问得特别好。错误本身不是函数被禁止修改而是更深一层在const成员函数里this的类型变成了const Foo*于是this-value被解释成了const int。给一个const int赋值编译器当然要报错。所以正确的理解是const成员函数不是说你发誓不改而是编译器的类型系统强制让你改不了。它把this指向的东西当成const看待这个类型约束发生在编译期运行期的地址值其实一点都没变。3.2 用表格理清this的几种形态下面这个表格可以帮你快速梳理this在不同成员函数里的形态成员函数形态this的类型能否修改this本身能否通过this修改成员普通成员函数Foo*教学上理解成顶层const指针不能能const成员函数const Foo*不能不能volatile成员函数volatile Foo*不能受限极端场景这张表在面试里经常被问到。注意this本身不能修改和this指向的内容能否修改是两件事。普通成员函数里this像是带只读标签的地址地址本身不能改成别的但地址指向的内存可以随便写。const成员函数则把只读标签贴到了地址指向的内存上。3.3 mutable成员的设计意义如果你想在const成员函数里合法地修改某一个成员标准做法是把它声明成mutable。比如缓存命中次数、调试用的统计值class Cache { int value; mutable int hits 0; public: int Get() const { hits; return value; } };mutable的意思是这个成员在逻辑上不算对象的可见状态所以const成员函数修改它不违反语义。每次调用Get时value不能被改但hits作为内部统计可以自增。这个设计是有意义的但我建议你在实际项目里谨慎使用mutable出现得越少越好。它应该被看作该成员承担了非状态职责的信号而不是用来掩盖糟糕设计的补丁工具。4. *this的三次出场链式调用、连续赋值与CRTP4.1 为什么链式调用必须返回*this且必须是引用this是地址*this就是对象本体。让成员函数返回*this的引用是实现链式调用最经典的手段。你看标准库里的std::cout a b之所以能连续输出就是因为operator返回的是ostream第一个输出完成后得到的是同一个cout于是可以继续往下链。我们自己写一个配置类来体验class Builder { std::string title_; int width_ 800; public: Builder Title(const std::string s) { title_ s; return *this; } Builder Width(int w) { width_ w; return *this; } };使用起来就很顺Builder b; b.Title(main).Width(1024);这一行等价于先执行b.Title(main)再在同一对象上执行b.Width(1024)。这里有个关键点返回值类型必须写成Builder不能写成Builder。如果返回的是值编译器会在返回时生成一份临时副本后续的Width调用是在副本上做的改不到原来的b上整个链条就失去了意义。还有一个生命周期细节值得提醒如果你用临时对象发起链式调用比如Builder().Title(main).Width(1024)只要这个完整表达式不结束临时对象就还活着链式调用是安全的。但如果你把中间的Builder存到一个引用变量里再把引用拿出这个表达式这条引用就会变成悬垂引用属于未定义行为。新手很容易在这里翻车。4.2 赋值运算符里this的自我保护另一个经典场景是赋值运算符。手写operator时几乎唯一的正确形态就是Foo Foo::operator(const Foo rhs) { if (this ! rhs) { // 防止自赋值 value_ rhs.value_; } return *this; }这里if (this ! rhs)是为了防止自赋值比如a a。如果实现里包含释放旧资源的动作自赋值就会导致先释放再拷贝把对象搞坏。而return *this则是为了支持a b c这种连续赋值链也和C赋值表达式本身是左值的历史语义有关。无论从哪个角度这条规则都值得背下来赋值运算符应该返回*this的引用。4.3 CRTP把this抬到类型层面的玩法链式调用只是把this用作运行时的当前对象标识如果想看this更高级的用法可以看看CRTP全称Curiously Recurring Template Pattern。它让基类模板知道自己被哪个派生类继承从而不用虚函数也能实现静态多态template typename Derived struct Base { void Interface() { static_castDerived*(this)-Impl(); } }; struct Child : BaseChild { void Impl() { /* 子类实现 */ } };这里的static_castDerived*(this)本质上就是把this从基类类型的指针转成派生类类型的指针。它之所以合法是因为BaseChild实例化时已经知道派生类是谁。整个过程没有虚函数、没有vptr调用在编译期就确定了。不少高性能组件为了省掉虚函数调用的开销会采用这种模式。理解CRTP的前提就是清楚this在模板推导里可以当作类型桥梁来使用。5. 空指针与回调场景this最危险的两个现场5.1 空指针调用成员函数为什么有时不崩有一种现象会让不少人大吃一惊对一个空指针调用成员函数有时居然不崩溃还能正常输出。比如class Foo { public: void Hello() const { std::cout hello\n; } }; Foo* f nullptr; f-Hello(); // 多数编译器上不会崩输出hello原因回到第1节的模型成员函数调用只是把this放进寄存器或栈里如果Hello内部压根没有通过this访问任何成员那就没有发生解引用上的问题。std::cout是全局对象跟this无关。整段代码就像给一个空的地址打了个电话但电话那边没接听也没挂断。但请注意这绝对不是一个可以依赖的特性。在标准看来用空指针去调用任何成员函数都是未定义行为标准不承诺任何结果。编译器在开启优化后可能假设this不可能为空于是把某些检查直接删掉到时候行为会变得更难预测。遇到可空指针调用成员函数正经的修复方式是调用前判空而不是祈祷函数里不访问成员。5.2 遇到虚函数空指针调用几乎必崩一旦类里有虚函数情况就完全不同。虚函数调用要从this指针指向的内存中取出vptr再根据vptr找到虚表槽位最后才得到真正要调用的函数地址。也就是说函数体内部什么都没做光是取vptr这一步就已经解引用了this访问了非法内存程序自然会崩。这个判断在代码审查里很有用。看到一行ptr-Method()如果ptr可能是空指针先问两个问题这个Method是不是虚函数它内部是不是访问了成员两个答案都是否才能暂时说现阶段没出事但正确做法依然是判空。别跟未定义行为赌语法细节赌赢了是运气赌输了是事故。5.3 回调函数里的this会变成悬垂指针另一个高频事故现场是回调。假设代码里有一个消息总线允许注册一个无参数回调void RegisterCallback(std::functionvoid() cb); class Task { public: void Run() { /* 干活 */ } void Start() { RegisterCallback(std::bind(Task::Run, this)); } };对象存活期间这段代码运行得很好。问题是如果Task先析构消息总线后来才触发这个回调那么std::bind里保存的this就是一个悬垂指针。此时调用Run还未必立刻崩但只要Run里访问了任何成员就是在释放过的内存上读写结果可能是偶发崩溃、数据错乱或者看起来没事但某天突然爆炸。更稳的做法是让回调持有对对象的强引用或者至少能感知对象是否还活着。现代写法会用enable_shared_from_thisclass Task : public std::enable_shared_from_thisTask { public: void Start() { std::weak_ptrTask weak shared_from_this(); RegisterCallback([weak]() { if (auto sp weak.lock()) { sp-Run(); } }); } };shared_from_this的作用可以简单理解为把this升级成一个能被shared_ptr正确管理的对象。但这里有个坑千万不要在构造函数里调用shared_from_this因为那一刻还没有任何shared_ptr在管理这个对象调用会直接抛std::bad_weak_ptr。这个坑我见过不止一个人踩。另外继承enable_shared_from_this时要注意基类声明顺序把它放在继承列表中合适的位置编译期的检查一般会给出提示但提前了解能省去很多定位时间。6. 多继承与虚表this指针在对象模型里的搬家6.1 多继承下的this地址调整最后一个话题稍微进阶一些但对理解this至关重要。大多数简单场景下this就等于我们创建出来的对象地址。可一旦涉及继承尤其多继承就没那么天真了。考虑这段代码struct A { int a; }; struct B { int b; }; struct C : A, B { int c; };在典型实现中C的内存布局大致是A子对象在开头B子对象在A之后c再往后。当我们用B* pb指向一个C对象时指针会自动调整到C对象内部B子对象的起始位置而不是C的首地址。在B的成员函数里this指向的就是这个内部地址。所以this并不总是对象开头的地址它永远是当前函数所属类型的子对象地址。理解了这一点就能理解为什么多继承下的指针转换要交给编译器它需要额外做一次地址调整而不是简单地拷贝数值位。这也是static_cast和dynamic_cast在继承体系里存在的意义之一。不要手动去算自己设计的偏移量编译器比你可靠得多。6.2 构造期间的虚调用this的身份暂未生效虚函数调用是另一个和this紧密协作的场景。大多数实现里对象内部会有一个vptr指向虚表。调用虚函数时编译器先从this指向的位置取出vptr再从虚表中取出目标函数地址最后把this作为隐藏参数传入。正因为虚函数地址的寻找依赖this构造函数里调用虚函数时调到的往往是当前正在构造的这层类的版本而不是最终派生类的版本。很多人写基类构造函数想在里面触发多态结果发现调用不到子类的重写函数原因就是这样在基类构造期间派生类部分的vptr还没有设置好当时的this在类型层面还被看作是基类对象。这个身份暂未生效的窗口期是C对象模型里一个经典的知识点。6.3 打印地址验证this的偏移如果你想把上面那些地址调整都验证一遍可以写一个很小的实验#include cstdio struct A { int a; }; struct B { int b; }; struct C : A, B { void Show() { printf(this%p A%p B%p\n, (void*)this, (void*)static_castA*(this), (void*)static_castB*(this)); } };运行之后你往往会看到this和static_castA*(this)的地址一样而static_castB*(this)和它们相差一个偏移。单继承通常没有这种偏移多继承才会。这个实验的成本极低几分钟看完但对建立对象模型的感觉很有帮助。我自己真正想明白this靠的就是这种笨办法打印地址看反汇编把代码改来改去验证猜想。如果你也想把这一套变成自己的东西建议别光看结论亲手把第6.3节的代码跑一下再对着第2节的示例编译看看寄存器里装的是什么。走完这一步this对你来说就不再是一句需要背诵的定义而是一个能预测行为的东西。有些知识真的只有踩过才扎实。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑