-
Notifications
You must be signed in to change notification settings - Fork 50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal: a macro DSL based on bidirectional type checking #61
Comments
I believe the proposed API would be sufficiently-powerful to express the Here are the examples you gave for
The second case is the interesting one: we know that the type of First, notice that
At this point the macro is done, and the type-checker can continue its unification in order to discover that Here is the example you gave for
In this particular example, a definition of
This time, the surrounding context is only enough to determine that the expansion must unify with Anyway, let's go back to the proposal. Here is an even more challenging example:
Here, both I'm a bit disappointed that both examples had to use |
Sounds like some ideas Turnstile could use @stchang |
Hackett's goal has always been for "types and macros to be able to cooperate and communicate with each other". Today, however, Hackett macros are written using
syntax-parse
's DSL, which makes it easy to write a macro which alters its output depending on the syntactic categories of its inputs, not their types. This proposal introduces a monadic DSL which makes it easy for the macro to depend on the type of its inputs, without unnecessarily forcing the type-checker to commit to a type for all of the inputs before expanding the macro.Design goals
Unification API
When implementing type inference using unification, the important primitive operations are:
newSVar : (Solver SVar)
: create a new solver variable about which nothing is known yet.unify : {TypeExp -> TypeExp -> (Solver Unit)}
: unify two type expressions, each of which may contain some solver variables.readSVar : {SVar -> (Solver TypeExp)}
: look up a solver variable to obtain a type expression which may contain some solver variables; in the worst case, the entire expression may itself be a solver variable, in which case we learn nothing.The core idea of this proposal's DSL is to expose those primitives to the macro implementation, so that it can use
readSVar
to inspect types, andunify
to tell the type-checker about expected types. However, exposingreadSVar
directly (or equivalently, allowing the macro to call the type checker on a term) is problematic, because how much information we get fromreadSVar
depends on which other expressions the type checker has already visited. Moreover, what should the macro implementation do in the worst case in whichreadSVar
doesn't reveal any new information?Stuck macros
Here's how I propose to solve both problems at once: instead of giving back an entire type expression containing everything we currently know about the type variable,
readSVar
should always return a type expression in which only the outermost type constructor is known, and all its arguments (if any) are solver variables. Let's call that variantreadSVar'
. If the outermost type constructor isn't yet known,readSVar'
cannot return yet, so the macro expansion remains stuck until more information is obtained, by visiting and type checking other expressions (which can involve expanding other macros, which might also get stuck). If some macros are still stuck after everything is visited, then type checking fails with an "ambiguous type" error for that solver variable.This way, the order in which the type checker visits the expressions doesn't affect the output of
readSVar'
: either the outermost type constructor is uniquely determined, in which case the macro will receive that value (perhaps after being stuck and resumed), or two different unification calls give conflicting information for that type constructor, in which case type checking is going to fail with a type error anyway. This is similar to the way in which concurrent programs which only communicate viaIVar
s are guaranteed to give a deterministic answer even though the order in which the threads are scheduled isn't deterministic.Pattern matching
Repeatedly calling
readSVar'
in order to expand the outermost type constructor, then second outermost type constructor, etc., can be quite tedious. Let's provide a nicer API based on pattern-matching: type-pattern matching.In the same way that ordinary pattern-matching expands to a tree of nested case statements, type-pattern matching can be desugared to a tree in which each node calls
readSVar'
and then examines the resulting type constructor using a case statement. The subtle relationship between the order of the type-patterns and the order in which thereadSVar'
calls are made (which determine the behaviour of the type checker) is the same as the relationship between the order of ordinary patterns and the order in which inputs are forced by a pure function.One important difference between ordinary pattern-matching and type-pattern matching is the case in which a case statement with a single alternative is generated: for ordinary pattern-matching, the input is forced and then an exception is raised if the result doesn't match the pattern. For type-pattern matching, we could similarly call
readSVar'
and then fail with a type error if the outermost constructor doesn't match the pattern. But we can do better: we canunify
the input (a solver variable) with the pattern!Unifying
SVar
s in contextThe macro implementation should definitely receive an
SVar
representing the type of its output, so it can give the type checker some information (viaunify
) about the type of the expression it plans to produce before the macro is done producing that expression. In particular, providing this information before the macro becomes stuck might help type inference succeed more often.Which other
SVar
s should we provide to the macro implementation? When type-checking an expression such as(f x)
, we create one solver variable forf
, one solver variable forx
, and one solver variable for(f x)
. So when expanding the macro application(my-macro (f x))
, we might naively think of providing the macro implementation with one solver variable forf
, one solver variable forx
, one solver variable for(f x)
, and one solver variable for(my-macro (f x))
. That would be a mistake, because the macro is free to rearrange and duplicate those components arbitrarily, and it's this rearranged term which needs one solver variable per sub-term, not the input term.Another difficulty is that an API in which macros can ask about the type of arbitrary expressions encourages a counter-productive style in which the macro asks a lot of questions about expressions without providing much information in return, thereby accidentally hurting type inference.
My proposed solution to both problems in an alternate API in which the only way to ask about the type of a target expression is to put this expression in a bigger expression which the macro implementation promises has the same type as the expression which the macro will output. This is done via a
preliminary-output : {Syntax -> (Solver Unit)}
command, where the syntax object may contain type annotations in which the type expression containsSVar
s. The command adds the syntax object to the type checker's list of expressions to visit, so that whenreadSVar'
causes the control to return to the type checker, there is some hope for it to infer some information about theSVar
s in those type annotations.Some macros may still wish to delay choosing their output type until they have learned the type of a few target expressions. This can still be done using this API, by using
unsafe-coerce!
orundefined!
around their target expression to ensure that the output type remains unconstrained. However, due to the stigma around those partial functions, macro implementer will be encouraged to only do that if that's what they really want to do, and so in the common case the macro implementer will write an example output containing the target expression, thereby helping type inference.Summary
A macro is implemented as a function of type
{Syntax -> (Solver Syntax)}
. The usualsyntax-parse
mechanism can be used to determine what to do with the input syntax object. If at some point the macro wants to get some information from the type checker, it must first create a solver variable, then it must use that solver variable in a type annotation inside an example output expression, then it must pattern-match on the solver variable in order to force type inference to find some information about the solver variable, or fail trying. Each time an example output is given, its type is unified with the type of the macro's call site (and thus with all the previous example outputs as well), thereby giving some important information for type inference. Eventually, the macro returns its final output, whose type is again unified with the earlier preliminary outputs.The text was updated successfully, but these errors were encountered: