类型、运算符与表达式

对照 K&R 第二章掌握 ANSI C 的类型与范围、常量和声明、整数提升与通常算术转换、位运算和条件表达式,并严格区分优先级、结合性与求值顺序。

学习目标

  • 能比较 limits.hfloat.hsizeof 给出的信息,区分 C 标准保证、实现定义选择和项目额外假设
  • 能逐步推导整数提升与通常算术转换,安全改写有符号/无符号比较、整数除法和位操作
  • 能回答:为什么 a + b * c 的分组确定,却不能据此推出函数调用或操作数的求值先后?

从“本机是 4 字节”开始质疑

类型首先规定一组值与可执行操作,然后才由实现选择表示。C 只保证 sizeof(char) == 1;这个“1 字节”有 CHAR_BIT 位且 CHAR_BIT >= 8。标准不保证 char 恰好 8 位、int 恰好 32 位、double 恰好 64 位,也不保证普通 charsigned char 具有相同符号性。

ANSI C 的基础类型还包括 signed/unsigned charshortunsignedlonglong doublevoid,并非只有 char/int/float/double 四种。long long 来自 C99,_Bool 也不是 K&R 第二版的 ANSI C90 核心;学习时应标明版本。

下面的程序打印实现事实,而不是宣称这些值跨平台不变:

#include <float.h>
#include <limits.h>
#include <stdio.h>
 
int main(void)
{
    printf("CHAR_BIT=%d, plain char signed=%s\n", CHAR_BIT,
           CHAR_MIN < 0 ? "yes" : "no");
    printf("sizeof(short/int/long)=%lu/%lu/%lu\n",
           (unsigned long)sizeof(short), (unsigned long)sizeof(int),
           (unsigned long)sizeof(long));
    printf("UINT_MAX=%lu, FLT_RADIX=%d\n",
           (unsigned long)UINT_MAX, FLT_RADIX);
    return 0;
}

在 C90 中,sizeof 结果是无符号整数类型 size_t;这里转成 unsigned long%lu 是为了保持示例的 ANSI C 可移植性。现代 C 可用 %zu,但该格式说明符来自 C99。除变长数组等后续标准特性外,sizeof 的操作数通常不被求值。

常量与声明先决定类型

整数字面量的类型由进制、值和后缀共同决定。十进制常量与十六/八进制常量的候选类型序列不同;UL 后缀会改变候选集合。字符常量 'A' 在 C 中类型是 int,字符串字面量则是带结尾空字符的字符数组,不能通过指针去修改。

声明把“说明符”和“声明符”组合起来:unsigned long count; 的说明符给出基础类型,int *p[10]; 的声明符说明 p 是含 10 个 int * 的数组。const 在 K&R 第二版的 ANSI C 中已存在,但它限制的是通过某个左值修改对象的能力,不自动让对象成为编译期常量。

算术、关系与逻辑运算符

不只决定“算什么”,还继承操作数类型的值域。INT_MAX + 1 不是得到一个更大的数学整数,而是有符号溢出的未定义行为;UINT_MAX + 1U 则按模得到零。取余 % 只接受整数操作数,不能用于 double

包括 < <= > >=,相等运算符是 == !=。结果类型是 int,值为 0 或 1。不要写成数学连写 0 < x < 10:它先算 0 < x 得到 0/1,再把这个整数与 10 比较,所以几乎总为真;正确写法是 0 < x && x < 10

逻辑与 &&、逻辑或 || 和逻辑非 ! 把零视为假、非零视为真。前两者具有短路规则:p != NULL && p->value > 0 会先检查指针,若左侧为假就不求右侧。短路常用于保护后续访问,但也应避免把重要副作用藏在右操作数中,否则读者很难看出某条路径是否执行。

先预测:若 int x = 5;,表达式 0 < x < 3 的值是什么?先画语法树,再按每一步的类型和值计算,不要凭数学直觉回答。

