consteval

Jul 25, 2024•c++

Overload:福兮祸兮

你知道什么一直困扰着我吗?自从我写了上一篇文章以来,一直都是“更好”这个词。当我们谈论重载解析和隐式转换序列时,它就出现了。我解释了一个必要的特殊情况——关于在引用绑定中添加 const 是更优的——然后就巧妙地闭口不谈剩下的部分了。

void run(int (**f)());        // #1
void run(int (*const *f)() noexcept); // #2
int foo() noexcept;
int (*p)() noexcept = &foo;
run(&p); // ???

但这非常诱人,不是吗?它会选择哪一个?我们该如何推断? 我能从你酸涩但渴望的眼神中看出来。你渴望_转换_。好吧,我原本并不想——我——好吧……好吧,既然你如此坚持。只为你。我们开始吧?


让我们从小处着手,逐步深入。一个隐式转换序列是一个 标准转换序列,可能后面跟着一个用户定义的转换,以及在类类型的情况下,再跟着另一个标准转换序列。1 用户定义的转换类似于 T::operator S(),它定义了如何将 T 转换为 S。这些很简单:它们完全按照我们告诉它们的方式工作。所以,显然足以理解_标准转换序列_。

定义 1

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

  1. 左值到右值 (Lvalue-to-rvalue)、数组到指针 (array-to-pointer) 或函数到指针 (function-to-pointer) 转换:
    • 左值到右值:将非函数、非数组类型的 glvalue 转换为 prvalue。与重载解析没有特别的关系,而且有点复杂,所以我们基本上会忘记这一点。
    • 数组到指针:将类型为“NNN 个 T 的数组”或“未知大小的 T 的数组”的表达式转换为类型为“指向 T 的指针”的 prvalue,如果表达式是一个 prvalue,则应用临时对象实体化转换(注意 GCC 有一个 bug,不会这样做;临时对象实体化 在后面定义)。
    • 函数到指针:将类型为 T 的左值函数转换为类型为“指向 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=“指向的指针”P_0=P_1=\text{``指向的指针''}P0​=P1​=“指向的指针”, 并且 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​ 都是 "指向的指针," "NiN_iNi​ 的数组," 或 "未知大小的数组." 为了简单起见,让我们假设每个 PiP_iPi​ 总是 “指向的指针”。

请注意,对于确定一种类型是否可以限定转换为另一种类型(例如,尝试将 int* 转换为 const int*),我们可以总是完全放弃对 cv0cv_0cv0​ 的考虑——特别是在顶层,我们可以总是从 T 初始化 const T,反之亦然,同样我们可以总是从一个转换到另一个。所以,让我们忘记 cv0cv_0cv0​。

由于我们不太关心任何 PiP_iPi​ 或 U\mathtt{U}U——这些是“非 const 部分”,我们将单独处理它们——让我们将此写得更紧凑,写成 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) 一个是 "NiN_iNi​ 的数组" 而另一个是 "未知大小的数组",则它们被称为相似的; 而且,它们的 U\mathtt{U}U 应该一致。基本上,如果它们的 cv 分解的 “非 const” 部分基本一致,则它们被称为 “相似的”。

好的。是时候了。我几乎只是在复述标准,因为 这就是我现在能做的——老实说,它的措辞非常严谨。让 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-资格签名没有 const\mathtt{const}const,直到与 T1\mathtt{T1}T1 最后一个不一致的点,则转换可能不会成功。

我通过看一些例子来学习得最好,所以这里有两个我认为有用的例子:

// q :: "指向指向指向 int 的指针"
int*** q{};
// p :: "指向指向 int 的 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 的指针"
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。这是一种延长临时对象生命周期的可爱方法:它发生在前面提到的数组到指针的转换、将引用绑定到 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

在顶部加上了这颗腐烂的樱桃之后,让我们重新审视重载解析。

迈向一个“更好”的“更好”

虽然我们对类型之间的转换有了一个概念,但重载解析涉及到在 许多 可能的类型之间进行转换——对于每个重载——并决定哪些转换“更好”。 回顾上一篇文章中给出的定义:

定义 2

在表达式 f(E1, ..., En) 的重载解析中,如果满足以下条件,则候选函数 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 之后的更好,则 S1S2 更好(回想一下,根据 隐式转换序列 的定义,一个 (可能为平凡的) 标准转换序列总是跟随用户定义的转换)。 这使用户定义的转换序列得到解决(注意到术语 “更好” 本身也变得有点过载),因此仍然需要对标准转换进行排名。

我们快到了——我能感觉到。 让我们从标准中的 [over.ics.scs] 中提取一个表格来开始:

[tab:over.ics.scs] 转换 | 级别 ---|--- 无 | 完全匹配 左值到右值 数组到指针 函数到指针 资格限定 函数指针 整型提升 | 提升 浮点提升 整型 | 转换 浮点型 浮点型到整型 指针 指向成员的指针 布尔型

