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

一个标准转换序列是以下每个类别中的零个或一个转换的序列,按顺序排列:

  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。
  2. 整型/浮点型/布尔型/指针/指向成员的指针的转换和提升:
    • 有很多在各种整型和浮点型之间进行转换的规则,这些规则是必要的,但坦率地说,很琐碎且无趣,所以我们也会省略这些。指针/指向成员的指针的转换可能是你已经知道的东西。
  3. 函数指针转换:将 “指向 noexcept 函数的指针” 类型的 prvalue 转换为 “指向函数的指针” 类型的 prvalue。
  4. 限定转换:以某种方式统一两种类型的 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}cv0​P0​cv1​P1​cv2​U 其中 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}cv0​P0​cv1​P1​…cvn−1​Pn−1​cvn​U 对于某个 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:

这可以被认为是一种用于查找转换后的类型 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 如下:

  1. 设置 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),所以只需继续。
  2. 设置 c2:=a2∪b2=∅c_2 := a_2\cup b_2=\emptysetc2​:=a2​∪b2​=∅。由于 a2=b2a_2 = b_2a2​=b2​,继续。
  3. 设置 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 如下:

  1. 设置 c1:=a1∪b1=∅c_1 := a_1\cup b_1=\emptysetc1​:=a1​∪b1​=∅。由于 a1=b1a_1 = b_1a1​=b1​,继续。
  2. 设置 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}。
  3. 设置 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 称为可行的:

定义 3

令 FFF 和 GGG 为两个可行的候选函数,令 ICS⁡i(F)\operatorname{ICS}_i(F)ICSi​(F) 表示将第 iii 个参数转换为 FFF 的第 iii 个参数类型的(可能微不足道的)隐式转换序列。如果对于每个 iii,ICS⁡i(F)\operatorname{ICS}_i(F)ICSi​(F) 不比 ICS⁡i(G)\operatorname{ICS}_i(G)ICSi​(G) 更差,则我们说 FFF 比 GGG 更好,并且:

那里有 痒。有太多未解的问题。首先,我们仍然无法弄清楚为什么一个转换序列可能比另一个更好;此外,仍然不清楚为什么这段代码会这样运行:

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 为相同排名的标准转换序列。那么:

  1. 如果 S1 是 S2 的真子序列,则选择 S1。
  2. 如果 S1 和 S2 是基类/派生类指针之间的转换,那么有很多关于哪个更好的广泛的无趣规则,你 可能大部分 都能凭直觉理解。
  3. 通常,尽可能优先绑定 rvalue 引用。2
  4. 优先将函数 lvalue 绑定到 lvalue 引用,而不是 rvalue 引用。3
  5. 如果 S1 和 S2 分别是从 T0 到相似类型 T1 和 T2 的转换,仅在限定转换步骤中有所不同,并且 T1 可以限定转换为 T2,那么 S1 优于 S2(如果 T1 位于 T0 和 T2 之间,则转换为 T1 的 “工作量更少”,所以我们更喜欢 S1)。
  6. 如果在 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,因为关联的隐式转换序列是第一个的子集。

请记住,由于唯一的 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:

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——即 名称共享——有点可怕。转换,我也猜,但我认为现在已经广泛一致认为隐式转换通常会使编写 不正确 的代码更容易。 这真的值得吗? 很难说。不过,很难不去以一种病态的方式看着它,就像看着某种受伤的动物。 一个结束语: 标准非常长且密集且分散,而且,我非常愚蠢,所以这里出错的可能性不为零。如果你比我聪明并且发现了任何此类实例,请给我发送电子邮件或类似的东西。正如他们所说,最简单的学习方式是在互联网上犯错。 ❦

  1. 还有 省略号转换序列 ,它们的排名最后,但我在这里将其从文章中删除。↩
  2. 实际规则比这更复杂,但我正在简化。↩
  3. 我刚刚了解到这是一个语言特性,而且太愚蠢了。看:
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