4

Right now, I've got an AST for expression that's polymorphic over the type of recursion:

data Expr a = Const Int
            | Add a a

This has been incredibly useful by allowing me to use a type for plain recursion (Fix Expr) and another one when I need to attach extra information (Cofree Expr ann).

The issue occurs when I want to introduce another type into this recursion scheme:

data Stmt a = Compound [a]
            | Print (Expr ?)

I'm not sure what to put for the Expr term without introducing additional type variables and breaking compatibility with all the general functions I've already written.

Can this be done, and if so, is it a useful pattern?

xal
  • 127
  • 1
  • 5

1 Answers1

7

The recursion-schemes perspective is to view recursive types as fixed points of functors. The type of expressions is the fixed point of the following functor:

data ExprF expr = Const Int
                | Add expr expr

The point of changing the name of the variable is to make explicit the fact that it is a placeholder for the actual type of expressions, that would otherwise be defined as:

data Expr = Const Int | Add Expr Expr

In Stmt, there are two recursive types, Expr and Stmt itself. So we put two holes/unknowns.

data StmtF expr stmt = Compound [stmt]
                     | Print expr

When we take a fixpoint with Fix or Cofree, we are now solving a system of two equations (one for Expr, one for Stmt), and that comes with some amount of boilerplate.

Instead of applying Fix or Cofree directly, we generalize, taking those fixpoint combinators (Fix, Cofree, Free) as parameters in the construction of expressions and statements:

type Expr_ f = f ExprF
type Stmt_ f = f (StmtF (Expr_ f))

Now we can say Expr_ Fix or Stmt_ Fix for the unannotated trees, and Expr_ (Flip Cofree ann), Stmt_ (Flip Cofree ann). Unfortunately we have to pay another LOC fee to make the kinds match, and the types get ever more convoluted.

newtype Flip cofree a f b = Flip (cofree f a b)

(This also assumes we want to use the same Fix or Cofree everywhere at the same times.)


Another representation to consider is (called HKD nowadays):

data Expr f = Const Int
            | Add (f Expr) (f Expr)

data Stmt f = Compount [f Stmt]
            | Print (f (Expr f))

where you only abstract from annotation/no-annotation (f = Identity or (,) ann) and not from recursion.

Li-yao Xia
  • 31,896
  • 2
  • 33
  • 56
  • The generalization step with the type synonyms was the crucial step I couldn't come up with, but it all seems so clear now that it's in front of me. Thanks! – xal Jun 28 '18 at 02:32
  • How would you do this with Expr also referring to Stmt like if Expr had a `Block [Stmt] Expr` – kthompson Aug 14 '20 at 17:15
  • 1
    In the case of mutually recursive types you'll need a fixpoint operation for functors with multiple parameters: `ExprF expr stmt`, `StmtF expr stmt`. The recursion-schemes library won't cut it anymore. I think compdata is a good alternative, but it's a bit more sophisticated. It seems easiest to roll your own recursion schemes at that point. – Li-yao Xia Aug 14 '20 at 17:53