鉴于字符串在.NET中是不可变的,我想知道为什么设计字符串时string.Substring()
要花O(substring.Length
)时间,而不是O(1)
?
即,权衡是什么(如果有)?
鉴于字符串在.NET中是不可变的,我想知道为什么设计字符串时string.Substring()
要花O(substring.Length
)时间,而不是O(1)
?
即,权衡是什么(如果有)?
Answers:
更新:我非常喜欢这个问题,我只是写了博客。请参阅字符串,不变性和持久性
简短的答案是:如果n不大,则O(n)为O(1)。 大多数人从微小的字符串中提取微小的子字符串,因此复杂度渐近地增长完全无关紧要。
长答案是:
一种不可变的数据结构,其构造使得实例上的操作仅通过少量的复制或新分配(通常为O(1)或O(lg n))就可以重复使用原始内存,这被称为“持久”不变的数据结构。.NET中的字符串是不可变的。您的问题本质上是“为什么它们不持久”?
因为当您查看通常在.NET程序中对字符串执行的操作时,简单地创建一个全新的字符串在所有相关方面都几乎不会恶化。建立复杂的持久性数据结构的费用和困难并不能自负。
人们通常使用“子字符串”从稍长的字符串(可能是数百个字符)中提取一个短字符串(例如,十个或二十个字符)。您在逗号分隔的文件中有一行文本,并且要提取第三个字段,即姓氏。该行可能长几百个字符,名字将是几十个。在现代硬件上,字符串分配和50个字节的内存复制速度非常快。这使得它由一个指针到一个现有的字符串的中间加一个长度为一个新的数据结构也惊人地快无关; 按照定义,“足够快”足够快。
提取的子字符串通常尺寸小且寿命短;垃圾收集器将很快回收它们,并且它们最初并没有在堆上占用太多空间。因此,使用鼓励重复使用大多数内存的持久策略也不是一件容易的事。您要做的只是使您的垃圾收集器变慢,因为现在它不得不担心处理内部指针。
如果人们通常在字符串上执行的子字符串操作完全不同,那么采用持久方法是有意义的。如果人们通常有上百万个字符的字符串,并且正在提取数千个大小在十万个字符范围内的重叠子字符串,并且这些子字符串在堆中存在很长的时间,那么使用持久性子字符串是很有意义的方法 不这样做将是浪费和愚蠢的。但是,大多数业务线程序员甚至都不会像这些事情那样模糊地做任何事情。.NET并非为满足人类基因组计划的需求而量身定制的平台。DNA分析程序员每天都必须解决这些字符串使用特性方面的问题。你没有的几率很好。很少有人构建自己的持久性数据结构,这些结构与他们的使用场景非常匹配。
例如,我的团队编写的程序会在您键入时对C#和VB代码进行即时分析。其中一些代码文件非常庞大,因此我们无法进行O(n)字符串操作来提取子字符串或插入或删除字符。我们已经建立了一堆持久的不可变数据结构,用于表示对文本缓冲区的编辑,这使我们能够在典型的编辑中快速有效地重用大量现有字符串数据以及现有的词法和句法分析。这是一个很难解决的问题,其解决方案仅针对C#和VB代码编辑的特定领域而定制。期望内置字符串类型为我们解决此问题是不现实的。
string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ...
或其他版本。我的意思是读取整个文件,然后处理各个部分。如果字符串是持久的,则这种代码会更快,并且需要更少的内存。您将始终在内存中只保留文件的一个副本,而不是复制每一行,然后复制每一行的部分进行处理。但是,就像Eric所说的那样-这不是典型的用例。
String
被实现为一个持久的数据结构(标准中没有指定,但是我知道的所有实现都可以做到这一点)。
正是由于字符串是不可变的,因此.Substring
必须至少复制原始字符串的一部分。复制n个字节应花费O(n)时间。
您认为如何在恒定时间内复制一堆字节?
编辑:Mehrdad建议根本不复制该字符串,但保留对其一部分的引用。
考虑在.Net中,一个多兆字节的字符串,有人在该字符串上调用.SubString(n, n+3)
(对于字符串中间的任何n)。
现在,仅因为一个引用保留了4个字符,便无法收集整个字符串吗?这似乎是对空间的荒谬浪费。
此外,跟踪对子字符串(甚至可能在子字符串内部)的引用,并尝试在最佳时间进行复制以避免破坏GC(如上所述),这使该概念成为噩梦。复制.SubString
并维护简单的不可变模型要简单得多,也更可靠。
编辑: 这是有关在较大的字符串中保留对子字符串的引用的危险的少量阅读资料。
memcpy
仍为O(n)的值。
char*
子字符串。
NULL
终止。如Lippert的帖子所述,前4个字节包含字符串的长度。正如Skeet所指出的,这就是为什么它们可以包含\0
字符的原因。
Java(与.NET相对)提供了两种方法Substring()
,您可以考虑是否要保留引用或将整个子字符串复制到新的内存位置。
该简单对象与原始String对象.substring(...)
共享内部使用的char
数组,然后new String(...)
可以将其复制到新数组(如果需要)(以避免妨碍原始对象的垃圾回收)。
我认为这种灵活性是开发人员的最佳选择。
.substring(...)
。
Java曾经引用较大的字符串,但是:
我觉得可以改进:为什么不只有条件地复制呢?
如果子字符串的大小至少是父字符串的一半,则可以引用父字符串。否则,只能复制一份。这避免了泄漏大量内存的同时仍提供了显着的好处。
char[]
(使用不同的起点和终点指针)变为创建一个new String
。这清楚地表明,成本效益分析必须显示出对创建新产品的偏好String
。
此处的答案均未解决“包围问题”,也就是说,.NET中的字符串表示为BStr(指针“之前”存储在内存中的长度)和CStr(字符串以A结尾)的组合。 '\ 0')。
字符串“ Hello there”因此表示为
0B 00 00 00 48 00 65 00 6C 00 6F 00 20 00 74 00 68 00 65 00 72 00 65 00 00 00
(如果char*
在- fixed
语句中将其分配给,则指针将指向0x48。)
这种结构允许快速查找字符串的长度(在许多情况下很有用),并允许将指针以P / Invoke的形式传递给Win32(或其他)期望以空值终止的字符串的API。
当您执行Substring(0, 5)
“哦,但我保证最后一个字符后会有一个空字符”规则时,说您需要进行复制。即使您将子字符串放在末尾,也没有地方放置长度而不会破坏其他变量。
但是,有时候,您确实确实想谈论“字符串的中间部分”,而不必关心P / Invoke行为。最近添加的ReadOnlySpan<T>
结构可用于获取无副本子字符串:
string s = "Hello there";
ReadOnlySpan<char> hello = s.AsSpan(0, 5);
ReadOnlySpan<char> ell = hello.Slice(1, 3);
在ReadOnlySpan<char>
“串”存储长度独立,且这个值结束后有一个“\ 0”它不能保证。它可以以多种方式“像字符串”使用,但不是“字符串”,因为它既没有BStr也没有CStr特征(两者都少得多)。如果您从不(直接)进行P / Invoke,则没有太大的区别(除非您要调用的API没有ReadOnlySpan<char>
重载)。
ReadOnlySpan<char>
不能用作引用类型的字段,因此还有ReadOnlyMemory<char>
(s.AsMemory(0, 5)
),这是具有a的间接方式ReadOnlySpan<char>
,因此string
存在相同的差异。
关于先前答案的一些答案/评论谈到,当您继续谈论5个字符时,让垃圾收集器必须保留一百万个字符的字符串很浪费。这正是使用该ReadOnlySpan<char>
方法可以获得的行为。如果您只是进行简短的计算,则ReadOnlySpan方法可能更好。如果您需要将其保留一段时间,并且只保留原始字符串的一小部分,那么执行适当的子字符串(以修剪掉多余的数据)可能会更好。中间某处有一个过渡点,但这取决于您的特定用法。