Was sind die Nachteile bei der Angabe von Scala-Fallklassen?

105

Wenn Sie Code schreiben, der viele schöne, unveränderliche Datenstrukturen verwendet, scheinen Fallklassen ein Glücksfall zu sein, sodass Sie mit nur einem Schlüsselwort kostenlos Folgendes erhalten:

  • Alles standardmäßig unveränderlich
  • Getter werden automatisch definiert
  • Ordentliche toString () Implementierung
  • Konform mit equals () und hashCode ()
  • Begleitobjekt mit der Methode unapply () zum Abgleichen

Aber was sind die Nachteile der Definition einer unveränderlichen Datenstruktur als Fallklasse?

Welche Einschränkungen gibt es für die Klasse oder ihre Kunden?

Gibt es Situationen, in denen Sie eine Nicht-Fall-Klasse bevorzugen sollten?

Graham Lea
quelle
Siehe diese verwandte Frage: stackoverflow.com/q/4635765/156410
David
18
Warum ist das nicht konstruktiv? Die Mods auf dieser Seite sind viel zu streng. Dies hat eine begrenzte Anzahl möglicher sachlicher Antworten.
Eloff
5
Stimme Eloff zu. Dies ist eine Frage, auf die ich auch eine Antwort haben wollte. Die Antworten sind sehr nützlich und erscheinen nicht subjektiv. Ich habe viele Fragen gesehen, wie ich meinen Code-Auszug reparieren kann, was zu mehr Debatten und Meinungen geführt hat.
Herc

Antworten:

51

Ein großer Nachteil: Eine Fallklasse kann eine Fallklasse nicht erweitern. Das ist die Einschränkung.

Weitere Vorteile, die Sie der Vollständigkeit halber verpasst haben: konforme Serialisierung / Deserialisierung, zum Erstellen muss kein "neues" Schlüsselwort verwendet werden.

Ich bevorzuge Nicht-Fall-Klassen für Objekte mit veränderlichem Status, privatem Status oder keinem Status (z. B. die meisten Singleton-Komponenten). Fallklassen für so ziemlich alles andere.

Dave Griffith
quelle
48
Sie können eine Fallklasse unterordnen. Die Unterklasse kann auch keine Fallklasse sein - das ist die Einschränkung.
Seth Tisue
99

Zuerst die guten Teile:

Alles standardmäßig unveränderlich

Ja, und kann varbei Bedarf sogar überschrieben werden

Getter werden automatisch definiert

Möglich in jeder Klasse durch Präfixieren von Parametern mit val

Ordentliche toString()Umsetzung

Ja, sehr nützlich, aber bei Bedarf in jeder Klasse von Hand machbar

Konform equals()undhashCode()

In Kombination mit dem einfachen Mustervergleich ist dies der Hauptgrund, warum Benutzer Fallklassen verwenden

Begleitobjekt mit unapply()Methode zum Abgleichen

Es ist auch möglich, jede Klasse mit Extraktoren von Hand zu bearbeiten

Diese Liste sollte auch die überaus leistungsstarke Kopiermethode enthalten, eine der besten Voraussetzungen für Scala 2.8


Dann das Schlechte, es gibt nur eine Handvoll wirklicher Einschränkungen bei Fallklassen:

Sie können applyim Begleitobjekt nicht dieselbe Signatur wie die vom Compiler generierte Methode definieren

In der Praxis ist dies jedoch selten ein Problem. Das Ändern des Verhaltens der generierten Apply-Methode wird die Benutzer garantiert überraschen und sollte dringend entmutigt werden. Die einzige Rechtfertigung dafür ist die Validierung von Eingabeparametern - eine Aufgabe, die am besten im Hauptkonstruktorkörper ausgeführt wird (wodurch die Validierung auch bei Verwendung verfügbar wird copy).

Sie können keine Unterklasse

Es stimmt, obwohl es immer noch möglich ist, dass eine Fallklasse selbst ein Nachkomme ist. Ein gängiges Muster besteht darin, eine Klassenhierarchie von Merkmalen aufzubauen, wobei Fallklassen als Blattknoten des Baums verwendet werden.

Es ist auch erwähnenswert, den sealedModifikator. Jede Unterklasse eines Merkmals mit diesem Modifikator muss in derselben Datei deklariert werden. Beim Mustervergleich mit Instanzen des Merkmals kann der Compiler Sie warnen, wenn Sie nicht alle möglichen konkreten Unterklassen überprüft haben. In Kombination mit Fallklassen kann dies ein hohes Maß an Vertrauen in Ihren Code bieten, wenn dieser ohne Vorwarnung kompiliert wird.

Als Unterklasse von Product können Fallklassen nicht mehr als 22 Parameter haben

Keine wirkliche Problemumgehung, außer um den Missbrauch von Klassen mit so vielen Parametern zu stoppen :)

