Magpie is a class-based language. That means everything you can stick in a variable will be an object and every object is an instance of some class. Even primitive types like numbers and booleans are full-featured objects, as are functions.
Objects exist to store data, package it together, and let you pass it around. Objects also know their class, which can be used to select one method in a multimethod.
Unlike most object-oriented languages, classes in Magpie do not own methods. State and behavior are not encapsulated together. Instead, classes own state, and multimethods own behavior.
A class has a name and a set of fields, which describe the state the class holds. Unlike other dynamic languages, Magpie requires all of a class's fields to be explicitly declared. They aren't just loose bags of data. You can define a new class using the
defclass Point var x var y end
This declares a simple class
Point, with two fields
Once you've defined a class, you instantiate new instances of it by calling the constructor method
new. The left-hand argument to
new is the class being instantiated, and the right-hand argument is a record. The record has a named field for each field that the class defines. Given our above
Point class, we can create a new instance like this:
val point = Point new(x: 2, y: 3)
Construction actually proceeds in two stages. When you invoke
new(), it creates a fresh object of your class in some hidden location. Then it invokes the
init() multimethod, passing in the same arguments.
init() is responsible for initializing the fields of this hidden instance, but it doesn't return anything. After
init() is done,
new() returns the freshly-created and now fully-initialized object.
When you define a class, Magpie automatically creates a new specialization for
init() that takes your class on the left, and a record of all of its fields on the right. This is called the canonical initializer. You can also provide your own specializations of
init(). Doing so lets you overload constructors.
def (this == Point) init(x is Int, y is Int) this init(x: x, y: y) end
Here we've defined a new
init() method that takes a
y coordinates using a simple unnamed record. We can call it like this:
val point = Point new(2, 3)
When you call
new() it looks for an
init() method that matches whatever you pass to it. In this case,
2, 3 matches our overloaded
init() method. That method in turn calls the canonical or "real" initializer to ensure that all of the class's fields are initialized.
This way, you are free to overload
init() to make it easy to create instances of your classes. The only key requirement is that an
init() method needs to eventually "bottom out" and call the canonical initializer before it returns. If you fail to do that, Magpie will throw an
InitializationError when you try to construct the object.
In the above example, we provide an alternate path for initializing a new object, but we're still creating a new object normally. Sometimes you may need to be more flexible than that. Perhaps you want to cache objects so that calling
new() with certain arguments always returns the same object.
def (this == Point) new(x is Int, y is Int) match x, y case 0, 0 then zeroPoint // Use cached one. else this new(x: x, y: y) end end
As you can see, you can also overload
new() itself. If you do that, you can sidestep the process of creating a fresh instance entirely and return another existing object.
This also gives you the flexibility of creating an instance of a different class than what was passed in. You may want to hide the concrete class behind an abstract superclass, or switch out the concrete class based on some specific data passed in. Overloading
new() gives you this flexibility without having to go through the trouble of implementing your own factory.
Once you have an instance of a class, you access a field by invoking a getter on the object whose name is the name of the field.
val point = Point new(x: 2, y: 3) print(point x) // 2
point x is a call to a method
x with argument
point. As you would expect, it returns the field's value.
Setting a field on an existing object looks like you'd expect:
val point = Point new(x: 2, y: 3) point x = 4 print(point x)
TODO: Explain that this is just a setter and point to assignment page.
When defining a field, you may optionally give it a pattern after the field's name. If provided, then you will only be able to initialize or assign to the field using values that match that pattern.
defclass Point var x is Int var y is Int end
y are now constrained to number values by using
is Int patterns. If you try to construct a
Point using another type, or set a field using something other than an
Int, you'll get an error.
So far, we've seen fields defined using
var, but you can also define them with
val. Doing so creates an immutable field. Immutable fields can be initialized at construction time, but don't have a setter, so they can't be modified.
defclass ImmutablePoint val x is Int val y is Int end var point = ImmutablePoint new(x: 1, y: 2) point x = 2 // ERROR: There is no setter for "x".
Finally, when defining a field in a class, you can give it an initializer by having an
= followed by an expression. If you do that, you won't need to pass in a value for the field when instantiating the class. Instead, it will automatically evaluate that initializer expression and set the field to the result.
defclass Point var x = 0 var y = 0 end
Points will default to be
0, 0. You can create a new one simply by doing:
val point = Point new()
Like most class-based languages, Magpie supports inheritance. You can specify that one class is a child or subclass of another, like so:
defclass Widget val label is String end defclass Button is Widget var pressed? is Bool end
The part after
is in the declaration of
Button specifies its parent class. A child class inherits all of the state of its parent class. So here,
Button will have fields for both
When constructing a new instance of a class, its parent classes also need their constructors to be called so that inherited fields can be correctly initialized. To do this, the canonical initializer is extended to include a field for the parent class.
Button new(Widget: (label: "Play"), pressed?: false)
When the initializer for
Button is called, it takes the value of the
Widget: field and uses that as the argument to
Widget init(...). This way, child classes can invoke overridden parent class initializers, like so:
// Since there's just one field, don't require a record: def (this == Widget) init(label is String) this init(label: label) end // We can then call it through Button: Button new(Widget: "Play", pressed?: false)
If a parent class doesn't have any fields, or has initializers for all of them, you can omit it when constructing the child class. If the parent class has its own parent class, you may need to initialize it too:
defclass CheckBox is Button var checked? is Bool end CheckBox new(Button: (Widget: "45 RPM", pressed?: false), checked?: true)
That's a bit tedious, so you're encouraged to override the initializers for your class to "flatten" that out and hide the parent class initialization.
def (this == Button) init(label is String, pressed? is Bool) this init(Widget: label, pressed? pressed) end def (this == CheckBox) init(label is String, pressed? is Bool, checked? is Bool) // Use the init() for Button we just defined. this init(Button: (label, pressed?), checked?: checked?) end // Use the init() for CheckBox. CheckBox new("45 RPM", false, true)
You can consider canonical initializers to be "raw" initializers that you'll likely hide behind a simpler overridden one.
If you call a multimethod and pass in an instance of a child class, it will look through all of its methods to find a pattern that matches. A type pattern will match against an object of the given class, but it will also match objects of child classes of that class. In that way, methods defined using
is patterns are automatically inherited by child classes.
def (this is Widget) display() print(this label) end val checkBox = CheckBox new("45 RPM", false, true) checkBox display() // Prints "45 RPM".
When multiple patterns match an object, type patterns on child classes take precedence over parent classes. This lets you override a method defined on a parent class.
def (this is CheckBox) display() val check = if this checked? then "[X] " else "[ ] " print(check + this label) end val checkBox = CheckBox new("45 RPM", false, true) checkBox display() // Prints "[X] 45 RPM".
Note that method inheritance like this only works for type patterns. Value patterns (i.e.
== ones) do not match subclasses.
// Given a string like "Play|true", creates a new Button. def (this == Button) fromString(descriptor is String) val parts = descriptor split("|") this new Button(parts, parts true?) end Button fromString("45 RPM|false") // OK. CheckBox fromString("45 RPM|false") // ERROR: No method found.
This more or less follows other languages where "static" or "class" methods are not inherited, just "instance" ones.
Unlike many newer languages, Magpie embraces multiple inheritance. A class is free to have as many parent classes as it wishes:
defclass Container val widgets is List end defclass GroupBox is Widget, Container var selected is Int end
GroupBox inherits from both
Container. This means it gets all of the fields of both of those parents (and their parents). In addition, methods specialized to either of those are valid for
GroupBox objects too. When constructing an object, all of its parent classes must be initialized.
GroupBox new( Widget: "Group", Container: (widgets: ), selected: 0)
Here, we're passing fields for both
Multiple inheritance can add considerable complexity to a language, which is why many shy away from it. Most of that complexity can be traced back to a single corner case: inheriting from the same class twice. For example:
defclass RadioButton is Button, Widget end val group = RadioButton new( Button: ("From Button", false), Widget: "From Widget") print(group label) // ???
Widget along two paths: once from
Button, and once from
Widget directly. Both paths try to initialize the label, one using
"From Button" and one using
"From Widget". How do we decide which one "wins"? Overriding methods have similar ambiguity problems.
To avoid these problems, Magpie has a simple rule: a class may only inherit from some other class once, either directly or indirectly. In other words, there may only be one path from a given child class to a given parent class. If you try to define a class like
RadioGroup above, Magpie will throw
ParentCollisionError at you.
A side-effect of this rule is that Magpie doesn't have a "root" class like
Any in other languages. It doesn't need one. Root classes usually don't have state, and you can define methods that work on objects of any class (think
getHashCode()) simply by defining methods that aren't specialized to any type.