Scala:抽象类型与泛型

li9yvcax  于 2023-04-12  发布在  Scala
关注(0)|答案(5)|浏览(138)

我正在阅读 A Tour of Scala: Abstract Types。什么时候使用抽象类型更好?
比如说

abstract class Buffer {
  type T
  val element: T
}

而不是泛型,例如,

abstract class Buffer[T] {
  val element: T
}
dddzy1tm

dddzy1tm1#

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

The Purpose of Scala's Type System

与马丁·奥德斯基的对话,第三部分
作者:Bill Venners和Frank Sommers(2009年5月18日)
更新(2009年10月):下面的内容实际上已经在Bill Venners的这篇新文章中得到了说明:
Abstract Type Members versus Generic Type Parameters in Scala(参见结尾处的摘要)
(Here是2009年5月第一次面试的相关摘录,重点是我的)

一般原则

抽象一直有两个概念:

  • 参数化和
  • 抽象成员

在Java中,你也可以两者兼得,但这取决于你抽象的是什么。
在Java中,你有抽象方法,但你不能把方法作为参数传递。
您没有抽象字段,但可以将值作为参数传递。
同样,你也没有抽象类型成员,但你可以指定一个类型作为参数。
所以在Java中你也有这三种,但是有一个区别,你可以用什么样的抽象原则来处理什么样的事情,你可以说这个区别是相当随意的。

Scala之道

我们决定对所有三种类型的成员采用相同的构造原则。
因此,您可以拥有抽象字段以及值参数。
你可以将方法(或“函数”)作为参数传递,也可以抽象它们。
您可以将类型指定为参数,也可以对它们进行抽象。
从概念上讲,我们可以根据另一个来建模。至少在原则上,我们可以将每种参数化表示为面向对象抽象的形式。因此,在某种意义上,你可以说Scala是一种更正交和更完整的语言。

为什么?

特别是,抽象类型给你带来的是我们之前讨论过的协方差问题的一个很好的处理。
一个标准的问题,这已经存在了很长一段时间,是动物和食物的问题。
这个难题是有一个类Animal和一个方法eat,它吃一些食物。
问题是,如果我们子类化Animal并有一个类,比如Cow,那么它们只会吃草,而不是任意的食物。
你想要的是能够说一头牛有一种只吃草而不吃其他东西的进食方法。
实际上,你不能在Java中这样做,因为你可能会构造出不合理的情况,比如我之前提到的将Fruit赋值给Apple变量的问题。
答案是你在Animal类中添加了一个抽象类型
你说,我的新Animal类有一个SuitableFood类型,我不知道。
所以它是一个抽象类型,你没有给予该类型的实现,那么你就有了一个只吃SuitableFoodeat方法。
然后在Cow类中,我会说,好吧,我有一个Cow,它扩展了Animal类,对于Cow type SuitableFood equals Grass
所以抽象类型在一个我不知道的超类中提供了一个类型的概念,然后我在后面的子类中填充了一些我知道的东西。

参数化也一样?

