这是我在2015年初写的草稿,且从未考虑过发布。这是一个未经雕琢的版本,因为没有任何人对这个草稿提供改进。最简单的变化只是将发布时间从2015年改成2016年。
如果有缺陷、改进和抱怨,请随时联系。-Matt
Adrián Arroyo Calle 在 ¿Cómo programar en C (en 2016)? 提供了西班牙语翻译。
Keith Thompson 在 howto-c-response 提供了一些勘误和替代性意见
下面是正文
使用C语言的首要规则是,能不用就不用。
如果必须要用C语言,应该遵照现代的规则。
自 70年代初 ,C语言已经存在。人们在C不同的发展时间点上“学会了C语言”,但是知识一般在学习后就停滞了,因此每个人都有自己对C语言的理解,这些理解基于他们第一次学习的时间。
尤其需要注意的是,不要将对C语言开发的知识停滞在“80、90年代学到的知识”。
本文假设我们是在一个现代化的平台、符合现代标准,且没有过多的历史遗漏需求。我们不该只是因为一些公司拒绝升级20年前的老系统而仍然依赖古老的标准。
c99标准(c99表示“1999年制定的标准”;c11表示“2011年制定的标准”,因此 11 > 99)
GNU C11模式
);如果需要使用c99标准,使用 -std=c99
。 -std=c11
;如果需要使用标准的c99版本,使用 -std=c99
。 -std=c99
或 -std=c11
GNU c11模式
(和clang相同),但是如果需要标准的c11或者c99标准,仍然需要指定 -std=c11
和 -std=c99
。 优化
-O2
优化级别,但是有的时候我们希望使用 -O3
优化级别。可以在这两种优化级别(和跨编译器)下的测试之后,保留最佳性能的二进制代码。 -Os
优化级别能够帮助提高缓存性能(它应该是) 警告
-Wall -Wextra -pedantic
-Wpedantic
开关,但是为了向下兼容,它们仍然支持古老的 -pedantic
开关。 -Werror
和 -Wshadow
开关 -Wstrict-overflow -fno-strict-aliasing
-fno-strict-aliasing
开关或者确保只使用对象创建时的类型来访问对象。由于大量已经存在的C代码使用了跨类型别名,如果我们无法控制源码树,使用 -fno-strict-aliasing
开关会更加安全。 -Wno-missing-field-initializers
开关 构建
make -j
来改善) -flto
开关开启LTO。 LTO
的使用需要一些注意事项。有的时候,如果项目代码不是直接使用而是作为库使用,LTO会在最终链接结果中移除一些函数和代码,因为LTO会在链接时全局检测未使用/不可达或者 不需要 的代码。 架构
-march=native
-msse2
和 -msse4.2
对于需要使用非构建机器构建目标文件可能会有用。 如果我们在新代码中还在使用 char
、 int
、 short
、 long
或者 unsigned
类型,那我们可能做错了。
对于现代程序,我们应该引入 #include <stdint.h>
,然后使用 标准 类型。
更多细节,参见 stdint.h规范 。
常见的标准类型:
int8_t
、 int16_t
、 int32_t
、 int64_t
——有符号整数 uint8_t
、 uint16_t
、 uint32_t
、 uint64_t
——无符号整数 float
——标准32位浮点数 double
——标准64位浮点数 注意,我们不再有 char
类型。 char
类型在C语言中实际上是名不符实且滥用的。
开发者经常滥用 char
来表示“字节”,甚至当他们是在操作无符号字节类型的时候。因此,使用 uint8_t
类型来表示无符号字节(八位组值),使用 uint8_t *
类型来表示无符号字节序列(八位组值)会更加清晰。
除了像 uint16_t
和 int32_t
这样标准的固定宽度类型之外,标准还在 stdint.h规范 中定义了 快速类型 和 最小类型
int_fast8_t
、 int_fast16_t
、 int_fast32_t
、 int_fast64_t
uint_fast8_t
、 uint_fast16_t
、 uint_fast32_t
、 uint_fast64_t
快速类型提供了最小 X
位,但是它实际存储大小是不确定的。如果在目标平台上更大的类型有更好的性能, 快速类型 将自动使用这个较大的类型。
例如在一些64位系统中,当我们在使用 uint_fast16_t
类型时,实际上会使用 uint64_t
类型,因为处理和字宽相同的整数速度会比处理16位整数快很多。
不过,不是每个系统都遵照 快速类型 指引。其中一个就是OS X系统,其 快速类型 宽度 和它们对应的固定宽度类型宽度完全相同 。
快速类型对于编写自描述代码也非常有用。如果我们的计数器只需要16位,但是因为平台计算64位整数速度会更快,我们更希望直接使用64位整数进行运算,这时 uint_fast16_t
类型就非常有用。在64位Linux平台上, uint_fast16_t
类型实际使用64位计数器,而从代码层面来看,“这里只需要一个16位的变量”。
使用快速类型时,有一点需要注意:它可能会影响测试用例。如果用例需要测试变量的存储位宽,使用 uint_fast16_t
类型,在一些平台上可能是16位(如OS X)而在另一些平台上是64位(如Linux),这时可能会导致测试用例失败。
快速类型 和 int
类型一样,在不同平台上有不确定的长度,但是使用 快速类型 ,可以将这些不确定长度限制在代码中的安全位置(如计数器、有边界检测的临时变量等)。
int_least8_t
、 int_least16_t
、 int_least32_t
、 int_least64_t
uint_least8_t
、 uint_least16_t
、 uint_least32_t
、 uint_least64_t
最小类型提供满足对应类型最 紧凑 的字节数。
在实践中, 最小类型 规范通常是定义的标准固定宽度类型,因为标准固定宽度类型已经提供了对应类型需要的最小字节数。
int
类型 一些读者指出他们对 int
类型是真爱,至死方休。我想指出,如果使用长度不可控的变量类型,技术上 不可能 正确的开发应用。
RATIONALE 提供了 inttypes.h 头文件,就为了解决使用非固定位宽类型不安全问题。如果开发者能够理解 int
类型在一些平台上是16位,在另一些平台上是32位,同时在代码中任何使用 int
类型的地方都对16位和32位两种位宽边界进行了测试,那么请放心使用 int
类型。
对于其他无法在写代码的时候记得多层次决策树平台规范结构的人来说,我们可以使用固定宽度类型,这能够在写出更加正确代码的同时,减少概念上的困扰和测试成本。
或者,规范中更简明的说到:“ISO C标准整数提升规则可能会意外产生未知的变化”。
祝你好运。
char
类型的特殊场景 在2016年, 唯一 能够使用 char
类型的场景是已经存在的API需要 char
类型(例如 strncat
、printf函数中的“%s”占位符等)或者在初始化只读字符串(例如 const char *hello = "hello";
),因为字符串( "hello"
)的C类型是 char []
。
另外:在C11中增加了本地unicode支持,对于像 const char *abcgrr = u8"abc";
多字节UTF-8字符串
类型仍然是 char []
。
int
、 long
等类型的特殊场景 如果函数使用这些类型作为其返回值或者参数,请使用函数原型或者API文档中说明的类型。
在任何时候,都不应该在代码中输入 unsigned
字符。现在我们可以避免在代码中使用c语言中丑陋的多词组合类型,它既影响可读性,也影响使用。当能够输入 uint64_t
的时候,谁还希望输入 unsigned long long int
。 <stdint.h>
头文件中定义的类型更加 明确 ,含义更加 确切 ,传达的 意图 更好,使得排版的 使用 和 可读性 更加 紧凑 。
但是,你可能会说:“我需要在进行指针运算的时候将指针类型转换成 long
类型。”
你可以这样说,但是这是错误的。
对于指针运算的正确方式是使用 <stdint.h>
头文件中定义的 uintptr_t
类型,同时也可以使用 stddef.h 头文件中定义的 ptrdiff_t
类型。
不要使用:
long diff = (long)ptrOld - (long)ptrNew;
而使用:
ptrdiff_t diff = (uintptr_t)ptrOld - (uintptr_t)ptrNew;
同时,如果需要输出内容:
printf("%p is unaligned by %" PRIuPTR " bytes./n", (void *)p, ((uintptr_t)somePtr & (sizeof(void *) - 1)));
如果继续争论,“在32位平台上我需要32位long类型,而在64位平台上我需要64位long类型”。
如果我们跳出思维定势,不是为了在不同平台上使用两种不同大小的类型而在代码中 故意 引入难题,我们仍然不会为了系统相关类型而试图使用 long
类型。
在这种情况下,我们应该使用 intptr_t
类型——定义了当前平台上字长的整数。
在32位平台上, intptr_t
是 int32_t
类型。
在64位平台上, intptr_t
是 int64_t
类型。
同时, intptr_t
还有对应的无符号类型 uintptr_t
。
对于指针偏移量,我们有一个更加恰当的类型: ptrdiff_t
,它是存储指针差值的正确类型。
我们需要一个整数类型,能够持有系统中任何整数吗?
在这种情况下,人们倾向于使用已知类型中最大的类型,例如将较小的无符号类型转换成64位无符号类型 uint64_t
,但是,还有技术上更正确的方式来确保一个值可以持有任何其他值。
对于任何整数最安全的容器是 intmax_t
类型(还有 uintmax_t
)。我们可以在不损失精度的情况下,将任意有符号整数赋值或转换成 intmax_t
类型,同样,也可以在不损失精度的情况系,将任意无符号整数赋值或转换成 uintmax_t
类型。
系统相关类型中,使用最广的类型是 size_t
,它由 stddef.h 头文件提供。
size_t
表示“能够持有最大数组索引的整数”,同时它也表示应用程序中变量持有最大内存偏移量的能力。
在实际使用中, size_t
类型是 sizeof
操作符的返回类型。
在任一情况下: size_t
类型在所有现代平台上 事实上 定义为 uintptr_t
类型,即在32位平台上, size_t
类型是 uint32_t
,而在64位平台上 size_t
类型是 uint64_t
。
除此以外还有 ssize_t
类型,它用来表示有符号好的 size_t
类型,用于库函数的返回值。这些函数通常会在出错时返回 -1
。(注意: ssize_t
是POSIX规范中定义,因此不适用于Windows平台接口。)
综上所述,我们应该在自己的函数参数中使用 size_t
类型表示任何变量长度和系统相关的类型吗?从技术上来说, size_t
类型是 sizeof
操作符的返回值,因此任何接受代表字节数量参数的函数,都可以使用 size_t
类型。
size_t
类型的其他使用包括: size_t
类型作为malloc函数的参数; ssize_t
类型作为 read()
和 write()
函数的返回值(除了Windows平台, ssize_t
不存在,这两个函数的返回值是 int
类型)。
在打印时,我们不应该进行类型转换,而应该使用 inttypes.h 头文件中定义的描述符。
这些描述符包括但不限于:
size_t
: %zu
ssize_t
: %zd
ptrdiff_t
: %td
%p
(在现代编译器中打印出16进制地址编码;使用前需要将指针转换成 (void *)
类型) PRIu64
(无符号)和 PRId64
(有符号) long
类型,其他使用 long long
类型 intptr_t
: "%" PRIdPTR
uintptr_t
: "%" PRIuPTR
intmax_t
: "%" PRIdMAX
uintmax_t
: "%" PRIuMAX
PRI*
相关的格式化描述符需要注意:它们是 宏 ,这些宏会展开成特定平台上正确的printf描述符。因此,不能这样使用:
printf("Local number: %PRIdPTR/n/n", someIntPtr);
而因为它们是宏,应该这样使用:
printf("Local number: %" PRIdPTR "/n/n", someIntPtr);
注意,需要将 %
写在格式化字符串 内部 ,而类型描述符写在格式化字符串 外面 ,这样所有相邻字符串会被预处理器连接成最终的字符串。
因此,不要这样写:
void test(uint8_t input) { uint32_t b; if (input > 3) { return; } b = input; }
而应该这样写:
void test(uint8_t input) { if (input > 3) { return; } uint32_t b = input; }
警告:如果代码中有紧密的循环,请检查变量初始化的位置。有时疏散的定义可能会引发意外的性能问题。对于常规非快速路径代码(这是大部分情形),变量定义最好能够尽可能清晰,将类型定义写在初始化语句附近可以大大提高可读性。
for
循环中定义计数器 因此,不要这样写:
uint32_t i; for (i = 0; i < 10; i++)
而应该这样写:
for (uint32_t i = 0; i < 10; i++)
一个例外:如果在循环完成后还需要复用计数器,显然不能将计数器定义在循环作用域内。
#pragma once
因此,不要这样写:
#ifndef PROJECT_HEADERNAME #define PROJECT_HEADERNAME . . . #endif /* PROJECT_HEADERNAME */
而应该这样写:
#pragma once
#pragma once
告诉编译器只引入头文件一次,我们再也 不 需要在头文件中用三行预处理指令来确保。pragma预处理指令已经被几乎所有平台上的所有编译器支持,因此更加推崇。
更多详情,参见 pragma once 的编译器支持列表。
因此,不要这些写:
uint32_t numbers[64]; memset(numbers, 0, sizeof(numbers));
而应该这样写:
uint32_t numbers[64] = {0};
因此,不要这样写:
struct thing { uint64_t index; uint32_t counter; }; struct thing localThing; void initThing(void) { memset(&localThing, 0, sizeof(localThing)); }
而应该这样写:
struct thing { uint64_t index; uint32_t counter; }; struct thing localThing = {0};
重要提示:如果结构体中有填充,使用 {0}
方式初始化无法将多余的填充字节置为0。例如, struct thing
结构体在 counter
字段之后有4字节填充(64位平台上),因为结构体需要按照字长对齐。这种情况下如果需要将整个结构体( 包括 未使用的填充字节)置为0,可以使用 memset(&localThing, 0, sizeof(localThing))
因为 sizeof(localThing) == 16 字节
,即使可寻址内容只有 8 + 4 = 12 字节
。
如果需要重新初始化已经分配内存空间的结构体,可以定义一个全局空结构体,然后赋值:
struct thing { uint64_t index; uint32_t counter; }; static const struct thing localThingNull = {0}; . . . struct thing localThing = {.counter = 3}; . . . localThing = localThingNull;
如果我们幸运的使用C99(或更新)版本的环境,我们可以使用复合字面量(compound literals),以取代保存一个全局空结构体。(参见 The New C: Compound Literals )
复合字面量允许直接将匿名结构体赋值给变量:
localThing = (struct thing){0};
因此,不要这样写:
uintmax_t arrayLength = strtoumax(argv[1], NULL, 10); void *array[]; array = malloc(sizeof(*array) * arrayLength); /* 记得当使用完数组后,释放其内存 */
而应该这样写:
uintmax_t arrayLength = strtoumax(argv[1], NULL, 10); void *array[arrayLength]; /* 不需要释放数组内存 */
重要警告:可变长数组(通常)和普通数组一样分配在栈上。如果我们不需要很多元素的数组,不要尝试通过这种语法创建大数组。它不是python/ruby中的自增长列表。如果定义了一个数组的长度,相对于栈空间比较大,应用程序将会发生可怕的事情(崩溃、安全问题)。可变长数组适用于长度小的一般用途场景,而不应该大规模用于生产软件。如果有时需要使用3个元素的数组,而其他时候需要300万个元素的数组,绝对不要使用可变长数组。
可变长数组语法还需要注意检查其可访问(或者做快速一次行检查),但还是需要考虑 危险的反模式 ,因为简单的忘记检查数组元素边界或者目标平台上没有足够的栈空间,都可能导致应用程序崩溃。
注意:使用可变长数组时,必须确保数组的确切长度在一个合理的大小。(例如小于几KB,有时在一些平台上,最大栈大小只有4KB。)我们不能在栈上分配 巨大的 数组(百万级),但是如果是有的大小,使用 C99可变长数组 相比于人工在堆上请求内存会更加方便。
另:上面示例代码中没有输入检查,因此用户可以通过分配一个巨大的可变长数组而让应用程序崩溃。到目前为止, 一些人 称可变长数组为反模式,但是如果我们能够加强边界检查,在一些场景下可能会有优势。
参见 restrict关键字说明 (通常该关键字为 __restrict
)。
如果一个函数接受 任意 输入类型和一个数据长度,不要限制这个参数的类型。
因此,不要这样写:
void processAddBytesOverflow(uint8_t *bytes, uint32_t len) { for (uint32_t i = 0; i < len; i++) { bytes[0] += bytes[i]; } }
而应该这样写:
void processAddBytesOverflow(void *input, uint32_t len) { uint8_t *bytes = input; for (uint32_t i = 0; i < len; i++) { bytes[0] += bytes[i]; } }
函数的入参用于描述代码的 接口 行为,而不是代码中是如何处理这些参数的。上面示例代码中的接口表示“接受一个字节数组及其长度”,因此无需限制调用者仅能传入uint8_t字节流。可能调用者甚至想传入老式的 char *
类型或者其他未预期的值。
通过将入参定义为 void *
,然后在函数内部重新赋值或者类型转换成实际类型,可以减少函数调用者对函数 内部 抽象的猜测。
一些读者指出示例可能会存在对齐问题,但是我们是在访问入参中的每个字节元素,因此不会有问题。如果我们需要将入参转换成更宽的类型,就需要注意对齐问题。对于处理跨平台对齐问题,参见 未对齐的内存访问 。(提醒:这个网页的主要内容不是关于C语言跨硬件架构的复杂性,因此完全理解其中的示例需要一些外部的知识和经验。)
C99提供了 <stdbool.h>
头文件,其中定义了 true
为 1
, false
为 0
。
对于标识成功/失败的返回值类型,函数应该返回 true
或者 false
,而不是一个 int32_t
的数值来人为指定 1
和 0
(或者更糟糕的使用 1
和 -1
),调用者很难确认 0
代表成功还是失败。
如果函数会修改入参,且可能修改成无效值,不要返回修改后的指针,而应该将API中可能会被修改成无效的参数都改成指针的指针。将接口定义为”对于一些调用,返回值会使得入参无效“在大规模使用的时候很容易出错。
因此,不要这样写:
void *growthOptional(void *grow, size_t currentLen, size_t newLen) { if (newLen > currentLen) { void *newGrow = realloc(grow, newLen); if (newGrow) { /* resize success */ grow = newGrow; } else { /* resize failed, free existing and signal failure through NULL */ free(grow); grow = NULL; } } return grow; }
而应该这样写:
/* 返回值: * - 如果newLen大于currentLen,且尝试调整内存,返回‘true’ * - ’true‘不表示内存扩大成功,仍然需要通过‘*_grow’的值来判断是否成功 * - 如果newLen小于等于currentLen返回‘false’*/ bool growthOptional(void **_grow, size_t currentLen, size_t newLen) { void *grow = *_grow; if (newLen > currentLen) { void *newGrow = realloc(grow, newLen); if (newGrow) { /* 调整大小成功 */ *_grow = newGrow; return true; } /* 调整大小失败 */ free(grow); *_grow = NULL; /* 对于这个函数,返回‘true’不代表成功, * 它只表示‘尝试扩展’ */ return true; } return false; }
或者,更好的可以这样写:
typedef enum growthResult { GROWTH_RESULT_SUCCESS = 1, GROWTH_RESULT_FAILURE_GROW_NOT_NECESSARY, GROWTH_RESULT_FAILURE_ALLOCATION_FAILED } growthResult; growthResult growthOptional(void **_grow, size_t currentLen, size_t newLen) { void *grow = *_grow; if (newLen > currentLen) { void *newGrow = realloc(grow, newLen); if (newGrow) { /* 调整大小成功 */ *_grow = newGrow; return GROWTH_RESULT_SUCCESS; } /* 调整大小失败,无需移除数据,因为我们已经能够提示失败 */ return GROWTH_RESULT_FAILURE_ALLOCATION_FAILED; } return GROWTH_RESULT_FAILURE_GROW_NOT_NECESSARY; }
编码风格既非常重要又一文不值。
如果你的项目有50页的编码风格指南,没有人会帮助你。但是,如果你的代码可读性非常差,没有人会 希望 帮助你。
这个问题的解决方案是 总是 使用自动化代码格式化工具。
2016年唯一能够能使用的C代码格式化工具是 clang-format 。clang-format拥有格式化C代码的最佳默认值,并且仍然处于活跃开发阶段。
下面示例是我运行clang-format时的首选脚本,它包含一些不错的参数:
#!/usr/bin/env bash clang-format -style="{BasedOnStyle: llvm, IndentWidth: 4, AllowShortFunctionsOnASingleLine: None, KeepEmptyLinesAtTheStartOfBlocks: false}" "$@"
然后调用这个脚本(假设这个脚本被命令成 cleanup-format
):
matt@foo:~/repos/badcode% cleanup-format -i *.{c,h,cc,cpp,hpp,cxx}
-i
参数会将格式化之后的内容覆盖到原文件中,而不是写入新文件或者创建备份文件。
如果有很多文件,可以并行递归处理整个源码树:
#!/usr/bin/env bash # 注意:clang-tidy命令一次只能接受一个文件,但是我们可以在不相交集合中并行执行。 find . /( -name /*.c -or -name /*.cpp -or -name /*.cc /) |xargs -n1 -P4 cleanup-tidy # clang-format命令一次运行能够接受多个文件,但是为了防止内存的过度使用, # 我们限制位为一次最多12个文件。 find . /( -name /*.c -or -name /*.cpp -or -name /*.cc -or -name /*.h /) |xargs -n12 -P4 cleanup-format -i
现在, cleanup-tidy
脚本修改后的内容为:
#!/usr/bin/env bash clang-tidy / -fix / -fix-errors / -header-filter=.* / --checks=readability-braces-around-statements,misc-macro-parentheses / $1 / -- -I.
clang-tidy 是策略驱动的代码重构工具。上面示例中的参数开启了两个修正:
readability-braces-around-statements
:强制所有的 if
、 while
、 for
语句体都使用大括号括起来 misc-macro-parentheses
:自动为宏的所有参数加上括号 clang-tidy
命令在它正常工作时非常优秀,但是对于一些复杂的代码可能会卡壳。还有, clang-tidy
命令不会对代码进行 格式化 ,因此在整理完成之后,还需要运行 clang-format
命令来对齐新的大括号和重新推导宏。
写作似乎从这里开始减慢了……
代码逻辑应该自包含在代码文件中。
尽可能将源码行限制在1000行以内(1500行已经是非常糟糕的情况了)。如果测试代码也包含在源码中(为了测试静态函数等情况),尽可能调整这种结构。
malloc
我们应该总是使用 calloc
。获取清零的内存没有性能损失。如果不喜欢 calloc(object count, size per object)
的函数原型,我们可以将其包装成 #define mycalloc(N) calloc(1, N)
。
对此读者进行了一些评论:
calloc
在 巨大 内存申请的场景下, 的确 会有性能影响 calloc
在一些奇怪的平台上(最小嵌入式系统、游戏主机、30年前的旧硬件等) 的确 会有性能影响 calloc(element count, size of each element)
原型进行包装不总是一个好主意 malloc()
的很大一个因素是其不进行整数溢出检查,这是一个潜在的安全风险 calloc
函数分配内存可以避免valgrind对于未初始化内存潜在读写的警告,因为它会在分配内存时自动初始化为 0
以上是使用 calloc
的优势,同时我们还需要进行性能测试和回归测试,以确定跨编译器、平台、操作系统和硬件设备上的性能。
直接使用 calloc()
而非其包装的优势是,不同于 malloc()
, calloc()
函数能够检查整数溢出,因为它会将其参数做乘法,以确认实际分配的大小。如果直至分配很小的内存,对 calloc()
进行包装没有问题。如果需要分配潜在无边界数据流,可能需要使用 calloc(element count, size of each element)
的原型以方便使用。
没有建议是可以普适的,试图给出 准确完美 的通用建议,最终会变琛阅读一本类似语言规范的书。
对于 calloc()
如何无损耗提供干净的内存,参见这些文章:
我还是坚持我的立场,建议在2016年的大部分场景下(假定:x64目标平台,一般大小的数据,不包括人体基因数量级的数据)总是使用 calloc()
函数。任何和“期望”的偏离,会将我们拖入“领域知识”,这不是我们今天谈论的范围。
注:通过调用 calloc()
申请到的预先清零内存是一次性的。如果使用 realloc()
函数来扩展 calloc()
函数分配的内存,是 没有 清零的内存。扩展的内存仍然会被内核提供常规未初始化内容填充。如果需要在调用realloc之后将内存置零,必须针对扩展的内存手工调用 memset()
函数。
当可以静态初始化结构(或数组)为零(或者通过内联复合字面量赋值为零,或者通过赋值为预先置零的全局变量)时,绝不要使用 memset(ptr, 0, len)
。
不过,如果需要将结构体填充字节置零, memset()
是唯一选择。(因为 {0}
语法只能设置定义的字段,而无法填充未定义的填充字节。)
参见 固定宽度整数类型(从C99)
参见苹果公司的 让64位代码更加清晰
参见 跨架构C类型大小 ——除非我们能够记住整个表格中的每一行并应用到代码的每一行,我们都应该使用明确定义宽度的整数,绝不使用char/short/int/long这些内置存储类型。
参见 size_t和ptrdiff_t
参见 安全编码 。如果我们希望写出完美的代码,只需记住其中的上千个简单示例。
参见来自Inria(法国国家信息与自动化研究所)的Jens Gustedt编写的 现代C语言 。
对于C11对Unicode支持的细节,参见 理解C/C++中的字符/字符串字面量
大规模的编写正确的代码基本上是不可能的。我们有多种操作系统、运行时环境、程序库和硬件平台需要考虑,更不用说小概率的内存随机位反转、块设备故障等。
我们能做最好的是编写简单易懂的代码,尽可能减少间接代码和未注释的魔术代码。
-Matt — @mattsta — ☁mattsta
本文在twitter和Hacker News上都有讨论,因此需要读者都有帮忙,指出瑕疵或有偏见的想法,我在此公布一下。
首先,Jeremy Faller、 Sos Sosowski 、Martin Heistermann和其他一些读者很友好的指出文中 memset()
示例的问题,并且给予修正。
Martin Heistermann同时指出 localThing = localThingNull
示例也存在问题。
文章开头关于C语言能不用就不用的引用,来自聪明的互联网智者 @badboy_ 。
Remi Gacogne 指出我忘了 -Wextra
参数。
Levi Pearson 指出gcc-5默认使用gnu11标准,而不是c89标准,同时澄清了clang的默认标准。
Christopher 指出 -O2
和 -O3
对比章节应该更加清晰。
Chad Miller 指出我在处理clang-format脚本参数时偷懒了。
许多 读者指出关于 calloc()
的建议不 总是 好主意,如果使用场景是极端情况下或者非标准硬件(是坏主意的示例:大内存分配、嵌入式设备上的内存分配、在30年前的老硬件上内存分配等)。
Charles Randolph指出“Building(构建)”这个词有拼写错误。
Sven Neuhaus友善的指出我也没有拼写“initialization(初始化)”和“initializers(初始设置)”的能力。(并且也指出我在这里第一次也拼错了“initialization”这个单词)
Colm MacCárthaigh 指出我忘记提及 #pragma once
。
Jeffrey Yasskin 指出我们也应该禁止严格别名(主要针对gcc的优化)。
Jeffery Yasskin同时为 -fno-strict-aliasing
章节提供了更好的措辞。
Chris Palmer 和其他一些读者指出calloc相比于malloc参数上的优势,编写一个 calloc()
的包装的整体缺点,因为 calloc()
相比于 malloc()
提供了更加安全的接口。
Damien Sorresso指出我们应该提醒读者针对 calloc()
请求获取的初始化置零内存调用 realloc()
不会将增长的内存置零。
Pat Pogson指出我也没有正确拼写“declare(定义)”单词的能力。
@TopShibe 指出栈分配初始化示例是错误的,因为我提供的这个示例是全局变量。将措辞修改成了“自动分配内存”,以表示栈或数据段。
Jonathan Grynspan 建议在变长数组(VLA)示例前后增加更严厉的措辞,因为变长数组误用时 是 危险的。
David O'Mahony友善的指出“specify(指定)”单词拼写错误。
David Alan Gilbert博士指出 ssize_t
是POSIX行为,Windows平台要么没有定义,要么被定义成 无符号 类型,这明显会引入各种有趣的行为,因为该类型在POSIX平台是有符号类型,而Windows平台上是无符号的。
Chris Ridd建议我们明确说明C99是1999年以后定义的,而C11是2011年定义的,否则说11比99新看起来很奇怪。
Chris Ridd同时注意到 clang-format
示例使用了不清晰的命名约定,并且建议跨示例的命名一致性。
Anthony Le Goff 指出有一份名为 Modern C 的文档提供了书籍长度的现代C思想。
Stuart Popejoy指出我对“deliberately(故意)”单词的拼写错误是真的拼写错误。
Jack Rosen指出我使用了“exists(存在)”但是实际想表示的是“exits(退出)”。
Jo Booth指出我将“compatibility(兼容性)”拼成了“compatability”,看上去更加有逻辑,但是英国公民(English commonality)不同意。
Stephen Anderson将我拼错的“stil”改正成“still”。
Richard Weinberger指出使用 {0}
语法初始化结构体,不会将填充字节置零,因此将 {0}
结构体通过网络传输在特定结构体下可能会无意泄漏一些字节。
@JayBhukhanwala 指出 返回值类型 章节中的函数注释不准确,因为当代码变化时,没有更新注释(很像我们生活中的故事吧?)
Lorenzo指出在 参数类型 章节中,我们应该对潜在的跨平台对齐问题提供明确的警告。
Paolo G. Giarrusso 重新明确了我之前为示例添加的对齐警告,并给予了更加正确的提示。
Fabian Klötzl提供了有效的结构体复合字面量赋值示例,它完美的语法,我之前没有遇见过。
Omkar Ekbote提供了拼写错误和一致性问题的全面走查,包括“platform(平台)”、“actually(事实上)”、“defining(定义)”、“experience(经验)”、“simultaneously(同时)”、“readability(可读性)”等,同时标注了其他一些含糊的措辞。
Carlo Bellettini修正了我对“aberrant(错误的)”单词错误的拼写。
Keith S Thompson 在其巨著 C如何回应(How to C Response) 中提供了很多技术更正。
Marc Bevand指出我们应该谈谈 inttypes.h 头文件中提供的 fprintf
类型描述符。
reddit上很多读者被激怒,因为本文最初被一些地方误“引用”。对不起,疯狂的读者,但是本文刚开始被公开时是一篇几年前未经编辑、未经审核的旧草稿。这些错误已经修正。
一些读者同时指出静态初始化示例使用全局变量,这样可以在让变量总是初始化成零(事实上它们都没有初始化,只是被静态分配了内存)。在我看来这个示例是一个糟糕的选择,但是想表达的概念仍然代表在函数作用域内典型的用法。这个示例是表示一些通用的“代码片段”,而不代表必须使用全局变量。
一些读者将本文理解成“我讨厌C语言”,其实并不是这样。C语言用在错误的地方(没有足够测试、大规模部署的时候没有足够的经验等)是危险的,因此自相矛盾的两类C语言开发者应该只有新手爱好者(代码出现问题没有关系,它只是一个玩具)和希望测试到死的人(代码出现问题会引起生命和财产损失,它不是一个玩具),他们应该在生产环境使用C语言编写代码。对于“普通观察者进行C语言开发”的使用空间很小。对于其他开发者,这就是为什么我们有Erlang语言。
许多读者还提到他们自己的疑问或者超出本文范围的问题(包括新的C11标准的特性,例如 George Makrydakis 提醒我们关于C11的属性推导能力)。
或许另一篇关于“C语言实践”的文章将会覆盖测试、性能调优、性能追踪、可选但是有用的警告级别等。
感谢魏星对本文的审校。
给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ,@丁晓昀),微信(微信号: InfoQChina )关注我们。