Scala: types abstraits vs génériques

250

Je lisais A Tour of Scala: Abstract Types . Quand est-il préférable d'utiliser des types abstraits?

Par exemple,

abstract class Buffer {
  type T
  val element: T
}

plutôt que les génériques, par exemple,

abstract class Buffer[T] {
  val element: T
}
thatismatt
la source

Réponses:

257

Vous avez un bon point de vue sur cette question ici:

Le but de la
conversation du système de type A de Scala avec Martin Odersky, partie III
de Bill Venners et Frank Sommers (18 mai 2009)

Mise à jour (octobre 2009): ce qui suit est en fait illustré dans ce nouvel article de Bill Venners:
Membres de type abstrait par rapport aux paramètres de type générique dans Scala (voir résumé à la fin)


(Voici l'extrait pertinent de la première interview, mai 2009, c'est moi qui souligne)

Principe général

Il y a toujours eu deux notions d'abstraction:

  • paramétrage et
  • membres abstraits.

En Java, vous avez également les deux, mais cela dépend de ce que vous résumez.
En Java, vous avez des méthodes abstraites, mais vous ne pouvez pas passer une méthode en tant que paramètre.
Vous n'avez pas de champs abstraits, mais vous pouvez passer une valeur comme paramètre.
De même, vous n'avez pas de membres de type abstrait, mais vous pouvez spécifier un type comme paramètre.
Donc, en Java, vous avez également ces trois éléments, mais il existe une distinction quant au principe d'abstraction que vous pouvez utiliser pour quels types de choses. Et vous pourriez faire valoir que cette distinction est assez arbitraire.

La voie Scala

Nous avons décidé d'avoir les mêmes principes de construction pour les trois types de membres .
Vous pouvez donc avoir des champs abstraits ainsi que des paramètres de valeur.
Vous pouvez passer des méthodes (ou "fonctions") en tant que paramètres, ou vous pouvez les abstraire.
Vous pouvez spécifier des types en tant que paramètres ou vous pouvez les abstraire.
Et ce que nous obtenons conceptuellement, c'est que nous pouvons modéliser l'un en fonction de l'autre. Au moins en principe, nous pouvons exprimer toute sorte de paramétrage comme une forme d'abstraction orientée objet. Donc, dans un sens, on pourrait dire que le scala est une langue plus orthogonale et complète.

Pourquoi?

En particulier, ce que les types abstraits vous achètent est un bon traitement pour ces problèmes de covariance dont nous avons parlé auparavant.
Un problème standard, qui existe depuis longtemps, est le problème des animaux et des aliments.
Le puzzle était d'avoir une classe Animalavec une méthode eat, qui mange de la nourriture.
Le problème est que si nous sous-classons Animal et avons une classe telle que Vache, alors ils ne mangeront que de l'herbe et pas de la nourriture arbitraire. Une vache ne pouvait pas manger un poisson, par exemple.
Ce que vous voulez, c'est pouvoir dire qu'une vache a une méthode de manger qui ne mange que de l'herbe et pas d'autres choses.
En fait, vous ne pouvez pas faire cela en Java car il s'avère que vous pouvez construire des situations non fiables, comme le problème de l'attribution d'un fruit à une variable Apple dont j'ai parlé plus tôt.

La réponse est que vous ajoutez un type abstrait dans la classe Animal .
Vous dites que ma nouvelle classe d'animaux en a un type SuitableFoodque je ne connais pas.
C'est donc un type abstrait. Vous ne donnez pas d'implémentation du type. Ensuite, vous avez une eatméthode qui ne mange que SuitableFood.
Et puis dans la Cowclasse, je dirais, OK, j'ai une vache, qui prolonge la classe Animal, et pour Cow type SuitableFood equals Grass.
Les types abstraits fournissent donc cette notion de type dans une superclasse que je ne connais pas, que je remplis ensuite dans des sous-classes avec quelque chose que je connais .

Pareil avec le paramétrage?

En effet, vous le pouvez. Vous pouvez paramétrer la classe Animal avec le type de nourriture qu'il mange.
Mais en pratique, lorsque vous faites cela avec beaucoup de choses différentes, cela conduit à une explosion de paramètres , et généralement, en plus, dans des limites de paramètres .
Lors de l'ECOOP 1998, Kim Bruce, Phil Wadler et moi avions un article dans lequel nous montrions qu'à mesure que vous augmentez le nombre de choses que vous ne savez pas, le programme type se développera de façon quadratique .
Il y a donc de très bonnes raisons de ne pas faire de paramètres, mais d'avoir ces membres abstraits, car ils ne vous donnent pas cette explosion quadratique.


thatismatt demande dans les commentaires:

Pensez-vous que ce qui suit est un bon résumé:

  • Les types abstraits sont utilisés dans les relations 'has-a' ou 'uses-a' (par exemple a Cow eats Grass)
  • où les génériques sont généralement des relations «de» (par exemple List of Ints)

Je ne suis pas sûr que la relation soit si différente entre l'utilisation de types abstraits ou génériques. Ce qui est différent, c'est:

  • comment ils sont utilisés, et
  • comment les limites des paramètres sont gérées.

Pour comprendre de quoi Martin parle en ce qui concerne "l'explosion des paramètres, et généralement, en plus, dans les limites des paramètres ", et sa croissance quadratique ultérieure lorsque le type abstrait est modélisé à l'aide de génériques, vous pouvez considérer l'article " Abstraction de composants évolutifs " "écrit par ... Martin Odersky, et Matthias Zenger pour OOPSLA 2005, référencé dans les publications du projet Palcom (terminé en 2007).

Extraits pertinents

Définition

Les membres de type abstrait fournissent un moyen flexible d'abstraire sur des types concrets de composants.
Les types abstraits peuvent masquer des informations sur les composants internes d'un composant, similaires à leur utilisation dans les signatures SML . Dans un cadre orienté objet où les classes peuvent être étendues par héritage, elles peuvent également être utilisées comme un moyen flexible de paramétrage (souvent appelé polymorphisme familial, voir cette entrée de blog par exemple , et l'article écrit par Eric Ernst ).

(Remarque: le polymorphisme familial a été proposé pour les langages orientés objet comme solution pour prendre en charge des classes mutuellement récursives réutilisables mais sûres.
Une idée clé du polymorphisme familial est la notion de familles, qui sont utilisées pour regrouper des classes mutuellement récursives.)

abstraction de type borné

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

