代码范围—深入了解Ruby字符串

284 阅读17分钟

代码范围。深入了解Ruby字符串

对任何一个Ruby实现的贡献都是一项艰巨的任务。很多内部功能经过多年的发展,或者从一个实现移植到另一个实现,而且很多都是没有文档的。这篇文章是对Ruby中编码感知字符串的功能和性能的非正式考察。我希望它能帮助你开始挖掘你自己的Ruby,或者为Ruby VM为你做的所有美妙的事情提供一些额外的见解。

Ruby有一个令人难以置信的灵活的,甚至是不寻常的字符串表示。Ruby字符串通常是可变的,尽管核心库中的许多操作都有不可变和可变的变体。还有一种冻结字符串的机制,使字符串对象在每个对象或每个文件上都是不可变的。如果一个字符串字头被冻结,虚拟机将使用该字符串的内部版本。此外,Ruby中的字符串是有编码意识的,Ruby提供了100多种编码,可以应用于任何字符串,这与其他语言形成了鲜明的对比,这些语言对其所有的字符串使用一种通用的编码,或者防止构建无效的字符串。

根据不同的环境,在创建一个没有明确编码的字符串时,会应用不同的编码。默认情况下,使用的三个主要编码是UTF-8、US-ASCII和ASCII-8BIT(别名为BINARY)。与一个字符串相关的编码可以通过或不通过验证来改变。有可能创建一个在相关编码中无效的基础字节序列的字符串。

对字符串的Ruby方法允许该语言适应许多遗留的应用程序和深奥的平台。这种灵活性的代价是在几乎所有的字符串操作中考虑编码所需的运行时开销。当两个字符串被附加时,必须检查它们的编码是否兼容。对于某些操作来说,知道字符串是否对其附加的编码有有效的数据是很关键的。对于其他操作,有必要知道字符或字素的边界在哪里。

根据编码的不同,有些操作比其他操作更有效。如果一个字符串只包含有效的ASCII字符,每个字符是一个字节宽。如果知道每个字符只有一个字节,那么像String#[],String#chr, 和String#downcase这样的操作就会非常有效。有些编码是固定宽度的--每个 "字符 "正好有N个字节宽。(当涉及到Unicode时,"字符 "这一术语是模糊的。Ruby字符串(从Ruby 3.1开始)有一些方法可以遍历字节、字符、码位和字素簇。与其在每个细节上纠缠不清,我将专注于String#each_char的输出,并自始至终使用 "字符 "这个术语)。许 多固定宽度编码的操作都可以有效地实现,因为字符偏移量的计算很简单。在UTF-8中,即Ruby(和许多其他语言)的默认内部字符串编码,字符的宽度是可变的,每个字符需要1-4个字节。这通常会使操作复杂化,因为如果不扫描字符串中的所有字节,就不可能确定字符偏移量,甚至是字符串中的字符总数。然而,UTF-8与ASCII是向后兼容的。如果一个UTF-8字符串只由ASCII字符组成,每个字符将是一个字节宽,如果运行时知道,它可以对这样的字符串进行优化操作,就像字符串具有更简单的ASCII编码一样。

编码范围

一般来说,判断一个字符串是否由相关编码的有效字符组成的唯一方法是对所有字节进行全面扫描。这是一个O(n)过程,虽然不是世界上效率最低的操作,但却是我们想要避免的。不允许无效字符串的语言只需要在字符串创建时做一次验证。提前编译(AOT)的语言可以在编译时验证字符串字面。只有不可改变的字符串的语言可以保证,一旦字符串被验证,它就永远不会变成无效的。Ruby没有这些特性,所以它减少不必要的字符串扫描的解决方案是将每个字符串的信息缓存在一个被称为代码范围的字段中。

有四个代码范围值:

  • ENC_CODERANGE_UNKNOWN
  • ENC_CODERANGE_7BIT
  • ENC_CODERANGE_VALID
  • ENC_CODERANGE_BROKEN

代码范围在运行时中占据了一个奇怪的位置。作为运行时记录概况信息的地方,它是一个实现细节。没有办法直接从一个字符串中请求代码范围。然而,由于代码范围记录了关于有效性的信息,它也影响了一些操作的执行。因此,一些String方法允许你导出字符串的代码范围,允许你相应地调整你的应用程序。

这些映射是:

代码范围
卢比代码等价物
ENC_CODERANGE_UNKNOWN
没有表示*
ENC_CODERANGE_7BIT
str.ascii_only?
ENC_CODERANGE_VALID
str.valid_encoding? && !str.ascii_only?
ENC_CODERANGE_BROKEN
!str.valid_encoding?

表1:内部代码范围值与公共Ruby方法的映射。

  • 代码范围在大多数情况下是懒惰地计算的。然而,当请求关于一个代码范围所包含的属性的信息时,代码范围是按要求计算的。因此,你可以传递有一个ENC_CODERANGE_UNKNOWN 代码范围的字符串,但是询问关于其有效性的信息或其他需要代码范围的方法,例如字符串的字符长度,将在返回一个值给调用者之前计算和缓存代码范围。

考虑到它在某种程度上是实现细节,某种程度上不是,每个主要的Ruby实现都将代码范围与字符串联系起来。如果你曾在Ruby实现的内部或涉及String对象的本地扩展中工作过,你几乎肯定会遇到与代码范围值相关的工作,并有可能对其进行管理。

语义

在MRI中,代码范围值以int值的形式存储在对象头中,用bitmask标志来表示这些值。每个值都是相互排斥的。这一点很重要,因为从逻辑上讲,每一个具有ASCII兼容编码且仅由ASCII字符组成的字符串都是一个有效的字符串。然而,这样的字符串永远不会有一个代码范围值ENC_CODERANGE_VALID 。你应该使用ENC_CODERANGE(obj) 宏来提取代码范围值,然后将其与定义的代码范围常数之一进行比较,将代码范围常数基本上与枚举相同(例如,if (cr == ENC_CODERANGE_7BIT) { ... })

如果你试图直接将代码范围值作为位掩码使用,你会得到非常混乱和难以调试的结果。由于掩码的定义方式,如果一个字符串被注释为既是ENC_CODERANGE_7BIT ,又是ENC_CODERANGE_VALID ,它就会显示为ENC_CODERANGE_BROKEN 。相反,如果你试图在一个组合的掩码上进行分支,如if (cr & (ENC_CODERANGE_7BIT |ENC_CODERANGE_VALID)){ ...},这将包括ENC_CODERANGE_BROKEN 字符串。这是因为四个有效值在对象头中只用两个比特表示。这种紧凑的表示方法有效地利用了对象头中的有限空间,但对于任何习惯于使用位掩码来匹配和设置属性的人来说,可能会产生误导。

为了更好地说明这一点,我把一些相关的C语言代码移植到Ruby中(见清单1)。

清单1:MRI的原始C代码范围表示法在Ruby中重新创建。

JRuby有一个与MRI非常相似的实现,将代码范围值作为一个int紧凑地存储在对象头中,只占用两个比特。在TruffleRuby中,代码范围被表示为一个枚举,并作为一个int存储在对象的形状中。枚举表示法占用了额外的空间,但防止了误用比特掩码的一类错误。

字符串操作和代码范围变化

对象的代码范围是它的字节序列和与解释这些字节的对象相关的编码的一个函数。因此,当字节改变或编码改变时,代码范围值就有可能失效。当这种操作发生时,最安全的做法是对产生的字符串进行完整的代码范围扫描。然而,在我们的能力范围内,我们希望避免在没有必要时重新计算代码范围。

MRI通过两个主要机制来避免不必要的代码范围扫描。第一个机制是通过改变字符串的代码范围值到ENC_CODERANGE_UNKNOWN ,简单地扫描代码范围。当执行需要知道真正的代码范围的操作时,MRI会根据需要计算它,并用新的结果来更新缓存的代码范围。如果不需要代码范围,就不会计算。(如果这样做很便宜,MRI会急切地计算代码范围。特别是,在对源文件进行词法分析时,MRI已经需要检查字符串中的每一个字节,并且知道字符串的编码,所以采取额外的步骤来发现和记录代码范围的值是相当便宜的)。

MRI避免代码范围扫描的第二个方法是推理任何正在操作的字符串的代码范围值,以及一个操作如何可能导致一个新的代码范围。例如,在处理具有ENC_CODERANGE_7BIT 代码范围值的字符串时,大多数操作可以保留代码范围值,因为所有ASCII字符都在0x00 - 0x7f范围内。无论你是取一个子串,改变字符的大小写,还是剥去空白,所产生的字符串都保证有ENC_CODERANGE_7BIT ,所以执行完整的代码范围扫描是浪费的。清单1中的代码演示了对一个具有ENC_CODERANGE_7BIT 代码范围的字符串的一些操作,以及产生的字符串如何总是具有相同的代码范围。

清单2:改变一个代码范围为ENC_CODERANGE_7BIT 的字符串的大小写,总是会产生一个代码范围也为ENC_CODERANGE_7BIT 的字符串。

有时,代码范围值本身对于一个特定的优化是不够的,在这种情况下,MRI会考虑额外的背景。例如,MRI跟踪一个字符串是否是 "单字节可优化的"。如果一个字符串的代码范围是ENC_CODERANGE_7BIT ,或者相关的编码使用的字符只有一个字节宽,例如用于I/O的ASCII-8BIT/BINARY编码,那么这个字符串就是单字节可优化的。如果一个字符串是单字节可优化的,我们知道String#reverse必须保留相同的代码范围,因为每个字节对应一个字符,所以反转字节不能改变它们的含义。

不幸的是,代码范围并不总是很容易推导出来,特别是当字符串的代码范围是ENC_CODERANGE_VALIDENC_CODERANGE_BROKEN ,在这种情况下,可能需要进行全代码范围扫描。如果源字符串的编码与ASCII兼容,对代码范围为ENC_CODERANGE_VALID 的字符串进行的操作可能会产生一个ENC_CODERANGE_7BIT 的字符串;否则,会产生一个编码为ENC_CODERANGE_VALID 的字符串。(我们特意把String#setbyte的情况放在一边,因为它可能导致一个字符串有一个ENC_CODERANGE_BROKEN 的代码范围值。一般来说,Ruby中的字符串操作是定义明确的,不会导致字符串被破坏)。在清单2中,你可以看到一些例子,对一个代码范围为ENC_CODERANGE_VALID 的字符串进行的操作,会产生一个代码范围为ENC_CODERANGE_7BITENC_CODERANGE_VALID 的字符串。

清单3:改变一个代码范围为ENC_CODERANGE_VALID 的字符串的大小写,可能导致一个代码范围不同的字符串。

由于源字符串可能有一个ENC_CODERANGE_UNKNOWN ,而操作可能不需要解决的代码范围,例如在一个具有ASCII-8BIT/BINARY编码的字符串上调用String#reverse,有可能产生一个同样具有ENC_CODERANGE_UNKNOWN 代码范围的字符串。也就是说,很有可能出现一个只有ASCII码的字符串,但它有一个未知的代码范围,当对其进行操作时,仍然会产生一个可能需要在以后进行完整代码范围扫描的字符串。不幸的是,这只是在懒惰地计算代码范围和不借助于对字符串进行全字节扫描而得出代码范围之间的权衡。对最终用户来说,这没有什么区别,因为代码范围值将被计算出来,并且在需要时是准确的。然而,如果你正在研究本地扩展、Ruby运行时的内部结构,或者只是对你的Ruby应用程序进行分析,你应该知道代码范围是如何被设置或推迟的。

TruffleRuby和代码范围派生

作为一个小插曲,我想花点时间谈谈TruffleRuby中的代码范围和它们的衍生。与其他Ruby实现不同,如MRI和JRuby,TruffleRuby急切地计算代码范围值,因此字符串永远不会有一个ENC_CODERANGE_UNKNOWN 代码范围值。TruffleRuby所做的权衡是,它可能会计算出永远不需要的代码范围值,但由于永远不需要按需计算代码范围,所以字符串操作被简化。此外,TruffleRuby可以在比MRI或JRuby更多的情况下推导出操作结果字符串的代码范围,而不需要进行全字节扫描。

虽然急于计算代码范围看起来很浪费,但由于TruffleRuby对字符串数据的广泛重用,它在程序的生命周期中摊销得非常好。TruffleRuby使用绳索作为它的字符串表示,这是一种基于树的数据结构,其中叶子看起来像传统的C风格的字符串,而内部节点代表字符串操作,将其他绳索连接在一起。(如果你去寻找TruffleRuby中对 "绳索 "的引用,你可能会惊讶地发现它们大多已经消失了。TruffleRuby仍然在很大程度上使用绳索,但TruffleRuby的绳索实现被提升为Truffle系列语言实现中的顶级库,TruffleRuby已经采用。如果你使用GraalVM发行版中的任何其他语言,你也在使用曾经是TruffleRuby的绳索)。一个Ruby字符串指向一个绳索,而绳索则保存关键的字符串数据。

例如,在一个字符串连接操作中,我们不是分配一个新的缓冲区并将数据复制到其中,而是用绳索创建一个 "连接绳",将每个被连接的字符串作为其子代(见图1)。然后,字符串被更新以指向新的连接绳。虽然该连接绳不包含任何字节数据(委托给它的孩子),但它确实存储了一个代码范围值,这很容易得出,因为每个子绳被保证有一个代码范围值和一个相关的编码对象。

图1:"你好 "+"François "的结果的样本绳索

此外,绳索元数据是不可改变的,所以获取绳索的代码范围值永远不会比读取字段产生更多的开销。TruffleRuby利用这一特性,在其JIT编译器的内联缓存中使用绳索作为守卫。此外,TruffleRuby可以根据任何参数字符串的代码范围来专门进行字符串操作。由于大多数Ruby程序从未处理过ENC_CODERANGE_BROKEN 字符串,TruffleRuby的JIT将消除任何处理该代码范围的代码路径。如果一个破碎的字符串在运行时出现,JIT将去优化并在慢速路径上处理该操作,保留Ruby的完整语义。同样地,虽然Ruby支持100多种编码,但TruffleRuby JIT将为Ruby应用程序优化其使用的少量编码。

任何其他名称的字符串

通常情况下,字符串性能的讨论是围绕着网页模板渲染或文本处理进行的。虽然是重要的用例,但字符串在Ruby运行时中也被广泛使用。每个符号或正则表达式都有一个相关的字符串,它们被用于各种操作。真正的乐趣来自于Ruby的元编程设施:字符串可以用来访问实例变量、查询方法、向对象发送消息、评估代码片段等等。字符串性能的提高(或降低)可以产生巨大的、连带的影响。

退一步说,我不想过分强调代码范围对快速元编程的重要性。它们是一个有点复杂的配方中的一个成分。代码范围可以用来快速取消已知不匹配的字符串的资格,例如那些具有ENC_CODERANGE_BROKEN 代码范围值的字符串。在过去,当特定的标识符只允许是ASCII的时候,代码范围被用来快速失效。虽然目前没有在MRI中实现,但当所有标识符都已知是ENC_CODERANGE_7BIT ,这样的检查可以用来排除具有ENC_CODERANGE_VALID 代码范围的字符串,反之亦然。然而,一旦一个字符串通过了代码范围检查,还有一个问题就是看它是否与一个标识符(实例变量、方法、常量等)相匹配。在TruffleRuby中,这种检查可以很快得到满足,因为其不可变的绳索是内部的,可以通过引用进行比较。在MRI和JRuby中,平等性检查可能涉及到对字符串数据的线性传递,因为字符串是内部的。即使这个过程也会变得模糊不清,这取决于你是在处理一个动态生成的字符串还是一个冻结的字符串字面。如果你对在Ruby中快速实现元编程的困难和解决方案感兴趣,Chris Seaton已经发表了一篇关于这个主题的论文,我也在RubyKaigi上发表了一篇关于这个主题的演讲。

总结

与许多其他当代语言相比,Ruby暴露了一些难以优化的功能,但却赋予了开发者大量的表现力。代码范围是虚拟机避免重复工作的一种方式,并在每个字符串的基础上优化操作,当不需要该功能时,引导远离缓慢的路径。从历史上看,当在解释器中运行时,这种好处被最敏锐地观察到。当与具有去优化功能的JIT集成时,如TruffleRuby,代码范围可以帮助消除应用程序和虚拟机内部使用的字符串类型的生成代码。

知道什么是代码范围以及它们的用途可以帮助你调试问题,包括性能和正确性。在一天结束时,代码范围是一个缓存,像所有的缓存一样,它可能包含错误的值。虽然这种情况在Ruby虚拟机中很少见,但也不是没有发生过。更常见的是,操作字符串的本地扩展可能无法正确地更新一个字符串的代码范围。希望通过对代码范围的深入了解,你会发现Ruby对字符串的处理不那么令人生畏。