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é:
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?
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.
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:
apply
dans l'objet compagnon en utilisant la même signature que la méthode générée par le compilateurEn 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
)
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.
Aucune vraie solution de contournement, sauf pour arrêter d'abuser des classes avec autant de paramètres :)
Une autre restriction parfois notée est que Scala ne prend pas (actuellement) en charge les paramètres paresseux (comme lazy val
s, 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.
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é.
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.
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.