3

【译文】为什么白宫说错了;C 语言技术水平问题

 6 months ago
source link: https://www.techug.com/post/c-skill-issue-how-the-white-house-is-wrong/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

关于 Rust 应如何取代 C 语言的争论一直没有停止过,但最近白宫发布了一份报告,敦促开发人员从 C 语言等 “不安全 “的语言迁移到 Rust 等 “安全 “的语言,这引发了新一轮的讨论。

现在看来,白宫的观点并非完全错误:大多数使用 C 语言的程序员都应该使用 Rust,但这并不意味着所有程序员都应该使用 Rust。

我的观点很简单:在某些情况下,如果使用得当,C 语言比 Rust 语言更胜一筹。

不幸的是,大多数人似乎都无法理解我的论点,这也许是由于人类倾向于用大笔来描绘一切。因此,在这篇文章中,我将逐步解释我的论点,希望大家不要忽略其中的细微差别,并列举大量实例,以免产生歧义。

这篇文章分为三个部分。首先,我将解释我的论点,然后我将讨论我的论点如何才能让那些不以为然的人信服,最后我将举例说明。

并非人人都应成为狙击手

让我们想象一下,10 名士兵组成的队伍全部使用狙击步枪。这支队伍可能不会取得好成绩,那么我们是否可以得出结论:不应该有人使用狙击步枪?如果给这支队伍中的每个人都配上普通步枪,他们的表现会好很多,那就会得出一个简单化的结论:普通步枪比狙击步枪好。

是的,在大多数人手中,普通步枪都比狙击步枪好,但这并不意味着每个人都是如此。

假设 Erich 是一名非常出色的狙击手,那么重新组建的队伍中就有 9 把普通步枪和 Erich 的狙击步枪。现在队伍的表现比 10 把普通步枪好得多。

混合团队的表现更好,因为它接受了人类能力的多样性,以及狙击步枪在正确的手中所具有的潜力。

这种认识不应引起争议。使用正确的编程语言来完成特定工作的概念很常见。你不会用 C 语言来按一定顺序运行一堆程序,shell 脚本更合适;你也不会用 JavaScript 来编写操作系统内核。

有些人更擅长 JavaScript,有些人更擅长 Ruby,有些人更擅长 PHP,这都没有问题。

C 语言擅长某些任务,如系统编程和底层库,而不是所有任务。在其他任务中,例如图形 widget 工具包(如 GTK+),Rust 可能比 C 更出色。因此,如果图形窗口小部件工具包是用 C 语言编写的,那么用 Rust 重写它可能更有意义。这同样适用于许多其他任务(也许是大多数任务),但并非所有任务。

Rust 的拥护者认为 Rust 应该在所有任务中取代 C 语言,而这正是我强烈反对的。

他们认为,由于 Rust 本身比 C 语言更安全,因此即使在 C 语言称王称霸的系统编程中,也应该使用 Rust。

在许多系统编程的代码库中,使用 Rust 而不是 C 可能更好,这一点我并不反对,但同样……不是全部。

如果目前所有的 C 代码都用 Rust 重写,世界会变得更好,是的,就像如果所有 10 名士兵都使用普通步枪而不是狙击步枪,团队的战斗力会更强一样。但这并不意味着埃里希应该放弃他心爱的狙击步枪,改用普通步枪,仅仅因为其他人都不擅长狙击步枪。

C 总是会有一席之地的,即使有十倍于 Rust 代码的代码,因为总会有人能让 C 大放异彩。

基础与王牌

Rust 的拥护者喜欢分享在 C 代码中发现的安全漏洞列表,并以此证明没有人能安全地编写 C 代码。

让我们假设大多数 C 代码实际上是不安全的,是写得不好的。按照狙击手的比喻……难道不是因为 10 个 C 程序员中就有 9 个写得很烂吗?那么埃里希呢?那 10 个程序员中最优秀的那 1 个呢?

我把前 10%称为 “王牌”,把后 90%称为 “基础”。

我的论点是,你不应该用基础选手的表现来证明王牌选手应该放弃 C 语言而改用 Rust。

对统计学理解不深的人可能会说,不只是 “基础”,”所有 “C 代码都是如此。事实并非如此。如果你对 10 个人进行考试,其中 9 个人得了 1/10,而 1 个人得了 10/10,你可能会得出 “所有人 “都不及格的结论,因为平均分是 1.9,但这不是统计学的工作原理。

给天真者吃红丸

如果你承认 C 语言王牌可能存在,那么你就可以继续阅读接下来的章节,我将举例说明这些 C 语言王牌是如何编写截然不同的代码的。

如果不接受,接下来的小节将让你明白,兔子洞可能比你想象的更深,从而让你质疑你自以为了解的东西,不仅仅是 C 语言,还有统计学和其他一切。

少数人的团队

好吧,在我所举的例子中,只有一个出类拔萃的学生,其平均水平并不具有代表性,但这在现实世界中肯定不会发生。对不对?

你知道在每个开源项目中,大多数人的提交次数都低于平均值吗?没错:大多数人都低于平均值。

下面这张图显示了 Linux 项目中每个贡献者的提交次数:

平均值为 37.04 次提交,中位数为 3 次提交。88% 的贡献者的提交次数少于 37 次,这意味着 88% 的贡献者的提交次数低于平均值。

这可能与大多数人的直觉背道而驰,但这是因为大多数人只掌握了基本的统计学知识。与大学里学到的知识不同,大多数分布都不是正态分布,而是幂律分布,尤其是在涉及信息问题时。

这意味着什么呢?这意味着你对统计学的了解可能没有你想象的那么多,因此你从 C 程序员的整体表现中得出的结论可能也是有缺陷的。

你听说过帕累托原则吗?如果 C 语言的熟练程度遵循这一分布,那么 20% 的程序员将掌握 80% 的技能。当然,比例不一定是 80%/20%,很可能是 90%/10%,或 60%/40%,或任何其他比例。

因此,我的考试例子一点也不牵强,现实可能与之非常接近。

愚蠢山与邓宁-克鲁格(Dunning–Kruger)效应

你看过这幅图吗?

愚蠢山

很多人会把邓宁-克鲁格效应与这张图联系在一起,你可能会想:”我打赌作者是在暗示我正处于愚蠢山的顶峰,而实际上我知道的并不多”:我敢打赌,作者是在暗示我正处于 “愚蠢山 “的顶峰,实际上我知道的并不多。

问题只有一个:邓宁-克鲁格效应与这张图完全无关。

这是最正确地描述邓宁-克鲁格效应的图表:

实际的邓宁-克鲁格效应

邓宁-克鲁格效应表明,对某一话题知之甚少的人会高估自己对该话题的了解程度,而对邓宁-克鲁格效应知之甚少的人却高估了自己对邓宁-克鲁格效应的了解程度,这就是邓宁-克鲁格效应的讽刺之处。

我不知道是谁想出了上面的 “愚蠢山 “图表,但他们显然没有读过 J. Kruger 和 D. Dunning 的论文,而且大多数提到邓宁-克鲁格效应的人都不知道它到底是什么。

同样的道理也适用于 C 语言:对 C 语言知之甚少的人高估了自己对 C 语言的了解程度。

因此,你可能会从一个对 C 语言有 “相当 “了解的人那里听说,编写安全的 C 代码几乎是不可能的,但如果他连自己对 C 语言了解多少都不知道,他又怎么会知道呢?

你开始明白元认知的问题了吗?

希望到此为止,我已经提供了足够多的有关统计学和邓宁-克鲁格效应的例子,让你对你认为自己了解的 C 可能并不准确代表你实际了解的情况产生疑问。

为了准确评估编写安全 C 代码的可行性,您是否需要具备大多数人根本不具备的技能水平?

换句话说:评估编写安全 C 代码是否存在技能问题的能力本身就是一个技能问题。

这个问题并不是依靠某些业内知名人士的意见就能解决的,因为他们自己可能并不像他们自己认为的那样了解那么多,而你也无法弄清楚这一点。

换句话说:为了准确评估狙击步枪的威力,你也许应该去问一个真正的狙击手,而不是一个错误地认为自己是狙击手的人。

不幸的是,我们无法客观地判断谁是出色的 C 程序员,谁不是。你需要一个杰出的 C 程序员来告诉你。这是一个自我暗示的问题。

