整理一下关于C++中特殊的this指针的知识,并且学习C++23中的新内容:显式推导this。

隐式this

基础

this指针是C++面向对象编程中的重要机制,在自定义类型的非静态成员函数中,都存在这一个自动传递的this指针指向当前对象自身,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

struct Test {
int data = 0;
void call() { std::cout << "call: " << data << "\n"; }
};

int main() {
Test test{1};
test.call();

return 0;
}

对于编译器来说,这里的定义和调用过程等效于下面的形式(因为this是关键词,在代码中使用this_来代表)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

struct Test {
int data = 0;
};

void Call(Test *const this_) { std::cout << "call: " << this_->data << "\n"; }

int main() {
Test test{1};
Call(&test);

return 0;
}

为了语法的简洁性,C++默认将this在参数列表和调用过程中直接隐藏了,相应的细节由编译器负责代劳。在Python中就必须要显式写出self来代表当前对象自身,两者发挥的是完全相同的角色。

隐式传递this虽然提供了很多便利性,但是却带来了额外的问题,这导致C++需要在语法上不断地为其添加补丁,并且最终促成了C++23的显式推导this。

const 限定符

在默认情况下,this指针的类型是C *const(这里C是类型名称,下同),代表着指针自身不可变,但是指向的内容可变。 但是一个const对象可以调用一个方法修改自身数据吗?显然这是不合逻辑的,但是这就意味着const对象无法调用任何的普通成员方法,这也是不合适的,我们必须为方法提供合适的this指针类型。

C++允许我们给普通成员方法加上额外的修饰来改变这个指针的类型,最常见的修饰词是const, 它会将this指针的类型变为const C *const,这代表指向常量的常量指针,即除了指针自身不可变,指针指向的内容也不可变。

对于参数列表完全相同的两个同名方法,如果其中一个有const修饰,另一个没有,那么这代表着隐含参数this的类型不同,两个方法构成重载的关系,编译器会自动判断调用哪一个版本的方法最合适,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

struct Test {
int data = 0;
void call() { std::cout << "call: " << data << "\n";}

void call() const { std::cout << "call const: " << data << "\n";}
};

int main() {
Test test1{1};
const Test test2{2};

test1.call(); // call: 1
test2.call(); // call const: 2

return 0;
}

在实践中,我们需要这样设计一个普通成员方法:

  • 如果不需要修改内部成员,那么只提供const版本的方法即可;(这意味着可变对象和不可变对象都可以调用这个方法)
  • 如果必须要修改内部成员,那么只提供非const版本的方法即可;(这意味着不可变对象禁止调用这个方法,否则编译失败)
  • 如果我们希望对于可变对象和不可变对象都支持调用,但是对应的实现内容不同,那么我们就需要同时提供const和非const版本的方法。

引用限定符

上面这只是体现了最简单的可变和不可变对象的区别,由于C++引入了右值引用的概念,有时我们甚至需要区分调用对象到底是左值还是右值, 并为其提供不同版本的实现内容,这时就需要加上额外的引用限定符:

  • 加上&修饰,则方法只允许被左值对象调用,否则编译报错;
  • 加上&&修饰,则方法只允许被右值对象调用,否则编译报错。

如果没有出现这两种修饰词,那么在默认情况下,对于方法调用的合法性判断是与左值右值无关的。

引用限定符修饰可以进一步和const组合,得到&&&const &const &&四个版本。再加上原始版本和const版本,我们可以得到至多六个不同版本的普通成员函数,但是需要注意的是:引用限定符不改变this指针类型,这意味着六个版本无法同时出现,并不都构成重载关系,并且存在语义的冲突。

例如原始版本、&版本和&&版本三者可能存在冲突,允许的做法是:

  • 只提供原始版本;(左值右值都调用)
  • 只提供&版本;(只有左值可以调用,右值不允许调用)
  • 只提供&&版本;(只有右值可以调用,左值不允许调用)
  • 同时提供&&&两个版本;(左值和右值调用对应的版本)

常见的其实只有两种做法:要么不区分左值右值,只提供原始版本的实现;要么区分左值右值,分别进行实现。对于const版本,const &版本和const &&版本之间的关系同理。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

struct Test {
int data = 0;

void call() & { std::cout << "lvalue: " << data << "\n";}

void call() && { std::cout << "rvalue: " << data << "\n";}

void call() const & { std::cout << "const lvalue: " << data << "\n";}

void call() const && { std::cout << "const rvalue: " << data << "\n";}
};

int main() {
Test test{1};
test.call();
std::move(test).call();

Test{2}.call();

const Test constTest{3};
constTest.call();
std::move(constTest).call();
}

运行结果如下

1
2
3
4
5
lvalue: 1
rvalue: 1
rvalue: 2
const lvalue: 3
const rvalue: 3

由结果可见,编译器分别为其匹配了最合适的版本,这种匹配大致遵循如下原则:

  • 如果非const版本的方法缺失,那么可变对象也会尝试匹配const版本的方法;
  • const &可以被各种版本的调用对象都匹配到,但是优先级最低。

