Type Constructors (=multiple dispatch for `UnionAll`)

TL;DR

Let’s use (static) multiple dispatch for UnionAll type application!
This would introduce type constructors. They would take a UnionAll to construct a type similar to how inner (object/value) constructors take a type to construct an object/value.

Motivation

Backwards Compatibility

Suppose we wanted to generalize SubString from

struct SubString{T <: AbstractString} <: AbstractString
    string::T
    offset::Int
    ncodeunits::Int
end

into:

struct SubString{T <: AbstractString, R <: AbstractUnitRange{<:Integer}} <: AbstractString
    string::T
    ind::R
end

This would have multiple advantages (allowing smaller index types, allow something like Base.OneTo, generally reusing code) which are not relevant for our discussion here.

Object constructors and properties could preserve much of the existing interface. However, SubString{String} would become a UnionAll, so existing fields declared as ::SubString{String} would no longer be concrete. For all I know, this incompatibility could not be avoided (within the desired solution space, i.e without introducing additional types or something) with Julias features as of today.

Constraining Types

Currently, it’s often not possible to usefully constraint types so that invalid types cannot even be constructed (used). Instead, constructors need to make sure that they don’t construct an object of an (then already existing) invalid type.

So assume we wanted to define for our example above (to not introduce a more useful, but another example)

SubString{UnitRange{Int}}(bytes::Vector{UInt8}) =
    SubString{String, UnitRange{Int}}(String(bytes), 1:length(bytes))

then this could not be constructed, as SubString is a UnionAll, which only accepts a T <: AbstractString as first TypeVar, not an AbstractUnitRange.
The only possibility to make this work is to remove the type constraint(s) from the type definition and let the constructor do this work. While that kind of works, it not only feels wrong, but it becomes obvious, that regular constructors are currently more powerful.

Asymmetry

There is also an inherent asymmetry involved in defining parametric types with more than one type parameter, closely related to the point mentioned before: SubString{T <: AbstractString} automatically is a UnionAll, while SubString{R <: AbstractUnitRange{<:Integer}} isn’t, although they are clearly distinguishable. Again, regular constructors, using multiple dispatch, are just more powerful due to their multiple dispatch.

Conclusion

Julia lets us customize object construction through constructor methods, but type application such as Foo{T} always follows positional parameter substitution.
If we only had multiple (static) dispatch for type construction! So could we support inner type constructors, analogous to inner object constructors?

Proposed Syntax

Inside the type definition, allow (type) constructors without the parenthesized object argument list:

struct SubString{T <: AbstractString, R <: AbstractUnitRange{<:Integer}} <: AbstractString
    string::T
    ind::R

    SubString{T <: AbstractString} = new{T, UnitRange{Int}}
    SubString{R <: AbstractUnitRange{<:Integer}} =
        new{T,R} where T <: AbstractString
    SubString{T,R} = new{T,R}
end

The distinction would be:

  • new{T,R} returns the canonical type through native parameter substitution.
  • new{T,R}(string, ind) constructs an instance, as today.

From the user’s perspective:

SubString{String}
# Concrete type: String parent, UnitRange{Int} indices

SubString{String, UnitRange{Int32}}
# Concrete type: String parent, UnitRange{Int32} indices

SubString{UnitRange{Int}}
# UnionAll: any permitted string type, UnitRange{Int} indices
# Equivalent to:
SubString{T, UnitRange{Int}} where T <: AbstractString

SubString{String, R} where R <: AbstractUnitRange{<:Integer}
# UnionAll: String parent, any permitted range representation

SubString would still denote the generic UnionAll.

Semantics

Type constructors would dispatch on parameter combinations and bounds. Their bodies could use = begin … end with Julia code to validate parameters, supply defaults, or reorder them before returning a type.

Like inner object constructors, defining an inner type constructor would suppress automatic public type-application defaults. Explicit forwarding, as above, would restore the desired forms. Types without custom definitions would behave exactly as today.

