随着 C 语言的广泛使用,一些常见的实践和约定已经发展出来,旨在帮助避免程序中的错误。这些实践既展示了良好的软件工程原理在语言中的应用,也揭示了 C 语言的局限性。尽管其中的少数做法并没有得到普遍应用,有些做法甚至具有争议,但每种做法都被广泛使用。

动态多维数组

尽管使用 malloc 动态创建一维数组相对简单,并且使用内置语言特性创建固定大小的多维数组也很容易,但动态多维数组则要复杂一些。创建动态多维数组的方法有多种,每种方法都有不同的权衡。最常见的两种创建方式是:

  1. 作为单一内存块分配,就像静态多维数组一样。这要求数组是矩形的(即较低维度的子数组是静态的且具有相同的大小)。这种方法的缺点是,声明指针的语法一开始对程序员来说有些棘手。例如,如果想创建一个有 3 列和若干行的 int 数组,可以这样做:
int (*multi_array)[3] = malloc(rows * sizeof(int[3]));

(注意,这里 multi_array 是一个指向包含 3 个 int 的数组的指针。)

由于数组指针可以互换,所以可以像静态多维数组一样进行索引访问,即 multi_array[5][2] 是第 6 行、第 3 列的元素。

  1. 通过先分配指针数组,再为子数组分配内存并将它们的地址存储在指针数组中。这种方法也被称为 Iliffe 向量(Iliffe vector)。元素的访问语法与上面描述的多维数组相同(即使它们的存储方式完全不同)。这种方法的优点是能够处理不规则数组(即具有不同大小的子数组)。然而,它的缺点是使用更多的空间,并且需要更多的间接层来进行索引,可能会导致较差的缓存性能。它还需要多个动态分配,每次分配都可能非常昂贵。

欲了解更多信息,请参阅 comp.lang.c FAQ,第 6.16 问题。

在某些情况下,多维数组的使用可以通过定义结构体数组来更好地处理。在用户自定义数据结构出现之前,一个常见的技巧是定义一个多维数组,每列包含关于该行的不同信息。这种方法也常被初学者使用。例如,二维字符数组的列可能包含姓、名、地址等信息。

在这种情况下,更好的方法是定义一个包含列中存储信息的结构体,然后创建指向该结构体的指针数组。尤其是在记录的某些数据点数量可能变化的情况下,如专辑中的歌曲。在这些情况下,最好创建一个包含专辑信息的结构体,并为专辑上的歌曲列表创建一个动态数组。然后,可以使用指向专辑结构体的指针数组来存储该专辑集合。

另一种创建动态多维数组的有用方法是将数组展平并手动索引。

例如,具有 xy 尺寸的二维数组有 x * y 个元素,因此可以通过以下方式创建:

int dynamic_multi_array[x*y];

索引比之前稍微复杂一些,但仍然可以通过 y*i + j 来获取。然后可以这样访问数组:

static_multi_array[i][j];
dynamic_multi_array[y*i+j];

更多高维数组的示例:

int dim1[w];
int dim2[w*x];
int dim3[w*x*y];
int dim4[w*x*y*z];

索引方式如下:

dim1[i]
dim2[w*j + i]
dim3[w*(x*i + j) + k] // 索引为 k + w*j + w*x*i
dim4[w*(x*(y*i + j) + k) + l] // 索引为 w*x*y*i + w*x*j + w*k + l

注意,w*(x*(y*i+j)+k)+l 等价于 w*x*y*i + w*x*j + w*k + l,但使用的操作更少(见霍纳法则)。它与通过 dim4[i][j][k][l] 访问静态数组所需的操作数相同,因此使用起来应该没有更慢。

使用这种方法的优点是:

  • 数组可以在函数之间自由传递,而无需在编译时知道数组的大小(因为 C 将其视为一维数组,尽管仍然需要以某种方式传递维度信息)。
  • 整个数组在内存中是连续的,因此访问连续元素应该很快。

缺点是:

  • 刚开始时,可能很难习惯如何进行元素索引。

构造函数和析构函数

在大多数面向对象的编程语言中,客户端无法直接创建对象。相反,客户端必须通过一个名为构造函数的特殊例程请求类构建一个对象实例。构造函数很重要,因为它们允许对象在其生命周期内强制执行内部状态的不变量。析构函数在对象生命周期结束时被调用,它们在对象持有独占资源的系统中非常重要,因为它们确保释放资源供其他对象使用。

