Age discrimination in Perl 6 using subsets and multiple dispatch
I recently read a blog post by Alex Miller about Clojure multi-methods.
It described and answered a question his friend had asked him, as well as discussing some related problems. I'm going to showcase the different options Perl 6 provides for solving these same problems. Here's the initial question:
Is it possible to write a multimethod that has defmethods which handle ranges of values? For example, say that a person has an age. Can I write a multimethod that accepts a person as a parameter and returns "child" if age < 16, "adult" if 16 <= age < 66 and "senior" if age >= 66?
As in Clojure, the answer is "Sure." In keeping with TIMTOWDI, Perl 6 provides several ways to do this.
First, let's define our Person class:
class Person {
has Int $.age;
has Str $.name;
}
This simply defines a class called Person that has two attributes: an age and a name. The age must be an Int, Perl 6's integer type[1]. The name must be a Str, Perl 6's string type. It also generates readonly accessors so that we can access them outside of the class.
Now let's define an age-group multi that tells us which age-group a person belongs to:multi age-group ($person where (*.age < 16)) { "child" }
multi age-group ($person where (*.age >= 66)) { "senior" }
multi age-group ($person) { "adult" }
If you're not familiar with Perl 6, there are probably one or two things here that might look a little odd to you. First, there's the "where" clause. It adds a constraint to the parameter that says that the parameter must match the thing on its right for that multi to be selected. The where clause can be a regex, a type, an exact value, a predicate block, or a number of other things[2].
The *.age < 16 parts are probably even more confusing. What is the asterisk? That asterisk is a special value called Whatever. It generally does what you mean in a given situation. In smart-matching, it always matches, so you can use it as a default in a given/when block (but there's already a "default" for that). But one of the most useful uses of whatever is for creating anonymous blocks. For most operators, if you perform them on a Whatever, it produces an anonymous block that performs that operator on its argument. If there are multiple Whatevers in an expression, the anonymous block will have multiple arguments that go into successive Whatever positions.
For example * + 1
produces a block that adds one to its argument. * + *
produces a block that adds its two arguments. In this case, we are calling the "age" method on the Whatever and asking whether it is less than 16. We could do the same thing more verbosely in several other ways: sub ($person) { $person.age < 16 }
, -> $person { $person.age < 16 }
, { .age < 16 }
. But for simple operations like this, the Whatever version is often much easier to read and less verbose than the alternatives. Unfortunately, in a where clause in a parameter list, you need to parenthesize many complicated expressions, including Whatever blocks.
> age-group Person.new(:name<timmy>, :age(10)) child > age-group Person.new(:name<john>, :age(23)) adult > age-group Person.new(:name<ezekiel>, :age(89)) senior
So far, so good. But what if we accidentally pass an age instead of a Person to age-group?
> age-group 15 Method 'age' not found for invocant of class 'Int'
We can specify that only Persons are valid arguments to age-group:
multi age-group (Person $person where (*.age < 16)) { "child" }
multi age-group (Person $person where (*.age >= 66)) { "senior" }
multi age-group (Person $person) { "adult" }
This correctly handles the Person cases. How about the calling age-group with an age?
> age-group 15 No applicable candidates found to dispatch to for 'age-group'. Available candidates are: :(Person $person where ({ ... })) :(Person $person where ({ ... })) :(Person $person)
Much better. What if we wanted to allow asking for the age-group of an age?
We can rewrite the Person variants of age-group to accept ages as Ints and write a single Person variant that calls age-group on the person's age.
multi age-group(Int $age where (* < 16)) { "child" }
multi age-group(Int $age where (* >= 66)) { "senior" }
multi age-group(Int $age) { "adult" }
multi age-group(Person $person) { age-group $person.age }
That works for each of the Person examples, and for their ages.
Now, let's define a multi that uses age-group for dispatching, as in Alex Miller's Clojure post. We'll call our multi print-name for analogy with the Clojure example.
The obvious way to dispatch based on age-group is with a where clause.
multi print-name(Person $person where (age-group($person) eq "child")) { "Little {$person.name}" }
multi print-name(Person $person where (age-group($person) eq "adult")) { $person.name }
multi print-name(Person $person where (age-group($person) eq "senior")) { "Old Man {$person.name}" }
{$person.name}
inside the double-quoted strings interpolates the result of the block into the string.
Let's try it on our example people from earlier.
> print-name Person.new(:name<timmy>, :age(10)) Little Timmy > print-name Person.new(:name<john>, :age(23)) John > print-name Person.new(:name<ezekiel>, :age(89)) Old Man Ezekiel
That's nice, but what if we have more multis we want to dispatch based on a person's age-group? Do we really need to write out (Person $person where (age-group($person) eq "child"))
, etc. every time? No, we don't, thanks to subset types.
subset Child of Person where *.age < 16;
subset Adult of Person where -> $person { 16 <= $person.age < 66 };
subset Senior of Person where *.age >= 66;
multi print-name(Child $person) { "Little {$person.name}" }
multi print-name(Adult $person) { $person.name }
multi print-name(Senior $person) { "Old Man {$person.name}" }
Due to a bug in Rakudo's handling of Whatever when combined with chained comparison operators, we have to write an explicit block for Adult.
This new version of print-name produces the same results as the old version. Now, we can also rewrite age-group in terms of Child/Adult/Senior.
multi age-group(Child) { "child" }
multi age-group(Adult) { "adult" }
multi age-group(Senior) { "senior" }
multi age-group(Int $age) { age-group Person.new(:$age) }
:$age
is a convenient shorthand for :age($age).
Again, we have clearer code that produces the desired result, thanks to multiple dispatch and subset types.
These are just the basics of Perl 6's type system and multiple dispatch, but nonetheless, I think they demonstrate some of its power. If it's piqued your interest in Perl 6, do try Rakudo *.
[1] There is also an "int" type, which represents an unboxed native integer. There are a couple of important differences between int and Int. Firstly, int has no undefined value and is thusly initialized to 0 instead of an undefined value. Secondly, Ints can hold both integers that fit into a native integer and arbitrary-precision integers, transparently. int is also a native integer.
[2] Specifically, anything that can be smart-matched against is acceptable in a where clause. Even user-defined objects can be placed in where clauses if they define an ACCEPTS method.
Update: I accidentally had Alex Miller's name listed as Arthur Miller. Sorry about that.
Update again: As TimToady++ pointed out, 16-year-olds were neither Adults nor Children. Now they're Adults.
It feels to me that the solution you showed is still somewhat hacky in that it is expressed in terms of effect (“how do I get the desired result to happen”) rather than intent (“which constructs most directly express my way of thinking about the problem”).
The correction for that seems to me to be to define Child, Adult and Senior as subsets of Age, which itself is defined as a kind of Num; on top of that, you’d define coercions from Person and Int to Age. That would express the intent directly.
How do the names get upper-cased initials in the print-name examples? I'm still just a tourist in Perl 6 land, so I can't tell whether that's a typo, or whether I'm overlooking something.