C语言必备知识

学习C语言相当困难。不是因为C语言难学,而是因为用C编程需要知道很多其它类型的知识。例如

有用的资源

可以学习的好项目

学习一些小的,只包含C语言的项目是很有用的:

编译,链接,头文件和符号表

这是一些关于编译C语言的一些基础知识,这对理解其它事情很有帮助。

C代码是写在.c源文件中的。每个源文件都被编译成一个.o对象文件,它就像是.c文件中编译后的功能代码的容器。它们是不可执行的。对象文件内部有一个符号表,是该文件中定义的全局函数和变量的名称。

# compile to objects
cc -c thing.c -o thing.o
cc -c stuff.c -o stuff.o

源文件是完全独立的,可以并行地编译成对象。

为了跨文件使用函数和变量,我们使用头文件(.h)。这些只是以特定方式使用的普通C源文件。记得上面说过,对象文件只包含全局函数和变量的名称,没有类型、宏,甚至函数参数。为了跨文件使用符号,我们需要指定所有需要使用它们的额外信息。我们把这些"声明"放在自己的.h文件中,这样其他的.c文件就可以通过#include引用它们了。

为了避免重复,一个.c文件通常不会定义自己的类型/宏等,而只是包括自己或其所属模块/组件的头文件。

把头文件看作是一个API的规范,它可以在任何数量的源文件中实现。你甚至可以为不同的平台或目的,编写同一头文件的不同实现。

当编译一个仅被声明(例如被包含的头文件)而未被定义的符号的引用时,对象文件将标记这个符号是缺失的,需要被实现。

将一个或多个对象连接在一起的最后工作,匹配所有的符号引用,是由编译器的 "链接器 "组件完成的。链接器输出完整的可执行文件或共享库。

# link objects to executable
cc thing.o stuff.o -o gizmo

总之,我们在C语言中不像其他语言那样"包含"其他源文件。我们只包括声明,然后代码被链接器匹配起来。

不要使用这些函数

C语言已经很老了,并试图做到高度向后兼容。因此,它有一些应该被避免的功能。

数组不是值

重要的是要意识到C语言作为一种语言,只处理已知大小的数据块。你也许可以把C语言概括为 "复制已知大小的值的语言"。

我可以在程序中传递一个整数或一个结构,从函数中返回它们,等等,并将它们视为适当的对象,因为C知道它们的大小,因此可以编译代码来复制它们的全部数据。

我不能用数组来做这个。当我在一个函数中声明一个int[5]类型的变量时,实际上我并没有得到一个int[5]类型的值;我得到的是一个int*值,其中有5个int被分配到它那里。由于这只是一个指针,应当由程序员而不是语言管理复制它后面的数据并保持其有效性。

然而,结构体内的数组被视为值,并与结构体一起被完全复制。

(从技术上讲,已知大小的数组类型是真实的类型,而不仅仅是指针;例如,sizeof会告诉你整个数组的大小。但是你不能把它们当作自带的值)。

重要的编译选项

编译器有这么多的选项,而默认的选项并不是很好。下面是你可能需要的绝对必要的标志。(它们是以GCC/Clang风格给出的;其他编译器的语法可能有所不同)。

参考Option Summary (Using the GNU Compiler Collection (GCC)),了解更多编译选项。

三种类型的内存以及何时使用

如果你想从一个函数中 "返回 "内存,你不必使用malloc/分配的存储;你可以传递一个指向本地数据的指针:

void getData(int *data) {
    data[0] = 1;
    data[1] = 4;
    data[2] = 9;
}

void main() {
    int data[3];
    getData(data);
    printf("%d\n", data[1]);
}

命名习惯

C语言不支持命名空间。如果你要做一个公共库,或者希望一个"模块"有一个名字,你需要选择一个前缀来添加到所有公共API的名字中:

此外,你应该总是为每个枚举包括一些不同的前缀,这样你就知道这个值属于哪个枚举类型:

enum color {
    COLOR_RED,
    COLOR_BLUE,
    ...
}

关于名字没有真正的标准,例如,snake_case或者cameCase。选择并保持一致,我所知道的最接近约定的东西是,有些人把类型命名为my_type_t,因为许多标准的C类型都是这样的(ptrdiff_t,int32_t, 等等)。

static

在一个函数或文件内变量上使用,static使其成为文件本地的。它不会被导出为一个符号,供其他源文件使用。

static也可以用在局部变量上,这使得变量在调用该函数时持续存在。你可以认为这就像一个全局变量,它只在一个函数的范围内。这对于计算和存储数据以便在随后的调用中重复使用是很有用的;但请记住,这也有全局/共享状态,需要在多线程或递归调用中注意他们的使用。

