Quali sono gli svantaggi nel dichiarare le classi case Scala?


105

Se stai scrivendo codice che utilizza molte bellissime strutture di dati immutabili, le classi case sembrano essere una manna dal cielo, dandoti tutto quanto segue gratuitamente con una sola parola chiave:

  • Tutto immutabile per impostazione predefinita
  • Getters definiti automaticamente
  • Decente implementazione di toString ()
  • Conforme equals () e hashCode ()
  • Oggetto companion con metodo unapply () per la corrispondenza

Ma quali sono gli svantaggi di definire una struttura dati immutabile come classe di casi?

Quali restrizioni pone alla classe o ai suoi clienti?

Ci sono situazioni in cui dovresti preferire una classe senza casi?


Vedi questa domanda correlata: stackoverflow.com/q/4635765/156410
David

18
Perché questo non è costruttivo? Le mod su questo sito sono troppo rigide. Questo ha un numero finito di possibili risposte fattuali.
Eloff

5
D'accordo con Eloff. Anche questa è una domanda a cui volevo una risposta e le risposte fornite sono molto utili e non sembrano soggettive. Ho visto molte domande su "come correggere il mio estratto di codice" suscitando più dibattiti e opinioni.
Herc

Risposte:


51

Un grande svantaggio: una classe case non può estendere una classe case. Questa è la restrizione.

Altri vantaggi che ti sei perso, elencati per completezza: serializzazione / deserializzazione conforme, non è necessario utilizzare la parola chiave "nuova" per creare.

Preferisco classi senza maiuscole e minuscole per oggetti con stato mutabile, stato privato o nessuno stato (ad esempio la maggior parte dei componenti singleton). Classi di casi praticamente per tutto il resto.


48
Puoi sottoclassare una classe case. Anche la sottoclasse non può essere una classe case: questa è la restrizione.
Seth Tisue

99

Prima i pezzi buoni:

Tutto immutabile per impostazione predefinita

Sì, e può anche essere sovrascritto (usando var) se ne hai bisogno

Getters definiti automaticamente

Possibile in qualsiasi classe anteponendo a params val

toString()Attuazione decente

Sì, molto utile, ma fattibile a mano in qualsiasi classe se necessario

Conforme equals()ehashCode()

Combinato con una facile corrispondenza dei modelli, questo è il motivo principale per cui le persone usano le classi di casi

Oggetto companion con unapply()metodo per la corrispondenza

È anche possibile fare a mano su qualsiasi classe utilizzando estrattori

Questo elenco dovrebbe includere anche il potente metodo di copia, una delle cose migliori in arrivo in Scala 2.8


Quindi il cattivo, ci sono solo una manciata di restrizioni reali con le classi di casi:

Non è possibile definire applynell'oggetto associato utilizzando la stessa firma del metodo generato dal compilatore

In pratica, però, questo è raramente un problema. Il cambiamento del comportamento del metodo di applicazione generato è garantito per sorprendere gli utenti e dovrebbe essere fortemente sconsigliato, l'unica giustificazione per farlo è convalidare i parametri di input - un'attività eseguita al meglio nel corpo del costruttore principale (che rende anche la convalida disponibile durante l'utilizzo copy)

Non puoi sottoclasse

Vero, sebbene sia ancora possibile che una classe case sia essa stessa un discendente. Un modello comune è costruire una gerarchia di classi di tratti, utilizzando le classi case come nodi foglia dell'albero.

Vale anche la pena notare il sealedmodificatore. Qualsiasi sottoclasse di un tratto con questo modificatore deve essere dichiarata nello stesso file. Quando si confronta il modello con le istanze del tratto, il compilatore può quindi avvisarti se non hai controllato tutte le possibili sottoclassi concrete. Se combinato con le classi case, questo può offrire un livello molto elevato di fiducia nel codice se viene compilato senza preavviso.

Come sottoclasse di Product, le classi case non possono avere più di 22 parametri

Nessuna vera soluzione alternativa, tranne per smettere di abusare delle classi con tutti questi parametri :)

Anche...

Un'altra restrizione a volte notata è che Scala (attualmente) non supporta parametri pigri (come lazy vals, ma come parametri). La soluzione alternativa a ciò è utilizzare un parametro per nome e assegnarlo a un lazy val nel costruttore. Sfortunatamente, i parametri per nome non si mescolano con la corrispondenza del modello, il che impedisce che la tecnica venga utilizzata con le classi case poiché interrompe l'estrattore generato dal compilatore.

Ciò è rilevante se si desidera implementare strutture di dati pigre altamente funzionali e si spera che venga risolto con l'aggiunta di parametri pigri in una futura versione di Scala.


1
Grazie per la risposta esauriente. Penso che qualsiasi cosa, ad eccezione di "Non puoi sottoclasse", probabilmente non mi sopporterà presto.
Graham Lea

15
Puoi sottoclassare una classe case. Anche la sottoclasse non può essere una classe case: questa è la restrizione.
Seth Tisue

5
Il limite di 22 parametri per le classi case è stato rimosso in Scala 2.11. issues.scala-lang.org/browse/SI-7296
Jonathan Crosmer

Non è corretto affermare "Non è possibile definire apply nell'oggetto associato utilizzando la stessa firma del metodo generato dal compilatore". Anche se richiede saltando attraverso alcuni cerchi di farlo (se avete intenzione di mantenere la funzionalità che ha usato per essere invisibile generato dal compilatore Scala), che certamente può essere raggiunto: stackoverflow.com/a/25538287/501113
chaotic3quilibrium

Ho utilizzato ampiamente le case class Scala e ho escogitato un "modello di classe case" (che alla fine finirà come una macro Scala) che aiuta con una serie di problemi identificati sopra: codereview.stackexchange.com/a/98367 / 4758
caotic3quilibrium

10

Penso che qui si applichi il principio TDD: non sovrascrivere. Quando dichiari qualcosa come a case class, stai dichiarando molte funzionalità. Ciò ridurrà la flessibilità che hai nel cambiare classe in futuro.

Ad esempio, a case classha un equalsmetodo sui parametri del costruttore. Potrebbe non interessarti di questo quando scrivi per la prima volta la tua classe, ma, in secondo luogo, potresti decidere di volere che l'uguaglianza ignori alcuni di questi parametri, o fai qualcosa di un po 'diverso. Tuttavia, il codice client può essere scritto nel frattempo che dipende case classdall'uguaglianza.


4
Non credo che il codice client debba dipendere dal significato esatto di "uguale"; spetta a una classe decidere cosa significa per essa "uguale". L'autore della classe dovrebbe essere libero di modificare l'implementazione di "uguale" su tutta la linea.
pkaeding

8
@pkaeding Sei libero di non far dipendere il codice client da alcun metodo privato. Tutto ciò che è pubblico è un contratto che hai accettato.
Daniel C. Sobral

3
@ DanielC.Sobral Vero, ma l'esatta implementazione di equals () (su quali campi si basa) non è necessariamente nel contratto. Almeno, potresti escluderlo esplicitamente dal contratto quando scrivi per la prima volta la classe.
Herman

2
@ DanielC.Sobral Ti stai contraddicendo: dici che le persone si affideranno addirittura all'implementazione di default uguale (che confronta l'identità dell'oggetto). Se questo è vero e in seguito scrivi un'implementazione di uguale diversa, anche il loro codice si interromperà. Ad ogni modo, se specifichi condizioni e invarianti pre / post e le persone le ignorano, questo è il loro problema.
Herman

2
@herman Non c'è contraddizione in quello che sto dicendo. Quanto al "loro problema", certo, a meno che non diventi un tuo problema. Diciamo, ad esempio, perché sono un enorme cliente della tua startup, o perché il loro manager convince la dirigenza superiore che è troppo costoso per loro cambiare, quindi devi annullare le tue modifiche, o perché il cambiamento causa un multimilionario bug e viene ripristinato, ecc. Ma se stai scrivendo codice per hobby e non ti importa degli utenti, vai avanti.
Daniel C. Sobral

