C语言必备知识
学习C语言相当困难。不是因为C语言难学,而是因为用C编程需要知道很多其它类型的知识。例如
- C语言没有消除平台或操作系统差异的环境;你也需要了解你的平台。
- 有许多C语言编译器选项和构建工具,即使是运行一个简单的程序也要做出许多决定。
- 有一些与CPU、操作系统、一般的编译代码有关的重要概念
- 与其他语言相比,C语言的使用方式多种多样,所以集中的"社区"或风格要少得多。
有用的资源
- TutorialsPoint C:基础的介绍
- awesome-c:一些有用的库和工具
- cppreference:C语言和标准库的技术参考
可以学习的好项目
学习一些小的,只包含C语言的项目是很有用的:
- Bloopsaphone:一个用于合成声音的Ruby库,它的核心是一个小型的C模块。有少量的概念和良好的结构。
- Simple Dynamic Strings (sds):一个很好的例子,只有一个.c和.h文件,就能实现复杂的资源管理。
- Brogue CE:一个roguelinke的视频游戏,很大单并不完美,有30k+行,贡献它可以很好的磨砺你的C语言。
- stb single-file libraries:这些是中小型的模块,旨在高度便携,包括针对嵌入式设备和游戏机。
编译,链接,头文件和符号表
这是一些关于编译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语言已经很老了,并试图做到高度向后兼容。因此,它有一些应该被避免的功能。
atoi()
,atol()
等类似函数;发生错误时,它们返回0,但是0也是一个有效的值。更好的是使用strtoi()
等函数。gets()
是不安全的,因为它没有给出目标缓冲区的界限。最好使用fgets()
。
数组不是值
重要的是要意识到C语言作为一种语言,只处理已知大小的数据块。你也许可以把C语言概括为 "复制已知大小的值的语言"。
我可以在程序中传递一个整数或一个结构,从函数中返回它们,等等,并将它们视为适当的对象,因为C知道它们的大小,因此可以编译代码来复制它们的全部数据。
我不能用数组来做这个。当我在一个函数中声明一个int[5]
类型的变量时,实际上我并没有得到一个int[5]
类型的值;我得到的是一个int*
值,其中有5个int被分配到它那里。由于这只是一个指针,应当由程序员而不是语言管理复制它后面的数据并保持其有效性。
然而,结构体内的数组被视为值,并与结构体一起被完全复制。
(从技术上讲,已知大小的数组类型是真实的类型,而不仅仅是指针;例如,sizeof会告诉你整个数组的大小。但是你不能把它们当作自带的值)。
重要的编译选项
编译器有这么多的选项,而默认的选项并不是很好。下面是你可能需要的绝对必要的标志。(它们是以GCC/Clang风格给出的;其他编译器的语法可能有所不同)。
-O2
:为发布版本的构建优化代码-g -Og
:用于调试构建;为调试器启用额外信息,并为调试进行优化。-Wall
:启用所有警告;也可以通过-Wno-...
禁用指定警告。-Werror
:将所有警告转换为错误;确保至少启用-Werror=implicit
,确保调用未声明函数时报错。-DNAME
和-DNAME=value
:生命一个宏。-fsanitize=address,undefined
:用于调试构建;使能两个sanitizer,它在整个编译后的代码中注入额外的检查以发现错误。详见GCC编译选项。-std=...
:选择一个标准。在大多数情况下,你可以省略它,使用你的编译器的默认值(通常是最新的标准)。那些对可移植性有特殊关注的人可以使用-std=c89
来保持"经典"C。
参考Option Summary (Using the GNU Compiler Collection (GCC)),了解更多编译选项。
三种类型的内存以及何时使用
- 自动存储是存储局部变量的地方。当一个函数被调用时,将为其创建一个新的自动存储区域,并在其返回时删除。只有返回值被保留;它被复制到调用它的函数的自动存储中。这意味着返回一个指向局部变量的指针是不安全的,因为底层数据会被悄悄地删除。自动存储通常被称为堆栈。
- 分配的存储空间是使用
malloc()
的得到的。它一直存活到被free()
的时候,所以可以被传递到任何地方,包括向上传递到调用函数。它通常被称为堆。 - 静态存储在程序的生命周期内有效。它是在进程开始时分配的。全局变量被存储在这里。
如果你想从一个函数中 "返回 "内存,你不必使用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库,缩写为"stdlib"。例如:
stdio.h
,stdlib.h
,error.h
- 这是语言规范的一部分,应该由所有符合要求的平台和编译器来实现。非常安全的依赖。
- https://en.cppreference.com/w/c/header
- POSIX,一套标准的操作系统APIs。例如:
unistd.h
,sys/time.h
- 非标准的操作系统接口
- Linux特殊APIs,可以通知man文档第三章了解。
- Windows Win32
- Mac's OS APIs
- 安装在标准位置的一些第三方库。
通过一个平台中立的头文件与你的特定平台代码对接可能是一个好主意,这样它就可以用不同的方式来实现。很多流行的C语言库基本上都是统一的、精心设计的对特定平台功能的抽象。
整型
整数在C语言中是经常被抱怨的,编写正确的代码需要一些小心:
Sizes
所有的整型有一个默认的最小size.在常见的平台上,有些比它们的最小size更大,例如int,尽管最小size是16位,但在Windows、macOS和Linux上是32位。在编写可移植代码时,你必须假设整数永远不能超过其最小size。
如果你想精确控制整数大小,你可以使用stdint.h
中的标准类型,如int32_t
、uint64_t
等。还有_least_t
和_fast_t
类型。例如: uint_fast8_t
、uint_least8_t
。
_least_t
:表示至少保存多少字节。_fast_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代码。
相对于函数的优势:
- 这些代码被直接粘贴在周围的代码中,而不是编译函数调用指令。这可以使代码更快,因为函数调用有一些开销。
- 它们可以是类型通用的。例如,
x + y
是任何数字类型的有效语法。如果我们把它变成一个函数,我们就必须把它们声明为参数,并事先选择它们的类型,即大小和符号性,这将使它只能在某些情况下使用。
缺点
- 参数的重复使用。假设我们有一个宏
MY_MACRO(x)
。如果x在定义中被多次使用,那么表达式x将被多次出现,因为它被简单地复制和粘贴。而函数的参数的表达式被会被计算一次,然后传入函数。 - 它们可能容易出错,因为它们是在源码级别工作。一般来说,多使用括号是个好主意,总是对整个宏定义本身和任何参数使用括号,这样表达式就不会在无意中合并。
// 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)