Ici, la déclaration de type de T est contrainte par une borne de type supérieure qui se compose d'un nom de classe ordonné et d'un raffinement { type O = T }.
La limite supérieure limite les spécialisations de T dans les sous-classes aux sous-types de Ordered pour lesquels le type membre Ode equals T.
En raison de cette contrainte, la <méthode de la classe Ordered est garantie d'être applicable à un récepteur et à un argument de type T.
L'exemple montre que le membre de type borné peut lui-même apparaître comme faisant partie de la borne.
(c'est-à-dire que Scala prend en charge le polymorphisme lié à F )

(Remarque, d'après Peter Canning, William Cook, Walter Hill et Walter Olthoff: la
quantification bornée a été introduite par Cardelli et Wegner comme un moyen de saisir des fonctions qui opèrent uniformément sur tous les sous-types d'un type donné.
Ils ont défini un modèle "objet" simple. et utilisé une quantification limitée pour des fonctions de vérification de type qui ont du sens sur tous les objets ayant un ensemble spécifié d '"attributs".
Une présentation plus réaliste des langages orientés objet permettrait aux objets qui sont des éléments de types définis de manière récursive .
Dans ce contexte, délimité la quantification ne remplit plus son objectif, il est facile de trouver des fonctions qui ont du sens sur tous les objets ayant un ensemble de méthodes spécifié, mais qui ne peuvent pas être saisies dans le système Cardelli-Wegner.
Pour fournir une base pour les fonctions polymorphes typées dans les langages orientés objet, nous introduisons la quantification bornée F)

Deux faces des mêmes pièces

Il existe deux formes principales d'abstraction dans les langages de programmation:

  • paramétrage et
  • membres abstraits.

La première forme est typique des langages fonctionnels, tandis que la seconde forme est généralement utilisée dans les langages orientés objet.

Traditionnellement, Java prend en charge le paramétrage des valeurs et l'abstraction des membres pour les opérations. Le plus récent Java 5.0 avec génériques prend également en charge le paramétrage pour les types.

Les arguments pour inclure des génériques dans Scala sont doubles:

  • Premièrement, l'encodage en types abstraits n'est pas si simple à faire à la main. Outre la perte de concision, il existe également le problème des conflits de noms accidentels entre les noms de type abstrait qui émulent les paramètres de type.

  • Deuxièmement, les génériques et les types abstraits remplissent généralement des rôles distincts dans les programmes Scala.

    • Les génériques sont généralement utilisés lorsque l'on a juste besoin d' instancier le type , alors que
    • les types abstraits sont généralement utilisés lorsque l'on doit se référer au type abstrait à partir du code client .
      Ce dernier se pose notamment dans deux situations:
    • On pourrait vouloir cacher la définition exacte d'un membre de type du code client, pour obtenir une sorte d'encapsulation connue des systèmes de modules de style SML.
    • Ou on peut vouloir remplacer le type de manière covariante dans les sous-classes pour obtenir le polymorphisme familial.

Dans un système à polymorphisme borné, la réécriture de type abstrait en génériques pourrait entraîner une expansion quadratique des bornes de type .


Mise à jour d'octobre 2009

Membres de type abstrait et paramètres de type générique dans Scala (Bill Venners)

(c'est moi qui souligne)

Jusqu'à présent, mon observation sur les membres de type abstrait est qu'ils sont principalement un meilleur choix que les paramètres de type génériques lorsque:

  • vous voulez laisser les gens se mélanger dans les définitions de ces types via des traits .
  • vous pensez que la mention explicite du nom du membre de type lors de sa définition aidera la lisibilité du code .

Exemple:

si vous voulez passer trois objets fixture différents dans les tests, vous pourrez le faire, mais vous devrez spécifier trois types, un pour chaque paramètre. Ainsi, si j'avais adopté l'approche des paramètres de type, vos classes de suite auraient pu ressembler à ceci:

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

Alors qu'avec l'approche de type membre, cela ressemblera à ceci:

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

Une autre différence mineure entre les membres de type abstrait et les paramètres de type générique est que lorsqu'un paramètre de type générique est spécifié, les lecteurs du code ne voient pas le nom du paramètre de type. Ainsi, quelqu'un a pu voir cette ligne de code:

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

Ils ne sauraient pas quel était le nom du paramètre de type spécifié comme StringBuilder sans le rechercher. Alors que le nom du paramètre type est juste là dans le code dans l'approche membre de type abstrait:

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

Dans ce dernier cas, les lecteurs du code pourraient voir qu'il StringBuilders'agit du type "paramètre de luminaire".
Ils auraient encore besoin de comprendre ce que signifiait «paramètre de luminaire», mais ils pouvaient au moins obtenir le nom du type sans consulter la documentation.

VonC
la source
61
Comment suis-je censé obtenir des points de karma en répondant aux questions de Scala lorsque vous venez faire ça ??? :-)
Daniel C. Sobral
7
Salut Daniel: Je pense qu'il doit y avoir des exemples concrets pour illustrer les avantages des types abstraits par rapport à la paramétrisation. En poster dans ce fil serait un bon début;) Je sais que je voterais pour cela.
VonC
1
Pensez-vous que ce qui suit est un bon résumé: les types abstraits sont utilisés dans les relations «a-a» ou «utilise-a» (par exemple, une vache mange de l'herbe) alors que les génériques sont généralement «des» relations (par exemple, la liste des entrées)
thatismatt
Je ne suis pas sûr que la relation soit si différente entre l'utilisation de types abstraits ou génériques. Ce qui est différent, c'est la façon dont ils sont utilisés et la façon dont les limites des paramètres sont gérées. Plus dans ma réponse dans un instant.
VonC
1
Note à soi-même: voir aussi ce billet de blog de mai 2010: daily-scala.blogspot.com/2010/05/…
VonC
37

J'avais la même question quand je lisais sur Scala.

L'avantage d'utiliser des génériques est que vous créez une famille de types. Personne ne devra sous - classe Buffer-Ils peuvent simplement utiliser Buffer[Any], Buffer[String]etc.

Si vous utilisez un type abstrait, les utilisateurs seront obligés de créer une sous-classe. Les gens auront besoin des classes comme AnyBuffer, StringBuffer, etc.

Vous devez décider lequel convient le mieux à vos besoins particuliers.

Daniel Yankowsky
la source
18
mmm amincit beaucoup amélioré sur ce front, vous pouvez simplement exiger Buffer { type T <: String }ou Buffer { type T = String }selon vos besoins
Eduardo Pareja Tobes
21

Vous pouvez utiliser des types abstraits conjointement avec des paramètres de type pour établir des modèles personnalisés.

Supposons que vous devez établir un modèle avec trois traits connectés:

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

dans la façon dont les arguments mentionnés dans les paramètres de type sont respectivement AA, BB, CC lui-même

Vous pouvez venir avec une sorte de code:

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

qui ne fonctionnerait pas de cette manière simple en raison des liaisons de paramètres de type. Vous devez rendre covariant pour hériter correctement

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

Cet échantillon serait compilé mais il fixe des exigences strictes sur les règles de variance et ne peut pas être utilisé dans certaines occasions

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
}

Le compilateur s'opposera à un tas d'erreurs de vérification de variance

Dans ce cas, vous pouvez rassembler toutes les exigences de type dans un trait supplémentaire et paramétrer d'autres traits dessus

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

Maintenant, nous pouvons écrire une représentation concrète pour le modèle décrit, définir des méthodes de gauche et de jointure dans toutes les classes et obtenir à droite et doubler gratuitement

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

Ainsi, les types abstraits et les paramètres de type sont utilisés pour créer des abstractions. Ils ont tous deux un point faible et un point fort. Les types abstraits sont plus spécifiques et capables de décrire n'importe quelle structure de type mais sont verbeux et nécessitent d'être spécifiés explicitement. Les paramètres de type peuvent créer un tas de types instantanément, mais vous inquiètent davantage concernant l'héritage et les limites de type.

Ils se synergisent et peuvent être utilisés conjointement pour créer des abstractions complexes qui ne peuvent pas être exprimées avec un seul d'entre eux.

ayvango
la source
0

Je pense qu'il n'y a pas beaucoup de différence ici. Les membres abstraits de type peuvent être vus comme des types simplement existentiels qui sont similaires aux types d'enregistrement dans certains autres langages fonctionnels.

Par exemple, nous avons:

class ListT {
  type T
  ...
}

et

class List[T] {...}

Ensuite , ListTest la même chose que List[_]. L'intérêt des membres de type est que nous pouvons utiliser la classe sans type concret explicite et éviter trop de paramètres de type.

Comcx
la source