| 目录 |

16 类型 IV:限定符与说明符

现在我们已经掌握了不少类型,事实证明我们还可以给这些类型加上一些额外的属性来控制它们的行为。这就是类型限定符和存储类说明符。

16.1 类型限定符

它们能让你声明常量值,也能给编译器提供一些可供利用的优化提示。

16.1.1 const

这是你最常见到的类型限定符。它的意思是该变量是常量,任何修改它的尝试都会招来一个非常生气的编译器。

const int x = 2;

x = 4;  // 编译器发出呕吐声,不能给常量赋值

你无法修改 const 值。

你经常会在函数的形参列表里看到 const:

void foo(const int x)
{
    printf("%d\n", x + 30);  // 没问题,不会修改 "x"
}

16.1.1.1 const 与指针

这个就有点古怪了,因为涉及指针时,它有两种用法,含义也各不相同。

其一,我们可以让指针所指的东西不能被修改。做法是在类型声明中把 const 放在类型名前面(星号之前)。

int x[] = {10, 20};
const int *p = x; 

p++;  // 我们可以修改 p,没问题

*p = 30; // 编译器报错!不能改变它所指的内容

有点让人困惑的是,下面这两种写法是等价的:

const int *p;  // 不能修改 p 所指的内容
int const *p;  // 不能修改 p 所指的内容,和上一行一样

很好,现在我们不能改指针所指的东西,但可以改指针本身。那反过来呢?如果我们想能改指针所指的东西,但不能改指针本身呢?

只要在声明中把 const 移到星号后面即可:

int *const p;   // 我们不能用指针运算修改 "p"

p++;  // 编译器报错!

但我们可以修改它们所指的内容:

int x = 10;
int *const p = &x;

*p = 20;   // 把 "x" 设为 20,没问题

你也可以让两者都是 const:

const int *const p;  // 不能修改 p 或 *p!

最后,如果你有多级间接,就应该给相应的层级加上 const。一个指针是 const,并不意味着它所指向的指针也必须如此。你可以像下面这些例子那样显式地设定:

char **p;
p++;     // 没问题!
(*p)++;  // 没问题!

char **const p;
p++;     // 报错!
(*p)++;  // 没问题!

char *const *p;
p++;     // 没问题!
(*p)++;  // 报错!

char *const *const p;
p++;     // 报错!
(*p)++;  // 报错!

16.1.1.2 const 正确性

还有一点必须提一下:编译器会对下面这样的代码发出警告:

const int x = 20;
int *p = &x;

大意是说:

initialization discards 'const' qualifier from pointer type target

这是怎么回事呢?

嗯,我们得看看赋值号两侧的类型:

    const int x = 20;
    int *p = &x;
//    ^       ^
//    |       |
//  int*    const int*  ← 两侧的类型

编译器在警告我们:赋值右侧的值是 const,而左侧的不是。同时它也在告诉我们,它正在丢弃右侧表达式的“const 属性”。

也就是说,我们仍然可以试着做下面这件事,但它就是错的。编译器会警告,而且这是未定义行为:

const int x = 20;
int *p = &x;

*p = 40;  // 未定义行为——它也许会修改 "x",也许不会!

printf("%d\n", x);  // 运气好的话是 40

16.1.2 restrict

长话短说:你完全不必用它,每次看到它都可以忽略。如果你用对了,多半能得到一些性能提升;如果用错了,你得到的就是未定义行为。

restrict 是给编译器的一条提示:某块特定的内存只会由一个指针访问,绝不会由另一个指针访问。(也就是说,restrict 指针所指向的那个对象不会被别名化。)如果开发者把一个指针声明为 restrict,随后又用别的方式(例如通过另一个指针)访问它指向的对象,那么该行为就是未定义行为。

基本上你是在告诉 C:“嘿——我保证这一个指针是我访问这块内存的唯一途径,如果我撒谎,你大可以拿未定义行为来收拾我。”

C 会利用这一信息来做某些优化。例如,如果你在循环里反复解引用 restrict 指针,C 可能决定把结果缓存到一个寄存器里,只在循环结束后才把最终结果写回。如果有别的指针指向同一块内存并在循环中访问它,结果就不准确了。

(注意,如果所指的对象从未被写入,restrict 就不起作用。它针对的全是关于写入内存的优化。)

