Каковы недостатки объявления классов случаев Scala?

105

Если вы пишете код, в котором используется множество красивых, неизменяемых структур данных, классы case кажутся находкой, предоставляя вам все следующее бесплатно с помощью всего одного ключевого слова:

  • По умолчанию все неизменяемо
  • Геттеры определяются автоматически
  • Достойная реализация toString ()
  • Соответствует equals () и hashCode ()
  • Сопутствующий объект с методом unapply () для сопоставления

Но каковы недостатки определения неизменяемой структуры данных как класса case?

Какие ограничения он накладывает на класс или его клиентов?

Есть ли ситуации, когда вы должны предпочесть класс без регистра?

Грэм Ли
источник
См. Этот связанный вопрос: stackoverflow.com/q/4635765/156410
Дэвид
18
Почему это неконструктивно? Моды на этом сайте слишком строгие. У этого есть конечное число возможных фактических ответов.
Eloff
5
Согласен с Eloff. На этот вопрос я тоже хотел получить ответ, и предоставленные ответы очень полезны и не кажутся субъективными. Я видел много вопросов «как исправить фрагмент кода», вызывавших больше споров и мнений.
Herc

Ответы:

51

Один большой недостаток: классы case не могут расширять класс case. Это ограничение.

Другие преимущества, которые вы упустили, перечислены для полноты: совместимость с сериализацией / десериализацией, отсутствие необходимости использовать ключевое слово «новое» для создания.

Я предпочитаю классы без регистра для объектов с изменяемым состоянием, частным состоянием или без состояния (например, большинство одноэлементных компонентов). Кейс-классы почти для всего остального.

Дэйв Гриффит
источник
48
Вы можете создать подкласс класса case. Подкласс тоже не может быть классом case - это ограничение.
Сет Тисуэ,
99

Сначала хорошие моменты:

По умолчанию все неизменяемо

Да, и даже может быть переопределен (с помощью var), если вам это нужно

Геттеры определяются автоматически

Возможно в любом классе с префиксом params с val

Достойная toString()реализация

Да, очень полезно, но при необходимости можно выполнить вручную на любом классе

Соответствует equals()иhashCode()

В сочетании с простым сопоставлением с образцом это основная причина, по которой люди используют классы case

Сопутствующий объект с unapply()методом сопоставления

Также можно сделать вручную на любом классе, используя экстракторы

Этот список также должен включать сверхмощный метод копирования, одно из лучших решений Scala 2.8.


Тогда плохо, есть только несколько реальных ограничений с классами case:

Вы не можете определить applyв сопутствующем объекте ту же сигнатуру, что и метод, созданный компилятором.

Однако на практике это редко бывает проблемой. Изменение поведения сгенерированного метода apply гарантированно удивит пользователей, и его следует настоятельно не рекомендовать, единственное оправдание для этого - проверка входных параметров - задачу лучше всего выполнять в теле основного конструктора (что также делает проверку доступной при использовании copy)

Вы не можете создать подкласс

Верно, хотя класс case все еще может быть потомком. Один из распространенных шаблонов - построение иерархии классов признаков с использованием классов случаев в качестве конечных узлов дерева.

Также стоит отметить sealedмодификатор. Любой подкласс признака с этим модификатором должен быть объявлен в том же файле. При сопоставлении с образцом экземпляров признака компилятор может предупредить вас, если вы не проверили все возможные конкретные подклассы. В сочетании с классами case это может предложить вам очень высокий уровень уверенности в вашем коде, если он компилируется без предупреждения.

Как подкласс Product, классы case не могут иметь более 22 параметров.

Нет реального обходного пути, кроме как прекратить злоупотреблять классами с таким количеством параметров :)

Также...

Еще одно ограничение, которое иногда отмечается, заключается в том, что Scala (в настоящее время) не поддерживает ленивые параметры (например, lazy vals, но как параметры). Обходной путь - использовать параметр по имени и назначить его ленивому значению в конструкторе. К сожалению, параметры по имени не смешиваются с сопоставлением с образцом, что не позволяет использовать этот метод с классами case, поскольку он нарушает работу экстрактора, созданного компилятором.

Это актуально, если вы хотите реализовать высокофункциональные ленивые структуры данных, и, надеюсь, будет решено с добавлением ленивых параметров в будущую версию Scala.

Кевин Райт
источник
1
Спасибо за исчерпывающий ответ. Я думаю, что все, за исключением «Вы не можете создать подкласс», вряд ли в ближайшее время меня помешает.
Грэм Ли
15
Вы можете создать подкласс класса case. Подкласс тоже не может быть классом case - это ограничение.
Сет Тисью,
5
Ограничение на 22 параметра для классов case удалено в Scala 2.11. issues.scala-lang.org/browse/SI-7296
Джонатан Кросмер,
Утверждать, что «Вы не можете определить apply в сопутствующем объекте, используя ту же сигнатуру, что и метод, созданный компилятором», неверно. Хотя для этого требуется преодолеть некоторые препятствия (если вы намерены сохранить функциональность, которая раньше незаметно генерировалась компилятором scala), это наверняка может быть достигнуто: stackoverflow.com/a/25538287/501113
chaotic3quilibrium
Я широко использую классы кейсов Scala и придумал «шаблон кейсов» (который в конечном итоге станет макросом Scala), который помогает с рядом проблем, указанных выше: codereview.stackexchange.com/a/98367 / 4758
chaotic3quilibrium
10

Я думаю, что здесь применим принцип TDD: не переусердствуйте. Когда вы объявляете что-то как a case class, вы декларируете большую функциональность. Это снизит гибкость при смене класса в будущем.

