Open Rewbert opened 3 years ago
It seems to me that this is actually really crucial to having a decent EDSL front-end. A big part of FP is the ability to easily write polymorphic code like the examples you've shown! (:
Agreed! And if we can do this we can definitely implement the supervisor pattern you mentioned earlier.
I just had a meeting with @koengit where we talked a bit about this. Just appending type information might not be enough. We can describe issues and fixes like this:
f = box "f" ["r"] $ \r -> do
v <- deref r
assign r (v + 1)
+
is overloaded so it can work for any numerical type. We solve this scenario with the system I described above, by recording types.
f = box "f" ...
g = box "f" ...
Here we have an issue since we have two different functions in Haskell, but we are naming them the same. Even if they are applied to the same types the bodies of the two functions might be different. We can fix this by having a plugin that makes sure the proper names are inserted (and clearly documenting that the brave programmer that wishes to do this without a plugin must make sure to name things uniquely. Crap like the above example is disallowed).
f1 = f where f = box "f" ...
f2 = f where f = box "f" ...
Code above is not necessarily wrong by the recipe we gave above. We can solve this by having the plugin also insert the line/column-numbers in the function name (essentially grabbing the source location. The current plugin already does this, but in another context).
f a = box "f" ["x"] $ \x ->
do ... x ... something with a ...
Here a
appears as a parameter to the Haskell function f
. Essentially, the body of f 3
and f 17
will be different, even if their names and types are the same. Not clear how to fix this. I am envisioning that the programmer will just write stuff like
f x a = do
...
-- plugin takes the above and converts to this
f = box "f" ["x","a"] $ \x a -> do
...
So this issue would only arise for the brave programmer that used box
manually?
f = box "f" ["x"] $ \x ->
do ... x ... ?a ...
I will show you the ImplicitParams
extension today, where we can use variables like ?a
. This variable does not appear in the definition at all, we just wildly use it. No idea how to tackle this, currently.
There are some semantic approaches we can take to monomorphizing also. When we see a function f
with body b
we can check if b
matches the body we've seen before. Works perfectly for functions that are not recursive. For recursive ones there is no guaranteed way that this will work, but perhaps some heuristic is fine, such as traversing the function body to a depth of 5 or something?
Tryhard solution is to define every function that you can fork globally before calling your program, and then passing that in as some parameter to your program. This is not a solution I think is good, as it removes the nice function creation/application we have right now.
Something I don't know a lot about but which @koengit is familiar with is observational sharing, where you can have two functions
f = <expr>
g = <expr> -- same body as f
With observable sharing you can tell that f =?= f
but not that f =?= g
. I need to read up on this approach before I try to describe some examples in more detail.
Box
works like this: We have some code,code
, which represents some function body. When we write a function such aswhen we evaluate
f x
, we get a hold of the statements that make up the functions body. There is nothing in the emitted code that says that this should be a separate procedure that can be invoked.When we instead define our function like this:
box helps us by making it explicit that this is a procedure, by inserting some extra meta-statements in the code that delimits the procedure scope.
This is nice as we can now 'call' procedures in the generated C code, while having the Haskell code still look like any normal function application. The alternative would be to define some type of functions, and having a special
app :: (a :-> b) -> a -> b
combinator for applying functions to arguments.One kink about this is that if you try to generate code for a recursive function, you will never terminate. At each recursive application we will run into the entire functions definition again.
becomes
While we have now gained a very pleasant way of defining and applying functions, we end up with an infinite program instead. To combat this, we have defined a second datatype to represent programs, which uses a different set of statements. For instance, the constructor for fork in that AST is simply
Fork :: [(String, [(Either SSMExp Reference)]] -> Stm
. It is not recursive at all, and simply represents that we wish to call a function with this name with these arguments, which can be either expressions or references. The types of these arguments are also recorded.While we do the transpilation from the initial, infinite datatype to this more flatter one, we also produce a map
funs :: Map String Procedure
. Procedure looks like this:This map simply maps function names to their definition. When a program is later turned into a C-file, every procedure in this map is compiled to a C procedure, and at fork sites an ordinary procedure call is generated. This map is touched at every fork statement by checking the first statement of the forked code (which should be a
Procedure n
meta statement). If the name in theProcedure
constructor has not been seen before, the body of that function will be recursively transpiled and then turned into aProcedure
value, and then an entry will be made in the map to reflect that we have now seen and recorded this function. If it has already been seen, we do nothing.Now comes the issue of polymorphic functions. If we write this function:
what code should we generate? When we assign values to references in the generated code, we need to specify the type of the thing we are assigning a value to. Here we are writing a polymorphic function that can be applied at any type, so which type should the generated code talk about? The issue is that we have no way of being polymorphic in the generated code, so this can not be done.
Monomorphization
Monomorphization is a compilation technique where the type at which a function is applied is recorded, and if that function is polymorphic, a specialized version of the function is made. This pleases the type checker.
After this transformation there are no polymorphic functions left in the program. The price we pay is that we now essentially have two identical functions that we need to generate code for, which will occupy our precious, scarce memory on our IoT devices.
If this is a price that we are willing to pay, I believe we can make this happen very easily. The only thing we need to do is to change
box
to also accept a list ofType
s, to reflect the type of the application. We already have a typeclassSSMType
that has a functiontypeOf :: proxy a -> Type
, which marshals a Haskell type to the corresponding representation in our EDSL. If we now changebox
&Procedure
to behave like this:Two applications of
f
would then produce different code if the types of the arguments are different, while the code is still the same if the types are the same.When we transpile the above statements to the flat version which replaces the recursive
Fork
constructor with the non recursive one, we currently only inspect the name of the procedure to determine if we've seen it before. Now I suggest that we also check the type of the arguments to the procedure. If the(name, [type])
pair has not been seen before, specialize a new version of the function and put it in the map, otherwise, just do nothing. The name of this specialized version would be"f"
and some generated suffix, to make it distinct from the other versions off
. We can derive a simple name from the types to reflect the type in the name.This is a simple change that would make it specialize functions by itself, with the machinery that is already in place. We do not need to add a separate compiler pass or anything. Function applications in Haskell still are polymorphic, but behind the scenes, functions would be specialized. In #19 I wrote a bit about polymorphic functions and how we can not write polymorphic programs currently.
A big enhancement with embedding a language in Haskell as opposed to writing a custom compiler frontend is that we can piggyback on the type system in place. Being restricted to writing monomorphic programs would be a real shame. Making use of Haskell's polymorphic type system is a killer feature that we would want.