-
Notifications
You must be signed in to change notification settings - Fork 3
Classes
A concept of classes in Ela is similar to the concept of typeclasses in Haskell.
A class in Ela is a class of operations and can be seen as an abstraction over types. In fact, most of standard functions and operators from prelude are members of classes. However, classes in Ela do not directly correspond to classes in object oriented languages. Class in Ela is a set of global functions which perform similar operations (such as comparison operations).
Major difference between regular functions and class functions is that run-time environment allows for a class function to have several implementations. A required "overload" is determined upon a run-time type (which is important to understand for those who are familiar with typeclasses concept in Haskell as long as this is the major difference).
Classes in Ela can be compared with interfaces in object oriented languages (such as C#) as they do share with interfaces a number of peculiarities. A class (as well as an interface) doesn't provide an implementation of its members. Also a single type can implement instances for several classes.
However unlike object oriented languages, where you have to implement all interfaces at the point when you define your type, and you cannot implement interfaces for the types that are already defined, declaration of a type and declaration of a class instance in Ela may not be done at the same time. For example, you can easily write instances for built-in Ela types.
Another important difference is that unlike interfaces classes are used to define global bindings. A class member in Ela is effectively a global function that is not different from other functions - it is curried and is a first class value:
class Foo a where
foo _->a->_
foo
Dispatch rules are also different. Ela provides a support for only single parameter classes, however, this parameter can appear at any place in a function signature.
In the example above we have defined a function foo
, that accepts any value as its first argument and a specific value (that
can be specified through an instance) as its second argument. Now we can define an instance of this class like so:
instance Foo Int where
foo _ a = a
Here we effectively ignore the first argument of a foo
function. This function is overloaded only by its second argument:
foo "some value" 42
Class member signature is used to provide overload rules for a function. For example, if we define a class Foo
like so:
class Foo a where
foo a->a->_
an application of foo
to string and integer would fail.
Classes in Ela do support dispatch by return type (through contexts, see a section below). Therefore it is possible to use classes to define both functions an polymorphic constants. For example, the folowing code defines a class with polymorphic constant:
class Default a where
default a
A class in Ela is declared using class
declaration that has the following syntax:
"class" ClassName param "where"
(ident|operator) signature
{(ident|operator) signature}
A name of a class shoudl always start with a capital letter.
A where
clause is used to specify members for a class. A class should have at least one member. Class members can be either functions or regular constants.
As it was mentioned in introduction, class members are just ordinary global bindings and comply to the rules of bindings in Ela. Such as the head symbol can be either a valid Ela identifier or an operator symbol. You can also define class members using prefix, postfix and infix form. However, using a pattern instead of a binding head is not allowed and will result in compile time error (but it is perfectly valid to use pattern inside function argument list).
A param
clause should contain a valid Ela identifier. It is used for substitution in function signature. Any valid identifier can be
used here, however, it is recommended to use a name a
as a type parameter.
A signature
clause represents a required class member signature. This signature is used to specify overloading rules for a function.
It has the following syntax:
(_|param) { "->" (_|param) }
A underscore character denotes value of any type, when a param
should be an identifier from the param
clause. As long as
class in Ela is instantiated for one specific type, a param
entry is used to specify occurences of this type in a function
signature.
A signature should define at least one argument. Also a function signature should have at least one occurence of param
entry
(multiple occurences are OK as well). Therefore, a signature a
is valid, as well as a->_
and a->_->_
, but a signature
_
is not valid, and a signature _->_
is not valid as well. A signature a
means a polymorphic constant, a signature a->_
means a function with one argument, and a signature a->_->_
means a function with two arguments, where the first argument is
of our target type.
Here is an example of a custom class declaration:
class Car a where
drive _->a->_
Here we define a Car
class with one function drive
. A drive
function expects a target type as its second argument.
Instances are used to implement a class for a particular type. An instance declaration has the following syntax:
"instance" ClassName TypeName where
binding
{binding}
A name of an instance is composed of a class name and a type name. A where
clause is used to specify implementations of class
functions. This clause should contain regular Ela bindings. Also Ela compiler controls that an instance provides implementations
for all functions of a class. If this is not the case, than a compile time error is generated, unless there is a default instance
for this class (see below for more details).
Let's implement an instance for a class Car
from a previous section.
A Car
class defines a drive
function, that can be used to accellerate our car to a specific speed. However, all cars have certain
speed limits, and, if a limit is reached, than this function would return a SpeedLimit
variant.
Taking that, we can first write a "test drive" function for a car like so:
//This type will be used to present vehicle speed
type Speed = Speed a | SpeedLimit
testDrive n car =
match drive n car with
Speed x = x :: testDrive (n+1) car
SpeedLimit = []
This function pushes a given car to its limits and generates a list with our accelleration history - up to the moment when a speed limit is reached.
Now we can define a custom type SteamCar
, which cannot go faster than 10 miles an hour:
type SteamCar = SteamCar
And here is an instance for this type:
instance Car SteamCar where
drive s _ | s < 10 = Speed s
| else = SpeedLimit
Now we can test drive our new car like so:
testDrive 0 SteamCar
However ten miles an hour is not really fast, so we might wish to implement a faster car like so:
type ElectroCar = ElectroCar
instance Car ElectroCar where
drive s _ | s < 15 = Speed s
| else = SpeedLimit
And test drive it as we did before:
testDrive 0 ElectroCar
Instances can be defined for user types and for built-in Ela types as well. However, it is an error to have two instances of the same class for the same type - even in different modules.
So far we have only discussed class functions that are overloaded by their arguments. However, Ela also supports overloading by return type. It is even possible to use constants as class members.//br As soon as Ela is a dynamic language, its approach to overloading by return type is quite different from static languages. Let's take an example:
class Empty a where
empty a
instance Empty Int where
empty = 0
Here we have a class Empty
that defines a single member - a constant empty
. This constant should be evaluated to an empty
(or default) instance of a type. We have also implemented this class for a 32-bit integer, where this constant evaluates to 0.
A constant empty
is called a polymorphic constant. In order to dispatch such a constant one should specify an explicit context, e.g.:
empty ::: Int
A syntax exp ::: context
is used to specify a context. A context can be specified for a constant or for a function
application.
It is quite similar to the type annotation, but is pretty different from it in many aspects. First, a context can be specified using a type name (short or qualified with a module alias) or can be extracted from a value. For example, this code is equivalent to the code sample above:
x = 42
empty ::: (x)
In order to extract a context from a value, one should enclose this value in parentheses; otherwise, it will be recognized as a type name. Second, if a context is specified for a function application, this context is propagated inside this function - in other words it is passed to this function as an implicit parameter.
Let's take another example:
open core
class Pointed a where
return _->a
instance Pointed Maybe where
return x = Some x
instance Pointed Either where
return x = Left x
Here we have declared a Pointed
class with a single return
function. This function is overloaded by its return type. We
also have instances for types Maybe
and Either
from core
module. This is how these instances work:
return 12 ::: Maybe
and
return 12 ::: Either
But as soon as contexts can be propagated inside functions, it is not always required to specify them explicitly:
getSomeValue x = return x
getSomeValue 12 ::: Maybe
Here we have defined getSomeValue
function that simply calls return
function. You can see that an application of return
function is not annotated. But as long as we annotate an application of getSomeValue
with a context, it is a perfectly
valid code, and return
function is dispatched using type Maybe
.
A single context annotation can be propagated inside an unlimited number of functions. For example, we can transform our previous code sample like so:
calcAndGet f x = getSomeValue (f x)
calcAndGet (*2) 12 ::: Maybe
It is also possible to specify a default context for a current function or for a whole top level, i.e.:
open monad io
::: IO //here we say that a default context for top level is IO monad
_ = do putStrLn "Hello!"
_ = do putStrLn "Bye!"
A default context annotation can be overriden by a specific context annotation.
In some cases it is also possible to infer the context from one of the function arguments, e.g.:
open monad
mfilter p !ma = do
a <- ma
if p a then return a else default
A context can be inferred by applying a so called "bang" pattern to one of the arguments.
In the example above a context is inferred from the ma
argument, therefore the function mfilter
can be used without
explicit annotations at all:
mfilter odd [1,2,3]
and
mfilter odd (Some 2)
Ela also allows to declare default instances. A default instance is an instance without a type specification. For example, this
is how we can define a default instance for a Car
class:
instance Car where
drive _ _ = SpeedLimit
A default instance can have implementations for all of the class function or just for some of them. Implementations of functions from a default instance will be used, when a specific instance doesn't provide implementations for these functions. If a default instance defines all functions from a class, than it is valid to create specific instance with an empty body like so:
instance Car BrokenCar
Alternatively, one can use a deriving
clause and specify instances in a type declaration directly:
type BrokenCar = BrokenCar
deriving Car
A deriving
clause can be used to specify any number of instances.
As with custom types, class names are not unique. It is not allowed to define two classes of the same name in a single module - however, they can perfectly coexist in different modules. Because of that, it is allowed to prefix a class name with a module alias:
open cars //We have cars implementations in this module
open carClass //We have a Car class in this module
instance carClass.Car cars.SteamCar
where drive _ s = ...
It is possible to query which instances are supported by a given value using is
expression:
x is Eq Num Car
Class names can be prefixed with module aliases.