Scala:抽象类型 vs 泛型

273

我正在阅读 Scala之旅:抽象类型。何时更适合使用抽象类型?

例如,

abstract class Buffer {
  type T
  val element: T
}

相比于泛型,例如,
abstract class Buffer[T] {
  val element: T
}
5个回答

274

您在这个问题上有一个很好的观点:

Scala类型系统的目的
与Martin Odersky的谈话,第三部分
由Bill Venners和Frank Sommers(2009年5月18日)

更新(2009年10月):以下内容已在Bill Venners的新文章中得到说明:
抽象类型成员与Scala中的通用类型参数(请参见结尾摘要)


以下是有关第一次采访的相关摘录,时间为2009年5月(强调是我的)

概述原则

一直以来,抽象有两个概念:

  • 参数化和
  • 抽象成员。

在Java中,您也有这两种方式,但这取决于您要抽象的内容。
在Java中,您有抽象方法,但无法将方法作为参数传递。
您没有抽象字段,但可以将值作为参数传递。
同样,您没有抽象类型成员,但可以将类型指定为参数。
因此,在Java中,您也拥有所有三者,但区分了哪种抽象原则适用于哪种事物。您可以认为这种区分是相当武断的。

Scala方式

我们决定对所有三种成员使用相同的构造原则
因此,您可以有抽象字段以及值参数。
您可以将方法(或“函数”)作为参数传递,或者进行抽象处理。
您可以将类型指定为参数,或进行抽象处理。
从概念上讲,我们可以将一个模型转换为另一个模型。至少从原则上讲,我们可以将每种类型的参数化表达为面向对象的抽象形式。因此,您可以认为Scala是一种更正交和完整的语言。

为什么?

特别的,抽象类型能够帮助解决我们之前提到的协变问题。一个长期存在的标准问题是动物和食物之间的关系。这个谜题是要创建一个Animal类,并有一个方法eat,使其能够吃一些食物。问题在于如果我们派生出一个Cow类,那么它只会吃草而不是任意食物。例如,一头牛不能吃鱼。你需要做的是让一头牛拥有一个只吃草而不吃其他东西的eat方法。实际上,在Java中你无法做到这一点,因为你会发现你可以构造出不安全的情况,就像我之前提到的将水果分配给苹果变量的问题。
答案是在Animal类中添加一个抽象类型。你可以说,我的新Animal类有一个我不知道的SuitableFood类型。所以它是一个抽象类型。你不提供类型的实现。然后你有一个只吃SuitableFood的eat方法。然后在Cow类中,我会说,好的,我有一头牛,它继承了Animal类,并且对于Cow类型的SuitableFood等于Grass。因此,抽象类型提供了这样一个概念:超类中我不知道的类型,然后在子类中用我知道的东西填充它。
参数化也是如此吗?
当然可以。您可以使用动物类的参数来指定它所吃的食物种类。
但是,在实践中,如果您对许多不同的事物都这样做,会导致参数数量的激增,并且通常还会在参数范围内出现问题。
在1998年ECOOP上,Kim Bruce、Phil Wadler和我发表了一篇论文,其中我们展示了随着您不知道的事物数量的增加,典型程序将呈二次增长。
因此,有非常好的理由不要使用参数,而要使用这些抽象成员,因为它们不会给您带来这种二次增长。

thatismatt在评论中问道:

你认为以下总结是否公正:

  • 抽象类型用于“拥有”或“使用”关系(例如,Cow eats Grass
  • 而泛型通常是“of”关系(例如,List of Ints

我不确定使用抽象类型或泛型之间的关系是否有那么大的差别。 不同之处在于:

  • 它们的使用方式,以及
  • 如何管理参数边界。

为了理解Martin所谈及的"参数爆炸问题,通常情况下是在参数边界内发生的,并且在使用泛型建模抽象类型时呈现出二次增长的趋势",你可以阅读Martin Odersky与Matthias Zenger于2005年OOPSLA会议发表的论文"可扩展组件抽象",该论文收录于Palcom项目的出版物中(该项目于2007年完成)。
以下是相关摘录:

定义

抽象类型成员提供了一种灵活的方式来抽象组件的具体类型。抽象类型可以隐藏组件内部信息,类似于它们在SML签名中的使用。在面向对象的框架中,由于类可以通过继承进行扩展,因此它们也可以用作一种灵活的参数化手段(通常称为家族多态性,请参见本网络日志条目以及Eric Ernst撰写的论文)。

注意:家族多态已被提议作为面向对象语言的解决方案,以支持可重用但类型安全的相互递归类。 家族多态的一个关键思想是家族的概念,用于将相互递归的类分组。
有界类型抽象
abstract class MaxCell extends AbsCell {
type T <: Ordered { type O = T }
def setMax(x: T) = if (get < x) set(x)
}

在这里,T的类型声明受到上界约束,该上界由类名Ordered和细化型{ type O = T }组成。
上界将子类中T的特殊化限制为Ordered的那些子类型,其中类型成员O等于equals T
由于这个约束,Ordered类的<方法保证可以应用于类型为T的接收器和参数。
该示例显示了有界类型成员本身可能出现在边界的一部分中。
(即,Scala支持F-bounded polymorphism
(注意,来自Peter Canning,William Cook,Walter Hill,Walter Olthoff论文:
有界量化是由Cardelli和Wegner引入的,作为对所有给定类型的子类型进行统一操作的函数的类型检查手段。
他们定义了一个简单的“对象”模型,并使用有界量化来类型检查对具有指定“属性”集的所有对象有意义的函数。
更现实的面向对象语言的演示将允许作为递归定义类型元素的对象。
在这种情况下,有界量化不再起到既定目的。很容易找到在所有具有指定方法集的对象上有意义但无法在Cardelli-Wegner系统中进行类型化的函数。
为了为面向对象语言中的类型多态函数提供基础,我们引入了F-bounded量化)

同一枚硬币的两面

在编程语言中,有两种主要形式的抽象:
  • 参数化和
  • 抽象成员。
第一种形式通常用于函数式语言,而第二种形式通常用于面向对象语言。
传统上,Java支持对值进行参数化,并支持对操作进行成员抽象。更近期的Java 5.0与泛型一起支持对类型进行参数化。
将泛型包含在Scala中的论据有两个:
  • 首先,手动进行抽象类型的编码并不那么直观。除了简洁性的损失之外,还存在模拟类型参数的抽象类型名称之间的意外名称冲突问题。
  • 其次,Scala程序中通常使用泛型和抽象类型来执行不同的任务。
    • 泛型通常用于需要类型实例化的情况,而
    • 抽象类型通常用于需要从客户端代码引用抽象类型的情况。
      后者尤其出现在两种情况下:
    • 可能希望从客户端代码中隐藏类型成员的确切定义,以获得类似于SML样式模块系统的封装。
    • 或者可能希望在子类中协变地覆盖类型,以获得家族多态性。
在具有有限多态性的系统中,将抽象类型改写为泛型可能会导致类型边界的二次扩展

2009年10月更新

Scala中的抽象类型成员与泛型类型参数(Bill Venners)

(强调是我的)

到目前为止,我对抽象类型成员的观察是,当:

  • 你想要让人们通过特质混合定义这些类型时。
  • 你认为在定义时显式提及类型成员名称将有助于代码可读性时。

抽象类型成员通常比泛型类型参数更好。

例如:

如果您想将三个不同的fixture对象传递到测试中,您可以这样做,但是您需要指定三种类型,每个参数一个。因此,如果我采用了类型参数方法,您的套件类可能会变成这样:

// Type parameter version
class MySuite extends FixtureSuite3[StringBuilder, ListBuffer, Stack] with MyHandyFixture {
  // ...
}

相比之下,采用类型成员的方法将会是这样:
// Type member version
class MySuite extends FixtureSuite3 with MyHandyFixture {
  // ...
}

另一个抽象类型成员和泛型类型参数之间的小差别是,在指定泛型类型参数时,代码读者看不到类型参数的名称。因此,如果有人看到这行代码:
// Type parameter version
class MySuite extends FixtureSuite[StringBuilder] with StringBuilderFixture {
  // ...
}

如果没有查阅,他们不会知道指定为StringBuilder的类型参数的名称。而在抽象类型成员方法中,类型参数的名称就在代码中:

// Type member version
class MySuite extends FixtureSuite with StringBuilderFixture {
  type FixtureParam = StringBuilder
  // ...
}

在后一种情况下,代码的读者可以看到 StringBuilder 是“装置参数”类型。他们仍然需要弄清楚“装置参数”的含义,但至少可以在不查看文档的情况下获取类型的名称。

64
当你这么做时,我该如何通过回答Scala问题来获得Karma积分呢? :-) - Daniel C. Sobral
1
你认为以下总结是否公正:抽象类型用于“拥有”或“使用”关系(例如,奶牛吃草),而泛型通常是“属于”关系(例如,整数列表)。 - thatismatt
1
提醒自己:请参阅2010年5月的博客文章:http://daily-scala.blogspot.com/2010/05/abstract-types-vs-parameter.html - VonC
我想把这个问题缩小一点:在类型参数方面,有什么我不能用抽象类型成员实现的功能吗? - user573215
@PaulDraper 最好将其作为自己的问题(并附上此链接以提供背景信息)。 - VonC
显示剩余5条评论

40

当我在阅读Scala相关的资料时,也曾有过同样的问题。

使用泛型的优点在于你可以创建一系列类型的家族。没有人需要对Buffer进行子类化——他们只需使用Buffer[Any]Buffer[String]等即可。

如果你使用抽象类型,那么人们就必须创建一个子类。人们需要像AnyBufferStringBuffer这样的类。

你需要决定哪种方式更适合你的特定需求。


20
在这方面,mmm细节有很大改善,你可以根据自己的需求只需使用 Buffer { type T <: String }Buffer { type T = String }。请注意,我的翻译可能不是逐字逐句的,但它保留了原始意思并尽可能简洁易懂。 - Eduardo Pareja Tobes

20

您可以结合类型参数来使用抽象类型,以建立自定义模板。

假设您需要使用三个相连的特质来建立一个模式:

trait AA[B,C]
trait BB[C,A]
trait CC[A,B]

按照泛型参数中提到的AA、BB、CC本身分别来解释。

以下是可能的代码示例:

trait AA[B<:BB[C,AA[B,C]],C<:CC[AA[B,C],B]]
trait BB[C<:CC[A,BB[C,A]],A<:AA[BB[C,A],C]]
trait CC[A<:AA[B,CC[A,B]],B<:BB[CC[A,B],A]]

由于类型参数绑定,这种简单的方式不起作用。您需要将其协变才能正确继承。

trait AA[+B<:BB[C,AA[B,C]],+C<:CC[AA[B,C],B]]
trait BB[+C<:CC[A,BB[C,A]],+A<:AA[BB[C,A],C]]
trait CC[+A<:AA[B,CC[A,B]],+B<:BB[CC[A,B],A]]

这个示例可以编译,但它对方差规则有强制要求,并且在某些情况下无法使用。

trait AA[+B<:BB[C,AA[B,C]],+C<:CC[AA[B,C],B]] {
  def forth(x:B):C
  def back(x:C):B
}
trait BB[+C<:CC[A,BB[C,A]],+A<:AA[BB[C,A],C]] {
  def forth(x:C):A
  def back(x:A):C
}
trait CC[+A<:AA[B,CC[A,B]],+B<:BB[CC[A,B],A]] {
  def forth(x:A):B
  def back(x:B):A
}
编译器会抛出许多变异检查错误。
在这种情况下,您可以将所有类型要求收集到一个额外的特质中,并使其他特质参数化。
//one trait to rule them all
trait OO[O <: OO[O]] { this : O =>
  type A <: AA[O]
  type B <: BB[O]
  type C <: CC[O]
}
trait AA[O <: OO[O]] { this : O#A =>
  type A = O#A
  type B = O#B
  type C = O#C
  def left(l:B):C
  def right(r:C):B = r.left(this)
  def join(l:B, r:C):A
  def double(l:B, r:C):A = this.join( l.join(r,this), r.join(this,l) )
}
trait BB[O <: OO[O]] { this : O#B =>
  type A = O#A
  type B = O#B
  type C = O#C
  def left(l:C):A
  def right(r:A):C = r.left(this)
  def join(l:C, r:A):B
  def double(l:C, r:A):B = this.join( l.join(r,this), r.join(this,l) )
}
trait CC[O <: OO[O]] { this : O#C =>
  type A = O#A
  type B = O#B
  type C = O#C
  def left(l:A):B
  def right(r:B):A = r.left(this)
  def join(l:A, r:B):C
  def double(l:A, r:B):C = this.join( l.join(r,this), r.join(this,l) )
}

现在我们可以为描述的模式编写具体表示,定义所有类中的左和加入方法,并免费获得正确和双重效果。

class ReprO extends OO[ReprO] {
  override type A = ReprA
  override type B = ReprB
  override type C = ReprC
}
case class ReprA(data : Int) extends AA[ReprO] {
  override def left(l:B):C = ReprC(data - l.data)
  override def join(l:B, r:C) = ReprA(l.data + r.data)
}
case class ReprB(data : Int) extends BB[ReprO] {
  override def left(l:C):A = ReprA(data - l.data)
  override def join(l:C, r:A):B = ReprB(l.data + r.data)
}
case class ReprC(data : Int) extends CC[ReprO] {
  override def left(l:A):B = ReprB(data - l.data)
  override def join(l:A, r:B):C = ReprC(l.data + r.data)
}

因此,抽象类型和类型参数都用于创建抽象,它们各有优缺点。抽象类型更具体,并能够描述任何类型结构,但较为冗长且需要明确指定。类型参数可以立即创建一堆类型,但会增加继承和类型边界的额外担忧。

它们相互协作,可以结合使用,创建无法仅通过其中一个表示的复杂抽象。


我们可以为方法添加类型参数,并为类型参数情况节省编译:def forth[B1 >: B](x: B1): Cdef back[C1 >: C](x: C1): B等。 - Dmytro Mitin

2

Stephen Compall. 类型成员几乎是类型参数 https://typelevel.org/blog/2015/07/13/type-members-parameters.html

Jon Pretty @propensive. 类型成员 vs 类型参数 - NE Scala 2016 https://www.youtube.com/watch?v=R8GksuRw3VI

类型参数可以变成类型成员

trait A[_T] {
  type T = _T
}

类型成员可以作为类型参数

trait A { type T }
object A {
  type Aux[_T] = A { type T = _T }
}

// using A.Aux[T] instead of A[T]

但是:

  • 类型成员不能在主构造函数和自身类型{ self: T => ...中使用。类型参数不能直接在实例上调用。类型成员可以被视为命名的类型参数。
trait A[T]
val a: A[Int] = ???
type X = ?? // what is T of a?

trait A { type T }
val a: A { type T = Int } = ???
type X = a.T

trait A[_T] { type T = _T }
val a: A[Int] = ???
type X = a.T
  • 类型成员的差异不能在定义位置声明,只能在调用位置声明(就像Java中的类型参数一样)
trait A[+T] // definition

trait A[-T] // definition

trait A[T]
type X[+T] = A[_ <: T] // call
type Y[-T] = A[_ >: T] // call

trait A { type T }
type X[+_T] = A { type T <: _T } // call
type Y[-_T] = A { type T >: _T } // call

在Scala3中,如果将泛型类型参数映射到依赖类型,协变和逆变修饰符如何映射?

  • 区别在于部分应用。对于trait MyTrait { type A; type B; type C },您可以指定某些类型而不指定其他类型。但是对于trait MyTrait[A, B, C],您只能指定所有类型或不指定任何类型。因此,类型参数更像输入(要指定的)而类型成员更像输出(要推断的)。

Shapeless何时需要依赖类型?

为什么我们需要为某些类型计算的输出指定精制类型(或其等效Aux)?

为什么类型级计算需要Aux技术?

  • 类型参数和类型成员的类型推断存在差异。类型参数应该被推断,如果无法推断,则将其最小化(例如Nothing)。但是类型成员可以保持抽象。

"def apply[T](c:T)"和"type T;def apply(c:T)"有什么不同?

Aux模式的使用可以在不推断适当类型的情况下编译

为什么Scala在未指定类型参数时会推断底部类型? (答案)

  • 此外,类型参数和类型成员类型类的隐式解析可能存在差异(即在trait A[T] { type S }中,类型S有时可以在功能上依赖于T,而在trait A[T, S]中,类型TS是任意的,在Haskell中为class A t s | t -> sclass A t where type S tclass A t s)。

https://github.com/lampepfl/dotty/issues/17212

https://github.com/scala/bug/issues/12767

  • 同时,类型参数和类型成员的广义代数类型(GADT)是不同的。

Scala 3. 实现依赖函数类型

https://github.com/lampepfl/dotty/issues/17235

  • Scala 3中类型参数将如何被编码为类型成员,以及为什么最终决定不这样做

https://dotty.epfl.ch/docs/internals/higher-kinded-v2.html

https://contributors.scala-lang.org/t/scala-3-type-parameters-and-type-members/3472


0

我认为这里没有太大的区别。类型抽象成员可以被视为存在类型,类似于其他一些函数式语言中的记录类型。

例如,我们有:

class ListT {
  type T
  ...
}

并且

class List[T] {...}

那么ListT就和List[_]一样了。 类型成员的便利之处在于我们可以使用类而不需要显式具体类型, 避免过多的类型参数。


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