ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ this指针:从对象模型到智能指针陷阱

C++ this指针:从对象模型到智能指针陷阱 很多写C的人第一次跟this指针打交道基本都是在学类的时候被一句话带过去的“每个成员函数里都隐含了一个this指针指向当前调用它的对象。”然后课堂上拿this-x x演示完构造函数这事就算翻篇了。这个解释不算错但远远不够。我自己把C的对象模型揉碎了看了一遍之后才意识到this指针背后串着的是一整套C理解“对象”的方式同一个成员函数怎么为不同对象服务、const成员函数到底怎么生效、为什么智能指针不能直接接收this、以及大量崩溃到底是怎么跟this扯上关系的。这篇文章想把这些底层逻辑串起来讲透顺便把实战里容易踩的坑一个个掰开。适合刚学完类的语法、想往深处走一步的同学也适合写了几年C但没认真琢磨过this的同行。1. this指针的本质编译器塞进来的隐秘参数1.1 对象调用成员函数时到底发生了什么先看一段最普通的代码class Point { public: Point(int x, int y) { this-x x; this-y y; } void print() const { std::cout ( x , y ) std::endl; } private: int x; int y; }; int main() { Point p1(1, 2); Point p2(3, 4); p1.print(); p2.print(); }问题来了print()这个函数的代码在内存里只有一份凭什么p1.print()打印出(1, 2)而p2.print()打印出(3, 4)答案就在this指针上。编译器在处理成员函数时并不是把它当成一个孤立的函数而是偷偷给它加了一个参数指向当前对象的指针。也就是说在编译器眼里print()函数大概长成这样void print(Point* this) { std::cout ( this-x , this-y ) std::endl; }调用p1.print()时编译器会把p1作为那个隐藏参数传进去调用p2.print()时传的是p2。所以同一个函数体每次执行时拿到的this不同操作的数据就不同。这个机制就是C对象模型里最基础的一块砖。1.2 this指针的类型为什么它不能改指向既然this是隐藏参数那它的类型是什么在非const成员函数中this的类型是Class* const注意这个const的位置它是“指向Class的指针且这个指针本身是const的”。意思很明确——你不能让this指向别的对象void SomeClass::func() { this nullptr; // 编译错误this 是右值不能被赋值 }编译器甚至不允许你取this的地址因为this不是一个存储在某个内存位置的变量它在寄存器里传递属于右值。很多新手以为this就是“对象自己存的指针成员”这是误解。对象内存里并没有一个被称为this的字段this是由编译器在调用时临时注入的。还有一个容易搞混的点this和*this完全是两回事。this是指针*this才是对象本身或者说指向对象的左值引用。写return this和写return *this往往天差地别这点在链式调用里尤其致命下一章就讲。1.3 三个对象共用一份函数代码靠什么区分数据可以把类想象成一张图纸对象是按照图纸造出来的一个个独立房间。成员函数代码是挂在公共走廊里的使用说明谁来了都能看但它不知道你是谁this指针就是你进门时刷的那张卡告诉使用说明“现在操作的是302房间”。网格上的数据成员分布在每个对象自己的内存区域里但函数代码只存在一份。数据在哪、代码在哪、怎么把两者绑定起来这三件事全靠this指针在中间牵线。也因此对象的sizeof里不会包含“this”这个成员。类的大小只跟数据成员、虚表指针、对齐规则有关跟成员函数个数毫无关系。这个结论经常在面试里考理解了this的本质你就不会答错。2. this指针的四个高频实战场景2.1 形参撞名成员变量this-是解药可能是this指针最朴素也最常见的用途。构造函数里经常出现参数名和成员变量名一致的命名习惯class Point { public: Point(int x, int y) : x(x), y(y) {} // 老手也常这么写 void setX(int x) { this-x x; // 必须用this-说明“左边是成员变量” } private: int x; int y; };这段代码里setX(int x)的形参x把成员x“遮蔽”了。如果直接写x x;那就是形参自己给自己赋值成员变量纹丝不动。this-x的意义就是突破这种遮蔽明确告诉编译器“我要的是对象身上的x”。不加这个代码能过编译但行为完全错误。更隐蔽的是在构造函数初始化列表里x(x)这种写法不是靠this指针消歧的而是依靠初始化列表的特殊语法规则括号里的参数名优先被解释为形参。一旦你混淆了这两种机制调试时就会面对“为什么成员变量一直是随机值”的诡异问题。2.2 链式调用与返回*this链式调用是this指针最高频的价值体现。最常见的场景是operator这类复合赋值运算class Vector2 { public: Vector2 add(const Vector2 other) { x other.x; y other.y; return *this; } Vector2 operator(const Vector2 other) { return add(other); } private: float x 0.0f, y 0.0f; }; Vector2 v1(1, 2), v2(3, 4); v1.add(v2).add(v2); // 连续添加两次v1变成了(7, 10)这里的核心是return *this;。如果写成return this;返回类型就得是指针链式写法就变成v1.add(v2)-add(v2)既难看又容易漏空指针判断。如果函数返回类型是Vector2值类型而不是Vector2那么return *this;会触发一次拷贝构造把临时副本返回去第二次add作用在副本上原对象根本没变。这几乎是新手写链式调用最容易踩的坑签名里忘写返回半天全是白忙活。那为什么不直接返回Vector2因为链式调用的本意是“修改当前对象本身”而不是生成一个新对象。返回引用意味着没有拷贝、没有额外开销、语义也最贴近设计初衷。同理operator的输出流重载末尾也总写着return os;这里的os本身就是一个对象引用跟return *this干的是同一件事。2.3 赋值运算符重载自赋值判断离不开this在写operator时自赋值判断是必须处理的边界条件。经典的写法长这样class MyString { public: MyString operator(const MyString other) { if (this other) { return *this; // 自己给自己赋值啥也别干 } // 先释放旧资源再按other的大小拷贝数据 delete[] buffer; buffer new char[other.length 1]; std::copy(...); return *this; } private: char* buffer; int length; };this other这个比较就是拿当前对象的地址跟传入对象的地址做比对。没有这一步当代码写出str str;尤其是经过别名或引用传递变量名不同但指向同一对象时你会先释放资源然后又用已经释放的内存去拷贝自己典型的use-after-free。很多老项目里深藏的偶发崩溃根源就是自赋值判断缺失。即使绝大多数调用不会触发自赋值这行判断也是廉价保险——几次指针比较而已成本可以忽略不计。2.4 delete this边缘但必须知道的危险操作严格来说delete this不是常用手段但你一定会在某些遗留代码里看到它。它往往出现在“对象自己决定自己生命周期”的场合比如某个回调管理器里的Handlerclass Handler { public: void release() { delete this; // 只能对 new 出来的对象调用 } };调用Handler* h new Handler(); h-release();之后原来那个h指针变成悬垂指针再访问就是未定义行为。这里有几个硬性前提对象必须是堆上分配的栈对象绝对不能这么干否则栈空间被delete会直接导致程序崩溃delete this所在的函数体在执行完delete之后不能再碰任何成员变量不能再碰this甚至不能再调用其他成员函数除非你极其确定那个函数体内不访问成员但这种钻空子行为没有任何保障。从工程角度我的建议是能不用就不用现代C有智能指针管理所有权delete this只适合作为“最后手段”去理解而不是作为常规武器。3. const成员函数、static成员函数与this的爱恨情仇3.1 const成员函数里的this变成了什么成员函数尾部加的const实际上是改变了this指针的类型。普通成员函数中this是Class* const而加了const后this的类型变成了const Class* const。后者指向的是一个const对象于是函数体内对数据成员的写操作一律被编译器拒绝。这就是const成员函数语义的底层解释class Counter { public: void add() { count_; // okthis 是 Counter* } int get() const { count_; // 编译错误this 是 const Counter* return count_; } private: int count_ 0; };了解这一点有很多好处。比如你有一个const Counter c能调用get()但不能调用add()因为后者的this类型是Counter*放不下const Counter*。本质上const成员函数就是“这个函数通过一个只读视角来操作对象”。如果const对象调用了非const成员函数相当于强行丢弃const限定这在语言层面被直接禁止。3.2 mutable给const成员函数开个小后门const成员函数带久了你会发现有些“修改”其实是必要的比如缓存、引用计数、统计日志。这个时候mutable关键字登场了。它允许const成员函数内修改特定成员无论this是不是constclass DataCache { public: int getData() const { if (!cached_) { cache_ expensiveCompute(); // okcache_ 是 mutable cached_ true; } return cache_; } private: mutable bool cached_ false; mutable int cache_ 0; };mutable的本质是this虽然变成了const DataCache*但mutable成员在编译器眼里“不受const限制”所以可以通过const指针修改。注意这是特例不是普遍许可。滥用mutable会让你的const语义形同虚设最好只把它用在缓存、懒加载、线程同步元数据这些有明确理由的场景。3.3 static成员函数为什么拿不到thisstatic成员函数不属于任何一个对象它本质上是“搬进类命名空间里的普通函数”。调用static成员函数不需要对象自然也没有this参数。这就带来一个特性static函数里访问非static成员变量是非法的因为没有this编译器不知道“哪个对象”的成员。你可能会想那我存一个全局对象指针在static函数里通过它访问成员行不行行但那是绕弯子。static函数没有this是因为语言设计上它就不绑定对象实例。这也是static成员函数不能声明为const的原因——const修饰的是this指向的东西没有this就无从谈起。理解了这一点很多“为什么这样会编译错误”的困惑就迎刃而解。4. this指针与对象生命周期、智能指针的纠缠4.1 空指针调用成员函数为什么没崩反而是大问题先看一个反直觉的代码class Foo { public: void hello() { std::cout hello std::endl; } int bar() { return value_; } private: int value_ 42; }; int main() { Foo* p nullptr; p-hello(); // 理论上未定义行为但大概率不会崩 p-bar(); // 大概率崩溃 }p-hello()之所以可能不崩是因为hello()的函数体内完全没有访问this指向的数据。编译器把调用展开后只是把nullptr作为this参数传给了函数函数体只是打印一行字压根没用this。所以这行的“未定义行为”没有演变成真实崩溃。而p-bar()会读this-value_相当于对一个空地址解引用必然崩。这个例子最大的启发是不要指望“空指针调用成员函数没崩”来侥幸过关。第一它仍是未定义行为换了编译器优化级别就可能换一种结果第二一旦成员函数里访问成员变量哪怕只是读崩溃马上就来。排查这类问题时看到崩溃地址是0x0或者接近0x0第一反应就该是“this是空指针”。有个小坑要提醒不要写if (this nullptr)来防御这个写法在C里同样属于未定义行为优化器甚至可能直接把这个判断删掉让你“防御了个寂寞”。正确做法是让调用方保证指针非空或者用引用类型从源头杜绝空指针。4.2 把this塞进shared_ptr双重delete事故智能指针普及后很多人的第一反应是在成员函数内部把this包装成shared_ptr再传出去。这个操作非常危险class BadFoo { public: std::shared_ptrBadFoo getSharedBad() { return std::shared_ptrBadFoo(this); // 错得离谱 } }; int main() { std::shared_ptrBadFoo sp1(new BadFoo); std::shared_ptrBadFoo sp2 sp1-getSharedBad(); // sp1 和 sp2 各自持有独立的控制块都认为自己是唯一管理者 // 析构时同一块内存被释放两次 }shared_ptr的关键在于“控制块”——引用计数、删除器都放在那块内存里。同一个原始指针被两个独立的shared_ptr接管时会创建两个控制块于是两套引用计数各自从1减到0各自执行一次delete。同一块堆内存被释放两次轻则崩溃重则内存破坏。我在实际项目里遇到过一个非常隐蔽的段错误查了很久才发现是某处回调把this塞进shared_ptr导致的双重释放。4.3 enable_shared_from_this解决this共享生命周期的标准答案C标准库提供了解决这个问题的正统方式继承std::enable_shared_from_thisT然后在成员函数中用shared_from_this()获得共享所有权class GoodFoo : public std::enable_shared_from_thisGoodFoo { public: std::shared_ptrGoodFoo getShared() { return shared_from_this(); // 返回同一个控制块的shared_ptr } }; int main() { auto sp std::make_sharedGoodFoo(); auto sp2 sp-getShared(); // sp 和 sp2 引用同一个控制块引用计数变成2 // 最后一个shared_ptr销毁时才释放对象 }enable_shared_from_this的实现机制并不神秘它内部藏了一个weak_ptr当对象第一次被shared_ptr接管时这个weak_ptr会被初始化指向该对象的控制块。之后shared_from_this()就通过这个weak_ptr去lock()一个新shared_ptr。因为它拿的是同一个控制块的副本所以不会产生双重删除。这里有个极易踩的坑shared_from_this()要求在“对象已经被某个shared_ptr管理”的前提下调用。如果对象是在栈上创建的GoodFoo foo;或者在new出来但还没被shared_ptr接管时调用会抛出std::bad_weak_ptr异常。有些老实现是未定义行为新版标准则是明确异常。所以用这个功能前先确认对象确实活在shared_ptr手里。4.4 多线程下的this悬垂指针this指针跟普通指针一样面临悬垂问题。典型场景是把this传给工作线程class Worker { public: void start() { thread_ std::thread([this] { doWork(); }); } void stop() { if (thread_.joinable()) thread_.join(); } void doWork() { while (running_) { /* 模拟干活 */ } } private: std::thread thread_; bool running_ true; }; // 错误示范 void bad() { Worker* w new Worker; w-start(); delete w; // 线程可能还在访问w的成员悬垂 }start()里的lambda捕获了this线程启动后访问的其实是w-running_。一旦主线程把wdelete掉thread里的this就成了悬垂指针。很多偶发崩溃就是这样来的业务正常时不崩恰好对象析构和线程执行撞在一起就崩极难复现。解决方案要么是保证线程在对象析构前join如上面stop()所做要么让对象本身由shared_ptr管理在线程内持有shared_ptr副本保证对象生命周期被线程拉长。无论哪种核心思想都是一样的this指针只是入口真正的生命周期管理还得靠所有权设计。5. this指针与相关指针概念的横向对比5.1 this、引用、值传递三种传参方式的取舍热搜词里“引用 指针 和 值传递”常常并列出现this指针跟它们正好能互相参照。值传递会拷贝整个对象开销高修改影响不到原对象引用传递本质是编译器帮你管理地址语法上像值操作开销小且能改原对象指针传递则显式暴露地址可空、可重新绑定。this指针的位置很特殊它既不能为空合法调用时必然指向对象也不能重新绑定所以语言不允许你显式传一个“this参数”。从使用角度看普通函数里该用引用还是指针可以根据“是否允许为空”来决定允许为空就传指针否则传引用。类内部的this你没法选它是编译器强加的但你可以在成员函数之间传递*this或this去控制后续函数是接受引用还是指针。实际项目里我倾向于对外接口用引用因为少一层空指针判断但需要表达“可空”语义时指针仍然是唯一选择。5.2 成员函数指针真正的“this 函数地址”普通函数指针不能直接绑定非static成员函数这背后也是this在作怪。成员函数在底层是“隐藏this参数 实际入口地址”的组合所以成员函数指针的类型必须携带类信息class Foo { public: void func(int x) {} }; void (Foo::*pmf)(int) Foo::func; // 成员函数指针 Foo f; (f.*pmf)(42); // 调用时需要提供对象这里的.*运算符会把f作为this传进pmf指向的函数。老式编译器会把成员函数指针实现成一个“包含函数地址和this调整量的结构体”多继承时还要做这个this偏移修正。这也是为什么sizeof一个成员函数指针往往比普通指针大而且不同编译器结果不一。理解this你就能理解为什么成员函数指针这么难用也明白为什么std::function绑定成员函数时需要用std::bind(Foo::func, obj)这种形式——它本质上就是提前绑定好this。5.3 数组指针、指针数组这两个高频词和this的亲戚关系“数组指针”和“指针数组”是C语言绕不开的区分题int* p[3]是指针数组数组里存了3个int*int (*p)[3]是数组指针p指向一个内含3个int的数组。它们和this的关系其实很浅但能帮我们建立统一的指针观指针的价值就在于“通过地址间接操作一段数据”this同样如此只是它指向的对象类型固定、来源由编译器保证。比如你在某函数里遍历一个指针数组时如果数组里存的恰好是某个类的对象指针那么arr[i]-method()等价于把arr[i]作为this传给method。至于“函数指针与指针函数”“常量指针与指针常量”同理都是修饰词和指针的排列组合一旦你吸收了“this是指针的一种特殊形态”这个观念再看这些概念就顺眼多了。6. 常见问题与排查技巧实录6.1 构造函数里能不能用this可以在构造函数体内使用this但有个大忌不要在对象尚未完全构造时把this泄露出去。比如class Foo { public: Foo() { g_registerCallback(this); // 危险别的代码可能立刻调用this } };this指针本身已经可用但对象的构造还没完成基类、成员变量的构造顺序有严格规定虚表指针在基类构造期间可能还指向基类版本。如果g_registerCallback立刻调用this-virtualFunc()很可能调用的不是子类的重写版本或者访问到尚未初始化的成员。正确做法是把这种注册挪到构造完成后用一个单独的init()函数或者使用shared_from_this方案延迟接管。至于初始化列表里this的用法更受限——它只能作为成员函数调用参数之类别在初始化列表里对this做复杂操作。6.2 排查this相关崩溃的实用手段遇到崩溃第一步永远是在调试器里看调用栈。以gdb为例崩溃到某个成员函数时打印p this大概率能看到类似this 0x0或this 0x20之类的地址。0x0是空指针调用0x20这种小地址多半是“this偏移后访问成员但this本身已经非法”导致。访问一个已释放对象时gdb打印this会得到一个“血统可疑”的地址此时配合watch或malloc_check之类工具可以定位到是哪次释放干掉了它。还有一个经验如果崩溃发生在虚函数调用上且this不合法通常栈顶是纯汇编比如mov rax, [rdioffset]再往下才能看到调用方。这时翻到调用方重点查那个对象指针的来源有没有被提前delete、有没有在线程间裸传、有没有被shared_ptr的双控制块坑过。很多this相关的崩溃根子不在this而在所有权设计。调试技巧只是让你快速定位到病灶的手段。6.3 场景速查表什么时候用this什么时候别用场景正确姿势一句话原因形参和成员同名this-x x;this消除遮蔽歧义链式调用return *this;返回本对象引用避免拷贝const成员函数里读数据直接用成员this自动变const只读视角操作对象static成员函数访问成员不行没有this不绑定对象实例自定义析构后自杀仅堆对象且之后不再访问thisdelete this是最后手段把this塞进shared_ptr继承enable_shared_from_this避免多控制块导致双重delete多线程里长期持有this用shared_ptr延长生命周期或join防止对象先死、线程后跑注册回调传this构造完成后再传最好用shared_ptr管理防止对象尚未初始化就被调用这张表我建议直接收藏。遇到拿不准的场景对照一下基本不会跑偏。6.4 我这些年用this得到的一点体会如果只让我说一句经验那就是this指针不是用来“炫技”的而是用来“明确对象身份”的。早期我写代码能省则省总觉得this-是多余的毕竟直呼成员变量名更简洁。后来维护一个占地几万行C的旧项目发现大量事故都源于“不知道当前操作的是哪个对象”——尤其当临时对象、引用、move语义混在一起时。从那以后我养成了两个习惯一是所有成员函数内访问成员变量统一用this-显式标注虽然多敲几个字符但代码可读性和检索性都大幅提升二是在任何涉及跨线程或跨模块传递对象的地方下意识就问一句“这个对象的生命周期够不够长this拿出去还能不能活着”。第二个习惯帮我挡掉了相当多隐蔽的崩溃这比任何奇技淫巧都实在。
返回列表