-
-
Notifications
You must be signed in to change notification settings - Fork 21
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
Do notation #2
Comments
I've been playing with this a bit. There are a few problems with the implementation but I think there are problems with the concept of do-notation in Python more generally. I've made attempts at do-notation in the past and never been particularly satisfied with them. That said, I'm not actually against the idea so if we can find something that works I'll definitely consider it. With that said, I'll walk you through the problems that I see so far: First a minor issue. The old version of PyMonad had operators by default but they're very un-pythonic. I've kept them around in the operators sub-package but for general purpose stuff like your 'do' function it's best to stick to the 'bind' instead of '>>' since that will be available everywhere. More problematic, this won't work as written for other monads. Consider Writer.
Even though the functions don't do anything, I want the full log but this will only give me back the log from the last line of log(). Changing the return to a yield means I end up getting nothing back at all. We can actually fix that and the recursion problem at the same time.
This gives us what we want but we have to ban 'return' from any coroutines that we use with do because using return will end up throwing away the log. A similar problem would happen with State, etc. Except now that implementation of 'do' won't work with your original example. When we try to divide by zero we get back a Nothing (as expected). The next iteration of the loop then calls 'bind' on Nothing which simply ignores the function passed to it. As a result gen.send() is never called, we never raise StopIteration and we're stuck in an infinite loop. There are a couple ways we can solve that but neither is ideal. The first is that we can modify 'do' to check the type of the monad it's working with and if it's a Maybe (or Either) we treat it slightly differently. This is brittle though since any new monads, whether added to PyMonad directly or added by users, might also need special conditions and 'do' becomes a mess and stops being general. The second solution isn't really a solution, it just looks like one in the right light.
Basically, we pull the gen.send() call out to get the next value and then we bind to a lambda, ignoring the input and returning the next_val. This works exactly the same for the logging example but it would actually fail for the Maybe example because we'd end up with 'divided' being assigned the value None and then attempting to add None and 10, raising a TypeError. The example seems to work because the final catch-all except clause catches the TypeError and "backs up" to the last valid value and returns that instead. In your example that happens to be Nothing and so we seem to get the correct behaviour. And this is why it's not really a solution. Suppose we do something like this to the log example:
That's an actual error that needs to be fixed but the program appears to work fine. In actual fact we're getting a truncated log and masking an error, not unlike the problem with Maybe in Issue #1. |
More generally, I think do-notation in Python is just inherently problematic. Do-notation in Haskell gives an imperative-like syntax to chain or monadic binds. It looks imperative but, under the hood, it's actually not. Python, on the other hand, actually is imperative so what we're trying to accomplish with do-notation is to basically replace Pythons actual imperative semantics with our own. Which gets messy because we have to yield every line in our functions, or run into the problems above. On the other hand, using the apply, bind, map, and then methods still gives us a way to order our operations while working with Pythons imperative model. The results aren't always as nice as we might like but at least we're not fighting the language quite so hard. So, for instance, your original example I would probably write something like this:
And actually, if I add a join method (which I should because it's useful for exactly these situations) we could make it a bit cleaner:
This still isn't perfect but it's not bad. Note that join() isn't implemented (yet) so this example won't actually work if you try it. The call to apply().to_arguments() will result in a Maybe inside of a Maybe: join() unpacks that one level. Anyway, as I said I'm not actually against do-notation, I'll probably come back to it periodically. I'll leave the issue open for now because I absolutely welcome any further thoughts on the problem. |
That's fair. The reason I want to be able to use do-notation is monadic IO which composes cleanly with other monads. There is a library called effect which lets you write purely functional IO code. It has its own do-notation decorator for imperative-looking IO code, but it also lets you hook into it and interrogate what side effects it's trying to do, and substitute your own fake inputs back in for testing purposes. That's something you can't easily do with normal Python imperative code. However, effect's do-notation is hardcoded to only work with its own Effect objects, so it doesn't compose with other monads (via monad transformers or some other compositional mechanism). A Python do-notation that works on arbitrary monads would be quite useful in this context. |
I wasn't aware of effect, thanks for pointing that out. I haven't dug into it very deeply but I took a quick look and had some thoughts. I've not testing this with all monads yet so there might be problems but I think this might actually work. I'll detail the implementation in the next comment, for now I'll just talk about the syntax. One thing I noticed about effect was that the do blocks didn't return functions/generators but actually returned an Effect value (I think, I may have misunderstood exactly how it was being used), which makes sense since that what a do block should return. So my implementation requires that the coroutine takes zero arguments. You can always wrap it in a function to send it arguments if necessary. The second thing is that, in Haskell, we just return the value and not an already wrapped value. In other words, in Haskell:
For that to work we're going to need to know what monad we're dealing with so we end up with something like this. So we get this:
Notice that add_1_2 is an actual Maybe value and we can treat it as such and continue using it with other monad operations. We can even use your safe_div function from upthread:
We can do a similar thing with Writer:
Here the return appends an empty string as the message so but we still get the log from the other two Writer values. If we wanted a message we could do this instead:
So far so good. But this also makes it possible to do things like this:
So we've got a Writer inside of a Maybe. There aren't proper monad transformers in pymonad so at the moment you'd have to deal with these kinds of values manually but that would've been the case anyway. You can, of course, create values like this manually or using the monad methods without a do-syntax but this is not horrible, at least from my perspective. |
The implementation is actually simple and not too different from what we had up above: In the monad module:
I'll have to do some thorough testing before I'm willing to add it as an actual feature but feel free to play around with it and suggest any changes/additions you think would help with your specific use case and we'll see what we can do. |
Can't it just be
So that the coroutine can take arguments? |
You could. My thinking is that if you do that though then what you get back is a new function/generator and not a Maybe (or whatever) value. Or a function/generator wrapped in a monad. Not really sure which is best. You can always wrap the do block to inject values.
Maybe that's less clear/convenient though. We'd also have to change the return value from do:
Otherwise we can't actually pass it arguments at all. If we change nothing else then this doesn't work quite right.
The original seems clearer to me but I don't have a clear idea of your use case so your idea might be better. Do you have an example of how you want to use it by any chance? |
Did anything ever come of this? I'd like to use do-notation rather than deeply nested if/else or a huge flatMap chain of lambdas. I found this: https://github.com/miiohio/ziopy but not particularly enamored with it given all the ZIO-specific stuff and the lack of clarity around how the do magic actually works (mostly the ZIO naming, environment, types, etc). |
?? |
Oh hey, sorry, I somehow missed your previous comment. But to answer your
question: unfortunately no, nothing ever came of it. I've not done much
with pymonad at all for quite a while because I've just not had the time or
energy to do so.
The only other thought I've had but not really explored is somehow using
the 'with' statement to create a monad context to work within. But, like I
said, I haven't really explored it.
…On Mon, Mar 6, 2023 at 11:40 AM jleonard-r7 ***@***.***> wrote:
??
—
Reply to this email directly, view it on GitHub
<#2 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ALD4GMTNYDI5TNKOFD3VQ6TW2YHQLANCNFSM4RST5VTQ>
.
You are receiving this because you commented.Message ID:
***@***.***>
|
Yea, something like the |
Yeah, that's pretty much the problem I run up against any time I take
another look at this idea. There's always some sort of trade-off that makes
it more of a pain than it's worth. I'm reasonably convinced that there's
just not a good solution to the problem unfortunately.
…On Mon, Mar 6, 2023 at 2:14 PM jleonard-r7 ***@***.***> wrote:
Yea, something like the ExitStack could work but the problem would be
getting the individual bindings. Otherwise, a normal context per binding
would still result in "nesting hell" (which is the whole syntactic problem
i'm mainly trying to solve).
—
Reply to this email directly, view it on GitHub
<#2 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ALD4GMUMCGBZ5KH5TK2DW3LW2YZS3ANCNFSM4RST5VTQ>
.
You are receiving this because you commented.Message ID:
***@***.***>
|
Well, at least not until this is passed: |
Indeed. Wasn't aware of that PEP, thanks for pointing it out. I guess it's
true, everything really does become LISP sooner or later!
…On Mon, Mar 6, 2023 at 3:16 PM jleonard-r7 ***@***.***> wrote:
Well, at least not until this is passed:
https://peps.python.org/pep-0638/
—
Reply to this email directly, view it on GitHub
<#2 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ALD4GMWM5ND56DW4KQYDAOLW2ZA27ANCNFSM4RST5VTQ>
.
You are receiving this because you commented.Message ID:
***@***.***>
|
Well except that LISP’s homoiconocity would come in really nicely for this stuff. But yea I’ve long considered these scripting languages to be mere imperfect reflections of LISP. |
I implemented a The following nested code runs (surprisingly) without monad transformers: from donotation import do
from pymonad.maybe import Just
from pymonad.writer import Writer
pymonad_do = do(attr='bind')
@pymonad_do
def stacked():
x = yield Just(1)
y = yield Just(2)
@pymonad_do
def inner_write():
yield Writer(x + y, f'adding {x} and {y}')
return inner_write()
# Output will be (3, adding 1 and 2)
print(stacked()) https://github.com/MichaelSchneeberger/do-notation Let me know what you think. |
With coroutines we can implement a Haskell-ish do-notation. Call them "doroutines":
We yield monad values out of the coroutine, and get unwrapped values back. The decorator handles the bookkeeping logic. Here's a rough implementation:
The main deficiency here is that we are limited by the Python interpreter's maximum recursion depth of about 1000. I tried to convert it to an iteration but it gave me a headache since we're returning a function each time rather than a value :S.
It currently relies on the generator raising a
StopIteration
exception to get the return value, which is incompatible with theEither
monad which is supposed to capture exceptions and halt their journey up the stack. I see two ways of dealing with this: first, keep thedo
implementation the same and have theEither
monad log all exceptions other thanStopIteration
; second, create some kind of wrapper/decorator for generators so that they use anEither
monad to signal their return value instead of an exception, then alterdo
so that it exits when it sees this type of error. I think the second one is preferable.I've only tested it with
Maybe
so far. I had to editMaybe
so that it doesn't swallow errors as I mentioned in #1. For that I just commented out some parts in themaybe.py
file:I'm not familiar enough with the codebase to figure out a more robust solution yet.
The text was updated successfully, but these errors were encountered: