Quais são as desvantagens de declarar classes de caso Scala?

105

Se você está escrevendo um código que usa muitas estruturas de dados lindas e imutáveis, as classes de caso parecem ser uma dádiva de Deus, oferecendo a você todos os itens a seguir gratuitamente com apenas uma palavra-chave:

  • Tudo imutável por padrão
  • Getters definidos automaticamente
  • Implementação decente de toString ()
  • Compatível com equals () e hashCode ()
  • Objeto complementar com método unapply () para correspondência

Mas quais são as desvantagens de definir uma estrutura de dados imutável como uma classe de caso?

Que restrições impõe à classe ou aos seus clientes?

Existem situações em que você deve preferir uma aula que não seja o caso?

Graham Lea
fonte
Veja esta questão relacionada: stackoverflow.com/q/4635765/156410
David
18
Por que isso não é construtivo? Os mods neste site são muito rígidos. Isso tem um número finito de respostas factuais possíveis.
Eloff de
5
Concordo com Eloff. Esta é uma pergunta para a qual eu também queria uma resposta, e as respostas fornecidas são muito úteis e não parecem subjetivas. Já vi muitas perguntas sobre 'como corrigir meu trecho de código', gerando mais debate e opinião.
Herc

Respostas:

51

Uma grande desvantagem: uma classe de caso não pode estender uma classe de caso. Essa é a restrição.

Outras vantagens que você perdeu, listadas para integridade: serialização / desserialização compatível, sem necessidade de usar a palavra-chave "nova" para criar.

Eu prefiro classes não case para objetos com estado mutável, estado privado ou nenhum estado (por exemplo, a maioria dos componentes singleton). Classes de caso para praticamente todo o resto.

Dave Griffith
fonte
48
Você pode criar uma subclasse de uma classe de caso. A subclasse não pode ser uma classe de caso também - essa é a restrição.
Seth Tisue
99

Primeiro as partes boas:

Tudo imutável por padrão

Sim, e pode até ser substituído (usando var) se você precisar

Getters definidos automaticamente

Possível em qualquer classe prefixando params com val

toString()Implementação decente

Sim, muito útil, mas pode ser feito manualmente em qualquer aula, se necessário

Compatível equals()ehashCode()

Combinado com fácil correspondência de padrões, este é o principal motivo pelo qual as pessoas usam classes de caso

Objeto companheiro com unapply()método para correspondência

Também é possível fazer manualmente em qualquer classe usando extratores

Esta lista também deve incluir o método de cópia super-poderoso, uma das melhores coisas que virão para Scala 2.8


Então o ruim, há apenas um punhado de restrições reais com classes de caso:

Você não pode definir applyno objeto complementar usando a mesma assinatura do método gerado pelo compilador

Na prática, porém, isso raramente é um problema. Mudar o comportamento do método apply gerado é garantido para surpreender os usuários e deve ser fortemente desencorajado, a única justificativa para fazer isso é validar os parâmetros de entrada - uma tarefa melhor realizada no corpo do construtor principal (que também torna a validação disponível ao usar copy)

Você não pode subclass

Verdade, embora ainda seja possível que uma classe de caso seja uma descendente. Um padrão comum é construir uma hierarquia de classes de características, usando classes de caso como os nós folha da árvore.

Também vale a pena observar o sealedmodificador. Qualquer subclasse de uma característica com este modificador deve ser declarada no mesmo arquivo. Ao fazer a correspondência de padrões com instâncias do traço, o compilador pode avisá-lo se você não tiver verificado todas as subclasses concretas possíveis. Quando combinado com classes de caso, isso pode oferecer um nível muito alto de confiança em seu código se ele compilar sem aviso.

Como uma subclasse de Produto, as classes de caso não podem ter mais de 22 parâmetros

Nenhuma solução alternativa real, exceto para parar de abusar de classes com tantos parâmetros :)

Além disso...

Uma outra restrição às vezes observada é que Scala não suporta (atualmente) parâmetros preguiçosos (como lazy vals, mas como parâmetros). A solução alternativa para isso é usar um parâmetro por nome e atribuí-lo a um val preguiçoso no construtor. Infelizmente, parâmetros por nome não se misturam com correspondência de padrões, o que evita que a técnica seja usada com classes de caso, pois quebra o extrator gerado pelo compilador.

Isso é relevante se você deseja implementar estruturas de dados lazy altamente funcionais e, com sorte, será resolvido com a adição de parâmetros lazy em uma versão futura do Scala.

Kevin Wright
fonte
1
Obrigado pela resposta abrangente. Acho que é improvável que todas as exceções "Você não pode criar uma subclasse" me eliminem tão cedo.
Graham Lea
15
Você pode criar uma subclasse de uma classe de caso. A subclasse não pode ser uma classe de caso também - essa é a restrição.
Seth Tisue
5
O limite de 22 parâmetros para classes de caso foi removido no Scala 2.11. Issues.scala-lang.org/browse/SI-7296
Jonathan Crosmer
É incorreto afirmar "Você não pode definir a aplicação no objeto complementar usando a mesma assinatura do método gerado pelo compilador". Embora seja necessário pular alguns obstáculos para fazer isso (se você pretende manter a funcionalidade que costumava ser gerada de forma invisível pelo compilador scala), certamente pode ser alcançado: stackoverflow.com/a/25538287/501113
chaotic3quilibrium
Tenho usado classes de caso Scala extensivamente e criei
chaotic3quilibrium
10

Acho que o princípio TDD se aplica aqui: não superdimensione. Quando você declara algo como um case class, está declarando muitas funcionalidades. Isso diminuirá a flexibilidade que você tem para mudar de classe no futuro.

