traits and implementations
α2traits and implementations
ferrule has no trait keyword. traits are record types. impl registers canonical implementations. the compiler resolves automatically when unambiguous.
why no trait keyword
record types already describe interfaces:
type Eq<T> = { eq: (T, T) -> Bool }adding a trait keyword would create two ways to say the same thing. records-as-traits are simpler, composable with &, and work with ferrule's existing type system.
defining interfaces
type Eq<T> = { eq: (T, T) -> Bool }
type Hash<T> = { hash: (T) -> u64 }
type Show<T> = { show: (T) -> String }
type Ord<T> = { compare: (T, T) -> Ordering }these are just record types. nothing special about them.
implementing interfaces with impl
impl Eq<UserId> {
eq: function(a: UserId, b: UserId) -> Bool { return a.id == b.id },
}
impl Hash<UserId> {
hash: function(u: UserId) -> u64 { return u.id },
}
impl Show<UserId> {
show: function(u: UserId) -> String { return fmt"User({u.id})" },
}impl registers the canonical implementation of a record type for a given type. under the hood, impl Eq<UserId> { ... } creates const UserId.Eq: Eq<UserId> = { ... }.
automatic resolution in generic functions
function dedupe<T>(items: View<T>) -> Vec<T>
where T impl Eq & Hash
effects [alloc]
{
// compiler resolves T.Eq and T.Hash automatically
}
// clean usage:
const unique = dedupe(users)the where T impl Eq & Hash constraint means: T must have impl Eq<T> and impl Hash<T> registered.
explicit override when needed
const unique = dedupe(users, with Hash = caseInsensitiveHash)when the automatic resolution isn't what you want, pass an explicit implementation.
derive generates impl blocks
type Point = derive(Eq, Hash, Show) {
x: f64,
y: f64,
}
// generates: impl Eq<Point>, impl Hash<Point>, impl Show<Point>derive uses comptime introspection to generate implementations.
combining interfaces
use intersection types:
type Hashable<T> = Eq<T> & Hash<T>
type HashShow<T> = Hashable<T> & Show<T>effect-aware traits
trait methods can declare effects:
type Serialize<T> = {
serialize: (T, Writer) -> Unit effects [fail<EncodeError>],
}
type Deserialize<T> = {
deserialize: (Reader) -> T effects [alloc, fail<DecodeError>],
}
impl Serialize<User> {
serialize: function(u: User, w: Writer) -> Unit effects [fail<EncodeError>] {
check w.write_string(u.name)
check w.write_u32(u.age)
},
}no other systems language has effect-tracked traits.
no orphan rules
you can impl anything for anything in your own module. if two modules provide conflicting impls for the same type + record, it's a compile error at the use site. you must disambiguate.
explicit passing still works
for cases where you want multiple implementations or don't want automatic resolution:
function dedupe<T>(items: View<T>, h: Hashable<T>) -> Vec<T> effects [alloc] {
// explicit, like the old way
}
const unique = dedupe(users, UserId.hashable)both styles coexist. impl + automatic resolution is sugar, not a replacement.
comparison to other languages
| language | approach | ferrule difference |
|---|---|---|
| rust | trait keyword + impl blocks | no separate trait keyword, records ARE traits |
| go | structural interfaces | ferrule is nominal, requires explicit impl |
| haskell | type classes | similar concept, but no orphan rules, effect-aware |
| zig | no traits, explicit passing | ferrule adds impl sugar on top of explicit passing |