ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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 编译器自动做的三件事

如果我们总结一下,编译器处理一个非静态成员函数时,一共做了三件事:

  1. 把函数原型翻译成带this指针的版本,this被当作第一个隐藏参数;
  2. 把函数体内所有对成员变量的直接访问,改写成this->成员名的形式;
  3. 在调用点,自动把对象地址作为this传入。

这三件事听起来简单,但对理解C++对象模型很关键。因为this根本不属于对象的一部分,它和对象的关系,更像是快递单上的地址和包裹的关系——地址告诉你包裹在哪,但地址本身不在包裹里面。

我见过不少初学者误以为"成员函数就是对象的一部分,每个对象都存了一份",这是完全错误的理解。类的方法只有一份,所有对象共享;让每个对象在使用时都感觉"自己独占了一份方法"的,正是这个每次调用都不同、函数体内又始终隐式存在的this指针。

1.3 静态成员函数为什么没有this

顺带提一个经常被问到的点:为什么静态成员函数里不能使用this?因为静态成员函数属于类本身,不依赖任何具体对象,调用时不需要传对象地址。既然没有对象参与,自然也就没有this。你可以把静态成员函数理解成一个被放进类命名空间里的普通函数,它没有隐藏参数,这也是它无法访问非静态成员的根本原因。

2. 从寄存器到偏移量:this指针在运行时的真实形态

2.1 寄存器里的this:ecx与rcx

只看伪代码还是不够,我们直接看汇编。以x86 32位平台为例,使用某种常见的调用约定,编译器可能会把this放进ecx寄存器,其余参数继续从右到左压栈。前面那个Set函数的汇编,大致长这样:

; 下面只是理解用的示意,不同编译器和平台会有差异 Foo_Set: mov eax, [esp+4] ; 取出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, [ecx+4]这种形式。

这些偏移量在编译期就全部确定了。所以编译器在编译成员函数时,不需要知道对象具体在哪,它只需要在运行时拿到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的三次出场:链式调用、连续赋值与CRTP

4.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_cast<Derived*>(this)->Impl(); } }; struct Child : Base<Child> { void Impl() { /* 子类实现 */ } };

这里的static_cast<Derived*>(this),本质上就是"把this从基类类型的指针,转成派生类类型的指针"。它之所以合法,是因为Base<Child>实例化时已经知道派生类是谁。整个过程没有虚函数、没有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::function<void()> cb); class Task { public: void Run() { /* 干活 */ } void Start() { RegisterCallback(std::bind(&Task::Run, this)); } };

对象存活期间,这段代码运行得很好。问题是如果Task先析构,消息总线后来才触发这个回调,那么std::bind里保存的this就是一个悬垂指针。此时调用Run还未必立刻崩,但只要Run里访问了任何成员,就是在释放过的内存上读写,结果可能是偶发崩溃、数据错乱,或者看起来没事但某天突然爆炸。

更稳的做法是让回调持有对对象的强引用,或者至少能感知对象是否还活着。现代写法会用enable_shared_from_this:

class Task : public std::enable_shared_from_this<Task> { public: void Start() { std::weak_ptr<Task> 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_cast<A*>(this), (void*)static_cast<B*>(this)); } };

运行之后,你往往会看到this和static_cast<A*>(this)的地址一样,而static_cast<B*>(this)和它们相差一个偏移。单继承通常没有这种偏移,多继承才会。这个实验的成本极低,几分钟看完,但对建立对象模型的感觉很有帮助。

我自己真正想明白this,靠的就是这种笨办法:打印地址,看反汇编,把代码改来改去验证猜想。如果你也想把这一套变成自己的东西,建议别光看结论,亲手把第6.3节的代码跑一下,再对着第2节的示例编译看看寄存器里装的是什么。走完这一步,this对你来说就不再是一句需要背诵的定义,而是一个能预测行为的东西。有些知识,真的只有踩过才扎实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进