web-dev-qa-db-fra.com

Scala: Types abstraits vs génériques

Je lisais ne visite guidée de Scala: Types abstraits. 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
}
237
thatismatt

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

L'objectif du système de types de Scala
Entretien avec Martin Odersky, troisième partie
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 types abstraits et paramètres de types génériques dans Scala (voir le résumé à la fin)


(Voici l'extrait pertinent de la première interview, mai 2009, souligné par moi)

Principe général

Il y a toujours eu deux notions d'abstraction:

  • paramétrage et
  • membres abstraits.

Dans Java vous avez également les deux, mais cela dépend de ce que vous voulez résumer.
Dans 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 en tant que paramètre.
De même, vous n'avez pas de membres de type abstrait, mais vous pouvez spécifier un type en tant que paramètre.
Donc dans Java vous avez également les trois, mais il existe une distinction quant au principe d’abstraction que vous pouvez utiliser pour quel type de choses. Et vous pouvez 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 résumer.
Vous pouvez spécifier des types en tant que paramètres ou les résumer par dessus.
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 chaque type de paramétrage sous la forme d'une abstraction orientée objet. Donc, dans un sens, vous pourriez dire Scala est un langage plus orthogonal et complet.

Pourquoi?

Ce que, en particulier, les types abstraits vous achètent, est un traitement de Nice pour ces problèmes de covariance dont nous avons parlé auparavant.
Le problème des animaux et des aliments est un problème récurrent depuis longtemps.
Le puzzle devait avoir une classe Animal avec 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, ils ne mangeraient que de l'herbe et non de la nourriture arbitraire. Une vache ne pourrait 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 dans Java car il est possible de créer des situations désagréables, comme le problème de l'affectation d'un Fruit à un Apple variable dont j'ai parlé plus tôt.

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

Pareil pour le paramétrage?

En effet vous pouvez. Vous pouvez paramétrer la classe Animal avec le type de nourriture qu’elle mange.
Mais en pratique, lorsque vous faites cela avec beaucoup de choses différentes, cela entraîne une explosion de paramètres , et en général, quoi de plus, dans des limites de paramètres .
À l'ECOOP de 1998, Kim Bruce, Phil Wadler et moi avions un article dans lequel nous montrions que à mesure que vous augmentez le nombre de choses que vous ne connaissez pas, le programme type va croître de façon quadratique .
Il existe donc de très bonnes raisons de ne pas définir de paramètres, mais d’avoir ces membres abstraits, car ils ne vous font pas exploser de façon 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, un Cow eats Grass)
  • où les génériques sont généralement des "relations" (par exemple, List of Ints)

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

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

Comprendre de quoi parle Martin quand il s'agit "d'une explosion de paramètres, et généralement de plus, dans des limites de paramètres ", puis de façon quadratique croissance lorsque les types abstraits sont modélisés à l'aide de génériques, vous pouvez considérer le document "Abstraction de composant évolutif" écrit par ... Martin Odersky et Matthias Zenger pour OOPSLA 2005, référencés dans le publications du projet Palcom (terminé en 2007).

Extraits pertinents

Définition

Les membres de type abstrait offrent un moyen flexible de résumer des types de composants concrets.
Les types abstraits peuvent masquer des informations sur les éléments internes d’un composant, similaires à leur utilisation dans [~ # ~] sml [~ # ~] signatures. 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 de famille, voir ceci entrée de blog, par exemple , et le document écrit par Eric Ernst ).

(Remarque: le polymorphisme de famille a été proposé pour les langages orientés objet comme solution permettant de prendre en charge des classes mutuellement récursives, réutilisables mais sûres du type.
Une idée clé du polymorphisme de famille est la notion de famille, utilisée 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 limite de type supérieure qui consiste en un nom de classe Ordered et 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 membre de type O de equals T.
À cause de cette contrainte, le < méthode de la classe Ordered est garantie pour ê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 Scala prend en charge polymorphisme lié à F) )

(Remarque, de Peter Canning, William Cook, Walter Hill, Walter Olthoff écrit:
Cardelli et Wegner ont introduit la quantification bornée comme moyen de dactylographier des fonctions qui opèrent de manière uniforme sur tous les sous-types d’un type donné.
Ils ont défini un modèle "d'objet" simple et ont utilisé une quantification bornée pour des fonctions de vérification de type utiles pour tous les objets possédant un ensemble "d'attributs" spécifié.
Une présentation plus réaliste des langages orientés objet autoriserait les objets qui sont des éléments de types définis récursivement .
Dans ce contexte, la quantification limitée ne sert plus son objectif. Il est facile de trouver des fonctions utiles pour tous les objets ayant un ensemble de méthodes spécifié, mais qui ne peuvent pas être typées dans le système Cardelli-Wegner.
Pour introduire une base pour les fonctions polymorphes typées dans les langages orientés objet, nous introduisons la quantification lié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 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 des types.

Les arguments pour inclure des génériques dans Scala sont de deux ordres:

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

  • Deuxièmement, les génériques et les types abstraits servent 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'une instanciation de type , alors que
    • Les types abstraits sont généralement utilisés lorsqu'il est nécessaire de faire référence au type abstrait à partir du code client .
      Ce dernier cas se présente notamment dans deux situations:
    • On peut vouloir cacher la définition exacte d'un membre du type au code client pour obtenir un type d'encapsulation connu des systèmes de modules de style SML.
    • Ou bien, on peut vouloir remplacer le type de manière covariante dans les sous-classes pour obtenir un polymorphisme de famille.

Dans un système à polymorphisme lié, la réécriture de types abstraits en génériques peut impliquer un expansion quadratique des limites de type .


Mise à jour d'octobre 2009

Membres de types abstraits et paramètres de types génériques dans Scala (Bill Venners)

(c'est moi qui souligne)

Mon observation à ce jour concernant les membres de type abstrait est qu’ils constituent avant tout un meilleur choix que les paramètres de type générique lorsque:

  • vous voulez laisser les gens mélanger 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 souhaitez passer trois objets de 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 de type paramètre, vos classes de suites auraient pu ressembler à ceci:

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

Considérant que, avec l’approche 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 aurait-il pu voir cette ligne de code:

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

Ils ne sauraient pas quel est le nom du paramètre de type spécifié en tant que StringBuilder sans le rechercher. Considérant que le nom du paramètre type se trouve exactement là dans le code de 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 que StringBuilder est le type "paramètre de fixture".
Ils auraient encore besoin de comprendre ce que "paramètre de montage" signifiait, mais ils pourraient au moins obtenir le nom du type sans consulter la documentation.

246
VonC

J'avais la même question quand je lisais à propos de Scala.

L'avantage d'utiliser des génériques est que vous créez une famille de types. Personne n’aura besoin de 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 de classes comme AnyBuffer, StringBuffer, etc.

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

37
Daniel Yankowsky

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

Supposons que vous deviez établir un modèle avec trois traits liés:

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

dans la mesure où les arguments mentionnés dans les paramètres de type sont AA, BB, CC lui-même respectueusement

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

ce qui ne fonctionnerait pas de cette manière simple à cause des liens de type paramètre. Vous devez le 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 exemple compilerait, mais il fixe des exigences strictes sur les règles de variance et ne peut pas être utilisé dans certaines circonstances.

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'objectera avec un tas d'erreurs de contrôle de variance

Dans ce cas, vous pouvez regrouper toutes les exigences de type dans un trait supplémentaire et paramétrer d'autres traits par 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 motif décrit, définir les méthodes left et join dans toutes les classes et obtenir right and double 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 les deux un point faible et fort. Les types abstraits sont plus spécifiques et capables de décrire n'importe quelle structure de type, mais sont détaillés et doivent être spécifiés explicitement. Les paramètres de type peuvent créer instantanément de nombreux types, mais vous inquiètent davantage à propos de l'héritage et des limites de type.

Ils se donnent des synergies 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.

19
ayvango