显式推导this

基础

C++23提供了显式推导this的语法,它允许我们通过更自然的方式来明确this参数的类型。(必须开启c++23语法标准)

我们可以使用显式推导this改写之前的例子,例如原始版本和const版本的对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

struct Test {
int data = 0;

void call(this Test &self) { std::cout << "call: " << self.data << "\n"; }

void call(this const Test &self) {
std::cout << "call const: " << self.data << "\n";
}
};

int main() {
Test test1{1};
const Test test2{2};

test1.call(); // call: 1
test2.call(); // call const: 2

return 0;
}

这两个成员函数中的第一个参数self作为对象自身的引用,完全替代了原本this指针的角色,虽然语法上更加繁琐,但是self的类型区别看起来非常清晰

1
2
3
4
5
void Test::call(this Test &self) { std::cout << "call: " << self.data << "\n"; }

void Test::call(this const Test &self) {
std::cout << "call const: " << self.data << "\n";
}

对于显式推导this有以下语法要求:

  • 显式this参数必须出现在形参列表中的首位,并且通常是当前对象的某种引用类型,需要给一个具体的名称,不允许使用this,建议使用self
  • 显式推导this与原本的隐式this的用法冲突,不能在成员函数尾部加上const限定符和引用限定符;
  • 在函数体中不能使用this指针,我们需要使用提供的this参数名称,而且需要以引用的形式来使用,而非指针的形式;
  • 对于当前对象的所有数据成员的访问,不允许省略调用者,例如(*this).n可以省略为n,但是显式this参数不允许省略,必须使用完整形式self.n

对于使用引用限定符的不同版本,使用显式推导this改写的结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

struct Test {
int data = 0;

void call(this Test &self) { std::cout << "lvalue: " << self.data << "\n"; }

void call(this Test &&self) {
std::cout << "rvalue: " << self.data << "\n";
}

void call(this const Test &self) {
std::cout << "const lvalue: " << self.data << "\n";
}

void call(this const Test &&self) {
std::cout << "const rvalue: " << self.data << "\n";
}
};

int main() {
Test test{1};
test.call();
std::move(test).call();

Test{2}.call();

const Test constTest{3};
constTest.call();
std::move(constTest).call();
}

应用

显式推导this最主要的动机其实是消除成员函数修饰所带来的多份重复代码冗余,例如

1
2
3
4
5
6
struct Test {
int data;

int& get() & { return data; }
const int& get() const & { return data; }
};

使用显式推导this并结合模板可以将不同版本通过一份代码实现

1
2
3
4
5
6
7
8
struct Test {
int data;

template <typename Self>
auto&& get(this Self&& self) {
return std::forward<Self>(self).data;
}
};

习惯上这里的模板类型参数使用Self

显式推导this还可以更好地解决lambda表达式递归的问题。在此之前,我们需要外部工具才能实现lambda表达式的递归操作,例如基于std::function包装并捕获

1
2
3
4
5
6
7
8
9
10
#include <functional>
#include <iostream>

int main() {
std::function<int(int, int)> gcd = [&](int a, int b) -> int {
return b == 0 ? a : gcd(b, a % b);
};

std::cout << gcd(20, 30) << "\n";
}

使用显式推导this则可以自行实现递归调用

1
2
3
4
5
6
7
8
9
#include <iostream>

int main() {
auto gcd = [](this auto self, int a, int b) -> int {
return b == 0 ? a : self(b, a % b);
};

std::cout << gcd(20, 30) << "\n";
}

显式推导this在某些情况下可以简化替代CRTP,但是有很多局限:

  • 比如我们必须通过派生类自身来调用方法,而不是通过基类引用来调用(因为没有使用模板保存派生类,编译器此时根本不知道派生类是什么);
  • 再比如我们必须实现两个方法(否则会陷入自身的无限递归,运行期报错),其中一个方法被派生类分别实现,另一个方法被基类通过显式推导this实现,在其内部可以调用前者的派生类版本(根据调用者的类型决定)

示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <string>

class Base {
public:
void run1(this auto &self) {
std::cout << "Base Run" << std::endl;
self.run2();
}
};

class Derived1 : public Base {
private:
std::string m_name;

public:
explicit Derived1(std::string name) : m_name(std::move(name)) {}

void run2() {
std::cout << "Derived1 Run, his name is " << m_name << std::endl;
}
};

class Derived2 : public Base {
private:
std::string m_name;

public:
explicit Derived2(std::string name) : m_name(std::move(name)) {}

void run2() {
std::cout << "Derived2 Run, her name is " << m_name << std::endl;
}
};

int main() {
Derived1 d1("Tom");
d1.run1();

Derived2 d2("Jerry");
d2.run1();
return 0;
}

运行结果如下

1
2
3
4
Base Run
Derived1 Run, his name is Tom
Base Run
Derived2 Run, her name is Jerry