Например, a case classимеет equalsметод над параметрами конструктора. Вы можете не заботиться об этом, когда впервые пишете свой класс, но в последнем случае вы можете решить, что вы хотите, чтобы равенство игнорировало некоторые из этих параметров, или сделало что-то немного другое. Однако клиентский код может быть написан в среднем за время, которое зависит от case classравенства.

Дэниел С. Собрал
источник
4
Я не думаю, что клиентский код должен зависеть от точного значения слова «равно»; класс решать, что для него значит «равный». Автор класса должен иметь право изменять реализацию «равно» в дальнейшем.
pkaeding 01
8
@pkaeding Вы можете не иметь клиентского кода, зависящего от какого-либо частного метода. Все, что является публичным, - это договор, на который вы согласились.
Дэниел С. Собрал,
3
@ DanielC.Sobral Верно, но точная реализация equals () (на каких полях она основана) не обязательно указывается в контракте. По крайней мере, вы могли бы явно исключить его из контракта при первом написании класса.
Herman
2
@ DanielC.Sobral Вы сами себе противоречите: вы говорите, что люди даже будут полагаться на реализацию по умолчанию equals (которая сравнивает идентичность объектов). Если это правда, и позже вы напишете другую реализацию equals, их код также сломается. В любом случае, если вы укажете предварительные / пост-условия и инварианты, а люди их игнорируют, это их проблема.
Herman
2
@herman В том, что я говорю, нет противоречия. Что касается «их проблемы», конечно, если только это не станет вашей проблемой. Скажем, например, потому что они являются огромным клиентом вашего стартапа, или потому что их менеджер убеждает высшее руководство в том, что для них слишком дорого менять, поэтому вам нужно отменить свои изменения, или потому что изменение вызывает многомиллионные ошибка и откат и т.д. Но если вы пишете код для хобби и не заботитесь о пользователях, продолжайте.
Дэниел С. Собрал
7

Есть ли ситуации, когда вы должны предпочесть класс без регистра?

Мартин Одерский дает нам хорошую отправную точку в своем курсе « Принципы функционального программирования в Scala» (лекция 4.6 - Сопоставление с образцом), который мы могли бы использовать, когда нам нужно выбирать между классом и классом case. Глава 7 Scala By Example содержит тот же пример.

Скажем, мы хотим написать интерпретатор для арифметических выражений. Для простоты изначально мы ограничиваемся числами и операциями +. Такие выражения могут быть представлены в виде иерархии классов с абстрактным базовым классом Expr в качестве корня и двумя подклассами Number и Sum. Тогда выражение 1 + (3 + 7) будет представлено как

новая сумма (новое число (1), новая сумма (новое число (3), новое число (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
}

Кроме того, добавление нового класса Prod не влечет за собой изменений существующего кода:

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

Напротив, добавление нового метода требует модификации всех существующих классов.

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

Та же проблема решена с case-классами.

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

Добавление нового метода - это локальное изменение.

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

Добавление нового класса Prod потенциально требует изменения всего сопоставления с образцом.

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

Стенограмма видеолекции 4.6 Сопоставление с образцом

Оба эти дизайна прекрасны, и выбор между ними иногда является вопросом стиля, но, тем не менее, есть некоторые важные критерии.

Одним из критериев может быть : вы чаще создаете новые подклассы выражения или чаще создаете новые методы? Таким образом, это критерий, который смотрит на будущую расширяемость и возможный проход расширения вашей системы.

Если вы в основном создаете новые подклассы, то на самом деле решение объектно-ориентированной декомпозиции имеет преимущество. Причина в том, что очень легко и очень локально изменить просто создать новый подкласс с помощью метода eval , где, как и в функциональном решении, вам придется вернуться и изменить код внутри метода eval и добавить новый случай. к нему.

С другой стороны, если то , что вы делаете, будет создавать множество новых методов, но сама иерархия классов будет оставаться относительно стабильной, тогда сопоставление с образцом действительно выгодно. Потому что, опять же, каждый новый метод в решении сопоставления с образцом - это просто локальное изменение , независимо от того, помещаете ли вы его в базовый класс или, возможно, даже за пределы иерархии классов. В то время как новый метод, такой как show в объектно-ориентированной декомпозиции, потребует нового приращения каждого подкласса. Так что было бы больше частей, к которым вы должны прикоснуться.

Таким образом, проблема этой расширяемости в двух измерениях, когда вы, возможно, захотите добавить новые классы в иерархию, или вы можете захотеть добавить новые методы, или, может быть, и то и другое, была названа проблемой выражения .

Помните: мы должны использовать это как отправную точку, а не как единственный критерий.

введите описание изображения здесь

Габриэльджусси
источник
0

Я цитирую это из Scala cookbookпо Alvin Alexanderглаве 6: objects.

Это одна из многих вещей, которые мне показались интересными в этой книге.

Чтобы предоставить несколько конструкторов для класса case, важно знать, что на самом деле делает объявление класса case.

case class Person (var name: String)

Если вы посмотрите на код, который компилятор Scala генерирует для примера класса case, вы увидите, что он создает два выходных файла: Person $ .class и Person.class. Если вы дизассемблируете Person $ .class с помощью команды javap, вы увидите, что он содержит метод apply, а также многие другие:

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

Вы также можете разобрать Person.class, чтобы увидеть, что он содержит. Для такого простого класса он содержит дополнительно 20 методов; это скрытое раздувание - одна из причин, по которой некоторым разработчикам не нравятся классы case.

Аргли
источник