C语言的2016

这是我在2015年初写的草稿,且从未考虑过发布。这是一个未经雕琢的版本,因为没有任何人对这个草稿提供改进。最简单的变化只是将发布时间从2015年改成2016年。如果有缺陷、改进和抱怨,请随时联系。-Matt

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语言实践”的文章将会覆盖测试、性能调优、性能追踪、可选但是有用的警告级别等。

本文文字及图片出自 InfoQ

你也许感兴趣的:

共有 2 条讨论

  1. yyj  这篇文章
  2. hello 对这篇文章的反应是赞一个

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注