16.5 遵从性和行为
1. 为什么先讲行为分类
C 语言当中的行为分为多种类别,“对”与“错”只覆盖其中一部分。标准区分“好”、“实现决定”、“结果不唯一”、“完全无保证”等情况。
2. 常见行为类别
2.1 实现定义行为
实现定义行为
标准要求实现给出一种明确行为,并在文档中说明。例如 char 默认是有符号还是无符号,属于实现定义行为。工程上可以使用这类能力,但要通过文档或编译选项把前提写清楚。
2.2 未指定行为
未指定行为
标准允许出现若干种结果,但不要求实现每次都做同样选择。例如某些表达式中子表达式的求值先后次序并未固定。代码如果依赖某一种求值次序,就会在不同编译器下表现不一致。
2.3 未定义行为
未定义行为
一旦触发未定义行为,标准不再提供任何结果保证。越界访问、悬垂指针解引用、对同一标量对象进行无序修改都属于这一类。这些代码有时“看起来能跑”,平台或优化一变就立刻失效。
存在未定义行为的程序是错误的,编写代码时要规避未定义行为。
#include <stdio.h>
int main(void) {
int a[5] = {1, 2, 3, 4, 5};
printf("%d\n", a[10]); /* 越界访问:未定义行为 */
return 0;
}2
3
4
5
6
7
这里 a[10] 越过了数组的有效下标范围(最大为 a[4]),标准对该访问的结果不作任何保证。
3. 遵从性 (Conformance)
遵从实现与遵从程序
遵从实现 (Conforming implementation) 是指满足标准约束的编译器和库实现。严格遵从程序 (Strictly conforming program) 只使用标准规定的特性,不依赖任何未指定、未定义或实现定义的行为。遵从程序 (Conforming program) 的要求宽松得多:只要能被某个遵从实现接受即可,因此可以依赖实现定义行为和编译器扩展。
4. 习题
把下列现象归入「标准保证」「实现定义」「未指定」「未定义」四类,每项写一句理由。
char默认按有符号还是无符号处理。unsigned对象的取值超过UINT_MAX(<limits.h>)后回绕到 0。int a[5]之后读取a[7]。int s = f() + g();中f与g两个调用的先后。printf("%d %d\n", i++, i++);的输出。struct { char c; int x; }的sizeof。
第 1、6 项能不能通过编译选项或文档把前提固定下来?第 3、5 项无论怎么编译为什么都不能依赖?
最后再回答一个问题:一段代码用了 __int128 与 GNU 的语句表达式,在 GCC 默认的 gnu17 模式下能编译,改用严格模式就报错。按第 3 节的定义,它属于遵从程序还是严格遵从程序?换一个编译器它还能不能编译,取决于哪一件事?
写一个 impl_probe.c,在程序里定义 enum color { RED, GREEN, BLUE };,输出下面五项在你所用平台上的取值:
#include <limits.h>
#include <stdio.h>
enum color { RED, GREEN, BLUE };
int main(void) {
printf("CHAR_MIN = %d, CHAR_MAX = %d\n", CHAR_MIN, CHAR_MAX);
printf("(int)(char)0xFF = %d\n", (int)(char)0xFF);
printf("-8 >> 1 = %d\n", -8 >> 1);
printf("sizeof(enum color) = %d\n", (int)sizeof(enum color));
printf("sizeof(long) = %d\n", (int)sizeof(long));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
用 gcc -std=c17 -Wall -Wextra 编译运行,把结果记成一张表。再分别加上 -funsigned-char、-fshort-enums 各编译一次,标出发生变化的行。前者让 char 按无符号处理,后者让枚举使用能容纳其全部取值的最小整数类型。
然后把程序依赖的两条前提写成 _Static_assert(C11,见 15.1.3),例如把「char 有符号」写成 _Static_assert(CHAR_MIN < 0, "char 必须有符号");。用同样的三组选项各编译一次,确认前提不成立时编译器直接报 static assertion failed。
验收:三组输出齐全;两条断言在默认选项下通过,在对应选项下失败;能说明这两条断言把哪一种行为从运行时才暴露的问题提前到了编译期。
下面的程序用两个带输出的函数观察求值次序:
#include <stdio.h>
static int f(void) {
printf("f ");
return 1;
}
static int g(void) {
printf("g ");
return 2;
}
int main(void) {
int s = f() + g();
printf("| s = %d\n", s);
printf("%d %d\n", f(), g());
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- 编译运行,记录输出。
f() + g()的两个操作数之间、printf的两个实参之间,标准有没有规定先后? - 实际观察到的两处次序是否相同?说明这种差异属于哪一类行为,以及为什么它不是未定义行为。
- 在
return 0;之前加上int i = 0; printf("%d\n", i++ + i);再编译。-Wall报了什么?这一次为什么是未定义行为而不是未指定行为?给出不依赖求值次序的改写。
验收:贴出运行的完整输出;第 3 问的改写结果不依赖求值次序,且编译时没有 -Wsequence-point 警告。
下面是一个做小样本检查的 report.c。它的输出取决于编译选项与实现,其中一处甚至可能跑不完。先用 gcc -std=c17 -Wall -Wextra 编译运行一次,再加上 -funsigned-char 试一次(跑不完就终止它,不必等)。
#include <limits.h>
#include <stdio.h>
#include <string.h>
struct sample {
char tag;
int value;
};
static long count_bytes(const char *path) {
FILE *f = fopen(path, "rb");
if (f == NULL) {
return -1;
}
long n = 0;
char c;
while ((c = getc(f)) != EOF) {
n++;
}
fclose(f);
return n;
}
static unsigned long checksum(const unsigned char *p, size_t n) {
unsigned long h = 0;
for (size_t i = 0; i < n; i++) {
h = h * 31u + (char)p[i];
}
return h;
}
static int add_would_overflow(int a, int b) {
return a + b < a;
}
static int same(const struct sample *a, const struct sample *b) {
return memcmp(a, b, sizeof *a) == 0;
}
int main(void) {
const unsigned char data[] = {0x41, 0xFF, 0x42, 0x50};
FILE *out = fopen("sample.bin", "wb");
if (out == NULL) {
return 1;
}
fwrite(data, 1, sizeof data, out);
fclose(out);
struct sample a = {'x', 7};
struct sample b = {'x', 7};
printf("count = %ld\n", count_bytes("sample.bin"));
printf("checksum = %lu\n", checksum(data, sizeof data));
printf("overflow = %d\n", add_would_overflow(INT_MAX, 1));
printf("same = %d\n", same(&a, &b));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
- 四处问题分别在
count_bytes、checksum、add_would_overflow、same里。逐处指出它属于哪一类行为,以及换用别的实现或编译选项后可能表现成什么样。 - 修正这四处,要求下面两种构建的行为一致:
-funsigned-char缺省与显式打开。修好后应稳定输出count = 4、checksum = 2183596、overflow = 1、same = 1。 - 说明每处修改消除了哪一种依赖。
same一处的修改与 16.2 讲的填充字节有关,写清memcmp为什么会把填充字节读进来,以及为什么同样的两个结构体在不同实现上可能得出不同的比较结果。 - 修改后的程序是不是严格遵从程序?对照第 3 节的定义,逐条说明它还在哪些地方依赖实现,或者为什么已经不再依赖。
验收:两种构建的输出完全一致;gcc -std=c17 -Wall -Wextra -pedantic-errors 编译无诊断;第 4 问的结论有具体依据,不只是一句「是」或「不是」。