程序语义
一、相关术语
易失访问(volatile access): 使用volatile限定类型的左值访问对象是易失访问。
副作用(side effects): 易失访问对象、修改对象、修改文件或者调用函数执行这些操作都是副作用,副作用即执行环境状态的改变。
表达式(expression): 表达式是运算符和操作数的序列,用于指定值的计算,或者指定对象/函数,或者产生副作用,或者执行上述功能的组合。
表达式评估(evaluation of an expression): 表达式评估通常包括值计算和副作用的启动。左值表达式(lvalue expression)的值计算包括确定指定对象。
之前排序(sequenced before): 之前排序是单线程执行评估间的不对称、可传递、成对的关系;之前排序在这些评估间产生偏序(partial order)。任意两个评估A和B,如果A在B之前排序,则A的执行应在B的执行之前。如果A在B之前排序,则B在A之后排序(sequenced after)。如果A没有在B之前或者之后排序,那么A和B没有被排序。当A在B之前或者之后排序,但没有具体说明是哪一种情况时,A和B的评估排序是不确定的。未排序的评估可以交错执行;不确定顺序的评估不能交错执行,但可以按任意顺序执行。如果在表达式A的评估和表达式B的评估之间存在序列点(sequence point),则意味着与A关联的每个值计算和副作用在与B关联的每个值计算和副作用之前排序。
整型提升(integer promotions): 对于转换等级小于等于int类型或者unsigned int类型(不包括int类型和unsigned int类型)的整型对象或者表达式,以及_Bool、int、signed int或者unsigned int类型的位字段,位精确整数类型的位字段值被转换为相应的位精确整数类型;如果原始类型不是位精确整数类型:如果int类型可以表示原始类型的所有值,原始值将转换成int类型,否则转换成unsigned int类型,这些统称为整型提升,例如:signed char类型会提升为int类型;位字段unsigned _BitInt(7) : 5会提升为unsigned _BitInt(7)类型。
注:位精确整数类型(bit-precise integer type)是ISO/IEC 9899:2024标准新增的整数类型。
二、程序语义
抽象机(abstract machine)中所有表达式按照语义指定的方式进行评估。实现无需评估部分表达式,如果实现可以推断出其值没有被使用,并且没有产生所需的副作用(包括调用函数或者易失访问对象引起的任何副作用)。
当抽象机进程因接收到信号而中断时,既不是无锁原子对象,也不是volatile sig_atomic_t类型对象的值是未指定的,动态浮点环境的状态也是如此。处理程序退出时,处理程序修改的任何对象(既不是无锁原子对象,也不是volatile sig_atomic_t类型对象。)表示是不确定的;如果动态浮点环境被处理程序修改,但未恢复到其原始状态,则动态浮点环境的状态也是不确定的。
遵守ISO/IEC 9899:2024标准的实现的最低要求是:
-- 易失访问对象严格按照抽象机的规则进行评估。
-- 程序终止时,写入文件的所有数据应与根据抽象语义执行程序所产生的结果相同。
-- 初始打开时,标准错误流不是完全缓冲的;标准输入流和标准输出流只有在可以确定不指向交互式设备时才是完全缓冲的。这样要求的目的是尽快出现无缓冲或者行缓冲输出,以确保在程序等待输入之前出现提示消息。
交互式设备的构成由实现定义。实现可能定义更严格的抽象语义(abstract semantics)和实际语义(actual semantics)的对应关系。如果实现在抽象语义和实际语义之间定义一一对应关系,在每个序列点(sequence point)实际对象值和抽象语义指定的值一致。这种情况下关键词volatile就多余了。
实现可能在单个编译单元内执行各种优化,这使得只有在跨编译单元边界进行函数调用时,实际语义与抽象语义才会一致。这种情况下,如果调用函数和被调用函数位于不同的编译单元,每次函数进入和函数返回时,所有具有外部链接(external linkage)的对象以及通过指针访问的对象的值将和抽象语义一致。此外每次进入这类函数时,被调用函数的形参值以及通过指针访问的所有对象值将和抽象语义一致。这类实现中,由signal函数激活的中断服务例程所引用的对象应明确使用volatile类型限定符进行限定以及其它实现定义的限制。
short s = 'A'; short space = ' '; s += space;
整型提升要求将short类型变量提升为int类型。如果两个short类型对象相加没有溢出,或者在整数溢出的情况下通过静态包装来产生正确的结果,那么实际执行只需要产生相同的结果,可能会省略整型提升。
上述代码中变量s和变量space相加不会溢出,所以实现可能会省略整型提升。
float f = 3.14f; double d = 2.0; f = f + d;
根据常用算术转换规则(usual arithmetic conversions),算术运算中既存在double类型数据,也存在float类型数据,float类型数据会提升为double类型。如果实现可以确定使用float类型计算结果和使用double类型计算结果相同,实现可能使用float类型进行运算。
上述代码使用float类型和使用double类型计算结果相同,所以实现可能使用float类型进行运算。
使用宽寄存器的实现必须遵守适当的语义。值与在寄存器中表示还是在内存中表示无关。寄存器的隐式溢出不允许改变值。显式的存储和加载要求舍入到存储类型的精度。强制转换和赋值要求执行指定的转换。
double d; float f; d = f = 3.1415926; printf("%.10f\n", d); //输出3.1415925026。 printf("%.10f\n", (float)3.1415926); //输出3.1415925026。 printf("%.10f\n", 3.1415926); //输出3.1415926000。
C语言中的float类型对应于IEEE 754-2019标准中的单精度(single-precision),精度至少为6;double类型对应于IEEE 754-2019标准中的双精度(double-precision),精度至少为15。由于赋值执行指定的转换,第一个输出从小数点后第7位起丢失了信息;由于强制转换,第二个输出从小数点后第7位起丢失了信息;第三个输出在精度范围内,没有丢失信息。
由于精度和范围的限制,浮点表达式的重排经常会受到限制。由于舍入误差,即使在没有溢出和下溢的情况下,实现通常也不能使用数学结合律(associative law)进行加法或者乘法运算,也不能使用分配律(distributive law);同样实现通常不能为重排表达式而替换十进制常量。下面代码中的实数重排通常是无效的。
double x, y, z; ... x = (x * y) * z; //不等于 x *= y * z;。 z = (x - y) + y; //不等于 z = x;。 z = x + x * y; //不等于 z = x * (1.0 + y);。 y = x / 5.0; //不等于 y = x * 0.2;。
当一个值加上一个很大值时,由于精度原因,这个值很可能无法表示出来。下面这个例子展示了这种情况下由于重排造成结果的差异性。
printf("%f\n", (2.0f + powf(2.0f, 25.0f)) - powf(2.0f, 25.0f)); //输出0.000000。
printf("%f\n", 2.0f + (powf(2.0f, 25.0f) - powf(2.0f, 25.0f))); //输出2.000000。
详细解释请参阅浮点算术运算的不可结合性。
运算符具有优先级(precedence)和结合性(associativity)。优先级高的运算符会先评估,例如:x + y * z,会先乘后加。优先级相同的情况下,运算符的结合性决定了评估方向,例如:x = y = z,由于=运算符具有从右到左的结合性,所以上式解析为x = (y = z)。
int x, y;
...
x = x + 32760 + y + 1;
由于+运算符具有从左到右的结合性,上述表达式将解析为:
x = (((x + 32760) + y) + 1);
如果实现int类型可表示的值域范围为[-32768, +32767],并且如果发生溢出,将生成显式陷阱,这种情况下实现无法将表达式重写成以下形式。
x = ((x + y) + 32761);
因为如果x + y的和溢出(例如:x为-32760;y为-40。),将会生成陷阱;但这种情况原表达式不会生成陷阱。
该表达式也不能重写成以下形式。
x = ((x + 32761) + y); //如果x + 32761的和溢出,将生成显式陷阱,例如:x为20。
或者
x = (x + (y + 32761)); //如果32761 + y的和溢出,将生成显式陷阱,例如:y为20。
如果实现发生溢出时,能够安静地生成某些值,并且正溢出和负溢出会取消,由于结果相同,实现可以用上述三种形式中的任意一种重写表达式。
运算符操作数的值计算在运算符结果的值计算之前。除非另有说明,否则完整表达式中子表达式的副作用和值计算都是无序的。
int sum = 0;
sum = func1() + func2() + func3();
对于上述三个函数调用,func1函数的调用可能第一个评估,也可能第二个评估,甚至第三个评估;但只要求发生在需要其返回值之前。func2函数、func3函数的调用也是一样的。
主要参考资料:
3、cppreference.com : implicit conversions
4、open-std.org : bit-precise bit-fields
5、learncpp.com : floating point numbers