C语言的2016(转载)

转载自:https://infoq.cn/article/c-language-2016    

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

使用 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++ 中的字符 / 字符串字面量

结尾

大规模的编写正确的代码基本上是不可能的。我们有多种操作系统、运行时环境、程序库和硬件平台需要考虑,更不用说小概率的内存随机位反转、块设备故障等。

发布了102 篇原创文章 · 获赞 17 · 访问量 11万+

猜你喜欢

转载自blog.csdn.net/jin615567975/article/details/88579815