正如你可能想象的那样,“完全匹配” 比 “提升” 更好,而 “提升” 比 “转换” 更好,并且转换序列的级别是其组成转换的级别中最低的。 因此,如果这是两个转换序列之间的争斗,请选择级别更好的那个。 但是,如果它们的级别 相同,那么情况会变得有点复杂。 设 S1S2 是级别相同的标准转换序列。 然后:

  1. 如果 S1S2 的真子序列,则选择 S1
  2. 如果 S1S2 是基类/派生类指针之间的转换,那么有一大堆关于哪个更好的、大致上没有趣味的规则,你可以 可能大部分 凭直觉理解。
  3. 通常,尽可能首选绑定右值引用。2
  4. 首选将函数左值绑定到左值引用,而不是右值引用。3
  5. 如果 S1S2 分别是从 T0 到相似类型 T1T2 的转换,仅在一个资格限定转换步骤上不同,并且 T1 是可资格限定转换为 T2 的,则 S1S2 更好(如果 T1 位于 T0T2 之间,则转换为 T1 “工作量更少”,因此我们首选 S1)。
  6. 如果在 S1S2 期间绑定了引用,并且被引用类型在顶层 cv 限定符之前是相同的,则首选被引用类型限定较少的序列 (即,避免在引用绑定中进行不必要的 cv 限定)。

最后一个规则解释了前面示例中的重载解析,并且它也在我的上一篇文章中出现过。 看吧。 无论如何,我们现在有了标准转换,因此有了隐式转换序列,因此有了整体上的重载解析。 都明白了吗? 没有? 很好——至少这 有点 密集,所以这里有一些例子:

void foo(const int p); // #1
void foo(int p);    // #2
foo(5); // #1 还是 #2?

显示答案 这个格式错误,因为对于非引用类型,消除歧义永远不会通过顶层 cv 限定符发生——任何调用都会是模棱两可的,所以这个 “重载” 算作 重新定义。 由于我们按值传递,因此无法有意义地区分: 编译器应该如何知道 const 或非 const 副本比另一个更好?

void run(int (*f)());        // #1
void run(int (*const f)() noexcept); // #2
int foo() noexcept;
run(foo); // #1 还是 #2?

显示答案 这将选择第二个重载,因为关联的隐式转换序列是第一个的子集。

请记住,由于唯一的 const 是第一个,因此不会发生资格限定转换。 我们可以删除 const。 请注意,void run(int (const *f)() noexcept) 将格式错误,因为函数类型不能是 cv 限定的。

// f :: "指向指向 `int()` 的指针"
void run(int (**f)());        // #1
// g :: "指向 const 指向 `int() noexcept` 的指针"
void run(int (*const *g)() noexcept); // #2
int foo() noexcept;
int (*p)() noexcept = &foo;
// &p :: "指向指向 `int() noexcept` 的指针"
run(&p); // #1 还是 #2?

显示答案 选择第二个重载:

void foo(int*& p);
int arr[3];
foo(arr); // 格式良好?

显示答案 格式错误: 数组到指针的转换会将参数表达式 arr 从类型为 “3 个 int 的数组” 的左值转换为类型为 “指向 int 的指针” 的 prvalue,该 prvalue 不能绑定到左值引用参数。

// p :: "指向指向 const 指向 int 的指针"
void foo(int* const** p);
// a :: "指向 5 个 `指向 int 的指针` 的数组的指针"
int* (*a)[5]{};
foo(a); // 格式良好?

显示答案 格式错误: 数组不在顶层,因此不能发生数组到指针的转换,因此也不能发生资格限定转换。


该死。就是能工作。 就像一台运转良好的机器。 我的意思是,显然它是能工作的——毕竟编译器使它工作——但能够 感受 它的所有工作方式,并且能够更彻底地推断它,则是另一回事。

…另一方面,像这样,这 太糟糕了,对吧? 当然,你可以通过编写健全的重载并且不要嵌套指针太深来避免这种胡说八道,但是后退一步,看着这台庞大的、曲折的 Rube Goldberg 机器的存在只是为了支持特设多态——即 名称共享,这有点可怕。 我想转换也是如此,但我认为现在人们普遍认为,隐式转换通常会使编写 不正确的 代码变得更容易。 这真的值得吗? 很难说。 但是,以一种病态的方式来审视它也很难,就像观看某种受伤的动物一样。

一个结束语: 标准非常长且密集且分散,而且,我很笨,所以这里出错的可能性不是零。 如果你比我聪明并且发现任何此类实例,请给我发送电子邮件或类似的内容。 正如他们所说,最容易学习的方式是在互联网上犯错。

  1. 还有 省略号转换序列,其排名最后,但我在这里将其排除在外。
  2. 实际规则比这更复杂,但我正在简化。
  3. 我刚得知这是一个语言特性,而且它非常愚蠢。 看:
using U = int();
void foo(U&&); // #1
void foo(U&); // #2
int bar();
foo(bar); // 选择 #2 -- 好的
foo(std::move(bar)); // 也选择 #2 -- ???

C++ 中有一条规则说,如果你有一个返回右值引用(比如 T&&)的函数 f,那么表达式 f(args) 是一个 xvalue。 今天我了解到这条规则中有一个例外——如果 T 是一个函数类型,那么它是一个左值。 所以你实际上永远无法得到一个表示函数的右值。 并且,就像,我想这说得通 ——代码不是“临时的”——但是如果你只是要将右值引用与左值引用完全相同地对待,为什么要允许右值引用函数类型???

相关文章

Jul 3, 2024 |

我没有构造函数,但我必须初始化

---|--- Jul 22, 2024 |

我,相等运算符

Mar 10, 2025 |

Inserter? 我不太了解 'er!

© 2025 EVAN GIRARDIN CC BY-NC 4.0 © 2025 EVAN GIRARDIN CC BY-NC 4.0 TOP THEME RESET RSS SUPER I KERNEL I