Jakie są wady deklarowania klas przypadków Scala?

105

Jeśli piszesz kod, który używa wielu pięknych, niezmiennych struktur danych, klasy przypadków wydają się być darem niebios, oferując za darmo wszystkie poniższe elementy za pomocą tylko jednego słowa kluczowego:

  • Domyślnie wszystko niezmienne
  • Gettery definiowane automatycznie
  • Przyzwoita implementacja metody toString ()
  • Zgodne równa się () i hashCode ()
  • Obiekt towarzyszący z metodą unapply () do dopasowania

Ale jakie są wady definiowania niezmiennej struktury danych jako klasy przypadku?

Jakie ograniczenia nakłada na klasę lub jej klientów?

Czy są sytuacje, w których wolisz zajęcia bez przypadków?

Graham Lea
źródło
Zobacz to powiązane pytanie: stackoverflow.com/q/4635765/156410
David
18
Dlaczego to nie jest konstruktywne? Mody na tej stronie są zbyt surowe. To ma skończoną liczbę możliwych odpowiedzi opartych na faktach.
Eloff
5
Zgadzam się z Eloffem. To jest pytanie, na które również chciałem odpowiedzieć, a udzielone odpowiedzi są bardzo przydatne i nie wydają się subiektywne. Widziałem wiele pytań typu „jak naprawić fragment kodu”, wywołujących więcej dyskusji i opinii.
Herc

Odpowiedzi:

51

Jedna duża wada: klasy przypadków nie mogą rozszerzać klasy przypadku. To jest ograniczenie.

Inne pominięte korzyści, wymienione dla kompletności: zgodna serializacja / deserializacja, brak potrzeby używania słowa kluczowego „new” do tworzenia.

Preferuję klasy bez wielkości liter dla obiektów ze zmiennym stanem, stanem prywatnym lub bez stanu (np. Większość pojedynczych komponentów). Klasy przypadków dla prawie wszystkiego innego.

Dave Griffith
źródło
48
Możesz podklasować klasę sprawy. Podklasa nie może być również klasą przypadku - to jest ograniczenie.
Seth Tisue
99

Najpierw dobre bity:

Domyślnie wszystko niezmienne

Tak, a nawet można go zastąpić (używając var), jeśli tego potrzebujesz

Gettery definiowane automatycznie

Możliwe w dowolnej klasie, poprzedzając parametry przedrostkiem val

Godna toString()realizacja

Tak, bardzo przydatne, ale w razie potrzeby możliwe do wykonania ręcznie na dowolnej klasie

Zgodny equals()ihashCode()

W połączeniu z łatwym dopasowywaniem wzorców jest to główny powód, dla którego ludzie używają klas przypadków

Obiekt towarzyszący z unapply()metodą dopasowania

Możliwe również ręczne na dowolnej klasie przy użyciu ekstraktorów

Ta lista powinna również zawierać niezwykle potężną metodę kopiowania, jedną z najlepszych rzeczy, jakie można znaleźć w Scali 2.8


Z drugiej strony jest tylko kilka rzeczywistych ograniczeń z klasami przypadków:

Nie można zdefiniować applyw obiekcie towarzyszącym przy użyciu tego samego podpisu, co metoda wygenerowana przez kompilator

Jednak w praktyce rzadko stanowi to problem. Zmiana zachowania wygenerowanej metody Apply gwarantuje, że zaskoczy użytkowników i powinna być zdecydowanie odradzana, jedynym uzasadnieniem takiego działania jest walidacja parametrów wejściowych - zadanie najlepiej wykonać w głównej treści konstruktora (która również udostępnia walidację podczas używania copy)

Nie możesz podklasy

To prawda, chociaż nadal istnieje możliwość, aby klasa przypadku sama była potomkiem. Jednym z powszechnych wzorców jest zbudowanie hierarchii klas cech, używając klas przypadków jako węzłów liści drzewa.

Warto również zwrócić uwagę na sealedmodyfikator. Każda podklasa cechy z tym modyfikatorem musi być zadeklarowana w tym samym pliku. Podczas dopasowywania wzorców do wystąpień cechy kompilator może następnie ostrzec Cię, jeśli nie sprawdziłeś wszystkich możliwych konkretnych podklas. W połączeniu z klasami przypadków może to zapewnić bardzo wysoki poziom zaufania do kodu, jeśli kompiluje się bez ostrzeżenia.

Jako podklasa Product, klasy przypadków nie mogą mieć więcej niż 22 parametry

