Luke's half-complete notion of type-classes for Perl, called "theories": ======================================================= First run, describing the problem, and then abandoning. =head1 TITLE Model Theory - models and theories generalizing classes and roles =head1 DESCRIPTION An important problem in programming languages is the expressibility of B and B. Together, it is possible to express an abstract tree in language constructs and process it neatly. Usually, such an abstract tree has a data representation (a set of node types) and a set of processors that act on those node types. Few modern languages struggle with these. However, most modern languages begin to break down when you want to I the abstract representation. Functional languages tend to be good at adding new processors, whereas object-oriented languages tend to be good at adding new node types. However, when you wish to add a new node type in a functional language, all your processors must be updated. When you wish to add a new processor in a procedural language, all your node implementations must be updated. We would like to be able to extend either of these in a code-local and type-safe way. There is a solution in the language Scala[1]. It collects the data types and the processors together in a parent trait (role), where it can store an abstract type (virtual type) that is consistent with all the data types and processors therein. Then to extend either the data or the processors, you extend the I, all the while making sure it is consistent. This solves the problem in the sort of way that, instead of adding a convenience feature or relaxation, forces you to think clearly about the problem. Taken from [1] (with comments added): // The base system trait Base { type exp <: Exp; // An abstract type representing the type of a // tree node. trait Exp { def eval: int; } class Num(v: int) extends Exp { val value = v; def eval = value; } } // Extend the system to incorporate a new node type trait BasePlus extends Base { class Plus(l: exp, r: exp) extends Exp { val left = l; val right = r; def eval = left.eval + right.eval; } } // Extend the original system to incorporate a new processor trait Show extends Base { type exp <: Exp; // redefine what it means to be a node trait Exp extends super.Exp { def show: String; } // define Num in the new system so so that it can support show class Num(v: int) extends super.Num(v) with Exp { def show = value.toString(); } } The details aren't important. The general technique is what is interesting here. We extend the whole algebra -- the whole B -- at once, including the base type of nodes. Other than that, the techniques are standard object-oriented visitor techniques. ([1] also shows a dual technique to the one above, functional decomposition, which allows us to define visitors without extending C, but defining new node types requires extending C). However, a problem shows its head when we have a processor that returns nodes. Consider an extension to the C system above, whereby we add a transform that doubles every C node. trait DblePlus extends BasePlus { type exp <: Exp; trait Exp extends super.Exp { def dble: exp; } def Num(v: int): exp; // XXX factory constructors def Plus(l: exp, r: exp): exp; class Num(v: int) extends super.Num(v) with Exp { def dble = Num(v * 2); } class Plus(l: exp, r: exp) extends super.Num(v) with Exp { def dble = Plus(left.dble, right.dble); } } We cannot explicitly construct new nodes inside the body of the new C function because C is a subtype of C, not of C (which is what C is in this system). You may think that this is just a fault of the type checker and that we can relax this away, but in fact there is a fundamental problem in the logic (always have faith in your type checker): What is keeping C from defining a private subtype of C that does not support C and returning it, say, as a special private optimization? In order to solve this problem, we define abstract factories for each node type. This must be done at the final level when the system is actually used, otherwise such type difficulties arise. But we don't want to require anything special at the final level, because then it is being "locked in" type-wise, and code that follows from that cannot be extended (it turns into a concretely-typed factory just as in classic, non-virtual OO type systems). There is a way to solve this: to keep a system extensible and type-safe forever, without requiring a "lock in" stage. For the rest of this paper, I will focus on code in (my proposed extension to) Perl 6. =head2 Union Types The first piece of machinery we need above the status quo is the B. This is taken from ML-family languages, and is sometimes referred to as I. A union type is a closed type, made up of a set of disjoint B. The concept of a union type is helpful because if you write a function that handles each constructor, you are sure you have covered every case in the type. Here is a union type that defines a tree with C and C as above: data Exp ( ENum[ Num --> Exp ], EPlus[ Exp, Exp --> Exp ], ); (Keep in mind that I am not proposing syntax; all that is required is the concept of a union type, whatever syntax it has.) You may extend union types to create new types: data NegExp extends Exp ( ENeg[ Num --> Exp ], ); There is something going on here that may be a little strange to people who have seen C before in a language. For instance, in Java, when a class C C a class C, the C denotes that the I is extended, and therefore that C is a subset of C. Here, however, the C denotes that the I is extended, and therefore C is a subset of C. That is, when you extend a union type, you make it bigger, more general. Anywhere a C can be used, an C can be used. I'll also adopt a syntax for unifying against union type constructors: multi run (Exp --> Num) {...} multi run (ENum[$num]) { $num } multi run (EPlus[$left, $right]) { run($left) + run($right) } =head1 REFERENCES [1] http://icwww.epfl.ch/publications/documents/IC_TECH_REPORT_200433.pdf ======================================================= Larry currently thinks that matching against data constructors will look something like this: multi run (ENum ($num)) { $num } multi run (EPlus ($left, $right)) { run($left) + run($right) } Where you can also have Haskell-like node@... syntax with: multi run (ENum $node ($num)) { $num } Who knows how he thinks we will declare data constructors... ======================================================= Second run, a brain-dump to the design team. A theory is a generalization of a role. If you think of a role as a contract that says "if you implement these methods for me, I'll implement these methods for you" (where either of the "these methods" can be empty). Correspondingly, a theory is a contract that says "if you implement these multis for me, I'll implement these multis for you". That's the first level view, i.e., what we put in the intro docs. More abstractly, a role specifies an interface while a theory specifies an algebra. In order to solve the expression problem, "trait Base" in the Scala paper becomes "theory Base". In it we put the abstract specification of the data structure and the visitors. Let's try it out with a simple expression grammar (which doesn't look all that different from PIL): theory Base { data Exp[::t] = ( Zero[ --> Exp[Int]], Pred[Exp[Int] --> Exp[Int]], # n + 1 Succ[Exp[Int] --> Exp[Int]], # n - 1 IsZero[Exp[Int] --> Exp[Bool]], Cond[Exp[Bool], Exp[::t], Exp[::t] --> Exp[::t]], ); sub run (Exp[::t] --> ::t) {...} # that was trying to specify a general type signature for the # set of multis multi run (Zero[]) { 0 } multi run (Pred[$x]) { run($x) - 1 } multi run (Succ[$x]) { run($x) + 1 } multi run (IsZero[$x]) { run($x) == 0 ?? Bool::True !! Bool::False; } multi run (Cond[$cond, $true, $false]) { if run($cond) { run($true); } else { run($false); } } } Mmm, the runtime of language right there :-) Now let's add a node: theory BaseWithNot extends Base { data Exp[::t] extends Base::Exp[::t] ( Not[Exp[Bool] --> Exp[Bool]], ); Now if we leave it at that, it's okay. But you can't put a BaseWithNot::Exp into run, because when you extend a union type, you make it bigger. Base::Exp is a subtype of BaseWithNot::Exp. So we have to specify a new type for run(): sub run(Exp[::t] --> ::t) {...} I'm not sure if the old run()s will automatically kick in, or if you need something like: multi run(Base::Exp[::t] $exp) { Base::run($exp) } If you do specify that, then there's really no difference (yet, see below) between a theory and a module. But maybe that "extends Base" does something for you. But after we added that new type signature, it won't compile (if we have strict types on), since we forgot to implement the Not case: multi run(Not[$x]) { !run($x) } } It should be pretty obvious how you add a visitor. Extend the theory and then add the visitor. Let's say we did that with a theory called BaseWithConstantFolding (deal with the names; they'd probably be shorter in practice, because things would be namespaced about and whatnot). Then we can combine the two theories: theory BaseWithNotCF extends BaseWithConstantFolding extends BaseWithNot { Again, this is okay, but it shouldn't typecheck if you try to constant_fold an Exp from this theory, because constant_fold was defined in terms of the data defined in Base. So we need to implement the constant folding case for not. Let's just have it do nothing: sub constant_fold(Exp[::t] --> Exp[::t]) {...} multi constant_fold(Not[$x]) { Not[constant_fold($x)] } } Note that, unlike in the Scala paper, we can return new nodes without needing to define factories. That's because in the Scala paper, they were creating new *subtypes* (meaning subclasses, not necessarily constrained types) of the original exp, whereas we are making *supertypes*. This is why I think the union type model fits this kind of problem a lot better than the base class/subclass model. Up until now, a theory seems like a glorified module. That's because it had zero parameters; that is, it had already been instantiated right when we created it. Maybe in that sense it should have been a model. And maybe a model is just a module anyway. Uh, see below. Theories really get cool when you start parameterizing them. Here's a theory that specifies a group: theory Ord[::T] { # T is ordered # required methods multi infix:{'<='} (T, T --> Bool) {...} # provided methods multi infix:<==> (T $x, T $y --> Bool) { $x <= $y && $y <= $x } multi infix: (T $x, T $y --> Bool) { not $x == $y } multi infix:{'>'} (T $x, T $y --> Bool) { not $x <= $y } ... } Then to say that Int is ordered, you "model" the theory (this vocab is incidentally from model theory :-) : model Ord[Int] { multi infix:{'<='} (Int $x, Int $y --> Bool) { # uh, well... builtin } } # a whole bunch of useful multis are now defined for you This shows a nice feature of theories already. All that operator magic that Perl 5 done now gets put into theories, and you get it if you ask for it. That is, the presence of a < operator no longer implies that you're ordered (for instance, IO::All wasn't ordered, but it used <). Saying that you're ordered by modeling the theory also implies that you obey the laws that this theory represents. For instance, Junction couldn't model Ord (well it could if it wanted to, because Perl can't check that kind of thing, but it shouldn't). Then to write a function that only accepts types in a certain theory, you use a constraint. Since we have | available in signatures now, I'll use that (but I'm not attached to it by any means): sub min(::T *@values --> ::T | Ord[::T]) { die "Null list given to min()" unless @values; my $min = @values[0]; for @values[1...] { $min = $_ if $_ < $min; } return $min; } The reason it's specified with a constraint instead of just saying "Ord *@values", is because theories can have more than one parameter, specifying an algebra over multiple types. For instance, you might define a vector space over a field, and then both the vector type and the field would be parameters to that theory. However, I'm sure "Ord $foo" could be shorthand for "::T $foo | Ord[::T]" for single-parameter theories. Incidentally, single-parameter theories are just roles. :-) ======================================================= That last statement wasn't quite true. Consider the theory Num, which is single-parameter. This theory mandates that if you add two As together, and A is a Num, then you get back another A (closure under addition). Clearly Int is a Num. However, the subtype Int where { $_ < 10 } is not, since 8+8 == 16, and 16 isn't in that type. However, if you have a type that does a role, you want every subtype also to do that role (of course, my parent supplies these methods so I do too). That is, roles are more like interfaces than algebras. So in Theory theory, the formal definition of a role is: A theory R is a role if: for all types T and U, U is an instance of R and T does U implies that T is an instance of R. Programmatically, this I means that all operations in the theory depend covariantly on the type. That is, you can't specify that a method returns the parameter type of the theory, only that the methods accept the type as a parameter.