Ebenfalls...

Eine andere Einschränkung, die manchmal bemerkt wird, ist, dass Scala (derzeit) keine faulen Parameter unterstützt (wie lazy vals, sondern als Parameter). Die Problemumgehung besteht darin, einen By-Name-Parameter zu verwenden und ihn im Konstruktor einem Lazy Val zuzuweisen. Leider mischen sich namentliche Parameter nicht mit dem Mustervergleich, wodurch verhindert wird, dass die Technik mit Fallklassen verwendet wird, da der vom Compiler generierte Extraktor beschädigt wird.

Dies ist relevant, wenn Sie hochfunktionale Lazy-Data-Strukturen implementieren möchten, und wird hoffentlich durch Hinzufügen von Lazy-Parametern zu einer zukünftigen Version von Scala behoben.

Kevin Wright
quelle
1
Danke für die umfassende Antwort. Ich denke, dass jede Ausnahme "Sie können nicht unterklassifizieren" mich wahrscheinlich nicht so schnell in Phase bringen wird.
Graham Lea
15
Sie können eine Fallklasse unterordnen. Die Unterklasse kann auch keine Fallklasse sein - das ist die Einschränkung.
Seth Tisue
5
Die 22-Parameter-Grenze für Fallklassen wurde in Scala 2.11 entfernt. Issues.scala-lang.org/browse/SI-7296
Jonathan Crosmer
Es ist falsch zu behaupten, dass "Apply im Begleitobjekt nicht mit derselben Signatur wie die vom Compiler generierte Methode definiert werden kann". Dies
erfordert zwar das
Ich habe Scala-Fallklassen ausgiebig verwendet und mir ein "Fallklassenmuster" ausgedacht (das schließlich als Scala-Makro enden wird), das bei einer Reihe der oben genannten Probleme hilft: codereview.stackexchange.com/a/98367 / 4758
chaotic3quilibrium
10

Ich denke, hier gilt das TDD-Prinzip: Überdesign nicht. Wenn Sie etwas als a case classdeklarieren, deklarieren Sie viele Funktionen. Dies verringert die Flexibilität, die Sie beim zukünftigen Wechsel der Klasse haben.

Zum Beispiel case classhat a eine equalsMethode über die Konstruktorparameter. Das interessiert Sie vielleicht nicht, wenn Sie Ihre Klasse zum ersten Mal schreiben, aber letztere entscheiden möglicherweise, dass die Gleichheit einige dieser Parameter ignoriert oder etwas anderes tut. Client-Code kann jedoch in der Zwischenzeit geschrieben werden, die von der case classGleichheit abhängt .

Daniel C. Sobral
quelle
4
Ich denke nicht, dass Client-Code von der genauen Bedeutung von 'gleich' abhängen sollte; Es liegt an einer Klasse zu entscheiden, was "gleich" für sie bedeutet. Dem Klassenautor sollte es freigestellt sein, die Implementierung von "gleich" auf der ganzen Linie zu ändern.
pkaeding
8
@pkaeding Es steht Ihnen frei, dass der Clientcode nicht von einer privaten Methode abhängt. Alles, was öffentlich ist, ist ein Vertrag, dem Sie zugestimmt haben.
Daniel C. Sobral
3
@ DanielC.Sobral Stimmt, aber die genaue Implementierung von equals () (auf welchen Feldern es basiert) ist nicht unbedingt im Vertrag enthalten. Zumindest können Sie es beim ersten Schreiben der Klasse ausdrücklich vom Vertrag ausschließen.
Hermann
2
@ DanielC.Sobral Sie widersprechen sich selbst: Sie sagen, die Leute werden sich sogar auf die Standard-Gleichheitsimplementierung verlassen (die die Objektidentität vergleicht). Wenn dies zutrifft und Sie später eine andere gleichwertige Implementierung schreiben, wird auch deren Code unterbrochen. Wenn Sie Pre / Post-Bedingungen und Invarianten angeben und die Leute sie ignorieren, ist das ihr Problem.
Hermann
2
@herman Es gibt keinen Widerspruch in dem, was ich sage. Was "ihr Problem" betrifft, sicher, es sei denn, es wird Ihr Problem. Sagen wir zum Beispiel, weil sie ein großer Kunde Ihres Startups sind oder weil ihr Manager das obere Management davon überzeugt, dass es zu kostspielig ist, Änderungen vorzunehmen, sodass Sie Ihre Änderungen rückgängig machen müssen oder weil die Änderung mehrere Millionen Dollar verursacht Fehler und wird zurückgesetzt usw. Aber wenn Sie Code für Hobby schreiben und sich nicht um Benutzer kümmern, fahren Sie fort.
Daniel C. Sobral
7

