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

105

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 est immuable par défaut
  • Getters définis automatiquement
  • Implémentation décente de toString ()
  • Conforme equals () et hashCode ()
  • Objet compagnon avec méthode unapply () pour la correspondance

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

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

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

Graham Lea
la source
Voir cette question connexe: stackoverflow.com/q/4635765/156410
David
18
Pourquoi n'est-ce pas constructif? Les mods sur ce site sont bien trop stricts. Cela a un nombre fini de réponses factuelles possibles.
Eloff
5
D'accord avec Eloff. C'est une question à laquelle je voulais aussi une réponse, et les réponses fournies sont très utiles et ne semblent pas subjectives. J'ai vu de nombreuses questions sur la manière de résoudre mon extrait de code, suscitant davantage de débats et d'opinions.
Herc

Réponses:

51

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, listés par souci d'exhaustivité: sérialisation / désérialisation conforme, pas besoin d'utiliser "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.

Dave Griffith
la source
48
Vous pouvez sous-classer une classe de cas. La sous-classe ne peut pas non plus être une classe de cas - c'est la restriction.
Seth Tisue le
99

D'abord les bons morceaux:

Tout est 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

Mise en toString()œuvre décente

Oui, très utile, mais faisable à la main sur n'importe quel cours si nécessaire

Conforme equals()ethashCode()

Combiné à une correspondance de motifs facile, c'est la principale raison pour laquelle les gens utilisent des classes de cas

Objet compagnon avec unapply()méthode de correspondance

É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 dans Scala 2.8


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

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

En pratique cependant, c'est rarement un problème. Changer le comportement de la méthode apply générée est garanti pour surprendre les utilisateurs et devrait ê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 du constructeur principal (qui rend également la validation disponible lors de l'utilisation copy)

Vous ne pouvez pas sous-classer

C'est vrai, bien qu'il soit toujours possible pour une classe de cas d'être elle-même un descendant. Un modèle courant consiste à construire une hiérarchie de classes de traits, en utilisant des classes de cas comme nœuds feuilles de l'arbre.

Il convient également de noter le sealedmodificateur. 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 modèles 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 se compile sans avertissement.

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

Pas de 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 supporte pas (actuellement) les paramètres paresseux (comme lazy vals, mais en tant que paramètres). La solution de contournement consiste à utiliser un paramètre par nom et à l'affecter à une valeur paresseuse 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 des classes de cas car elle rompt l'extracteur généré par le compilateur.

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

Kevin Wright
la source
1
Merci pour la réponse complète. Je pense que toute exception "Vous ne pouvez pas sous-classe" est probablement peu susceptible de me mettre en phase de si tôt.
Graham Lea
15
Vous pouvez sous-classer une classe de cas. La sous-classe ne peut pas non plus être une classe de cas - c'est la restriction.
Seth Tisue le
5
La limite de 22 paramètres pour les classes de cas est supprimée dans Scala 2.11. issues.scala-lang.org/browse/SI-7296
Jonathan Crosmer
Il est incorrect d'affirmer "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". Bien que cela nécessite de sauter à travers quelques obstacles pour le faire (si vous avez l'intention de conserver la fonctionnalité qui était auparavant générée de manière invisible par le compilateur scala), cela peut très certainement être réalisé: stackoverflow.com/a/25538287/501113
chaotic3quilibrium
J'ai beaucoup utilisé les classes de cas Scala et j'ai mis au point un "modèle de classe de cas" (qui finira par devenir une macro Scala) qui aide à résoudre un certain nombre de problèmes identifiés ci-dessus: codereview.stackexchange.com/a/98367 / 4758
chaotic3quilibrium
10

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

Par exemple, a case classa une equalsméthode sur les paramètres du constructeur. Vous ne vous souciez peut-être pas de cela lorsque vous écrivez votre classe pour la première fois, mais ce dernier peut décider que vous voulez que l'égalité ignore certains de ces paramètres, ou fasse quelque chose d'un peu différent. Cependant, le code client peut être écrit dans l'intervalle de temps qui dépend de l' case classégalité.

Daniel C. Sobral
la source
4
Je ne pense pas que le code client devrait dépendre de la signification exacte de «égal»; c'est à une classe de décider ce que signifie «égal» pour elle. L'auteur de la classe devrait être libre de modifier l'implémentation de «égal» sur toute la ligne.
pkaeding le
8
@pkaeding Vous êtes libre de ne pas faire dépendre le code client d'une méthode privée. Tout ce qui est public est un contrat que vous avez accepté.
Daniel C.Sobral
3
@ DanielC.Sobral Vrai, mais l'implémentation exacte de equals () (sur quels champs il est basé) n'est pas nécessairement dans le contrat. Au moins, vous pouvez l'exclure explicitement du contrat lorsque vous écrivez le cours pour la première fois.
herman
2
@ DanielC.Sobral Vous vous contredisez: vous dites que les gens vont même s'appuyer sur l'implémentation par défaut égale (qui compare l'identité d'objet). Si c'est vrai et que vous écrivez une autre implémentation égale plus tard, leur code sera également interrompu. Quoi qu'il en soit, si vous spécifiez des conditions pré / post et des invariants, et que les gens les ignorent, c'est leur problème.
herman
2
@herman Il n'y a pas de contradiction dans ce que je dis. Quant à "leur problème", bien sûr, à moins que cela ne devienne votre problème. Disons, par exemple, parce qu'ils sont un gros client de votre startup, ou parce que leur manager convainc la haute direction qu'il est trop coûteux pour eux de changer, donc vous devez annuler vos changements, ou parce que le changement cause plusieurs millions de dollars. bug et est annulé, etc. Mais si vous écrivez du code pour un hobby et que vous ne vous souciez pas des utilisateurs, allez-y.
Daniel C. Sobral
7

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 Principes de programmation fonctionnelle en Scala (Lecture 4.6 - Pattern Matching) que nous pourrions utiliser lorsque nous devons choisir entre la classe et la classe de cas. Le chapitre 7 de Scala By Example contient le même exemple.

Disons que nous voulons écrire un interpréteur pour les expressions arithmétiques. Pour garder les choses simples au départ, nous nous limitons aux nombres et aux opérations +. De telles expressions peuvent être représentées sous la forme d'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 par

nouvelle somme (nouveau nombre (1), nouvelle somme (nouveau nombre (3), nouveau nombre (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, l'ajout d'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 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 potentiellement de modifier 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 Pattern Matching

Ces deux modèles sont parfaitement bien et le choix entre eux est parfois une question de style, mais il y a néanmoins certains critères qui sont 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 regarde l'extensibilité future et le passage d'extension possible de votre système.

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

D'un autre côté, si ce que vous faites est de créer beaucoup de nouvelles méthodes, mais que la hiérarchie des classes elle-même restera relativement stable, alors la correspondance de modèles est en fait avantageuse. Parce que, encore une fois, chaque nouvelle méthode dans la solution de correspondance de modèle 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 dans 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 pourriez vouloir ajouter de nouvelles classes à une hiérarchie, ou vous pourriez vouloir 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.

entrez la description de l'image ici

Gabrielgiussi
la source
0

Je cite ce de Scala cookbookpar le Alvin Alexanderchapitre 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 case, il est important de savoir ce que fait réellement la déclaration de classe case.

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ésassemblez Person $ .class avec la commande javap, vous verrez qu'elle contient une méthode apply, ainsi que bien d'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ésassembler Person.class pour voir ce qu'il contient. Pour une classe simple comme celle-ci, elle contient 20 méthodes supplémentaires; ce ballonnement caché est l'une des raisons pour lesquelles certains développeurs n'aiment pas les classes de cas.

arglee
la source