Skip to content

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:

  1. 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 is has priority over those parents specified by with, which then take priority in the order given. The algorithm used is called C3 linearization[^1], and for our simple Dog example yields the ordering Dog, Pet, Walker: Methods in Dog have priority over those in Pet, which have priority over those in Walker. You can obtain the linearization computed by the compiler for a class by calling the linearization method 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.

  2. 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 Dog would replace one in Pet or Walker. 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:

  1. clss Returns an object's class. As noted above, classes are themselves objects in Morpho.

  2. has Tests if an object possesses a property. Either supply a property label as a string, e.g has("foo") or call with no arguments to get a list of properties.

  3. respondsto Tests 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.