考虑到字符串在.NET中是不可变的,我想知道为什么它们的设计使string.Substring()需要O(substring.Length时间,而不是O(1)时间?
也就是说,如果有的话,权衡是什么?
- 什么是N?也许是绳子的长度?
- @穆罕默德:是的,对不起,修好了……
- @我喜欢这个问题。你能告诉我如何在.NET中确定给定函数的o()吗?是很清楚还是应该计算?谢谢你
- @Odiseh:有时(像在本例中一样),很明显字符串正在被复制。如果不是这样,那么您可以查看文档、执行基准测试,或者尝试查看.NET框架源代码以了解它是什么。
更新:我非常喜欢这个问题,我只是在博客上写了它。参见字符串、不变性和持久性
简短的回答是:如果n不变大,o(n)是o(1)。大多数人从微小的字符串中提取微小的子串,所以复杂性如何渐进地增长是完全不相关的。
答案很长:
一种不可变的数据结构,其构造使得在一个实例上的操作只允许少量(通常为O(1)或O(lg n))复制或新分配的原始内存的重用,称为"持久"不可变的数据结构。.NET中的字符串是不可变的;您的问题本质上是"为什么它们不持久"?
因为当您查看通常在.NET程序中对字符串执行的操作时,仅仅生成一个全新的字符串在所有相关方面都不会更糟。构建一个复杂的持久性数据结构的开销和困难本身并没有代价。
人们通常使用"子字符串"从稍长的字符串中提取一个短字符串——比如说,10或20个字符——可能有几百个字符。在逗号分隔的文件中有一行文本,您希望提取第三个字段,这是姓氏。这行可能有几百个字符长,名字是几十个。在现代硬件上,50字节的字符串分配和内存复制速度惊人。创建一个新的数据结构(由指向现有字符串中间的指针加上一个长度)的速度也惊人地快,这与此无关;"足够快"的定义足够快。
提取的子串通常体积小,寿命短;垃圾收集器很快就会回收它们,而且它们一开始在堆上没有占用太多空间。因此,使用鼓励重用大多数内存的持久性策略也不是一个成功;您所做的只是让垃圾收集器变慢,因为现在它必须担心处理内部指针。
如果人们通常对字符串所做的子字符串操作完全不同,那么使用持久方法是有意义的。如果人们通常有一百万个字符串,并且正在提取成千上万个大小在十万个字符范围内的重叠子串,而这些子串在堆中生存了很长时间,那么使用持久的子串方法是完全有意义的;不这样做是浪费和愚蠢的。但大多数业务线程序员甚至都不做类似的事情。.NET不是一个为人类基因组计划量身定制的平台;DNA分析程序员每天都要解决这些字符串使用特性的问题;你不这样做的可能性很大。很少有人构建自己的持久数据结构,这些结构与他们的使用场景非常匹配。
例如,我的团队编写程序,在您键入代码时对C和VB代码进行动态分析。其中一些代码文件非常庞大,因此我们不能执行O(N)字符串操作来提取子字符串或插入或删除字符。我们已经构建了一系列持久不变的数据结构,用于表示对文本缓冲区的编辑,从而允许我们在典型编辑时快速高效地重用大量现有字符串数据以及现有的词汇和语法分析。这是一个很难解决的问题,其解决方案仅针对C和VB代码编辑的特定领域进行了调整。期望内置字符串类型为我们解决这个问题是不现实的。
- 对比Java是如何做的(或者至少在过去的某个时间点)它是有趣的:子字符串返回一个新的字符串,但是指向与较大的字符串相同的字符[],这意味着较大的字符[]不能再被垃圾收集,直到子串超出范围。到目前为止,我更喜欢.NET的实现。
- 我已经看过很多这种代码:string contents = File.ReadAllText(filename); foreach (string line in content.Split("
")) ...或它的其他版本。我的意思是读取整个文件,然后处理各个部分。如果一个字符串是持久的,那么这类代码将更快,所需内存也更少;在内存中始终只有文件的一个副本,而不是复制每一行,然后在处理时复制每一行的各个部分。但是,正如Eric所说的-这不是典型的用例。
- @配置器:同样,在.NET 4中,file.read lines方法将一个文本文件分解为多行,而不必首先将其全部读取到内存中。
- @埃里克:是的,当然。要点是,有时会读取一个大字符串,并对其进行部分处理——不管是用换行符还是空行(如.Split("
")
)拆分,还是按协议要求进行拆分。
- +1个好答案(一如既往!):)
- @米迦勒:Java的"1"实现是一个持久的数据结构(在标准中没有指定,但是我所知道的所有实现都是这样)。
- 简短回答:复制数据以允许对原始字符串进行垃圾收集。
- 我想你可以为固定的gchandles优化这个,但是你可能会因为额外的切换而损失微秒的性能:)
- 值得注意的是,在file.readalltext示例中,磁盘操作比内存复制慢几个数量级,因此O(N)ness仍然不相关。
- "如果n不变大,o(n)就是o(1)。"错误错误错误错误错误。拜托!!
- 复制的另一种选择是非常危险的,并且容易出现毫无疑问的内存泄漏。如果我们从磁盘中读取一个大字符串,并保留它的子字符串以备日后处理,这将违背许多人的期望(包括我的期望),即初始内存不会被收集。这将导致太多的微妙和不可能跟踪的问题,在大多数代码外面,整个系统将崩溃和发展将变得困难10倍-我们将再次回到C++。
- "大多数人从微小的字符串中提取出微小的子串,所以复杂性如何渐进地增长是完全不相关的"。这是一个循环论证。大多数人提取小的子字符串是因为提取大的子字符串非常慢。对分法和尾分法有明显的应用前景。
- "通过持久性,我的意思是……可以重用现有数据结构的大部分或全部内存"。在数据结构的上下文中,"持久"一词通常用于表示"更新创建新的数据结构而不破坏旧的数据结构"。例如,请参见cs.cmu.edu/~sleator/papers/persistence.htm
- 对于Substring()的特定情况,新的编程语言(如d和go)提供了"切片"数据类型(实现为指针+长度,或一对指针),这使得Substring()非常快。D语言甚至没有"real"数组或字符串数据类型;当您创建一个字符串时,该语言为您提供了一个可以访问它的切片。d字符串是不可变的,因此更改字符串或连接仍然需要O(Length)。如果.NET能够支持切片,那就太好了。
- @jonharrop字符串确实为您提供了许多方法,可以使用显式传递的索引和长度。这使您能够在需要的地方实现类似切片的功能。或者,如果确实需要,可以使用不安全的代码。正如埃里克所说,如果你在写你自己的数据库或DNA分析仪,你可能知道你在做什么,你有自己的处理字符串的方法。
- @Luaan:任何人想对字符串做的第一件事就是把它的位解析成int、float、datetime等。这些Parse函数中有多少接受偏移量和长度?
- @是的,这是个很好的例子。当我实际上不得不编写一个DBF的高性能解析器(这正是您所说的场景)时,我必须创建自己的解析例程。另一种选择可能是使用流(StringReader和co.),通过反复重用相同的char数组,可以节省大量的分配。不过,我想指出的是,BCLS数字解析例程实际上非常慢(但很安全!)在我的例子中,更好的内存分配模式节省了3-5%的CPU时间。更快的分析速度-70%。.NET的内存管理非常好:)
- @CDiggins"错错错错错错。拜托!!"——自己来吧。如果n以常数c为界,则o(n)=o(c)=o(1)。
- "D语言甚至没有"real"数组或字符串数据类型;当您创建一个字符串时,该语言为您提供了一个可以访问它的切片。D字符串是不可变的,因此更改字符串或连接字符串仍然需要O(长度)。"——这是完全错误的。
- @Jimbalter"这是完全错误的"你能解释为什么或者提供一个正确的替代分析吗?没有这一点,你的评论没有多大帮助。
- @这里的主题不是D语言,请随意阅读。D语言当然有真正的数组。字符串是不可变的字符数组,有三种类型:字符串、wstring和dstring,用于三种大小的Unicode编码。切片是另一种动物…它们是引用数组某一部分的"光标"。创建数组时,不会得到切片…切片是您独立创建的。
- 仅供参考,此设计错误现已修复:msdn.microsoft.com/en-us/magazine/…
- @伊萨克:我不认为这是设计错误。这是一个有意识的决定,与其他选择相比,这是相当令人惊讶的自由。Java注意到有一种持久的字符串,因为默认情况下,在某些使用模式中存在各种各样的麻烦,因为有时会有一个非常长且难以追踪的巨型字符串对象躺在周围。所以他们最终也改变了这种行为。从一个API设计POV,我认为这两个选项都应该是可用的,因为它们现在是可用的。但是,SPAN也向读者清楚地表明,您在另一个对象上有一个视图,而不是一个副本。
- @乔伊,我同意违约应该是什么。我所指的错误是从一开始就不包括共享模式,我认为我们是一致的。
正因为字符串是不可变的,所以.Substring必须至少复制原始字符串的一部分。复制n个字节需要O(n)个时间。
您认为如何在恒定时间内复制一组字节?
编辑:Mehrdad建议不要复制字符串,而是保留对其中一个字符串的引用。
在.NET中,一个多兆字节的字符串,在该字符串上有人调用.SubString(n, n+3)(对于字符串中间的任何n)。
现在,不能仅仅因为一个引用包含4个字符而对整个字符串进行垃圾收集?这似乎是一种荒谬的空间浪费。
此外,跟踪对子字符串的引用(甚至可能在子字符串内部),并尝试在最佳时间复制以避免破坏GC(如上所述),这使得该概念成为一场噩梦。在.Substring上复制并维护直接不变的模型要简单得多,而且更可靠。
编辑:这里有一个很好的阅读关于在较大的字符串中保留对子字符串的引用的危险。
- +1:正是我的想法。在内部,它可能使用仍然是O(N)的memcpy。
- @阿贝伦基:我想可能根本不抄?它已经在那里了,你为什么要复制它?
- @Mehrdad,除非您返回的子字符串恰好是字符串的最右边部分,否则它必须复制子字符串才能放置新的空终止符字节。
- @塞缪尔:空终结者?在.NET中需要它吗?
- @Abbelenky:很好的编辑,这绝对是我没有想到的(尽管这不是什么大问题——例如,让用户使用string.Copy可以很容易地修复)。+ 1
- @如果你在表演之后。在这种情况下不安全。然后你可以得到一个char*子串。
- @勒皮:是的,虽然那时我不得不手动执行和/或调用strlen、strstr等……当然是可行的。
- @Mehrdad,是的,.NET中的字符串以空结尾。csharpindepth.com/articles/general/strings.aspx
- @迈赫达德:总是一个艰难的选择:)你有没有试着"截断"一个StringBuilder来看看它是否更快(假设你不需要旧的字符串)?
- @麻风病人:除非你所做的只是串联或简单的操作,否则StringBuilder是一种后方的疼痛。一旦你尝试做一个IndexOf来找到一个子串然后复制它,这是不切实际的使用(而且,你经常需要原始的字符串,因为你正在重新搜索它)。
- @迈赫达德-你可能对那里期望太高了,它被称为StringBuilder,这是一个很好的构建字符串。它不是叫StringMultipurposeManipulator
- @马特达维:哈哈,我只是在解释为什么它通常不能很好地替代char*或string。:)
- @Mehrdad——够公平的):我喜欢把StringBuilder称为Ronseal类——它完全按照它在锡罐上所说的做!除非你是英国人,否则这可能毫无意义:)
- @samuelneff,@mehrdad:net中的字符串不是NULL终止的。如Lippert的文章中所述,前4个字节包含字符串的长度。这就是为什么,正如skeet指出的,它们可以包含\0个字符。
- 我继续说:内部数组在末尾(内存使用部分)包含一个\0,但只是为了非托管代码。只有将优化的子字符串传递给非托管代码时,才需要它的副本来添加NULL。
- @Elideb,我说过字符串末尾有一个空字符。埃里克·利珀特说:"这与乔恩·斯基特的说法是一致的。"是的,对于非托管的互操作性,空值是存在的,而不是字符串结尾的真正指示器,但事实上,字符串结尾处都有空字符。
- @塞缪尔内夫:如果.NET中的字符串被更改为持久的,那么它们也可以被更改为不被空终止。当然,这会使非托管interop首先需要字符串的副本,但这是您可以合理选择的。子字符串操作可能比互操作更频繁。
- @Joren完全依赖于应用程序。我敢肯定,在我们的应用程序中,我们会为每个子字符串调用执行数千个互操作调用。
- @你的链接说字符串可以包含空。这并不意味着它们被空终止。
- Java通过保持对原始字符串(或更确切地说,它的基础字符数组)的引用来做子串。这根本不是一场噩梦;事实证明,只保留一个巨大字符串的小子字符串和创建大量大子字符串一样罕见。它很少引起问题。
- @Abelenky"不能因为一个引用包含4个字符而对整个字符串进行垃圾收集"。GC可以负责复制,在复制过程中用显式字符串替换子字符串。此外,GC可以通过考虑内存压力和引用的稀疏性来更有效地做到这一点。
- @Abbelenky:"跟踪对子字符串的引用(甚至可能在子字符串内部),并尝试在最佳时间复制以避免破坏GC(如上所述),这使得这个概念成为一场噩梦。"注意,子字符串的子字符串可以简单地简化为数组的子字符串。
- @Michaelborgwardt"只保留一个巨大字符串的小子串,就像创建大量大子串一样罕见"。当心自我选择。在我看来,这些东西在.NET上很少见,因为.NET不能很好地处理它们,所以大多数人都学会了避免它们。我这里有550kloc的VB代码用于LOB应用程序(文档生成),它使用重复的原始字符串连接,因此速度太慢。
- 我认为大多数评论都没有抓住要点。在现实世界中,您永远不会将4MB的数据放入字符串中,除非它是一次性的,并且有一个定义良好的生命周期。实际上,在LOB应用程序中真正处理字符串的唯一时间是处理文本,即使这样,通常也有一个字符串属性和一个StringBuilder支持字段。
Java(与.NET相反)提供了两种EDCOX1(3)的方式,可以考虑是否只保留一个引用或将整个子串复制到新的内存位置。
简单的.substring(...)与原始字符串对象共享内部使用的char数组,然后,如果需要,可以使用new String(...)复制到新的数组(以避免妨碍对原始数组的垃圾收集)。
我认为这种灵活性是开发人员的最佳选择。
- 你说"原本"是什么意思?这个被移除了吗?
- @亨克·霍尔特曼:不好意思弄混了,我相信这是因为我的纯英语,不好意思
- 你称之为"灵活性",我称之为"一种意外地将一个难以诊断的错误(或性能问题)插入软件的方法,因为我没有意识到我必须停止并考虑所有可能调用此代码的地方(包括那些只在下一个版本中被发明的地方),从中间获取4个字符。"弦"
- 从一开始,发展不是一件容易的事情,需要大量的知识,但实际上你是对的,新手可以用错这种东西,这往往会导致问题。
- 投票被撤回…在仔细阅读代码之后,它看起来像Java引用中的子串,至少是在OpenJDK版本中的一个共享数组。如果你想确保一个新的字符串,有一种方法可以做到。
- @我称之为"现状偏见"。对你来说,Java的方式似乎充满了风险,而.NET的方式是唯一有意义的选择。对于Java程序员来说,情况正好相反。
- Michael Borgwardt——你可能是对的,但我可以为我辩护说,我以前在Java .NET学习过Java,我发现它总是充满风险和不明智的(但比C++要少得多,以至于我在过去的十年中有"乐趣")。
- 我非常喜欢.NET,但这听起来像是Java做对了。允许开发人员访问真正的O(1)子字符串方法是很有用的(不滚动您自己的字符串类型,这会妨碍与其他库的互操作性,并且不如内置解决方案有效)。Java的解决方案可能是低效的(至少需要两个堆对象,一个用于原始字符串,另一个用于子字符串);支持切片的语言有效地替换了堆栈上的一对指针替换第二个对象。
- 在点网中,有没有一种方法可以得到一个共享的子串?我需要这个来提高性能。我正在编写自定义字符串.split
- 自从JDK 7U6不再是真的了,现在Java总是复制每个EDCOX1 0的字符串内容。
Java用于引用更大的字符串,但是:
Java也将其行为更改为复制,以避免内存泄漏。
不过,我觉得可以改进一下:为什么不有条件地进行复制呢?
如果子字符串的大小至少是父字符串的一半,则可以引用父字符串。否则你只能复制一份。这样可以避免大量内存泄漏,同时还能提供显著的好处。
- 始终复制允许您删除内部数组。将堆分配的数量减半,在短字符串的常见情况下节省内存。它还意味着您不需要为每个字符访问跳过额外的间接寻址。
- 我认为从这个角度来看,重要的是Java实际上是从使用同一个基础EDCOX1(0)(用不同的指针到开始和结束)来创建一个新的EDCOX1×1。这清楚地表明,成本效益分析必须优先考虑创建新的String。
这里的答案都没有解决"括号问题",也就是说.NET中的字符串表示为BSTR(指针之前存储在内存中的长度)和CSTR(字符串以' '结尾)的组合。
因此,字符串"hello there"表示为
1
| 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 |
(如果在fixed语句中分配给char*,指针将指向0x48。)
此结构允许快速查找字符串的长度(在许多上下文中很有用),并允许在P/Invoke中将指针传递给期望以空结尾的字符串的Win32(或其他)API。
当你使用Substring(0, 5)时,"哦,但是我保证在最后一个字符后会有一个空字符"规则说你需要复制一份。即使在末尾有子字符串,也没有地方可以在不损坏其他变量的情况下放置长度。
但是,有时您确实想谈论"字符串的中间部分",并且不必关心p/invoke行为。最近添加的ReadOnlySpan结构可用于获取无副本子字符串:
1 2 3
| string s ="Hello there";
ReadOnlySpan<char> hello = s.AsSpan(0, 5);
ReadOnlySpan<char> ell = hello.Slice(1, 3); |
ReadOnlySpan的"substring"独立存储长度,并且不保证值结束后有一个' '。它可以在许多方面"像字符串一样"使用,但它不是"字符串",因为它既没有BSTR特性,也没有CSTR特性(更不用说两者都有)。如果您从不(直接)P/Invoke,那么没有太大的区别(除非您要调用的API没有ReadOnlySpan重载)。
ReadOnlySpan不能用作引用类型的字段,因此也有ReadOnlyMemory(s.AsMemory(0, 5)),这是一种间接拥有ReadOnlySpan的方式,因此存在与string相同的差异。
以前答案的一些答案/评论说垃圾回收器必须保留一百万个字符串,而您继续谈论5个字符是浪费的。这正是使用ReadOnlySpan方法可以得到的行为。如果你只是做一些简短的计算,那么readonlyspan方法可能更好。如果您需要将其保持一段时间,并且只保留原始字符串的一小部分,那么执行适当的子字符串(以消除多余的数据)可能更好。中间有一个过渡点,但这取决于您的具体用法。