¿Cuáles son las desventajas de declarar clases de casos de Scala?

105

Si está escribiendo código que utiliza muchas estructuras de datos hermosas e inmutables, las clases de casos parecen ser un regalo del cielo, y le brindan todo lo siguiente de forma gratuita con una sola palabra clave:

  • Todo inmutable por defecto
  • Getters definidos automáticamente
  • Implementación decente de toString ()
  • Compatible con equals () y hashCode ()
  • Objeto complementario con el método unapply () para hacer coincidir

Pero, ¿cuáles son las desventajas de definir una estructura de datos inmutable como una clase de caso?

¿Qué restricciones impone a la clase o sus clientes?

¿Hay situaciones en las que debería preferir una clase que no sea de casos?

Graham Lea
fuente
Vea esta pregunta relacionada: stackoverflow.com/q/4635765/156410
David
18
¿Por qué esto no es constructivo? Las modificaciones en este sitio son demasiado estrictas. Esto tiene un número finito de posibles respuestas fácticas.
Eloff
5
De acuerdo con Eloff. Esta es una pregunta que también quería una respuesta, y las respuestas proporcionadas son muy útiles y no parecen subjetivas. He visto muchas preguntas sobre cómo arreglar mi extracto de código que suscitan más debate y opinión.
Herc

Respuestas:

51

Una gran desventaja: las clases de casos no pueden extender una clase de casos. Esa es la restricción.

Otras ventajas que perdió, enumeradas para completar: serialización / deserialización compatible, no es necesario usar la palabra clave "nueva" para crear.

Prefiero clases sin mayúsculas para objetos con estado mutable, estado privado o sin estado (por ejemplo, la mayoría de los componentes singleton). Clases de casos para casi todo lo demás.

Dave Griffith
fuente
48
Puede subclasificar una clase de caso. La subclase no puede ser una clase de caso también, esa es la restricción.
Seth Tisue
99

Primero lo bueno:

Todo inmutable por defecto

Sí, e incluso se puede anular (usar var) si lo necesita

Getters definidos automáticamente

Posible en cualquier clase prefijando parámetros con val

toString()Implementación decente

Sí, muy útil, pero factible a mano en cualquier clase si es necesario

Cumple equals()yhashCode()

Combinado con una fácil coincidencia de patrones, esta es la razón principal por la que las personas usan clases de casos

Objeto complementario con unapply()método de coincidencia

También es posible hacerlo a mano en cualquier clase mediante el uso de extractores.

Esta lista también debería incluir el método de copia súper poderoso, una de las mejores cosas que vienen a Scala 2.8


Luego, lo malo, solo hay un puñado de restricciones reales con clases de casos:

No puede definir applyen el objeto complementario utilizando la misma firma que el método generado por el compilador

Sin embargo, en la práctica, esto rara vez es un problema. Se garantiza que el cambio de comportamiento del método de aplicación generado sorprenderá a los usuarios y debe desaconsejarse enérgicamente, la única justificación para hacerlo es validar los parámetros de entrada, una tarea que se realiza mejor en el cuerpo del constructor principal (que también hace que la validación esté disponible cuando se usa copy)

No puedes subclase

Es cierto, aunque todavía es posible que una clase de caso sea un descendiente. Un patrón común es construir una jerarquía de rasgos de clases, utilizando clases de casos como los nodos hoja del árbol.

También vale la pena señalar el sealedmodificador. Cualquier subclase de un rasgo con este modificador debe declararse en el mismo archivo. Cuando se comparan patrones con instancias del rasgo, el compilador puede advertirle si no ha verificado todas las posibles subclases concretas. Cuando se combina con clases de casos, esto puede ofrecerle un nivel muy alto de confianza en su código si se compila sin previo aviso.

Como subclase de producto, las clases de casos no pueden tener más de 22 parámetros

No hay una solución real, excepto para dejar de abusar de las clases con tantos parámetros :)

También...

Otra restricción que a veces se observa es que Scala no admite (actualmente) parámetros perezosos (como lazy vals, pero como parámetros). La solución a esto es usar un parámetro por nombre y asignarlo a un valor perezoso en el constructor. Desafortunadamente, los parámetros por nombre no se mezclan con la coincidencia de patrones, lo que evita que la técnica se use con clases de casos, ya que rompe el extractor generado por el compilador.

Esto es relevante si desea implementar estructuras de datos perezosas altamente funcionales y, con suerte, se resolverá con la adición de parámetros perezosos a una versión futura de Scala.

Kevin Wright
fuente
1
Gracias por la respuesta completa. Creo que es poco probable que todas las excepciones "No puedes subclase" me eliminen pronto.
Graham Lea
15
Puede subclasificar una clase de caso. La subclase no puede ser una clase de caso también, esa es la restricción.
Seth Tisue
5
El límite de 22 parámetros para las clases de casos se elimina en Scala 2.11. issues.scala-lang.org/browse/SI-7296
Jonathan Crosmer
Es incorrecto afirmar "No se puede definir aplicar en el objeto complementario utilizando la misma firma que el método generado por el compilador". Si bien requiere pasar por algunos obstáculos para hacerlo (si tiene la intención de mantener la funcionalidad que solía ser generada de manera invisible por el compilador de Scala), ciertamente se puede lograr: stackoverflow.com/a/25538287/501113
chaotic3quilibrium
He estado usando clases de casos de Scala ampliamente y he creado un "patrón de clases de casos" (que eventualmente terminará como una macro de Scala) que ayuda con varios de los problemas identificados anteriormente: codereview.stackexchange.com/a/98367 / 4758
chaotic3quilibrium
10

Creo que el principio TDD se aplica aquí: no sobre-diseño. Cuando declaras que algo es a case class, estás declarando mucha funcionalidad. Eso disminuirá la flexibilidad que tiene para cambiar de clase en el futuro.

