5.2. Overload terms with typeclasses

A typeclass lets one name have a different implementation for each type it is applied to. Where term polymorphism gives the compiler a free choice between interchangeable definitions, a typeclass instance is selected by the type at the call site. The idea is the same as typeclasses in Haskell, traits in Rust, interfaces in Java, and concepts in C++.

A class declares method signatures. An instance supplies the implementations for one type:

pretty.loc
module main (describeInts, describeReals)

import root-py

class Pretty a where
    pretty :: a -> Str

instance Pretty Int
       , Pretty Real where
    source Py from "ops.py" ("to_str" as pretty)

title :: Pretty a => a -> Str
title x = "value: " <> pretty x

describeInts :: [Int] -> [Str]
describeInts = map title

describeReals :: [Real] -> [Str]
describeReals = map title
ops.py
def to_str(x):
    return str(x)
$ morloc make -o pretty pretty.loc
$ ./pretty describeInts '[1,2]'
["value: 1","value: 2"]
$ ./pretty describeReals '[1.5]'
["value: 1.5"]

Three things in that module are worth naming.

One instance may cover several types. instance Pretty Int , Pretty Real where declares two instances that share a body. Python’s str handles both, so writing the source line twice would be noise. The standard library uses this form heavily — root-py declares a dozen RealLike instances in one block.

A signature may carry a class constraint. `title

Pretty a ⇒ a → Str` says title works for any type that has a Pretty instance. Everything to the left of is a constraint; multiple constraints are comma-separated and

parenthesized, as in `root’s `sum

(Foldable f, Integral a) ⇒ f a → a`.

A class body holds signatures only. Morloc has no default method implementations. Writing a body inside a class block is a parse error:

$ morloc typecheck dm.loc
dm.loc:6:16: unexpected identifier 'xs'
    |
  6 |     prettyList xs = "list"
    |                ^
  expected '::'

Put the shared logic in an ordinary constrained function instead, the way title does above.

Important
A generic function cannot be an entry point

An exported term whose type still has a class constraint is dropped from the generated program, because the compiler cannot pick an instance without a concrete type. Export title directly and it does not become a command:

$ morloc make -o generic generic.loc
Warning: skipping generic export 'title'
$ ./generic title 1
error: unexpected argument 'title' found
...

Export a monomorphic wrapper instead — describeInts and describeReals above — and keep the generic function internal.

5.2.1. One class, many languages

An instance may source implementations from several languages at once. The compiler then has a choice of instance bodies for the same method, and the usual collapse applies: it takes whichever one keeps the program in one language.

class Addable a where
    zero :: a
    (+) :: a -> a -> a

instance Addable Int where
    source Py from "arithmetic.py" ("add" as (+))
    source Cpp from "arithmetic.hpp" ("add" as (+))
    zero = 0

instance Addable Real where
    source Py from "arithmetic.py" ("add" as (+))
    source Cpp from "arithmetic.hpp" ("add" as (+))
    zero = 0.0

The native functions may be polymorphic in their own language, in which case the same implementation is named by several instances. The Python add above is one function:

arithmetic.py
def add(x, y):
    return x + y

And so is the C++ one:

arithmetic.hpp
template <class A>
A add(A x, A y){
    return x + y;
}

A method does not have to come from a foreign language. zero = 0 is an ordinary Morloc definition, and it is polymorphic in the same way any other term is: zero in the Int instance is the integer literal, zero in the Real instance is the floating-point one.

Warning
This example collides with root

Addable redeclares zero and (+), which root already supplies through its Integral class. Two classes cannot define the same term, so this module compiles only in isolation — with import internal for the primitive types, not import root-py:

$ morloc typecheck main.loc
In module 'main': The typeclasses 'Integral' and 'Addable' have conflicting definitions of the term 'zero'

5.2.2. Superclasses

A class may require another class. Write the requirement to the left of in the class head:

class Pretty a => Boxed a where
    box :: a -> Str

Any type with a Boxed instance must also have a Pretty instance, and a function constrained on Boxed a may use pretty as well as box. This is how root layers its numeric hierarchy: class Integral a ⇒ Numeric a means every Numeric type is also Integral.

5.2.3. Importing a class from another module

A class is exported and imported by its name. Its methods are not separately importable and may not appear in an export list:

numops/main.loc
module numops (Pretty, exclaim)

import root

class Pretty a where
    pretty :: a -> Str

exclaim :: Pretty a => a -> Str
exclaim x = pretty x <> "!"

Listing pretty in that export list gives Module '.numops' does not export the following terms or types: [pretty], which is confusing but means what it says: a method has no standalone identity to export.

Importing the class name is enough to declare instances for it elsewhere:

main.loc
module main (shout)

import root-py
import .numops (Pretty, exclaim)

instance Pretty Int where
    source Py from "ops.py" ("to_str" as pretty)

shout :: Int -> Str
shout = exclaim
$ morloc make -o prog main.loc
$ ./prog shout 7
"7!"

This is the shape every language-specific standard library module takes. root declares Eq, Ord, Functor, Foldable and the rest; root-py, root-cpp and root-r import those names and fill in instances. Nothing in root knows which languages exist.