Mastering Placeholder Type Deduction
本篇可以结合 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。
int a = 42;
static_assert(std::is_same_v<decltype(a), int>);
std::println("lvalue: {}", std::is_same_v<decltype(a), int&>); // false
std::println("prvalue: {}", std::is_same_v<decltype(a), int>); // true
std::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。
int a = 42;
static_assert(std::is_same_v<decltype((a)), int&>);
std::println("lvalue: {}", std::is_same_v<decltype((a)), int&>); // true
std::println("prvalue: {}", std::is_same_v<decltype((a)), int>); // false
std::println("xvalue: {}", std::is_same_v<decltype((a)), int&&>); // false
a
是 lvalue,推导类型为 T&
,依旧是一个 lvalue。
核心就记住这两条规则即可,需要注意 (E)
推导的不只是实体的类型,还附加有实体所在的环境,就是规则中的 T&
所指,比如:
struct A { double x; };
const A* a;
decltype(a->x) y; // double
decltype((a->x)) z = y; // const double&
再比如:
void f() {
float x, &r = x;
[=] {
decltype(x) y1; // float
decltype((x)) y2 = y1; // const float&
decltype(r) r1 = y1; // float&
decltype((r)) r2 = y2; // const float&
}
}
由于 Lambda expressions 默认是不可修改的,因此使用 (x)
推导时会带上 const
。
到此为此,本文第一部分结束,接着让我们更进一步,看 Placeholder Type 的推导。
Placeholder Type
C++ 存在两种类型的 Placeholder Type 说明符,auto
和 decltype(auto)
。使用这种类型的说明符,类型名称不必再显式指定,也不必使用 decltype()
根据表达式推导,一切推导都自动完成。它们也构成了 Modern C++ 的 Left-to-Right 声明风格。
刚开始,auto
仅是作为 Right-to-Left 风格的代替语法,以下两种声明形式完全相同。
int f() {}
auto f() -> int {}
这里只是换了一种语法形式,并不存在类型推导,返回类型由 trailing-return-type 显式指出。
若不显式从 trailing-return-type 指定,此时将推导类型。例如:
auto f() {}
decltype(auto) g() {}
它们两个的首要不同来源于语法,auto
可以和其他修饰符组合出现,如 const auto&
,而 decltype(auto)
必须单独出现,不能添加任何修饰符。
推导规则是另外一个不同点,auto
使用的是 TAD 规则,而 decltype(auto)
使用的是本文第一部分介绍的 decltype(E)
推导规则。
第一条规则是,auto
推导时总是以 value 返回,不会返回引用,而 decltype(auto)
的规则支持动态返回。
auto f(int& a) {
return a;
}
decltype(auto) g(int& a) {
return a;
}
int x = 42;
static_assert(std::is_same_v<decltype(f(x)), int>);
static_assert(std::is_same_v<decltype(g(x)), int&>);
示例中 f()
永远返回 int
,而 g()
可以返回 int&
。但是 auto
可以和修饰符组合使用,因此你也可以这样来返回引用:
auto& f(int& a) {
return a;
}
int x = 42;
static_assert(std::is_same_v<decltype(f(x)), int&>);
再看回 decltype(auto)
,推导起来其实相当于 decltype(a)
,类型就是实体 a
的类型。
TAD 的内容在 洞察函数重载决议 中已经详细讨论过,在此不再细述。需要注意,auto
使用 TAD 的规则推导,所以推导出来的类型也并不一定与原实体类型一致。例子:
const int b = 0;
auto c = b; // c is an int
static_assert(std::is_same_v<decltype(c), int>);
这与 decltype(auto)
的行为完全不一致:
const int b = 0;
decltype(auto) c = b; // c is an int const
static_assert(std::is_same_v<decltype(c), int const>);
只要谨记这条规则,就知道何时该使用哪种 Placeholder Type 了。
第二条规则,重定义函数,或是特化函数模板时,如果本身就使用的是 Placeholder Type,那么也应该使用相同的形式。
auto f(); // OK
auto f() { return 42; } // OK
auto f(); // OK
int f(); // error
decltype(auto) f(); // error
decltype(auto) g(); // OK
decltype(auto) g() { return 42; } // OK
decltype(auto) g(); // OK
int g(); // error
auto g(); // error
下面是一个函数模板的例子:
template <class T> auto f(T t) { return t; } // #1
template char f(char); // error, no matching template
template auto f(int); // OK, return type is int
template<> auto f(double); // OK, forward declration with unknown return type
template <class T> T f(T t) { return t; } // OK, not functionally equivalent to #1
template auto f(float); // OK, still matches #1
template char f(char); // OK, now there is a matching template
不借助 auto
返回的模板声明,参数与返回类型一致,所以模板 explicit instantiation 才能够匹配。
第三个规则,函数的返回类型为 braced-init-list,即以 {}
括起来的元素时,程序非法。
auto func(int t) {
return {t}; // ill-formed
}
decltype(auto)
也是同样的结果。然而,非函数返回值的地方,auto
能够推导出 std::initializer_list
。
auto x1 = { 1, 2 }; // ok, x1 is std::initializer_list<int>
decltype(auto) x2 = { 1, 2 }; // 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
。
auto x1 = { 3 }; // std::initializer_list<int>
auto x2{ 3 }; // int
这里只介绍这几条重要的规则,其他规则很难出乎意料,不必细说。当你将以上规则熟稔于心,对大多数类型推导场景的理解都将不在话下。
Trailing return type vs. decltype(auto)
最后的最后,再补充一个重要的不同。
decltype(auto)
并不是 SFINAE-Friendly 的,而 Trailing return type 在某些情况下是的(上篇中是另一种情况)。
看个例子:
template<typename T>
auto f(T& t, int i) -> decltype(t[i]) {
return t[i];
}
template<typename T>
decltype(auto) g(T& t, int i) {
return t[i];
}
template <typename T>
concept CanAccessF = requires(T i) {
f(i, i);
};
template <typename T>
concept CanAccessG = requires(T i) {
g(i, i);
};
int main() {
bool a = CanAccessF<int>; // OK
bool b = CanAccessG<int>; // Error
}
因此,它们并不总是能够相互替代的,在这种情况下,Trailing return type 这种方式更加可取。
通过两篇文章,算是把该部分知识点汇总了一下,特性虽小,但却总是容易混淆。有这两篇文章打底,对于 C++ 类型推导可以说是熟悉掌握了。