Understanding UnionAll types

Reading through the documentation on constructors, I came across this example.

struct OurRational{T<:Integer} <: Real
    num::T
    den::T
    function OurRational{T}(num::T, den::T) where T<:Integer  # why is this UnionAll necessary?
        if num == 0 && den == 0
            error("invalid rational: 0//0")
        end
        g = gcd(den, num)
        num = div(num, g)
        den = div(den, g)
        new(num, den)
    end
end

The documentation explains " The first line – struct OurRational{T<:Integer} <: Real – declares that OurRational takes one type parameter of an integer type, and is itself a real type."

Can someone break down the signature of the inner constructor in a similar manner? I’m particularly thrown off by the need for a UnionAll type here.

As I currently understand it, the where T<:Integer means we are defining several OurRational{T}(num::T, den::T) one for each subtype of Integer. Why is it not possible to instead do something like function OurRational{T<:Integer}(num::T, den::T) without the UnionAll? When I do so, I get an error saying there are too few type parameters specified in new{...}, so I edit that line to read new{T}(num, den), which results in a “T is not defined” error.

In older versions of Julia, we had a syntax like what you’re suggesting (OurRational{T<:Integer}(...), but it came with a significant problem: there was no obvious way to distinguish parameters of a function (which are just restrictions on the argument type) from explicit parameters of a type specified by a user. This is easier to explain with an example. Let’s say you defined a method using the old syntax:

function foo{T <: Integer}(x::T)
  x + 1
end

If foo is a function, then this defines a method which a user might call using foo(1). But what if foo is actually a type? Then this is a constructor, but is it one you’d call using foo{Int}(1) ? Or is it one you’d call with foo(1) ? I honestly don’t remember which way it used to work, and I think it changed at least once. Both of those cases had what you would now call a UnionAll: there is some parameter T with some restrictions on its type, and the specific type is determined from the arguments (or maybe from the explicit Int in foo{Int}(1), hence the confusion).

The with keyword doesn’t really change the fact that there is a type parameter, it just makes it much more explicit what’s going on (now we have a name for this behavior and can therefore control it). This allows us to write:

# In this case, the `T` is specified by the user 
# at the call site: foo1{T}(1)
function foo1{T}(x::T) where {T} 
  ...
end

and have it actually be different from:

# In this case, the `T` is not specified by the user
# at the call site: foo2(1)
function foo2(x::T) where {T}
  ...
end

The fact that you’re looking at an inner constructor is now almost completely irrelevant. You would write exactly the same function as an outer constructor, except that you wouldn’t be able to call new() directly.

I think part of your question is also why the where {T <: Integer} is not already implied by the {T <: Integer} on the first line. The answer to that is that there’s no assumption or requirement in Julia that the parameters of a given constructor will be the same as the parameters of the type itself. In your case, there’s just one parameter and it’s obviously the same as the parameter of the type, but that’s not generally true. For example, the Array{T, N} type has two parameters (the element type and the number of dimensions), but it has constructors that take 0, 1, or 2 parameters.

4 Likes

Really appreciate the detailed answer. The examples especially cleared up a lot of confusion.