当然可以。你可以用它吃的食物来参数化Animal类。
但是在实践中,当你对许多不同的事情这样做时,它会导致参数的爆炸,而且通常,更重要的是,在参数的范围内。
在1998年的ECOOP上,Kim布鲁斯,Phil Wadler和我写了一篇论文,我们表明随着你不知道的事情的数量增加,典型的程序将以二次方增长
所以我们有很好的理由不使用参数,而是使用抽象成员,因为它们不会给予你二次爆炸。
thatismatt在评论中写道:
你认为以下是一个公平的总结:

  • 抽象类型用于'has-a'或'uses-a'关系(例如Cow eats Grass
  • 其中泛型通常是“of”关系(例如List of Ints

我不确定使用抽象类型或泛型之间的关系有什么不同。不同的是:

  • 如何使用,以及
  • 如何管理参数边界。

要理解Martin在谈到“参数爆炸,而且通常是在参数的范围内”时所说的话,以及当抽象类型使用泛型建模时其随后的二次增长,您可以考虑由... Martin Odersky和Matthias Zenger为OOPSLA 2005撰写的论文“Scalable Component Abstraction”,publications of the project Palcom(2007年完成)。
相关摘录

定义

抽象类型成员提供了一种灵活的方式来抽象具体类型的组件。

抽象类型可以隐藏组件内部的信息,类似于它们在SML签名中的使用。在面向对象的框架中,类可以通过继承来扩展,它们也可以用作参数化的灵活方法(通常称为家族多态性,参见weblog entry for instanceEric Ernst撰写的论文)。
(Note家族多态性已经被提出用于面向对象语言,作为支持可重用但类型安全的相互递归类的解决方案。

家族多态性的一个关键思想是家族的概念,家族用于对相互递归的类进行分组)

绑定类型抽象

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的那些子类型,其中equals T的类型成员O
由于这个约束,Ordered类的<方法保证适用于接收器和类型为T的参数。
该示例表明,绑定类型成员本身可能显示为绑定的一部分。
(i.e. Scala支持F-bounded polymorphism
(Note,摘自Peter Canning、William Cook、Walter Hill和Walter Olthoff的论文:
有界量化是由Cardelli和Wegner引入的,作为一种类型化函数的方法,它可以在给定类型的所有子类型上统一操作。
他们定义了一个简单的“对象”模型,并使用有界量化来对所有具有指定“属性”集的对象进行类型检查。
面向对象语言的一个更现实的表示将允许对象是递归定义类型的元素。
在这种情况下,有界量化不再服务于其预期目的。很容易找到对所有具有指定方法集的对象都有意义的函数,但这些函数不能在Cardelli-Wegner系统中类型化。
为了给面向对象语言中的类型化多态函数提供一个基础,我们引入了F-有界量化)

一个硬币的两面

在编程语言中有两种主要的抽象形式:

  • 参数化和
  • 抽象成员

第一种形式通常用于函数式语言,而第二种形式通常用于面向对象语言。
传统上,Java支持值的参数化和操作的成员抽象。最新的Java 5.0支持泛型的参数化。
在Scala中包含泛型的理由有两个:

  • 首先,手工编码抽象类型并不简单,除了简洁性方面的损失外,模拟类型参数的抽象类型名称之间还存在意外的名称冲突问题。
  • 其次,泛型和抽象类型通常在Scala程序中扮演不同的角色。
    *泛型通常用于只需要类型示例化的情况,而
    *抽象类型通常用于从客户端代码中引用抽象类型

后者尤其在两种情况下出现:

  • 人们可能希望从客户端代码中隐藏类型成员的确切定义,以获得一种从SAML风格的模块系统中已知的封装。
  • 或者可能希望在子类中覆盖类型协变以获得家族多态性。

在有界多态的系统中,将抽象类型重写为泛型可能需要quadratic expansion of type bounds

2009年10月更新

Abstract Type Members versus Generic Type Parameters in Scala(Bill Venners)
(强调我的)
到目前为止,我对抽象类型成员的观察是,在以下情况下,它们主要是比泛型类型参数更好的选择:

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

示例:
如果你想将三个不同的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是“fixture parameter”类型。
他们仍然需要弄清楚“fixture参数”是什么意思,但他们至少可以在不查看文档的情况下获得类型的名称。

3duebb1j

3duebb1j2#

我在阅读Scala的时候也有同样的问题。
使用泛型的好处是你可以创建一个类型家族,没有人需要子类Buffer-他们可以使用Buffer[Any]Buffer[String]等。
如果你使用一个抽象类型,那么人们将被迫创建一个子类。人们将需要像AnyBufferStringBuffer等类。
你需要决定哪一个更适合你的特殊需要。

xurqigkl

xurqigkl3#

可以将抽象类型与类型参数结合使用来建立自定义模板。
让我们假设你需要建立一个具有三个相关特质的模式:

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
}

编译器将反对与一堆差异检查错误
在这种情况下,您可以在附加trait中收集所有类型需求,并在其上参数化其他trait

//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) )
}

现在我们可以为所描述的模式编写具体的表示,在所有类中定义left和join方法,并免费获得right和double

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)
}

因此,抽象类型和类型参数都用于创建抽象。它们都有优缺点。抽象类型更具体,能够描述任何类型结构,但冗长,需要显式指定。类型参数可以立即创建一堆类型,但会给您带来继承和类型边界的额外担忧。
它们相互协作,可以结合使用,以创建无法仅用其中一个表示的复杂抽象。

mbyulnm0

mbyulnm04#

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

class ListT {
  type T
  ...
}

class List[T] {...}

那么ListT就和List[_]一样,类型成员的方便之处在于我们可以使用没有显式具体类型的类,避免了太多的类型参数。

3qpi33ja

3qpi33ja5#

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

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

In Scala3, if generic type argument(s) is mapped to dependent type, how are covariant & contravariant modifiers mapped?

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

When are dependent types needed in Shapeless?
Why do we need to specify a refined type (or its equivalent Aux) for the output of certain type computations?
Why is the Aux technique required for type-level computations?

  • 类型参数和类型成员的类型推断是有区别的。类型参数应该被推断,如果不能被推断,它就被最小化(例如Nothing)。但是类型成员可以保持抽象。

[What's different between "def applyT" and "type T;def apply(c:T)"](https://stackoverflow.com/questions/59148665/whats-different-between-def-applytct-and-type-tdef-applyct)
Aux-pattern usage compiles without inferring an appropriate type
Why Scala Infer the Bottom Type when the type parameter is not specified?answer

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

https://github.com/lampepfl/dotty/issues/17212
https://github.com/scala/bug/issues/12767

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

Scala 3. Implementing Dependent Function Type

  • 在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

相关问题