Simple question about Julia types

Hi!

Given the following statements are true:

julia> Matrix <: AbstractMatrix
true

julia> Float64 <: Number
true

why is it that

julia> Matrix{Float64} <: AbstractMatrix{Number}
false

?

Float64 is not Number. It’s a subtype of Number.

julia> Matrix{Float64} <: AbstractMatrix{Float64}
true

julia> Matrix{Float64} <: AbstractMatrix{<:Number}
true
2 Likes

Thanks, @mkitti, for your quick response!

For more context, there is this part of the manual:

https://docs.julialang.org/en/v1/manual/types/#man-parametric-composite-types

Terms to research this more generally as a topic in computer science is invariant, covariant, and contravariant types: Covariance and contravariance (computer science) - Wikipedia

My superficial understanding is that this subtyping rule is necessary in order to have reasonably fast type inference.

4 Likes

Thanks a lot, @Krastanov, for those specific pointers to the documentation.

Well, there’s code that works when passed Matrix{Number} but fails for Matrix{Float64}. So it doesn’t make lots of sense to make Matrix{Float64} <: Matrix{Number}.
Simple example of such code: f(A::Matrix{Number}) = A[1] = big"10"^1000.

3 Likes

The Julia docs seem rather clear in that the main reason for invariance is mutable types:

This last point is very important: even though Float64 <: Real we DO NOT have Point{Float64} <: Point{Real}.

In other words, in the parlance of type theory, Julia’s type parameters are invariant, rather than being covariant (or even contravariant). This is for practical reasons: while any instance of Point{Float64} may conceptually be like an instance of Point{Real} as well, the two types have different representations in memory:

  • An instance of Point{Float64} can be represented compactly and efficiently as an immediate pair of 64-bit values;
  • An instance of Point{Real} must be able to hold any pair of instances of Real. Since objects that are instances of Real can be of arbitrary size and structure, in practice an instance of Point{Real} must be represented as a pair of pointers to individually allocated Real objects.

Indeed, tuples – which are immutable – are covariant, e.g., Tuple{Float64} <: Tuple{Number} is true.

3 Likes

Nothing in those quotes from the docs refers to mutability. If it were possible to restrict subtypes of Real to only immutable types (i.e. struct instead of mutable struct), then the arguments in the docs around practicality would still apply.

One way to understand that Point{Float64} is not a subtype of Point{Real} is to note that Point{Real} is a concrete type, and concrete types cannot be subtyped. Of course, that begs the question, “How come concrete types can’t be subtyped?” Also, that argument does not apply to AbstractMatrix{Number}, since AbstractMatrix{Number} is not a concrete type.

So, it seems the best conceptual way to understand why Foo{Float64} is not a subtype of Foo{Real} is the observation pointed out by @aplavin: There are methods that work on Foo{Real} but not on Foo{Float64}, even when Foo is immutable. Here’s an example:

struct Foo{T}
    x::T
end

bar(foo::Foo) = typeof(foo)(big(foo.x)^100)
julia> bar(Foo{Real}(2))
Foo{Real}(1267650600228229401496703205376)

julia> bar(Foo{Int}(2))
ERROR: InexactError: Int64(1267650600228229401496703205376)

However, I’m not sure if that’s the best example, since it relies on introspection of the type of foo via typeof. Thoughts?

2 Likes

You’re right, there is no explicit reference to mutability. It was my interpretation/assumption that the memory layout should just play a role if mutating a data structure, i.e., trying to assign an object with Point{Real} layout to a place with an Point{Float64} layout.

In the end, the variance of data types is a design decision of the language effecting which invariants – in the sense of laws – can be assumed about functions. In particular, in functional languages such as Haskell the types provide a lot of information on what a function can or cannot do:

generic :: [a] -> [a]
# Fully generic type, i.e., valid for all `a`
# => Function can at most change the list structure, but not look at elements!

parametric :: Show a => [a] -> String
# Function can assume/call `show` function on elements, but nothing else
# => will also work on all subtypes, i.e., with more specific constraints than `Show`. 

Dynamic languages are less stringent and especially with introspection – as in your example – any type assumptions can be broken. Guess this might be another of several reasons why invariance was chosen as the save default in Julia.
Interestingly tuples are an exception, such that myfun(x::Real, y::AbstractString) is implicitly considered the same as yfun(x::T, y::S) where {T<:Real, S<:AbstractString} saving a bit of thought and some characters of code. As far as I know there is also no alternative syntax, to force invariance in this case. Similarly, with abstract parametric types I would be curious about a real-world example where myfun(x::AbstractVector{Real}) and maybe further methods are needed instead of the more generic myfun(x::AbstractVector{T}) where {T <: Real}.

Here’s a less introspective example, though it’s more about containers than methods:

julia> struct Foo{T}
           x::T
       end

julia> x = Foo{Real}[Foo(3.0)]
ERROR: MethodError: Cannot `convert` an object of type
  Foo{Float64} to an object of type
  Foo{Real}

That is, Foo{Float64} doesn’t fit into a Foo{Real}-typed array.


But then what about tuples?

julia> x = Tuple{Real}[(3.0,)]
1-element Vector{Tuple{Real}}:
 (3.0,)

I suppose this works because Tuple{Real} is an abstract type, so under the hood Vector{Tuple{Real}} holds pointers like any other array with abstract eltype.

julia> isconcretetype(Tuple{Real})
false

julia> Tuple{Real} == Tuple{<:Real}
true
3 Likes

Tuples are covariant unlike everything else for mostly historical reasons.

2 Likes

I believe it’s mostly because Tuple is used by type system itself (i.e function call argument types are put in a Tuple

3 Likes