Nie ma prawdziwego obejścia, z wyjątkiem zaprzestania nadużywania klas z tak wieloma parametrami :)

Również...

Innym zauważanym czasem ograniczeniem jest to, że Scala (obecnie) nie obsługuje leniwych parametrów (takich jak lazy vals, ale jako parametry). Obejściem tego problemu jest użycie parametru by-name i przypisanie go do leniwej wartości w konstruktorze. Niestety, parametry według nazwy nie mieszają się z dopasowywaniem wzorców, co zapobiega używaniu tej techniki z klasami przypadków, ponieważ przerywa działanie ekstraktora generowanego przez kompilator.

Jest to istotne, jeśli chcesz zaimplementować wysoce funkcjonalne leniwe struktury danych i miejmy nadzieję, zostanie rozwiązane przez dodanie leniwych parametrów do przyszłej wersji Scali.

Kevin Wright
źródło
1
Dzięki za wyczerpującą odpowiedź. Myślę, że wszystko, z wyjątkiem „Nie możesz podklasy”, prawdopodobnie w najbliższym czasie nie wpłynie na mnie w fazie.
Graham Lea
15
Możesz podklasować klasę sprawy. Podklasa nie może być również klasą przypadku - to jest ograniczenie.
Seth Tisue
5
Limit 22 parametrów dla klas przypadków został usunięty w Scali 2.11. Issues.scala-lang.org/browse/SI-7296
Jonathan Crosmer
Nieprawidłowe jest stwierdzenie „Nie można zdefiniować zastosowania w obiekcie towarzyszącym przy użyciu tego samego podpisu, co metoda wygenerowana przez kompilator”. Chociaż wymaga to przeskoczenia przez niektóre obręcze (jeśli zamierzasz zachować funkcjonalność, która była niewidocznie generowana przez kompilator scala), z pewnością można to osiągnąć: stackoverflow.com/a/25538287/501113
chaotic3quilibrium
Często korzystam z klas przypadków Scala i wymyśliłem „wzorzec klasy przypadku” (który ostatecznie wyłoni
chaotic3quilibrium
10

Myślę, że ma tu zastosowanie zasada TDD: nie projektuj nadmiernie. Kiedy deklarujesz coś jako a case class, deklarujesz wiele funkcji. Zmniejszy to elastyczność, jaką masz przy zmianie klasy w przyszłości.

Na przykład a case classma equalsmetodę nad parametrami konstruktora. Możesz nie przejmować się tym, kiedy po raz pierwszy piszesz swoją klasę, ale później możesz zdecydować, że chcesz, aby równość ignorowała niektóre z tych parametrów lub zrobiła coś nieco innego. Jednak kod klienta może zostać napisany w średnim czasie, który zależy od case classrówności.

Daniel C. Sobral
źródło
4
Nie sądzę, aby kod klienta zależał od dokładnego znaczenia słowa „równa się”; do klasy należy decyzja, co dla niej znaczy „równy”. Autor klasy powinien mieć swobodę zmiany implementacji wyrażenia „równa się” w dół wiersza.
termin
8
@pkaeding Możesz nie uzależniać kodu klienta od żadnej prywatnej metody. Wszystko, co jest publiczne, jest umową, na którą się zgodziłeś.
Daniel C. Sobral
3
@ DanielC.Sobral Prawda, ale dokładna implementacja equals () (na jakich polach jest oparta) niekoniecznie musi znajdować się w umowie. Przynajmniej możesz wyraźnie wykluczyć go z umowy, kiedy po raz pierwszy piszesz klasę.
herman
2
@ DanielC.Sobral Zaprzeczasz sobie: mówisz, że ludzie będą nawet polegać na domyślnej implementacji równa się (która porównuje tożsamość obiektu). Jeśli to prawda, a później napiszesz inną implementację equals, ich kod również się zepsuje. W każdym razie, jeśli określisz warunki i niezmienniki pre / post, a ludzie je zignorują, to ich problem.
herman
2
@herman Nie ma sprzeczności w tym, co mówię. Jeśli chodzi o „ich problem”, oczywiście, chyba że stanie się to twoim problemem. Powiedzmy, na przykład, że są ogromnym klientem Twojego startupu lub ponieważ ich menedżer przekonuje wyższą kadrę zarządzającą, że zmiana jest dla nich zbyt kosztowna, więc musisz cofnąć zmiany, lub ponieważ zmiana powoduje wiele milionów dolarów błąd i zostaje cofnięty, itp. Ale jeśli piszesz kod dla hobby i nie dbasz o użytkowników, śmiało.
Daniel C. Sobral
7

Czy są sytuacje, w których wolisz zajęcia bez przypadków?

Martin Odersky daje nam dobry punkt wyjścia w swoim kursie Zasady programowania funkcjonalnego w Scali (Wykład 4.6 - Dopasowywanie wzorców), z którego moglibyśmy skorzystać, gdy musimy wybrać między klasą a klasą przypadku. Rozdział 7 książki Scala By Example zawiera ten sam przykład.

Powiedzmy, że chcemy napisać interpretera dla wyrażeń arytmetycznych. Aby początkowo wszystko było proste, ograniczamy się tylko do liczb i + operacji. Takie wyrażenia mogą być reprezentowane jako hierarchia klas, z abstrakcyjną klasą bazową Wyr jako rdzeniem i dwiema podklasami Liczba i Suma. Następnie wyrażenie 1 + (3 + 7) byłoby reprezentowane jako

nowa suma (nowa liczba (1), nowa suma (nowa liczba (3), nowa liczba (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
}

Ponadto dodanie nowej klasy Prod nie pociąga za sobą żadnych zmian w istniejącym kodzie:

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

Natomiast dodanie nowej metody wymaga modyfikacji wszystkich istniejących klas.

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

Ten sam problem rozwiązany z klasami przypadków.

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

Dodanie nowej metody jest zmianą lokalną.

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

Dodanie nowej klasy Prod wymaga potencjalnie zmiany całego dopasowania wzorców.

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

Transkrypcja z wykładu wideo 4.6 Dopasowywanie wzorców

Oba te projekty są w porządku, a wybór między nimi jest czasami kwestią stylu, ale mimo to są pewne kryteria, które są ważne.

Jednym z kryteriów może być: czy częściej tworzysz nowe podklasy ekspresji, czy częściej tworzysz nowe metody? Jest to więc kryterium, które dotyczy przyszłej rozszerzalności i możliwego przebiegu rozszerzenia twojego systemu.

Jeśli robisz głównie tworzenie nowych podklas, to w rzeczywistości rozwiązanie do dekompozycji obiektowej ma przewagę. Powodem jest to, że bardzo łatwo i bardzo lokalną zmianą jest po prostu utworzenie nowej podklasy z metodą eval , gdzie podobnie jak w rozwiązaniu funkcjonalnym należałoby cofnąć się i zmienić kod wewnątrz metody eval i dodać nowy przypadek do tego.

Z drugiej strony, jeśli tym, co zrobisz, będzie utworzenie wielu nowych metod, ale sama hierarchia klas będzie względnie stabilna, wtedy dopasowywanie wzorców jest w rzeczywistości korzystne. Ponownie, każda nowa metoda w rozwiązaniu dopasowywania wzorców jest tylko lokalną zmianą , niezależnie od tego, czy umieścisz ją w klasie bazowej, czy może nawet poza hierarchią klas. Podczas gdy nowa metoda, taka jak show w dekompozycji obiektowej, wymagałaby nowej inkrementacji każdej podklasy. Więc byłoby więcej części, których musisz dotknąć.

Tak więc problematyka tej rozszerzalności w dwóch wymiarach, w przypadku której możesz chcieć dodać nowe klasy do hierarchii lub możesz chcieć dodać nowe metody, a może oba, została nazwana problemem z wyrażeniami .

Pamiętaj: musimy traktować to jako punkt wyjścia, a nie jako jedyne kryteria.

wprowadź opis obrazu tutaj

gabrielgiussi
źródło
0

Cytuję z tego Scala cookbookprzez Alvin AlexanderRozdział 6:objects .

To jedna z wielu rzeczy, które wydały mi się interesujące w tej książce.

Aby zapewnić wiele konstruktorów dla klasy case, ważne jest, aby wiedzieć, co faktycznie robi deklaracja klasy case.

case class Person (var name: String)

Jeśli spojrzysz na kod, który kompilator Scala generuje dla przykładu klasy sprawy, zobaczysz, że tworzy on dwa pliki wyjściowe, Person $ .class i Person.class. Jeśli zdemontujesz Person $ .class za pomocą polecenia javap, zobaczysz, że zawiera ona metodę stosującą, a także wiele innych:

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

Możesz również zdemontować Person.class, aby zobaczyć, co zawiera. W przypadku takiej prostej klasy zawiera 20 dodatkowych metod; ten ukryty nadmiar jest jednym z powodów, dla których niektórzy programiści nie lubią klas przypadków.

arglee
źródło