-
Notifications
You must be signed in to change notification settings - Fork 30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
SIP-50 - Struct Classes #50
Conversation
I am not convinced that such modifications are wise instead of an anti-pattern, so I'm not convinced of the wisdom of including it as a core language feature. The reason is that adding optional fields is not a neutral operation when it comes to very many likely operations. It's good to make people recompile and rethink. For instance, suppose you add an optional Suppose you have a val superuser = Permissions(
allUsers.map(u => u.level1).max,
allUsers.map(u => u.level2).max
) Except you're now silently broken, with no clue even by the requirement to recompile suggesting that your logic is broken. Or what about custom serialization? Or what about transformation of a data field where the code doesn't use user match
case User(first, last) => User(capitalize(first), capitalize(last)) Oh boy, I just lost all my addresses! It seems that to get this right, you require a whole list of best practices, and leaning on best practices is the least desirable route to correctness. And you have to expect people will have done them in anticipation of possible changes, without a recompile (which is at least slightly hinted at by having it be a I grant that all these issues already exist, and that the boilerplate-heavy manual method allows you to get around them (as do the other mechanisms), with the same risks. But just like |
A struct class definition `c[tps](ps_1)...(ps_n)` with type parameters `tps` and | ||
value parameters `ps` is handled as follows. A `private` method named `copy` is | ||
implicitly added to the class definition unless the class already has a member | ||
(directly defined or inherited) with that name, or the class has a repeated parameter. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it just the name copy
used to decide generation? Or would other parts of an existing function signature, such as the return type used to determine if it needs to generate a copy method?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I copied and adapted the specification of case classes from here. The reason is that I want the same schema to be applied both to struct classes and case classes.
In that specification, only the name is used, but not the signature. I’ve noticed that the Scala 2 compiler does not synthesize a copy
method if I already define one (even if it’s signature does not match the case class fields). However, I’ve just noticed that the Scala 3 compiler does not behave the same here: if you add a method copy
, the compiler adds another one as an overload. I’ve created scala/scala3#16309 to clarify whether this is expected or not.
|
||
Besides the two solutions shown in the Motivation section (based on regular classes | ||
or case classes), we also considered a more powerful variant of `struct class` that | ||
would also automatically generate the transformation methods (`withName`, `withAge`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel not having this kind of feature will be frustrating to users. Most will reach out to struct classes as a way to deal with lighter-weight case classes or solve the bin compact issues, but the problem of updating and dealing with immutable structures does not go away.
We have a similar problem in Kotlin in the context of value classes where they may include special syntax for immutable copy updates https://github.com/Kotlin/KEEP/blob/master/notes/value-classes.md#updating-immutable-classes. I don't propose we follow a similar approach; just pointing out that not having a feature like this and forcing users into considerable boilerplate to get it working may introduce more issues than struct
classes solve. In contrast, case classes that at least give you copy
are not great to work with and with bin compact issues, but they are still an alternative to writing a bunch of boilerplate.
I think struct
classes will be even better if they improve on case classes
not just from a bin compact standpoint but also the state of what working with immutable data looks like in Scala.
I'd like to consider this from a beginner perspective: is yet-another kind of class really worth it? We already need to explain the difference between plain classes and case classes, which digs into tricky things such as equality. But does a beginner need to bother about such Perhaps an annotation similar to |
It changes the public API of the class. As such, it falls way beyond what an annotation is supposed to be able to do. |
I share Bjorn's sentiment. Adding another kind of class with all its special rules adds considerable complexity to the language. But I am also dubious about "hiding" the complexity under an annotation In my mind, an annotation can
Which leads me to ask: Do we really need this? What do other languages have to offer in this respect? Is this an area where Scala falls clearly short, or is it an area where we think that Scala should be leading the pack? If the latter, why? Otherwise, it looks like a major complication for a relatively minor use case. One could argue that we could just keep using normal classes and implement structural operations by hand. Or, use case classes with the tweaks described in the proposal. Adding a third kind of class is a very large complication. Is it really worth it? |
I think it's mostly useful for library authors who want to provide case classes and evolve them. Most languages don't have something comparable. Maybe it should be a keyword modifier of case class. Or maybe we should have a more general mechanism to limit what case class normally generates. Regarding generating withXXX methods it would be nice to have a way to do that but maybe orthogonal to this. But I'd actually rather modXXX which take a function instead of a constant. Or even better, lenses, which doesn't currently seem possible in Scala 3. But it's not specific to this new kind of class. I also don't think struct is the best name. It's not more of a value class than case classes. I guess without an extractor "case" may not be the best name. I guess it's just a simple product type, or data class. Coming back to the issue of learning a new keyword, I think Anyway it's not hard to teach that case classes are data class plus pattern matching and a public copy method. |
If it is really important to support this use case with less boilerplate, can we extend So if you want just
where If it's really really important to have even less boilerplate, then a predictable assemblage of such things could be created, maybe something like
which you would then deploy with
as if it were a typeclass, except it would work for this quasi-structural-type instead. Then we support more use-cases, we don't have to argue about exactly what does and does not go into a struct class, we punt on anything difficult and make the user implement it themselves, and what is entailed by "data class" becomes somewhat more explicit. I'm still not sure this is a very good idea, but I think it has substantially fewer dangers and greater benefits than a |
defined as `x_ij: T_ij`. In all cases, `x_ij` and `T_ij` refer to the name and type | ||
of the corresponding class parameter `ps_ij`. | ||
|
||
Every struct class implicitly overrides some method definitions of class `scala.AnyRef` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shall struct classes be implicitly final
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That’s a good question. I am tempted to stay conservative and follow the same behavior as case classes (ie, forbid struct-to-struct inheritance, struct-to-case inheritance, and case-to-struct inheritance, but not make struct classes final
).
That being said, if we allow a class to extend a struct class, we should probably make struct classes extend Equals and use its canEqual
method to implement equals
to make sure we don’t generate an implementation of equals
that is not an equivalence relation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My take would be that these should be final. What would be the usecase for subclassing these?
As mentioned in the Motivation section (under "Using Code Generation or Meta-Programming"), the need is clearly there. Several libraries have reinvented the pattern using a variety of ways. And that's not even counting libraries who simply write everything by hand (e.g., the Scala.js linker library). This is a very well-known pattern, reinvented many times, but requiring a lot of boilerplate if done in user space, as well as a lot of care not to mess up. Isn't that the best motivation we could possibly have for putting something into the language? Isn't it the same kind of motivation that led to |
I wonder if we should have a |
|
Riiight. Forget that one. :) |
I guess the SIP should mention its relation to Java's |
If we think binary compatible case classes without copy etc is important enough to include in the language, I'd like to challenge the keyword |
It's probably better to avoid "record", depending on how sure we are that Java is going to use it. There's enough similarity in what https://openjdk.org/jeps/359 is proposing that using the same keyword will likely be more confusing than helpful. |
I would rather use the workaround mentioned in the motivation section: case class Person private (name: String, age: Int):
def withName(newName: String): Person = copy(name = newName)
def withAge(newAge: Int): Person = copy(age = newAge)
object Person:
def apply(name: String, age: Int): Person = new Person(name, age)
private def unapply(person: Person): Person = person |
@julienrf Looking at it again, I now agree with you. This looks like an OK solution to me. I even think you don't need the secondary constructor. Here's the setup of an extensible case class Person private (name: String, age: Int):
def withName(name: String) = copy(name = name)
def withAge(age: Int) = copy(age = age)
object Person:
def apply(name: String, age: Int) = new Person(name, age)
private def unapply(p: Person) = p and here's the delta if you want to add a new field: case class Person private (name: String, age: Int, address: String = ""):
...
def withAddress(address: String) = copy(address = address)
object Person:
...
def apply(name: String, age: Int, address: String) = new Person(name, age, address) It does not look too burdensome to me. |
@odersky Does that mean that no change is needed for this or is there some changed behavior of the compiler assumed by your last proposal to make the generated code of the updated with "delta" binary compatible? |
@bjornregnell That works out of the box. No compiler change is needed. |
@odersky Ok great! I guess there is still the problem that you might start off with a "normal" case class and then realize that it should be evolve-able in a bincompat fashion, so maybe there is still some value in introducing some kind of marker trait or something that can signal this intent? And perhaps then it would still be valuable to scrap some boilerplate by including some compiler magic? If it's worth it, the SIP proposal could then perhaps be transformed to something like "less boilerplate for binary compatible evolution of case classes" or similar. |
In Scala, we have the |
I've written up more on how I envision a "built-in codegen" approach working in https://contributors.scala-lang.org/t/scala-3-macro-annotations-and-code-generation/6035 |
Thank you everyone for your feedback on this proposal! I do understand the concern of introducing yet another type of type definition that would be complicated to explain to beginners. I think the workaround mentioned in this comment is acceptable, although not ideal. Therefore, I withdraw the proposal. If someone else wants to revive the proposal, or add anything to the discussion, feel free to comment here. |
Maybe we could have this pattern in official documentation, explaining how do design evolvable/compatible case classes. It doesn't look overly complicated, just private constructor and private |
Let's document this pattern: scala/docs.scala-lang#2662 |
I agree that the pattern should be well documented. Thank you @sideeffffect for going ahead with a PR! |
So it turns out we have to do 3 things now:
This pattern doesn't seem as attractive as it did in the beginning... 😕 |
I was asked to post here an example of me trying to use a cross-compilable builder pattern (can't dataclass because I need Scala 3 as well). The end result is very verbose, requires discipline, and from what I can tell isn't even going far enough to maintain compatibility (I didn't apply all the suggestions from the referenced docs PR). I would ask the SIP committee to reconsider this SIP or a version thereof. |
Indeed, we don’t have a good solution when you need to cross-compile your code with Scala 2 and 3 (which is common when you write a library…). But I am not sure even if we had a good proposal that it would be implemented in both Scala 2 and Scala 3? |
This was discussed at the meeting today — apparently |
Yes, I was checking that and indeed with |
IMO there are two problems here:
That seems like a lose-lose situation, IMO. |
Could you please elaborate on that point? |
-Xsource:3 has improved quite a bit since this issue was posted, so this could be revisited. |
@smarter How far can the coming annotation macros in Scala 3 take us with this (also when cross-compiling to Scala 2)? |
I've prototyped these ideas in scala/scala3#16545. |
Agree, and I've added a note to scala/bug#11661 to say so. |
Based on the pre-SIP discussion.