答案是 1:0 < x 先产生 int 值 1,随后 1 < 3 仍为真。这个例子把分组、关系结果类型和逻辑组合三件事放在了一起。

整数提升不是“窄类型变宽”

小整数参与大多数表达式前先做 。因此 unsigned short 在一种实现上可能提升为 int,在另一种 int 范围不足的实现上提升为 unsigned int

随后,二元算术和比较通常通过 寻找公共类型。它绝不是“总把有符号转无符号”或“较窄转较宽”这么简单。

经典例子中 int i = -1; unsigned int u = 10; i < u 为假,是因为两者等级相同,i 转成 unsigned int,负一按模转换成 UINT_MAX。但安全修复不是无条件写 (unsigned)i < u,那会保留同样转换。若业务语义是数学整数比较,应先处理负数:

int int_less_than_unsigned(int i, unsigned int u)
{
    if (i < 0)
        return 1;
    return (unsigned int)i < u;
}

i 非负时,转成 unsigned int 才能保持值。更好的接口通常从源头统一类型,并在跨类型边界先做范围检查。

赋值、递增与条件表达式

赋值运算符要求左操作数是可修改左值。x += yx = x + y 的数值意图相近,但左操作数在复合赋值中只求值一次;当左侧包含函数调用或指针移动时,这一点影响副作用。赋值表达式自身也有值,所以 a = b = 0 按右结合工作,但把多重赋值塞进复杂条件会降低可审计性。

的前后缀差别在表达式值。若 i 原为 5,j = i++j 得 5、i 变 6;重新置 5 后,j = ++i 让二者都为 6。单独写成语句时 i++++i 都只表达加一,不应凭 C++ 迭代器经验虚构性能差异。

条件表达式 condition ? when_true : when_false 先求条件,只求两个候选中的一个。它不是任意类型的“内联 if”:第二、第三操作数要按标准规则形成结果类型,涉及算术类型时会发生通常算术转换,涉及指针时还要满足兼容或空指针规则。

int clamp_to_byte(int value)
{
    return value < 0 ? 0 : (value > 255 ? 255 : value);
}

这个嵌套条件表达式的三个最终候选都是 int,结果类型清楚;若分支要修改多个对象、处理错误或记录日志,应改用 if-else,不要为了“短”牺牲控制流可见性。

除法、溢出与浮点转换

7 / 2 两个操作数都是整数,结果为 3;赋给 double 只会把已经得到的 3 转成 3.0。写 7.0 / 2.0 或先转换一个操作数,才会执行浮点除法。K&R 的 ANSI C 与后续 C 标准在负整数除法细节上存在历史边界;现代 C 规定商向零截断,阅读旧实现时不要把后来的规则倒推成所有历史编译器都一致。

无符号算术按比最大值大一的模数运算,回绕是定义良好的;有符号整数溢出是 。浮点转换也可能舍入或溢出;精度与指数范围应从 float.h 查询,不能把 IEEE 754 的具体位布局当成所有 C 实现的保证。

位运算建立在无符号值上

按位与、或、异或、取反和移位只适用于整数类型。工程代码优先对无符号值做位操作:无符号右移补零,左移在合法移位计数下按模处理;负有符号数右移是实现定义行为,有符号左移还可能触发未定义行为。

#include <limits.h>
 
unsigned int set_field(unsigned int word, unsigned int value,
                       unsigned int shift, unsigned int width)
{
    unsigned int bits = (unsigned int)(sizeof word * CHAR_BIT);
    unsigned int mask;
    if (width == 0 || width > bits || shift > bits - width)
        return word;
    mask = width == bits ? ~0U : ((1U << width) - 1U);
    mask <<= shift;
    return (word & ~mask) | ((value << shift) & mask);
}

先验证 widthshift,避免移位计数等于或超过类型宽度;width == bits 单独处理,避免 1U << bits。掩码只保留目标字段,其余位保持不变。

优先级只决定语法树

a + b * c 分组为 a + (b * c);结合性决定同级运算符如何分组,例如赋值从右结合。二者都不等于

