Classes and Objects
Classes and Objects
Morpho, in contrast to many dynamic languages, is strongly oriented
towards object oriented programming (OOP). The central idea behind OOP
is to encapsulate related data or properties into packages called
objects that also supply a collection of actions or methods that can
be performed on them. Methods are much like functions they have
parameters and return values except they are always called with reference
to a particular object. A method call is specified in Morpho using the
. operator:
var a = [1,2,3]
print a.count()
Here, the count method returns the number of entries in a List. Many
Morpho objects provide the same method. The left hand operand of the
. operator is called the receiver of the call and the label count
is called the selector. Like functions, method calls can have
parameters and return values.
To define new object types, use the class keyword. A class provides
the definition of an object type in that it comprises the methods
available to the object; objects themselves are instances of a
particular class and are made using a constructor. Let's create a
simple Pet class with two methods, init and greet:
class Pet {
init(name) {
self.name = name
}
greet() {
print "You greet ${self.name}!"
}
}
To create a Pet instance, we write a constructor, which uses the
same syntax as a function call.
var whiskers = Pet("Whiskers")
Since Morpho classes generally begin with capital letters although the language doesn't enforce this explicitly you can usually spot constructors easily in code.
The init method that we defined in Pet is special: if provided, it's
called immediately after the object is created, before the
constructed object is returned to the user, and is intended to prepare
the object for use. Here, we store the provided name in the object's
name property. Note that the init method must not return a
value; if you try to use return in its definition, the compiler will
throw an InitRtn error. It's also a good idea to avoid complex code in
the init method; if your object requires significant setup, or is
complicated to create, consider using the Builder pattern described in
Section Builder.
Classes are themselves objects in Morpho; it's occasionally useful to call methods on a class rather than on an instance.
Inheritance
A key goal of object oriented programming is reuse of code. In
class-based languages like Morpho, you can create a new class that
reuses the methods provided by a previously defined class using the is
keyword; the prior class is called the parent of the new class, which
becomes its child. Here, the Cat class inherits from Pet:
class Cat is Pet {
hiss() {
print "${self.name} hisses!"
}
}
Any methods defined in the parent class are copied into the child class,
so Cat acquires init from Pet, but also defines a new method
hiss.
Multiple inheritance
A programmer may wish to make a class by combining unrelated
functionality from different classes, a design strategy known as
composition. If classes only inherit from one parent a paradigm called
single inheritancethis is challenging and requires special techniques
to overcome the limitation. To support composition, Morpho classes can
inherit from multiple parents. Let's create a class, Walker, that
describes something that can walk.
class Walker {
walk() {
print "${self.name} walks!"
}
}
We can define a Dog class by composing Pet and Walker
class Dog is Pet with Walker {
bark() {
print "${self.name} barks!"
}
}
The Dog class inherits init from Pet and walk from Walker.
If two parents (or their parents) provide the same method, there is a special mechanism called #method-resolution that determines which implementation is actually inherited by a class; we'll describe it in section Method resolution below.
Multiple dispatch
Like functions, methods utilize multiple dispatch: You may define multiple implementations that accept different types of argument. In languages like C++, similar functionality is provided by overloading, multiple dispatch generalizes this idea.
This sketch implementation of a class provides different services for various kinds of Pet:
class PetHotel {
lodge(Dog x) {
print "${x.name} gets a private room!"
}
lodge(Cat x) {
print "${x.name} is at home in the Cattery!"
}
lodge(Pet x) {
print "Unfortunately, we lack the facilities to look after ${x.nam}."
}
lodge(x) {
Error("PetRqd", "This hotel is for Pets").throw()
}
}
As described in Section Multiple dispatch for functions, when the lodge
method is called the correct implementation is selected at runtime. The
most specific implementation is the one selected: If lodge is called
with a Dog or Cat, the first or second implementation is used
respectively. If another kind of Pet including subclasses that the
implementors of PetHotel don't know about when the class was defined is
used, the third implementation of lodge is able to provide a
user-friendly message. Any other kind of value triggers the fourth
implementation, which raises an error. Multiple dispatch uses the entire
signature of a method, i.e. all positional arguments, to select the
correct implementation and not just one parameter as in some languages.
Method resolution
If more than one parent or ancestor classes provide methods with the same name, the following rules apply:
-
Priority. Method implementations have priority given by a linearization of the class structure; i.e. the compiler computes a ordering of the parent classes consistent with ordering relationships expressed in the class definitions. For each class definition, priority of the parents is given left to right, so the direct parent given after
ishas priority over those parents specified bywith, which then take priority in the order given. The algorithm used is called C3 linearization[^1], and for our simpleDogexample yields the orderingDog, Pet, Walker: Methods inDoghave priority over those inPet, which have priority over those inWalker. You can obtain the linearization computed by the compiler for a class by calling thelinearizationmethod on the class, e.g.print Dog.linearization()(an example of reflection described in the next section). Not all possible class structures admit a C3 linearization; an error is raised at compile time where a linearization cannot be computed. -
Similarity. Methods with the same name and the same signature are considered to be similar. When assembling a new class's methods from its definition and parents, the implementation whose class that comes first in the priority list is used. Hence, an implementation in
Dogwould replace one inPetorWalker. An error is raised if a single class definition provides two similar implementations.
While these rules may seem complicated, in most cases they correspond to what the programmer expects. Pathological examples certainly exist, and the programmer is advised to be wary of deep inheritance structures. Favor composition over inheritance is a commonly advised design principle.
Reflection
Like many dynamic languages, Morpho provides facilities for code to discover an object's class, properties and methods at runtime. The methods to do so are:
-
clssReturns an object's class. As noted above, classes are themselves objects in Morpho. -
hasTests if an object possesses a property. Either supply a property label as a string, e.ghas("foo")or call with no arguments to get a list of properties. -
respondstoTests if an object provides a method. Either give a method label as a string, e.g.respondsto("foo")or call with no arguments to get a list of methods.