Section outline

  • 求值顺序与序列点(Order of Evaluation and Sequence Points)

    根据标准 C 的规定,除以下几种运算符外,其它表达式的求值顺序都是未指定的

    • 函数调用运算符 ()

    • 逻辑或运算符 ||

    • 逻辑与运算符 &&

    • 逗号运算符 ,

    • 条件运算符 ?:

    虽然运算符优先级表定义了运算符的优先级和结合性,但括号分组的优先级更高。不过,K&R 认为像 *+&|^ 这样的交换律和结合律运算符可以在括号存在的情况下仍然被重排。但标准 C 要求:必须遵守括号中的分组顺序

    例如以下表达式:

    i = a + (b + c);
    

    在 K&R 中可能会被当成如下来计算:

    i = (a + b) + c;
    i = (a + c) + b;
    

    这可能在某些整数边界值场景中引发中间值溢出问题。为避免此类问题,可以将表达式拆分为多条语句,如下:

    i = (b + c);
    i += a;
    

    ✅ 推荐:如果担心求值顺序对结果有影响,应拆分表达式,使用中间变量。

    对于浮点运算,由于精度有限,以下数学恒等式不一定成立:

    • (x + y) + z == x + (y + z)

    • (x * y) * z == x * (y * z)

    • (x / y) * y == x

    • (x + y) - y == x

    涉及副作用的表达式也可能受求值顺序影响,例如:

    i = f() + g(); // f() 和 g() 调用顺序不确定
    

    不确定求值顺序的表达式包括:

    j = (i + 1)/i++;
    dest[i] = source[i++];
    dest[i++] = source[i];
    i & i++;
    i++ | i;
    i * --i;
    

    这些语句的行为是未定义的。

    ✅ 推荐:即便你知道当前编译器的行为,也不要依赖它。不同版本或不同编译选项都可能导致行为变化。

    C89 引入了“完整表达式(full expression)”与“序列点(sequence point)”的概念:

    • 完整表达式是指不属于其它表达式、声明符或抽象声明符的一部分

    • 每个完整表达式求值完毕后与下一个完整表达式之间存在一个序列点。

    ✅ 推荐:熟悉并识别代码中的所有序列点。

    对有符号类型进行位运算(如 ~, <<, >>, &, |, ^)的结果是实现定义的

    ✅ 推荐:明确目标平台整数类型的表示方式,尤其是移位和掩码操作。

    浮点运算的性质是实现定义的,且软件仿真与硬件执行可能结果不同。有些机器允许通过编译选项选择浮点格式。

    ✅ 推荐:使用浮点类型时,确认其表示范围和精度,了解是否有硬件浮点支持。

    关于浮点表达式求值,C99 规定:

    • 浮点表达式可以被“合并(contracted)”为一个操作,从而减少中间舍入误差;

    • <math.h> 中的 FP_CONTRACT 编译指令允许禁止这种合并;

    • 否则是否合并及如何合并是实现定义的。

    对于无效算术操作(如除以零)或结果无法表示(如溢出),行为是未定义的

    好的,我们继续翻译下一部分内容:


    主表达式(Primary Expressions)

    • 括号表达式(如 (x + y))是主表达式;

    • C89 要求编译器至少支持 32 层嵌套括号表达式,C99 将其提高到 63 层;

    • C11 引入了泛型选择表达式 _Generic,它也是主表达式的一种。


    后缀运算符(Postfix Operators)

    数组下标运算(Array Subscripting)

    • 格式为 a[b],其中 ab 都是表达式;

    • a 必须是指向某种类型(不能是 void)的指针,b 必须是整型;

    • 并不要求 a 是指针、b 是整数。即 a[b]b[a] 是等价的。

    示例:

    int i[] = {0, 1, 2, 3, 4};
    int *pi = &i[2];
    for (int j = -2; j <= 2; ++j)
        printf("x[%2d] = %d\n", j, pi[j]);
    

    输出:

    x[-2] = 0
    x[-1] = 1
    x[ 0] = 2
    x[ 1] = 3
    x[ 2] = 4
    

    ✅ 推荐:

    • 不要对数组 A 使用超出 [0, n-1] 范围的下标;

    • 如果是指针表达式,则可以适当使用负下标,只要指向的是可预知的内存区域。

    利用指针构造任意下标范围的数组(⚠️ 不推荐,可能不具可移植性):

    int k[] = {1, 2, 3, 4, 5};
    int *p4 = &k[-1];
    int *yr = &k[-1983];
    
    printf("p4[1] = %d\n", p4[1]); // 即 k[0]
    printf("yr[1983] = %d\n", yr[1983]); // 即 k[0]
    

    这种“反向偏移”依赖于机器是否具有线性地址空间,以及指针算术的实现方式,某些使用段式内存架构的机器可能不支持。

    ⚠️ C 标准规定:指针运算结果只能指向数组内部或“数组末尾后一个元素”,否则行为未定义

    多维数组维度计算示例:

    int i[2][3][4];
    dim3 = sizeof(i[0][0]) / sizeof(i[0][0][0]); // 4
    dim2 = sizeof(i[0]) / (dim3 * sizeof(int)); // 3
    dim1 = sizeof(i) / (dim2 * dim3 * sizeof(int)); // 2
    

    函数调用(Function Calls)

    • C99 要求:调用函数前必须有其声明;

    • 如果未声明就调用函数,或实参与形参类型/数量不匹配,行为未定义;

    • 对可变参数函数(如 printf)调用时若无带省略号 ... 的原型,行为未定义。

    ✅ 推荐:

    • 函数原型中必须包含 ... 才能安全地使用变参函数;

    • 对于 printfscanf 等,始终包含 <stdio.h>

    • 使用 stdarg.h(或 varargs.h)正确处理变参。

    函数参数的求值顺序未指定

    f(i, i++); // 行为未定义
    

    扩展示例:

    (*table[i])(i, i++); // 无法确定 i++ 是否影响 table[i]
    

    ✅ 推荐:不要依赖函数调用参数的求值顺序或函数名表达式的求值顺序。

    未显式声明的函数将被当作 extern int 返回类型处理。

    ⚠️ 示例:

    g(60000); // 没有原型的调用
    void g(int i) { /* ... */ }
    

    这在 16 位系统上可能出错,因为 60000 是 long,但函数期望 int。

    ✅ 推荐:使用显式类型转换(cast)或函数原型来匹配参数类型。

    C 标准允许结构体或联合体以值传递方式传参。但值传递结构体的最大大小是实现定义的

    C89 要求函数最多接收 31 个参数,C99 扩展至 127 个。

    调用函数指针可用 (*fp)()fp(),但推荐使用前者以清晰表达意图。

    ✅ 推荐:当通过函数指针调用函数时,使用 (*fp)() 格式; ✅ 推荐:所有函数调用处和定义处应使用一致的函数原型。


    接下来部分包括:

    • ->. 的结构体/联合体成员访问规则;

    • 一元与二元运算符行为;

    • sizeof_Alignof、强制类型转换;

    • 各种运算符的实现细节和未定义行为。

    好的,我们继续完整翻译剩下的内容,以下是后续部分的翻译:


    结构体与联合体成员(Structure and Union Members)

    由于 C89 引入了结构体(或联合体)按值传递与返回、以及结构体赋值,因此现在可以将结构体(或联合体)表达式作为合法表达式使用。

    K&R 中指出,在 x->y 表达式中,x 可以是指向结构体(或联合体)的指针,也可以是绝对机器地址。但标准 C 要求 . -> 运算符的左操作数必须是结构体(或联合体)类型或其指针。

    例如在一些机器上,I/O 地址会映射到物理内存中,设备寄存器会像普通变量一样访问。旧做法可能是:

    0xFF010->status
    

    但现在应显式转换为结构体指针:

    ((struct tag1 *)0xFF010)->status
    ((union tag2 *)0xFF010)->status
    

    当联合体中访问的成员与上一次赋值的成员不同,则行为是实现定义的。不能假设多个成员在内存中重叠的方式,除非多个结构体具有相同的前缀成员序列并包含在同一个联合体中

    示例:

    struct rectype1 {
        int rectype;
        int var1a;
    };
    
    struct rectype2 {
        int rectype;
        float var2a;
    };
    
    union record {
        struct rectype1 rt1;
        struct rectype2 rt2;
    } inrec;
    

    如果 inrec 当前保存的是 rt1rt2,则可以安全访问 rectype 字段,因为它们位置一致。

    ⚠️ 标准 C 明确指出:访问原子结构体或联合体对象的成员会导致未定义行为


    后缀自增/自减运算符(Postfix Increment and Decrement)

    早期某些实现曾认为 (i++)++ 是合法的并可修改。但标准 C 不允许。此表达式应报错


    复合字面量(Compound Literals)

    C99 支持复合字面量(如 (int[]){1,2,3}),可在表达式中创建匿名数组或结构体。

    ⚠️ C++ 不支持复合字面量。


    一元运算符(Unary Operators)

    前缀自增/自减(Prefix Increment/Decrement)

    同样 (++i)++ 等表达式在早期实现中可能被允许,但标准 C 不支持修改前缀运算结果。


    取地址与间接访问运算符(Address and Indirection Operators)

    以下操作行为未定义

    • 越界数组下标;

    • 解引用空指针;

    • 访问自动变量生命周期已结束的对象;

    • 访问已释放的堆内存。

    例如某些平台中,解引用空指针会直接触发严重的“访问冲突”。

    • 对函数名使用 & 运算符是多余的

    • 但对结构体或联合体变量名使用 & 表示获取指针,不能省略

    • 某些编译器允许 &bit-field,这在标准 C 中是不允许的;

    • 也不允许 &register 变量,虽然有些实现接受;

    • 有些实现允许对常量表达式取地址,标准 C 不支持。

    如果将指针转换为其它类型后再解引用,可能因对齐要求不满足而导致致命错误,例如将奇地址的 char * 转为 int *


    一元算术运算符(Unary Arithmetic Operators)

    • + 是 C89 引入的;

    • 在二进制补码系统中,-INT_MIN 仍为 INT_MIN,因为无法表示其相反数;

      同理适用于 LONG_MIN, LLONG_MIN


    sizeof 运算符

    C99 之前,sizeof 的结果始终为编译时常量。
    C99 引入变长数组后,如果 sizeof 作用于变长数组,则在运行时求值。

    • 结果类型为 size_t,定义于 <stddef.h>

    • 如何使用 printf 打印 sizeof 的结果:

    printf("%u", (unsigned int)sizeof(type));        // 最多支持 65535
    printf("%lu", (unsigned long)sizeof(type));      // 最多支持 4G
    printf("%llu", (unsigned long long)sizeof(type));// 最多支持 18E
    printf("%zu", sizeof(type));                     // C99 推荐用法
    

    ✅ 推荐:如果使用 printf 打印 size_t 值,应使用 %zu#include <stddef.h>


    _Alignof 运算符

    C11 引入,返回类型也是 size_t,定义在 <stddef.h>

    • <stdalign.h> 中定义宏 alignof,展开为 _Alignof

    • C++11 中的 alignof 是关键字,但在 C 中是宏。


    强制类型转换(Cast Operators)

    • 指针与整数互转(除了 0)是实现定义的

    • 不同对齐要求的指针类型之间转换,其结果也是实现定义的

    • C11 禁止将指针类型直接转换为浮点类型,或反过来;

    • 某些转换为确保表达式行为正确是必要的(如 VP/UP 问题);

    • C 标准库中多数函数返回 void *,通常无需显式转换。

    ✅ C++ 中将 void * 转换为其他类型需显式强转。

    要获取结构体成员偏移量,有时会用如下宏:

    #define OFFSET(struct_type, member) \
      ((size_t)(char *) &((struct_type *)0)->member)
    

    ✅ 推荐:标准 C 提供 offsetof 宏(定义于 <stddef.h>)用于此目的,优先使用。

    注意:(void *)0 表示空指针,但其位模式不一定是“全为 0”。


    乘法运算符(Multiplicative Operators)

    • 整型和浮点除法可能导致未定义行为

    • C89 中某些整数除法行为为实现定义,C99 删除了这项内容。


    加法运算符(Additive Operators)

    • 若指针不是指向数组中的元素或“末尾后一个元素”,对其加减整数会导致未定义行为

    • 两个指向同一数组元素的指针相减,结果类型为 ptrdiff_t,定义于 <stddef.h>


    位移运算符(Bitwise Shift Operators)

    • 对表达式移负数位,或移位数 ≥ 表达式位宽 → 未定义行为

    • 左移操作若结果不能表示 → 未定义行为

    • 有符号负数右移 → 实现定义行为

    • 在 VP/UP 模式下,右移结果也可能不同。


    关系运算符(Relational Operators)

    • 比较不属于同一数组或结构的指针 → 未定义行为

    • 指针可向后越过数组一位比较;

    • VP/UP 模式下,比较表达式可能产生不同结果。


    相等运算符(Equality Operators)

    • 指针可与 0 比较;

    • 将非 0 整型与指针比较 → 实现定义行为

    • 结构体和联合体不能整体比较,只能成员比较;

    • 对浮点数使用相等运算要小心,因精度误差导致结果不可预测。


    位与 / 位异或 / 位或运算符(Bitwise AND / XOR / OR)

    这些运算符的结果依赖于整型的位宽和表示方式。


    逻辑与 / 逻辑或 运算符(Logical AND / OR)

    • 逻辑与 &&、逻辑或 || 运算符之间存在序列点

    • 第一个操作数求值完成后,才会求第二个操作数。


    条件运算符(?:)

    • 在判断表达式与第二/第三操作数之间存在序列点


    赋值运算符(Assignment Operators)

    简单赋值(=)

    • 将 0 赋值给任意指针是可移植的;

    • 其他数值赋给指针则不可移植;

    • 严格对齐要求下,类型转换行为为实现定义

    • 不同对象类型之间的指针赋值必须显式强制转换(void * 除外);

    • 结构体和联合体只能赋值给同类型的结构体或联合体;

    • 若目标对象与源对象有重叠,赋值结果为未定义行为,应使用临时变量中转。

    复合赋值(+=, -=, *= 等)

    • 类似 =op 的早期形式(如 =+)不被标准 C 支持;

    • 如果 x[i] = x[i++] + 10;,行为不明确;

      使用 x[i++] += 10; 可以明确求值顺序。


    逗号运算符(Comma Operator)

    • 在两个操作数之间定义了序列点


    常量表达式(Constant Expressions)

    • 静态初始化表达式可在程序启动时计算;

    • 编译器必须使用不少于运行时精度;

    • C89 引入了 float, long double, unsigned 整型常量;

    • C99 引入 signed/unsigned long long 整型常量;

    • C99 还支持带二进制指数的浮点常量;

    • 标准允许扩展常量表达式支持自定义类型,但不同编译器行为可能不同。