Why is it impossible to subtype a struct?

Is there a deeper reason, why the following does not work? I guess a struct can only be a leaf in the type hierarchy. Is there a reason why this has to be like this?

julia> struct TypeA{T} 
           data::T 
       end

julia> struct TypeB{T} <: TypeA{T} 
           data::T 
       end
ERROR: invalid subtyping in definition of TypeB

Edit, I found the follwing in the Manual:

One particularly distinctive feature of Julia’s type system is that concrete types may not subtype each other: all concrete types are final and may only have abstract types as their supertypes.

1 Like

Multiple inheritance is a totally different story and could be allowed. I’ve written about why we don’t allow subtyping of concrete types before if someone wants to dig up one of my earlier posts about it.

Edit: here’s a link to an old google groups conversation about it

https://groups.google.com/d/msg/julia-dev/eA4VkFAD-yQ/LNUP_OT0zy0J

In particular, this paragraph from the end of my first post on the thread sums up my position fairly well still:

While there are a number of practical reasons to disallow subtyping of concrete types, I think they all stem from the basic fact that subtyping a concrete type is logically unsound. A type can only be instantiated if it is completely specified, and it only makes sense to subtype something if it is incompletely specified. Thus, a type should either be abstract or concrete but not both. What object-oriented languages that allow the same type to be both instantiated and subtyped are really doing is using the same name for two different things: a concrete type that can be instantiated and an abstract type that can be subtyped. Problems ensue since there’s no way to express which you mean. You end up with attempts to resolve this confusion like the “final” keyword and recommendations against ever subtyping classes that weren’t intended to be abstract. But wouldn’t it be better not to conflate the two things in the first place?

10 Likes

Note that if what you really want is the ability to declare fields in an abstract type, that is not a crazy idea:

https://github.com/JuliaLang/julia/issues/4935

Although it was proposed a long time ago, and has support in principle from several core developers, no one seems to have made it a high priority to implement, probably because most Julia programmers don’t find themselves needing this feature often once they shake off old OOP habits.

3 Likes

I think the main lack of enthusiasm came (for me at least) at this point in the conversation. Reading that comment it’s a little hard to parse quite what I’m getting at but let me try to explain it more clearly. The crux of the question is what would doing this mean:

abstract type Rectangle{T<:Real}
    width::T
    height::T
end

Does it mean that every subtype of Rectangle must have actual fields width and height of type T ? Naively that’s what one would think. But then what if you want to declare Square <: Rectangle with a single side::T field but which provides getproperty implementations of width and height? It seems like the naive meaning of declaring fields on an abstract type would prevent that, or at least force you to store both the width and the height even though you actually only want Square to have a single field.

So it seems like what we might actually want here is some way of declaring that when r::Rectangle{T} and you write r.height and r.width you will get something of type T. But with overloadable properties, that’s equivalent to saying that getproperty(r, :height) and getproperty(r, :width) return values of type T. What would such a “guarantee” look like in Julia? How do we deal with abstract interfaces in general? We document them and then if someone implements or uses an abstraction wrong they get a runtime error. In other words, the above abstract type declaration would have no effect except acting as documentation that Rectangle{T} is supposed to have width and height properties that return values of type T. That just doesn’t seem like a sufficiently compelling feature to add it until it has some more useful meaning.

7 Likes

I was trying to enhance TypeA, which was provided by some other package without some AbstractTypeA:

module SomeoneElsesPackage
struct TypeA
    a
end

f(x::TypeA) = ...
end

struct TypeB <: TypeA
    a
    b
end

Now f(x::TypeB) should already be defined and I only need to reimplement some of the functionality.

2 Likes

You could make a pull-request on SomeoneElsesPackage to create a common abstract type, which would require SomeoneElsesPackage methods to dispatch the abstract type instead of the concrete type.

Composition and method forwarding is the way to go! :slight_smile:

1 Like

