为什么要使用以null结尾的字符串?或:以null结尾的字符串与字符+长度存储相比有何优劣之处?

36

我正在用C语言编写一款语言解释器,我的string类型包含一个length属性,如下所示:

struct String
{
    char* characters;
    size_t length;
};
因此,我必须在我的解释器中手动处理这种字符串,因为C语言没有内置支持。考虑到底层的C要求使用简单的空字符结尾的字符串,但似乎有很多不适用的情况:
如果你使用"length"而不是寻找空字符,那么边界检查就内置了。
必须遍历整个字符串才能找到它的长度。
必须做一些额外的工作来处理空字符出现在空字符结尾字符串中间的情况。
空字符结尾的字符串对Unicode的支持较差。
非空字符结尾的字符串可以更好地进行字符串切片(注意:在我的语言中,字符串是不可变的)。显然第二个方法更慢(并且更容易出错:思考将错误检查添加到两个函数的begin和end参数中)。
struct String slice(struct String in, size_t begin, size_t end)
{
    struct String out;
    out.characters = in.characters + begin;
    out.length = end - begin;

    return out;
}

char* slice(char* in, size_t begin, size_t end)
{
    char* out = malloc(end - begin + 1);

    for(int i = 0; i < end - begin; i++)
        out[i] = in[i + begin];

    out[end - begin] = '\0';

    return out;
}
在这一切之后,我不再考虑是否应该使用空结尾字符串:我在思考为什么C要使用它们!
所以我的问题是:我是否忽略了空结尾的任何好处?

由于在C语言中malloc()函数的开销很大,我建议使用以下结构体:struct String { size_t length; char[1] characters; } 只需分配strlen(s)+1+sizeof(size_t)或strlen(s)+sizeof(String)字节,并将字符串复制到地址&characters即可。 - Aaron Digulla
很简单。这就是好处。 - Mike Dunlavey
这个回答解决了你的问题吗?null终止字符串的原理是什么? - undefined
10个回答

33

以下内容翻译自Joel的Back to Basics:

C语言中为什么要这样使用字符串呢?这是因为UNIX操作系统和C编程语言在PDP-7微处理器上诞生,该处理器支持ASCIZ字符串类型。ASCIZ代表“带有结尾零的ASCII字符”。

这是存储字符串的唯一方法吗?实际上,这是存储字符串最糟糕的方式之一。对于非平凡程序、API、操作系统、类库等,你应该像避免瘟疫一样避免使用ASCIZ字符串。