7

Ci sono situazioni in cui dovresti preferire una classe senza casi?

Martin Odersky ci dà un buon punto di partenza nel suo corso Principi di programmazione funzionale in Scala (Lezione 4.6 - Pattern Matching) che potremmo usare quando dobbiamo scegliere tra classe e classe caso. Il capitolo 7 di Scala By Example contiene lo stesso esempio.

Diciamo, vogliamo scrivere un interprete per espressioni aritmetiche. Per mantenere le cose semplici inizialmente, ci limitiamo solo a numeri e operazioni +. Tali espressioni possono essere rappresentate come una gerarchia di classi, con una classe base astratta Expr come radice e due sottoclassi Number e Sum. Quindi, un'espressione 1 + (3 + 7) sarebbe rappresentata come

nuova somma (nuovo numero (1), nuova somma (nuovo numero (3), nuovo numero (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
}

Inoltre, l'aggiunta di una nuova classe Prod non comporta alcuna modifica al codice esistente:

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

Al contrario, l'aggiunta di un nuovo metodo richiede la modifica di tutte le classi esistenti.

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

Lo stesso problema risolto con le classi di casi.

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'aggiunta di un nuovo metodo è una modifica locale.

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'aggiunta di una nuova classe Prod richiede potenzialmente la modifica di tutti i criteri di corrispondenza.

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

Trascrizione della videolettura 4.6 Pattern Matching

Entrambi questi modelli vanno benissimo e la scelta tra di loro a volte è una questione di stile, ma comunque ci sono alcuni criteri che sono importanti.

Un criterio potrebbe essere: crei più spesso nuove sottoclassi di espressione o crei più spesso nuovi metodi? Quindi è un criterio che guarda all'estensibilità futura e al possibile passaggio di estensione del tuo sistema.

Se quello che fai è principalmente creare nuove sottoclassi, allora in realtà la soluzione di decomposizione orientata agli oggetti ha il sopravvento. Il motivo è che è molto semplice e una modifica molto locale creare una nuova sottoclasse con un metodo eval , dove come nella soluzione funzionale, dovresti tornare indietro e cambiare il codice all'interno del metodo eval e aggiungere un nuovo caso ad esso.

D'altra parte, se quello che farai creerà molti nuovi metodi, ma la gerarchia delle classi stessa sarà mantenuta relativamente stabile, allora il pattern matching è effettivamente vantaggioso. Perché, ancora una volta, ogni nuovo metodo nella soluzione di pattern matching è solo un cambiamento locale , sia che lo mettiate nella classe base, o forse anche al di fuori della gerarchia delle classi. Considerando che un nuovo metodo come show nella scomposizione orientata agli oggetti richiederebbe un nuovo incremento per ogni sottoclasse. Quindi ci sarebbero più parti, che devi toccare.

Quindi la problematica di questa estensibilità in due dimensioni, in cui potresti voler aggiungere nuove classi a una gerarchia, o potresti voler aggiungere nuovi metodi, o forse entrambi, è stata chiamata problema dell'espressione .

Ricorda: dobbiamo usarlo come punto di partenza e non come unico criterio.

inserisci qui la descrizione dell'immagine


0

Sto citando questo dal Scala cookbookdal Alvin Alexandercapitolo 6: objects.

Questa è una delle tante cose che ho trovato interessanti in questo libro.

Per fornire più costruttori per una classe case, è importante sapere cosa fa effettivamente la dichiarazione della classe case.

case class Person (var name: String)

Se guardi il codice che il compilatore Scala genera per l'esempio della classe case, vedrai che crea due file di output, Person $ .class e Person.class. Se disassembli Person $ .class con il comando javap, vedrai che contiene un metodo apply, insieme a molti altri:

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

Puoi anche disassemblare Person.class per vedere cosa contiene. Per una classe semplice come questa, contiene altri 20 metodi; questa massa nascosta è una delle ragioni per cui ad alcuni sviluppatori non piacciono le classi case.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.