Why create an abstract super type in Julia?
What are abstract types?
Abstract types are nodes in the type hierarchy: they group types together. This allows you to write methods that apply to the whole group of types:
julia> abstract type AbstractFoo end
julia> struct Foo1 <: AbstractFoo end
julia> struct Foo2 <: AbstractFoo end
julia> foo_op(x::AbstractFoo) = "yay!"
foo_op (generic function with 1 method)
julia> foo_op(Foo1())
"yay!"
julia> foo_op(Foo2())
"yay!"
Why are abstract types useful?
Abstract types allow you to separate behavior from implementation. This is critical for performance. When you declare an abstract supertype, you automatically inherit the core behavior of the supertype, but are free to implement more efficient implementations of that behavior.
A common example is the AbstractArray
abstract type. It represents the ability to access individual elements of some multi-dimensional collection of elements. Given some problem, we can usually choose a subtype of abstract arrays which will yield efficient operations: the additional constraints on a subtype constitute information that the programmer can leverage to make certain operations more efficient.
For example, say we want to find the sum of 1..N. We could use an array of integers, but this would be very inefficient compared to a UnitRange
. The choice of UnitRange
encodes information about the characteristics of the data; information we can leverage for efficiency. (See this answer for more information on this example).
julia> using BenchmarkTools
julia> @btime sum($(1:1000_000))
0.012 ns (0 allocations: 0 bytes)
500000500000
julia> @btime sum($(collect(1:1000_000)))
229.979 μs (0 allocations: 0 bytes)
500000500000
BitArray
provides space efficient representations for an array of booleans, SparseArrays
provides efficient operations for sparse data, and so on. If you have some data that generally behaves like an abstract array, but has unique characteristics, you can define your own subtype.
This pattern generalizes to other abstract types. Use them to group different implementations of some shared behavior.
A much more practical use case is to create strongly typed, potentially mutually recursive structs. E.g., you could not write the following:
struct Node
edges::Vector{Edge}
end
struct Edge
from::Node
to::Node
end
One way to write this is the rather artificial
abstract type AbstractNode end
abstract type AbstractEdge end
struct Node{E<:AbstractEdge}
edges::Vector{E}
end
struct Edge{N<:AbstractNode}
from::N
to::N
end
Often, with enough experience, this problem will be solved naturally already during the design of a data structure, as in the following:
abstract type Program end
abstract type Expression <: Program end
abstract type Statement <: Program
struct Literal <: Expression
value::Int
end
struct Var <: Expression
name::Symbol
end
struct Plus <: Expression
x::Expression
y::Expression
end
struct Assign <: Statement
var::Var
expr::Expression
end
struct Block <: Expression
side_effects::Vector{<:Program}
result::Expression
end
This makes sure that Expression
s (things that evaluate to numbers) and Statement
s (things that are only side-effects) are properly separated -- you never can create invalid programs like 1 + (x = 2)
. And it could not be written without abstract types (or mutually recursive types, but they currently do not exist).
From the documentation at https://docs.julialang.org/en/v1/manual/types/:
"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. While this might at first seem unduly restrictive, it has many beneficial consequences with surprisingly few drawbacks. It turns out that being able to inherit behavior is much more important than being able to inherit structure, and inheriting both causes significant difficulties in traditional object-oriented languages...
So, what you were seeing when you saw " <: when creating a struct" was an example of subclassing the struct from an abstract type, usually in order to use other functions (methods) of that type with the new struct. That is, the code was not creating an abstract supertype. The <: meant that they were creating a concrete type but deriving it (with the <:) from a previously stated abstract type (that would have been to the right of the <: in that case).