我可以告诉你我是杰出的,我是能给你指出其他杰出程序员的王牌,但你怎么知道呢?我又怎么会知道呢?我很可能也高估了自己的能力。

我唯一能想到的办法就是以一个人作为参照,很少有人会不同意他是最优秀的 C 程序员之一,我推荐 Linus Torvalds。

以 Linus Torvalds 为参照,并不是说他说的每一句话都是福音,而是说他大概能分辨出谁也是优秀的 C 语言程序员。因此,任何为 Linux 项目(内核)做出贡献的人都必须至少是优秀的,因为无论如何,所有代码最终都会被托瓦尔兹否决。

因此,我将以 Linux 的风格作为参考。

有些人可能会认为有更好的程序员和更好的参考代码库。我不这么认为。最终还是要由你来决定,我所要做的只是举例说明 C 语言的最佳用法和基本用法。

让我们看一个非常简单的例子:分配一些内存,分配一些值,然后做一些事情:

struct person {
	char name[50];
	int age;
	struct person *boss;
};

int test(void)
{
	struct person *john = (struct person *)malloc(sizeof(struct person));

	if (john == NULL)
		return 1;

	memset(john, 0, sizeof(struct person));

	strcpy(john->name, "John Doe");
	john->age = 25;

	do_stuff(john);

	free(john);

	return 0;
}

这是一种非常典型的 C 代码编写方式,但我一眼就能看出编写者不是一个经验丰富的开发人员。首先,sizeof(struct person)过于冗长,我们可以用 sizeof(*john) 代替,这样就不需要指定变量的类型,如果类型发生变化,我们也不需要修改这段代码。其次,malloc() 返回 void *,任何对 C 语言稍有了解的人都知道,这些指针不需要被转换。因此,可以将这行代码清理为

struct person *john = malloc(sizeof(*john));

接下来,john == NULL 也是不必要的啰嗦:!john 更简单。

然后,我们可以在 memset() 调用中使用同样的 sizeof(*john) 技巧,但如果我们使用 calloc() 而不是 malloc(),然后在分配时将内存清零,就会简单得多。

但这不是 Linux 风格。Linux 风格直截了当,没有废话:

struct person john = { .name = "John Doe", .age = 25 };
do_stuff(&john);

这是否与传统代码的功能相同?是的。

当调用一个函数时,内存中会有一个位置存放与该函数相关的所有信息,其中包括局部变量。当函数退出时,内存将被重新利用。

这甚至不是 C 语言的特性,而是 CPU 的特性。C 作为一种低级语言,使得 CPU 的这些特性变得透明。

因此,函数内部的定义会自动分配所需的内存,同时你还可以对内存进行初始化。

内存清零怎么办?我没有将老大成员设置为 NULL,因此那里会有垃圾,对吗?不会。如果你设置了结构中的一个成员,那么其他所有成员都会清零。

不仅如此,如果我们将变量设置为静态,那么即使我们不初始化任何东西,所有内存也会清零。但更重要的是:数据将成为程序的一部分,因此在调用函数之前就会被设置,从而节省了时间。如果函数被多次调用,数据就不必一次又一次地被复制到堆栈中。

现在,我可以附上这个程序的汇编代码,用静态变量和非静态变量来说明 C 语言中一个关键字的差别有多大,但这可能会过于累赘,希望此时你已经能接受我的前提,即 C 语言初学者和专家之间存在数量级的差别。

而且要明确的是:比较不同 C 代码生成的汇编代码并不是矫枉过正:C 专家经常这样做,使用不同的编译器选项,甚至不同的编译器。一行 C 代码的背后可能有大量的工作和思考。

请记住,这绝对是我能想到的最基本的例子,而初学者和专家之间的差距已经非常大了。

让我们继续。

让我们来做一个更复杂的、实际需要动态内存分配的东西:通用单链表。

struct node_data {
	int value;
};

struct node {
	void *data;
	struct node *next;
};

void list_foreach(struct node *head, void (*func)(void *, void *), void *user_data)
{
	struct node *p = head;

	while (p) {
		struct node *next = p->next;
		(*func) (p->data, user_data);
		p = next;
	}
}