我们来写一个交换两个变量的函数,用 restrict 关键字向 C 保证我们绝不会传入指向同一对象的指针。然后我们再把这事搞砸,试着传入指向同一对象的指针。

void swap(int *restrict a, int *restrict b)
{
    int t;

    t = *a;
    *a = *b;
    *b = t;
}

int main(void)
{
    int x = 10, y = 20;

    swap(&x, &y);  // 没问题!上面的 "a" 和 "b" 指向不同的对象

    swap(&x, &x);  // 未定义行为!"a" 和 "b" 指向同一个对象
}

如果把上面的 restrict 关键字去掉,两次调用就都能安全工作了。但那样一来,编译器可能就无法优化。

restrict 具有块作用域,也就是说,这种限制只在它被使用的那个作用域内有效。如果它出现在函数的形参列表中,那么它就处于该函数的块作用域内。

如果受限指针指向一个数组,那么这一限制只作用于数组中的各个对象。其他指针可以读写该数组,只要它们读写的不是受限指针所访问的那些元素。

如果它位于任何函数之外、处于文件作用域,那么这一限制覆盖整个程序。

你很可能会在像 printf() 这样的库函数里看到它:

int printf(const char * restrict format, ...);

再说一遍,这只是告诉编译器:在 printf() 函数内部,只有一个指针会指向那个 format 字符串的任何部分。

最后一点:如果你出于某种原因在函数形参里使用数组写法而不是指针写法,也可以这样使用 restrict:

void foo(int p[restrict])     // 不带大小

void foo(int p[restrict 10])  // 或者带大小

不过指针写法会更常见。

16.1.3 volatile

除非你直接跟硬件打交道,否则不太可能见到或需要它。

volatile 告诉编译器:某个值可能会在它背后发生变化,因此每次都必须重新读取。

一个例子是:编译器正在查看内存中某个在背后不断更新的地址,比如某种硬件定时器。

如果编译器决定对此做优化,把该值长时间保存在寄存器里,那么内存中的值会更新,却不会反映到寄存器里。

把某个东西声明为 volatile,你就是在告诉编译器:“嘿,这东西指向的内容可能会因为本程序代码之外的原因随时改变。”

volatile int *p;

16.1.4 _Atomic

这是 C 的一个可选特性,我们会在原子操作那一章里讨论。

16.2 存储类说明符

存储类说明符与类型限定符类似。它们向编译器提供关于变量类型的更多信息。

16.2.1 auto

你几乎见不到这个关键字,因为 auto 是块作用域变量的默认值。它是隐含的。

下面两者是一样的:

{
    int a;         // auto 是默认的……
    auto int a;    // 所以这是多余的
}

auto 关键字表示该对象具有自动存储期。也就是说,它存在于定义它的那个作用域中,并在退出该作用域时被自动释放。

关于自动变量有一个陷阱:在你显式初始化之前,它们的值是不确定的。我们说它们装满了“随机”或“垃圾”数据,虽然这两个词都不太让我满意。无论如何,除非你初始化它,否则你不会知道里面是什么。

使用之前一定要初始化所有自动变量!

16.2.2 static

这个关键字有两种含义,取决于变量是文件作用域还是块作用域。

先从块作用域说起。

16.2.2.1 块作用域中的 static

这种情况下,我们基本上是在说:“我只想让这个变量存在一个实例,在各次调用之间共享。”

也就是说,它的值会在各次调用之间保持。

块作用域中带初始化器的 static 只会在程序启动时初始化一次,而不是每次调用函数时都初始化。

我们来看一个例子:

#include <stdio.h>

void counter(void)
{
    static int count = 1;  // 这里只初始化一次

    printf("This has been called %d time(s)\n", count);

    count++;
}

int main(void)
{
    counter();  // 输出:"This has been called 1 time(s)"
    counter();  // 输出:"This has been called 2 time(s)"
    counter();  // 输出:"This has been called 3 time(s)"
    counter();  // 输出:"This has been called 4 time(s)"
}

看到 count 的值在各次调用之间保持了吗?

值得注意的是,static 块作用域变量默认初始化为 0。

static int foo;      // 默认起始值是 `0`……
static int foo = 0;  // 所以 `0` 的赋值是多余的

最后提醒一句:如果你在写多线程程序,必须确保不要让多个线程践踏同一个变量。

16.2.2.2 文件作用域中的 static

当你来到文件作用域、位于任何块之外时,含义就变了不少。

文件作用域的变量本来就在函数调用之间保持,所以这一行为已经具备了。

在这里,static 的意思是:这个变量在这个特定的源文件之外不可见。有点像“全局”,但只在这个文件里。

更多内容见关于用多个源文件构建的那一节。

16.2.3 extern

extern 存储类说明符让我们可以引用其他源文件中的对象。

举个例子,假设文件 bar.c 的全部内容如下:

// bar.c

int a = 37;

就这些。在文件作用域里声明一个新的 int a。

但如果我们有另一个源文件 foo.c,想引用 bar.c 里的那个 a 呢?

用 extern 关键字很容易:

// foo.c

extern int a;

int main(void)
{
    printf("%d\n", a);  // 37,来自 bar.c!

    a = 99;

    printf("%d\n", a);  // 同一个来自 bar.c 的 "a",但它现在是 99
}

我们也可以把 extern int a 放在块作用域里,它同样会引用 bar.c 里的 a:

// foo.c

int main(void)
{
    extern int a;

    printf("%d\n", a);  // 37,来自 bar.c!

    a = 99;

    printf("%d\n", a);  // 同一个来自 bar.c 的 "a",但它现在是 99
}

不过,如果 bar.c 里的 a 被标记为 static,这招就不管用了。文件作用域的 static 变量在该文件之外不可见。

关于函数上的 extern 最后说一句。对函数而言,extern 是默认的,所以它是多余的。如果你只想让某个函数在单个源文件中可见,可以把它声明为 static。

16.2.4 register

这个关键字用来向编译器暗示:该变量被频繁使用,应尽可能让它访问得最快。编译器没有义务答应这一点。

如今,现代 C 编译器的优化器自己就很擅长判断这件事,所以这个关键字现在很少见了。支持使用它的理由有几点,我在下面列出了。不过说实话,我写过的所有 C 代码里都没用过它。

但如果你非用不可:

#include <stdio.h>

int main(void)
{
    register int a;   // 让 "a" 尽可能访问得快。

    for (a = 0; a < 10; a++)
        printf("%d\n", a);
}

不过它是有代价的。你不能取寄存器的地址:

register int a;
int *p = &a;    // 编译器报错!不能取寄存器的地址

数组的任何部分也是如此,因为把 register 数组当作指针处理是未定义行为。你倒是可以求它的 sizeof()。但这算不上什么好处。也就是说,register 数组基本上是没用的。

GCC 和 Clang 对下面这些写法都会报错(不过奇怪的是,GCC 对 a[2] 不报错,只有你指定 -pedantic 时才给警告):

register int a[] = {11, 22, 33, 44, 55};
int *p = a;       // 未定义行为!
int b = *(a + 2); // 未定义行为!
int c = a[2];     // 未定义行为!

无法取寄存器变量的地址这一事实,让编译器可以在这个假设之上做优化(如果它还没想到这些优化的话)。另外,给 const 变量加上 register,还能防止有人不小心把它的指针传给另一个会故意忽略其 const 性的函数116。

这里稍微讲点历史背景:CPU 深处有一些专门的小“变量”,叫作寄存器117。与 RAM 相比,它们的访问速度极快,所以用它们就能获得速度提升。但它们不在 RAM 里,因此没有关联的内存地址(这就是为什么你不能取它们的地址,也不能取得指向它们的指针)。

但是,就像我说的,现代编译器非常擅长生成最优代码,只要可能就会使用寄存器,不管你有没有指定 register 关键字。不仅如此,规范还允许编译器愿意的话就把它当作你写的是 auto 来处理。所以没有任何保证。

16.2.5 _Thread_local

当你在使用多个线程,并且有些变量位于全局作用域或 static 块作用域时,这个关键字能确保每个线程都得到该变量自己的一份副本。这有助于你避免竞争条件和线程之间互相踩脚。

如果你处在块作用域中,就必须把它和 extern 或 static 一起使用。

另外,如果你包含了 <threads.h>,就可以用顺眼得多的 thread_local 作为更丑的 _Thread_local 的别名。

更多信息可以在线程那一节中找到。


| 目录 |