ChatGPT解决这个技术问题 Extra ChatGPT

What are the disadvantages to declaring Scala case classes?

If you're writing code that's using lots of beautiful, immutable data structures, case classes appear to be a godsend, giving you all of the following for free with just one keyword:

Everything immutable by default

Getters automatically defined

Decent toString() implementation

Compliant equals() and hashCode()

Companion object with unapply() method for matching

But what are the disadvantages of defining an immutable data structure as a case class?

What restrictions does it place on the class or its clients?

Are there situations where you should prefer a non-case class?

See this related question: stackoverflow.com/q/4635765/156410
Why is this not constructive? The mods on this site are way too strict. This has a finite number of possible factual answers.
Agree with Eloff. This is a question I wanted an answer too, and the answers provided are very useful, and do not appear subjective. I have seen many 'how to I fix my code excerpt' questions eliciting more debate and opinion.

C
Community

First the good bits:

Everything immutable by default

Yes, and can even be overridden (using var) if you need it

Getters automatically defined

Possible in any class by prefixing params with val

Decent toString() implementation

Yes, very useful, but doable by hand on any class if necessary

Compliant equals() and hashCode()

Combined with easy pattern-matching, this is the main reason that people use case classes

Companion object with unapply() method for matching

Also possible to do by hand on any class by using extractors

This list should also include the uber-powerful copy method, one of the best things to come to Scala 2.8

Then the bad, there are only a handful of real restrictions with case classes:

You can't define apply in the companion object using the same signature as the compiler-generated method

In practice though, this is rarely a problem. Changing behaviour of the generated apply method is guaranteed to surprise users and should be strongly discouraged, the only justification for doing so is to validate input parameters - a task best done in the main constructor body (which also makes the validation available when using copy)

You can't subclass

True, though it's still possible for a case class to itself be a descendant. One common pattern is to build up a class hierarchy of traits, using case classes as the leaf nodes of the tree.

It's also worth noting the sealed modifier. Any subclass of a trait with this modifier must be declared in the same file. When pattern-matching against instances of the trait, the compiler can then warn you if you haven't checked for all possible concrete subclasses. When combined with case classes this can offer you a very high level level of confidence in your code if it compiles without warning.

As a subclass of Product, case classes can't have more than 22 parameters

No real workaround, except to stop abusing classes with this many params :)

Also...

One other restriction sometimes noted is that Scala doesn't (currently) support lazy params (like lazy vals, but as parameters). The workaround to this is to use a by-name param and assign it to a lazy val in the constructor. Unfortunately, by-name params don't mix with pattern matching, which prevents the technique being used with case classes as it breaks the compiler-generated extractor.

This is relevant if you want to implement highly-functional lazy data structures, and will hopefully be resolved with the addition of lazy params to a future release of Scala.


Thanks for the comprehensive answer. I think everything exception "You can't subclass" is probably unlikely to phase me anytime soon.
You can subclass a case class. The subclass can't be a case class too — that's the restriction.
The 22-parameter limit for case classes is removed in Scala 2.11. issues.scala-lang.org/browse/SI-7296
It is incorrect to assert "You can't define apply in the companion object using the same signature as the compiler-generated method". While it requires jumping through some hoops to do so (if you intend to keep the functionality which used to be invisibly generated by the scala compiler), it most certainly can be achieved: stackoverflow.com/a/25538287/501113
I have been using Scala case classes extensively and have come up with a "case class pattern" (which will eventually end up as a Scala macro) which helps with a number of the issues identified above: codereview.stackexchange.com/a/98367/4758
L
LoicTheAztec

One big disadvantage: a case classes can't extend a case class. That's the restriction.

Other advantages you missed, listed for completeness: compliant serialization/deserialization, no need to use "new" keyword to create.

I prefer non-case classes for objects with mutable state, private state, or no state (e.g. most singleton components). Case classes for pretty much everything else.


You can subclass a case class. The subclass can't be a case class too — that's the restriction.
D
Daniel C. Sobral

I think the TDD principle apply here: do not over-design. When you declare something to be a case class, you are declaring a lot of functionality. That will decrease the flexibility you have in changing the class in the future.

