`Overload`:福兮祸兮 (2024)
consteval NAVIGATION Home About Archive Jul 25, 2024•c++
Overload
:福兮祸兮
你知道什么一直困扰着我吗?自从写了我上一篇文章之后,就是“更好 (better)”这个词。它是在我们讨论 overload resolution
和隐式转换序列时出现的。我解释了一个必要的特殊情况——关于在引用绑定中添加 const
优先——然后策略性地闭口不谈其余部分。
void run(int (**f)()); // #1
void run(int (*const *f)() noexcept); // #2
int foo() noexcept;
int (*p)() noexcept = &foo;
run(&p); // ???
但这太 诱人 了,不是吗? 它会选择哪一个?我们该如何推理? 我能从你疲惫但渴望的眼神中看出来。你渴望 转换 (conversion)。好吧,我本来不打算——我——好吧……好吧,既然你这么坚持。 只为你。 我们开始吧?
∗ ∗ ∗
让我们从小处着手,逐步深入。一个隐式转换序列是一个 标准转换序列 ,可能后跟一个用户定义的转换,并且在类类型的情况下,再后跟另一个标准转换序列。1 用户定义的转换类似于 T::operator S()
,它定义了如何将 T
转换为 S
。这些都很简单:它们完全按照我们告诉它们的方式工作。因此,显然足以理解 标准转换序列。
定义 1
一个标准转换序列是以下每个类别中的零个或一个转换的序列,按顺序排列:
- Lvalue-to-rvalue、array-to-pointer 或 function-to-pointer 转换:
- Lvalue-to-rvalue:将非函数、非数组类型的 glvalue 转换为 prvalue。与
overload resolution
没有特别的关系,而且有点复杂,所以我们主要会忘记这一点。 - Array-to-pointer:将 “NNN 个
T
的数组” 或 “T
的未知边界数组” 类型的表达式转换为 “指向T
的指针” 类型的 prvalue,如果表达式是 prvalue,则应用临时实体化转换(请注意,GCC 有一个错误,不会这样做; temporary materialization 将在稍后定义)。 - Function-to-pointer:将
T
类型的 lvalue 函数转换为 “指向T
的指针” 类型的 prvalue。
- Lvalue-to-rvalue:将非函数、非数组类型的 glvalue 转换为 prvalue。与
- 整型/浮点型/布尔型/指针/指向成员的指针的转换和提升:
- 有很多在各种整型和浮点型之间进行转换的规则,这些规则是必要的,但坦率地说,很琐碎且无趣,所以我们也会省略这些。指针/指向成员的指针的转换可能是你已经知道的东西。
- 函数指针转换:将 “指向
noexcept
函数的指针” 类型的 prvalue 转换为 “指向函数的指针” 类型的 prvalue。 - 限定转换:以某种方式统一两种类型的
const
性质。哦,天啊。不会那么糟糕吧?对吧?
惊喜!这篇文章实际上是关于限定转换的
好吧——好吧。呃。听我说。
在 C++ 中,const
和 volatile
通常被称为 cv 限定符,之所以这样称呼,是因为它们 限定 类型以形成 cv 限定的 类型。cv 非限定类型 T
的 cv 限定版本是 const T
、volatile T
和 const volatile T
。我们还可以考虑在内部嵌套 cv 限定符的类型 T
——例如,const int** const
(“指向指向 const int
的指针的 const
指针”)可以以类型别名系列中被替换为 X
:
using U = const int;
using V = U*;
using W = V*;
using X = const W;
现在,一个有数学头脑的读者可以选择将 “指向指向 const int
的指针的 const
指针” 写作
cv0P0cv1P1cv2Ucv_0~P_0~cv_1~P_1~cv_2~\mathtt{U}cv0P0cv1P1cv2U
其中 cv0={const}cv_0=\{\mathtt{const}\}cv0={const}, cv1=∅cv_1=\emptysetcv1=∅, cv2={const}cv_2=\{\mathtt{const}\}cv2={const}, P0=P1=“pointer to”P_0=P_1=\text{``pointer to''}P0=P1=“pointer to”, 并且 U=int\mathtt{U}=\mathtt{int}U=int. 更一般地,我们可以将 任何 类型 T\mathtt{T}T (不一定唯一地)写作
cv0P0cv1P1…cvn−1Pn−1cvnUcv_0~P_0~cv_1~P_1~\ldots~cv_{n-1}~P_{n-1}~cv_n~\mathtt{U}cv0P0cv1P1…cvn−1Pn−1cvnU
对于某个 n≥0n\ge 0n≥0 和某个类型 U\mathtt{U}U;每个 PiP_iPi 要么是 “指向...的指针”,要么是 “NNN 个...的数组”,要么是 “未知大小的...的数组”。为简单起见,我们假设每个 PiP_iPi 始终是 “指向...的指针”。
请注意,为了确定一种类型是否可以限定转换为另一种类型(例如,尝试将 int*
转换为 const int*
),我们总是可以完全删除 cv0cv_0cv0 —— 特别是,在顶层,我们总是可以用 T
初始化一个 const T
,反之亦然,同样地,我们总是可以从一个转换到另一个。所以,让我们忘记 cv0cv_0cv0。
由于我们不太关心任何 PiP_iPi 或 U\mathtt{U}U——这些是 “非 const
-y” 部分,我们将分别处理它们——让我们更紧凑地将其写作 nnn 元组 (cv1,cv2,…,cvn)(cv_1,cv_2,\ldots,cv_n)(cv1,cv2,…,cvn)。最 长 的这种元组称为 T\mathtt{T}T 的 cv 限定签名。
我们快到了。我正在 非常 努力地使 C++ 标准更容易理解,所以请耐心。如果两个类型 T1\mathtt{T1}T1 和 T2\mathtt{T2}T2 具有相同大小的 cv 分解,使得它们各自的 PiP_iPi 要么 (1) 相同,要么 (2) 一个是 “NNN 个...的数组”,另一个是 “未知大小的...的数组”;并且,此外,它们的 U\mathtt{U}U 应该一致。 基本上,如果它们的 cv 分解的 “非 const
-y” 部分基本一致,则称它们为 “相似”。
好的。是时候了。我几乎只是在释义标准,因为 这是我现在唯一能做的 ——坦率地说,它的措辞非常严谨。令 T1\mathtt{T1}T1 和 T2\mathtt{T2}T2 为两种类型。那么,它们的 cv 组合类型 T3\mathtt{T3}T3 (如果存在)是与 T1\mathtt{T1}T1 相似的类型,使得对于每个 i>0i>0i>0:
- cvi3=cvi1∪cvi2cv_i^3=cv_i^1\cup cv_i^2cvi3=cvi1∪cvi2;
- 如果 Pi1P_i^1Pi1 或 Pi2P_i^2Pi2 中的任何一个是 “未知边界的数组”,那么 Pi3P_i^3Pi3 也是如此;并且,
- 如果 cvi3≠cvi1cv_i^3\ne cv_i^1cvi3=cvi1, cvi3≠cvi2cv_i^3\ne cv_i^2cvi3=cvi2, Pi3≠Pi1P_i^3\ne P_i^1Pi3=Pi1 或 P13≠Pi2P_1^3\ne P_i^2P13=Pi2,那么 const\mathtt{const}const 将被添加到每个 cvj3cv_j^3cvj3,对于 0<j<i0<j<i0<j<i。
这可以被认为是一种用于查找转换后的类型 T3\mathtt{T3}T3 的算法。如果在最后发现 T3=T2\mathtt{T3}=\mathtt{T2}T3=T2 (直到顶层 cv 限定符),那么 T1\mathtt{T1}T1 类型的 prvalue 可以成功转换为 T2\mathtt{T2}T2 类型的 prvalue。 嘿,这只是 有点 可怕。如果你仔细想想,它实际上很 可爱。要点是,如果 T2\mathtt{T2}T2 的 cv 限定签名在与 T1\mathtt{T1}T1 最后一个不一致的点之前没有 const\mathtt{const}const,那么转换可能不会成功。 我通过看一些例子学得最好,所以这里有两个我认为有用的例子:
// q :: "指向指向指向 int 的指针的指针"
int*** q{};
// p :: "指向指向指针的 const 指针的指针"
int** const* p = q;
这个可以编译。PiP_iPi 和 UUU 都匹配,所以我们只关注 cv 限定符。int***
的 cv 限定签名是 a:=(∅,∅,∅)a := (\emptyset, \emptyset, \emptyset)a:=(∅,∅,∅),而 int** const*
的 cv 限定签名是 b:=({const},∅,∅)b := (\{\mathtt{const}\}, \emptyset, \emptyset)b:=({const},∅,∅)。所以,我们确定 cv 组合类型的 cv 限定签名 ccc 如下:
- 设置 c1:=a1∪b1={const}c_1 := a_1\cup b_1=\{\mathtt{const}\}c1:=a1∪b1={const}。尽管 a1≠b1a_1 \ne b_1a1=b1,但没有先前的集合需要更改(即,i=1i=1i=1),所以只需继续。
- 设置 c2:=a2∪b2=∅c_2 := a_2\cup b_2=\emptysetc2:=a2∪b2=∅。由于 a2=b2a_2 = b_2a2=b2,继续。
- 设置 c3:=a3∪b3=∅c_3 := a_3\cup b_3=\emptysetc3:=a3∪b3=∅。由于 a3=b3a_3 = b_3a3=b3,继续。
然后,c=b=(const,∅,∅)c=b=(\mathtt{const}, \emptyset, \emptyset)c=b=(const,∅,∅) 是 int** const*
的 cv 限定签名,与 bbb 完全匹配,因此 q
到 p
类型的转换成功。
正如你现在可能已经预料到的那样,当我们把 p
中的一个星号移过来时,情况会变得更糟:
// q :: "指向指向指向 int 的指针的指针"
int*** q;
// p :: "指向指向 const 指针的指针"
int* const** p = q;
这一个让我彻夜难眠。实际上,它 不 编译,原因很容易被忽略。让我们过一遍:int***
的 cv 限定签名是 a:=(∅,∅,∅)a := (\emptyset, \emptyset, \emptyset)a:=(∅,∅,∅),而 int** const*
的 cv 限定签名是 b:=(∅,{const},∅)b := (\emptyset, \{\mathtt{const}\}, \emptyset)b:=(∅,{const},∅)。所以,我们确定 cv 组合类型的 cv 分解 ccc 如下:
- 设置 c1:=a1∪b1=∅c_1 := a_1\cup b_1=\emptysetc1:=a1∪b1=∅。由于 a1=b1a_1 = b_1a1=b1,继续。
- 设置 c2:=a2∪b2={const}c_2 := a_2\cup b_2=\{\mathtt{const}\}c2:=a2∪b2={const}。由于 a2≠b2a_2\ne b_2a2=b2,设置 c1:=c1∪{const}={const}c_1:=c_1\cup\{\mathtt{const}\}=\{\mathtt{const}\}c1:=c1∪{const}={const}。
- 设置 c3:=a3∪b3=∅c_3 := a_3\cup b_3=\emptysetc3:=a3∪b3=∅。由于 a3=b3a_3 = b_3a3=b3,继续。
然后,c=({const},{const},∅)≠bc=(\{\mathtt{const}\}, \{\mathtt{const}\}, \emptyset)\ne bc=({const},{const},∅)=b,所以转换失败,我们得到了一个编译器错误,但并没有说明太多关于此过程的信息。太棒了。 我们刚才在说什么来着?哦。 对了。我想你可能想重新浏览一下这篇文章的开头,以刷新你对剩余的标准转换内容的记忆。在我们继续之前,我将添加一种我没有提到的隐式转换,你可能已经知道了。临时实体化是一种应用于 prvalue 的转换,它初始化 prvalue 指定的对象并生成表示它的 xvalue。这是一种扩展临时对象生命周期的可爱方式:它发生在像前面提到的 array-to-pointer 转换、将引用绑定到 prvalue 等情况中。 通常,这只会将临时对象的生命周期延长到原始语句的评估完成为止;对此的少数例外之一是引用绑定:
void foo(int* arr);
using U = int[4];
foo(U{1,2,3,4}); // OK
int* ptr = U{1,2,3,4}; // 悬垂指针...
const U& ref = U{1,2,3,4}; // OK
有了这个腐烂的樱桃,让我们回到 overload resolution
。
走向更好的 “更好”
虽然我们对类型之间的转换有了一些了解,但 overload resolution
涉及在 许多 可能的类型之间进行转换——对于每个 overload
——并决定哪些转换 “更好”。回顾上一篇文章中给出的定义:
定义 2
在表达式 f(E1, ..., En)
的 overload resolution
中,如果满足以下条件,则候选函数 FFF 称为可行的:
- 给定的参数数量 “匹配” FFF 的参数数量;
- 它的约束(即,C++20 concepts/constraints)被表达式满足;并且
- 对于每个参数,都存在一些隐式转换序列将其转换为相应参数的类型。
定义 3
令 FFF 和 GGG 为两个可行的候选函数,令 ICSi(F)\operatorname{ICS}_i(F)ICSi(F) 表示将第 iii 个参数转换为 FFF 的第 iii 个参数类型的(可能微不足道的)隐式转换序列。如果对于每个 iii,ICSi(F)\operatorname{ICS}_i(F)ICSi(F) 不比 ICSi(G)\operatorname{ICS}_i(G)ICSi(G) 更差,则我们说 FFF 比 GGG 更好,并且:
- 存在某个 jjj,使得 ICSj(F)\operatorname{ICS}_j(F)ICSj(F) 是比 ICSj(G)\operatorname{ICS}_j(G)ICSj(G) “更好” 的转换序列;或者,否则,
- (为了简洁起见,省略了一些其他事项的列表)。
那里有 痒。有太多未解的问题。首先,我们仍然无法弄清楚为什么一个转换序列可能比另一个更好;此外,仍然不清楚为什么这段代码会这样运行:
void foo(const int&); // #1
void foo(int&); // #2
const int x; int y;
foo(4); // #1
foo(x); // #1
foo(y); // #2
我们需要一个严谨的 “更好” 的概念。
好吧,这里有一个开始:让我们说 任何 标准转换序列总是优于用户定义的转换序列。此外,我们将说,对于两个调用相同转换函数/非显式构造函数的用户定义的转换序列 S1
和 S2
,如果 S1
之后的标准转换序列优于 S2
之后的标准转换序列,则 S1
优于 S2
(回想一下,根据 隐式转换序列 的定义,一个(可能微不足道的)标准转换序列总是跟在用户定义的转换之后)。这将用户定义的转换序列放在一边(注意 “更好” 这个术语本身已经变得有点 overload
),因此仍然需要对标准转换进行排序。
我们正在到达那里——我能感觉到。让我们从标准中的 [over.ics.scs] 中提取一个表格开始:
[tab:over.ics.scs] 转换 | 排名
---|---
None | 完全匹配
Lvalue-to-rvalue
Array-to-pointer
Function-to-pointer
Qualification
Function pointer
Integral promotions | Promotion
Floating-point promotion
Integral | Conversion
Floating-point
Floating-integral
Pointer
Pointer-to-member
Boolean
正如你可能想象的那样,“完全匹配” 优于 “提升”,“提升” 优于 “转换”,并且转换序列的排名是其组成转换的排名中最低的。因此,如果在两个转换序列之间进行战斗,请选择排名更好的那个。但是,如果它们具有 相同 的排名,事情会变得有点复杂。令 S1
和 S2
为相同排名的标准转换序列。那么:
- 如果
S1
是S2
的真子序列,则选择S1
。 - 如果
S1
和S2
是基类/派生类指针之间的转换,那么有很多关于哪个更好的广泛的无趣规则,你 可能大部分 都能凭直觉理解。 - 通常,尽可能优先绑定 rvalue 引用。2
- 优先将函数 lvalue 绑定到 lvalue 引用,而不是 rvalue 引用。3
- 如果
S1
和S2
分别是从T0
到相似类型T1
和T2
的转换,仅在限定转换步骤中有所不同,并且T1
可以限定转换为T2
,那么S1
优于S2
(如果T1
位于T0
和T2
之间,则转换为T1
的 “工作量更少”,所以我们更喜欢S1
)。 - 如果在
S1
和S2
期间绑定了引用,并且被引用类型直到顶层 cv 限定符都相同,则优先选择被引用类型限定较少的序列(即,避免在引用绑定中不必要的 cv 限定)。
最后一条规则解释了之前示例中的 overload resolution
,它也出现在我的上一篇文章中。明白了。无论如何,我们现在有了标准转换,因此有了隐式转换序列,因此有了整个 overload resolution
。都明白了吗? 没有? 很好——这至少 有点 密集,所以这里有一些例子:
void foo(const int p); // #1
void foo(int p); // #2
foo(5); // #1 or #2?
展示答案
这个格式不正确,因为对于非引用类型,消除歧义永远不会通过顶层 cv 限定符发生——任何调用都会是模棱两可的,所以这个 "overload
" 算作 重定义。不可能有意义地消除歧义,因为我们是通过值传递的:编译器应该如何知道 const
或非 const
副本哪个更好?
void run(int (*f)()); // #1
void run(int (*const f)() noexcept); // #2
int foo() noexcept;
run(foo); // #1 or #2?
展示答案
这将选择第二个 overload
,因为关联的隐式转换序列是第一个的子集。
- 候选函数 1: function-to-pointer; function pointer; 完成。
- 候选函数 2: function-to-pointer; 完成。
请记住,由于唯一的 const
是第一个,所以不会发生限定转换。我们可以删除 const
。请注意,void run(int (const *f)() noexcept)
的格式不正确,因为函数类型不能是 cv 限定的。
// f :: "指向指向 `int()` 的指针的指针"
void run(int (**f)()); // #1
// g :: "指向指向 `int() noexcept` 的 const 指针的指针"
void run(int (*const *g)() noexcept); // #2
int foo() noexcept;
int (*p)() noexcept = &foo;
// &p :: "指向指向 `int() noexcept` 的指针的指针"
run(&p); // #1 or #2?
展示答案
选择第二个 overload
:
- 候选函数 1: 不可行——我们无法在超出 “指向
int() noexcept
的指针” 的深度执行函数指针转换,所以我们能做的最好的就是运行限定转换,但那时我们仍然缺少一个noexcept
,所以无法完成转换。 - 候选函数 2: 限定转换;成功!
void foo(int*& p);
int arr[3];
foo(arr); // 格式良好?
展示答案
格式错误: array-to-pointer 转换会将参数表达式 arr
从 “3 个 int
的数组” 类型的 lvalue 转换为 “指向 int
的指针” 类型的 prvalue,该 prvalue 不能绑定到 lvalue 引用参数。
// p :: "指向指向 const 指针的指针"
void foo(int* const** p);
// a :: "指向 5 个 `指向 int 的指针` 的数组的指针"
int* (*a)[5]{};
foo(a); // 格式良好?
展示答案
格式错误: 数组不在顶层,所以 array-to-pointer 转换无法发生,因此限定转换也无法发生。
∗ ∗ ∗
该死。它 就那样工作了。就像一台运转良好的机器。我的意思是,很明显它确实有效——毕竟编译器让它工作——但能够 感受 它如何工作并能够更彻底地推理它是另一回事。
……另一方面,就像,这 太可怕了,对吧?当然,你可以通过编写健全的 overload
并避免嵌套太深的指针来避免这种胡说八道,但是站在一边,看着这个庞大的、蜿蜒的鲁布·戈德堡机械,仅仅为了支持 ad hoc polymorphism
——即 名称共享——有点可怕。转换,我也猜,但我认为现在已经广泛一致认为隐式转换通常会使编写 不正确 的代码更容易。 这真的值得吗? 很难说。不过,很难不去以一种病态的方式看着它,就像看着某种受伤的动物。
一个结束语: 标准非常长且密集且分散,而且,我非常愚蠢,所以这里出错的可能性不为零。如果你比我聪明并且发现了任何此类实例,请给我发送电子邮件或类似的东西。正如他们所说,最简单的学习方式是在互联网上犯错。
❦
using U = int();
void foo(U&&); // #1
void foo(U&); // #2
int bar();
foo(bar); // 选择 #2 -- OK
foo(std::move(bar)); // 也选择 #2 -- ???
C++ 中有一条规则说,如果你有一个函数 f
返回 rvalue 引用(例如 T&&
),那么表达式 f(args)
就是一个 xvalue。今天我了解到,这条规则中有一个 例外——如果 T
是一个函数类型,那么它就是一个 lvalue。所以你永远无法真正获得一个表示函数的 rvalue。而且,就像,我想这很有道理 ——代码不是 “临时的” ——但是如果只是要像对待 lvalue 引用一样对待它们,为什么要允许对函数类型的 rvalue 引用??? ↩
相关文章
Jul 22, 2024 |
我,等于运算符
---|---
Jul 3, 2024 |
我没有构造函数,但我必须初始化
© 2025 EVAN GIRARDIN CC BY-NC 4.0 © 2025 EVAN GIRARDIN CC BY-NC 4.0 TOP THEME RESET RSS SUPER I KERNEL I