引言

C++ 的类型可读性很差,并且大多数入门材料中并没有详细介绍如何阅读它们,最多只是讲到 top-level const 和 low-level const 的区别。有不少朋友问过我这方面的问题,讲得多了,干脆整理起来写篇文章。

常见误解

在详细讲类型的阅读之前,需要纠正一个常见的误解。

问:int a[5]里的a是什么类型的?

答:int[5]类型,在适当的时候会「退化」(decay)成为int*类型。

问:int a[5][6]里的a是什么类型的?

答:int[5][6]类型,在适当的时候会「退化」成为int(*)[6]类型,即指向int[6]的指针。

由于「退化」这种隐式转换的存在,很多初学 C++ 的人会把数组类型等同于指针类型。

类似的,函数类型也会「退化」到指针类型,如int(int, int)会「退化」成为int(*)(int, int)

解方程

当你查阅如何阅读一些复杂的类型时,你可能会看到网络上一些人说 C++ 的类型就是解方程,让我来详细解释一下这句话。

抛去 CVR (const, volatile, reference) 等不谈,C++ 最基本的声明分为两个部分:写在最左边的类型名是「类型说明符」(type specifier),剩下的部分是「声明符」(declarator)。这里沿用了 C 语言中的说法,因为这部分内容是从 C 继承过来的,它们是 C++ 类型里最恶心的地方。但这两个名字实在没啥识别度,我喜欢不严谨地称呼为返回值类型和调用表达式,这点后面会解释。先来看几个例子吧:

声明 类型说明符 声明符
int a int a
int* a int *a
int a[5] int a[5]
int* a[5] int *a[5]
int (*a)[5] int (*a)[5]

作出这种分别后,就可以理解上表中a的类型是怎样决定的了:以「声明符」的形式调用a后,得到的返回值类型为「类型说明符」。所谓的解方程就是这样一个过程:

  1. int *a[5]得到*a[5]的类型是int
  2. 即对a[5]解引用得到int,推出a[5]的类型是int*
  3. 即对a取下标得到int*,推出a的类型是 5 个int*的数组

再比如:

  1. int (*a)[5]得到(*a)[5]的类型是int
  2. 即对(*a)取下标得到int,推出(*a)的类型是int[5]
  3. 即对a解引用得到int[5],推出a的类型是指向int[5]的指针

可以看到这里运算符优先级和调用时完全一致,形式上也基本一致。

再来个函数指针的例子:

  1. int (*a)(int, int)得到(*a)(int, int)的类型是int
  2. 即对(*a)(int, int)的形式调用得到int,推出(*a)的类型是有两个int参数并且返回int的函数,即int(int, int)
  3. 即对a解引用得到int(int, int),推出a的类型是指向int(int, int)的指针

更复杂一点:

  1. int (*(Foo::*a[5])(int, int))(int)得到(*(Foo::*a[5])(int, int))(int)的类型是int
  2. 推出(*(Foo::*a[5])(int, int))的类型是int(int)
  3. 推出(Foo::*a[5])(int, int)的类型是指向int(int)的指针,即int(*)(int)
  4. 推出(Foo::*a[5])的类型是有两个int参数并且返回int(*)(int)的函数
  5. 推出a[5]的类型是指向这种函数的成员指针
  6. 推出a的类型是 5 个这种成员指针的数组

简而言之,你可以把「声明符」部分看成一个调用表达式,这个调用表达式的返回值类型为「类型说明符」,这样就可以一步步倒推出类型来。

当然了,这么复杂的类型是不推荐这么写的,这里只是为了演示一下解方程的过程。上面那个a正常一点的声明大概会长这样:

1
2
3
4
5
struct Foo {
    auto bar(int, int) -> int(*)(int);
};

decltype(&Foo::bar) a[5];

即使在 C++11 以前,也可以利用typedef拆解成多层,提升可读性:

1
2
3
4
5
6
7
struct Foo {
    typedef int (*bar_t)(int);
    bar_t bar(int, int);
};

typedef Foo::bar_t (Foo::*foo_bar_t)(int, int);
foo_bar_t a[5];

这种包装在 C 语言中还是挺常见的。

不过如果你要看的类型是 demangle 出来的(或者其他查看类型名的方式),那就是长成那种魔鬼样子了。

多变量声明

有了前面的知识,就能够理解 C++ 多变量声明的规则了。多变量声明中,逗号分割的是「声明符」(即调用表达式部分),它们共享「类型说明符」(即返回值部分),比如:

1
int *a, b;

其中a的类型为int*b的类型则为int,因为*是「声明符」的一部分。如果你希望定义两个指针,那么应该写成int *a, *b

更复杂的例子也遵循同样的规则。因为实在想不到更复杂的情况这样写有什么意义,就不再举例子了。

constvolatile

constvolatile在类型中的位置是一样的,并且他们两个可以一起使用,因此这里仅仅用const作为例子。下面出现的所有const都可以合法地替换成volatileconst volatilevolatile const,后两者语义上等价。

const在一般情况下都很明确,如const int aint (*a)(const std::string&)const的理解障碍主要出在指针的情况中。对于指针来说,const需要能表达两种含义:

  1. 指针本身是const的,即这个指针没法再指向别的值,如char *const a
  2. 指针所指向的对象是const的,即你没法通过这个指针修改它指向的对象,如const char* a

当指针嵌套时,我们还要考虑指针指向的指针的const属性,于是类型就变得复杂起来。有了前面的知识,我们可以把const分为修饰「类型说明符」的const和修饰「声明符」的const。看下面这个类型:

1
const int * const * * const a;

从左往右的第一个const为修饰「类型说明符」的const,其余的为修饰「声明符」的const

修饰「类型说明符」的const只要写在「类型说明符」旁边,不论const T还是T const都是合法且等价的。也就是上面这个例子还可以写成:

1
int const * const * * const a;