Gibt es Situationen, in denen Sie eine Nicht-Fall-Klasse bevorzugen sollten?

Martin Odersky gibt uns einen guten Ausgangspunkt in seinem Kurs Funktionsprogrammierprinzipien in Scala (Vorlesung 4.6 - Pattern Matching), den wir verwenden können, wenn wir zwischen Klasse und Fallklasse wählen müssen. Das Kapitel 7 von Scala By Example enthält dasselbe Beispiel.

Angenommen, wir möchten einen Interpreter für arithmetische Ausdrücke schreiben. Um die Dinge zunächst einfach zu halten, beschränken wir uns nur auf Zahlen und + Operationen. Solche Ausdrücke können als Klassenhierarchie mit einer abstrakten Basisklasse Expr als Wurzel und zwei Unterklassen Number und Sum dargestellt werden. Dann würde ein Ausdruck 1 + (3 + 7) dargestellt als

neue Summe (neue Nummer (1), neue Summe (neue Nummer (3), neue Nummer (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
}

Darüber hinaus führt das Hinzufügen einer neuen Prod-Klasse zu keinen Änderungen am vorhandenen Code:

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

Im Gegensatz dazu erfordert das Hinzufügen einer neuen Methode die Änderung aller vorhandenen Klassen.

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

Das gleiche Problem wurde mit Fallklassen gelöst.

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

Das Hinzufügen einer neuen Methode ist eine lokale Änderung.

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

Das Hinzufügen einer neuen Prod-Klasse erfordert möglicherweise das Ändern aller Musterübereinstimmungen.

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

Transkript aus der Videolecture 4.6 Pattern Matching

Beide Designs sind vollkommen in Ordnung und die Wahl zwischen ihnen ist manchmal eine Frage des Stils, aber dennoch gibt es einige Kriterien, die wichtig sind.

Ein Kriterium könnte sein, erstellen Sie häufiger neue Unterklassen des Ausdrucks oder erstellen Sie häufiger neue Methoden? Es ist also ein Kriterium, das die zukünftige Erweiterbarkeit und den möglichen Erweiterungsdurchlauf Ihres Systems untersucht.

Wenn Sie hauptsächlich neue Unterklassen erstellen, hat die objektorientierte Zerlegungslösung tatsächlich die Oberhand. Der Grund ist, dass es sehr einfach und eine sehr lokale Änderung ist, nur eine neue Unterklasse mit einer Bewertungsmethode zu erstellen , bei der Sie wie bei der funktionalen Lösung zurückgehen und den Code innerhalb der Bewertungsmethode ändern und einen neuen Fall hinzufügen müssen dazu.

Auf der anderen Seite, wenn das, was Sie tun werden , viele neue Methoden schaffen werden, aber die Klassenhierarchie selbst relativ stabil gehalten werden, dann ist Musterabgleich tatsächlich vorteilhaft. Auch hier ist jede neue Methode in der Mustervergleichslösung nur eine lokale Änderung , unabhängig davon, ob Sie sie in die Basisklasse oder sogar außerhalb der Klassenhierarchie einfügen. Während eine neue Methode wie in der objektorientierten Zerlegung zeigen eine neue Inkrementierung erfordern würde, ist jede Unterklasse. Es würde also mehr Teile geben, die du anfassen musst.

Das Problem dieser Erweiterbarkeit in zwei Dimensionen, bei der Sie einer Hierarchie neue Klassen hinzufügen oder neue Methoden oder beides hinzufügen möchten, wurde als Ausdrucksproblem bezeichnet .

Denken Sie daran: Wir müssen dies als Ausgangspunkt verwenden und nicht als die einzigen Kriterien.

Geben Sie hier die Bildbeschreibung ein

gabrielgiussi
quelle
0

Ich zitiere diese aus Scala cookbooknach Alvin AlexanderKapitel 6: objects.

Dies ist eines der vielen Dinge, die ich in diesem Buch interessant fand.

Um mehrere Konstruktoren für eine Fallklasse bereitzustellen, ist es wichtig zu wissen, was die Fallklassendeklaration tatsächlich bewirkt.

case class Person (var name: String)

Wenn Sie sich den Code ansehen, den der Scala-Compiler für das Fallklassenbeispiel generiert, werden Sie feststellen, dass zwei Ausgabedateien erstellt werden, Person $ .class und Person.class. Wenn Sie Person $ .class mit dem Befehl javap zerlegen, werden Sie feststellen, dass es neben vielen anderen eine Methode zum Anwenden enthält:

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

Sie können Person.class auch zerlegen, um zu sehen, was es enthält. Für eine einfache Klasse wie diese enthält sie weitere 20 Methoden. Dieses versteckte Aufblähen ist einer der Gründe, warum manche Entwickler Fallklassen nicht mögen.

Arglee
quelle