So this should be fully backwards-compatible with the exception of local type aliases in the constructor which could have the same syntax. I have never seen them, but if they exist in larger frequency, we could either require, at least initially, a new (but this would disallow type constructor delegates) or change the syntax. Although I think that the proposed syntax is the logical choice, we could introduce a block-form with some keyword needing an end.

Arguments would be type objects, permitted value parameters, or TypeVars, so everything would need to be “static”. Ambiguous applications would be errors. At least initially constructors would be fixed with the type definition, so there would be no external type constructors. Type constructors must return specializations of that same nominal type (so a SubString type constructor must return a concrete or UnionAll SubString, not a Vector).

Summary

The goal is to make type application customizable in the same spirit as object construction, allowing a type’s representation to evolve while preserving useful existing spellings such as SubString{String}. This should solve all motivating problems and could make the language more consistent by allowing the same (and defining) language mechanism (multiple dispatch) to be applied not only on functions and on objects (=callables), but also on types.

What do you think? Obviously, a lot of details would need to be clarified before/while implementing it, but does the general idea sound interesting?

I had that same situation, and the only workaround was to change the order of the types, so I fully support this feature.

This part is not feasible in the general case. It only works here because you could distinguish the 2 parameters by bounds with an empty type intersection. If R <: Union{String, AbstractUnitRange{<:Integer}}, SubString{String} becomes ambiguous. If this involved the common practice of not proactively constraining type parameters at all, MyType{X} becomes ambiguous. Choosing the leading parameter may seem obvious, but it won’t be obvious which parameter is the leading one for a given value when there are many, more complex bounds, especially if the bounds from supertypes could be accounted for. Syntax ought to represent its meaning simply, and type computations aren’t simple.

If we toss that part out, we can simplify this to positional default values in the struct header:

struct SubString{T <: AbstractString,
                 R <: AbstractUnitRange{<:Integer} = UnitRange{Int}
                } <: AbstractString

Type parameters in Julia are inherently positional, and UnionAlls have so far defaulted to omitting trailing parameters, so I don’t see a good way of omitting leading parameters in general. If a shorthand would be useful, maybe some special value or syntax can denote the definition’s bounds in an iterated union e.g. Substring{:, UnitRange{Int}}. Not a fan of implicit type bounds, but supertypes already do that in a much more annoying way.

Aside-ish: It looks like the type-aliasing expression with the original name already does its weird reassignment as it suggests, so the parser would need to specially identify new to do what the proposed syntax intends.

julia> struct Foo{X,Y}
         Foo{T<:Number} = Foo{T,T}
       end

julia> Foo # global scope stuck with the alias now
Foo (alias for Foo{T, T} where T<:Number)

julia> struct Foo{X,Y} end # cannot redefine Foo with this change alone
ERROR: FieldError: type DataType has no field `body`, available fields: `name`, `super`, `parameters`, `types`, `instance`, `layout`, `hash`, `flags`

While it is correct, that this is not feasible in the general case, we need to be careful to not conclude that it is therefore not useful. Multiple dispatch with functions/callables is also not feasible in the general case and we get ambiguity errors when it isn’t, but nevertheless the mechanism is extremely useful.

I think it’s the same for type constructors. Sure, there will be cases where the type constraint is just exactly the same and therefore there is no benefit of multiple dispatch in that very situation, but experience (for functions/callables) shows that there are enough situations in which they are not exactly the same and it is very beneficial to make use of the mechanism.

Do you mean

R <: Union{String, AbstractUnitRange{<:Integer}, SubString{String}}

in your example?
Either way, the question will be: Is there a type constructor which is morespecific than the other ones which intersect with the provided parameters? And we obviously need to take care of Bottom, which is again a pain in the same.
At least in the beginning, ambiguous definitions could already be forbidden, not only ambiguous applications.

Well, I hope that my proposal is a good way. Your proposal with default arguments is interesting, but would only solve a subset of the problems, e.g. would not allow reordering. Sure, you can manually reorder the arguments in the example, but that does not scale to cases where you need both orderings with two type parameters and it obviously does not scale to multiple type parameters.

I other word: Your proposal with default arguments to type parameters is as useful as default arguments in method definitions, not as useful as multiple dispatch. Nevertheless, it can be very convenient for simple cases and would feel natural. So it is a nice feature, but it can’t replace type constructors with multiple dispatch.

Thanks for the example. That’s what I wanted to address with

A function call failing to dispatch to 1 specific method is still valid syntax with known behavior (MethodError). SubString{String} meaning 2+ possible types does not, at least not right now. This proposal would also have to consider introducing that behavior: does it represent a union type, would it error, etc. Personally I still wouldn’t want to deal with new trouble; method ambiguities are annoying as-is, and they’re frequently cited as a key reason for avoiding multimethods in other languages.

If type parameters have canonical positions in the first place, I don’t want to make one position ever look like another e.g. SubString{UnitRange{Int}} looks like it specifies the 1st parameter but it really specifies the 2nd in SubString{<:AbstractString, UnitRange{Int}}. Again, a short special value for default bounds makes more sense to me. If I really wanted to specify type parameters in an arbitrary order without the assistance of field values assigned to keyword arguments, I would rather that the type parameters get keywords. SubString{R=UnitRange{Int}} doesn’t look too bad, except I would prefer a manual opt-in to avoid all the existing parameter names and to specify keywords that actually mean something e.g. Array{eltype, dims}.

No, that sentence has no typo.

Yes, it’s very limited to providing default values to trailing parameters to illustrate a less problematic way to solve the 1st motivating problem of new trailing parameters making previously concrete types abstract. If a more powerful system also solves that problem, I wouldn’t want my suggestion to be around to interfere.

I think you really uncovered the core of it. Currently, from a user’s point of view, type parameters have a canonical position and you need to “fight” it to get something else.
For functions/callables, there are no canonical positions, because they are not needed due to multiple dispatch.
With the proposal, we would no longer have that canonical position as the user interface if the user does not want to have it. It’s still available as a fallback solution, but the user would be free to act like if there isn’t any canonical position.
Of course, the implementation might decide to still use an internal canonical position, but that is an implementation detail (from the user’s point of view).

I think we would limit it to “inner type constructors” anyway, at least in the beginning. How many ambiguous inner constructors are problematic in regular code? I do not expect problems with inner (type or value) constructors, because of their coordinated and closed nature, i.e. they are all in one place, one code location is responsible for it and no distributed extension happens across the ecosystem. Keeping these unambiguous is simple compared to outer constructors or public functions.

No, that sentence has no typo.

Sorry, then I didn’t get your point. How do I need to read your syntax? What gets ambiguous?

Make the corresponding changes to your example:

struct SubString{T <: AbstractString, R <: Union{String, AbstractUnitRange{<:Integer}} } <: AbstractString
    string::T
    ind::R

    SubString{T <: AbstractString} = new{T, UnitRange{Int}}
    SubString{ R <: Union{String, AbstractUnitRange{<:Integer}} } =
        new{T,R} where T <: AbstractString
    SubString{T,R} = new{T,R}
end

means that the proposed shorthand syntax for T=String overlaps with that of R=String:

SubString{String}
# Concrete type: String parent, UnitRange{Int} indices
...
SubString{String}
# UnionAll: any permitted string type, String indices
# Equivalent to:
SubString{T, String} where T <: AbstractString

Obviously string indices shouldn’t be strings, but that’s not a language-level constraint. This type syntax ambiguity doesn’t have a known behavior, but since the inspiration is multiple dispatch, we can at least see how methods behave:

julia> begin # method analog to proposed syntax, edited type bound of R
       struct SubString{T <: AbstractString, R <: Union{String, AbstractUnitRange{<:Integer}}} <: AbstractString
           string::T
           ind::R
       end
       SubString(::Type{T}) where T<:AbstractString = SubString{T, UnitRange{Int}}
       SubString(::Type{R}) where R<:Union{String, AbstractUnitRange{<:Integer}} = SubString{<:AbstractString, R}
       SubString(::Type{T}, ::Type{R}) where {T,R} = SubString{T, R}
       end; SubString(String) # analog to ambiguous case
