声明(Declarations)
章节大纲
-
常量表达式(Constant Expressions)
静态初始化表达式允许在程序启动时而不是编译时进行求值。
翻译环境必须使用不少于执行环境的精度;如果使用了更高的精度,那么在编译期初始化的静态值,在目标机器启动时初始化可能会有不同的值。
C89 引入了
float
、long double
和无符号整型常量(unsigned
常量)。C99 又引入了有符号/无符号long long
整型常量。C99 还增加了带二进制指数的浮点常量(如
0x1.fp2
)。标准 C 允许实现支持标准中未定义的其它/扩展类型的常量表达式形式。不过,各编译器对这些常量表达式的处理方式不尽相同:有些会将其当作整数常量表达式处理。
声明(Declarations)
C89 对 C 语言影响最大的部分也许就是“声明”机制。它新增了许多与类型相关的关键字,并引入了新的术语来分类它们。对程序员而言,最显著的变化是函数原型(即新风格函数声明)从 C++ 引入到 C 中。
声明元素的顺序
一个声明可以包含以下一种或多种元素:
-
存储类说明符(storage-class specifier)
-
类型说明符(type specifier)
-
类型限定符(type qualifier)
-
函数说明符(function specifier)
-
对齐说明符(alignment specifier)
标准 C 允许这些元素顺序任意,但要求标识符及其初始化表达式出现在最后。例如:
static const unsigned long int x = 123;
可以重写为:
int long unsigned const static x = 123;
或者任何其他组合,只要
x
和它的初始值在最后即可。同样地:
typedef unsigned long int uType;
可以写成:
int long unsigned typedef uType;
一些老旧编译器可能要求特定顺序。K&R 是否允许类型说明符任意排序是有争议的:K&R 第 192 页的语法图表示支持,但第 193 页写道:“以下类型说明符组合是允许的:short int、long int、unsigned int 和 long float。” 这是否明确禁止
int short
、int long
等组合,尚不明确。
代码块中的位置
在 C99 之前,块级作用域中的所有声明都必须在所有语句之前。C99 取消了这个限制,允许声明与语句交错出现。C++ 也允许这一点。
存储类说明符(Storage-Class Specifiers)
auto
存储类在实际代码中极少看到
auto
的使用,因为在标准 C 中,局部变量默认就是 auto 类型。自动变量的存储方式及其可用内存大小由实现决定。使用栈或其他方式的实现可能对自动变量的空间有上限。例如,16 位机器可能将栈限制为 64 KB,或者如果整个地址空间是 64 KB,则代码、静态数据和栈加起来不得超过此限制。此时,随着代码或静态数据增大,可用栈空间减少,可能会导致无法为
auto
对象分配足够空间。有些实现会在每次进入函数时检查栈空间是否足够,如果不够就终止程序。有些甚至调用一个函数来执行检查,也就是说,每次调用你自己的函数时都会隐式调用另一个函数。
建议: 如果某个实现“探测栈空间”,通常可以通过编译选项关闭这种检查。虽然这可能会释放更多的栈空间,使程序得以运行,但不建议在测试阶段禁用栈溢出检查。
int i, values[10]; int j, k;
上述
auto
声明中,四个变量在内存中的相对位置是不确定的,并可能在不同的系统或不同的编译之间变化。但可以确定的是,数组values
的十个元素一定是连续分配的,并且地址按升序排列。建议: 不要依赖特定实现的自动变量分配方案,尤其不要假设变量会按照声明顺序在内存中分配。
C++ 注意事项:
从 C++11 起,
auto
不再作为存储类说明符使用,而是作为类型推导关键字,含义完全不同。
register
存储类register
是对编译器的提示,表示希望将变量存储在寄存器中以提高访问速度。实际能放入寄存器的变量数和支持的类型是实现定义的。如果不能放入寄存器,就会被当作auto
类型处理。标准 C 允许任何数据声明使用此存储类,也允许将其用于函数参数。
K&R 曾指出:“只有某些类型的变量才会被存入寄存器;在 PDP-11 上,它们是 int、char 和指针。”
建议: 随着编译器优化技术的提升,现代编译器自动优化寄存器分配,
register
的作用已经很小。除非你能证明它对某个目标实现确实有优化效果,否则不要使用register
。标准 C 不允许将
register char
当作register int
处理。即使它被存入一个宽于 char 的寄存器,它在行为上也必须完全像一个char
。有些实现甚至能在一个寄存器中同时存放多个register char
对象。
C++ 注意事项:
-
在 C++14 中仍然支持
register
,但已被弃用; -
在 C++17 中该关键字已被保留但不再使用,未来可能赋予不同语义。
static
存储类当尝试前向引用一个
static
函数时,可能会出现问题,例如:void test() { static void g(void); void (*pfi)(void) = &g; (*pfi)(); } static void g() { // ... }
上述代码中,
test
函数中对g
的声明具有块级作用域,并带有static
存储类。这意味着test
会调用一个static
函数g
,而不是其它同名extern
函数。然而,标准 C 不允许块作用域的函数声明带有
static
存储类。它只允许文件作用域的函数声明使用static
,如下:static void g(void); void test() { void (*pfi)(void) = &g; (*pfi)(); }
建议: 即使编译器允许,也不要在块作用域的函数声明中使用
static
存储类。
_Thread_local
存储类这是 C11 新增的关键字,用于定义线程局部存储的变量。(参见
<threads.h>
)
C++ 注意事项:
-
C++11 中引入了对应但不相同的关键字
thread_local
; -
标准 C 定义了宏
thread_local
,用于兼容性。
判断编译器是否支持线程局部存储的方式:
#ifdef __cplusplus /* 如果是 C++ 编译器 */ #if __cplusplus >= 201103L /* 支持 thread_local 存储时长 */ #else #error "该 C++ 编译器不支持 thread_local 存储时长" #endif #else /* 否则是 C 编译器 */ #ifdef __STDC_NO_THREADS__ #error "该 C 编译器不支持 thread_local 存储时长" #else /* 支持 thread_local 存储时长 */ #endif #endif
以下是你提供内容的完整中文翻译:
类型说明符(Type Specifiers)
C89 引入了以下用于类型说明符的关键字:
enum
、signed
和void
。它们对应的基本类型声明包括:-
void
(仅用于函数返回类型) -
signed char
-
signed short
-
signed short int
-
signed
-
signed int
-
signed long
-
signed long int
-
enum [tag]
(枚举类型)
C89 还新增了以下类型声明(部分实现原本已支持
unsigned char
和unsigned long
):-
unsigned char
-
unsigned short
-
unsigned short int
-
unsigned long
-
unsigned long int
-
long double
C99 增加了以下类型支持:
-
signed long long
-
signed long long int
-
unsigned long long
-
unsigned long long int
标准 C 规定,是否将一个“普通的 char”(即未指定
signed
或unsigned
)视为有符号或无符号,是实现定义的行为。虽然 K&R 允许
long float
作为double
的同义词,但标准 C 并不支持这种用法。在 C99 之前,如果省略了类型说明符,则默认视为
int
。例如,以下是合法的文件作用域声明:i = 1; extern j = 1;
但 C99 明确禁止这种写法。
C99 通过类型说明符
_Bool
添加了对布尔类型的支持(参见<stdbool.h>
,若该头文件不可用,亦有兼容处理)。💡 C++ 注意事项: C++ 使用关键字
bool
(与 C 中的_Bool
不同),而标准 C 将其定义为宏bool
,在<stdbool.h>
中提供。C99 还引入了
_Complex
类型说明符,从而提供以下类型:-
float _Complex
-
double _Complex
-
long double _Complex
(参见<complex.h>
)
C11 引入了
_Atomic
类型说明符,但它是可选的;详情见条件宏__STDC_NO_ATOMICS__
(参见<stdatomic.h>
)。
表示方式、大小与对齐(Representation, Size, and Alignment)
头文件
<limits.h>
和<float.h>
中的宏定义了算术类型的最小范围和精度。标准 C 要求如下:-
_Bool
:足以存储值0
和1
-
char
:至少 8 位 -
short int
:至少 16 位 -
int
:至少 16 位 -
long int
:至少 32 位 -
long long int
:至少 64 位
浮点类型的范围和精度应满足以下关系:
float ≤ double ≤ long double
三者可能具有相同的大小与表示方式,也可能完全不同,或部分重叠。
对于整数值,一个符合标准的实现可以使用:
-
一补码(ones-complement)
-
二补码(two-complement)
-
带符号绝对值表示(signed magnitude)
标准 C 的有符号整数最小值定义允许使用一补码。例如,一个使用 32 位 long 并采用二补码的实现,可以将
LONG_MIN
定义为-2147483647
,但合理的做法应是使用-2147483648
,以准确反映二补码的性质。通常,
float
用 32 位单精度表示,double
用 64 位双精度表示,long double
也常用 64 位表示。但若系统支持扩展精度,则long double
可能为 80 位或 128 位。⚠️ 注意:即便在多个浮点表示方式相同的处理器上运行同一个程序,也不能期望获得完全相同的浮点计算结果。例如,早期的 Intel 浮点处理器总是使用 80 位扩展精度进行计算,而不是严格的 64 位模式,这会导致与标准
double
运算结果不同。此外,舍入模式(rounding mode) 也会影响结果。建议: 关于浮点运算,应设定合理的期望,不能苛求不同平台上的结果一致性。特别要注意硬件浮点单元与软件浮点库之间可能的差异。
sizeof
与预处理表达式标准 C 不要求在预处理器
#if
表达式中识别sizeof
作为运算符。根据机器字长进行条件编译是一种常见做法。例如,若不在 16 位系统上,则默认为 32 位,可使用如下判断:
#include <limits.h> #if INT_MAX < LONG_MAX long total; #else int total; #endif
sizeof
编译时运算符会返回某个数据类型对象所占的 char 数量。乘以<limits.h>
中的CHAR_BIT
可得出实际使用的比特数。但对象所占的所有比特未必全部用于表示其值!以下是一些示例:示例 1:
早期 Cray Research 的机器采用 64 位字寻址结构。当声明一个
short int
时,虽然会分配 64 位(sizeof(short)
为 8),但只使用其中 24 或 32 位来表示值。示例 2:
Intel 浮点处理器支持:
-
32 位单精度(float)
-
64 位双精度(double)
-
80 位扩展精度(long double)
某些编译器将
long double
映射为 80 位格式。但为了性能,可能将其对齐到 32 位边界,从而占用 12 字节,其中有 2 字节未使用。示例 3:
C89 制定时曾讨论是否要求整数类型必须使用二进制表示,最终决定是的。因此描述中写道:“每一个物理上相邻的位代表一个更高的 2 的幂”。但有一位委员指出,他们公司使用的 16 位处理器将两个 16 位字用于表示一个
long int
,但低字中的最高位未被使用,等于中间有一个 1 位的“空洞”。这种实现虽然不能满足标准对 32 位整数的要求,但在其目标应用中依然有效。示例 4:
为了对齐,结构体的成员之间或最后可能插入空洞(未使用的位),位域容器内部也可能存在。
建议:
-
不要假设或硬编码任何对象类型的大小。请使用
sizeof
,并结合<limits.h>
、<float.h>
和<stdint.h>
提供的宏。 -
不要假设对象未使用的位具有特定或可预测的值。
虽然在许多实现中,所有数据指针和函数指针的大小与表示方法可能一致,甚至与整数类型一致,但标准 C 并不强制如此。例如,有些机器将地址表示为有符号整数,那么地址 0 就处于地址空间的中间位置。在这类机器上,空指针值可能不是“全 0”。一些分段内存架构支持近指针(16 位)和远指针(32 位)。标准 C 的要求是:所有数据指针和函数指针必须能用
void *
类型表示。建议: 除非你有非常特殊的应用需求,请默认认为:
-
每种指针类型都有独立的表示
-
它们与任何整数类型的表示方式不同
-
不要假设任何空指针值为“全 0”
一些程序会使用
union
将对象与某个整数类型组合,从而检查或操作其内部位。显然这属于实现定义行为。标准 C 提供了一个特殊保证:“为了简化
union
的使用,如果一个union
包含若干结构体,且这些结构体共享一个公共的初始成员序列,那么当前union
对象中包含这些结构体之一时,可以在任何声明可见范围内访问这些结构体的公共初始部分。”
结构体与联合体说明符(Structure and Union Specifiers)
K&R 没有限定位域(bit-field)可用的类型,但 C89 限定为:
int
、unsigned int
和signed int
,并说明:“是否将普通
int
位域的高位作为符号位,是实现定义的。”C99 规定:
“位域的类型应是
_Bool
、signed int
、unsigned int
或其他实现定义的类型(无论是否加限定符)。”C11 增加:
“是否允许原子类型用于位域,是实现定义的。”
K&R 要求连续的位域被打包进机器整数中,不能跨越字边界。而标准 C 则规定:
-
位域打包在哪种容器对象中是实现定义的;
-
位域是否可以跨容器边界也是实现定义的;
-
位域在容器中分配的顺序也是实现定义的。
标准 C 允许位域直接存在于联合体中,而无需先声明在结构体中,例如:
union tag { int i; unsigned int bf1 : 6; unsigned int bf2 : 4; };
K&R 要求联合体所有成员“从偏移量 0 开始”。标准 C 更明确地说明:
“一个指向
union
的指针(经过适当类型转换)应可以访问每个成员,反之亦然。如果某成员是位域,则该指针指向包含该位域的容器。”C11 支持匿名结构体、匿名联合体和灵活数组成员(flexible array members)。
枚举说明符(Enumeration Specifiers)
K&R 没有枚举类型,但 C89 出现前已有一些编译器实现支持枚举。
标准 C 规定:
“每个枚举类型应与
char
、有符号整数类型或无符号整数类型兼容。具体选择是实现定义的,但必须能表示该枚举中所有成员的值。”建议: 不要假设枚举类型就是
int
类型 —— 它可能是任何整型。需要注意的是,标准 C 要求枚举常量的类型为
int
。因此,某个枚举变量的类型不一定等同于其成员的类型。
C99 对枚举器列表末尾逗号的支持
C99 增加了对枚举器列表末尾逗号的支持,例如:
enum Color { red, white, blue, };
💡 C++ 注意事项: 标准 C++ 扩展了枚举类型,允许为其指定具体的基础类型(即底层表示),并将枚举常量的作用域限制在该枚举类型中。
原子类型说明符(Atomic Type Specifiers)
除非实现支持原子类型,否则不允许使用原子类型说明符。是否支持,可通过检查条件定义的宏
__STDC_NO_ATOMICS__
是否为整数常量1
来确定。(参见<stdatomic.h>
)_Atomic
类型说明符的形式为_Atomic(type-name)
,不要将其与_Atomic
类型限定符混淆(后者仅为_Atomic
名字本身,详见“类型限定符”部分)。💡 C++ 注意事项: C++ 不支持
_Atomic
,但它定义了头文件<atomic>
,用于提供各种原子操作相关的支持。
类型限定符(Type Qualifiers)
C89 引入了
const
类型限定符(源自 C++),还引入了volatile
限定符。通过指向非
const
类型的指针来修改const
对象,其行为是未定义的。建议: 不要通过没有
const
限定的指针修改const
对象。同样地,通过未带
volatile
限定的指针引用volatile
对象,其行为也是未定义的。建议: 不要通过非
volatile
指针访问volatile
对象。C99 增加了
restrict
类型限定符,并将其用于多个标准库函数以增强性能优化。💡 C++ 注意事项: C++ 不支持
restrict
。C11 引入了
_Atomic
类型限定符(不要与_Atomic
类型说明符混淆)。
函数说明符(Function Specifiers)
C99 增加了
inline
函数说明符,这是对编译器的建议,其是否生效由实现自行决定。在 C99 之前,一些编译器使用__inline__
支持此功能。标准 C 允许同时存在
inline
定义和外部(external)定义。在这种情况下,使用哪一个定义由实现决定,未作规定。C11 引入了
_Noreturn
函数说明符,并提供了头文件<stdnoreturn.h>
,其中包含宏noreturn
,它展开为_Noreturn
。💡 C++ 注意事项: C++11 提供了不同方式的等价支持,通过属性
[[noreturn]]
实现。
对齐说明符(Alignment Specifier)
C11 使用关键字
_Alignas
增加了对齐说明符支持。头文件
<stdalign.h>
提供了宏alignas
,它展开为_Alignas
。💡 C++ 注意事项: C++11 增加了
alignas
关键字,用于类似功能,但其行为和语法略有不同。标准 C 规定:
“如果在不同翻译单元中,对同一对象的声明使用了不同的对齐说明符,其行为是未定义的。”
声明符(Declarators)
通用说明
K&R 和标准 C 都认为括号中的声明符与非括号形式等价。例如,以下写法在语法上是正确的:
void f() { int (i); int (g)(); ... }
第二个声明可用于隐藏一个与宏重名的函数声明。
标准 C 要求声明至少支持 12 层指针、数组、函数的派生声明修饰。例如,
***p[4]
含有 4 层修饰。K&R 未规定明确限制,只表示声明符中可以有多个类型修饰符。(Ritchie 最初的编译器仅支持声明符中的 6 层修饰。)
标准 C 还要求数组维度必须为 正整数且非零。也就是说,不允许声明大小为 0 的数组,尽管某些实现允许这种写法。
数组声明符(Array Declarators)
标准 C 允许省略大小信息来声明不完整的数组,例如:
extern int i[]; int (*pi)[];
但这些对象在大小信息确定前,其用途受到限制。例如,
sizeof(i)
和sizeof(*pi)
是未知的,应导致编译错误。C99 允许在函数参数中带有数组类型的声明中使用类型限定符和关键字
static
。💡 C++ 注意事项: 标准 C++ 不支持在数组类型声明中使用这些特性。
C99 增加了对变长数组(VLA,Variable-Length Array)的支持,并强制要求实现支持。但 C11 将其变为可选,是否支持由宏
__STDC_NO_VLA__
决定。💡 C++ 注意事项: 标准 C++ 不支持变长数组。
函数声明符(Function Declarators)
调用非 C 函数(Calling Non-C Functions)
某些实现允许使用扩展的
fortran
类型说明符,表示采用 Fortran 的调用约定(即按引用调用),或者生成不同形式的外部名称。还有一些实现提供pascal
和cdecl
关键字用于调用 Pascal 或 C 例程。标准 C 不提供任何外部链接机制。💡 C++ 注意事项: 标准 C++ 提供
extern "C"
来指定使用 C 的链接方式。
函数原型(Function Prototypes)
借鉴自 C++,C89 引入了函数声明与定义的新形式——函数原型,即在函数参数列表中包含参数类型信息。例如,传统风格写法如下:
int CountThings(); // 无参数信息的声明 int CountThings(table, tableSize, value) // 定义 char table[][20]; int tableSize; char* value; { // ... }
新的函数原型形式则为:
// 函数原型 - 含参数信息的函数声明 int CountThings2(char table[][20], int tableSize, char* value); int CountThings2(char table[][20], int tableSize, char* value) // 定义 { // ... }
标准 C 保留了对旧式写法的兼容。
💡 C++ 注意事项: 标准 C++ 要求必须使用函数原型形式。
C++ 注意事项:
标准 C++ 要求使用函数原型语法。
尽管你可能仍然在使用旧式函数定义的生产代码中看到这类写法,它们仍可与新式函数原型共存。唯一需要注意的是**窄类型(narrow types)**的问题。例如,一个旧式函数定义中使用
char
、short
或float
类型的参数会期望它们以更宽的形式(分别是int
、int
和double
)传递。如果同时存在带有窄类型的函数原型,可能会引发不匹配的问题。建议: 尽可能使用函数原型,它可以确保函数以正确的参数类型被调用。函数原型还可以实现参数的自动类型转换。例如,调用
f(int *p)
时如果没有原型在作用域内,而传入参数0
,则不会自动将0
转换为int *
,这在某些系统上可能会导致问题。
标准 C 要求:调用变参函数时,必须在有函数原型的前提下进行。例如,下面这个出自 K&R 的经典程序就不符合标准 C:
main() { printf("hello, world\n"); }
原因是:在没有函数原型的情况下,编译器可以假定参数个数是固定的。这样,它可能会使用寄存器或其他更高效的方式传递参数。然而,
printf
是一个需要变参的函数,如果调用端以固定参数方式进行编译,可能无法正确与printf
通信。要修复上述程序,可以选择以下方式之一(推荐第一种):
-
#include <stdio.h>
-
在使用
printf
之前显式地声明其原型(包括尾部的...
)
此外,函数也应显式声明为返回
int
类型。建议: 在调用变参函数时,始终确保原型在作用域内,并包含
...
表示法。
关于原型声明中的标识符命名问题
函数原型中允许使用占位符标识符,但它们可能引发如下问题:
#define test 10 int f(int test);
虽然
test
这个名字在原型中的作用域仅限于其声明到原型结尾,但它在预处理阶段仍会被宏替换,因此变成:int f(int 10); // 语法错误
更糟的是,如果
test
被宏定义为*
,那么原型就会悄然地变成以int *
为参数。类似问题也可能出现在标准头文件中,如果实现使用的占位符名称与用户自定义的宏名冲突(特别是未加前导下划线的名称)。
建议: 如果必须在函数原型中使用标识符,请选择不容易与宏名冲突的名称。一个通用做法是:所有宏用全大写,其他标识符用小写,以避免混淆。
关于
int f();
与int f(void);
的区别-
int f();
告诉编译器f
是一个返回int
的函数,但参数数量和类型未知。 -
int f(void);
明确表示f
没有参数。
💡 C++ 注意事项: 在标准 C++ 中,这两个声明是等价的。
初始化(Initialization)
-
如果使用自动存储期(automatic storage duration)对象的值时尚未初始化,那么行为是未定义的。
-
外部和静态变量如果没有显式初始化,则默认初始化为
0
,并强制转换为相应类型。(这可能与calloc
或aligned_alloc
分配的区域不同,后者初始化为所有位为零。)
K&R 不允许对自动数组、结构体和联合体进行初始化,但标准 C 允许,只要初始化列表中的表达式为常量表达式,且不涉及变长数组。
此外,自动结构体或联合体也可以使用同类型的非常量表达式进行初始化。
标准 C 允许对联合体(union)进行显式初始化。该值会被转换为第一个成员的类型并存储。因此,成员的声明顺序很重要!
如果静态或外部联合体未显式初始化,其内容会是0
转换为第一个成员的类型(可能不会是所有位为零)。标准 C 允许自动结构体和联合体的初始化表达式为结构体或联合体类型的值。
位域初始化示例:
struct { unsigned int bf1 : 5; unsigned int bf2 : 5; unsigned int bf3 : 5; unsigned int bf4 : 1; } bf = {1, 2, 3, 0};
K&R 和标准 C 都要求:初始化表达式数量不得超过预期数量。不过有一种例外情况不会报错,例如:
char text[5] = "hello";
这里,数组
text
被初始化为字符'h'
,'e'
,'l'
,'l'
,'o'
,但不包含尾部的'\0'
字符。
某些实现允许在初始化列表末尾使用逗号,这种做法被标准 C 接受,也被 K&R 所允许。
C99 增加了对“指定初始化器(designated initializers)”的支持。
💡 C++ 注意事项: 标准 C++ 不支持指定初始化器。
静态断言(Static Assertions)
C11 引入了对静态断言的支持,并在头文件
<assert.h>
中新增了一个宏static_assert
,它会展开为_Static_assert
。 -