Por exemplo, a case classtem um equalsmétodo sobre os parâmetros do construtor. Você pode não se importar com isso ao escrever sua classe pela primeira vez, mas, posteriormente, pode decidir que deseja que a igualdade ignore alguns desses parâmetros ou faça algo um pouco diferente. No entanto, o código do cliente pode ser escrito enquanto isso depende da case classigualdade.

Daniel C. Sobral
fonte
4
Não acho que o código do cliente deva depender do significado exato de 'igual'; cabe a uma classe decidir o que 'igual' significa para ela. O autor da classe deve ser livre para alterar a implementação de 'igual' ao longo da linha.
data de
8
@pkaeding Você está livre para não permitir que o código do cliente dependa de nenhum método privado. Tudo o que é público é um contrato com o qual você concordou.
Daniel C. Sobral
3
@ DanielC.Sobral True, mas a implementação exata de equals () (em quais campos é baseado) não está necessariamente no contrato. Pelo menos, você poderia excluí-lo explicitamente do contrato ao escrever a aula pela primeira vez.
herman
2
@ DanielC.Sobral Você está se contradizendo: você diz que as pessoas vão até mesmo contar com a implementação padrão igual (que compara a identidade do objeto). Se isso for verdade, e você escrever uma implementação de igual diferente mais tarde, o código também será interrompido. De qualquer forma, se você especificar pré / pós condições e invariantes, e as pessoas as ignorarem, esse é o problema.
herman
2
@herman Não há contradição no que estou dizendo. Quanto ao "problema deles", claro, a menos que se torne o seu problema. Digamos, por exemplo, porque eles são um grande cliente de sua startup, ou porque seu gerente convence a alta gerência de que é muito caro para eles mudarem, então você tem que desfazer suas mudanças, ou porque a mudança causa um prejuízo de vários milhões de dólares bug e é revertido, etc. Mas se você está escrevendo código para hobby e não se preocupa com os usuários, vá em frente.
Daniel C. Sobral
7

Existem situações em que você deve preferir uma aula que não seja o caso?

Martin Odersky nos dá um bom ponto de partida em seu curso Princípios de Programação Funcional em Scala (Aula 4.6 - Correspondência de Padrões) que podemos usar quando devemos escolher entre classe e classe de caso. O capítulo 7 de Scala por exemplo contém o mesmo exemplo.

Digamos que queremos escrever um intérprete para expressões aritméticas. Para manter as coisas simples inicialmente, nos restringimos a apenas números e operações +. Essas expressões podem ser representadas como uma hierarquia de classes, com uma classe base abstrata Expr como raiz e duas subclasses Número e Soma. Então, uma expressão 1 + (3 + 7) seria representada como

nova soma (novo número (1), nova soma (novo número (3), novo 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
}

Além disso, adicionar uma nova classe Prod não acarreta nenhuma alteração no código existente:

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

Em contraste, adicionar um novo método requer a modificação de todas as classes 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(")")
  }
}

O mesmo problema foi resolvido com classes de caso.

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

Adicionar um novo método é uma mudança 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(")")
    }
  }
}

Adicionar uma nova classe Prod requer, potencialmente, alterar toda a correspondência de padrões.

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

Transcrição da videoleta 4.6 Correspondência de padrões

Ambos os designs são perfeitamente bons e escolher entre eles às vezes é uma questão de estilo, mas, no entanto, existem alguns critérios que são importantes.

Um critério poderia ser : você está criando com mais frequência novas subclasses de expressão ou está criando novos métodos com mais frequência? Portanto, é um critério que olha para a extensibilidade futura e a possível passagem de extensão de seu sistema.

Se o que você faz é principalmente criar novas subclasses, então, na verdade, a solução de decomposição orientada a objetos tem a vantagem. A razão é que é muito fácil e uma mudança muito local para apenas criar uma nova subclasse com um método eval , onde como na solução funcional, você teria que voltar e alterar o código dentro do método eval e adicionar um novo caso para isso.

Por outro lado, se o que você fizer for criar muitos métodos novos, mas a própria hierarquia de classes for mantida relativamente estável, então a correspondência de padrões é realmente vantajosa. Porque, novamente, cada novo método na solução de correspondência de padrões é apenas uma mudança local , independentemente de você colocá-lo na classe base ou talvez até mesmo fora da hierarquia de classes. Considerando que um novo método, como show na decomposição orientada a objeto, exigiria uma nova incrementação em cada subclasse. Portanto, haveria mais partes, que você teria que tocar.

Portanto, a problemática dessa extensibilidade em duas dimensões, onde você pode querer adicionar novas classes a uma hierarquia, ou pode querer adicionar novos métodos, ou talvez ambos, foi chamada de problema de expressão .

Lembre-se: devemos usar isso como um ponto de partida e não como o único critério.

insira a descrição da imagem aqui

gabrielgiussi
fonte
0

Estou citando isso Scala cookbookpor Alvin Alexandercapítulo 6: objects.

Essa é uma das muitas coisas que achei interessantes neste livro.

Para fornecer vários construtores para uma classe de caso, é importante saber o que a declaração da classe de caso realmente faz.

case class Person (var name: String)

Se você olhar o código que o compilador Scala gera para o exemplo de classe de caso, verá que ele cria dois arquivos de saída, Person $ .class e Person.class. Se você desmontar Person $ .class com o comando javap, verá que ele contém um método apply, junto com muitos outros:

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

Você também pode desmontar Person.class para ver o que ele contém. Para uma classe simples como essa, contém 20 métodos adicionais; esse inchaço oculto é um dos motivos pelos quais alguns desenvolvedores não gostam de classes de casos.

Arglee
fonte