Data classes should be able to inherit from each other


#1

I know there are a lot of issues and concerns about general data class inheritance, but just letting data classes inherit from other data classes would be hugely beneficial and a very common use-case I think.


#2

Inheritance would break the contract of equals

Maybe you could use delegation instead of inheritance.

fun main(args: Array<String>) {
    val b = B(1, XY(2, 3))
    println(b.y) // 3
}

interface XYHolder {
    val x: Int
    val y: Int
}

data class XY(override val x: Int, override val y: Int) : XYHolder
data class B(val a: Int, private val xyh: XYHolder) : XYHolder by xyh

#3

It would be interesting to discuss. In which cases the proposed data class inheritance breaks the contract of equals?


#4

I understand some of the problems.

However I think it would be worth trying to find solutions as i think many (if not a majority) of complex data contracts will necessarily have a hierarchy to them.


#5

Could you share an example of such hierarchy? Why does it make sense to have the base classes of such hierarchy as data classes?


#6
interface Value
data class SimpleValue (val id: Int, val description: String) : Value
data class CategoryDependentValue(val id: Int, val description: String, val dependsOn: Int) : Value

You would then serialize it to JSON so you can get something like:
[{ id: 42, description: "Some Value" }, { id: 56, description: "Something applicable to some category" , dependsOn: 42 }]

It’s true that doing it with inheritance you don’t gain that much (you probably have to type more because you have to receive the params and call the base constructor). But since an example was asked for…

I do sometimes encounter situations where a DTO needs to be extended, which works when you’re sending data to Javascript, for example. I’m not sure how it would work when you need to deserialize in a statically typed context.
Anyway, the alternative is having a huge class with all the possible options as nullables and setting them as null most of the time.


#7

@ilogico has a good example there.

Additionally, we are often conforming to a set of data contracts defined externally. So even if you could come back and say: “well you could use X feature of Kotlin to replicate that” it still needs to be flexible enough to match this common concept that’s available in other languages and platforms.

For instance, if you really do need to pass something around as it’s base type, it wouldn’t do to say: “In Kotlin, just have every data class implement every member from it’s super classes

We need the flexibility to represent our data hierarchies as they really exist.


#8

The current work around I’m trying out is as such:

abstract class Parent
{
    abstract val p1: String?
}

data class ParentData : Parent( override val p1: String? = null )

abstract class Child : Parent
{
    abstract val c1: Int?
}

data class ChildData : Child( override val c1: Int? = null, override val p1: String? = null )

This does preserve the hierarchy, but as you can tell it’s very clunky, it doubles the number of classes (which in a project with a large set of contracts to begin with, this is actually an issue) and I haven’t really gotten to use it in anger yet, so haven’t figured out if the distinction between Child and ChildData and going between them is going to be an issue yet.


#9

@Wavesonics, sorry, but this example with Parent and Child doesn’t make it more clear.

So could you share an example of such set of data contracts? Without that it’s hard to tell why would you need to organize the hierarchy that way.

What common concept and which languages and platforms are you specifically referring to here?

The drawbacks of building the hierarchy of data classes this way are understandable. How do you envision data class inheritance to solve them from you?


#10

Actually, in that example, the class Child seems irrelevant, ChildData can inherit directly from Parent, which could be an interface instead of a class, or a sealed class maybe.
Unless you need more classes that extend Child and want to implement some behaviour for that set of classes, in which case Child could also be an interface, unless I’m missing something.

The point is that data classes are not meant to implement behaviour, but to hold data. This means it is unlikely you will need to override methods, or values even.
So, you can have a bunch of data classes and repeat the values they hold (which you would have to do with inheritance anyway, because you would need to create the constructor (with overrides?) and then call the base constructor (to set the values you already overrode?)).
If you need to treat a set classes in a special manner, because some of them have a val c1: Int for example, you can always create interfaces for that and have the relevant classes implement them.


#11

Yes the idea is Child is part of a deeper hierarchy. I need a way of representing an arbitrarily deep inheritance tree. And to be able to pass around some derived object as one of it’s base classes.

So just having Child be a flat class with no inheritance, even if it had all of the members it needed, would not solve the problem.

Having the entire inheritance tree as a set of interfaces works, and is essentially what I’ve done (I could easily switch from abstract class to interface). But it means I have to have twice the classes/interfaces. For every single class I must define it’s interface, and then it’s concrete data class.

This is quite cumbersome, and with a large number of contracts it really balloons things.


#13

Data classes being able to be part of an inheritance hierarchy.

This would be the ideal I think:

data class Parent : Parent( val p1: String? = null )

data class Child( val c1: Int? = null, val p1: String? = null ) : Parent( p1 )

Just data classes directly inheriting from each other. Here I could pass a child around as a Parent if needed. And I don’t need to define any other classes or interfaces to make things work.


#14

This was a mistake of me. I remembered a problem regarding the reflexive nature of equals (if B inherits from A, A instances could be equal to B instances but not the other way around). However there seems to be no problem. Just a memory bug :wink:


#16

How do you expect the equality operator between parent and child instances should work then?
For example you have a Map<Parent, SomeValue>. Do you expect being able to query that map with a Child key and get something but null in return?


#17

My self certainly not being an expert here, I looked a bit into work done on this topic in other languages.

This is a pretty good deep dive look into mixed type equality in Java:
http://www.angelikalanger.com/Articles/JavaSolutions/SecretsOfEquals/Equals-2.html

The (very rough) tl;dr is if the common parts of the base class are equal, AND the subclass parts are default values, than A and B would be equal. Other wise false.


#18

Without real world examples it’s hard to tell whether this equality behavior or, say, no equality at all between parent and child would be more suitable.

Thus I’d still appreciate if you could provide an example or such hierarchy, where instead of Parent, Child, p1 and c1 there are some meaningful names, and show the situations, when you want to substitute parent instance with a child one.


#19

I think you remember half-correctly, see for example the explanations in Oracle Secure Coding Guidelines for Java (https://www.securecoding.cert.org/confluence/display/java/MET08-J.+Preserve+the+equality+contract+when+overriding+the+equals()+method). The problem is not with reflexivity, but with symmetry, however.


#20

When having an “impure” equals where a child instance can equal an ancestor to some degree of equality (after all they are different things) can be done by always using the equals implementation of the child class (by checking it early in the parent equals implementation, after checking for null etc). It’s hard to see how the child equality can be implemented automatically though, a child instance is not a parent instance even with default fields.

There is one thing that could be allowed though without semantic issues and that is an abstract data class. In this case the abstractness guarantees that there are no parent instances, and as such equality works without headaches.

At the same time there is no reason that the implementation of the default equals method (when the type is not final) could not always forward to the equals implementation of the most derived instance (either the target itself, or the equals parameter), as per definition the results of those equals comparisons are required to be the same. Of course there is the cost of some type checking and call forwarding.


#21

But if there can be no instances of the abstract data class, what’s the advantage of it over a sealed or abstract class?


#22

You still get to use inheritance (also of implementation) and it can do equality including the inherited fields. You basically get everything a data class does, spread over a parent and children except that the parent cannot be instantiated.