本篇可以结合 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 说明符,autodecltype(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++ 类型推导可以说是熟悉掌握了。

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.