最新发布《数智时代的AI人才粮仓模型解读白皮书(2024版)》,立即领取! 了解详情
写点什么

C 语言的 2016

  • 2016-03-31
  • 本文字数:13490 字

    阅读完需:约 44 分钟

这是我在 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)

  • clang,默认
    • clang 默认使用扩展的 c11 版本(GNU C11 模式);如果需要使用 c99 标准,使用-std=c99
    • 如果需要使用标准 c11 版本,需要指定-std=c11;如果需要使用标准的 c99 版本,使用-std=c99
    • clang 编译源码速度快于 gcc
  • gcc,需要用户指定-std=c99-std=c11
    • gcc 构建源码文件慢于 clang,但是 _ 有的时候 _ 会生成更快的代码。性能和回归测试都是非常重要的。
    • gcc-5 默认使用GNU c11 模式(和 clang 相同),但是如果需要标准的 c11 或者 c99 标准,仍然需要指定-std=c11-std=c99

优化

  • -O2,-O3
    • 通常我们需要使用-O2优化级别,但是有的时候我们希望使用-O3优化级别。可以在这两种优化级别(和跨编译器)下的测试之后,保留最佳性能的二进制代码。
  • -Os
    • -Os优化级别能够帮助提高缓存性能(它应该是)

警告

  • -Wall -Wextra -pedantic
    • 新版本编译器 提供了 -Wpedantic开关,但是为了向下兼容,它们仍然支持古老的-pedantic开关。
  • 在测试工程中,应该在全平台中添加-Werror-Wshadow开关
    • 在部署产品源码时,使用 -Werror 开关可能会非常棘手,因为不同的平台和编译器可能会发出不同的警告信息。我们可能不希望因为平台上的 GCC 版本有从未看见过的警告而中止用户的整个构建。
  • 其他花哨的选项-Wstrict-overflow -fno-strict-aliasing
    • 要么通过-fno-strict-aliasing开关或者确保只使用对象创建时的类型来访问对象。由于大量已经存在的 C 代码使用了跨类型别名,如果我们无法控制源码树,使用-fno-strict-aliasing开关会更加安全。
  • 截至目前,clang 会将一些有效语法作为警告,因此我们应该增加-Wno-missing-field-initializers开关
    • GCC 在 4.7.0 版本后修复了这个无效的警告

构建

  • 编译单元
    • 构建 C 项目的通常步骤是分解每个源文件到目标文件,然后将所有目标文件链接到一起。这个步骤对增量开发的非常有效,但这对性能和优化是次优的。这种方式下编译器无法检测跨文件边界的潜在优化。
  • LTO - 链接时优化
    • LTO 修复了“源码分析和优化无法跨编译单元的问题”,它通过在目标文件中增加中间标记的方式,使得源码感知的优化能够在链接时进行跨编译单元执行。(该过程会显著降低链接速度,但是能够通过make -j来改善)
    • clang LTO 指南
    • gcc LTO
    • 截至 2016 年,clang 和 gcc 都支持在目标文件编译和最终应用链接阶段,在命令行参数中增加-flto开关开启 LTO。
    • LTO的使用需要一些注意事项。有的时候,如果项目代码不是直接使用而是作为库使用,LTO 会在最终链接结果中移除一些函数和代码,因为 LTO 会在链接时全局检测未使用 / 不可达或者 _ 不需要 _ 的代码。

架构

  • -march=native
    • 授权编译器使用 CPU 的所有特性指令集
    • 同样,性能测试和回归测试(比较跨编译器或者编译器版本)非常重要,以确保启动了优化之后没有副作用。
  • -msse2-msse4.2对于需要使用非构建机器构建目标文件可能会有用。

编写代码

类型

如果我们在新代码中还在使用charintshortlong或者unsigned类型,那我们可能做错了。

对于现代程序,我们应该引入#include <stdint.h>,然后使用 _ 标准 _ 类型。

更多细节,参见 stdint.h 规范

常见的标准类型:

  • int8_tint16_tint32_tint64_t——有符号整数
  • uint8_tuint16_tuint32_tuint64_t——无符号整数
  • float——标准 32 位浮点数
  • double——标准 64 位浮点数

注意,我们不再有char类型。char类型在 C 语言中实际上是名不符实且滥用的。

开发者经常滥用char来表示“字节”,甚至当他们是在操作无符号字节类型的时候。因此,使用uint8_t类型来表示无符号字节(八位组值),使用uint8_t *类型来表示无符号字节序列(八位组值)会更加清晰。

特殊标准类型

除了像uint16_tint32_t这样标准的固定宽度类型之外,标准还在 stdint.h 规范中定义了 _ 快速类型 _ 和 _ 最小类型 _

快速类型有:

  • 有符号整数:int_fast8_tint_fast16_tint_fast32_tint_fast64_t
  • 无符号整数:uint_fast8_tuint_fast16_tuint_fast32_tuint_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_tint_least16_tint_least32_tint_least64_t
  • 无符号整数:uint_least8_tuint_least16_tuint_least32_tuint_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 []

使用intlong等类型的特殊场景

如果函数使用这些类型作为其返回值或者参数,请使用函数原型或者 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_tint32_t类型。

在 64 位平台上,intptr_tint64_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 *)类型)
  • 64 位类型在打印时需要使用PRIu64(无符号)和PRId64(有符号)
    • 在一些平台上 64 位值使用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);

注意,需要将%写在格式化字符串 _ 内部 _,而类型描述符写在格式化字符串 _ 外面 _,这样所有相邻字符串会被预处理器连接成最终的字符串。

C99 允许变量定义在任何地方

因此,不要这样写:

复制代码
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;
}

警告:如果代码中有紧密的循环,请检查变量初始化的位置。有时疏散的定义可能会引发意外的性能问题。对于常规非快速路径代码(这是大部分情形),变量定义最好能够尽可能清晰,将类型定义写在初始化语句附近可以大大提高可读性。

C99 允许在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 的编译器支持列表。

C 语言允许静态初始化自动分配数组

因此,不要这些写:

复制代码
uint32_t numbers[64];
memset(numbers, 0, sizeof(numbers));

而应该这样写:

复制代码
uint32_t numbers[64] = {0};

C 语言允许静态初始化自动分配结构体

因此,不要这样写:

复制代码
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};

C99 增加可变长数组支持(C11 将其设为可选)

因此,不要这样写:

复制代码
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 可变长数组相比于人工在堆上请求内存会更加方便。

另:上面示例代码中没有输入检查,因此用户可以通过分配一个巨大的可变长数组而让应用程序崩溃。到目前为止,一些人称可变长数组为反模式,但是如果我们能够加强边界检查,在一些场景下可能会有优势。

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>头文件,其中定义了true1false0

对于标识成功 / 失败的返回值类型,函数应该返回true或者false,而不是一个int32_t的数值来人为指定10(或者更糟糕的使用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:强制所有的ifwhilefor语句体都使用大括号括起来
    • C 语言中对于循环和条件后面的单行语句有”可选的大括号“是一个历史事故。在编写现代代码时,在循环和条件语句之后不使用大括号是 _ 不可原谅 _ 的行为。不要试图以”但是,编译器支持这样的写法!“为由来争辩,这对于代码的可读性、可维护性、易懂性没有任何好处。我们的代码不是用来取悦你的编译器,而是用来取悦将来维护代码的人,那时没有人记得当时为什么会存在这样的代码。
  • 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

当可以静态初始化结构(或数组)为零(或者通过内联复合字面量赋值为零,或者通过赋值为预先置零的全局变量)时,绝不要使用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 语言实践”的文章将会覆盖测试、性能调优、性能追踪、可选但是有用的警告级别等。

查看英文原文: How to C in 2016


感谢魏星对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

2016-03-31 17:468864

评论 1 条评论

发布
用户头像
感谢!
2021-07-12 13:45
回复
没有更多了
发现更多内容

CRMEB 知识付费模版消息修改教程

CRMEB

Java stream流用法总结

爱好编程进阶

Java 程序员 后端开发

SpringMVC快速入门(3)默认组件加载

爱好编程进阶

Java 程序员 后端开发

Tomcat与JDK版本对应关系,Tomcat各版本特性

爱好编程进阶

Java 程序员 后端开发

【并发编程】

爱好编程进阶

Java 程序员 后端开发

使用cobbler 安装工具批量安装服务器

爱好编程进阶

Java 程序员 后端开发

全靠这份阿里大厂Java面试真题手册,让我成功拿下12家大厂offer

爱好编程进阶

Java 程序员 后端开发

在Flutter中使用WillPopScope

坚果

5月月更

【C 语言】指针 Two 之[定义指针变量、有效声明指针、使用指针、指针变量初始化]

謓泽

5月月更

CDH5部署三部曲之二:部署和设置

爱好编程进阶

Java 程序员 后端开发

Java对内存空间的分配与回收机制

爱好编程进阶

Java 程序员 后端开发

Java市场饱和了?为什么都这么说?

爱好编程进阶

Java 程序员 后端开发

一个三线程序员的2020年,CSDN 10 万粉里程碑达成

爱好编程进阶

Java 程序员 后端开发

你真的知道Java同步锁何时释放?

爱好编程进阶

Java 程序员 后端开发

JSP标签

爱好编程进阶

Java 程序员 后端开发

基于SVN的CICD调研方案 _

ZuccRoger

5月月更

Java String类型和BigDecimal类型之间的转化及BigDecimal类型的介绍

爱好编程进阶

Java 程序员 后端开发

JAVA生成二维码图片和解析二维码图片内容代码实例

爱好编程进阶

Java 程序员 后端开发

《英雄联盟》首部成人动画全球爆火

爱好编程进阶

程序员 后端开发

一个${}引发的惨案

爱好编程进阶

Java 程序员 后端开发

关于扑克牌的一些讨论——《Fluent Python 2》读书笔记

codists

Python

Spring Boot MyBatis配置Druid多数据源

爱好编程进阶

Java 程序员 后端开发

亦直问JVM?凡不凡啊?记住这篇就不怕

爱好编程进阶

Java 程序员 后端开发

全栈开发之后端脚手架:SpringBoot集成MybatisPlus代码生成,分页

爱好编程进阶

Java 程序员 后端开发

2021-12-15【Codeforces Round #760 (Div

爱好编程进阶

Java 程序员 后端开发

Graylog 中文手册 常用功能和问题整理

爱好编程进阶

Java 程序员 后端开发

AuthTalk | 全面拆解多租户解决方案

Authing

SaaS 多租户 Idaas

【SpringBoot】6、自动配置原理【狂神篇

爱好编程进阶

Java 程序员 后端开发

一起学Java——html

爱好编程进阶

Java 程序员 后端开发

“三高”程序员谈:Mysql的“三高”集群架

爱好编程进阶

程序员 后端开发

【深度】阿里巴巴万级规模 K8s 集群全局高可用体系之美

爱好编程进阶

程序员 后端开发

C语言的2016_语言 & 开发_金灵杰_InfoQ精选文章