this:C++ 里的一个坑人关键字

11 阅读14分钟

我们发现对于 this 这个关键字,C++ 标准委员会那帮老爷子,压根没打算让我们舒舒服服用这个关键字。

他们把对象模型的复杂性全塞进 this 这根指针里,然后两手一摊:“喏,高性能和灵活性都给你了,剩下的你自己看着办。”

这也带来了许多坑爹的地方,所以这篇文章就试着写写 this 这玩意。

简略介绍下 this

1. 为什么需要 this?

没有 this,我们连自己是什么对象都不知道。

我们在一个成员函数里想返回对象本身来链式调用?没了 this 我们返回个寂寞。

我们在重载赋值运算符的时候想知道左边那个倒霉蛋是谁?没this我们就只能干瞪眼。

所以 this 存在的根本原因:对象方法需要一种机制来引用调用它的那个具体实例,而C++选择把这活儿交给编译器生成的隐藏指针,然后给它起了个名字叫this,让我们在代码里不用自己传,但又能随时抓来用

2. this 的基本定义

this 是一个 prvalue 表达式,其类型在非 const 函数里是 ClassName*,在 const 函数里是 const ClassName*。

因为它是 prvalue,所以我们不能给它赋值,就像不能写 &this 一样。

换句话说,我们不能给 this 赋值让它指向别处,这货从进函数那一刻起就钉死在这个对象上了。我们想把 this 指到隔壁老王的家里去?梦里啥都有,编译器会像看傻子一样看着我们。

从实现角度来看,一般编译器都把 this 当第一个隐式参数传进去,要么放寄存器,要么压栈。我们写 a.foo(),编译器生成的大概相当于 foo(&a)。

3. 最常见的用法

this 有一堆用法,我们就挑几个场景来看看吧。

解决名字冲突

还是蛮经典的用法,当成员和我们参数同名的时候:

class Person
{
    std::string name;
public:
    Person(const std::string& name)
    {
        this->name = name; // 没有this,name = name就是个自赋值笑话
    }
};

返回对象自身的引用

链式调用:

Person& Person::setName(const std::string& n)
{
    name = n;
    return *this// 就靠 this 解引用把自己丢回去
}

然后我们就能写出 p.setName("xx").setAge(18) 这种看着很不错的代码。

在成员函数里玩模板

写基类的时候,我们想知道真正的派生类类型?this 在这儿帮我们玩 CRTP(奇异递归模板模式):

template<typename Derived>
class Base
{
public:
    Derived& derived() return static_cast<Derived&>(*this); }
};

这时候 this 就是那个我是我爹的问题了,它帮我们在编译期把基类的引用整成派生类,省掉虚函数的开销。

this 从哪里来?

1. 成员函数的本质转换

我们来看这行代码:

class Dog
{
    int age;
public:
    void bark(int volume);
};

Dog d;
d.bark(5);

我们以为写的是面向对象,给 d 发了一条 bark 消息。编译器视角:什么狗屁消息,全是函数调用,而且还得偷偷塞参数。

在编译器眼里,Dog::bark 的原始形态大概是这样的全局函数:

void Dog_bark(Dog* thisint volume)// 名字我随便编的,实际是名字修饰

然后 d.bark(5) 变成了:

Dog_bark(&d, 5);

那个 this 指针,就是成员函数签名里第一个隐式参数

它靠编译器无条件硬塞,我们要是写静态成员函数,编译器就两手一摊:“这函数跟实例没关系,老子不塞了。” 所以我们才在静态函数里找不到 this,不是它躲起来了,是根本就没那个参数,我们在找空气呢。

2. 站在调用的角度看 this 的传递

现在换个视角:我们是调用方,写了个 d.bark(5)。

编译器的活分两步:

  1. 找函数地址:非虚函数,编译期就直接定死地址,直接 call。虚函数?那得通过 vptr 去虚函数表里找,但这是另一个坑爹玩意,咱先按住不表。
  2. 传 this:编译器拿到对象 d 的地址 &d,然后往这个全局函数里怼。这个过程对我们是透明的,对 CPU 是天经地义的,没有任何改变,就是地址。

