34 comments

[ 3.6 ms ] story [ 85.6 ms ] thread
The article does not actually explain how to do inheritance. In case you are wondering, the answer is using sealed classes.

Some neat things with data clases (vs. e.g. Java's records):

- default values for parameters

- if you use vals, your data classes are immutable

- copy constructor that has the same parameters as the constructor with their current values as the default value.

- destructuring values

- extension and delegated properties. Those are not limited to data classes of course but they are really nice to use with data classes.

Kotlin multi platform and data classes are actually a nice combination btw. We have a spring boot project with a Kotlin multi platform API client that we can consume on Android, IOS, in a Browser and in our integration tests for the server. Exact same code working exactly the same way on IOS native, in a browser, or on Android and the JVM. It uses ktor client for http communication, and kotlinx serialization for json parsing. Works great.

Kotlin is simply amazing if you pair it with the corresponding frameworks, i.e. kotlinx serialization instead of gson/jackson etc. Quickly fell in love with the language and Java, even in the new(er) versions, feels outdated.
The reason Java rejected data classes in favour of the very different and more powerful approach of nominal tuples (i.e. records) and algebraic data types in general, was the observation that the vast majority of data classes can be record classes. It's by not trying to cover the remaining small minority of cases with the same construct that we gain the guarantees of a more powerful one based on the property that the full state of a record is described by its canonical constructor and its deconstruction serves as its dual.

All records are well-behaved as collection members; not all data classes are.

All records can be safely serialised and deserialised; not all data classes can.

All records can be deconstructed and reconstructed; not all data classes can.

Data classes are about declaring classes with less code. When you have an object of a data class you know nothing more about its behaviour than you do about an object of any class. Record classes, which are similar in their design philosophy to enums, are not about writing less code, but about being reifying the concept of unencapsulated data, which offers semantic guarantees -- not just less syntax -- about the nature of the objects, just as you can with enums.

Java is able to offer new constructs such as records and enums, with runtime support, as it's not a hosted language.

Like your input a lot.

> All records are well-behaved as collection members; not all data classes are.

> All records can be safely serialised and deserialised; not all data classes can.

> All records can be deconstructed and reconstructed; not all data classes can.

Can you give examples please?

> All records are well-behaved as collection members; not all data classes are.

This is due to immutability. A var in a data class constructor means that the object's hashCode may change after its construction, and so mutating such an object may break collections that rely on hashCode to locate members, such as HashMaps and HashSets.

> All records can be safely serialised and deserialised; not all data classes can.

This is because records do not have any state beyond that which is constructed by the canonical constructor, which is never bypassed. When serializing a record, its components are serialized. When deserializing one, the only way to construct the record is by passing the deserialized components to the canonical construct, which then validates them. Deserializing an object with hidden state bypasses the constructor and can inject inconsistent, potentially harmful, data into it.

> All records can be deconstructed and reconstructed; not all data classes can.

A record is its components (and type; this is why they're nominal tuples). When you deconstruct a record with a pattern, you get its full state. If you put it back together you'll get an object with the same data as the original.

>A var in a data class constructor

Just because you can, doesn't mean you should. I can count on one hand the number of data classes with vars in our codebase of 500k+LOC, and they are specialized implementations for processes that would otherwise entail lots of copying and awful performance, whereas mutation just works.

That is the exact observation I was referring to! You can make that desired behaviour into a guarantee that the runtime and any other code can rely on without giving up much. So even though most data classes could be records, the construct still doesn't make the necessary guarantees.

Because those times where data objects require mutation are not numerous, the benefit of reducing their syntax is small. So Java was able to give the syntax benefits in almost all of the same cases, and additionally it was able to make powerful behaviour guarantees.

If you follow that logic to its conclusion, it means that you should just replace your Java code with Haskell (or at least OCaml).

These are fine languages, but Kotlin is known to be more pragmatic. There are some footguns, make sure to be aware of them.

OCaml and Haskell are very different; OCaml is much closer to Java than it is to Haskell. Java is, or soon will be, a nominally-typed ML-ish (which isn't saying all that much given how many languages were inspired by ML). You could say that one difference between Haskell and OCaml/Java is that OCaml and Java have algebraic data types, while Haskell has only algebraic data types.

The only way my logic could lead to the conclusion that the Haskell approach is always advisable is if you mistake "ADTs are very useful" to mean "anything beyond ADTs is harmful", which is something that Java or OCaml don't say.

> Java is, or soon will be, a nominally-typed ML-ish (which isn't saying all that much given how many languages were inspired by ML).

Sorry, but I call bullshit. As long as reflection is a huge part of major frameworks, libraries are loaded at runtime and mutability is the default for basically everything... there's just a world of difference between the two.

I'll give you mutability by default, but I don't see why dynamic linking and reflection matter all that much (parametricity is not celebrated by the MLs as much as it is in Haskell). I think that the biggest fundamental difference between ML and Java is that ML's type system is primarily structural while Java's type system is primarily nominal. That's why I certainly wouldn't say Java is an ML (not even as much as Clojure is a Lisp), only that, like many other languages, it's ML-ish. It is no accident that virtually all of Java's biggest language features added since 1.0 -- generics, lambdas, ADTs, and pattern-matching -- were directly inspired by ML (although Java, which seeks to be a last mover, usually waits for several languages to adopt a feature before considering it).
All you're saying is that records are more limited than data classes.

You see that as a strength, I see it as a restriction because sometimes, I do like the extra flexibility of adding mutable state to my value objects.

And I know better than altering the value of hashcode after creation, although this only matters if you've put these objects in a hashing container, so it's not even a universal risk.

Enums are also more limited than classes, yet they have purpose/place. In fact, that’s the general purpose of types — to reduce capabilities to gain access to guaranteed capabilities, which lets you reach compiler-verified logic rather than simply programmer-verified / unit-test-verified logic.

Of course, it only matters if something utilities those guarantees, which I don’t know for kotlin, but C# uses records to expand out the LINQ interface quite substantially/conveniently for value comparison (mainly made convenient when you also have anonymous records)

I'm saying that records are a semantic features, while data classes are not.

In the context of records, "the extra flexibility of adding mutable state" makes as much sense as saying that sometimes I like the flexibility of adding new instances to my enum classes on the fly. An enum is an enum to guarantee a fixed set of instances in the type.

That records and enums are more constrained in their behaviour than ordinary classes is the reason for their existence, because they don't do anything an object of a regular class can't do. Data classes, on the other hand, exist for syntactic brevity. The difference between the features is not a difference in degree but a difference in kind.

Thank your for these clear explantions and showing how problematic mutability can be there. As my siblings allready stated this is easily mitigated but still good to know.
But records don't actually offer such guarantees:

    record MutableRecord(ArrayList<String> list) {}
    
    var r = new MutableRecord(new ArrayList<>());
    System.out.println(r.hashCode()); // -> 1
    r.list().add("foo");
    System.out.println(r.hashCode()); // -> 101605

I sort of hoped they would freeze hashcode but they don't.
I'm sure there are good reasons for why record classes are the way they are. But the reasons you cite strike me as a bit esotheric / low value.

Serialization works fine with kotlinx.serialization. Java's built in serialization is a bit of a relic of the past that doesn't really have that many use cases left. I'll write that off as a "I couldn't care less". I've actually used java serialization but not this century. There probably is still some legacy stuff around that depends on it but nothing a typical Kotlin user would care about.

As for collections. Not sure what you mean here. The Java collections take just about any object. Including data class objects. You need working hashcode and equals implementations. Which are provided by data classes. There are some limitations with deep comparisons particularly with array types. The Kotlin compiler will warn you about those. Am I missing something else here?

Constructing/destructing seems to work fine for me. Nice feature. Copy constructors in particular are neat. Am I missing out on something here?

You make it sound that this is kind of a big deal or a big compromise but I just don't see it.

> I've actually used java serialization but not this century.

I'm not talking about Java's built-in serialization but any kind of serialization. An object with hidden state cannot be safely deserialised regardless of mechanism.

Hidden state, by definition, means that its consistency (which may translate to security) is not validated by mechanisms that are known to the deserialisation mechanism. Safe serialisation requires that there be no hidden state, i.e. that all of the objects state is validated by public mechanisms.

> You need working hashcode and equals implementations. Which are provided by data classes.

No, that's not enough. Some collections (such as hash maps and has sets) additionally require that the hashCode does not change over the object's lifetime, or at least over the time the object is in the collection.

> Constructing/destructing seems to work fine for me.

Reconstruction can only work when all of the object's state is deconstructed. That's not the case for data classes. The result will be equal in the `equals` sense, but the resulting object will not be substitutable for the original. Equal records are substitutable (unless you depend on object identity, but object identity is the focus of another project -- Valhalla)

> but I just don't see it.

The powerful properties of algebraic data types have been studied for half a century now, and there's a lot of literature you can find on the subject.

That's not to say that data classes aren't helpful, but they only address the problem of syntax ceremony. Records, by focusing on semantics, not only on syntax, additionally address other important problems.

> No, that's not enough. Some collections (such as hash maps and has sets) additionally require that the hashCode does not change over the object's lifetime, or at least over the time the object is in the collection.

If everything declared val in Kotlin, wouldn't that be enough?

Sure, just as any immutable object is fine in this regard. But the point is not the fact that we can write some classes that are well-behaved in certain respects, but that we can reify a concept as construct known to provide those guarantees. That's exactly what enums and records do, but data classes don't.

You could always code regular classes with behaviour similar to that of enums or records, but the point of enums and records is that all enums and all records are known to have their respective kind of behaviour.

Your characterization of "hidden state" doesn't make a lot of sense.

Either a state is part of the object's identity, and it's serialized, or it's not, and it's not. This observation has nothing to do with data classes, records, Java, or Kotlin.

> The result will be equal in the `equals` sense, but the resulting object will not be substitutable for the original.

This doesn't make sense either. You are describing someone who implemented flawed serialization. It's a programmer error, not a language or semantic one.

I see very little difference of practical importance between Java's records and Kotlin's value classe, and as much as I value your technical writings, I am concerned that your employer is tainting your objectivity when trying to compare Java and Kotlin. You can do better.

What's serialised is not identity but state. If any part of the state is hidden, i.e. serialization needs to set it not via the object's public API, then serialization can break the object's invariants. This is what serialization vulnerabilities are. You certainly can write classes with safe serialization, but serialization vulnerabilities happen because sometimes people don't.

Sure, it's an error, but it is one of the errors that enums and records were created to avoid. But it is true that we can live without records and enums, and, indeed, we did, so I wouldn't say they're critical features. Still, I think it's important that people know that records and enums -- unlike data classes, which are syntax sugar -- were added for the guarantees they make regarding certain behaviours, including interaction with collections and with serialization (of any kind).

> What's serialised is not identity but state.

These are not mutually exclusive and they describe actually two very different concepts.

If your objects will end up in a hashing container, you absolutely need to serialize what makes their identity. If not, you can serialize whatever you need from the object. It can be more fields than its identity, or fewer.

I'm not sure what your following point about serialization vulnerability (however correct) has to do with the current discussion.

This sounds like a whole lot of "just don't do that".

Making properties mutable in Kotlin is a choice. I typically don't. I also don't keep a lot of hidden state in my data classes. Why would I? And when I do, I mark those properties as transient/ignored for the serialization framework I use. Or I make them extension properties that are calculated from the other properties. None of the things you list are actually an issue unless you choose to do silly things like having hidden state and vars instead of vals.

You call that a problem, I call it flexibility.

As for hash codes not changing. That is indeed nice if you have a hash map but I've never come across that one as being a hard requirement. Just that objects that are equal must have the same hash code. So, this too is a non issue unless you do silly things that cause that to not be true. Like having mutable, hidden state that is then taken into account in your hash code function but not in your equals function.

Also worth pointing out that Java has had fully open and mutable classes by default with lots of hidden state and the collection classes have been there since java 1.1 (or 1.2) long before generics were even a thing. So, unless something changed there, people have been using them wrong for the last 20+ years.

> You call that a problem, I call it flexibility.

You misunderstand my point, I think. Java already offers you all that flexibility with regular classes, and data classes are just syntax sugar for creating regular classes. It's not that data classes have a "problem" so much as that they're just yet another syntactic construct, while records and enums are something else.

Of course, a hosted language (let alone one that targets multiple platforms) is quite limited in the kinds of constructs it can offer, as it has no influence over the runtime and the ecosystem, but it's still important to understand that data classes and records are features of a different nature.

> So, unless something changed there, people have been using them wrong for the last 20+ years.

Regular classes can, indeed, mimic the behaviour of enums and records, but the reason enums and records were added was that these construct provide guarantees of correct use, guarantees that the language and the runtime now rely on. True, we can and did live without them, and they're not absolutely critical. But they're still powerful, and their power is not a result of their succinct syntax but of their behaviour.

It's important to note that `data class` works on JVM platforms before version 16, and the Kotlin compiler will check the additional constraints and generate records if you add the `@JvmRecord` annotation. This is a reasonable approach for a non-Java JVM language, especially considering records is an ABI- and API-incompatible addition.
One downside of Java records seems to be that they can't participate as the alternatives (subtypes) for a sealed abstract type, since they can't extend any classes other than Object. Ie. the very common "sum of product types" pattern can't use records as the product types - which they would have been great for! You can have normal wrapper classes as the subtypes of the sealed type, and have those wrappers use records internally, but all the brevity and clarity is lost that way IMO and it just becomes obfuscation.
Of course they can! They can implement interfaces, which are sealed. In fact, that's one of their most common usages:

    sealed interface Option { 
        record InputFile(Path path) implements Option {}
        record OutputFile(Path path) implements Option {}
        record MaxLines(int maxLines) implements Option {}
        record PrintLineNumbers() implements Option {}
    }
(see https://www.infoq.com/articles/data-oriented-programming-jav...)

The problem of extending classes is that it can introduce hidden state and also result in non-reflexive equality. But interfaces are fine.

Thanks for that, I knew I must have been missing something!
How do you deal with schema versioning?

We use the exact same setup. However older client version often trip over server schema changes.

While this is a generic problem, it’s harder to prevent when your models it’s not built into your network data contract.

That’s why we considering using grpc which has backwards compatibility support on its design. Things can still break if you delete endpoints, but at least it’s contained to the idl file.

I basically don't version schemas currently. We do version our library of course. So you have to opt into using a new version.

But managing compatibility breaking API changes is a bit of an issue of course. Not just for Kotlin.

I mitigate it by configuring the serializer to be tolerant of new or missing keys, enum values, etc. The defaults for this are actually a bit dangerous/wrong IMHO. Also, most new properties that we add are nullable and default to null. This way we can safely deal with values not being there. And we use deprecations before removing anything from the API (which are nice in kotlin because you can automatically fix them in intellij with replace patterns). There are a few other things you can do. Like implementing some good contract tests, etc.

We actually considered grpc at some point but decided against it as being a bit too limiting and mobile centric at the time. Also, grpc in a browser was not well supported at the time we looked. Maybe that has changed?

> I mitigate it by configuring the serializer to be tolerant of new or missing keys, enum values, et

Good point, we do that too.

> We actually considered grpc at some point but decided against it as being a bit too limiting and mobile centric at the time. Also, grpc in a browser was not well supported at the time we looked. Maybe that has changed?

Its supported: https://github.com/grpc/grpc-web

Transcoding to HTTP is also supported: https://cloud.google.com/endpoints/docs/grpc/transcoding

https://cloud.google.com/blog/products/api-management/unders...

I like kotlin a lot, a very well crafted language, syntax is super clean

I wish they'd put WAY more effort into kotlin native