FYI, I recently merged a new thing: the Iter builtin type!
here's how they work on a basic level:
Iter builtin, which lets you iterate over thingsList, Iter doesn't allocate; it just uses closures (which don't allocate in Roc) to store what comes nextList.iter : List(item) -> Iter(item)Iter instead of List (e.g. 5.to(7) now returns an Iter of whatever number type you used)for loops no longer take a List, but rather something with an iter method (which includes both List and Iter itself)for loop over a range without allocating, but also you can give lists directly to for loops and they Just Work, so this is a backwards-compatible change to forIter.map, Iter.keep_if, and Iter.fold, and a couple others; we can add more in the futureIter will eventually unlock deforestation optimizations, but that's not a priority right nowiter() methods, e.g. Dict.iter, Set.iter - this way they can also be used in for loops and Just Workthis is a huge milestone in the new compiler because Iter was never implementable in the old Rust compiler; it always crashed that compiler (because of the closure stuff and lambda sets) so Iter marks the first time the new compiler has demonstrated a fix to the type of longstanding lambda set bug that we concluded could not possibly be fixed without rearchitecting...and now it's real! :smiley:
I'm happy about this. As someone with a Clojure background it's always bugged me that functions building on map/fold in typed functional languages were tied to List instead of being seq/iterator based.
That's, but I have a couple questions if you don't mind :)
1) what is the goal with the Skip() case in the iterator? is it for some future optimizations? I am coming from C# where the skip of elements is silent - the Next() call just spins until it can yield the next non-skipped value. This implementation also has to do that to fill the rest part
2) why the until and to (like U8.to(1, 4) have the length as unknown?
3) why are the until and to function repeated for all the types? I though there is something like Num * and Int *, and I would think that you can make this one a generic? I guess you want them as methods inside of the type, and since there is no inheritance, you have to repeat them
4) are there plans for a yield keyword to have the iterator state machine like other languages have? Implementing something like Fibonacci sequence iterator is much easier this way, but it "looks" stateful. On the other hand, loops and variables are already "a thing", so maybe?
5) will there be "effectful iterators"? like File.iter_lines(). That would need the next function to be called next!, which would make writing functions that can take both pure and unpure iterators a bit wonky.
I know this is a lot, and it's not a code review! but I just want to learn :)
thanks for the questions @Krzysztof Skowronek!
1) what is the goal with the Skip() case in the iterator? is it for some future optimizations? I am coming from C# where the skip of elements is silent - the Next() call just spins until it can yield the next non-skipped value. This implementation also has to do that to fill the
restpart
yeah it's for performance - things like .drop(1000) can be represented much more efficiently, but also it means keep_if doesn't need recursion, which is especially relevant when we get to deforestation/fusion (e.g. in this paper the section on "No recursion: filter" talks about why Skip is important to have)
2) why the
untilandto(likeU8.to(1, 4)have the length asunknown?
ha, that's an oversight, thanks! I'll populate that in a separate PR.
3) why are the
untilandtofunction repeated for all the types? I though there is something likeNum *andInt *
There used to be, but in the static dispatch world, that hierarchy is gone (by design), so this is the optimal way to do it.
4) are there plans for a yield keyword
no plans - I'd want to start with a concrete use case (e.g. "I want to build ____ but it is currently impossible")
5) will there be "effectful iterators"?
I think these would actually be called "Streams" - I have some design ideas in this direction, but I don't think it's a high priority right now. :smile:
Richard Feldman said:
2) why the
untilandto(likeU8.to(1, 4)have the length asunknown?ha, that's an oversight, thanks! I'll populate that in a separate PR.
glad I could help!
3) that's a bit of a shame, C# got so much better after they introduced the INumber interface. There is no hierachy at all now?
5) I guess the streams will also include asyncness in them? I haven't seen anything around async for Roc yet, but I am sure something is coming, right?
C# got so much better after they introduced the INumber interface
:raised: how so? Like what did people use it for?
5) I guess the streams will also include asyncness in them? I haven't seen anything around async for Roc yet, but I am sure something is coming, right?
the plan is to do it like Go: effects can be nonblocking as a behind-the-scenes implementation detail (the platform controls this - we have a proof-of-concept of doing this, but don't have a production platform implementing it yet), but you don't need async or await. So for example, when you call response = Http.get!(...) then it's semantically the equivalent of writing response = http_get(...).await in Rust or response = await http_get(...) in JS/TS, and you don't need to mark the function as async. It's just nonblocking behind the scenes with performance implications only.
Richard Feldman said:
C# got so much better after they introduced the INumber interface
:raised: how so? Like what did people use it for?
C# doesn't have structural typing or duck typing (usually, it's a long story :) for example the await keyword uses duck typing - you can await anything that has GetAwaiter method, no interface implementation)
The INumber interface was built around a new at a time feature of having static members in interfaces. INumber docs
I guess it solves things that Roc already has solutions for, but before that intetrface having a generic mathematic function wasn't possible:
T CartValue<T>(T[] numbers)
where T : INumber<T> // <-- Don't forget the constraint!
{
var result = T.Zero; // this is a static interface member
foreach (var i in numbers) result += i; // the + operator is also part of the interface
return result;
}
There is a whole tree of those interfaces, for integer number, non-negative and so on.
In case of Roc, first example that comes to mind are these iterator ranges - there could have been a generic implementation that you just call from each type, instead of copy-pasting many times. Even the fix for the iterator length is now more involved because of that.
I actually really like that in Roc, but if it is a trade off worth doing - I won't cry because of that
as for async I was thinking more about paralelism probably
I use F# at work now and I am in awe about how easy it is to handle async/parallel stuff, especially around collectiosn, and lazy collections at that. It has lazy async futures likes Rust, so you can do something like
// this probably won't compile, but you get the idea
Directory.EnumerateFiles("some_path", Options.OnlyDirs) // this is "lazy" iterator
|> Seq.map (fun dir -> async {
let! zip = zipDir dir //let! is await
return! uploadAsync zip
})
|> Async.Parallel 5
At work I have a pipeline that reads from an sql cursor in batches, feeds that as async stream/sequence, that get's filtered in smaller batches with an api call, then it gets uploaded one by one (up to 10 at a time) to S3 (there is some funky logic around paths, so it's easier one by one)
the code reads like english - do that, for each batch do that 5 at a time and so on - all with Result based error handling built in
Everything around threadpool, backpressure is also built in - you just set a limit on RAM and that's it.
I've been joking with the team that I will rewrite this service in Roc as soon it comes out :)
yeah regarding this:
T CartValue<T>(T[] numbers)
where T : INumber<T> // <-- Don't forget the constraint!
{
var result = T.Zero; // this is a static interface member
foreach (var i in numbers) result += i; // the + operator is also part of the interface
return result;
}
here's how this would look when directly ported to Roc - you don't need the formal interface at all:
cart_value : Iter(num) -> num
where [
num.default : () -> num,
num.plus : num, num -> num,
]
cart_value = |numbers| {
Num : num
var $result = Num.default()
for i in numbers {
$result = $result + i
}
$result
}
and you could pass any builtin number to that, as well as any user-defined number that defined both plus and default
that F# example is exactly the type of thing I want to make super nice
I don't think the concurrency stuff is a hard requirement for the 0.1.0 release we want to ship in 2026 though
more like 0.2.0
Cool, fingers crossed :) that also would it a bit complicated with the platforms so good call
Krzysztof Skowronek said:
I use F# at work now and I am in awe about how easy it is to handle async/parallel stuff, especially around collectiosn, and lazy collections at that. It has lazy async futures likes Rust, so you can do something like
// this probably won't compile, but you get the idea Directory.EnumerateFiles("some_path", Options.OnlyDirs) // this is "lazy" iterator |> Seq.map (fun dir -> async { let! zip = zipDir dir //let! is await return! uploadAsync zip }) |> Async.Parallel 5
Out of curiosity, is there anything specific to F# compared to C# that makes that work especially well? in my mind this kind of thing could be easily done in C# as well using something like
await Directory
.EnumerateDirectories("some_path")
.Select(dir => async (CancellationToken ct) =>
{
var zip = await dir.ZipAsync(ct);
await zip.UploadAsync(ct);
})
.ParExec(maxConcurrency: 5, cancellationToken);
where .ParExec could be a self-defined extension member on any TIter: IEnumerable<Task<TResult>>.
You would build more complex flows around https://fsprojects.github.io/FSharp.Control.AsyncSeq/
And yeah, it's pretty much the same, but in C# you don't get the Result out of the box, and you have to make the async functions lazy by hand like you did.
It just goes to show that C# is also a really good language, even getting discriminated unions in the next release
Thanks for the clarification. I agree, there have been lots of cool improvements to C# lately and are in planning. Working in roc would still be more fun though ;)
I might have missed it - but is there a collect type function for consuming an iterator into a list or collection? I would have thought there would be something like List.collect()/from_iter() or Iter.to_list() etc. but couldn't see one.
Would fold : Iter(a), acc, (acc, a -> acc) -> acc be the solution?
Oh for sure that's what I have currently, but given that there may be a known Iter length to guide an optimisation, or just as shorthand for what feels like a common operation instead of it.fold([], Str.append) (which really isn't bad haha I guess I'm just used to Elixir)
oh yeah we should add List.from_iter : Iter(item) -> List(item) - just an oversight!
happy to accept a contribution for that :smiley:
and yeah it can automatically use List.with_capacity and Iter.len_if_known : Iter(_item) -> [Known(U64), Unknown] (which we should also add - another oversight!)
Richard Feldman said:
happy to accept a contribution for that :smiley:
I would but I'm out for the week! I'll give it a shot when I get back if it's still available.
I would be happy to add this to the builtins :)
so:
U8.until(0,10).filter(|x| x % 2 != 0).collect() - the collect method has to be in the Iter type if I am not mistaken)does that sound about right? for now I am trying to compile roc on my windows machine which so far is not trivial :)
Im interested to know more about the Windows issues your experiencing... I assumed it was just install Zig and your gtg. Is our docs for contributing up to date based on your experience?
no, it's pretty much that, but my windows installation is a bit borked - "just install zig" is enough of a challenge
I had some random dev build laying around that was installed by choco pre-release package, and it was taking precedence over the new install I did with winget (I of course forgot that I played with zig 3 years ago and that I used choco then). zls was a similar story, and then git bash kept crashing when I was setting up commit signing
so yeah, 2 hours to do 10 lines of contribution
Krzysztof Skowronek said:
I would be happy to add this to the builtins :)
so:
- add known lengts to ranges
- add Iter.collect (so that it can be chained like
U8.until(0,10).filter(|x| x % 2 != 0).collect()- the collect method has to be in the Iter type if I am not mistaken)- Iter.len_if_known to expose the length
does that sound about right? for now I am trying to compile roc on my windows machine which so far is not trivial :)
Would it also make sense to have what Richard suggested with the collecting module defining a from_iter? That way I think(?) you'd also be able to dispatch on the destination type, in case a dict, set, etc wants to define its own from_iter but the caller is generic to the destination? I'm not sure if I'm off base here.
I was trying to create a Fibonacci sequence iterator, but I can't get it to work:
fib_iter : Iter(U64)
fib_iter = {
adv : ((U64, U64) -> Try((U64, Iter(U64)), [NoMore])) # same thing without this type annotation
adv = |(a, b)| Try.Ok((a, Iter.custom((b, a + b), Unknown, adv)))
Iter.custom(
(0.U64, 1.U64),
Unknown,
adv,
)
}
for f in fib_iter.take_first(5) {
dbg (f)
}
it compiles, but crashes in runtime:
roc.exe .\my.roc
thread 21812 panic: postcheck invariant violated: iterator step type was missing Skip
but it's 1AM for me, so time to go to sleep
I'll look into that, thanks for the repro!
That fib_iter looks :fire: (if it was working as expected) .. I'm so hyped for Roc
I'm really happy about the syntax and how these language features come together to enable something like that
@Krzysztof Skowronek yeah, Iter.collect should in theory be:
collect : Iter(item) -> output where [
output.from_iter : Iter(item) -> output
]
collect = |iter| {
Output : output
Output.from_iter(iter)
}
fix for the fib iter example: https://github.com/roc-lang/roc/pull/9535
Btw, I read the iterator fusion paper, and I noticed that the current implementations of take_first, skip_first (and my fib iterator) are recursive
I don't claim to fully understand the paper, but one of the topic they repeat over and over is that the steps cannot be recursive if you want to final compiler to eliminate them
Also, if they are recursive, bit not in the tail position, doesn't that impose a limit on the lenght based on the stack depth?
I will get back to this later, now I have to do my banking job, even though this is way more interesting
oh yeah good point :smile:
I looked at this again, and in the paper the next function (step in Roc) actually takes the previous "state" as parameter - that's how you can get the next state without recursio
hm I haven't thought deeply about this since I looked into it over a year, but I remember needing to be careful about not accidentally storing extra copies of things and therefore making them ineligible for in-place mutation
I don't think passing the old state as a parameter to the function would do that, but I'd want to double-check :smile:
I want to have some Zig tests that assert uniqueness of various builtin operations, and this would be a good example of why!
I am playing with changing these things into what the paper describes now, but then you have to have a second generic parameter for the state, and I am not sure if it can be made hidden by some type tricks
and I wouldn't be happy if all that went to waste
in terms of in-place mutation, if the state object is hidden from the outside consumers, then it should be fine, since it would only get ref count above 1 if you actually copy the iterator reference - and that should be fine
btw, I noticed this error showing up really often for me:
The name iter is being redeclared in this scope.
The redeclaration is here:
ÔöîÔöÇ C:\Users\KrzysztofSkowronek\source\roc\src\build\roc\Builtin.roc:429:24
Ôöé
429 Ôöé collect = |iter| {
Ôöé ^^^^
But iter was already defined here:
ÔöîÔöÇ C:\Users\KrzysztofSkowronek\source\roc\src\build\roc\Builtin.roc:411:3
Ôöé
411 Ôöé iter = |self| self
Ôöé
(I don't know why the zig build rebuild-builtins doesn't print utf right :D) the names of lambda parameters are in conflict with functions in the type - is that on purpopse? I guess there is no shadowing in Roc but this is surprising
I will throw out one more thing - in case of known length, shouldn't it go down by 1 on every step?
in case of known length, shouldn't it go down by 1 on every step?
ooo, good call!
I am playing with changing these things into what the paper describes now, but then you have to have a second generic parameter for the state, and I am not sure if it can be made hidden by some type tricks
can you say more about this? I think I remember looking into this and concluding there was a way to do what we needed to do without introducing a second type param, but that was so long ago I don't remember the details anymore :sweat_smile:
as an aside, I appreciate your getting into this! :smiley:
there is this range_done method that returns know(0) - so probably it was the idea from the start
I'm really excited about actually implementing fusion, and it helps having someone else focusing on it
Richard Feldman said:
I am playing with changing these things into what the paper describes now, but then you have to have a second generic parameter for the state, and I am not sure if it can be made hidden by some type tricks
can you say more about this? I think I remember looking into this and concluding there was a way to do what we needed to do without introducing a second type param, but that was so long ago I don't remember the details anymore :sweat_smile:
so I ended up with something like this:
Iter(item, state) :: {
len_if_known : [Known(U64), Unknown],
state : state,
step : state -> [One({ item : item, state : state }), Skip({ state : state }), Done],
}.{
# The general unfold: the stream-fusion `Stream` constructor. `advance` maps a
# seed to either the next element paired with the next seed, or `NoMore`.
custom : state, [Known(U64), Unknown], (state -> Try((item, state), [NoMore])) -> Iter(item, state)
custom = |seed, len_if_known, advance| {
len_if_known,
state: seed,
step: |s|
match advance(s) {
Ok((item, next_seed)) => One({ item, state: next_seed })
Err(NoMore) => Done
},
}
iter : Iter(item, state) -> Iter(item, state)
iter = |self| self
# Take a single step. The `rest` returned reuses the *same* stepper and only
# swaps in the advanced seed, which is what keeps the producer non-recursive.
next : Iter(item, state) -> [One({ item : item, rest : Iter(item, state) }), Skip({ rest : Iter(item, state) }), Done]
next = |iterator| {
rebuild = |new_state| { len_if_known: match iterator.len_if_known {
Unknown => Unknown,
Known(l) => Known(l - 1)
}, state: new_state, step: iterator.step }
match (iterator.step)(iterator.state) {
Done => Done
Skip({ state }) => Skip({ rest: rebuild(state) })
One({ item, state }) => One({ item, rest: rebuild(state) })
}
}
but at this point I think that the state part needs to be [Done, State(state)] or something like that so that you can return the range_done version
also, my claude is out of tokens for this session so I probably won't get into the zig compiler part today - it's a bit too new to me
the builtin.roc doesn't compile because of all the for loops with this, and I don't know the new design of Roc enough to try to work around the generic param
cool, I can look into it tonight
and see if I can remember/figure out how to do it without the second type param, assuming it's possible
also, this: match (iterator.step)(iterator.state) { I wanted to replace with match (iterator.state)->iterator.step {; but that didn't work
I can push the branch if you want, but it's not very impressive for sure
(iterator.state)->iterator.step - ha, that's cool! I don't think anyone has tried that before :laughing:
can you open an issue for it?
(iterator.state)->(iterator.step) might work btw
(but both should)
Richard Feldman said:
can you open an issue for it?
for (iterator.state)->iterator.step? I guess :) I can try to prepare a more focus repro than the builtins
right, I had a chat with Claude about this and got a pretty nice answer - short version is that this should optimize the way we want (once we get rid of the recursion, as you noted earlier) because Roc's lambda sets perform polymorphic defunctionalization, which means our existing use of closure captures to avoid a second type parameter should Just Work once we get the optimization flow implemented
longer version, summary from Claude:
Straight answer: with Iter at one type parameter, closure-captured state, and polymorphic defunctionalization, you can reach the same fused result the paper gets. It's not a workaround or a near-miss — it's the same destination by a different route.
Here's the design-level why, in three steps:
- The state lives in the lambda set, so one type parameter is genuinely enough. The paper hand-threads a state value of some concrete type per pipeline. You instead capture that state in step's closure, and defunctionalization collects all those captures into the lambda set attached to step's function type — not to Iter's parameter list. So Iter(item) stays single-parameter while the per-stage state is fully represented. The one-param invariant and "carry a state" are not in conflict once the carrier is the lambda set.
- Defunctionalization produces exactly the concrete state type the paper assumes. The paper's whole trick is that, for a given pipeline, the existential state resolves to one concrete, finite, flat type, and the stepper is a fixed function over it. Polymorphic defunctionalization at a monomorphic use site gives you precisely that: a straight-line pipeline's captured state becomes a finite tag value of invariant shape, and the consumer loop threads it. You arrive at "fixed stepper over a concrete state type" by computing the closed lambda set instead of by quantifying it away — but the thing you end up with is identical, and you actually know it more statically than the paper does at the equivalent point.
- The set of programs that fuse is the same set the paper fuses. Fusion is reachable exactly where the defunctionalized state is finite and non-recursive at the use site — straight-line pipelines. Where an Iter is chosen at runtime (a join, a branch, stored heterogeneously), the lambda set is genuinely recursive/open, there's no single static state, and it won't fuse — but the paper doesn't fuse those either; an existential Stream selected at runtime forces dynamic dispatch in the consumer just the same. So you're not giving anything up relative to the paper by going the defunctionalization route. Same fusable class, same fallback class.
So: one type parameter + closure captures + polymorphic defunctionalization is, at the design level, sufficient to express paper-grade fusion. The capability is in the model you already have; nothing about the single-parameter constraint blocks it.
I now vaguely remember reading through the paper and concluding "we don't have to do the separate state parameter for a Roc-specific reason" and in retrospect I think that reason was lambda sets :smile:
so that covers the recursion also I guess
it will be a decent mental exercise to create the iterators like the Fib one though, but if it works it works :D
yeah!
I really want it to be 1-param haha...worth the extra effort
I think the ergonomics difference is huge
I can see why
I'll create the repro for pipe operator, and google like 15 words from the claude response
do you want to make a PR for making those other operations non recursive?
I though you just told me that they can stay recursive?
no, sorry
I meant the above analysis was assuming we'd already gotten rid of the recursion
the recursion blocks the optimization no matter what, but it's a separate consideration from the state
I think the only thing we're missing before we could actually implement the actual fusion "remove pairs of nested of to/from calls" optimization is doing our own inlining. Right now we just let llvm do that, but we want to inline earlier than that
that shouldn't be a big task though, I can try to land that and then we can look into eliminating the call pairs
I am happy to try something, but I don't see how you code that without the state passing and without recursion
in theory, since our lambdas are unboxed, llvm should do the test of the optimization for us once we've eliminated the pairs (because at that point our code becomes equivalent to Rust iterators, which already optimize all the way down to the equivalent of for loops)
for which one specifically?
Krzysztof Skowronek said:
Richard Feldman said:
I am playing with changing these things into what the paper describes now, but then you have to have a second generic parameter for the state, and I am not sure if it can be made hidden by some type tricks
can you say more about this? I think I remember looking into this and concluding there was a way to do what we needed to do without introducing a second type param, but that was so long ago I don't remember the details anymore :sweat_smile:
so I ended up with something like this:
Iter(item, state) :: { len_if_known : [Known(U64), Unknown], state : state, step : state -> [One({ item : item, state : state }), Skip({ state : state }), Done], }.{ # The general unfold: the stream-fusion `Stream` constructor. `advance` maps a # seed to either the next element paired with the next seed, or `NoMore`. custom : state, [Known(U64), Unknown], (state -> Try((item, state), [NoMore])) -> Iter(item, state) custom = |seed, len_if_known, advance| { len_if_known, state: seed, step: |s| match advance(s) { Ok((item, next_seed)) => One({ item, state: next_seed }) Err(NoMore) => Done }, } iter : Iter(item, state) -> Iter(item, state) iter = |self| self # Take a single step. The `rest` returned reuses the *same* stepper and only # swaps in the advanced seed, which is what keeps the producer non-recursive. next : Iter(item, state) -> [One({ item : item, rest : Iter(item, state) }), Skip({ rest : Iter(item, state) }), Done] next = |iterator| { rebuild = |new_state| { len_if_known: match iterator.len_if_known { Unknown => Unknown, Known(l) => Known(l - 1) }, state: new_state, step: iterator.step } match (iterator.step)(iterator.state) { Done => Done Skip({ state }) => Skip({ rest: rebuild(state) }) One({ item, state }) => One({ item, rest: rebuild(state) }) } }but at this point I think that the state part needs to be
[Done, State(state)]or something like that so that you can return therange_doneversionalso, my claude is out of tokens for this session so I probably won't get into the zig compiler part today - it's a bit too new to me
this
ah, ok I can look into it tonight
on mobile right now :smile:
to my understanding you can either have each iterator have this state object and next function takes it as input, or you make the next function recursive so the state is "baked in" so you don't need the type param for the state
Krzysztof Skowronek said:
I'll create the repro for pipe operator, and google like 15 words from the claude response
https://github.com/roc-lang/roc/issues/9548 seems like record.some_function needs to wrapped in parenthesis, otherwise the compiler looks for a method with that name on the type (anonymous in this case)
oh I see
I guess the error message didn't make that clear
I guess it's useful to keep as-is
bc then you can express more different things compared to if we made it do the same thing with and without the parens
but maybe we could improve the error message
open System
let r = {| Number = 1; Double = fun x -> x + x |}
let result = r.Number |> r.Double
printfn "%d" result
this works in F#
also, I think the only case for record.something(some) for conflict is something like
Record :: { field: |x| x}.{
field = ....
}
which is already illegal
ok here's the change: https://github.com/roc-lang/roc/pull/9550
I also included your "decrement known count" change and also removed the Skip payload - turns out if we actually used that it would break fusion, so having Skip not have a payload (like it doesn't in the paper) is actually the only way to do it if we want fusion to work :smile:
the critical difference is that instead of Iter.custom's "callback" returning another Iter, it just closes over a state and produces another one of those
- (state -> Try((item, Iter(item)), [NoMore]))
+ (state -> Try((item, state ), [NoMore]))
Looks nice! So because of this "defunctionalization" it's ok that Iter.custom calls Iter.custom and take_first calls take_first and so on?
That's the recursion I was talking about
ah I thought you were talking about Iter.custom returning Iter
I mean, it's the same thing, different operations
Currently the map will fail on a long enough iterator with attack overflow, right?
sorry, I don't follow :sweat_smile:
can you give an example?
Isn't it a recursive function in a non-tail position? Or just because it assigned the same function to a field in a record that breaks the recursion?
Hmm, maybe I need more sleep then
Basically, inside of map you return a closure that calls map
That's like lazy recursion I guess
I don't think that's specific to iterators, is it? :smile:
you can do that in general, and then if the original function gets called, it'll potentially call itself
I'll try to let the fib iterator go on for a while in the evening mapping to 1 or something to calm my worries
update on fusion: I learned about a problem with it I wasn't aware of previously, and I actually think we shouldn't do it after all - details/discussion in
a big breaking change to the host-platform ABI boundary just landed, so if you're working on a platform you'll want to regenerate glue for it!
benefits:
extern" and then mark it as having weak linking and that's it; no need to wrap arguments in Roc-specific structs anymoreLast updated: Jun 16 2026 at 16:19 UTC