但是但是,可能会有一个错觉:以为 this 就是对象起始地址。在单继承、没有虚基类的情况下,这句话勉强对。一旦多继承掺和进来,this 指针的值就要被编译器偷偷偏移,我们传进去的 &d,跟函数体里用到的 this,可能不是同一个数。

为什么?因为基类子对象不一定在派生类对象的最开头,编译器要保证每个基类的成员函数拿到的 this 真正指向自己那个基类子对象的起始地址。

3. 内部的物理传递:寄存器与栈

好好好,现在又是一个坑爹玩意:不同平台、不同编译器,怎么塞这根 this 指针过来。

32 位 x86 时代(老古董了)

微软搞了个 thiscall 调用约定(对非变参成员函数),规定 this 指针放 ecx 寄存器,其余参数从右往左压栈或者 fastcall 传寄存器。

GCC 怎么干的?它干脆把成员函数当普通 cdecl 函数,this 作为第一个参数直接压栈。

你瞅瞅,同一个 C++ 代码,换个编译器后函数调用指令都不一样(C++ 标准不规定 ABI 的福报,真香)。

64 位时代

好了一些,但也没好到哪去。

  • Windows x64:this 放 rcx(第一个整数参数寄存器),挺规矩的。
  • System V AMD64(Linux/macOS):this 作为第一个隐式参数,放 rdi。

所以我们写一个最简单的 obj.setAge(3),在 Windows 上编译器大概率生成:

lea rcx, [obj]   ; this 进 rcx
mov edx, 3       ; 参数 3 进 edx
call Dog::setAge

在 Linux 上就是:

lea rdi, [obj]   ; this 进 rdi
mov esi, 3
call _ZN3Dog6setAgeEi  ; 狗名字修饰长到反人类

寄存器够用的时候一派祥和,一旦参数多了,寄存器不够了,this 就可能会被溅到上,这时候我们就看到什么 mov [rsp+某偏移], rcx 之类的倒腾。

this 只是个指针,传参过程跟普通 int* 没本质区别。

4. 成员访问的真相

最后,写完 this 指针的传递,我们再看自己在函数里随手写的 age,简直触目惊心。

我们写:

