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
?
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
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.
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
.
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 havePoint{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 ofPoint{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 ofReal
. Since objects that are instances ofReal
can be of arbitrary size and structure, in practice an instance ofPoint{Real}
must be represented as a pair of pointers to individually allocatedReal
objects.
Indeed, tuples – which are immutable – are covariant, e.g., Tuple{Float64} <: Tuple{Number}
is true.
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?
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
Tuple
s are covariant unlike everything else for mostly historical reasons.
I believe it’s mostly because Tuple is used by type system itself (i.e function call argument types are put in a Tuple