一般来说,有两种不同风格的集合操作库:
-
类型保留:这就是您对问题的期望
-
generic(不是“参数多态意义”,而是该词的标准英文意义)或者可能是“同质”
类型保留集合操作尝试为 select、take、drop 等仅保留未修改的现有元素的操作保留类型。对于像map 这样的操作,它会尝试找到最接近的仍然可以保存结果的超类型。例如。将IntSet 映射到String 显然不会导致IntSet,而只会导致Set。将IntSet 映射到Boolean 可以在BitSet 中表示,但我知道没有足够聪明的集合框架能够真正做到这一点。
通用/同构集合操作总是返回相同的类型。通常,选择这种类型是非常通用的,以适应最广泛的用例。例如,在 .NET 中,集合操作返回 IEnumerable,在 Java 中,它们返回 Streams,在 C++ 中,它们返回迭代器。
直到最近,只有通过复制所有类型的所有操作才能实现类型保留的集合操作。例如,Smalltalk 集合框架是类型保留的,它通过让每个集合类重新实现每个集合操作来实现这一点。这会导致大量重复代码,是维护的噩梦。 (许多新的面向对象抽象被发明出来的第一篇论文是关于如何将其应用于 Smalltalk 集合框架的,这绝非巧合。有关示例,请参见 Traits: Composable Units of Behaviour。)
据我所知,the Scala 2.8 re-design of the collections framework (see also this answer on SO) 是第一次有人设法创建保留类型的集合操作,同时最大限度地减少(尽管不是消除)重复。然而,the Scala 2.8 collections framework was widely criticized 过于复杂,并且在过去十年中需要不断的工作。事实上,它实际上也导致了对 Scala 文档系统的完全重新设计,只是为了能够hide the very complex type signatures that the type-preserving operations require。但是,this still wasn't enough,所以集合框架是completely thrown out and re-designed yet again in Scala 2.13。 (而这次重新设计花了几年时间。)
所以,“为什么 Ruby 集合框架不保留类型”的答案实际上很简单:因为 Ruby 是在 1993 年创建的,而我们(我的意思是整个编程社区)没有弄清楚如何正确地做到这一点,直到 26 年后的 2019 年。
还请注意,Scala 的实现严重依赖于静态类型。不仅是静态类型,还有编译时类型级编程、编译时类型级自省和编译时类型级元编程。这些不是必要的,但它们确实意味着你不能简单地将他们的解决方案复制到Ruby。例如,Scala 将使用 type classes 和 implicit search 来找出最佳匹配
IntSet(1, 2, 3).map(_.toString)
//=> val res: Set[String] = Set("1", "2", "3")
是一个Set[String]在编译时。在 Ruby 中,您显然仍然可以运行相同的搜索算法,尽管它会慢得多,因为您需要在运行时运行它,每次运行 map 时都一遍又一遍地运行.它会更慢,但有可能:它只是一个算法,如果你可以在编译时运行它,那么你也可以在运行时运行它。但!该算法需要块的返回类型作为其参数之一!在 Scala 中,这是在编译时推断的。在 Ruby 中你是怎么知道的?
但即使在 Scala 中,有时也无法找到合适的匹配项,例如这里:
val m = Map(1 → "one", 2 → "two", 3 → "three")
m.map { case (k, v) ⇒ s"$k $v" }
//=> val res: Iterable[String] = List("1 one", "2 two", "3 three")
所以,Scala 可以找到的最好的静态类型是 Iterable,它实际上是 Scala 集合层次结构的最顶层,Scala 可以找到的最好的运行时类型是 List,它实际上是“go -to" Scala 中的集合类型,类似于 Ruby 中的 Array。换句话说,这实际上是 Scala 在说“我放弃了,伙计。”
还有另一个问题,即类型保留的集合操作挑战了我们认为是某些操作合同的一部分。例如,大多数人会争辩说集合的基数在map 下应该是不变的,换句话说,map 应该将每个元素映射到一个新元素,因此map 不应该改变集合的大小.但是,这个带有保留类型的 Ruby 集合框架的假设代码怎么样:
Set[1, 2, 3].map(&:odd?)
#=> Set[true, false]
还有一些其他有趣的情况,我什至不知道在类型保留的集合框架中返回类型应该是什么,例如,Ranges 或 IO 流呢:
(1..1000).map(&:odd?)
(1..1000).select(&:odd?)
File.open('bla').map(&:upcase)
正因为如此,Ruby 的设计者选择在 Ruby 中使用同构集合操作:每个集合操作总是返回一个@ 987654360@.
嗯。好的。除了有时他们确实选择覆盖它们。例如。在Hash 中,过滤操作select、reject 等实际上做 返回一个Hash。但请注意,这是最近的变化,实际上有一段有趣的历史:
- 在 Ruby 1.8(可能更早版本)中,
Hash#select 返回一个 Array,但 Hash#reject 返回一个 Hash!
- 在 Ruby 1.9 中,这已更改为 both return a
Hash。
- 但是,
find_all(在 Enumerable 中定义为 select 的别名)直到今天在 Hash 中没有被覆盖,因此它返回一个 @987654374 @!
- 另一方面,新引入的
filter,也定义为Enumerable中select的别名,在@中被覆盖987654378@ 返回Hash。
因此,Ruby 设计者选择了简单性(没有代码重复,运行时没有复杂的类型计算,所有操作总是返回数组,所以没有像上面例子中的 map 改变集合的大小等意外。 ) 过度正确性,并使 Ruby 集合操作同质化而不是类型保留。但随后他们也选择了实用主义而不是纯粹性,并在各处散布了一些保留类型的覆盖。
所以,Set#map 返回一个Array 的事实应该不足为奇,因为集合框架中的每个其他类 也是这样做的。 only 更改 Set#map 不是一个好主意,IMO。 如果我们这样做,应该为map 的所有 实现者这样做。但这是一个重大的突破性变化,因此最早必须等到 Ruby 3。 (实际上,matz 说过他想避免在 Ruby 3 中进行重大更改。)但是即使为所有实现者仅更改map 也是很奇怪的,如果我们这样做, 所有操作都应该这样做。这是一项重大的研究任务,因此对于 Ruby 3 来说已经太晚了,所以至少要等到 Ruby 4。
然而,我们可以争论的是Array 是否是通用集合类型的正确选择。你可能会注意到其他类似的框架选择了一个非常通用的类型:.NET 有IEnumerable,Java 有Stream,C++ 有迭代器。 Ruby 中的等价物是Enumerator。也许,Enumerator 应该是所有集合操作返回的类型。例如,如果你 map 超过一个无限集,结果将再次是无限的,但它将是一个 Array,这意味着它需要无限量的内存!
不过,这让我们回到了实用主义:在大多数用例中,Array 比 Enumerator 更有用。