(它看起来有多重含义,因为在全局作用域中它似乎减少了变量的作用域,但在函数作用域中它又增加了变量的作用域)。实际上,在这两种情况下,它所做的是使它们与文件相联系。

结构体方法参数

如果你在C语言之前学过一种更有特点的语言,你可能会发现很难想象如何转化这些知识。这里有一个类似于面向对象编程的常用方法:"结构体方法"。你写的函数接收指向结构的指针,以改变它们或获得属性:

typedef struct {
    int x;
    int y;
} vec2;

void vec_add(vec2 *u, const vec2 *v) {
    u->x += v->x;
    u->y += v->y;
}

int vec_dot(const vec2 *u, const vec2 *v) {
    return u->x * v->x + u->y * v->y;
}

你不能扩展结构或做任何真正类似于OO的事情,但这是一个有用的模式,可以用来参考。

const

将一个T类型的变量或参数声明为const T,大致上意味着该变量不能被修改。这意味着它不能被赋值,如果T是一个指针或数组类型,它也不能被改变。

将类型为T的变量等于类型为const T的变量是允许的,反过来是不允许的。

一个好的习惯是默认将函数的指针参数声明为常数,只有在需要修改它们时才省略。

平台与标准APIs

当你通过#include<some_header.h>引用头文件时,一般无法确定它们依赖什么。通常来自以下情况:

通过一个平台中立的头文件与你的特定平台代码对接可能是一个好主意,这样它就可以用不同的方式来实现。很多流行的C语言库基本上都是统一的、精心设计的对特定平台功能的抽象。

整型

整数在C语言中是经常被抱怨的,编写正确的代码需要一些小心:

Sizes

所有的整型有一个默认的最小size.在常见的平台上,有些比它们的最小size更大,例如int,尽管最小size是16位,但在Windows、macOS和Linux上是32位。在编写可移植代码时,你必须假设整数永远不能超过其最小size。

如果你想精确控制整数大小,你可以使用stdint.h中的标准类型,如int32_tuint64_t等。还有_least_t_fast_t类型。例如: uint_fast8_tuint_least8_t

需要在任何地方使用这些明确的类型吗?必须承认我对这个问题很纠结,但我越想越觉得你应该这样做--没有什么坏处。你真正不应该这样做的唯一原因是在制作API时必须使用缺少stdint.h的老式C89编译器。使用int16_fast_t或其他类型而不是int,不会更糟,只会更清楚。

算数和优化

C语言中的算术受制于许多奇怪的规则,这些规则可能会产生意想不到的或无法实现的结果。整数提升尤其需要注意。

可以参考Nayuki’s summary of C integer rules

无符号char

所有其他的整数类型都默认为有符号,但char可以是有符号或无符号的,这取决于平台的情况。因此,它只有在用于字符串时才是可移植的;如果你想要一个小的/最小的8位的数字,也要指定符号。

宏与const变量

要定义简单的常数,你有两个选择:

static const int my_constant = 5;
// or
#define MY_CONSTANT 5

区别在于,前者是一个真正的变量,后者是一个复制粘贴的内联表达式。

让常量真正成为"常量表达式"是非常有用的,因此它们通常应该被定义为宏。变量更适合于更大或更复杂的值,如结构体实例。

如果你的常数是一个整数,你有第三个更好的选择,即枚举:

enum {
    MY_CONSTANT = 5
}

这在C语言中定义了一个常量表达式,而不是在预处理程序中,所以它可以更容易被调试器等看到。

在C23中,你可以给一个枚举类型指定明确的基础类型:

enum : size_t {
    BUFFER_LENGTH = 1024
}

宏与内联函数

宏可以有参数,然后可以扩展为C代码。

相对于函数的优势:

缺点

// Instead of:
#define MY_MACRO(x) x+x
// Do:
#define MY_MACRO(x) ((x)+(x))

除非你需要类型通用,否则你可以通过将一个函数定义为静态内联来获得两方面的好处。内联向编译器提供了一个提示,即函数中的代码应该直接编译到使用它的地方,而不是被调用。你可以把静态内联函数放在头文件中,就像宏一样,没有任何问题。

此外,从C11开始,你可以使用一个特殊的宏_Generic为不同类型的函数提供重载:

#define sin(X) _Generic((X), \
              long double: sinl, \
                  default: sin,  \
                    float: sinf  \
              )(X)

参考

  1. Everything I wish I knew when learning C - Tom Mewett (tmewett.com)[原文]