Multimethods
Methods are the workhorses of Magpie. Most of the code you write will reside inside a method, and most of that code will in turn be calls to other methods. A method is an executable chunk of code (an expression to be precise) that is bound to a name and has a pattern that describes the argument it expects.
Defining Methods
Methods are defined using the def
keyword.
def greet() print("Hi!") end
Here we've defined a method named greet
whose body is a block containing a single print
call. You can call this method like so:
greet()
We can define a method that takes an argument by using a pattern in its definition.
def greet(who) print("Hi, " + who) end greet("Fred") // Hi, Fred
In this case, the pattern who
is a simple variable pattern, but more complex patterns can be used:
def greet(who is String, whoElse is String) print("Hi, " + who + " and " + whoElse) end greet("Fred", "George")
Here we have a record pattern with two fields that must both be strings. We call it by passing it a record of two strings: "Fred", "George"
. This may seem a bit strange, but it's important to note that we are not passing two arguments. In Magpie, methods (and functions) always take a single argument. It's just that the argument may be a record which the method destructures.
The end result is that it works pretty much like other languages, but there's some conceptual unification going on under the hood. The destructuring initialization that you can do when declaring variables is the exact same process used when splitting out arguments to a method, or selecting a catch
clause when an error is thrown.
Left and Right Arguments
Method calls are infix expressions, which means that an argument may appear to the left of the name, to the right, or both. (More pedantically, the record that forms the single argument may have fields which appear to the left and right of the name.)
The greet
methods we've defined so far only have a right argument. Methods which only have a left argument are informally called getters.
def (this is String) isEmpty this count == 0 end
This defines a getter isEmpty
whose left argument must be a string. It takes no right argument. It can be called like this:
"not empty" isEmpty // false
And, finally, methods can have arguments on both sides:
def (this is String) greet(other is String) print("Hi, " + other + ", I'm " + this) end "Fred" greet("George")
When you define a method, the argument patterns always need to be in parentheses. When you call a method, only the right one must. If you chain several method calls, they associate to the left (like most languages). For example, this:
people find("Smith") firstName greet("George")
is the same as:
((people find("Fred")) firstName) greet("George")
Setters
In addition to getters, you can also define setter methods. Like getters, setters are just regular methods, but they have special syntax to make them look like assignment. You define a setter like so:
def (this is Person) name = (name is String) print("Set name to " + name + "...") end
The =
followed by a pattern tells Magpie that you're defining a setter. We can call the above setter:
person name = "Fred"
A setter may also have a right argument:
def (this is Contact) phoneNumber(type is String) = (number is String) print("Set " + type + " number to " + number) end jenny phoneNumber("Home") = "867-5309"
Magpie tries to roll as much behavior under method calls as possible and getters and setters are a good example of that. It's worth noting that everything that looks like a getter or setter is just a method call. When you're accessing fields in a class, you're just calling getter and setter methods that have automatically created implementations.
Indexers
In addition to setters, Magpie has one more little bit of extra syntax for method calls. To make working with collection-like objects easier, it provides indexer methods. These are essentially methods whose name is []
. The left argument appears before the brackets, and the right argument is inside them.
list[2]
Here, we're accessing an element from some list
variable by calling an indexer method. The left argument is list
and the right argument is 2
. Aside from their syntax, there is nothing special about indexers. They're just methods like any other and you're free to define your own indexers, like so:
def (this is String)[index is Int] this substring(index, 1) end
Here, we've defined an indexer on strings that takes a number and returns the character at that index (as a string). As you'd expect, the right argument can also be a record:
defclass Grid val width is Int val height is Int val cells is List end def (this is Grid)[x is Int, y is Int] cells[y * width + x] end
Here we've defined a Grid
class that represents a 2D array of cells. It includes an indexer for getting the cell at a given coordinate. You can call it like this:
val cell = grid[2, 3]
You can also define indexer setters which combines the syntax of those:
def (this is Grid)[x is Int, y is Int] = (value) cells[y * with + x] = value end grid[2, 3] = "some value"
Method Scope
Magpie's method call syntax looks similar to other OOP languages where a "receiver" argument precedes the method. We've seen some examples where we define methods whose left argument is a built-in type like String
. In other languages, this is called monkey-patching and doing it is fraught with peril. The reason is that when you invoke a method in those languages, it looks up the method on the class of the receiver. If two unrelated parts of the program define a method with the same name on the same class, those two methods will collide. When we call it later we may find the wrong one.
In Magpie (as in CLOS) methods are not owned by classes. Instead, methods reside in lexical scope, just like variables. When you call a method, the method is found by looking for it in the scope where the call appears, and not on the class of any of the arguments. When a method goes out of scope, it disappears just like a variable.
do def (this is String) method() print(this + " first") end "a" method() // a first end a method() // ERROR! do def (this is String) method() print(this + " second") end "a" method() // a second end
It is impossible to have a method collision in Magpie. If you try to define two methods with the same name and pattern in the same scope, it will throw an error. This way, you can define methods that have a nice readable calling syntax without having to worry about breaking code in some other part of the codebase.
Checking for pattern collisions hasn't been implemented yet.
Multimethods
In Magpie, all methods are multimethods. This is one of the places where the language really steps it up compared to other dynamic languages. In the previous section, we noted that it's an error to define two methods with the same name and pattern in the same scope. That qualifier is important. It's perfectly fine to define two methods with the same name but different patterns.
def double(n is Int) n * 2 end def double(s is String) s + s end
Here we've defined two double
methods, one on strings and one and numbers. Even though they are defined in the same scope, these don't collide with each other. Instead, these are combined to form a single double
multimethod containing two specializations.
When you call a multimethod, it looks through the methods it contains and their patterns. It then selects the most appropriate pattern, and calls the method associated with it.
double(3) // 6 double("ma") // mama
In simple terms, this means Magpie lets you overload methods, which is pretty unusual in dynamic languages. It's also more powerful than most static languages because it's selecting the most appropriate method at runtime where overloading in a language like Java is done at compile time.
Since all methods actually take a single argument, we're free to specialize a multimethod on the left argument, right argument, or both. You can also specialize on different record patterns.
def (this is String) double this + this end def (this is Int) double this * 2 end def double(s is String, s is Int) s + s, n * 2 end "left" double // leftleft 3 double // 6 double(3, "do") // 6, dodo
As long as you don't provide two specializations with the exact same pattern, you are free to define as many as you want. If you call a multimethod with an argument that doesn't match any of the specializations, it will throw an error.
do double(true) catch is NoMethodError print("We didn't specialize double on bools") end
We didn't define a double
method that accepts a boolean, so when we call it, it will throw a NoMethodError
which gets caught here to print a warning.
Linearization
TODO(bob): Methods are partial order now, not a strict linearization. Need to update this.
The previous section says that the "most appropriate" method is selected based on the argument. In the examples we've seen so far, only one method is a possible match, so most appropriate is pretty easy. If multiple methods match the argument, we need to determine the best one. Magpie (and other languages) call this linearization.
def odd?(0) false end def odd?(n is Int) not(odd?(n - 1)) end
Here we have an odd?
multimethod with two specializations. If we call it and pass in 0
, then both specializations match. Which is best? To answer this, Magpie has a few relatively simple rules that it uses to order the patterns.
Before we get to those rules, it's important to understand one thing that does not affect ordering: the order that methods are defined in a program has no effect on linearization.
Pattern Kind
First, different kinds of patterns are ordered. From best to worst:
- Value patterns
- Record patterns
- Type patterns
- Wildcard patterns
For variable patterns, we look at its inner pattern. (If it doesn't have one, it is implicitly _
, the wildcard pattern.) The above list addresses our odd?
example: the first method will win since a value pattern (0
) takes precedence over a type pattern (is Int
).
To linearize two patterns of the same kind, we need more precise rules.
Class Ordering
To order two type patterns, we look at the classes being compared and see how they are related to each other. Subclasses take precedence over superclasses.
defclass Parent end defclass Child is Parent end def sayClass(is Parent) print("Parent") end def sayClass(is Child) print("Child") end sayClass(Child new())
Here, both methods match because an instance of Child
is also an instance of Parent
. In this case, the second method specialized to is Child
wins because Child
is a subclass of Parent
.
Record Ordering
It's possible for two record patterns to match the same argument.
def printPoint(x: x, y: y) print(x + ", " + y) end def printPoint(x: x, y: y, z: z) print(x + ", " + y + ", " + z) end printPoint(x: 1, y: 2, z: 3)
A record pattern matches as long as the argument has the fields the record requires. Extra fields are allowed and ignored, so here both methods match. It's also possible for records with the same fields but different field patterns to match:
def sameGeneration?(a: is Parent, b: is Parent) true end def sameGeneration?(a: is Child, b: is Child) true end def sameGeneration?(a: _, b: _) false end
Ordering these can be complex, so the linearization rules for records a bit subtle. The first requirement is that the records must specify the same fields, or one must be a subset of the other. If not, they cannot be ordered and an AmbiguousMethodError
is thrown.
def say(x: x) print("x " + x) end def say(y: y) print("y " + y) end say(x: 1, y: 2)
It's unclear what the programmer was even trying to accomplish here, and Magpie can't read your mind. So in cases like this, it just raises an error to signal its confusion. Our first example doesn't have this problem, though. The first definition of printPoint
is a subset of the former, so there's no ambiguity. In that case, it proceeds to the next step.
There are several signals we can rely on to tell us which record should come first. We'll call those lean. To order records in a predictable way, those signals need to agree with each other. The first signal is if one record has fields the other doesn't. In our first printPoint
example, the second method has an extra z:
field. That means we lean towards preferring that method since it "uses more" of the argument that gets passed in.
Next, we go through the fields that the two records have in common and linearize their patterns. Whichever pattern wins is a lean towards that record. (If the patterns compare the same, like the x
and y
fields in the two printPoint
methods, then there's no lean one way or the other.)
If all of the leans are towards one record, it wins. If the leans are inconsistent then it's ambiguous. This is all a bit fishy, so some examples should clarify:
x: x, y: y x: x, y: y, z: z // Second wins: more fields x: x, y: y y: y, z: z // Ambiguous: neither is a subset of the other a: is Parent, b: is Parent a: is Child, b: is Parent // Second wins: Child is more specific a: is Parent, b: is Child a: is Child, b: is Parent // Ambiguous: fields disagree
The general theme here is that it tries to pick records that are "obviously" the more specific one where "more specific" means more fields or more precise fields. If it isn't crystal clear which one the programmer intended to win, Magpie just throws its hands up and pleads confusion.