&&||、条件运算符和逗号运算符提供明确序列规则;普通二元运算符与函数参数通常不提供你想象的从左到右顺序。注意 f(a(), b()) 中分隔参数的逗号不是逗号运算符。

#include <stdio.h>
 
int left(void)  { puts("left");  return 1; }
int right(void) { puts("right"); return 2; }
 
int main(void)
{
    int a = left();   /* 独立完整表达式,顺序明确 */
    int b = right();
    printf("sum=%d\n", a + b);
    return 0;
}

若顺序重要,就像上面一样拆成独立语句。i = i++ + 1 一类表达式在 K&R 对应的规则下对同一标量产生未被序列约束的读写,属于未定义行为;加括号只能改变分组,不能凭空添加序列保证。

把转换告警变成证明义务

在 GCC/Clang 上额外开启 -Wconversion -Wsign-conversion -Wshift-overflow,可以暴露窄化、符号变化和可疑移位,但告警不是标准判决器:有些合法转换会告警,有些跨函数的范围错误不会。正确处理方式是写下源值范围、目标值范围和越界策略,再决定保持类型、先检查、饱和、拒绝输入或显式转换。

显式强转只告诉编译器“执行这种转换”,不会证明值可表示,也不会修复对象生存期、别名或求值顺序。比如把负 int 强转为 unsigned 会得到定义良好的模转换结果,却未必符合业务语义;把大 unsigned long 强转为较小整数类型还要面对实现定义或信号等目标标准规则。每个跨符号、跨宽度转换都应在代码审查中附一条范围理由。

动态消毒器可以发现本次执行中的部分未定义行为,却无法穷尽所有输入和表达式路径。最终证据仍是:表达式的每一步类型可推导、每个值落在范围内、每个副作用有标准保证的序列关系。

小结

  • C 标准规定类型的关系和最小范围,不固定常见平台的字节数、普通 char 符号性或浮点布局
  • 整数提升先于通常算术转换;混合符号结果由等级与可表示性共同决定
  • 正整数除法截断小数;无符号回绕有定义,有符号溢出是未定义行为
  • 位运算优先使用无符号类型,并在移位前验证计数与字段宽度
  • 优先级和结合性决定分组,求值顺序决定时间约束;有顺序要求就拆语句

练习

问题 1 在不假设 int 位宽的前提下,解释 int i = -1; unsigned int u = 10; i < u,并给出保持数学整数语义的比较方式。

问题 2 sizeof(char)CHAR_BITsizeof(int)、普通 char 是否有符号,哪些是标准固定值,哪些要查询实现?

问题 3 为什么给 f(a(), b()) 加括号不能保证先调用 a?怎样改写才能建立顺序?

名词解释

本章出现的专业名词,用大白话再讲一遍。

整数提升

charshort 和部分位域在多数表达式中先转为 int;若 int 无法表示源类型全部值,则转为 unsigned int

算术运算符

+ - * / %。运算前按规则转换操作数,结果受转换后类型的值域约束;有符号溢出与无符号模运算不可混为一谈。

关系运算符

< <= > >=。比较前执行相应转换,结果为 int 的 0 或 1;数学连写需要改成由 && 连接的两个比较。

通常算术转换

在整数提升后,根据浮点等级、整数转换等级、符号性和可表示性,为二元操作数寻找公共实类型的规则。

递增与递减

前缀形式先修改再产生新值,后缀形式产生旧值并完成修改。二者都有副作用,不能依赖未保证的求值顺序组合访问同一对象。

未定义行为

违反标准要求后,标准不约束结果。稳定输出、关闭优化或显式强转都不能把根因变成合法语义。

运算符优先级

决定运算符与操作数如何绑定的语法规则。它与结合性共同形成语法树,但不决定兄弟子表达式的执行先后。

求值顺序

子表达式计算与副作用完成的先后约束。标准未保证顺序时,程序不得依赖某个编译器的选择。

资料与写作方式声明

本章以C 程序设计语言(K&R)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…