void Dog::bark(int volume)
{
    if (age > 10// 看似直接访问
    {   
        // ...
    }
}

编译器翻译得贼直白:

if (this->age > 10// 先通过 this 找到对象基址
{  
    // ...
}

更直白一点:

if (*(int*)((char*)this + offsetof(Dog, age)) > 10)

这就是成员访问的真相:把 this 指针当基址,对着编译器事先算好的偏移量,直接硬算内存地址

age 在 Dog 类里排第几个成员,偏移量在编译期就是常数,直接 this + 偏移 就是字段的真实地址。

所以成员访问无非就是 this 基址加偏移。这就是 C++ 效率的基石之一:没有反射开销,没有字典查找,一切都是编译期硬编码的算术,代价就是所有的灵活性都得我们自己手动模拟。

一些陷阱

1. 在构造与析构函数中的 this

在构造和析构函数里,虚函数机制是静态绑定的。

也就是说,如果我们在基类构造函数里调了一个虚函数,它绝对不会调到派生类的那个版本。因为当我们还在基类构造函数里时,派生类那部分内存还没初始化好,虚表指针现在指的还是基类的虚表。

同理,typeid(*this) 在构造/析构里返回的是当前正在执行的构造函数所属的类,而不是最终派生类。我们想在基类构造里靠 typeid 认出派生类是啥?做梦吧。

还有个更阴的:在构造函数里把 this 泄露给其他线程。对象都没构造完,另一个线程拿到指针就开始用了,这不崩谁崩?

析构函数同理:派生类析构体跑完了,轮到基类析构时,虚表已经指回基类了,虚函数也是静态绑定,这时候我们干的事越少越好。

2. delete this

delete this 可以写,但写了之后我们要对后续所有崩溃负全责。

首先必须是 new 出来的,而且不能是 new[],不能是栈对象,不能是全局对象,不能是成员子对象(别人的 new 甭管)。

如果当前类是多态基类,那么即使 delete this 是通过自身类型的指针在成员函数里调用的,为了正确销毁实际可能是派生类的完整对象,析构函数也必须是虚的,否则是未定义行为。

接着 delete this 之后,这个函数里不能再碰任何成员变量,不能再调任何成员函数(包括虚函数),不能再拿 this 做任何比较或运算。

标准场景就是 COM 的 Release(),或者某些事件驱动的自毁控件。它存在的合理性是:对象自己最清楚自己的生命周期,当引用计数归零或任务结束时,自己了断,避免外部管理散落满天飞。

多线程下就容易发生:我们刚判了 delete this,CPU 换到另一个核,那边刚好有个悬空指针准备调你的成员函数,恭喜恭喜,喜提随机崩溃。所以用这招必须在设计上把并发所有权想得明明白白,通常还得配个智能指针或者明确的唯一所有权模型。

我最恨的一种写法:

void SomeClass::doIt()
{
    delete this;
    m_logger.log("done"); // UB,this 已经成孤儿咯
}

这种代码在 debug 模式下可能碰巧没炸,release 一开优化直接飞升,因为编译器有权假设 this 在成员函数里始终有效,然后搞出一些优化后直接读悬挂内存的阴间指令。

3. 多继承与虚继承下的 this 调整

在多继承体系下,同一个对象的 this 指针,在不同基类的成员函数里,值可以不一样。

假设:

struct A { int a; void fa(); };
struct B { int b; virtual void fb(); };
struct C : A, B { int c; void fc(); };

C 继承自 A 和 B,A 是第一个基类,所以 A 子对象在 C 对象的起始位置;B 子对象不在开头,它前面有 A 的部分,大概在 sizeof(A) 的偏移处。

当我们写:

C c;
B* pb = &c;
pb->fb();

发生了什么?

&c 的类型是 C ,要转换为 B 时,编译器发现 B 不是主基类,于是默默加了一个偏移量(取决于 A 的大小和对齐)。

pb 实际指向的是 c 对象内部 B 子对象的起始地址。调用 fb() 时,fb() 里面拿到的 this 就是那个偏移后的值。

因此:

  • 在 C 的成员函数里打印 this,值可能是 0x1000。
  • 在 B 的成员函数里打印 this,值可能是 0x1008。

同一个 C 对象,不同的函数看到的 this 不一样。我们 debug 时若不知道这回事,会怀疑内存出了灵异事件。

虚函数 + 多继承更刺激

假设 B 声明了虚函数 fb,C 覆写了它。编译器需要保证通过 B* 调用 C::fb 时,this 必须指向正确的 B 子对象,因为 fb 可能是 B 的成员,也可能需要访问 B 里定义的成员。

编译器生成一个 thunk(一小段调整代码):

thunk for C::fb:
    this -= offset_of_B_in_C; // 调整 this 指向 C 对象首地址
    goto real C::fb;

(如果是反向转换,例如通过 C* 调用了只属于 B 的虚函数,偏移方向就会反过来。)

我们单步进入虚调用时,经常会先到这个 thunk 里,this 寄存器瞬间变个值,再跳进真正的函数体。

虚继承更是坑爹

虚基类子对象相对于派生类的偏移,在基类成员函数的代码里通常是编译期无法确定的,只能通过运行时查表得到,因为任何一个直系或间接派生类都可能共享同一个虚基类实例。

虚基类子对象的偏移量存放在 vbtable(虚基类表)里,运行时查询。每次转换到虚基类指针或者访问其成员,都可能要通过 this 加上某个查表得到的偏移量。这意味着 this 的调整甚至不是简单常数,而要通过间接读取,效率底下,而且一旦 vbtable 没初始化好(构造/析构期间),就会崩得找不到东南西北。

这些就是多继承下 this 调整的真相,它暴露了 C++ 零开销抽象的另一面:我们掌控一切布局,但也必须承受一切复杂性。

深度了解

1. 从成员函数指针窥探 this 的踪迹

C++ 的成员函数指针,上可飞天下可遁地。它的声明语法大概是这样的:

int (Dog::*pfn)(doubleconst = &Dog::bark;

这是什么鬼东西?首先,pfn 不是普通指针,它根本不知道将来要绑定到哪个对象上,它只记录了函数的入口偏移,还有一系列类型信息,但 this 啥的一丁点都没存。

我们要调用它的话,必须得配合一个对象:

class Dog
{
public:
    Dog(std::string name) : name_(std::move(name)) {}

    int bark(double volume) const
    {
        std::cout << "[bark 内部] this = " << this
            << "  (对象身份: " << name_ << ")"
            << ", volume = " << volume
            << std::endl;
        // 方便观察
        return static_cast<int>(volume * 10);
    }

private:
    std::string name_;
};

Dog dog("xx");
int ret = (dog.*pfn)(3.14);
std::cout << "返回值: " << ret << std::endl;

输出:

[bark 内部] this = 000000B88333F368 (对象身份: xx), volume = 3.14
返回值: 31

或者用指针:

ret = (pDog->*pfn)(2.718);
std::cout << "返回值: " << ret << std::endl;;

输出:

[bark 内部] this = 000000B88333F368 (对象身份: xx), volume = 2.718
返回值: 27

对非虚函数,它多半存的就是函数的实际地址;但对虚函数,它可能存的是虚表偏移量。

为什么说它能窥探 this?因为当我们 (pd->pfn)(...) 时,编译器必须根据 pd 的静态类型和成员指针的类型,生成一个可能调整过的 this 传进函数。如果 pfn 属于基类 B,而 pd 是派生类 C,编译器会像我们之前说的多继承那样,在调用前偷偷偏移 this,让最终跑在 B 的函数体里的 this 指向正确的 B 子对象。

这玩意在最大的用途就是写回调映射、消息分发表,但说实话,现在大家多半用 std::function 或 lambda 包一层了,因为成员函数指针的语法实在太劝退。

2. 协变返回类型与 this 的巧妙配合

普通虚函数要求返回类型严格一致,但 C++ 允许协变返回:如果基类虚函数返回 Base* 或 Base&,派生类覆盖时可以返回 Derived* 或 Derived&,前提是返回的确实是个派生对象。

不过我们可以利用 this 的精确类型,在派生类里搞出自动转型的克隆:

class Base
{
public:
    virtual Base* clone() const
    {
        return new Base(*this);
    }
};

class Derived : public Base
{
public:
    Derived* clone() const override
    {
        return new Derived(*this);
    }
};

this 在 Derived 的 clone() 里类型是 const Derived*,解引用后就是 const Derived&,拷贝构造完美匹配。调用方用 Derived 指针接,不用手写 static_cast。

还可以结合 CRTP,让基类帮我们自动生成 clone 的覆盖:

template<typename T>
class Clonable
{
public:
    T* clone() const // 返回 T*
    {
        return new T(static_cast<const T&>(*this));
    }
};
class MyClass : public Clonable<MyClass> { /*...*/ };

这里 *this 是 Clonable,不能直接拷贝成 MyClass,所以用 static_cast<const T&> 掰成真正的派生类引用,前提是 this 真的指向 MyClass,没有在奇怪的继承网里串门。

还有一个现代版协变智能指针,标准原生虚函数不支持返回 std::unique_ptr 协变,但我们可以手动用自由函数或者 std::unique_ptr 的转换特性模拟。这些玩意的核心都是把 this 的正确类型信息传递给工厂函数,让使用方拿到最精确的指针。

结尾

我们千万别指望 this 能温柔点,它本来就是从 C with Classes 时代走出来的,每个地方都是性能与抽象的妥协。

我们能做的,就是尊重它,理解它,别用它乱玩。