作者整理了自己学习 C 语言过程的一些笔记,相当于列出了 C 语言一些的学习难点。
学习C对我来说是相当困难的。语言本身的基础还不错,但是“用 C 编程”需要很多其他种类的知识,这些知识并不那么容易掌握:
- C 没有消除平台或操作系统差异的环境;你也需要了解你的平台
- 有许多 C 编译器选项和构建工具,即使运行一个简单的程序也需要做出很多决定
- 通常有与 CPU、操作系统、编译代码相关的重要概念
- 它的使用方式多种多样,以至于与其他语言相比,集中的“社区”或风格要少得多
此页面是对这些更广泛的要点的总结、路标和建议的生动集合,这些要点使我使用 C 和其他编译语言的旅程更加轻松。我希望它对你有用!(如果是,请确保订阅任何更新。)
- 一般资源
- 值得学习的好项目
- 编译、链接、标题和符号
- 不要使用这些功能
- 数组不是值
- 基本编译器标志
- 三种类型的记忆,以及何时使用它们
- 命名约定
- 静止的
- 结构方法模式
- 常数
- 平台和标准 API
- 整数
- 宏与 const 变量
- 宏与内联函数
一般资源
- TutorialsPoint C:非常基本的介绍
- awesome-c:库和工具的大列表
- cppreference : C 语言和标准库的技术参考
值得学习的好项目
有时,阅读一些小的、自包含的 C 代码以了解它的外观很有帮助。
- Bloopsaphone,一个用于合成声音的 Ruby 库,其核心有一个小的 C 模块。概念少,结构好。
- 简单动态字符串 (sds)。每个都有一个 .c 和 .h 文件,这是一个很好的例子,说明您可以如何进行更复杂的资源管理。
- Brogue CE,一款类 Roguelike 视频游戏。这是更大但更不完善的,在 >30k LOC。但我维护它,我们的许多贡献者都通过致力于它来提高他们的 C。
- stb 单文件库. 这些是中小型模块,旨在高度便携,包括针对嵌入式设备和游戏机。
编译、链接、标题和符号
关于 C 编译如何工作的一些基础知识,因为它会帮助其他事情变得有意义。
C 代码是用 .c 源文件编写的。每个源文件都被编译成一个.o目标文件,它就像是.c文件中编译后的函数代码的容器。它们不可执行。目标文件内部有一个符号表,这些符号是该文件中定义的全局函数和变量的名称。
# compile to objects
cc -c thing.c -o thing.o
cc -c stuff.c -o stuff.o
源文件相互完全独立,可以并行编译成对象。
要跨文件使用函数和变量,我们使用头文件 (.h)。这些只是以特定方式使用的普通 C 源文件。回想一下,目标文件只包含全局函数和变量的名称——没有类型、宏,甚至没有函数参数。要跨文件使用符号,我们需要指定使用它们所需的所有这些额外信息。我们将这些“声明” 1放在它们自己的 .h 文件中,因此其他 .c 文件也可以#include
。
为避免重复,.c 文件通常不会定义自己的类型/宏等,而只会包含其自身或其所属的模块/组件的头文件。
将头文件视为 API 的规范,可以在任意数量的源文件中实现。您甚至可以为不同的平台或目的编写同一标头的不同实现。
当编译对仅声明(例如通过包含的标头)但未定义的符号的引用时,目标文件将标记此符号丢失并需要填充。
将一个或多个对象连接在一起、匹配所有符号引用的最终工作由编译器的“链接器”组件完成。链接器输出完整的可执行文件或共享库。
# link objects to executable
cc thing.o stuff.o -o gizmo
总之,我们不会像其他语言那样在 C 中“包含”其他源文件。我们包含声明,然后代码由链接器匹配。
不要使用这些功能
C 很旧,并试图高度向后兼容。因此,它具有应该避免的功能。
atoi()
,atol()
, 和朋友;它们在出错时返回 0,但这也是一个有效的返回值。喜欢strtoi()
等gets()
不安全,因为无法给出目标缓冲区的界限。更喜欢fgets()
。
数组不是值
重要的是要认识到 C 作为一种语言只处理已知大小的数据块。您或许可以将 C 概括为“复制已知大小值的语言”。
我可以围绕程序传递整数或结构,从函数等返回它们并将它们视为适当的对象,因为 C 知道它们的大小,因此可以编译代码以复制它们的完整数据。
我不能用数组来做到这一点。数组的大小对于 C 来说是未知的。当我int[5]
在函数中声明一个类型的变量时,实际上我没有得到类型的值int[5]
;我得到一个int*
分配了 5 个整数的值。由于这只是一个指针,程序员而不是语言必须设法复制其背后的数据并保持其有效。
但是,结构内的数组被视为值并与结构一起完全复制。
(从技术上讲,大小数组类型是真实类型,而不仅仅是指针;例如sizeof
会告诉您整个数组的大小。但您不能将它们视为独立的值。)
基本编译器标志
编译器有很多选项,默认值不是很好。以下是您可能需要的绝对必要标志。(它们以 GCC/Clang 风格给出;语法可能因其他编译器而异。)
-O2
:优化发布版本的代码-g -Og
:用于调试版本;为调试器启用额外信息,并针对调试进行优化-Wall
启用许多警告(有点像 linter)。您可以使用禁用特定警告-Wno-...
-Werror
将警告变成错误。我建议始终至少打开-Werror=implicit
,以确保调用未声明的函数会导致错误(!)-DNAME
和-DNAME=value
定义宏-fsanitize=address,undefined
:用于调试版本;启用两种常见的“消毒剂”,它们会在整个编译代码中注入额外的检查以发现错误。另请参阅所有 GCC 检测选项。-std=...
: 选择一个标准。在大多数情况下,您可以省略它以使用编译器的默认值(通常是最新标准)。那些特别关注可移植性的人可能会使用-std=c89
“经典”C。
另请参阅完整文档以了解 GCC 支持的大量选项。
三种类型的记忆,以及何时使用它们
- 自动存储是存储局部变量的地方。一个新的自动存储区域在一个函数被调用时被创建,并在它返回时被删除。只保留返回值;它被复制到调用它的函数的自动存储中。这意味着返回一个指向局部变量的指针是不安全的,因为底层数据将被静默删除。自动存储通常称为堆栈。
- 分配的存储空间是使用
malloc()
. 它在被free()
'd 之前一直存在,因此可以传递到任何地方,包括向上调用函数。它通常被称为堆。 - 静态存储在程序的生命周期内有效。它在进程启动时分配。全局变量存储在这里。
如果你想从一个函数中“返回”内存,你不必使用malloc
/allocated storage;您可以传递一个指向本地数据的指针:
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
vs camelCase
。选择一些东西并保持一致!我所知道的最接近约定的是有些人将类型命名为 likemy_type_t
因为许多标准 C 类型都是这样的(ptrdiff_t
,int32_t
等)。
静止的
在函数或文件级变量上,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 的事情,但这是一个有用的思考模式。
常数
声明一个类型T
为as 的变量或参数const T
,粗略地说,该变量不能被修改。T
这意味着它不能被分配给,如果是指针或数组类型,也不能更改。
您可以强制转换T
为const T
,但反之则不行。
将函数的指针参数声明为默认是一个好习惯const
,只有在需要修改它们时才省略它。
平台和标准 API
当你投入其中时,#include <some_header.h>
很难概念化你所依赖的东西。它将来自以下之一:
- 标准 C 库(缩写为“stdlib”)。例子:
stdio.h
,stdlib.h
,error.h
- 这是语言规范的一部分,所有兼容的平台和编译器都应该实施。非常安全的依赖。
- https://en.cppreference.com/w/c/header
- POSIX,操作系统 API 的标准。例子:
unistd.h
,sys/time.h
- 一般由 Linux、macOS、BSDs 实现。
- 默认情况下在 Windows 上不可用。一些杂项。如果您使用 MinGW ,则可以使用 POSIX API。要获得更完整的支持,可以使用Cygwin库。
- 您可以在官方 OpenGroup 标准页面(单击边栏中的“标头”)或第 3 节手册页中查看 POSIX 标头(包括 C stdlib)的所有详细信息。
- 非标准操作系统接口:
- 特定于 Linux 的 API - 记录在第 3 节手册页中
- Windows Win32 (仅供参考,也可以使用称为C++/WinRT的更现代的 C++ 接口。)
- (Mac 的操作系统 API历来是通过 Objective C(现在是 Swift)而不是 C 使用的。)
- 安装在标准位置的第三方库。
通过平台中立的头文件与更多平台特定的代码进行交互可能是个好主意,这样它就可以以不同的方式实现。许多流行的 C 库基本上只是对特定于平台的功能进行统一的、精心设计的抽象。
整数
整数在 C 语言中非常受诅咒。编写正确的代码需要一些小心:
尺码
所有整数类型都有定义的最小大小。在常见平台上,有些比其最小大小更大,例如int
,尽管最小为 16 位,但在 Windows、macOS 和 Linux 上为 32 位。在编写可移植代码时,您必须假设整数永远不会超过其最小大小。
如果要精确控制整数大小,可以使用中的标准类型stdint.h
,例如int32_t
、uint64_t
等。还有_least_t
和_fast_t
类型。
您应该尽可能地使用这些明确指定的类型吗?我必须承认我在这个问题上左右为难,但我想得越多,我就越认为你应该——没有缺点。2你真正不应该的唯一原因是在制作一个 API 时,它必须与缺乏stdint.h
. 还有一个论点是考虑类型与读者交流的内容以及大小是否真的很重要;但是,通过使用像您这样的标准类型,int
您仍然隐含地依赖于一定的大小。useint16_fast_t
或 over可能不会更糟,但更清晰int
。(但是,通常没有人这样做,包括我!)
算术与提升
C 中的算术遵循许多奇怪的规则,这些规则可能会产生意想不到的或不可移植的结果。整数提升尤其重要。
字符符号
所有其他整数类型默认为有符号,但 barechar
可以有符号或无符号,具体取决于平台。因此,它仅在用于字符串时才可移植;如果你想要一个小的/最小的 8 位3数字,也要指定符号。
宏与 const 变量
要定义简单的常量值,您有两种选择:
static const int my_constant = 5;
// or
#define MY_CONSTANT 5
不同的是,前者是一个真正的变量,后者是一个复制粘贴的行内表达式。
- 与变量不同,您可以在需要“常量表达式”的上下文中使用宏,例如数组长度或 switch 语句情况。
- 与宏不同,您可以获得指向变量的指针。
常量实际上是“常量表达式”是非常有用的,因此它们通常应该被定义为宏。变量更适合更大或更复杂的值,如结构实例。
如果你的常量是一个整数,你有第三个更好的选择,“裸枚举”:
enum {
MY_CONSTANT = 5
}
这在 C 中定义了一个常量表达式,而不是在预处理器中,因此调试器等可以更容易地看到它。
在 C23 中,您可以选择为枚举提供明确的“底层类型”:
enum : size_t {
BUFFER_LENGTH = 1024
}
宏与内联函数
宏可以有参数,然后可以扩展为 C 代码。
相对于功能的优势:
- 代码直接粘贴在周围的代码中,而不是编译函数调用指令。这可以使代码更快,因为函数调用有一些开销。
- 它们可以是泛型的。例如,
x + y
是任何数字类型的有效语法。如果我们使它成为一个函数,我们必须将它们声明为参数并预先选择它们的类型,即大小和符号,这将使其仅在某些上下文中可用。
缺点:
- 反复评估参数。假设我们有一个宏
MY_MACRO(x)
。如果x
在定义中多次使用,那么表达式x
将被计算多次,因为它只是简单地复制和粘贴。4将其与函数进行比较,其中作为参数的表达式被计算一次为值,然后传递给函数。 - 它们可能容易出错,因为它们在源代码级别工作。无偿地使用方括号通常是个好主意,始终围绕整个宏定义本身和任何参数,这样表达式就不会无意中合并。
// Instead of: #define MY_MACRO(x) x+x // Do: #define MY_MACRO(x) ((x)+(x))
除非您需要类型泛型,否则您可以通过将函数定义为static inline
. inline
向编译器提供提示,函数中的代码应该直接编译到使用它的地方,而不是被调用。您可以将静态内联函数放在头文件中,就像宏一样,没有任何问题。
此外,从 C11 开始,您可以使用特殊宏为不同类型提供函数重载_Generic
:
#define sin(X) _Generic((X), \
long double: sinl, \
default: sin, \
float: sinf \
)(X)
- https://stackoverflow.com/questions/1410563/what-is-the-difference-between-a-definition-and-a-declaration ↩
- https://stackoverflow.com/a/9837399/1561010↩ _
- 但并不总是8 位。
char
是特殊的,因为它是当前平台上最小的可寻址类型,不需要(但基本上总是)8 位。in 位的大小在宏中char
可用。C 中的所有其他大小(例如 from )均以. ↩CHAR_BIT
limits.h
sizeof
char
- 如果表达式没有副作用并且编译器可以解决这个问题,则可以通过公共子表达式消除对其进行优化。 ↩
原 文:https://tmewett.com/c-tips/