Cpp 面向对象——成员函数中的 this
整理一下关于C++类的成员函数所拥有的特殊的this指针的知识,并且学习C++23中的新内容:显式推导this。
隐式this
基础
this指针是C++面向对象编程中的重要机制,在自定义类型的非静态成员函数中,都存在这一个自动传递的this指针指向当前对象自身,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
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
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
表明,在方法内部调用其它方法,本质上都是通过指针访问的,如果调用的是虚函数,也会涉及到动态绑定的行为。
隐式传递
this
虽然提供了很多便利性,但是却带来了额外的问题,这导致C++需要在语法上不断地为其添加补丁,并且最终促成了C++23的显式推导this。
const 限定符
在默认情况下,this
指针的类型是C *const
(这里C
是类型名称,下同),代表着指针自身不可变,但是指向的内容可变。
但是一个const
对象可以调用一个方法修改自身数据吗?显然这是不合逻辑的,但是这就意味着const
对象无法调用任何的普通成员方法,这也是不合适的,我们必须为方法提供合适的this
指针类型。
C++允许我们给普通成员方法加上额外的修饰来改变这个指针的类型,最常见的修饰词是const
,
它会将this
指针的类型变为const C *const
,这代表指向常量的常量指针,即除了指针自身不可变,指针指向的内容也不可变。
对于参数列表完全相同的两个同名方法,如果其中一个有const
修饰,另一个没有,那么这代表着隐含参数this
的类型不同,两个方法构成重载的关系,编译器会自动判断调用哪一个版本的方法最合适,例如
1 |
|
在实践中,我们需要这样设计一个普通成员方法:
- 如果不需要修改内部成员,那么只提供
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
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
5lvalue: 1
rvalue: 1
rvalue: 2
const lvalue: 3
const rvalue: 3
由结果可见,编译器分别为其匹配了最合适的版本,这种匹配大致遵循如下原则:
- 如果非
const
版本的方法缺失,那么可变对象也会尝试匹配const
版本的方法; const &
可以被各种版本的调用对象都匹配到,但是优先级最低。
显式推导this
基础
C++23提供了显式推导this(deducing
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
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
5void 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
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
6struct Test {
int data;
int& get() & { return data; }
const int& get() const & { return data; }
};
显式推导this可以结合模板和完美转发可以将不同版本通过一份代码实现
1
2
3
4
5
6
7
8struct 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
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
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结合模板或auto的用法实质上将调用方自身类型也模板化了,这在很多情况下可以简化替代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
44
45
46
47
48
class Base {
public:
void run1(this auto &&self) {
std::cout << "Base Run\n";
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 << '\n';
}
};
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 << '\n';
}
};
int main() {
Derived1 d1("Tom");
d1.run1();
Derived2 d2("Alice");
d2.run1();
Derived1{"Bob"}.run1();
Derived2{"Ada"}.run1();
return 0;
}
运行结果如下 1
2
3
4
5
6
7
8Base Run
Derived1 Run, his name is Tom
Base Run
Derived2 Run, her name is Alice
Base Run
Derived1 Run, his name is Bob
Base Run
Derived2 Run, her name is Ada
其中的关键部分如下 1
2
3
4void run1(this auto &&self) {
std::cout << "Base Run\n";
self.run2();
}
this auto &&self
会自动推导并调用不同的版本,并且支持左值和右值调用。
如果改成this auto &self
,那么就只支持左值调用
1
2
3
4void run1(this auto &self) {
std::cout << "Base Run\n";
self.run2();
}
也可以改成 1
2
3
4void run1(this const auto &self) {
std::cout << "Base Run\n";
self.run2();
}
然后把run2
全部加上const
限定即可通过编译。