Scala: tipos abstractos vs genéricos

Respuestas:

257

Tienes un buen punto de vista sobre este tema aquí:

El propósito del sistema
tipográfico de Scala Una conversación con Martin Odersky, Parte III
por Bill Venners y Frank Sommers (18 de mayo de 2009)

Actualización (octubre de 2009): lo que sigue a continuación ha sido ilustrado en este nuevo artículo por Bill Venners:
Miembros de tipo abstracto versus parámetros de tipo genérico en Scala (ver resumen al final)


(Aquí está el extracto relevante de la primera entrevista, mayo de 2009, énfasis mío)

Principio general

Siempre ha habido dos nociones de abstracción:

  • parametrización y
  • miembros abstractos

En Java también tiene ambos, pero depende de lo que esté abstrayendo.
En Java tiene métodos abstractos, pero no puede pasar un método como parámetro.
No tiene campos abstractos, pero puede pasar un valor como parámetro.
Y de manera similar, no tiene miembros de tipo abstracto, pero puede especificar un tipo como parámetro.
Entonces, en Java también tiene los tres, pero hay una distinción sobre qué principio de abstracción puede usar para qué tipo de cosas. Y podría argumentar que esta distinción es bastante arbitraria.

El camino Scala

Decidimos tener los mismos principios de construcción para los tres tipos de miembros .
Por lo tanto, puede tener campos abstractos, así como parámetros de valor.
Puede pasar métodos (o "funciones") como parámetros, o puede hacer un resumen sobre ellos.
Puede especificar tipos como parámetros, o puede hacer un resumen sobre ellos.
Y lo que obtenemos conceptualmente es que podemos modelar uno en términos del otro. Al menos en principio, podemos expresar todo tipo de parametrización como una forma de abstracción orientada a objetos. Entonces, en cierto sentido, se podría decir que Scala es un lenguaje más ortogonal y completo.

¿Por qué?

Lo que, en particular, los tipos abstractos te compran es un buen tratamiento para estos problemas de covarianza de los que hablamos anteriormente.
Un problema estándar, que ha existido durante mucho tiempo, es el problema de los animales y los alimentos.
El enigma era tener una clase Animalcon un método eatque comiera algo.
El problema es que si subclasificamos Animal y tenemos una clase como Vaca, entonces comerían solo Hierba y no comida arbitraria. Una vaca no podía comer un pescado, por ejemplo.
Lo que quieres es poder decir que una vaca tiene un método de comer que solo come hierba y no otras cosas.
En realidad, no puede hacer eso en Java porque resulta que puede construir situaciones poco sólidas, como el problema de asignar una fruta a una variable de Apple de la que hablé anteriormente.

La respuesta es que agrega un tipo abstracto a la clase Animal .
Dices, mi nueva clase Animal tiene un tipo de SuitableFood, que no sé.
Entonces es un tipo abstracto. No das una implementación del tipo. Entonces tienes un eatmétodo que solo come SuitableFood.
Y luego en la Cowclase diría, OK, tengo una vaca, que extiende la clase Animal, y para Cow type SuitableFood equals Grass.
Entonces, los tipos abstractos proporcionan esta noción de un tipo en una superclase que no sé, que luego rellenaré más adelante en las subclases con algo que sí sé .

Lo mismo con la parametrización?

De hecho puedes. Puede parametrizar la clase Animal con el tipo de comida que come.
Pero en la práctica, cuando haces eso con muchas cosas diferentes, se produce una explosión de parámetros y, por lo general, lo que es más, dentro de los límites de los parámetros .
En el ECOOP de 1998, Kim Bruce, Phil Wadler y yo teníamos un documento donde mostramos que a medida que aumenta la cantidad de cosas que no sabe, el programa típico crecerá de forma cuadrática .
Entonces, hay muy buenas razones para no hacer parámetros, sino para tener estos miembros abstractos, porque no te dan esta explosión cuadrática.


thatismatt pregunta en los comentarios:

¿Crees que el siguiente es un resumen justo:

  • Los tipos abstractos se utilizan en las relaciones 'has-a' o 'uses-a' (por ejemplo, a Cow eats Grass)
  • donde los genéricos son generalmente relaciones 'de' (p List of Ints. ej. )

No estoy seguro de que la relación sea tan diferente entre usar tipos abstractos o genéricos. Lo que es diferente es:

  • cómo se usan, y
  • cómo se gestionan los límites de los parámetros.

Para comprender de qué está hablando Martin cuando se trata de "explosión de parámetros y, por lo general, lo que es más, dentro de los límites de los parámetros ", y su posterior crecimiento cuadrático cuando los tipos abstractos se modelan usando genéricos, puede considerar el artículo " Abstracción de componentes escalables "escrito por ... Martin Odersky y Matthias Zenger para OOPSLA 2005, referenciado en las publicaciones del proyecto Palcom (terminado en 2007).

Extractos relevantes

Definición

Los miembros de tipo abstracto proporcionan una forma flexible de abstraer sobre tipos concretos de componentes.
Los tipos abstractos pueden ocultar información sobre componentes internos de un componente, similar a su uso en firmas SML . En un marco orientado a objetos donde las clases se pueden extender por herencia, también se pueden usar como un medio flexible de parametrización (a menudo llamado polimorfismo familiar, consulte esta entrada del blog, por ejemplo , y el artículo escrito por Eric Ernst ).

(Nota: el polimorfismo familiar se ha propuesto para los lenguajes orientados a objetos como una solución para apoyar clases recursivas mutuamente seguras y reutilizables.
Una idea clave del polimorfismo familiar es la noción de familias, que se utilizan para agrupar clases recursivas mutuas)

abstracción de tipo acotado

abstract class MaxCell extends AbsCell {
type T <: Ordered { type O = T }
def setMax(x: T) = if (get < x) set(x)
}

Aquí, la declaración de tipo de T está limitada por un límite de tipo superior que consiste en un nombre de clase Ordenado y un refinamiento { type O = T }.
El límite superior restringe las especializaciones de T en las subclases a los subtipos de Ordenado para los que el miembro Ode tipo equals T.
Debido a esta restricción, <se garantiza que el método de la clase Ordenada sea aplicable a un receptor y un argumento de tipo T.
El ejemplo muestra que el miembro de tipo acotado puede aparecer como parte del límite.
(es decir, Scala admite polimorfismo limitado por F )

(Nota, de Peter Canning, William Cook, Walter Hill, documento de Walter Olthoff:
Cardelli y Wegner introdujeron la cuantificación limitada como un medio para escribir funciones que operan de manera uniforme en todos los subtipos de un tipo dado.
Definieron un modelo simple de "objeto" y usó la cuantificación acotada para verificar las funciones que tienen sentido en todos los objetos que tienen un conjunto específico de "atributos".
Una presentación más realista de los lenguajes orientados a objetos permitiría objetos que son elementos de tipos definidos recursivamente .
En este contexto, acotado la cuantificación ya no cumple su propósito previsto, es fácil encontrar funciones que tengan sentido en todos los objetos que tienen un conjunto específico de métodos, pero que no se pueden escribir en el sistema Cardelli-Wegner.
Para proporcionar una base para las funciones polimórficas escritas en lenguajes orientados a objetos, presentamos la cuantificación limitada por F)

Dos caras de las mismas monedas

Hay dos formas principales de abstracción en los lenguajes de programación:

  • parametrización y
  • miembros abstractos

La primera forma es típica para lenguajes funcionales, mientras que la segunda forma se usa típicamente en lenguajes orientados a objetos.

Tradicionalmente, Java admite la parametrización de valores y la abstracción de miembros para operaciones. El Java 5.0 más reciente con genéricos admite la parametrización también para tipos.

Los argumentos para incluir genéricos en Scala son dobles:

  • Primero, la codificación en tipos abstractos no es tan fácil de hacer a mano. Además de la pérdida de concisión, también existe el problema de conflictos de nombres accidentales entre nombres de tipos abstractos que emulan parámetros de tipo.

  • En segundo lugar, los tipos genéricos y abstractos generalmente cumplen roles distintos en los programas Scala.

    • Los genéricos generalmente se usan cuando uno solo necesita una instanciación de tipo , mientras que
    • Los tipos abstractos se usan típicamente cuando uno necesita referirse al tipo abstracto del código del cliente .
      Esto último surge en particular en dos situaciones:
    • Es posible que desee ocultar la definición exacta de un miembro de tipo del código del cliente, para obtener un tipo de encapsulación conocida de los sistemas de módulos de estilo SML.
    • O uno puede anular el tipo covariantemente en subclases para obtener polimorfismo familiar.

En un sistema con polimorfismo acotado, reescribir el tipo abstracto en genéricos podría implicar una expansión cuadrática de los límites de tipo .


Actualización de octubre de 2009

Miembros de tipo abstracto versus parámetros de tipo genérico en Scala (Bill Venners)

(énfasis mío)

Mi observación hasta ahora sobre los miembros de tipo abstracto es que son principalmente una mejor opción que los parámetros de tipo genérico cuando:

  • desea que las personas mezclen definiciones de esos tipos a través de rasgos .
  • cree que la mención explícita del nombre del miembro tipo cuando se está definiendo ayudará a la legibilidad del código .

Ejemplo:

Si desea pasar tres objetos fijos diferentes a las pruebas, podrá hacerlo, pero deberá especificar tres tipos, uno para cada parámetro. Por lo tanto, si hubiera adoptado el enfoque de parámetro de tipo, sus clases de suite podrían haber terminado luciendo así:

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

Mientras que con el enfoque de miembro tipo se verá así:

// Type member version
class MySuite extends FixtureSuite3 with MyHandyFixture {
  // ...
}

Otra diferencia menor entre los miembros de tipo abstracto y los parámetros de tipo genérico es que cuando se especifica un parámetro de tipo genérico, los lectores del código no ven el nombre del parámetro de tipo. Por lo tanto, alguien podría ver esta línea de código:

// Type parameter version
class MySuite extends FixtureSuite[StringBuilder] with StringBuilderFixture {
  // ...
}

No sabrían cuál era el nombre del parámetro de tipo especificado como StringBuilder sin buscarlo. Mientras que el nombre del parámetro de tipo está ahí en el código en el enfoque de miembro de tipo abstracto:

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

En el último caso, los lectores del código podrían ver que StringBuilderes el tipo de "parámetro de dispositivo".
Todavía tendrían que averiguar qué significaba "parámetro de dispositivo", pero al menos podrían obtener el nombre del tipo sin consultar la documentación.

VonC
fuente
61
¿Cómo se supone que obtenga puntos de karma respondiendo preguntas de Scala cuando vengas a hacer esto? :-)
Daniel C. Sobral
77
Hola Daniel: Creo que debe haber ejemplos concretos para ilustrar las ventajas de los tipos abstractos sobre la parametrización. Publicar algo en este hilo sería un buen comienzo;) Sé que votaría eso.
VonC
1
¿Crees que el siguiente es un resumen razonable? Los tipos abstractos se usan en relaciones 'has-a' o 'uses-a' (por ejemplo, una vaca come hierba) donde los genéricos son generalmente 'de' relaciones (por ejemplo, Lista de Ints)
thatismatt
No estoy seguro de que la relación sea tan diferente entre usar tipos abstractos o genéricos. Lo que es diferente es cómo se usan y cómo se gestionan los límites de los parámetros. Más en mi respuesta en un momento.
VonC
1
Nota personal: vea también esta publicación de blog de mayo de 2010: daily-scala.blogspot.com/2010/05/…
VonC
37

Tenía la misma pregunta cuando estaba leyendo sobre Scala.

La ventaja de usar genéricos es que está creando una familia de tipos. Nadie va a necesitar subclase Buffer-ellos simplemente puede utilizar Buffer[Any], Buffer[String]etc.

Si usa un tipo abstracto, las personas se verán obligadas a crear una subclase. Personas necesitarán clases como AnyBuffer, StringBuffer, etc.

Debe decidir cuál es mejor para su necesidad particular.

Daniel Yankowsky
fuente
18
mmm thins mejoró mucho en este frente, solo puede requerir Buffer { type T <: String }o Buffer { type T = String }dependiendo de sus necesidades
Eduardo Pareja Tobes
21

Puede usar tipos abstractos junto con parámetros de tipo para establecer plantillas personalizadas.

Supongamos que necesita establecer un patrón con tres rasgos conectados:

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

en la forma en que los argumentos mencionados en los parámetros de tipo son AA, BB, CC respetuosamente

Puede venir con algún tipo de código:

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]]

que no funcionaría de esta manera simple debido a los enlaces de parámetros de tipo. Debes hacerlo covariante para heredar correctamente

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]]

Esta muestra se compilaría, pero establece requisitos estrictos sobre las reglas de variación y no se puede usar en algunas ocasiones.

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
}

El compilador objetará con un montón de errores de verificación de varianza

En ese caso, puede reunir todos los requisitos de tipo en rasgos adicionales y parametrizar otros rasgos sobre ellos

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

Ahora podemos escribir una representación concreta para el patrón descrito, definir métodos de unión e izquierda en todas las clases y obtener la derecha y el doble gratis

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

Por lo tanto, tanto los tipos abstractos como los parámetros de tipo se utilizan para crear abstracciones. Ambos tienen puntos débiles y fuertes. Los tipos abstractos son más específicos y capaces de describir cualquier estructura de tipos, pero son detallados y requieren una especificación explícita. Los parámetros de tipo pueden crear un montón de tipos al instante, pero le da una preocupación adicional sobre la herencia y los límites de tipo.

Se dan sinergia entre sí y se pueden usar en conjunto para crear abstracciones complejas que no se pueden expresar con solo una de ellas.

ayvango
fuente
0

Creo que no hay mucha diferencia aquí. Los miembros abstractos de tipo pueden verse como solo tipos existenciales que es similar a los tipos de registro en algunos otros lenguajes funcionales.

Por ejemplo, tenemos:

class ListT {
  type T
  ...
}

y

class List[T] {...}

Entonces ListTes lo mismo que List[_]. La conveniencia de los miembros de tipo es que podemos usar la clase sin un tipo concreto explícito y evitar demasiados parámetros de tipo.

Comcx
fuente