we currently have a special way to send polymorphic types: you declare a special concrete type in the application module (Model
is the classic example of this) and then this ends up being an opaque pointer (void*
) in the host
this solves the problem of needing to send things to the host whose size varies based on the application
but this was always intended to be a stopgap until we figured out a way to offer an API like what Elm does, which is based on normal type variables and has essentially no learning curve for users
I finally thought of a way to do this!
so the goal would be to have a platform which asks the application to provide something of this type:
main : {
init: (() => model),
update: (model, event -> (model, (() => event))),
view: (model -> Elem(event)),
}
the platform would get this from the application, and then convert it to the following before sending it to the host:
main_for_host : {
init: (() -> Box(model))
update: (Box(model), Box(event) -> (Box(model), Box(() => Box(event)))),
view: (Box(model) -> Elem(Box(event)))
}
the basic idea here is that all functions and type variables that get sent to the host must be wrapped in a Box
, which gives them a statically knowable size and makes the ABI boundary easy to get right
the host can't statically know the type that's inside the box - e.g. when we generate the glue, the host will see it as essentially a Box<void>
and it has no way to look inside it to see what's in there because the host knows nothing about its size or shape
but that's fine, because the point of these is that the host doesn't need to; the host obtains the value from Roc and then all it ever does with that value is pass it back to Roc later at the appropriate time
an obvious downside of this design is that it requires boxing at the boundary
I think (but don't know for sure) that we can optimize Box.map()
to avoid unboxing and re-boxing, such that in update
in there, we only end up allocating a single model
in practice for the life of the program, and then updating that one in-place many times
event
(named msg
in Elm) would need boxing every time, but they should be very small allocations, so hopefully that's ok in practice
the functions themselves would be inside a Box
, but since they'd be top-level functions with no captures, they wouldn't actually be on the heap; they'd basically be function pointers plus a refcount that's always hardcoded to the special "immortal" refcount
so assuming all that's true, it should result in:
and then we don't need any new language features, we just have errors if you try to define a platform which sends functions to the host that contain type variables or functions that aren't wrapped in a Box
a nice thing about this design is that it means if the perf is a problem in practice, we haven't closed the door to making it more flexible in the future somehow (e.g. allowing unboxed type variables to be passed to the host) - although note that Folkert and I spent months trying to get that to work correctly and reliably, and ended up giving up.
it's not impossible, but it's so astronomically difficult that I'm very convinced we should not attempt it unless the vastly simpler "box at the boundary" design hits performance problems in practice
anyway, that seems like the way to go for the new compiler...any thoughts welcome on the subject!
Yeah. I thought we already agreed on doing this. Specifically that everything going to the host must be concrete or behind a pointer. So List model
should also be fine.
I think (but don't know for sure) that we can optimize
Box.map()
to avoid unboxing and re-boxing, such that inupdate
in there, we only end up allocating a singlemodel
in practice for the life of the program, and then updating that one in-place many times
Probably not quite without invasive transforms that would require deep analysis, but minimizing cost should definitely be doable. Specific making sure the write back is correct is the hard part. Cause you may make the new struct in a subfunction that still has access to the original struct. So inplace mutations would be problematic. But we definitely can reduce a couple of copies
we agreed on it for closures but I don't remember it in the context of type variables...but either way, seems like the way to go! :smile:
Brendan Hansknecht said:
I think (but don't know for sure) that we can optimize
Box.map()
to avoid unboxing and re-boxing, such that inupdate
in there, we only end up allocating a singlemodel
in practice for the life of the program, and then updating that one in-place many timesProbably not quite without invasive transforms that would require deep analysis, but minimizing cost should definitely be doable. Specific making sure the write back is correct is the hard part. Cause you may make the new struct in a subfunction that still has access to the original struct. So inplace mutations would be problematic. But we definitely can reduce a couple of copies
yeah one way I think this could work well in practice is:
Box.map
for the specific case where it's given an a -> b
function that doesn't change the input and output typeBox
) which means we can look at that refcount to determine uniqueness. This would require an extra Layout
for like a "refcounted struct" but I believe it could work!Sure, but in most cases, you aren't guaranteed to finish reading the struct before trying to edit it.
In practice, I assume the function would be like this:
hm, could be
Last updated: Jul 06 2025 at 12:14 UTC