Mastering Placeholder Type Deduction

科技   2024-05-08 09:40   浙江  

本篇可以结合 Left-to-Right vs. Right-to-Left Coding Styles 阅读,属于同一主题。本篇侧重于讲解具体的类型推导规则。

Decltype Specifier

在静态类型语言中,一个变量需要由类型说明符指定,而随着 C++ 的发展,类型也可以从表达式推导出来,不必显式写出。

一切始于 C++11 decltype(E),Decltype 也属于说明符,接受一个表达式参数。也可以传入一个变量,因为变量名属于 id-expressions,也是表达式。

这里的核心在于,表达式其实包含三部分信息:type, value, 和 value category。使用 Decltype 推导出的表达式结果与原类型的信息并不总是相同,这种不一样的依据就是类型推导规则。

Decltype 的推导规则需要分为两种情况讨论,E(E),也就是说,多加一个括号将改变推导规则。

先来看第一种,E 的情况。

如果 E 是 id-expressions 或者类成员名称访问,此时推导结果的 type 和 value 都和 E 所对应的实体相同,但是不会保留原有的 value category。

1int a = 42;
2static_assert(std::is_same_v<decltype(a), int>);
3std::println("lvalue: {}"std::is_same_v<decltype(a), int&>);  // false
4std::println("prvalue: {}"std::is_same_v<decltype(a), int>);  // true
5std::println("xvalue: {}"std::is_same_v<decltype(a), int&&>); // false

a 是一个 lvalue,而 decltype(a) 是一个 prvalue。

再来看第二种,(E) 的情况。

如果 (E) 是 id-expressions 或者类成员名称访问,推导时 value 不变。对于 type,若 E 是 lvalue,推导的 type 为 T&;若 E 是 xvalue,type 为 T&&。同时也会保留 value category。

1int a = 42;
2static_assert(std::is_same_v<decltype((a)), int&>);
3std::println("lvalue: {}"std::is_same_v<decltype((a)), int&>);  // true
4std::println("prvalue: {}"std::is_same_v<decltype((a)), int>);  // false
5std::println("xvalue: {}"std::is_same_v<decltype((a)), int&&>); // false

a 是 lvalue,推导类型为 T&,依旧是一个 lvalue。

核心就记住这两条规则即可,需要注意 (E) 推导的不只是实体的类型,还附加有实体所在的环境,就是规则中的 T& 所指,比如:

1struct A { double x; };
2const A* a;
3
4decltype(a->x) y;       // double
5decltype((a->x)) z = y; // const double&

再比如:

 1void f() {
2    float x, &r = x;
3    [=] {
4        decltype(x) y1; // float
5        decltype((x)) y2 = y1; // const float&
6
7        decltype(r) r1 = y1; // float&
8        decltype((r)) r2 = y2; // const float&
9    }
10}

由于 Lambda expressions 默认是不可修改的,因此使用 (x) 推导时会带上 const

到此为此,本文第一部分结束,接着让我们更进一步,看 Placeholder Type 的推导。

Placeholder Type

C++ 存在两种类型的 Placeholder Type 说明符,autodecltype(auto)。使用这种类型的说明符,类型名称不必再显式指定,也不必使用 decltype() 根据表达式推导,一切推导都自动完成。它们也构成了 Modern C++ 的 Left-to-Right 声明风格。

刚开始,auto 仅是作为 Right-to-Left 风格的代替语法,以下两种声明意义完全相同。

1int f() {}
2auto f() -> int {}

这里只是换了一种语法形式,并不存在类型推导,返回类型由 trailing-return-type 显式指出。

若不显式从 trailing-return-type 指定,此时将推导类型。例如:

1auto f() {}
2decltype(auto) g() {}

它们两个的首要不同来源于语法,auto 可以和其他修饰符组合出现,如 const auto&,而 decltype(auto) 必须单独出现,不能添加任何修饰符。

推导规则是另外一个不同点,auto 使用的是 TAD 规则,而 decltype(auto) 使用的是本文第一部分介绍的 decltype(E) 推导规则。

下面看具体的几条规则。

第一条规则,auto 推导时总是以 value 返回,不会返回引用,而 decltype(auto) 的规则支持动态返回。

 1auto f(int& a) {
2    return a;
3}
4
5decltype(auto) g(int& a) {
6    return a;
7}
8
9int x = 42;
10static_assert(std::is_same_v<decltype(f(x)), int>);
11static_assert(std::is_same_v<decltype(g(x)), int&>);

示例中 f() 永远返回 int,而 g() 可以返回 int&。但是 auto 可以和修饰符组合使用,因此你也可以这样来返回引用:

1autof(int& a) {
2    return a;
3}
4
5int x = 42;
6static_assert(std::is_same_v<decltype(f(x)), int&>);

再看回 decltype(auto),推导起来其实相当于 decltype(a),类型就是实体 a 的类型。

TAD 的内容在 洞察函数重载决议 中已经详细讨论过,在此不再细述。需要注意,auto 使用 TAD 的规则推导,所以推导出来的类型也并不一定与原实体类型一致。例子:

1const int b = 0;
2auto c = b; // c is an int
3static_assert(std::is_same_v<decltype(c), int>);

这与 decltype(auto) 的行为完全不一致:

1const int b = 0;
2decltype(auto) c = b; // c is an int const
3static_assert(std::is_same_v<decltype(c), int const>);

只要谨记这条规则,就知道何时该使用哪种 Placeholder Type 了。

第二条规则,重定义函数,或是特化函数模板时,如果本身就使用的是 Placeholder Type,那么也应该使用相同的形式。

 1auto f();               // OK
2auto f() return 42; } // OK
3auto f();               // OK
4int f();                // error
5decltype(auto) f();     // error
6
7decltype(auto) g();               // OK
8decltype(auto) g() { return 42; } // OK
9decltype(auto) g();               // OK
10int g();                          // error
11auto g();                         // error

下面是一个函数模板的例子:

1template <class Tauto f(T t) { return t; } // #1
2template char f(char);                       // error, no matching template
3template auto f(int);                        // OK, return type is int
4template<> auto f(double);                   // OK, forward declration with unknown return type
5
6template <class TT f(T t) { return t; }    // OK, not functionally equivalent to #1
7template auto f(float);                      // OK, still matches #1
8template char f(char);                       // OK, now there is a matching template

不借助 auto 返回的模板声明,参数与返回类型一致,所以模板 explicit instantiation 才能够匹配。

第三个规则,函数的返回类型为 braced-init-list,即以 {} 括起来的元素时,程序非法。

1auto func(int t) {
2    return {t}; // ill-formed
3}

decltype(auto) 也是同样的结果。然而,非函数返回值的地方,auto 能够推导出 std::initializer_listdecltype(auto) 却不可以。

1auto x1 = { 12 };           // ok, x1 is std::initializer_list<int>
2decltype(auto) x2 = { 12 }; // error, { 1, 2 } is not an expression

brace-enclosed list 只是一种初始化方式,不是表达式,所以不满足 decltype(E) 的规则。相反,auto 使用 TAD 的推导规则,可以将这种初始化方式推导为 std::initializer_list

但是,此时 list initialization 必须是 copy list initialization,如果是 direct list initialization,不会推导为 std::initializer_list

1auto x1 = { 3 }; // std::initializer_list<int>
2auto x2{ 3 };    // int

这里只介绍这几条重要的规则,其他规则很难出乎意料,不必细说。当你将以上规则熟稔于心,对大多数类型推导场景的理解都将不在话下。

Trailing return type vs. decltype(auto)

最后的最后,再补充一个重要的不同。

decltype(auto) 并不是 SFINAE-Friendly 的,而 Trailing return type 在某些情况下是的(上篇中是另一种情况)。

看个例子:

 1template<typename T>
2auto f(T& t, int i) -> decltype(t[i]) {
3    return t[i];
4}
5
6template<typename T>
7decltype(auto) g(T& t, int i) {
8    return t[i];
9}
10
11template <typename T>
12concept CanAccessF = requires(T i) {
13    f(i, i);
14};
15
16template <typename T>
17concept CanAccessG = requires(T i) {
18    g(i, i);
19};
20
21int main() {
22    bool a = CanAccessF<int>; // OK
23    bool b = CanAccessG<int>; // Error
24}

因此,它们并不总是能够相互替代的,在这种情况下,Trailing return type 这种方式更加可取。

通过两篇文章,算是把该部分知识点汇总了一下,特性虽小,但却总是容易混淆。有这两篇文章打底,对于 C++ 类型推导可以说是熟悉掌握了。

留个小问题,return xreturn (x) 有哪些区别?


推荐阅读  点击标题可跳转

1、Left-to-Right vs. Right-to-Left Coding Styles

2、C语言进阶之 回调函数详解

3、全面讲解 C 语言的结构体(struct),一网打尽

CPP开发者
我们在 Github 维护着 9000+ star 的C语言/C++开发资源。日常分享 C语言 和 C++ 开发相关技术文章,每篇文章都经过精心筛选,一篇文章讲透一个知识点,让读者读有所获~
 最新文章