void list_free(struct node *head, void (*free_func)(void *))
{
	struct node *p = head;

	while (p) {
		struct node *next = p->next;
		(*free_func) (p->data);
		free(p);
		p = next;
	}
}

struct node *list_prepend(struct node *head, void *data)
{
	struct node *node;

	node = malloc(sizeof(*node));
	if (!node)
		return NULL;

	node->data = data;
	node->next = head;

	return node;
}

int test(void)
{
	struct node *head = NULL;
	struct node *new_head;
	struct node_data *node_data;

	node_data = malloc(sizeof(*node_data));
	if (!node_data) {
		list_free(head, free);
		return 1;
	}
	node_data->value = 1;

	new_head = list_prepend(head, node_data);
	if (!new_head) {
		list_free(head, free);
		return 1;
	}
	head = new_head;

	node_data = malloc(sizeof(*node_data));
	if (!node_data) {
		list_free(head, free);
		return 1;
	}
	node_data->value = 2;

	new_head = list_prepend(head, node_data);
	if (!new_head) {
		list_free(head, free);
		return 1;
	}
	head = new_head;

	list_foreach(head, do_stuff, NULL);

	list_free(head, free);

	return 0;
}

由于这段代码是通用代码(就像我们编写一个库一样),我们存储自定义数据的结构必须与存储通用节点信息(例如下一个指针)的结构分开。因此,每次我们要向列表中添加一个新节点时,都必须进行两次内存分配:一次是节点,另一次是节点数据。

这样做很麻烦,但大多数人都会这样做,例如 GLib (SList) 就是这样实现的。

大多数人不会去检查内存分配是否成功,他们会让分段故障发生(无论如何都可能发生),这样代码就会简单得多。但在这里,我想展示如何正确检查这些分配。

但这不是 Linux 的风格。C 王牌程序如何做到这一点?

struct llist_head {
	struct llist_node *first;
};

struct llist_node {
	struct llist_node *next;
};

struct node {
	struct llist_node node;
	int value;
};

#define llist_for_each(pos, node) \
	for (typeof(*(node)) *(pos) = (node); (pos); (pos) = (pos)->next)
#define llist_for_each_safe(pos, node) \
	for (typeof(*(node)) *(pos) = (node), *n; (pos) && (n = (pos)->next, 1); (pos) = n)

static inline void llist_add(struct llist_node *new, struct llist_head *head)
{
	new->next = head->first;
	head->first = new;
}

static inline bool node_add(struct llist_head *list, int value)
{
	struct node *node;
	node = malloc(sizeof(*node));
	if (!node)
		return false;
	node->value = value;
	llist_add((struct llist_node *)node, list);
	return true;
}

int test(void)
{
	struct llist_head *list;
	int r = 1;

	list = calloc(1, sizeof(*list));
	if (!list)
		return 1;

	if (!node_add(list, 1))
		goto cleanup;

	if (!node_add(list, 2))
		goto cleanup;

	llist_for_each(p, list->first)
		do_stuff((struct node *)p);

	r = 0;
cleanup:
	llist_for_each_safe(p, list->first)
		free(p);
	free(list);

	return r;
}

完全不同。

首先要注意的是没有多重分配,这是因为每个自定义节点都包含所有通用节点信息。因为 struct llist_node 元素是 struct node 内部的第一个元素,所以 struct node 类型的变量可以充当 struct llist_node。说到底,这只是内存,所有信息都在那里。

第二件要注意的事情是一个非常有用的宏 llist_for_each,它通过一个 for 循环遍历 list,但这个宏隐藏在这个宏中。这就是语法糖,它让这个库的用户生活得更轻松。

你只需知道有一个迭代器 struct llist_node * 会遍历所有节点,但分配的内存是用于 struct 节点的,因此自定义数据也在其中。

还有一个类似的宏 llist_for_each_safe,用于删除列表中的节点,但与上面的典型版本不同的是,这次我们只需对每个节点调用一次 free(),而不是两次。

根据我的经验,很少有人会这样编程,事实上,我敢打赌,有 30 年经验的 C 程序员都自认为是专家,但他们从未见过与此相差无几的东西。