For example, a case class has an equals method over the constructor parameters. You may not care about that when you first write your class, but, latter, may decide you want equality to ignore some of these parameters, or do something a bit different. However, client code may be written in the mean time that depends on case class equality.


I don't think client code should depend on the exact meaning of 'equals'; it is up to a class to decide what 'equals' means to it. The class author should be free to change the implementation of 'equals' down the line.
@pkaeding You are free to not have client code depend on any private method. Everything that is public is a contract you have agreed to.
@DanielC.Sobral True, but the exact implementation of equals() (which fields it is based on) isn't necessarily in the contract. At least, you could explicitly exclude it from the contract when you first write the class.
@DanielC.Sobral You're contradicting yourself: you say people will even rely on the default equals implementation (which compares object identity). If that's true, and you write a different equals implementation later, their code will also break. Anyway, if you specify pre/post conditions and invariants, and people ignore them, that's their problem.
@herman There's no contradiction in what I'm saying. As for "their problem", sure, unless it becomes your problem. Say, for instance, because they are a huge client of your startup, or because their manager convinces the upper management that it's too costly for them to change, so you have to undo your changes, or because the change causes a multi-million dollars bug and gets reverted, etc. But if you are writing code for hobby and don't care about users, go ahead.
C
Community

Are there situations where you should prefer a non-case class?

Martin Odersky gives us a good starting point in his course Functional Programming Principles in Scala (Lecture 4.6 - Pattern Matching) that we could use when we must choose between class and case class. The chapter 7 of Scala By Example contains the same example.

Say, we want to write an interpreter for arithmetic expressions. To keep things simple initially, we restrict ourselves to just numbers and + operations. Such expres- sions can be represented as a class hierarchy, with an abstract base class Expr as the root, and two subclasses Number and Sum. Then, an expression 1 + (3 + 7) would be represented as new Sum( new Number(1), new Sum( new Number(3), new Number(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
}

Furthermore, adding a new Prod class does not entail any changes to existing code:

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

In contrast, add a new method requires modification of all existing classes.

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

The same problem solved with case classes.

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

Adding a new method is a local change.

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

Adding a new Prod class requires potentially change all pattern matching.

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

Transcript from the videolecture 4.6 Pattern Matching

Both of these designs are perfectly fine and choosing between them is sometimes a matter of style, but then nevertheless there are some criteria that are important. One criteria could be, are you more often creating new sub-classes of expression or are you more often creating new methods? So it's a criterion that looks at the future extensibility and the possible extension pass of your system. If what you do is mostly creating new subclasses, then actually the object oriented decomposition solution has the upper hand. The reason is that it's very easy and a very local change to just create a new subclass with an eval method, where as in the functional solution, you'd have to go back and change the code inside the eval method and add a new case to it. On the other hand, if what you do will be create lots of new methods, but the class hierarchy itself will be kept relatively stable, then pattern matching is actually advantageous. Because, again, each new method in the pattern matching solution is just a local change, whether you put it in the base class, or maybe even outside the class hierarchy. Whereas a new method such as show in the object oriented decomposition would require a new incrementation is each sub class. So there would be more parts, That you have to touch. So the problematic of this extensibility in two dimensions, where you might want to add new classes to a hierarchy, or you might want to add new methods, or maybe both, has been named the expression problem.

Remember: we must use this like a starting point and not like the only criteria.

https://i.stack.imgur.com/XWhhv.png


a
arglee

I am quoting this from Scala cookbook by Alvin Alexander chapter 6: objects.

This is one of the many things that I found interesting in this book.

To provide multiple constructors for a case class, it’s important to know what the case class declaration actually does.

case class Person (var name: String)

If you look at the code the Scala compiler generates for the case class example, you’ll see that see it creates two output files, Person$.class and Person.class. If you disassemble Person$.class with the javap command, you’ll see that it contains an apply method, along with many others:

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

You can also disassemble Person.class to see what it contains. For a simple class like this, it contains an additional 20 methods; this hidden bloat is one reason some developers don’t like case classes.