-
Notifications
You must be signed in to change notification settings - Fork 11
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
local-expand variants #260
Comments
Here are some notes from a discussion with @david-christiansen about the above. ScopeMu'One problem with the solution so far is that if the global-transformation macro encounters an error while processing a
If I remember correctly, you gave the following definition instead:
Which makes sense as an intermediate structure when the global-transformation macro is still in the process of expanding the open / closeWe said that it would be annoying for users to manipulate values of type
The API we have chosen for that type is
which has room to improve (recursive matching will be easily solved with a macro, but having to specify the source location and scope set on every Syntax-Contents is unavoidably painful) but is much better than what we had before. So perhaps we could have
Then once we find API improvements for |
stuck macrosSince the goal is to give the programmer the expressive power to implement exotic type systems, it would be nice to provide support for Klister's own brand of exotism: stuck macros. We would like the global-transformation macro to be able to define a data constructor for We don't currently have a way for the global-transformation macro to spawn tasks, but we do plan to add more ways for macros to get stuck than just |
quadratic costthe quadratic cost of local-expandOne issue which plagues Racket's local-expand is its quadratic cost. I always forget what is causing this quadratic cost, so I want to write it down in a separate section.
The quadratic cost comes from the fact that
So if the route ending in The other reason is that program can contain multiple nested macro calls, so if n of them are implemented by calling the linear cost of local-expand-to-functorThe semantics of The semantics of with a directly-accessible Syntax, invariants must be re-checkedThere is a second reason why Racket's with a directly-accessible MyCore, invariants must be re-checkedWhile with an opaque MyCore, invariants don't have to be re-checked
This solves the performance issue, but the downside of this approach is that the inner global-transformation macro cannot manipulate the with a partially-opaque Syntax, invariants can be re-checked quicklyKlister's
This API makes it possible to manipulate This makes it possible for the invariant to be re-checked for cheap whenever bottom-up invariants checkingFor example, suppose we want our
The above algorithm proceeds from the outside in, but with
Similarly, if we want our terms to be well-typed, we cannot lookup a variable in a context to find its type, we must instead build a context containing all the free variables and the type they were used as. This necessarily requires unification, even if the language's context only has monotypes, because the bottom-up algorithm doesn't have access to that context to look up that monotype. This means that the type of For languages with rank 1 universally-quantified types, which already require unification, the situation is more complicated. The bottom-up algorithm doesn't have access to the context to look up if a variable has a universally-quantified type, so it cannot instantiate the universality-quantified variable to a new unification variable at each use site. It has to account for both the monotype case and the universally-quantified case. It can do so by creating a new unification variable for each use site, and storing the list of all the unification variables which have been created for a given variable. If it later turns out it's a monotype (e.g. the variable is bound by a lambda), unify all those unification variables together. If it later turns out it's a known universally-quantified type (e.g. a top-level definition with the type signature It is reassuring that impending an efficient type checker is possible, but since creating custom typed DSLs is likely to be very common in Klister, it makes sense to go the extra mile and attempt to make it easy. |
LFBelugaThe Beluga programming language is also focused on letting the programmer define a custom typed DSL and then manipulate its well-typed terms. It would thus make sense to take some inspiration from it. First, a short note about the difference between Beluga and Klister, so we understand which parts make sense to borrow and which parts don't. In both languages, the phase 1 code manipulates phase 0 terms. In Beluga, the phase 1 type system guarantees that all phase 0 terms are well-typed. In Klister, a To complete the picture, in a dynamically-typed language, the mistake would be caught when phase 0 runs, at runtime. These are three points on a spectrum where errors are caught later and later but the programmer has less and less onerous obligations towards the compiler, with Klister occupying a sweet spot between two extremes. Anyway, in Beluga, the AST of the phase 0 terms, their types, and their typing rules are all defined via the LF syntax:
Beluga then uses those LF rules to guarantee that a constant like
typechecker from LFAgain, in Beluga, that function is rejected when phase 1 is type-checked, whereas in Klister, we would like the corresponding code to fail when phase 1 runs.
This can be achieved in the way we described in the previous section: by having
This code closely corresponds to the LF definition, but is much longer. In order to simplify the programmer's life, it thus seems plausible that we can write a macro which takes an LF definition as input and produces the typechecker above as output. |
I now realize that #117, #119, #216, #232, #228, #229, and this concrete use-case for local-expand all fit together into a master plan, a sequence of features which each build on the previous one:
local-expand-to-functor
local-expand-problem
local-expand-monad
I will attempt to explain those features in the context of a running example, a DSL for specifying routes in an http server, in the style of servant:
local-transformation macros
:>
is a local-transformation macro, it takes n path components and expands into n-1 applications of a binary operator:It is a local transformation in the sense that it merely reorganizes its input sub-terms while leaving the sub-terms untouched and unobserved.
global-transformation macros
Let's look at a global transformation next. Suppose the implementation of the http server looks at each route from top to bottom, and that a common bug is that the
/tasks/:task-id
route is listed before the/tasks/completed
route, and therefore when the server receives a request for/tasks/completed
, it incorrectly attempts to parse the string "completed" as a task id. Then it would make sense to define aglobal-routes
macro which looks at all the routes defined within its body, spots the conflicts, and reorders them to avoid the bug:This is a global transformation because
global-routes
had to examine the details of its sub-terms in order to find the conflicts. In general, a global transformation typically examines its entire input, and thus expects that entire input to have a specific shape; here,global-routes
expects each route to be constructed via nestedbinary-:>
applications, not via the n-ary:>
. That restricted shape is inconvenient.local-expand-to-functor
The next step is to eliminate that inconvenience by allowing local-transformation macros to collaborate with the global-transformation macro. The idea is that
custom-core-routes
specifies that it expectsbinary-:>
, not:>
, which tells the expander to expand:>
but notbinary-:>
. This way, the user can use the nice:>
syntax while thecustom-core-routes
implementation can parse the simplebinary-:>
syntax.One way to implement
custom-core-routes
is vialocal-expand
, by havingcustom-core-routes
traverse its input and calllocal-expand
whenever it encounters:>
or any other symbol it does not recognize. I propose a fancier solution:custom-core-routes
should instead calllocal-expand-to-functor
, a polymorphic Macro action which in this case returns a(MyCore Syntax)
. One of the constructors ofMyCore
ismy-binary-:>
. Inside the body ofcustom-core-routes
, the local-transformation macros are given the option of returning a(MyCore Syntax)
value instead of returning aSyntax
. For example, thebinary-:>
macro uses themy-binary-:>
constructor.The reason the type is
(MyCore Syntax)
and not justMyCore
is two-fold:Syntax
values, andcustom-core-routes
recursively callslocal-expand-to-functor
on theSyntax
values in order to obtain a fully-expanded(Fix MyCore)
.The local-transformation macro should use
(which-problem)
to check if a(MyCore Syntax)
value is expected, because the only place where the expander knows what to do with that value is in the outermost macro of theSyntax
expanded bylocal-expand-to-functor
. The(MyCore Syntax)
is constructed in the same phase as thelocal-expand-to-functor
call site which matches on that value, so this is effectively a dynamically-typed call with extra steps.At that call site,
custom-core-routes
matches on a finite number of core cases (theMyCore
constructors), and gives them meaning by transforming them into e.g. theSyntax
for a function which receives a request and the implementation of an http handler, and chooses whether to call that handler or move on to the next route.local-expand-problem
In some circumstances, we don't want a closed set of core cases, we want an open set to which other libraries can contribute. For example,
capture
andquery-param
are only two of many ways one might parse part of an http request for routing purposes, and it would be nice to be able to add more ways to parse routes without having to modify the library which implements the global-transformation macro.If this was Haskell, then instead of a
MyCore
datatype, we would define a newtype wrapper around a function type(-> Bool Identifier Identifier (-> Bool Identifier Identifier Syntax) Syntax Syntax)
, and we would establish a convention explaining what such a function must do:Identifier
argument is the name of the http handler, a function which receives all the values parsed from the route and returns the response,Identifier
argument is the name of the part of the http request which remains to be parsed,(-> Bool Identifier Identifier Syntax)
argument is the success continuation,Syntax
argument is the failure continuation,Syntax
should be code which parses part of the http request and either calls the success continuation or the failure continuation.Since this is Klister, not Haskell, and we are not merely calling a function, we are making an indirect call via the expander, we must use a slightly different approach. Instead of a function from an input type to an output type, the global-transformation macro's module should define a custom
Problem
constructor (which must thus be an open type) wrapping the input type and specifying the output type (which isSyntax
in this case, but could be something like(MyCore Syntax)
as well).Then, instead of hardcoding
capture
andquery-param
as the only two valid possibilities, the library would export those as two example macros which solve thatProblem
, and document how other libraries can do the same.custom-problem-routes
would construct the appropriate continuations, wrap them in thatProblem
constructor, and pass it (and the syntax which might contain(capture [task-id TaskId])
or a library-provided alternative) tolocal-expand-problem
. It would receive the output, in this case aSyntax
, and use it to construct its own output, perhaps a function which takes an HList of handlers and an http request and calls the right one.local-expand-monad
In Haskell, it is also very common for this kind of newtype to wrap a function which returns a monadic action. For example, if we implement a custom type system which uses its own
MyType
instead of Klister'sType
, then the local-transformation macros might want to unify twoMyType
values or create new unification variables. Or, in our running example, maybe the library wants to provide a monadic API for consuming the next path component, so that under the hood it generates the code which examines the right part at runtime, without having to burden the implementation of the alternative-to-capture with the details.It would thus make sense to have an
expand-to-monad
variant whoseProblem
specifies the input type, the output type, and the monad (which must supportliftMacro
) which the local-transformation macro is allowed to use.The text was updated successfully, but these errors were encountered: