jspahrsummers / adt

Algebraic data types for Python (experimental, not actively maintained)
MIT License
172 stars 14 forks source link

Add support for matching using context managers. #37

Open aarondewindt opened 4 years ago

aarondewindt commented 4 years ago

One issue I have with the match function is that it relies on either using lambdas (which can only contain one line) or if you need multiple lines, first defining the handler function and then passing it to the match function.

An option that I think is worth considering is using context managers for pattern matching. The main drawback is that it would not allow returning a value like with the match function, but the syntax is nicer when the case handlers contain more than one statement. As a proof of concept I have implemented this new interface while keeping the library backwards compatible.

Here is some example code of the new proposed syntax.

@adt
class ContextMatching:
    EMPTY: Case
    INTEGER: Case[int]
    STRINGS: Case[str, str]

foo = ContextMatching.INTEGER(1)

with foo.empty:
    print("Is empty")

with foo.integer as value:
    print("Is integer:", value)

with foo.strings as (string_1, string_2):
    print("Is strings:", string_1, string_2)

This example will end up printing out Is integer: 1

This opens up the possibility for matching values as well. Although not implemented yet I believe code such as this is possible to implement while keeping the current API intact.

@adt
class ContextMatching:
    NONE: Case
    OK: Case[int, float]

with foo:
    with foo.ok[:4, :] as (val_1, val_2):
        print("val_1 less than 4 and val_2 anything")

    with foo.ok[4:, 1.3:9.9] as (val_1, val_2):
        print("val_1 4 or higher and val_2 between 1.3 and 9.9")

    with foo.ok as (val_1, val_2):
        print("Unhandled ok cases")
aarondewindt commented 3 years ago

Ok, so after a few months of having a look at this every once in awhile, I've decided to change directions. The main reason is because I'm not confident that the method I'm using to skip the code inside the context manager is reliable. It may fail depending on the interpreter (compile) setting, will probably mess with some debugger, and uses a function that is not part of the python standard and thus only guaranteed to be there for CPython.

So I've been trying to come up with new ideas for a nice looking match syntax and this is the best I've come up so far which I think won't require questionable workarounds.

@adt
class ContextMatching:
    EMPTY: Case
    INTEGER: Case[int]
    STRINGS: Case[str, str]

foo = ContextMatching.INTEGER(1)

with foo:
    if case(foo.empty):
        print("Is empty")

    if case(value := foo.integer):
        print("Is integer:", value)

    if case(x := foo.strings):
        string_1, string_2 = x
        print("Is strings:", string_1, string_2)

The main disadvantage of this, is that it requires python>=3.8. But I would make sure that the current API is still supported. So this feature would in the end only be available to those running on python 3.8 or higher. Is this an issue?

The walrus operator does not support unpacking, so you'll need to do the unpacking inside the if if it contains multiple values (strings), or use the result as a list/tuple.

The way I'm thinking of implementing this is by using the context manager to activate a "case access tracker", it would essentially throw an exception if one or more of the assessors where not accessed within the context manager. I would be implementing the descriptor protocol to track access to the assessors.

If accessing the case of the ADT, the accessor returns the value. If accessing the others, it returns a singleton object, lets call it Mismatch for now. If the case function receives the Mismatch object, it returns False. For anything else it returns True.

Since I'm using the context manager, the assessors will know whether they need to return the Mismatch object or throw an exception as it is with the current API.

I'll work on this during the holidays. Do any of have a suggestion or some feedback?

jspahrsummers commented 3 years ago

Wow, didn't realize you were working on this the whole time! Just to set expectations, I am not very active in maintaining this project--I think you are probably investing more than me at this point. đŸ˜…

That being said, I'm happy to support you in this, and can add you as a direct collaborator to the project. I think this proposal that you've laid out makes a lot of sense, and I have no qualms about restricting to Python 3.8+.

The one suggestion I would have is to make sure this is re-entrant (so caller and callee can both be pattern-matching at the same time, or a recursive function can have a pattern-match, or async/await code... etc). This is probably most relevant for the "case access tracker." If you would like help with this bit, I can try to offer some on a pull request.

Thanks for your contributions!

aarondewindt commented 2 years ago

@jspahrsummers I think I might change to a completely different approach that takes advantage of the new pattern matching in Python 3.10, so I'm probably starting a new project. However I have the feeling I'll have to "borrow" some code from here, mainly the mypy plugin stuff which I don't have much experience with. Do you have any issues?

Here's a gist with what I already tried: https://gist.github.com/aarondewindt/1cb0af45f05c0da03bfbec6f050f5b58

jspahrsummers commented 2 years ago

Please feel free! This project is licensed under MIT to support things like that.