web-dev-qa-db-fra.com

Quels sont les inconvénients de déclarer Scala classes de cas?

Si vous écrivez du code qui utilise beaucoup de belles structures de données immuables, les classes de cas semblent être une aubaine, vous offrant tout ce qui suit gratuitement avec un seul mot clé:

  • Tout immuable par défaut
  • Obturateurs définis automatiquement
  • Mise en œuvre décente de toString ()
  • Conforme equals () et hashCode ()
  • Objet compagnon avec méthode unapply () pour la correspondance

Mais quels sont les inconvénients de définir une structure de données immuable en tant que classe de cas?

Quelles restrictions impose-t-elle à la classe ou à ses clients?

Y a-t-il des situations où vous devriez préférer une classe sans cas?

102
Graham Lea

Un gros inconvénient: une classe de cas ne peut pas étendre une classe de cas. Voilà la restriction.

Autres avantages que vous avez manqués, répertoriés pour être complets: sérialisation/désérialisation conforme, pas besoin d'utiliser de "nouveau" mot-clé pour créer.

Je préfère les classes sans casse pour les objets avec un état mutable, un état privé ou aucun état (par exemple, la plupart des composants singleton). Classes de cas pour à peu près tout le reste.

49
Dave Griffith

D'abord les bons morceaux:

Tout immuable par défaut

Oui, et peut même être remplacé (en utilisant var) si vous en avez besoin

Getters définis automatiquement

Possible dans n'importe quelle classe en préfixant les paramètres avec val

Décent toString() implémentation

Oui, très utile, mais réalisable à la main sur n'importe quelle classe si nécessaire

Conforme equals() et hashCode()

Combiné avec un filtrage simple, c'est la principale raison pour laquelle les gens utilisent des classes de cas

Objet compagnon avec la méthode unapply() pour l'appariement

Également possible de faire à la main sur n'importe quelle classe en utilisant des extracteurs

Cette liste devrait également inclure la méthode de copie ultra-puissante, l'une des meilleures choses à venir Scala 2.8


Ensuite, le mauvais, il n'y a qu'une poignée de restrictions réelles avec les classes de cas:

Vous ne pouvez pas définir apply dans l'objet compagnon en utilisant la même signature que la méthode générée par le compilateur

En pratique cependant, cela pose rarement un problème. Le changement de comportement de la méthode d'application générée est garanti pour surprendre les utilisateurs et doit être fortement déconseillé, la seule justification pour le faire est de valider les paramètres d'entrée - une tâche mieux effectuée dans le corps principal du constructeur (qui rend également la validation disponible lors de l'utilisation de copy)

Vous ne pouvez pas sous-classer

Certes, bien qu'il soit toujours possible qu'une classe de cas soit elle-même un descendant. Un modèle courant consiste à créer une hiérarchie de classes de traits, en utilisant des classes de cas comme nœuds foliaires de l'arbre.

Il convient également de noter le modificateur sealed. Toute sous-classe d'un trait avec ce modificateur doit être déclarée dans le même fichier. Lors de la mise en correspondance de motifs avec des instances du trait, le compilateur peut alors vous avertir si vous n'avez pas vérifié toutes les sous-classes concrètes possibles. Lorsqu'il est combiné avec des classes de cas, cela peut vous offrir un niveau de confiance très élevé dans votre code s'il compile sans avertissement.

En tant que sous-classe de Product, les classes de cas ne peuvent pas avoir plus de 22 paramètres

Aucune vraie solution de contournement, sauf pour arrêter d'abuser des classes avec autant de paramètres :)

Aussi...

Une autre restriction parfois notée est que Scala ne prend pas (actuellement) en charge les paramètres paresseux (comme lazy vals, mais comme paramètres). La solution de contournement consiste à utiliser un paramètre de nom et à l'affecter à un val paresseux dans le constructeur. Malheureusement, les paramètres par nom ne se mélangent pas avec la correspondance de modèles, ce qui empêche la technique d'être utilisée avec les classes de cas car elle casse l'extracteur généré par le compilateur.

Cela est pertinent si vous souhaitez implémenter des structures de données paresseuses hautement fonctionnelles et, espérons-le, sera résolu avec l'ajout de paramètres paresseux à une future version de Scala.

98
Kevin Wright

Je pense que le principe TDD s'applique ici: ne pas trop concevoir. Lorsque vous déclarez que quelque chose est un case class, vous déclarez beaucoup de fonctionnalités. Cela diminuera la flexibilité dont vous disposez pour changer de classe à l'avenir.

Par exemple, un case class a une méthode equals sur les paramètres du constructeur. Vous pouvez ne pas vous en soucier lorsque vous écrivez votre classe pour la première fois, mais, dans ce dernier cas, vous pouvez décider que l'égalité doit ignorer certains de ces paramètres, ou faire quelque chose d'un peu différent. Cependant, le code client peut être écrit dans l'intervalle qui dépend de case class égalité.

10
Daniel C. Sobral

Y a-t-il des situations où vous devriez préférer une classe sans cas?

Martin Odersky nous donne un bon point de départ dans son cours Functional Programming Principles in Scala (Lecture 4.6 - Pattern Matching) que nous pourrions utiliser lorsque nous devons choisir entre classe et classe de cas. Le chapitre 7 de Scala By Example contient le même exemple.

Disons, nous voulons écrire un interpréteur pour les expressions arithmétiques. Pour garder les choses simples au départ, nous nous limitons aux seuls chiffres et + opérations. De telles expressions peuvent être représentées comme une hiérarchie de classes, avec une classe de base abstraite Expr comme racine et deux sous-classes Number et Sum. Ensuite, une expression 1 + (3 + 7) serait représentée comme

nouvelle somme (nouveau numéro (1), nouvelle somme (nouveau numéro (3), nouveau numéro (7)))

abstract class Expr {
  def eval: Int
}

class Number(n: Int) extends Expr {
  def eval: Int = n
}

class Sum(e1: Expr, e2: Expr) extends Expr {
  def eval: Int = e1.eval + e2.eval
}

De plus, l'ajout d'une nouvelle classe Prod n'entraîne aucune modification du code existant:

class Prod(e1: Expr, e2: Expr) extends Expr {
  def eval: Int = e1.eval * e2.eval
}

En revanche, ajouter une nouvelle méthode nécessite la modification de toutes les classes existantes.

abstract class Expr { 
  def eval: Int 
  def print
} 

class Number(n: Int) extends Expr { 
  def eval: Int = n 
  def print { Console.print(n) }
}

class Sum(e1: Expr, e2: Expr) extends Expr { 
  def eval: Int = e1.eval + e2.eval
  def print { 
   Console.print("(")
   print(e1)
   Console.print("+")
   print(e2)
   Console.print(")")
  }
}

Le même problème a été résolu avec les classes de cas.

abstract class Expr {
  def eval: Int = this match {
    case Number(n) => n
    case Sum(e1, e2) => e1.eval + e2.eval
  }
}
case class Number(n: Int) extends Expr
case class Sum(e1: Expr, e2: Expr) extends Expr

L'ajout d'une nouvelle méthode est un changement local.

abstract class Expr {
  def eval: Int = this match {
    case Number(n) => n
    case Sum(e1, e2) => e1.eval + e2.eval
  }
  def print = this match {
    case Number(n) => Console.print(n)
    case Sum(e1,e2) => {
      Console.print("(")
      print(e1)
      Console.print("+")
      print(e2)
      Console.print(")")
    }
  }
}

L'ajout d'une nouvelle classe Prod nécessite de modifier potentiellement toutes les correspondances de modèles.

abstract class Expr {
  def eval: Int = this match {
    case Number(n) => n
    case Sum(e1, e2) => e1.eval + e2.eval
    case Prod(e1,e2) => e1.eval * e2.eval
  }
  def print = this match {
    case Number(n) => Console.print(n)
    case Sum(e1,e2) => {
      Console.print("(")
      print(e1)
      Console.print("+")
      print(e2)
      Console.print(")")
    }
    case Prod(e1,e2) => ...
  }
}

Transcription de la vidéolecture 4.6 Correspondance de motifs

Ces deux modèles sont parfaitement fins et le choix entre eux est parfois une question de style, mais il existe néanmoins des critères importants.

Un critère pourrait être, créez-vous plus souvent de nouvelles sous-classes d'expression ou créez-vous plus souvent de nouvelles méthodes? C'est donc un critère qui examine l'extensibilité future et l'éventuelle carte d'extension de votre système.

Si ce que vous faites consiste principalement à créer de nouvelles sous-classes, alors la solution de décomposition orientée objet a le dessus. La raison en est qu'il est très facile et un changement très local de simplement créer une nouvelle sous-classe avec une méthode eval , où comme dans la solution fonctionnelle, vous devrez revenir en arrière et changer le code à l'intérieur de la méthode eval et y ajouter un nouveau cas.

D'un autre côté, si ce que vous faites va créer beaucoup de nouvelles méthodes, mais la hiérarchie des classes elle-même sera maintenue relativement stable, alors la correspondance de modèle est en fait avantageuse. Parce que, encore une fois, chaque nouvelle méthode dans la solution d'appariement de modèles n'est qu'un changement local , que vous la placiez dans la classe de base, ou peut-être même en dehors de la hiérarchie des classes. Alors qu'une nouvelle méthode telle que show dans la décomposition orientée objet nécessiterait une nouvelle incrémentation pour chaque sous-classe. Il y aurait donc plus de parties, que vous devez toucher.

Ainsi, la problématique de cette extensibilité en deux dimensions, où vous voudrez peut-être ajouter de nouvelles classes à une hiérarchie, ou vous voudrez peut-être ajouter de nouvelles méthodes, ou peut-être les deux, a été nommée le problème d'expression .

N'oubliez pas: nous devons utiliser cela comme un point de départ et non comme les seuls critères.

enter image description here

6
gabrielgiussi

Je cite ceci de Scala cookbook par Alvin Alexander chapitre 6: objects.

C'est l'une des nombreuses choses que j'ai trouvées intéressantes dans ce livre.

Pour fournir plusieurs constructeurs pour une classe de cas, il est important de savoir ce que fait réellement la déclaration de classe de cas.

case class Person (var name: String)

Si vous regardez le code généré par le compilateur Scala pour l'exemple de classe de cas, vous verrez qu'il crée deux fichiers de sortie, Person $ .class et Person.class. Si vous démontez Person $ .class avec la commande javap, vous verrez qu'elle contient une méthode apply, ainsi que de nombreuses autres:

$ javap Person$
Compiled from "Person.scala"
public final class Person$ extends scala.runtime.AbstractFunction1 implements scala.ScalaObject,scala.Serializable{
public static final Person$ MODULE$;
public static {};
public final Java.lang.String toString();
public scala.Option unapply(Person);
public Person apply(Java.lang.String); // the apply method (returns a Person) public Java.lang.Object readResolve();
        public Java.lang.Object apply(Java.lang.Object);
    }

Vous pouvez également démonter Person.class pour voir ce qu'il contient. Pour une classe simple comme celle-ci, elle contient 20 méthodes supplémentaires; ce gonflement caché est une raison pour laquelle certains développeurs n'aiment pas les classes de cas.

0
arglee