它们都说明:

  1. * const * * const a(当然去掉const后它才是一个合法的表达式,下同)的类型为const int(不能修改***a

修饰「声明符」的const只要看解方程时解到哪个表达式,就说明修饰的是哪个表达式的类型。接着上面这个例子:

  1. 推出* * const a是一个指向const int的 const 指针(不能修改**a
  2. 推出* const a是一个指向前面那个指针的非 const 指针(可以修改*a
  3. 推出a是一个指向前面那个指针的 const 指针(不能修改a

著名的 C++ 入门书籍《C++ Primer》把const分为了「顶层 const 」(top-level const)和「底层 const 」(low-level const)(此为原版翻译,显然并不准确)。顶层指的是修饰指针自身的,也就是类型中最内层的;底层指的是其余的。这两个术语在只讨论一层指针的时候才比较好用,当然这也是绝大多数情况。

&&&

C++ 的引用是一种非常特殊的类型。由于 C++ 规定引用不一定占用内存,所以你既没法写出引用类型的数组(int &a[5]),也没法写出指向引用类型的指针(int&* a),除此之外还有很多地方无法使用引用。我认为这是一个非常失败的设计,凭空增加了很多障碍。它明明只是一个「不可空指针」(non-nullable pointer)而已。右值引用也并没有什么特别,只是带有所有权转移语义的引用而已。

总之,除了函数类型(以及嵌套了函数类型的类型)里&&&会出现在参数类型和返回值类型中,其他情况下&&&只会出现在类型的最顶层。这里的顶层的含义与前文的「顶层 const 」相同。

&&&没法直接嵌套使用,比如int& && a是非法的。但是间接嵌套是合法的,比如:

1
2
3
using ref1 = int&&;
using ref2 = ref1&&;
using ref3 = ref2&;

在这种情况下会发生「引用折叠」(reference collapsing)。规则也很简单,如果全是&&,那么会折叠成一个&&。只要有一个&,就会折叠成&。比如上面的ref2实际上是int&&,而ref3则是int&

&&还有一种用法是在模板中。如果T是一个模板参数,那么T&&(不能带有constvolatile)就是一个「万能引用」(universal reference)。具体规则为:

  1. 如果传入T&&的类型为Foo&,那么T就被推导为Foo&,而T&&经过折叠变成Foo&
  2. 如果传入T&&的类型为Foo&&,那么T就被推导为Foo,而T&&则是Foo&&

请注意,T被推导为什么类型这一点很重要,因为你可能在模板内部再次使用T

成员函数

在 C++ 的成员函数中,我们有时会见到这样的例子:

1
2
3
4
struct Foo {
    int a(int, int) const;
    int b(int, int) &&;
};

这个其实也很好理解。C++ 的非 static 成员函数都可以调用this,一个指向实例的指针。写在后面的const&&就是用来修饰实例的。

在其他一些语言(比如 Rust 和 Python )中传入的实例是显式地作为第一个参数写出的,而 C++ 是隐式的,于是只能在其他地方加上类型修饰。不过即使不是隐式的,你也没法在 C++ 中直接写出this的类型,但这仍然是 C++ 的锅——因为this是一个指针,而 C++ 中指针没法指向引用类型。下面两个例子用注释不严谨地揭示了后置的类型修饰是怎么运作的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct Foo {
    // void a(const Foo& this_, int) {
    //     const Foo* this = &this_;
    // }
    void a(int) const&;

    // void a(Foo&& this_, int) {
    //     Foo* this = &this_;
    // }
    void a(int) &&;

    // void a(Foo& this_, int) {
    //     Foo* this = &this_;
    // }
    void a(int) &;

    // foo.a(1) -> Foo::a(foo, 1)
};

struct Bar {
    // void a(const Bar& this_, int) {
    //     const Bar* this = &this_;
    // }
    //
    // or just:
    //
    // void a(const Bar* this, int);
    void a(int) const;

    // void a(Bar& this_, int) {
    //     Bar* this = &this_;
    // }
    //
    // or just:
    //
    // void a(Bar* this, int);
    void a(int);

    // bar.a(1) -> Bar::a(bar, 1)
    //
    // or:
    //
    // bar.a(1) -> Bar::a(&bar, 1)
};

带有引用的成员函数无法和不带有引用的成员函数重载在一起,也就是你要么选择Foo演示的这一组,要么选择Bar演示的这一组。一些其他可能的重载,如带有volatile的,就不再写出了。

从这个例子你也可以再一次看到 C++ 的引用设计得多么失败。

typedefusing

前文已经有typedefusing的例子了。typedef的语法其实很简单,和定义变量是完全一致的。而using语法的区别仅仅在于把类型名的部分提到前面。下面这个例子中,foo_tbar_t都和func_ptr的类型一致。

1
2
3
int (*func_ptr)(int);
typedef int (*foo_t)(int);
using bar_t = int(*)(int);

using相比typedef的一个优势是可以直接定义「别名模板」(alias template),如:

1
2
3
4
template<typename T>
using foo_t = std::vector<T>;

foo_t<int> foo;

在 C++11 以前,这种需求可以通过套一层structclass实现,如:

1
2
3
4
5
6
template<typename T>
struct Bar {
    typedef std::vector<T> type;
};

Bar<int>::type bar;

本文仅讨论类型,所以using的其他语义就略过了。

更多

C++ 的声明语句构成部分相当复杂,并非只有「类型说明符」和「声明符」,但其余部分相比上文提到的那些已经相当人类友好了,没有多少学习障碍,而且其中的很多与类型无关。有兴趣了解的可以参阅 cppreference

类型本可以明确易读,只是 C 和 C++ 设计得很失败。既然读到这里了,那么恭喜你解决了一个其他语言不存在的问题。