20
丹尼斯·里奇的观点有所不同。BCPL采用长度+内容表示方法,长度在一个字节中包含。B转而使用以空字符结尾的字符串“部分原因是为了避免将计数保存在8或9位槽中导致字符串长度限制的限制,部分原因是因为我们的经验表明,保持计数比使用终止符不太方便。”(《C语言的发展》,http://cm.bell-labs.com/cm/cs/who/dmr/chist.pdf) - AProgrammer

19

通常的解决方案是同时保持字符串长度并维护空字符终止符。这不需要太多额外的工作,意味着你随时可以将字符串传递给任何函数。

空字符终止的字符串通常会影响性能,因为发现长度所需的时间取决于长度本身。但好的一面是,在C语言中,空字符终止的字符串是表示字符串的标准方式,如果你想使用大多数C库,就必须支持它们。


1
这就是Lua的作用。它使得与C的接口在普通使用情况下非常干净,并且仍然支持任意长度的二进制缓冲区。 - RBerteig
3
大多数情况下都是这样做的!您甚至不必始终维护空终止符-只需在需要时进行 str[len] ='\0' 即可。这通常是在C++中使用std::string::c_str的方式。 - Daniel Earwicker
我所指的“大多数东西”包括大多数字符串类和大多数解释器字符串表示。在Windows上广泛使用的一个例子是BSTR类型。 - Daniel Earwicker
这正是我提出这个问题的原因;我想我可能错过了某些解决方案。现在看起来很明显,但我没有想到! - Imagist
1
如果你必须等待某些东西,那么它的意义会更加重要,我认为! - Daniel Earwicker
显示剩余2条评论

9

空结束字符串的一个优点是,如果您按字符遍历字符串,只需要保留单个指针来寻址字符串:

while (*s)
{
    *s = toupper(*s);
    s++;
}

对于没有哨兵的字符串,你需要保留两个状态位:指针和索引:

while (i < s.length)
{
    s.data[i] = toupper(s.data[i]);
    i++;
}

...或者一个当前指针和一个限制:

s_end = s + length;
while (s < s_end)
{
    *s = toupper(*s);
    s++;
}

当CPU寄存器成为一种稀缺资源时(并且编译器在分配寄存器方面做得不好),这是很重要的。现在,这已经不那么重要了。


4
"当CPU寄存器是稀缺资源时" - 在x86和x64中,寄存器仍然是稀缺资源。 - Jimmy
我不明白,如果我在我给出的struct示例中存储字符串,为什么不能将其用作限制? - Imagist
1
重点是在像上面这样的处理循环中,像你的基于长度的字符串最终会使用两个寄存器来进行字符串记录,而像惯用的C字符串这样的基于哨兵的字符串只使用一个寄存器(另一个寄存器是“免费”获得的,因为字符值正在按顺序加载以进行处理)。 - caf

8

长度也有它们的问题。

  • 长度需要额外的存储空间(现在不是很严重的问题,但在30年前是一个重要因素)。

  • 每次修改字符串时都必须更新长度,因此整体性能会下降。

  • 使用以NUL结尾的字符串仍然可以使用长度或存储指向最后一个字符的指针,因此如果您正在进行大量的字符串操作,则仍然可以达到与带长度的字符串相等的性能。

  • NUL-terminated字符串更简单 - NUL终止符只是像strcat这样的方法用于确定字符串的结尾的约定。因此,您可以将它们存储在常规char数组中,而无需使用struct。


1
额外的存储对于嵌入式系统仍然可能是一个大问题,这就是强调保持语言轻量级的原因之一。 - Jimmy
4
我的问题是:在嵌入式系统上,为什么要使用字符串?我认为我从未在进行机器人编程时使用过 char。唯一想到的例外是如果你正在为LED显示器编程(例如那些滚动文本的东西或软饮料机上的显示器),但是在那里的功能如此简单,以至于我很难想象额外的3个字节会造成问题(4个字节的int - 1个字节因为您不必存储空字符)。 - Imagist
你所建议的并不是那么简单。谁将负责缓冲区?新创建的临时字符串会这样做吗?我怀疑你不想要这个,然后你需要一种方法来拥有这样的非拥有字符串以避免复制。 - sharptooth

7

一个好处是,使用空终止符,任何以空终止的字符串的尾部也是一个以空终止的字符串。如果您需要将从第N个字符开始的子字符串(假设没有缓冲区溢出)传递到某些字符串处理函数中 - 没有问题,只需将偏移地址传递给该函数即可。当以其他方式存储大小时,您需要构造一个新字符串。


你能举一个字符串的例子,你可能想要打印出字符串的结尾吗? - weiqure
这可以用于连接字符串 - 你可能只想附加字符串的尾部,而不是整个字符串。然后你调用strcat(目标,源+偏移量); - 就完成了。 - sharptooth
取出前面的空格。您可以确定第一个非空格字符,而不是实际更改字符串,只需传递起始偏移量即可,这样可以节省分配新内存或复制数据的时间。 - Dan McGrath
3
我做的事情与我的结构体并没有太大区别:struct String new; new.characters = old.characters + offset; new.length = old.length - offset;。这有点繁琐,但只需要5条指令就可以完成,如果你需要在字符串开头执行某些操作,则与之相比这似乎微不足道。 - Imagist
如果你能像在Lisp中处理列表一样处理字符串,那么递归字符串匹配、拼写纠正等操作就会变得非常容易。 - Mike Dunlavey

6

有点偏题,但是有比你描述的更有效的处理带长度字符串的方法。可以创建一个像这样的结构体(在C99及以上版本中有效):

struct String 
{
  size_t length;
  char characters[0];
}

这将创建一个结构体,其长度位于开头,'characters'元素可用作char*,就像您当前的结构体一样。然而,不同之处在于,您可以为每个字符串仅在堆上分配一个项目,而不是两个。请按照以下方式分配字符串:

mystr = malloc(sizeof(String) + strlen(cstring))

例如 - 结构体的长度(即size_t)加上足够的空间来放置实际字符串。
如果您不想使用C99,也可以使用“char characters [1]”,并从字符串长度中减去1来分配。

4

以下是一些假设:

  • 无法有“错误”的空终止字符串实现。然而,标准化的结构体可能有特定于供应商的实现。
  • 不需要结构体。空终止字符串是一种 char* 的特殊情况,可以说是“内置”的。

2
虽然在大多数情况下我更喜欢使用数组 + 长度方法,但使用 null-terminated 也是有有效的理由的。
以一个32位系统为例。
存储一个7字节的字符串:
char * + size_t + 8个字节 = 19个字节
存储一个7字节的null-term字符串:
char * + 8个字节 = 16个字节
与您的字符串不同,null-term数组不需要是不可变的。我可以通过简单地放置一个空字符来截断 c-string。如果您进行编码,您将需要创建一个新的字符串,这涉及到分配内存。
根据字符串的使用情况,您的字符串将永远无法与c-strings相匹配,因为c-strings的性能优势。

2
你可以通过减少长度来截断一个带有长度的字符串。通常这意味着你有两个长度——字符串的当前长度以及你当前为字符串分配的内存量。这使得它可以动态调整大小,而无需在每次修改时重新分配内存。 - Jason Williams
3
确实,这是正确的方法;我基于提问者的字符串结构给出了答案,它可以让你缩短长度,但无法再次利用该空间。Ropes(绳索)是另一种有趣的处理字符串的方式。http://en.wikipedia.org/wiki/Rope_(computer_science) - Dan McGrath
几个问题:假设一个字节是8位,那么32位系统应该有 sizeof(size_t) == 4sizeof(char*) == 4,对吗?而且使用我的方法,你不必为第一种方法使用8个字符。所以我得到的是:4 + 4 + 7 = 15,而空终止方法得到的是4 + 8 = 12。我不是在质疑你的观点,只是在质疑导致你观点的数学。 - Imagist
@Dan McG(评论)我的字符串将存储在垃圾收集系统中,这将允许我回收空间。我的解释器在内部使用绳索;垃圾收集器将利用空闲周期将绳索展平为字符串。 - Imagist

2

你说0结尾是一种在类型检查和性能方面都很差的方法,已有回答总结了它的来源和用途。

我喜欢Delphi存储字符串的方式。我相信它在(可变长度)字符串之前维护了长度/最��长度。这样可以为兼容性而使字符串以空字符结尾。

我对你的机制有以下顾虑: - 额外指针 - 不可变性在语言的核心部分; 通常字符串类型不是不可变的,因此如果你重新考虑它将会很困难。你需要实现一个"在更改时创建副本"的机制。 - 使用malloc(效率较低,但可能仅出于简便?)

祝你好运;编写自己的解释器可能会在理解编程语言的语法和语法方面非常有益!(至少对我来说是这样的)


3
大多数高级编程语言都有不可变的字符串。 - Nick Johnson
@NickJohnson 大多数低级语言都有不可变的字符串。而大多数高级语言则有可变的字符串。 - Samie Bencherif
2
哪些高级语言具有可变字符串? - Carl Smith

0

我认为主要原因是标准对除char以外的任何类型的大小都没有具体说明。但是sizeof(char) = 1,这绝对不足以表示字符串的大小。


每个字符串需要2^CHAR_BIT个字符就足够了。 - Daniel Earwicker
它只有255个字符。太少了。 - Kirill V. Lyadvinsky
不,每个字符串的2 ^ CHAR_BIT - 1个字符就足够了。如果您从未处理过超过255个字符的字符串,则只需为非常有限的问题域编程。但是C确实对其他整数类型有具体规定-例如,int的范围必须至少为-32767到+32767。特别是C表示size_t必须能够容纳任何对象的大小,因此将其作为标准化的字符串长度是可以的。 - caf
@caf,如果你不知道如何表示额外的字符长度,那么你的创造力还不够。 :) - Daniel Earwicker
@Imagist,你问为什么C使用空字符终止字符串。我认为,最初的size_t大小等于1。 - Kirill V. Lyadvinsky
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接