Por ejemplo, a case classtiene un equalsmétodo sobre los parámetros del constructor. Es posible que no le importe eso cuando escriba su clase por primera vez, pero, más tarde, puede decidir que desea que la igualdad ignore algunos de estos parámetros o haga algo un poco diferente. Sin embargo, el código del cliente puede escribirse mientras tanto depende de la case classigualdad.

Daniel C. Sobral
fuente
4
No creo que el código del cliente deba depender del significado exacto de "igual"; Depende de una clase decidir qué significa "igual" para ella. El autor de la clase debe tener la libertad de cambiar la implementación de 'igual' en el futuro.
pkaeding
8
@pkaeding Eres libre de que el código de cliente no dependa de ningún método privado. Todo lo que es público es un contrato que has acordado.
Daniel C. Sobral
3
@ DanielC.Sobral Es cierto, pero la implementación exacta de equals () (en qué campos se basa) no está necesariamente en el contrato. Al menos, podría excluirlo explícitamente del contrato cuando escriba la clase por primera vez.
herman
2
@ DanielC.Sobral Te estás contradiciendo: dices que la gente incluso confiará en la implementación predeterminada es igual (que compara la identidad del objeto). Si eso es cierto, y luego escribe una implementación igual diferente, su código también se romperá. De todos modos, si especificas condiciones pre / post e invariantes, y la gente las ignora, ese es su problema.
herman
2
@herman No hay contradicción en lo que digo. En cuanto a "su problema", claro, a menos que se convierta en tu problema. Digamos, por ejemplo, porque son un gran cliente de su startup, o porque su gerente convence a la alta gerencia de que es demasiado costoso para ellos cambiar, por lo que tiene que deshacer sus cambios, o porque el cambio genera una ganancia de varios millones de dólares. error y se revierte, etc. Pero si está escribiendo código para pasatiempos y no le importan los usuarios, adelante.
Daniel C. Sobral
7

¿Hay situaciones en las que debería preferir una clase que no sea de casos?

Martin Odersky nos da un buen punto de partida en su curso Principios de programación funcional en Scala (Lección 4.6 - Coincidencia de patrones) que podríamos usar cuando debemos elegir entre clase y clase de caso. El capítulo 7 de Scala By Example contiene el mismo ejemplo.

Digamos, queremos escribir un intérprete para expresiones aritméticas. Para mantener las cosas simples inicialmente, nos limitamos a números y operaciones +. Estas expresiones pueden representarse como una jerarquía de clases, con una clase base abstracta Expr como raíz y dos subclases Number y Sum. Entonces, una expresión 1 + (3 + 7) se representaría como

nueva suma (nuevo número (1), nueva suma (nuevo número (3), nuevo número (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
}

Además, agregar una nueva clase Prod no implica ningún cambio en el código existente:

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

Por el contrario, agregar un nuevo método requiere la modificación de todas las clases existentes.

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

El mismo problema resuelto con clases de casos.

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

Agregar un nuevo método es un cambio 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(")")
    }
  }
}

Agregar una nueva clase Prod requiere potencialmente cambiar todas las coincidencias de patrones.

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

Transcripción de la videolección 4.6 Coincidencia de patrones

Ambos diseños están perfectamente bien y elegir entre ellos a veces es una cuestión de estilo, pero sin embargo hay algunos criterios que son importantes.

Un criterio podría ser, ¿ está creando más a menudo nuevas subclases de expresión o está creando más a menudo nuevos métodos? Por lo tanto, es un criterio que mira la extensibilidad futura y el posible pase de extensión de su sistema.

Si lo que haces es principalmente crear nuevas subclases, entonces la solución de descomposición orientada a objetos tiene la ventaja. La razón es que es muy fácil y un cambio muy local simplemente crear una nueva subclase con un método eval , donde como en la solución funcional, tendrías que regresar y cambiar el código dentro del método eval y agregar un nuevo caso. lo.

Por otro lado, si lo que va a hacer será crear muchos métodos nuevos, pero la jerarquía de clases en sí se mantendrá relativamente estable, entonces la coincidencia de patrones es realmente ventajosa. Porque, nuevamente, cada nuevo método en la solución de coincidencia de patrones es solo un cambio local , ya sea que lo coloque en la clase base o tal vez incluso fuera de la jerarquía de clases. Mientras que un nuevo método como show en la descomposición orientada a objetos requeriría un nuevo incremento en cada subclase. Entonces habría más partes, que tienes que tocar.

Entonces, la problemática de esta extensibilidad en dos dimensiones, donde es posible que desee agregar nuevas clases a una jerarquía, o puede que desee agregar nuevos métodos, o tal vez ambos, se ha denominado problema de expresión .

Recuerde: debemos usar esto como un punto de partida y no como el único criterio.

ingrese la descripción de la imagen aquí

Gabrielgiussi
fuente
0

Cito esto desde Scala cookbookpor Alvin Alexandercapítulo 6: objects.

Esta es una de las muchas cosas que encontré interesantes en este libro.

Para proporcionar varios constructores para una clase de caso, es importante saber qué hace realmente la declaración de la clase de caso.

case class Person (var name: String)

Si observa el código que genera el compilador de Scala para el ejemplo de la clase de caso, verá que crea dos archivos de salida, Person $ .class y Person.class. Si desensambla Person $ .class con el comando javap, verá que contiene un método de aplicación, junto con muchos otros:

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

También puede desensamblar Person.class para ver qué contiene. Para una clase simple como esta, contiene 20 métodos adicionales; esta hinchazón oculta es una de las razones por las que a algunos desarrolladores no les gustan las clases de casos.

arglee
fuente