gluon
gluon copied to clipboard
The book doesn't explain how to sequence IO actions
After going through the book, there's no explanation for how to sequence IO actions. It doesn't even explain why the naive approach doesn't work, e.g.
let io = import! std.io
io.println "one" // nothing is printed
io.println "two" // this one works
I've had previous exposure to Haskell so I understand the IO monad, and the book does explain that when you have multiple statements it evaluates them both but discards the values from all but the last (side note: is this useful for anything at all besides assert
? What else has side-effects?), so I was able to figure out what was going on. But someone who hasn't seen Haskell before would probably be very confused.
Not only that, but it's not obvious how to proceed. As someone who's used Haskell before, my first thought was to try do
notation, but it appears that Gluon's do
notation is literally just an alternative way to write a single call to flat_map
rather than being like Haskell and handling both >>=
and >>
depending on whether the line is an assignment or not (also Gluon's do
doesn't appear to handle anything other than 2 lines.
Then I tried to use >>
but quickly determined that Gluon doesn't have a >>
at all. I could write io.println "one" >>= \_ -> io.println "two"
but that's pretty gross.
I finally hit upon what I believe to be the correct solution:
let io @ { ? } = import! std.io
let { (*>) } = import! std.applicative
io.println "one" *> io.println "two"
But this was non-obvious.
Ultimately, I'd like to see a few changes:
- The book should explicitly mention how to sequence multiple IO actions (and explain why the naive approach doesn't work).
- Assuming
*>
is the ideal way to handle this, the*>
operator should be in the prelude. - For that matter,
flat_map
(and probably>>=
and=<<
) should be in the prelude too. Having to import something in order for a language construct (do
) to even work is weird. - You might consider defining
>>
for Monad (I'm not sure if this should just be defined in terms of>>=
or if it should be an alias for*>
) - And finally,
do
notation should probably be enhanced to be more like Haskell in that it supports more than 2 expressions (and should probably work with 1 expression too, even though that makes it useless, because it's better than having a confusing syntax error), detects assignments vs other statements, and uses>>=
and>>
as appropriate. Such a change would likely require indenting the subsequent lines in thedo
block, otherwise you wouldn't know where the block ends, but that seems fine to me (the rule could just be the second line has to be indented at least one space past thedo
token, and subsequent lines would be indented to the same level).
Thanks for the feedback, there is definitely some things to improve here!
I've had previous exposure to Haskell so I understand the IO monad, and the book does explain that when you have multiple statements it evaluates them both but discards the values from all but the last (side note: is this useful for anything at all besides assert?
This is a hold over from an older, impure version of gluon. I have considered removing it since it isn't exactly useful for anything and it complicates the parser quite a bit. For the moment it is still around though, as an eventual effect systems (and removal of the IO
monad) would make it actually useful.
What else has side-effects?), so I was able to figure out what was going on. But someone who hasn't seen Haskell before would probably be very confused.
Noted, this definitely needs to be improved.
Not only that, but it's not obvious how to proceed. As someone who's used Haskell before, my first thought was to try do notation, but it appears that Gluon's do notation is literally just an alternative way to write a single call to flat_map rather than being like Haskell and handling both >>= and >> depending on whether the line is an assignment or not (also Gluon's do doesn't appear to handle anything other than 2 lines.
Yeah, this should be explained better.
While do
only works as a single application of flat_map
, it is possible to just keep writing do
for each "statement"
do x = action1
do y = action2
do z = action 3
expr x y z
I haven't written enough IO
heavy code to know whether that becomes far to verbose or not but I think it could be a worthwhile trade off.
As for >>
/*>
, these operators end up in a bit of a weird place in a strict language. While you don't have any side effects from the right hand side before the action actually gets evaluated as far as the high level program is concerned, there is still the side effect of executing the code to create the action which can potentially be very expensive (and might lead to stack overflows in some cases). So if do
is to be desugared to *>
then there should perhaps be a **>> : m a -> Lazy (m b) -> m b
function instead.
However, the main reason for the omission of *>
desugar is that it complicates the parser a lot (without a separate keyword which I haven't come up with yet).
For that matter, flat_map (and probably >>= and =<<) should be in the prelude too. Having to import something in order for a language construct (do) to even work is weird.
👍
You might consider defining >> for Monad (I'm not sure if this should just be defined in terms of >>= or if it should be an alias for *>)
I have tried to avoid defining multiple names for things so far as it feels like starting out with legacy code. Furthermore >>
(and <<
) is actually used for function composition http://gluon-lang.org/doc/nightly/std/function.html#value.%3E%3E since .
is a reserved operator already.
Good point about *>
with strict languages. That certainly would open up a different issue, which would be something like
let io @ { ? } = import! std.io
let { (*>), wrap } = import! std.applicative
let { (>>=) } = import! std.monad
let { (++) } = import! std.string
let { lazy, force } = import! std.lazy
let expensive_calculation = lazy (\_ -> 42)
io.println "foo" *> wrap (force expensive_calculation) >>= \x -> io.println ("done: " ++ show x)
In this example, if expensive_calculation
was actually expensive to calculate (as opposed to the obviously trivial code here) then the "starting" line wouldn't actually print until the calculation was complete.
So if do is to be desugared to
*>
then there should perhaps be a**>> : m a -> Lazy (m b) -> m b
function instead.
It occurs to me that there's no need to get Lazy
involved here, because there's no risk of the expression result being reused, so instead it could just desugar to >>= (\_ -> expression)
. That would produce the proper ordering of calculations without overhead.
And in fact I can hack my program to do this today by writing it like
let io @ { ? } = import! std.io
let { flat_map } = import! std.monad
do _ = io.println "one"
io.println "two"
So all that really needs to be done to support this is change the do
parser to detect non-assignment lines and pretend as though they started with _ =
.
Incidentally, without having looked at the parser at all, why is this particularly complicated? Doesn't the do
syntax today only allow a single identifier to the left of the =
? That suggests that you only need 2-token lookahead to determine if the do
line is an assignment or not. Though thinking about this some more, do you even need to do this at the parser level? Can you not just parse any¹ valid line after the do
, then when desugaring the do
you can inspect the line to see if it's an assignment?
¹Well, any line except another do
, and maybe a let
though ideally you'd desugar do let x = y
into do x = wrap y
instead, similar to Haskell.
So all that really needs to be done to support this is change the do parser to detect non-assignment lines and pretend as though they started with _ =.
You mean like this:
do io.println "one"
io.println "two"
I feel like it's a lot nicer than having a block where things just happen magically. It's explicit but still not too annoying. Apart from this, I really like how do
is basically just a let
with different semantics. With this change, do
would actually be very similar to Rust's ?
-Operator.
It occurs to me that there's no need to get Lazy involved here, because there's no risk of the expression result being reused,
This is not true in general. Consider
let action =
do action1
action2
do x = action
do y = action
wrap (x + y)
which would desugar to
let action = action1 **>> (lazy \_ -> action2)
do x = action
do y = action
wrap (x + y)
vs
let action = action1 >>= \_ -> action2
do x = action
do y = action
wrap (x + y)
In the second case action2
would be created twice whereas it is cached if lazy is used.
Incidentally, without having looked at the parser at all, why is this particularly complicated? Doesn't the do syntax today only allow a single identifier to the left of the =?
If only an identifier is allowed then yes, it should be possible. But I'd like to support irrefutable patterns such as records as well at some point to mirror let
and that requires arbitrary lookahead to disambiguate.
do let
is a decent compromise but I fear it might be to verbose, maybe that is ok though.
do let x = action1
do action2
...
Though thinking about this some more, do you even need to do this at the parser level? Can you not just parse any¹ valid line after the do, then when desugaring the do you can inspect the line to see if it's an assignment?
Yes, it is certainly possible to workaround that way (it is similar to what I did in https://github.com/Marwes/haskell-compiler) but if at all possible I'd like to avoid it.
It is also worth considering that with an effect system then the IO
monad could be removed and do
would then be more of a niche syntax and improving the ergonomics would be less valuable.
Adding do EXPR1 in EXPR2
works just fine with lalrpop! https://github.com/gluon-lang/gluon/pull/595/commits/0dda407f8f8aaba22c811f0aba5bd7e2baaa01c9 / #595 adds the sugar with the most simple way of translating it (no use of Lazy
)