C字符串:一个50年的错误

Lobsters Hottest 新闻

摘要

对C语言中空终止字符串的批评,认为基于长度的字符串在现代编程中更优越,可以减少错误并提高性能。

<p><a href="https://lobste.rs/s/upgpyq/c_strings_50_year_mistake">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/15 11:43

# C 字符串:一个持续 50 年的错误 来源:https://longtran2904.substack.com/p/c-strings-a-50-year-mistake [](https://substackcdn.com/image/fetch/$s_!qObY!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F126c1944-b2bd-44ad-9c65-2e050794599e_1750x1250.png) C 语言中一个如今已显过时的设计选择——也可以说是其最大的错误之一——就是使用空终止字符串。¹(https://longtran2904.substack.com/p/c-strings-a-50-year-mistake#footnote-1) 将字符串视为指向字符“流”的指针,并依赖一个终止的 `NULL` 字符,在 20 世纪 70 年代可能因为当时的内存和性能限制而更有道理,但如今基本上没有理由继续使用它。 大多数较新的语言和流行框架所采用的正确现代选择是基于长度的字符串——基本上就是一个包含指针和大小的结构体。 但这究竟好在哪里呢? 字符串的长度是一份极其宝贵的信息。如果不存储它,就会迫使所有使用该字符串的代码要么反复调用 `strlen`,要么逐字节遍历。这两种方法都很低效,并且都会导致整体代码质量下降。 代码也极其容易出错,因为在运行时检查并强制长度会变得更困难、更慢、更繁琐。调试也会受影响,因为将这些宝贵信息传递给其他工具和分析器会变成一场噩梦(例如,在不同的工具中,查看 `char*` 与 `char[N]` 与 `char*[N]` 的处理方式不一致)。 我个人认为,C 语言的许多内存相关问题和溢出错误在很大程度上都源于这个过时的设计选择。²(https://longtran2904.substack.com/p/c-strings-a-50-year-mistake#footnote-2) 其他人可能不同意,认为在大多数情况下哨兵值(sentinel)已经足够,无需存储字符串长度。然而,在不了解长度的情况下,字符串变得更像链表,具有串行访问模式,而不是数组的随机访问。 C 字符串的支持者经常通过论证这种迭代风格实际上*更可取*来为其辩护,因为它鼓励**单遍**算法而不是**多遍**遍历,其假设是字符串的遍历次数越少就越高效。 他们忽略的是,大多数程序员,尤其是初学者,并不会自然而然地以这种方式思考。他们通常编写的代码会假设字符串长度很容易获得。我还会认为,大多数常见的字符串例程用这种方法表达起来更自然。结果,现代程序中许多不必要的多遍遍历正是由反复调用 `strlen` 造成的。此外,即使不考虑这一点,认为更少的遍历总是更好的假设本身就是错误的,这源于对现代 CPU 工作原理的误解 (https://nrk.neocities.org/articles/cpu-vs-common-sense)。 当然,强制使用基于哨兵的设计也有一些优点,我们稍后会详细探讨。 C 语言中最常用的标准库函数之一大概是 `snprintf`。为了帮你回忆一下,这是它的原型: 好吧,你能告诉我它接受的整数和返回的整数分别是什么吗? 如果你是 C 语言新手,并且没有太留意——可能只是粗略浏览文档、阅读示例代码或回忆起之前听到的某些东西——你可能会回答类似:`snprintf` 接受输出缓冲区的大小作为参数,并返回它本应产生的字符串长度。 但一旦你实际坐下来使用它,这个答案就不够了。你会开始疑惑:“长度”在这里到底是什么意思?³(https://longtran2904.substack.com/p/c-strings-a-50-year-mistake#footnote-3) 它是否包括空终止符? 而这正是问题的核心。通过要求每个字符串以空字节结尾,“字符串长度”的概念在实践中变得微妙地模棱两可。这意味着每当你使用字符串相关函数时——无论是在标准库、大型生产代码库还是第三方库中——你都必须不断地问自己一系列问题:这个函数会为我写入空终止符吗?我需要为字符串加上空字节分配空间吗?参数(或返回值)的大小是否包含空终止符? 更糟糕的是,C 标准库在定义“长度”方面并不完全一致,这使得可靠地推断哪个是哪个变得更加困难。`snprintf` 就是一个很好的例子:缓冲区及其大小必须考虑空终止符。许多人正确地推断出这一点,并自然地假设返回值遵循相同的规则。但他们会错。实际上,两者的行为是相反的:输入整数包含空终止符,而输出整数不包含。 另一方面,表达式 `sizeof(str)` 可能看起来像是一个与字符串相关的函数,尤其是对于初学者来说,它的名字也以 `s` 开头、以 `f` 结尾,并且通常出现在你想要获取某个东西的“大小”的上下文中——它返回的大小*确实*包含空终止符。 但痛苦并未止步于此。哦不,不,不,我天真的夏天孩子。想象你正在编写一些快速的一次性代码: 然后你想认真起来,将测试字符串重构为运行时字符串: 你现在有了一个 bug,因为 `strlen` 返回的长度不包括空终止符。 真是他妈的妙极了。 我不确定,但我怀疑 `snprintf` 返回不包含空终止符的长度部分是为了支持像这样的模式: 你可能会想:等等,这难道不是意味着你不断地覆盖前一个空终止符吗?换句话说,所有这些中间终止符不都是不必要的吗? 你说得对。这是不必要的,理论上可能会稍慢一些,但我怀疑性能影响是否显著。我认为更糟糕的是它造成的混乱。因为大多数人不会期望代码一直做**不必要**的事情,这最终让他们相信 `snprintf` 不会空终止字符串。这就是为什么你经常看到人们在末尾添加额外的 `ptr[offset] = 0;`。这*再次*是不必要的,并且*再次*造成了更多的混乱,强化了初学者认为 `snprintf` 不空终止字符串的错误信念。 字符串长度有两种不同的含义,也导致无效字符串有两种不同的表示:空字符串和 null 字符串。结果,每段处理字符串的运行时代码都必须不断检查这两种情况。⁵(https://longtran2904.substack.com/p/c-strings-a-50-year-mistake#footnote-5) 类似的问题也出现在数组上。通过将数组退化为指针,你会丢弃宝贵的信息,比如它们的长度。以前无需特殊处理就能正常工作的代码,现在在数组为 null 时会失效。在 C# 中一个常见的例子是遍历数组或字符串。由于两者都是引用类型,如果引用为 null,尝试访问它们的 `Length` 属性或遍历它们会抛出异常,因此在使用前需要额外的 null 检查。 相反,如果你将字符串存储并传递为一个包含指针和长度的简单结构体,那么前面的例子就可以正常工作,没有任何问题。⁶(https://longtran2904.substack.com/p/c-strings-a-50-year-mistake#footnote-6) 现在只有一种正确的方法来测试字符串是否为空:检查其长度是否为 0。指针本身可以是 null 或非 null,因为有些合法情况下空字符串仍然有有效的指针⁷(https://longtran2904.substack.com/p/c-strings-a-50-year-mistake#footnote-7)——例如,当字符串已前进到其末尾,或者它引用较大字符串中的空子串时。 切换到基于长度的字符串的另一个优点是,相同的类型可以重复用于字节数组。由于所有代码现在都基于显式长度而不是依赖空终止符进行操作,该类型可以安全地存储任意二进制数据,包括包含空字节的值。而且,就像大多数 ASCII 字符串函数仍然适用于 UTF-8 一样,这些基于长度的字符串操作也可以自然地应用于任意二进制数据:扫描、拆分、修剪、切片以及其他字符串操作。 既然我们谈到了二进制数据,空终止字符串仍然带回了长度有两种不同含义的同样问题。你被迫决定是否存储空字节。存储空字节会使写和执行读操作的代码更简单,更接近正常的使用模式。不存储它会节省空间并减少处理开销,而这通常正是使用二进制格式的主要原因之一。⁸(https://longtran2904.substack.com/p/c-strings-a-50-year-mistake#footnote-8) 显然,始终使用基于长度的字符串可以避免所有这些问题和混淆,并且首先消除了做出这些艰难选择的必要。 最后,也许是所有优点中最重要的一个:**高效的子串**。这是基于长度的字符串相对于 C 字符串**最大的优势**,超过了前面所有优点的总和。 通过要求每个字符串以空终止符**结尾**,你迫使所有常见的字符串操作——如修剪、切片、分割、标记化和搜索——分配新的字符串,通常还伴随着多次中间分配和复制。 这也意味着,当不再**要求**字符串具有空终止符时,上述所有操作都可以简单地返回一个引用原始字符串一部分的新字符串,从而避免了分配、复制以及任何额外的内存管理。 为常见文件格式(如 CSV、Markdown、JSON 和 C)编写词法分析器和解析器变得如此简单和高效。与其分配和复制每个标记,结果解析树中存储的名称和值可以简单地引用原始输入的切片,任何使用该树的代码可以继续在这些相同的切片上操作,而无需额外的内存管理。 哨兵值可以是你编程工具箱中一个有用的工具,用于**强制不变量**(https://fgiesen.wordpress.com/2010/09/27/data-structures-and-invariants/),并且可能提供一些**性能优势**(https://easyperf.net/blog/2016/11/21/Sentinels)。然而,随着现代硬件和编译器的进步,性能方面的论点在很大程度上已经过时(除了小众情况)。另一方面,减少程序不变量本质上是 C 字符串仅存的最后一个优势。 一个常见的例子是手工编写的**标记检查**(https://guide.handmadehero.org/code/day510/#2858)。使用哨兵值,空终止字符串可以在循环中随时安全地检查当前字节。更好的是,它们可以进行前瞻检查并检查字节序列,同时保证顺序保持不变,从而允许短路求值提前失败并跳过表达式的其余部分(例如 `s[i] == 'f' && s[i+1] == 'o' && s[i+2] == 'r'` 将始终有效)。 另一方面,基于长度的字符串使这些检查稍微麻烦一些,因为由于遇到空白字符而终止标记与到达输入的末尾是不同的。前瞻逻辑也变得稍微复杂一些,需要更系统的方法。 人们经常提出的另一个缺点是,由于 C 使用空终止字符串,大多数流行的库和操作系统 API 都需要它们。⁹(https://longtran2904.substack.com/p/c-strings-a-50-year-mistake#footnote-9) 这确实是真的,但在实践中通常并不那么重要——尤其是在 Windows 上,你通常已经需要将 UTF-8 字符串转换为 UTF-16。在这种情况下,追加一个空字节的额外成本相对较小。 如何很好地实现这样一个字符串层,并配有精心设计的 API 和恰到好处的抽象,超出了本文的范围。我将来可能会撰写更深入的后续文章,探讨不同的设计权衡,讨论所涉及的各种考虑因素,并分享我一路学到的技巧,以及我在经验中发现有用的辅助工具。你也可以同时查看末尾的**资源**部分。 在此期间,你可以查看这些资源来获得一个粗略的概念。其中一些实际上启发我开始使用这些*冗长*的字符串。 - 你可以看看 Ryan Fleury 的 RAD Debugger (https://www.youtube.com/watch?v=s7pTd4np5Rw),该项目在整个项目中默认使用基于长度的字符串,并由区域分配器(arenas)支持。与此相关,他的 Metadesk 库 (https://github.com/ryanfleury/metadesk/tree/master) 也是一个很好的例子,展示了独立的解析库如何使用这种方法。 - Allen Webster 还有一个关于为代码库构建基础层的优秀系列 (https://www.youtube.com/playlist?list=PLT6InxK-XQvNKTyLXk6H6KKy12UYS_KDL),其中包含一个专门讨论字符串的章节 (https://www.youtube.com/watch?v=2wio9UOFcow&list=PLT6InxK-XQvNKTyLXk6H6KKy12UYS_KDL&index=7)。 - VoxelRifts 的视频 (https://youtu.be/3IAlJSIjvH0?t=335) 关于他学习 C 的经历也涉及了这一点。 - Chris Wellons 已经专门使用这种方法有一段时间了 (https://nullprogram.com/blog/2023/10/08/#strings),并且还在 C++ 中进行实验 (https://nullprogram.com/blog/2024/04/14/)。 - NRK 之前链接的关于 `strlcpy` 的文章末尾也包含了一个优秀附录 (https://nrk.neocities.org/articles/cpu-vs-common-sense#addendum-don-t-throw-the-length-away)。 - 像往常一样,你可以看看我的基本不活跃的仓库 (https://github.com/longtran2904/Base/tree/main),我主要用它来做实验。 但在继续之前,我想谈谈设计字符串系统时的一个核心架构决策:**不可变性**。这应该是你整个代码库中大多数 API 都要保证的基本规则。一旦构造了字符串,就不能改变它,其内容可以被视为常量。任何接收字符串并返回字符串的函数都必须尊重并保持这一属性。 请注意,这些保证仅在函数签名和 API 边界级别适用。在函数内部,你可以自由地随意修改、原地改变或进行任何你需要的疯狂修改。这样,你既能在更高的信息流层面获得函数式编程中不可变性的所有好处,同时仍然在更低的实现层面保留过程式编程的全部灵活性和强大功能。 有趣的是,这种保证主要是一种编码和 API 约定,而不是由语言强制或运行时检查的东西。有人可能认为这会降低安全性,并且不断在心智上跟踪这些约束的认知开销会使代码更容易出错且更难编写。 但实际情况往往相反。在实践中,维护这些约束并不特别困难,尤其是在不同的字符串类型和操作之间有**清晰的区分**(https://www.gingerbill.org/article/2024/04/05/string-type-distinctions/)时。通过不依赖语言来强制它们,你也使自己的代码库摆脱了某个随机语言委员会的控制,从而获得更大的掌控权。你可以决定何时放松或强制执行这些规则,以及在什么粒度层面上。记住:**语言只是 API**。 在收尾之前,让我们再来看看

相似文章

Dependable C

Lobsters Hottest

一个全面的资源,提供编写可靠、安全的C代码的指南和建议,涵盖未定义行为、内存模型以及特定版本的注意事项。

C++ 标准库在过去十五年间一直在自我撤步,证据公开

Lobsters Hottest

一份详细的目录,列出了从 C++11 到 C++26 期间被正式弃用、非正式不推荐或由于 ABI 约束实际上已损坏但无法修复的 C++ 标准库特性。文章指出,C++ 委员会推出一系列替代品来替换其自身特性的模式始终如一,其中包含一个基准测试,显示 Rust 和 C++ 标准库容器之间的 P99 延迟差异高达 58 倍。