由于 C 语言不是面向对象的语言,它没有内建对构造函数或析构函数的支持。客户端显式地分配和初始化记录及其他对象并不罕见。然而,这可能导致潜在的错误,因为如果对象没有正确初始化,对象上的操作可能失败或行为不可预测。更好的做法是使用一个函数来创建对象实例,可能还带有初始化参数,示例如下:

struct string {
    size_t size;
    char *data;
};

struct string *create_string(const char *initial) {
    assert(initial != NULL);
    struct string *new_string = malloc(sizeof(*new_string));
    if (new_string != NULL) {
        new_string->size = strlen(initial);
        new_string->data = strdup(initial);
    }
    return new_string;
}

同样,如果由客户端来正确销毁对象,他们可能会忘记这样做,导致资源泄漏。最好有一个显式的析构函数始终被使用,如下所示:

void free_string(struct string *s) {
    assert(s != NULL);
    free(s->data);  /* 释放结构体中持有的内存 */
    free(s);        /* 释放结构体本身 */
}

结合使用析构函数和置空已释放指针常常很有用。

置空已释放指针

正如之前讨论的,调用 free() 后,指针会成为悬挂指针。更糟的是,大多数现代平台无法检测到在指针重新赋值之前使用了这种指针。

一个简单的解决方法是在释放指针后立即将其设置为 NULL

free(p);
p = NULL;

与悬挂指针不同,在许多现代架构上,当对空指针进行解引用时,会触发硬件异常。而且,程序可以检查空指针的值,但无法检查悬挂指针的值。为了确保在所有位置都执行这一操作,可以使用宏:

#define FREE(p)   do { free(p); (p) = NULL; } while(0)

(要了解为什么宏是这样写的,参见宏约定)。此外,当使用此技巧时,析构函数应该将传递给它们的指针置为零,并且参数必须通过引用传递,以允许此操作。例如,下面是更新后的析构函数:

void free_string(struct string **s) {
    assert(s != NULL && *s != NULL);
    FREE((*s)->data);  /* 释放结构体中持有的内存 */
    FREE(*s);          /* 释放结构体本身 */
}

不幸的是,这种习惯不会对可能指向已释放内存的其他指针做任何处理。因此,一些 C 专家认为这种做法存在危险,因为它给人一种虚假的安全感。

宏约定

由于 C 中的预处理器宏通过简单的令牌替换工作,它们容易出现一些混淆错误,一些错误可以通过遵循一组简单的约定来避免:

  • 在宏参数周围尽可能放置括号。这可以确保如果参数是表达式时,操作顺序不会影响表达式的行为。例如:

    • 错误:#define square(x) x*x
    • 更好:#define square(x) (x)*(x)
  • 如果宏表达式是单一的表达式,将整个表达式括起来。这同样可以避免因操作顺序变化而改变含义。

    • 错误:#define square(x) (x)*(x)
    • 更好:#define square(x) ((x)*(x))

    要小心,记住宏会逐字替换文本。假设代码是 square(x++),宏调用后,x 会被递增两次。

  • 如果一个宏包含多条语句,或声明了变量,可以将其包裹在 do { ... } while(0) 循环中,不加分号。这样允许宏像单个语句一样在任何地方使用,比如在 if 语句体内,同时仍允许在宏调用后加分号而不会创建空语句。[3][4][5][6][7][8] 要小心,确保任何新变量不会掩盖宏参数的部分内容。

    • 错误:#define FREE(p) free(p); p = NULL;
    • 更好:#define FREE(p) do { free(p); p = NULL; } while(0)
  • 尽量避免在宏内部使用同一个宏参数两次或多次,因为这会导致宏参数出现副作用问题,如赋值。

  • 如果宏将来可能会被函数替代,考虑像函数一样命名它。

  • 按照惯例,预处理器值和通过 #define 定义的宏名称应全为大写字母。[9][10][11][12][13]

进一步阅读

  • Adam N. Rosenberg. A Description of One Programmer’s Programming Style Revisited 2001. p. 19-20.
  • comp.lang.c FAQ 列表:“为什么调用 free 后指针不为空?”
  • “comp.lang.c FAQ: 写多语句宏的最佳方式?”
  • “C 预处理器:吞掉分号”
  • “为什么宏中使用看似无意义的 do-whileif-else 语句?”
  • “宏中的 do {...} while (0)
  • “KernelNewbies: FAQ / DoWhile0”
  • “PRE10-C. 将多语句宏包裹在 do-while 循环中”
  • “常量命名全为大写字母的历史”
  • “C 语言风格指南”
  • “不以大写字母命名的宏永远是错误的”
  • “通过预处理器进行的娱乐与利润”
Last modified: Sunday, 12 January 2025, 1:30 PM