SubString{<:AbstractString, String}

julia> methods(SubString)
# 4 methods for type constructor:
 [1] SubString(::Type{T}, ::Type{R}) where {T, R}
     @ REPL[1]:8
 [2] SubString(::Type{R}) where R<:Union{String, AbstractUnitRange{<:Integer}}
     @ REPL[1]:7
 [3] SubString(::Type{T}) where T<:AbstractString
     @ REPL[1]:6
 [4] SubString(string::T, ind::R) where {T<:AbstractString, R<:Union{String, AbstractUnitRange{<:Integer}}}
     @ REPL[1]:3

julia> print([Union{String, AbstractUnitRange{<:Integer}} <: AbstractString
              AbstractString <: Union{String, AbstractUnitRange{<:Integer}}
              Base.morespecific(methods(SubString)[2], methods(SubString)[3])])
Bool[0, 0, 1]

The union containing the narrower String is chosen instead of throwing a MethodError, so if the type syntax ambiguity is resolved the same way, it actually fails to represent the coveted concrete type. This proposal can make different dispatch rules, but I vastly prefer to avoid this entirely by manually specifying the 2nd position parameter in the possible shorthand SubString{:, String} or out of order with keywords SubString{R=String}.

Parametric type structure doesn’t have multiple possibilities, more specifically all branches of your example’s dispatch immediately ends up at new{T,R}. Functions are fundamentally designed to represent multiple methods with usually very different implementations, and saying there aren’t canonical positions at this level doesn’t really mean much. Each method’s arguments definitely do have canonical positions, and default values baked into forwarding methods are evidently poor for shuffling positional arguments in spite of the oft-quoted power of multiple dispatch. Keyword arguments exist for this job, no need to introduce ambiguities and footguns.

Thanks for the clear example, @Benny. With the dispatch situation

a = Tuple{AbstractString}
b = Tuple{Union{String, AbstractUnitRange{<:Integer}}}
call = Tuple{String}

we get

julia> call <: a
true

julia> call <: b
true

julia> Base.morespecific(a, b)
false

julia> Base.morespecific(b, a)
true

so that both type constructors would be valid, but Union{String, AbstractUnitRange{<:Integer}} is strictly more specific than AbstractString due to its closed set regarding String.
So it would be well-defined, would act according to consistent rules, which the user has to know anyway when using or coming across the mechanism in such cases, and it would be useful in quite some cases (like my original example).

It also indicates, that it would probably make sense to have some function like typeapply where this lowers to and which @invoke (or some other macro) could be taught about such that a more specific type constructor could make use of a less specific type constructor.

And, yes, there are problems where it would either not be the right tool, because it is too powerful, or where it is still not fully elegant due to ambiguities. So it would not solve all problems, but it would further increase the number of problems which can be solved elegantly with Julia. And that’s what a programming language is about, isn’t it? In Simon’s words: “Julia is about getting the job done”.

Yes, exactly, I wanted to highlight this difference. Canonical positions of arguments are method-specific, not function-specific. I think canonical positions of parameters should also (optionally) be type-constructor-specific, not type-specific. I am referring to the type construction level, not to the type itself.

I am also not talking about removing the current canonical position default, which is useful for a significant number of problems. I also agree that there are problems where something like default parameters would solve the problem more elegantly. I propose a mechanism which adds limited complexity for the user, as they already know the concept, possibly even increases language consistency from their point of view, and which should only be used when it is the right tool for the job.
We, e.g., deliberately focused on small examples with two parameters, but I am convinced this mechanism would shine with complicated cases like having five parameters.