I think there is some tension between the above statement, which is both logical and desirable for performance reasons, and the actual possibility to reuse code.

In practice, it can happens that a type is completely specified for someone, but incompletely specified for someone else. The important word here is incompletely: it means that what is available is already a good starting point, but something else is missing.

While this is not a big issue for structure definitions (we can always use macros to copy/paste fields), it is a huge problem for code reuse since:

  • abstract types carry no information on the actual data representation;
  • methods operate on specific data representations, hence their arguments will be annotated with concrete types;
  • but concrete types can not be subtyped, hence those method implementations are locked, and can not be reused.

This problem might be mitigated if we could attach a specific data representation to abstract types. Besides, there are cases where an abstract type will always have one (and only one!!) concrete subtype (consider the DataFrame case, thoroughly discussed here: in this case the distinction between AbstractDataFrame and DataFrame is almost fictitious, and they are actually the same thing).

I completely agree that allowing subtyping in all cases brings more damage than advantages, but disallowing it in all cases prevents code reuse (as discussed above). It looks like we need a comporomise in the allowing/disallowing concrete subtyping and abstract/concrete dichotomy…

In ReusePatterns I explore the possibility to exploit one such compromise through the concept of quasi-abstract type, i.e. a type which:

  • can be subtyped like an abstract one;
  • can have data representation attached, like a concrete one;
  • introduce a performance penalty if used in a container, like an abstract one;
  • can be used to annotate method arguments, with no performance penalty, like a concrete one.
  • it inherits the structure fields of its ancestors, unlike both abstract and concrete types.
  • it is supposed to be subtyped, i.e. the author clearly states that it is not final and (most importantly) it fosters code reuse.

I agree there is no value added in the structure definition, but there is a huge advantage for code reuse: e.g. function mymethod(input::Rectangle) could be reused by all Rectangle subtypes, since they will all contain the width and height fields.

In summary: we may safely neglect the problem of concrete subtyping (there are macro for this), but for the purpose of code reuse it might be useful to attach data representation to an abstract type (making it a not-so-abstract one, or quasi-abstract). Incidentally, the latter condition brings the possibility of concrete subtyping, even if it was not the original goal.

As R.P. Feynman once said: sure it can give some practical results, but that’s not why we do it!. :wink:

4 Likes

Abstract types do carry parametric type information, so they can carry some implementation information


julia> abstract type AbstractInfo{Data} end

julia> struct SpecificInfo{Data,MoreInfo} <: AbstractInfo{Data} end

Have you tried attaching parametric type information to your abstract and concrete types? It can help.

I might not been seen it right, but something like this would save me some code.

I have a satellite simulator that I put in a package inside a compiled system image so that other people can use it easily. This simulator let you define the controller and the estimator. Each one has they own abstract type, so that the user can modify the default.

Every time the satellite changes the mode, the controller is different. In some very long simulations that take 2 to 3 hours, it would be bad if something fails because the new controller does not implement all the necessary properties. Hence, I need to programmatically check if everything is right before the beginning of the simulation. If we have such a feature, then the code would not even compile.

Anyway, maybe my approach is wrong and there are better ways to do that, but for my use case now, it will be very nice :slight_smile:

2 Likes

Can’t you just wrap everything in an abstract type and dispatch on that? Then field reuse is taken care of with a macro and the implementation isn’t locked.

I feel the deeper problem (that your package mostly seems to solve) is the point @Ronis_BR makes that when fields are absolutely required in a subtype we are relying on manual checks or error messages for enforcement instead of the compiler. It isn’t very clean, or newb friendly for our package users if they are actively meant to extend things.

2 Likes

There has been some discussion about formalizing interfaces, see eg

and the issues that reference it (it is a long read).

That said, this may not protect you from runtime errors either. Your best bet is unit testing on a toy problem (small dimensions, or some modification that allows it to run quick).

2 Likes

You can use the concrete subtyping feature in ReusePattern.jl for this purpose, i.e.:

@quasiabstract struct Controller
  field1
  field2
  etc.
end

@quasiabstract struct NewController <: Controller
  field3
  etc.
end

function simulate(c::Controller)
  println(c.field1, c.field2)
end

conctroller = NewController(arg1, arg2, arg3)

# The following will surely work (as long as you use `@quasiabstract` to subtype `Controller`)
simulate(controller)
2 Likes

This is just half of the problem.
The other half is that if package A dispatch on concrete type, then package B (maintained by another person) has no way to reuse A’s functionalities.

Or at best, we should follows @chakravala advice:

1 Like

Thanks for the replies, it must be really tiring for the core devs to listen to the same proposals again and again :slight_smile:. Although I don’t fully understand the problem yet, I see that there are some unresolved issues. It seems like ReusePatterns.jl may do what I want.

My intuition wants to simply treat

struct Rectangle{T<:Real}
    width::T 
    height::T 
end

the same way as

abstract type Rectangle{T<:Real} end

is treated now. If you have

area(x::Rectangle) = x.width * x.height
struct Square{T} <: Rectangle{T}
    width::T 
end  

then

x = Square(3)
area(x)

will try to use the method area(x::Rectangle) and error because there is no x.height unless you specify

area(x::Square) = x.width * x.width

or

getproperty(x::Square, i)
   # extract width and height
end

This would make it really easy to simply add an extra field to a type and to reimplement only the methods that touch the new field without having to use an AbstractRectangle.

struct ColoredRectangle{T<:Real} <: Rectangle{T}
    width::T 
    height::T 
    color::Color 
end

Maybe there should be a macro @make_abstract:

@make_abstract Rectangle

creates an AbstractRectangle type and converts all methods that use Rectangle to use AbstractRectangle.

I don’t think this is possible: once you dispatch on concrete type there is no point in defining new methods on abstract types, since the former are more specific.

Also, it is not clear to me if you want Square to have the same fields as Rectangle. If the answer is yes simply use:

@quasiabstract struct Rectangle{T<:Real}
    width::T 
    height::T 
end

If the answer is no, i.e. if there is not a specific data representation attached to Rectangle, then it must be an abstract type:

abstract type Rectangle{T<:Real} end

EDIT: I fixed a bug in ReusePatterns.
The following code:

@quasiabstract struct Rectangle{T<:Real}
    width::T 
    height::T 
end

now correctly produces:

abstract type Rectangle{T <: Real} <: Any end
struct Concrete_Rectangle{T <: Real} <: Rectangle{T}
    width::T
    height::T
end

(Rectangle{T}(args...; kw...) where T) = Concrete_Rectangle{T <: Real}(args...; kw...)
concretetype(::Type{Rectangle}) = Concrete_Rectangle
concretetype(::Type{Rectangle{T}}) where T = Concrete_Rectangle{T <: Real}

isquasiabstract(::Type{Rectangle}) = true
isquasiabstract(::Type{Rectangle{T}}) where T = true

isquasiconcrete(::Type{Concrete_Rectangle}) = true
isquasiconcrete(::Type{Concrete_Rectangle{T}} where T) = true

Yes, but I can subtype AbstractRectangle and then the methods are defined for the subtypes.

I would leave this entirely to the user, if the fields are not defined, then the methods that use those fields simply fail.

OK, but those methods will not be used if there is a more specific method accepting a concrete type.

struct Rectangle
    w
    h
end
area(x::Rectangle) = x.h * x.w
width(x::Rectangle) = x.w
@make_abstract Rectangle

struct Square <: AbstractRectangle
    w
end

s = Square(10)
area(s) # fails
width(s) # fine
area(x::Square) = x.w * w.w
area(s) # fine now

Yes, you have all the methods implemented two times and the ones for AbstractRectangle will not be used, unless they are used by some other subtype (e.g. Square).
It is not possible to make Rectangle <: AbstractRectangle, because you cannot redefine structs.