diff --git a/emis_funky_funktions.py b/emis_funky_funktions.py index 7b666f8..6b1704d 100644 --- a/emis_funky_funktions.py +++ b/emis_funky_funktions.py @@ -14,6 +14,14 @@ P2 = ParamSpec('P2') # Compose def c(f2: Callable[[B], C], f1: Callable[P, B]) -> Callable[P, C]: + """ + Compose two functions by passing the output of the second to the input of the first. + + `c(f1, f2)(*args)` is equivalent to `f1(f2(*args))`. + + This can also be thought of as mapping the output of a function using the first + parameter as a mapper function. + """ @wraps(f1) def inner(*args: P.args, **kwargs: P.kwargs) -> C: return f2(f1(*args, **kwargs)) @@ -21,6 +29,12 @@ def c(f2: Callable[[B], C], f1: Callable[P, B]) -> Callable[P, C]: # Flip: (A -> B -> C) -> B -> A -> C def flip(f: Callable[P1, Callable[P2, C]]) -> Callable[P2, Callable[P1, C]]: + """ + Reverse the order of the first two arguments of a curried function. + + This only works with curried functions, so apply `cur2` or `cur3` before applying + `flip` if the arguments you want to flip are not curried. + """ @wraps(f) def inner1(*args2: P2.args, **kwargs2: P2.kwargs) -> Callable[P1, C]: @wraps(f) @@ -31,39 +45,82 @@ def flip(f: Callable[P1, Callable[P2, C]]) -> Callable[P2, Callable[P1, C]]: # Identity function! def ident(x: A) -> A: + "The identity function. Output is identical to input." return x # Partial Appliaction shorthand p = partial +"An alias for partial application" # Two and three-argument currying # Defining these pointfree fucks up the types btw def cur2(f: Callable[Concatenate[A, P], C]) -> Callable[[A], Callable[P, C]]: + """ + Perform two-argument currying. + + For example, a function from (A, B) -> C becomes a function A -> B -> C. This can + also be though of as simply moving the first argument of a function out front, since + it preserves any arguments after the first. That is, a function (A, B, C, kw=D) -> E + becomes the function A -> (B, C, kw=D) -> E after being curried using this function. + + Can also be used as an annotation. + """ return p(p, f) #type:ignore def cur3(f: Callable[Concatenate[A, B, P], D]) -> Callable[[A], Callable[[B], Callable[P, D]]]: + """ + Perform three-argument currying. + + See `cur2` for an explaination of how this works. + """ return p(p, p, f) #type:ignore # Curried versions of map & filter with stricter types def p_map(f: Callable[[A], B]) -> Callable[[Sequence[A]], Sequence[B]]: + "A curried version of the built in `map` function" return partial(map, f) #type: ignore def p_filter(f: Callable[[A], bool]) -> Callable[[Sequence[A]], Sequence[A]]: + "A curried version of the built in `filter` function" return partial(filter,f) #type: ignore # Normal Accessors @cur2 def indx(i: int, s: Sequence[A]) -> A: + "A curried version of the getitem function" return s[i] + fst = indx(0) +"Get the first element of a tuple/sequence" + snd = indx(1) +"Get the second element of a tuple/sequence" # Semantic Editor Combinators class SemEdComb: + """ + A tool which approximates semantic editor combinators in python. + + Please read + https://web.archive.org/web/20221202200001/http://conal.net/blog/posts/semantic-editor-combinators + for context. + + Since Python has no infix function composition, using this pattern can get pretty + ugly. This class abuses python's ability to override the property accessor (.) in + order to approximate semantic editor combinators. + + Using this class, you can write `result.first.pmap(reverse)(myList)` to perform an + effect equivalent to `result . first reverse myList` in haskell. + + Unfortunately, due to limitations of Python's type system, this class is largely + untyped. + """ class Inner(): + "A chain of semantic editor combinators already paired with a map function" def __init__(self, f: Callable, name: str): self.f = f self.name = name def and_then(self, other: 'SemEdComb.Inner') -> 'SemEdComb.Inner': + "Composes this with another `SemEdComb.Inner`" return SemEdComb.Inner(c(other.f, self.f), self.name + ' and ' + other.name) def __repr__(self) -> str: return f"SemEdComb*({self.name})" @@ -78,18 +135,25 @@ class SemEdComb: return SemEdComb(c(self.f, next_f), self.name + next_fname) RESULT = cur2(c) + "Map the result of a function" + ARG = flip(RESULT) + "Map the argument of a function" + ALL = p_map + "Map every element of a list" @cur3 @staticmethod def INDEX(i, f, arr): + "Map the ith element of a mutable sequence" arr[i] = f(arr[i]) return arr @cur3 @staticmethod def INDEX_TUP(i: int, f: Callable[[Any], Any], tup: Tuple) -> Tuple: + "Map the ith element of an immutable sequence" l = list(tup) l[i] = f(l[i]) return (*l,) @@ -97,51 +161,69 @@ class SemEdComb: @cur2 @staticmethod def FIRST(f: Callable[[A], C], tup: Tuple[A, B]) -> Tuple[C, B]: + "Map the first element of a two-tuple" return (f(tup[0]), tup[1]) @cur2 @staticmethod def SECOND(f: Callable[[B], C], tup: Tuple[A, B]) -> Tuple[A, C]: + "Map the second element of a two-tuple" return (tup[0], f(tup[1])) @property def result(self) -> 'SemEdComb': + "Map the result of a function" return self._c(SemEdComb.RESULT, '.result') @property def arg(self) -> 'SemEdComb': + "Map the argument of a function" return self._c(SemEdComb.ARG, '.arg') @property def all(self) -> 'SemEdComb': + "Map every element of a list" return self._c(SemEdComb.ALL, '.all') def index(self, i) -> 'SemEdComb': + "Map the ith element of a mutable sequence" return self._c(SemEdComb.INDEX(i), f'.index({i})') def index_tup(self, i) -> 'SemEdComb': + "Map the ith element of an immutable sequence" return self._c(SemEdComb.INDEX_TUP(i), f'.index_tup({i})') @property def first(self) -> 'SemEdComb': + "Map the first element of a two-tuple" return self._c(SemEdComb.FIRST, f'.first') @property def second(self) -> 'SemEdComb': + "Map the second element of a two-tuple" return self._c(SemEdComb.SECOND, f'.second') def __repr__(self): return f"SemEdComb({self.name})" def pmap(self, mapper): + """ + Set the mapper function, but don't call it yet + + The name is short for partial map. + + See also: `map` + """ return SemEdComb.Inner(self.f(mapper), self.name) def map(self, mapper, thing_to_map) -> Callable: + "Apply the chain of combinators to a mapper and a mappee" return self.pmap(mapper)(thing_to_map) def __call__(self, *args, **kwargs): return self.f(*args, **kwargs) +# Pre-constructed base semantic editor combinators result = SemEdComb(SemEdComb.RESULT, 'result') arg = SemEdComb(SemEdComb.ARG, 'arg') index = lambda i: SemEdComb(SemEdComb.INDEX(i), f'index({i})') @@ -153,15 +235,36 @@ all_ = SemEdComb(SemEdComb.ALL, 'all') # Tail call optimizing recursion @dataclass class Recur(Generic[P]): + """ + Indicate that the function this is returned from should be called again with new args. + + Exclusively used with `tco_rec()` + """ def __init__(self, *args: P.args, **kwargs: P.kwargs): self.args = args self.kwargs = kwargs @dataclass(frozen = True) class Return(Generic[B]): + """ + Indicate that the function this is returned from should return this value + + Exclusively used with `tco_rec()` + """ val: B def tco_rec(f: Callable[P, Recur[P] | Return[B]]) -> Callable[P, B]: + """ + Run a tail-recursive function in a mannor which will not overflow the stack. + + Wraps a function in a loop which transforms its return type. The function is expected + to return an instance of `Recur` rather than calling itself to recur. The arguments + passed to the returned `Recur` instance become the arguments to the next iteration of + the function call. When the function is ready to return for real, it should return an + instance of `Return`. + + The function will be transformed by `tco_rec` to look as if it is a normal function. + """ @wraps(f) def tco_loop(*args: P.args, **kwargs: P.kwargs) -> B: while True: @@ -175,21 +278,34 @@ def tco_rec(f: Callable[P, Recur[P] | Return[B]]) -> Callable[P, B]: # Options! @dataclass(frozen=True) class Some(Generic[A]): + """ + The positive part of an optional datatype + + Component of `Option` and counterpart of `None` + """ val: A + Option = Some[A] | None +"An Option datatype, aka Maybe" + def map_opt(f: Callable[[A], B], o: Option[A]) -> Option[B]: + """ + Map the contents of an optional data type. Has no effect on `None` + """ match o: case Some(val): return Some(f(val)) case none: return none def bind_opt(f: Callable[[A], Option[B]], o: Option[A]) -> Option[B]: + "wow! monads! (aka 'and_then')" match o: case Some(val): return f(val) case none: return none -def note(e: B, o: Option[A]) -> Result[A, B]: +def note(e: B, o: Option[A]) -> 'Result[A, B]': + "Convert an `Option` to a `Result` by attaching an error to the `None` variants" match o: case Some(val): return Ok(val) @@ -199,54 +315,95 @@ def note(e: B, o: Option[A]) -> Result[A, B]: # Results! @dataclass(frozen=True) class Ok(Generic[A]): + """ + The positive part of a result (either) datatype + + Component of `Result` and counterpart of `Err` + """ val: A @dataclass(frozen=True) class Err(Generic[B]): + """ + The error part of a result (either) datatype + + Component of `Result` and counterpart of `Ok` + """ err: B def __bool__(self): return False Result = Ok[A] | Err[B] +"A Result datatype, aka Either" def map_res(f: Callable[[A], C], r: Result[A, B]) -> Result[C, B]: + "Map the success value of a result" match r: case Ok(val): return Ok(f(val)) case not_okay: return not_okay def bind_res(f: Callable[[A], Result[C, B]], r: Result[A, B]) -> Result[C, B]: + "Perform an fallible operation for successful results." match r: case Ok(val): return f(val) case not_okay: return not_okay def map_err(f: Callable[[B], C], r: Result[A, B]) -> Result[A, C]: + "Map the error value of a result" match r: case Err(e): return Err(f(e)) case oki_doke: return oki_doke def hush(r: Result[A, Any]) -> Option[A]: + "Convert a `Result` to an `Option` by converting any errors to `None`" match r: case Ok(val): return Some(val) case not_okay: return None def try_(handle: Callable[[Exception], B], f: Callable[P, A], *args: P.args, **kwargs: P.kwargs) -> Result[A, B]: + """ + Try-catch in a function! Attempt to perform and operation, and `Err` on failure + + Arguments: + handle - A function which handles any exceptions which arise. The return type is + what will be wrapped into the resulting `Err`. This is not called if nothing + goes wrong. + f - The fallible function to try. If this succeeds without raising an error, that + value is returned in an `Ok`. If this raises an exception, that exception + will be passed to `handle`. + args - Will be passed to `f` when it is called. + kwargs - Will be passed to `f` when it is called. + """ try: return Ok(f(*args, **kwargs)) except Exception as e: return Err(handle(e)) def unwrap_r(r: Result[A, Any]) -> A: + """ + Assert that a `Result` is `Ok` and return it's value. + + Throws: + `AssertionError` - The result was NOT okay. The `AssertionError` will have two + arguments: The first is a string to make it more obvious what happened. The + second is the error that was stored in the `Err`. + """ match r: case Ok(val): return val case Err(e): - raise Exception(f'Tried to unwrap an error: {e}') + raise AssertionError(f'Tried to unwrap an error: ', e) def sequence(s: Sequence[Result[A, B]]) -> Result[Iterator[A], B]: + """ + Convert a list of results into a result of a list. + + If the input sequence contains only `Ok` results, then the output is similarly `Ok`, + and contains a list of all the unwrapped values of the `Ok`s. If there are any + errors, proccessing of the sequence is immediately stopped, and the first error + encountered is returned. + """ if all(s): - return Ok(( - unwrap_r(r) - for r in s - )) + return Ok(map(unwrap_r, s)) else: o = next(filter(not_, s)) assert isinstance(o, Err)