jasondelaat / pymonad

PyMonad implements data structures typically available in pure functional or functional first programming languages like Haskell and F#. Included are Monad and Monoid data types with several common monads included - such as Maybe and State - as well as some useful tools such as the @curry decorator for defining curried functions. PyMonad 2.x.x represents an almost complete re-write of the library with a simpler, more consistent interface as well as type annotations to help ensure correct usage.
BSD 3-Clause "New" or "Revised" License
199 stars 22 forks source link

All #21

Open JesterXL opened 3 years ago

JesterXL commented 3 years ago

Two things.

First, I really love this project, and have been teaching FP to programmers using Python. While I've been a huge fan of dry/returns, I keep coming back to PyMonad, especially the new version with .then. Thanks for all the hard work, it's changing lives.

Second, curious about y'alls thoughts for an all construct. Putting things in tuples/lists and then extracting then in-between thens is quite verbose. For example, in JavaScript, we can do multiple values:

const [ uno, dos ] = await Promise.all([ 1, 2 ])

And can mix and match values too:

const [ uno, dos ] = await Promise.all([ 1, Promise.resolve(2) ])

It'd be nice to have both an all for Maybe, and all for Result, and have them work within a then. For example, here's pseudo all for Maybe:

from pymonad.maybe import Maybe, Just, Nothing
from functools import reduce
​
def is_nothing(value):
    if isinstance(value, Maybe):
        return value.is_nothing()
    return False
​
def get_or_nothing(something):
    if isinstance(something, Maybe):
        return something.maybe(Nothing, lambda value: value)
    return something
​
def get_maybe_and_add_to_list(array, value):
    array.append(
        get_or_nothing(value)
    )
    return array
​
def all(*args):
    dem_args = list(args)
    # find any Nothings
    nothings = list(
        filter(
            is_nothing,
            dem_args
        )
    )
    print("nothings:", nothings)
    if len(nothings) > 0:
        return Nothing

    values = list(
        reduce(
            get_maybe_and_add_to_list,
            dem_args,
            []
        )
    )
    return values
​
print(all(Just(1), Just(2), Just(3))) # [1, 2, 3]
print(all(Just(1), 2, Just(3))) # [1, 2, 3]
print(all(1, 2, 3)) # [1, 2, 3]
print(all(Nothing)) # [Nothing]
print(all(Nothing, Just(1))) # [Nothing]
print(all(Nothing, 1)) # [Nothing]

Maybe the results should contain the Just, but you get what I'm saying. There's no way to do 2 things at once which means we'll get tuples/lists that have mixed mixed values and Maybes/Results and it's quite painful to debug without types. Maybe all isn't such at a great name. Like all could get all values, but Nothing and Error would stop, or maybe we could enhance .then to take lists of Maybe/Result?

Anyway, I'm used to Promises handling this kind of stuff, and Folktale.js v2 in JavaScript has this same problem; there's no way to do multiple things at once.

jasondelaat commented 3 years ago

Hey, thanks so much for the kind words and I'm really glad that you find the project useful!

Let me start with my now standard apology for taking so long to reply. Life's been hectic lately so pymonad, sadly, tends to get pushed to the side.

I like this idea and I'm pretty sure there are similar functions in haskell (for example) for doing this kind of thing. I'm a little wary of the mixed type arguments since that's not something you would typically be able to do in "proper" typed functional language and allowing mixed types seems like it's asking for errors. On the other hand, the 'then' method already blurs that line somewhat so maybe it's not such a big deal as long as it's documented. Little "quality of life" improvements like this can certainly be handy.

I think this might actually be as simple (famous last words) as implementing monoid instances on everything. I have vague recollections of a function which turns a list of monoids to a monoid of a list, which is basically what you're after here. That wouldn't allow for mixed type arguments but should be fairly easy to extend to that use case. And then it would work with all, or at least most, of the existing monad types.

Thanks for the suggestion! I'll try to work on it in the next few days and let you know what I come up with.

JesterXL commented 3 years ago

Hah, dude, even if we weren't in a global pandemic, noooo expectations and no worries.

Didn't really think of the typing. On the one hand, I like how dry/returns expects you to use MyPy and enforce typings. However, no Python people where I work use it. Like one person other than me used async/await once. So I feel like even basic using of Typings just ain't happening. Those not using Go are happy with Python's dynamic nature.

However, having that enforce the type rules is interesting. The challenge there is how do we combine things? For example, if it becomes hard to do so, then I'd just end up using Result for everything instead of Maybe. I just figured both Nothing and Error were both "left type things", but on the other hand, you'd not want to combine them. It's the all use case which is hard.

Like, if I need a configuration from environment variables, a local JSON parsing service, and a value, the first and last can't really fail because I'll have a default; only the 2nd would be bad.

# No env? No big deal.
def get_environment():
  if os.environ.get('env') is not None:
    return Just(os.environ['env']
  return Nothing

# Dude, this fails, we're toast.
def get_endpoint():
  try:
      url = json.loads('config.json').read()['endpoint']
      return Ok(url)
  except Exception as e:
      return Error(str(e))

def get_log_prefix():
    return 'testing-'

Now, normally, you'd go:

env, url, prefix = all(get_environment(), get_endpoint(), get_log_prefix())

But some interesting behavior here, and I'm biased by JavaScript so bear with me.

What happens if get_endpoint fails? I'd assume url would be a Result.Error(...) in this case. Then I'd have to unpack everything.

... but why do I have to unpack? Why I can't it just flatMap like Promise does?

Because we don't have higher kinded types and I had to emulate them in Python, Jesse... Oh, right. That goes back to your type argument as it's hard to support all safely.

BUT, let's say you did. What happens then for get_endpoint? Does it raise an Exception? In dry/returns or Rust, you'd call unwrap and either a value would come out, or it'd raise an Exception. JavaScript Promise, about the same using then/catch.

Folktale assumes you map & chain the same types like you mentioned, so it's on you to unwrap or re-wrap when you need to combine, which is probably why Quil didn't create combine methods like all/then, etc. HOWEVER, she does have various `Maybe.fromNullable or maybeToResult type methods. Perhaps those combined with Maybe.all and Result.all would give you a fighting chance.

JesterXL commented 3 years ago

@jasondelaat DUDE https://www.python.org/dev/peps/pep-0636/

This changes everything!

jasondelaat commented 3 years ago

Yeah, I've been meaning to look into that for a while. Looks like a great addition to the language.