I’ve been thinking about Julia’s type system design, particularly how it uses nominal typing rather than structural typing or a trait-based system. I’m curious about the historical and technical reasons behind this choice, and what challenges might arise if Julia had gone with a more trait-based approach.
To illustrate my question, here’s a classic example in current Julia using nominal typing:
abstract type Pet end
struct Dog <: Pet
name :: String
end
struct Cat <: Pet
name :: String
end
function encounter(a::Pet, b::Pet)
verb = meets(a,b)
println("$(a.name) meets $(b.name) and $verb")
end
meets(a::Dog, b::Dog) = "sniffs"
meets(a::Dog, b::Cat) = "chases"
meets(a::Cat, b::Dog) = "hisses"
meets(a::Cat, b::Cat) = "slinks"
fido = Dog("Fido")
rex = Dog("Rex")
whiskers = Cat("Whiskers")
spots = Cat("Spots")
encounter(fido, rex)
encounter(fido, whiskers)
encounter(whiskers, rex)
encounter(whiskers, spots)
I’m wondering how this might look in a hypothetical trait-based version of Julia. Here’s what I envision:
trait Pet begin
name(a::Self)::String
end
# Self refers to the type implementing the trait
trait Meetable{T} begin
meets(a::Self, b::T)::String
end
# trait for pets that can meet each other, <: means that MeetablePet requires all the functions defined in Meetable and Pet traits
trait MeetablePet{T} <: Meetable{T} + Pet where T<:Pet
struct Dog
name::String
end
name(d::Dog) = d.name
struct Cat
name::String
end
name(c::Cat) = c.name
meets(a::Dog, b::Dog) = "sniffs"
meets(a::Dog, b::Cat) = "chases"
meets(a::Cat, b::Dog) = "hisses"
meets(a::Cat, b::Cat) = "slinks"
# this type constraints guarantee that a and b are pets that can meet each other
function encounter(a::MeetablePet{T}, b::T) where T <: Pet
verb = meets(a, b)
println("$(name(a)) meets $(name(b)) and $verb")
end
fido = Dog("Fido")
rex = Dog("Rex")
whiskers = Cat("Whiskers")
spots = Cat("Spots")
encounter(fido, rex)
encounter(fido, whiskers)
encounter(whiskers, rex)
encounter(whiskers, spots)
This trait-based approach is very expressive and replaces the need for abstract types entirely. Here’s a more complex example showing how it might work with mathematical concepts:
trait Field begin
+(a::Self, b::Self)::Self
*(a::Self, b::Self)::Self
-(a::Self)::Self
/(a::Self, b::Self)::Self
inv(a::Self)::Self
zero(::Type{Self})::Self
one(::Type{Self})::Self
end
trait VectorSpace{F <: Field} begin
+(a::Self, b::Self)::Self
*(f::F, v::Self)::Self
-(v::Self)::Self
zero(::Type{Self})::Self
end
trait InnerProductSpace{F} <: VectorSpace{F} where F <: Field begin
dot(a::Self, b::Self)::F
end
# Example implementation for Float32
primitive type Float32 32 end
+(a::Float32, b::Float32) = Base.add_float(a, b)
-(a::Float32) = Base.neg_float(a)
*(a::Float32, b::Float32) = Base.mul_float(a, b)
inv(a::Float32) = Base.inv_float(a)
zero(::Type{Float32}) = Float32(0.0)
one(::Type{Float32}) = Float32(1.0)
# Float32 is a field, also it is a vector space over itself, if we define dot it will be an inner product space as well
dot(a::Float32, b::Float32) = a * b
# Example implementation for Vec2
struct Vec2{F} where F <: Field
x::F
y::F
end
+(a::Vec2{F}, b::Vec2{F}) where F <: Field = Vec2(a.x + b.x, a.y + b.y)
*(f::F, v::Vec2{F}) where F <: Field = Vec2(f*v.x, f*v.y)
*(v::Vec2{F}, f::F) where F <: Field = f*v
-(v::Vec2{F}) where F <: Field = Vec2(-v.x, -v.y)
zero(::Type{Vec2{F}}) where F <: Field = Vec2(zero(F), zero(F))
# Vec2 is a vector space over F, if we define dot it will be an inner product space as well
dot(a::Vec2{F}, b::Vec2{F}) where F <: Field = a.x*b.x + a.y*b.y
This would allow for very generic code as in the current version of julia but it is much more clear what the concrete type has to do to be acceptable by the function. For example, in the current version of julia it is impossible to understand what is expected of the nominal abstract type Number
.
function do_computation(v1::V1, v2::V2)::F where {V1 <: InnerProductSpace{F}, V2 <: InnerProductSpace{F}, F <: Field}
return v2 * (dot(v1, v1) / dot(v2, v2))
end
# Works with different types implementing the same traits
do_computation(Float32(2.43), Float32(1.24))
do_computation(
Vec2(Float32(2.43), Float32(1.24)),
Vec2(Float32(1.0), Float32(2.0))
)
Questions:
- What were the main technical challenges that led Julia to choose nominal typing over structural typing/traits?
- Would a trait-based system like the one shown above introduce performance penalties compared to the current system?
- Would multiple dispatch work differently with traits vs. the current system?