I am more convinced that more parameters only increase the possible ways this proposed parameter-shuffling feature would fail to solve the motivating problems. In the example with edited type bounds, the backwards compatibility problem was actually made worse by SubString{String} meaning SubString{<:AbstractString, String} instead of the current SubString{String, <:Union{String, AbstractUnitRange{<:Integer}}. Any generic program that used to provide the T <: AbstractString parameter of SubString suddenly specially provides it for R = String, and the <:AbstractString bound needlessly worsens type inference for T. The most forgiving limitation to avoid the causal mis-dispatch is for every type bound to be a strict supertype of preceding type bounds, a union type that does not contain strict subtypes of preceding type bounds, or a disjoint bound (except Union{}) as in the original example. That is already rare in practice, and it is theoretically rarer with more parameters involved, especially including the type bounds implied by parametric supertypes (absent in these examples with SubString{...} <: AbstractString) or the rarer lower bounds. It’s true that future users could do the work to figure out when the feature causes problems, but that’s usually considered a good reason to reject the feature. People already complain about feature creep when those features are reliable, and the few features that can easily backfire are harshly criticized e.g. @inbounds for i in 1:length(vector::AbstractVector).

Bear in mind that any proposed feature is not compared with not solving the problems at all, it’s compared with the current approaches and alternate proposals.

  1. Mass text substitution of ::SubString{String} with ::SubString{String, UnitRange{Int}} is simple, fast, and a more explicit representation of the new backwards-compatible(?) concrete type we want. If we really don’t want to go through that effort (which seems odd considering how much we’d have to edit method implementations) or if the shorthand would really help for the type, my not-so-serious suggestion (lifted from C++ and Rust to be honest) to put trailing default values in the struct header could guarantee concrete types for ::SubString{String} without the aforementioned limitation on type bounds.

  2. While longer to write, SubString{<: AbstractString, UnitRange{Int}} is a valid constructor for the attempted SubString{UnitRange{Int}} method. If repeating that type bound is too annoying to maintain, then my more serious suggestion of a generic shorthand for the definition’s type bound e.g. SubString{:, UnitRange{Int}} would work just as well while preserving the explicit representation of specifying the 2nd positional parameter.

  3. I don’t have an example for the 3rd motivating problem because I do not understand what asymmetry the invalid/shorthand syntax there is representing, just didn’t want to leave it out entirely.

I think I have a realistic motivating issue for this topic. In DataStructures.jl, the sorted set container has two type arguments: SortedSet{Keytype, Ordertype}. Some years ago, someone asked me if it is possible to recover the position of an item in the set efficiently. In other words, the data item in the set with lowest key has position 0, the next key has position 1, etc.

In the current implementation of SortedSet, this cannot be done efficiently; the only means to determine the position is to count from 0, so that the time to determine the position of the item in position p is O(p). Suppose the data structure is augmented with an additional field: each internal tree node stores a counter of the number of data items in the subtree rooted at that node. This additional field makes it possible to determine positions in time O(log n), where n is the total number of items in the SortedSet.

But maintaining these counters adds storage and slightly slows down all operations on the SortedSet, and few users actually need the position operation, so I wouldn’t want it to be the default. So then I thought of adding another parameter to the type: SortedSet{Keytype, Ordertype, SaveCounters}. If SaveCounters is false, then the counters and the code for updating the counters is elided at compile time and there is no performance penalty. But the catch is that this change to the SortedSet API would mean that all users with existing codes that use SortedSet{Keytype, Ordertype} suddenly have a UnionAll type when previously they had a concrete type.

For this reason (and also a lack of time!) I never implemented the position function.

Overlapping rules are where the author needs to think the cases through, exactly as with overlapping methods. The same holds for positional parameters with overlapping bounds, where the user can get the order wrong, including with positional default values. Defaults are probably the better choice for simple cases, but the user needs to pay attention either way.

Also, nobody needs to “enable” dispatch for all parameter combinations. As with functions, the author can define type constructors only for selected combinations.

One detail I should add: a TypeVar argument selects the type constructor by its bound, so SubString{T} where T <: AbstractString is well-defined. In your overlapping example this means SubString{String} is not an instance of SubString{T} where T <: AbstractString. That follows from the overlapping bounds the author chose.

Mass text substitution is a good solution in some cases. But there are cases where it is not seen as a good option and theses cases are well expressed by semver. Although I think that the power of LLMs already makes text substitution the better solution in more cases than some years ago and will probably continue to do so, this is a separate discussion. Besides, compatibility is only one of the motivations.

SubString{<: AbstractString, UnitRange{Int}} is a valid constructor for the attempted SubString{UnitRange{Int}} method.

Yes, it is, but it is not the short version I was referring to, and the difference grows with the number of parameters.

SubString{:, UnitRange{Int}} would work just as well

In this case, yes. But it cannot keep old spellings valid when the author reorders parameters in the definition. Keyword parameters like SubString{R = String} would handle the order, but not choosing the role from bounds, validating or computing parameters, or defaults that depend on other parameters.

I don’t have an example for the 3rd motivating problem because I do not understand what asymmetry the invalid/shorthand

It’s that one of the two single-parameter UnionAlls can only be constructed with a where.

Indeed, the limitation would only be relevant for the parameters shuffled by the custom type-methods.

Well no, a type is an instance of a handful of metatypes. It does subtype whether we stick to Julia’s current UnionAll shorthand or dispatch the input String to either custom method.

julia> SubString{String} <: SubString{T} where {T<:AbstractString}
true

julia> SubString(String) <: SubString{T} where {T<:AbstractString}
true

julia> (@invoke SubString(String::Type{T} where T<:Union{AbstractString})) <: SubString{T} where {T<:AbstractString}
true

I can’t tell what you might be alluding to here.

Agreed, I think it’s best to design a language that doesn’t depend on an LLM for writing it. Seems like putting the cart before the horse.

What old spellings exactly? The original single-parameter SubString could never take a UnitRange{Int} parameter, that’s something you’re trying to introduce along with the 2nd parameter. If you mean that you would like the author to swap T and R in the definition further down the road without breaking code, it sounds like trying to make an API order and internal order of parameters at once. I don’t really see a good reason to do that, but we can already make a public wrapper type or a type alias that reshuffles an internal type’s parameters for backwards compatibility.

Still a bit lost on this one. Are these two single-parameter UnionAlls intended to be SubString{<:AbstractString, X} and SubString{Y, <: AbstractUnitRange{<:Integer}} where X and Y are concrete types? That looks symmetric to me. Or do you mean we can only asymmetrically omit trailing parameters as shorthand for the one type SubString{<: AbstractString, <: AbstractUnitRange{<:Integer}}, hence SubString{<:AbstractString} == SubString?

Right, I meant subtype. Your check runs in today’s Julia, though, where SubString{T} still leaves R free. Under the proposal with your overlapping bounds, SubString{T} where T <: AbstractString would be SubString{T, UnitRange{Int}} where T <: AbstractString, while SubString{String} would be SubString{<:AbstractString, String}. So the subtype relation would not hold.

Yes, the possibility of decoupling the two is intended, just as a method signature is independent of a struct’s field order. Note, that having the possibility does not mean that it is always a good idea. But it’s nice to be able to when it is useful.

Yes, that’s what I meant. SubString{String} fixes T alone, while fixing R alone requires spelling out T’s bound.

Interesting, I haven’t considered that at all. I had assumed that all the methods would only take known types for T or R, not type bounds; likewise, the method analog was fundamentally incapable of taking type bounds as inputs ie SubString(<:AbstractString). This is a new layer on top of the familiar-ish rules of method dispatch.

So what you’re saying is that SubString{<:AbstractString} would dispatch to the 1st method to output SubString{<:AbstractString, UnitRange{Int}} in either the original or overlapping examples, and we expect SubString{String} to dispatch to the 2nd method to output SubString{<:AbstractString, String} in the overlapping example, therefore breaking the SubString{String} <: SubString{<:AbstractString} relation prior to either example’s edit.

This is essentially the same concern I raised about the proposed type-multimethods changing poisitions of the input parameters, but involving UnionAlls in these methods introduces at least one other breaking change, which is unfortunately another good reason to reject the feature. Again, it is still true that developers could adhere to some strict limitations on new type parameters or type-methods to avoid the sometimes-breaking issues, but I still believe it’s too risky and that the motivating problems could be handled in other ways (and the first one had been in other languages).