然而,真正的王牌程序员不仅见过这样的代码,还能告诉你有些代码库在使用这些宏时会遇到困难,因为它们在 C89 上无法使用,只能在 C99 及以后的版本中使用,以及如何为 C89 修改这些宏。

更有甚者,王牌程序员会告诉你,根据 libc 和内核的不同,检查 malloc() 是否失效可能没有太大意义,因为即使在系统内存耗尽的情况下(这种情况本来就不太可能发生),malloc() 也不会失效(例如,这取决于 Linux 的超量提交策略),相反,一旦你尝试使用内存,进程就会被杀死。因此,我们不妨跳过这些检查,让系统来处理这些情况,从而大大简化代码。

实际上,这也是对 Linux 所做工作的重大简化。我把通用节点结构放在自定义节点的开头,正是为了避免解释 container_of()宏。在现实世界中,该结构可以放在其他位置,例如,如果你认为它是两个对象的混合体,如列表和设备。如需了解更多信息,请阅读这篇文章:神奇的 container_of() 宏

如果按照 GLib(基础)风格编写 C 代码的人都离开了,转而使用 Rust,那就只剩下那些能写出像这样漂亮的 C 代码的王牌了。在我看来,这是积极的一面。

程序如何简单读取文件内容?

int test(char *filename)
{
	FILE *file = fopen(filename, "r");

	if (!file)
		return 1;

	char buffer[0x1000];

	while (1) {
		size_t r = fread(buffer, 1, sizeof(buffer), file);
		if (!r)
			break;
		fwrite(buffer, r, 1, stdout);
	}

	fclose(file);

	return 0;
}

如此直截了当,没有太多改进的余地,对吗?

int test(const char *filename)
{
	int fd = open(filename, O_RDONLY);
	if (fd < 0)
		return 1;

	struct stat st;
	if (fstat(fd, &st) < 0)
		return 1;

	char *buffer = mmap(0, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
	if (buffer == MAP_FAILED)
		return 1;
	write(1, buffer, st.st_size);

	munmap(buffer, st.st_size);

	close(fd);

	return 0;
}

C语言高手都知道,读取文件的重任其实是由内核来承担的,mmap() 是一个系统调用,它告诉内核,我们需要在文件描述符和进程内存之间架起一座桥梁。因此,当我们试图读取内存映射中内核尚未加载的部分时,就会触发一个页面故障,内核会将下一块数据加载到相关页面,然后将控制权返回给之后的进程,该进程将继续读取数据,直到下一个页面故障发生。

这就在用户空间的进程和内核空间的代码之间创造了一个完美的舞步。

无论如何,fread()/fwrite() 版本中都会出现这种舞动,但通过使用 mmap(),我们简明扼要地说明了责任在内核。

这只适用于 POSIX 系统,如果我们想让代码适用于所有系统,就需要为不同版本编写条件代码,比如 Win32 上的 CreateFileMapping。C 语言专家对这类代码库并不陌生。

我还可以继续举出更多不同的例子(也许我稍后还会再补充几个),但希望我已经证实了我的说法,即最顶尖的 10% C 程序员编写代码的方式确实与其他程序员明显不同。

很多人对 “10 倍程序员 “的说法嗤之以鼻,但就 C 语言知识而言,顶级程序员的知识确实可能至少是底层程序员的 10 倍(可能更多)。

如果对 C 语言程序员进行淘汰,让大多数程序员(甚至是 90% 的程序员)转而学习 Rust 或其他语言,那么剩下的 10% 程序员就会成为拥有卓越技能的程序员,从而大大提高 C 语言代码的整体质量。

对前线士兵有利的东西不一定对狙击手最好。

因此,是的,也许大多数 C 语言程序员应该像白宫建议的那样转行,但不是所有人。

在这篇文章中,我只是略微介绍了顶级 C 代码的表象,但希望你能明白其中的含义。如果你想深入了解 Linus Torvalds 认为的 “好品味”,我在这里写了一篇由三部分组成的图解:好品味代码图解。

如果您有其他杰出 C 代码的例子,请随时发表评论。

本文文字及图片出自 C skill issue; how the White House is wrong

O1CN01m6hVhS1OQdoZnZM1V_!!2768491700.jpg_640x640q80_.webp

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK