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++ 中,constvolatile 通常被称为 cv 限定符,之所以这样称呼,是因为它们 限定 类型以形成 cv 限定的 类型。cv 非限定类型 T 的 cv 限定版本是 const Tvolatile Tconst 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 完全匹配,因此 qp 类型的转换成功。 正如你现在可能已经预料到的那样,当我们把 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

我们需要一个严谨的 “更好” 的概念。 好吧,这里有一个开始:让我们说 任何 标准转换序列总是优于用户定义的转换序列。此外,我们将说,对于两个调用相同转换函数/非显式构造函数的用户定义的转换序列 S1S2,如果 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
正如你可能想象的那样,“完全匹配” 优于 “提升”,“提升” 优于 “转换”,并且转换序列的排名是其组成转换的排名中最低的。因此,如果在两个转换序列之间进行战斗,请选择排名更好的那个。但是,如果它们具有 相同 的排名,事情会变得有点复杂。令 S1S2 为相同排名的标准转换序列。那么:

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