1
\$\begingroup\$

These are five implementations I’ve come up with for an equivalent for Haskell’s Maybe in Python. It’s not called Optional because typing.Optional already exists.

Here’s a simple monolithic class. The benefit of this one is that it can be inherited from:

from __future__ import annotations
from typing import Callable, Generic, Optional, TypeVar, cast, overload, Any

T = TypeVar("T")
U = TypeVar("U")
C = TypeVar("C", bound="Maybe[Any]")


class MissingValueError(ValueError):
    "Raised to indicate a potentially missing value was missing."
    pass


class Maybe(Generic[T]):
    present: bool
    value: Optional[T]

    @overload
    def __init__(self, /) -> None:
        ...

    @overload
    def __init__(self, value: T, /) -> None:
        ...

    def __init__(self, /, *args: T) -> None:
        if len(args) == 0:
            self.present = False
            self.value = None
        else:
            self.present = True
            self.value = args[0]

    @classmethod
    def Just(cls: type[C], value: T, /) -> C:
        return cls(value)

    @classmethod
    def Nothing(cls: type[C]) -> C:
        return cls()

    def assume_present(self) -> T:
        if not self.present:
            raise MissingValueError()
        return cast(T, self.value)

    def map(self: Maybe[T], f: Callable[[T], U], /) -> Maybe[U]:
        cls = type(self)
        if not self.present:
            return cls()
        else:
            return cls(f(cast(T, self.value)))

    def flatmap(self: Maybe[T], f: Callable[[T], Maybe[U]], /) -> Maybe[U]:
        cls = type(self)
        if not self.present:
            return cls()
        else:
            maybe2 = f(cast(T, self.value))
            if not maybe2.present:
                return cls()
            else:
                return cls(cast(U, maybe2.value))

    def join(self: Maybe[Maybe[T]], /) -> Maybe[T]:
        cls = cast(type[Maybe[T]], type(self))
        if not self.present:
            return cls()
        elif not cast(Maybe[T], self.value).present:
            return cls()
        else:
            return cls(cast(T, cast(Maybe[T], self.value).value))

    @classmethod
    def from_optional(cls: type[C], value: Optional[T], /) -> C:
        if value is None:
            return cls()
        else:
            return cls(value)

    @classmethod
    def with_bool(cls: type[C], /, present: bool, value: T) -> C:
        if present:
            return cls(value)
        else:
            return cls()

Here’s one where Maybe is an abstract base class from which Just and Nothing inherit. It supports pattern matching and checking via .present:

from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Callable, Generic, Optional, TypeVar, cast

T = TypeVar("T", covariant=True)
G = TypeVar("G")
U = TypeVar("U")


class Maybe(ABC, Generic[T]):
    present: bool
    value: Optional[T]

    @abstractmethod
    def assume_present(self) -> T:
        pass

    @abstractmethod
    def map(self: Maybe[G], f: Callable[[G], U], /) -> Maybe[U]:
        pass

    @abstractmethod
    def flatmap(self: Maybe[G], f: Callable[[G], Maybe[U]], /) -> Maybe[U]:
        pass

    @abstractmethod
    def join(self: Maybe[Maybe[G]]) -> Maybe[G]:
        pass

    @staticmethod
    def from_optional(value: Optional[G], /) -> Maybe[G]:
        if value is None:
            return Nothing[G]()
        else:
            return Just[G](value)

    @staticmethod
    def with_bool(present: bool, value: G) -> Maybe[G]:
        if present:
            return Just[G](value)
        else:
            return Nothing[G]()


class Just(Maybe[T]):
    def __init__(self: Just[T], value: T) -> None:
        self.present = True
        self.value = value

    def assume_present(self: Just[G]) -> G:
        return cast(G, self.value)

    def map(self: Just[G], f: Callable[[G], U], /) -> Just[U]:
        return Just[U](f(cast(G, self.value)))

    def flatmap(self: Just[G], f: Callable[[G], Maybe[U]], /) -> Maybe[U]:
        return f(cast(G, self.value))

    def join(self: Just[Maybe[G]]) -> Maybe[G]:
        return cast(Maybe[G], self.value)

    __match_args__ = ("value",)


class MissingValueError(ValueError):
    "Raised to indicate a potentially missing value was missing."
    pass


class Nothing(Maybe[T]):
    def __init__(self: Nothing[T]) -> None:
        self.present = False
        self.value = None

    def assume_present(self: Nothing[G]) -> G:
        raise MissingValueError()

    def map(self: Nothing[G], f: Callable[[G], U], /) -> Nothing[U]:
        return Nothing[U]()

    def flatmap(self: Nothing[G], f: Callable[[G], Maybe[U]], /) -> Nothing[U]:
        return Nothing[U]()

    def join(self: Nothing[Maybe[G]]) -> Nothing[G]:
        return Nothing[G]()

    __match_args__ = ()

Here’s one that takes advantage of Python’s type system. It supports pattern matching and checking via isinstance(.

from dataclasses import dataclass
from typing import Callable, TypeAlias, TypeVar, Generic, Optional

T = TypeVar("T", covariant=True)
G = TypeVar("G")
U = TypeVar("U")


@dataclass
class Nothing(Generic[T]):
    pass


@dataclass
class Just(Generic[T]):
    value: T


Maybe: TypeAlias = Nothing | Just[T]


def maybe_from_optional(value: Optional[G], /) -> Maybe[G]:
    if value is None:
        return Nothing[G]()
    else:
        return Just[G](value)


def maybe_with_bool(present: bool, value: G) -> Maybe[G]:
    if present:
        return Just[G](value)
    else:
        return Nothing[G]()


class MissingValueError(ValueError):
    "Raised to indicate a potentially missing value was missing."
    pass


def assume_present(maybe: Maybe[G], /) -> G:
    if isinstance(maybe, Nothing):
        raise MissingValueError
    return maybe.value


def map_maybe(f: Callable[[G], U], maybe: Maybe[G], /) -> Maybe[U]:
    if isinstance(maybe, Nothing):
        return Nothing[U]()
    else:
        return Just[U](f(maybe.value))


def flatmap_maybe(f: Callable[[G], Maybe[U]], maybe: Maybe[G], /) -> Maybe[U]:
    if isinstance(maybe, Nothing):
        return Nothing[U]()
    else:
        return f(maybe.value)

Here’s one where Maybe is a protocol that Just and Nothing implement. Again, checking is done via pattern matching or isinstance(:

from __future__ import annotations
from typing import Callable, Optional, TypeVar, cast, Protocol

T = TypeVar("T", covariant=True)
G = TypeVar("G")
U = TypeVar("U")


class Maybe(Protocol[T]):
    def assume_present(self) -> T:
        ...

    def map(self: Maybe[G], f: Callable[[G], U], /) -> Maybe[U]:
        ...

    def flatmap(self: Maybe[G], f: Callable[[G], Maybe[U]], /) -> Maybe[U]:
        ...

    def join(self: Maybe[Maybe[G]]) -> Maybe[G]:
        ...


class Just(Maybe[T]):
    value: T

    def __init__(self: Just[T], value: T) -> None:
        self.value = value

    def assume_present(self: Just[G]) -> G:
        return self.value

    def map(self: Just[G], f: Callable[[G], U], /) -> Just[U]:
        return Just[U](f(self.value))

    def flatmap(self: Just[G], f: Callable[[G], Maybe[U]], /) -> Maybe[U]:
        return f(self.value)

    def join(self: Just[Maybe[G]]) -> Maybe[G]:
        return self.value

    __match_args__ = ("value",)


class MissingValueError(ValueError):
    "Raised to indicate a potentially missing value was missing."
    pass


class Nothing(Maybe[T]):
    def __init__(self: Nothing[T]) -> None:
        self.present = False
        self.value = None

    def assume_present(self: Nothing[G]) -> G:
        raise MissingValueError()

    def map(self: Nothing[G], f: Callable[[G], U], /) -> Nothing[U]:
        return Nothing[U]()

    def flatmap(self: Nothing[G], f: Callable[[G], Maybe[U]], /) -> Nothing[U]:
        return Nothing[U]()

    def join(self: Nothing[Maybe[G]]) -> Nothing[G]:
        return Nothing[G]()

    __match_args__ = ()


def maybe_from_optional(value: Optional[G], /) -> Maybe[G]:
    if value is None:
        return Nothing[G]()
    else:
        return Just[G](value)


def maybe_with_bool(present: bool, value: G) -> Maybe[G]:
    if present:
        return Just[G](value)
    else:
        return Nothing[G]()

And finally, here’s a variant of the first monolithic one that uses a sentinel object instead of a present property:

from __future__ import annotations
from typing import Callable, Generic, Optional, TypeVar, cast, overload, Any

T = TypeVar("T")
U = TypeVar("U")
C = TypeVar("C", bound="Maybe[Any]")


class MissingValueError(ValueError):
    "Raised to indicate a potentially missing value was missing."
    pass


class Maybe(Generic[T]):
    sentinel = object()

    value: T | object

    @overload
    def __init__(self, /) -> None:
        ...

    @overload
    def __init__(self, value: T, /) -> None:
        ...

    def __init__(self, /, *args: T) -> None:
        if len(args) == 0:
            self.value = Maybe.sentinel
        else:
            self.value = args[0]

    @classmethod
    def Just(cls: type[C], value: T, /) -> C:
        return cls(value)

    @classmethod
    def Nothing(cls: type[C]) -> C:
        return cls()

    def assume_present(self) -> T:
        cls = type(self)
        if self.value is cls.sentinel:
            raise MissingValueError()
        return cast(T, self.value)

    def map(self: Maybe[T], f: Callable[[T], U], /) -> Maybe[U]:
        cls = type(self)
        if self.value is cls.sentinel:
            return cls()
        else:
            return cls(f(cast(T, self.value)))

    def flatmap(self: Maybe[T], f: Callable[[T], Maybe[U]], /) -> Maybe[U]:
        cls = type(self)
        if self.value is cls.sentinel:
            return cls()
        else:
            maybe2 = f(cast(T, self.value))
            if maybe2.value is cls.sentinel:
                return cls()
            else:
                return cls(cast(U, maybe2.value))

    def join(self: Maybe[Maybe[T]], /) -> Maybe[T]:
        cls = cast(type[Maybe[T]], type(self))
        if self.value is cls.sentinel:
            return cls()
        elif cast(Maybe[T], self.value).value is cls().sentinel:
            return cls()
        else:
            return cls(cast(T, cast(Maybe[T], self.value).value))

    @classmethod
    def from_optional(cls: type[C], value: Optional[T], /) -> C:
        if value is None:
            return cls()
        else:
            return cls(value)

    @classmethod
    def with_bool(cls: type[C], /, present: bool, value: T) -> C:
        if present:
            return cls(value)
        else:
            return cls()

Each of these function a bit differently, but they all broadly achieve the same thing—a Maybe type which can be a missing value or a present value, clearly distinguished. The monolithic ones have the benefit of being inheritable, although their typing is lacking as it does not represent the subclasses very well, as I know no way to accurately type methods of inheritable generic classes that change the parameter.

My questions: Which of these do you think is the best, and do you think there are any clear improvements that can be made to each one?


As I have been asked for a use-case for this, I will present the catalyst for me trying to implement this:

I was trying to generate random Signature objects using the Hypothesis library. Signature objects are composed of a list of Parameter objects subject to a number of conditions. To implement the generation, I ended up using a @composite search strategy that draws random lists of each of the parameters needed to construct a Parameter object. As part of this, I wanted to generate default values, and needed to distinguish between the absence of a default value for a parameter and the presence of one that is None. Unfortunately, generating Parameter.empty(), which is used internally by Parameter, as one of the parameters to Parameter didn’t work. In the end, I went with generating a one-off sentinel object and checking identity on it, but I find this solution to be less than satisfying. Because of this, I decided to try and implement a Maybe type in Python.

\$\endgroup\$
5
  • 1
    \$\begingroup\$ As you did not present a use case for either implementation, I'd say that they are all equally useless. \$\endgroup\$ Dec 3, 2022 at 22:00
  • 1
    \$\begingroup\$ That's a little harsh, but still: I do hope that this is strictly academic or recreational, because this should not be done in a professional setting. \$\endgroup\$
    – Reinderien
    Dec 4, 2022 at 0:30
  • \$\begingroup\$ yeah it is. Although I would claim this could be useful in some cases, but I'd like to hear your argument against this. \$\endgroup\$ Dec 4, 2022 at 0:58
  • \$\begingroup\$ FWIW my comment was not meant to be hash, but a tongue-in-cheek pun. At any rate, for a review it'd be good to know how this is supposed to be used in project. \$\endgroup\$ Dec 4, 2022 at 1:15
  • \$\begingroup\$ I have added a real-world use-case. \$\endgroup\$ Dec 4, 2022 at 16:01

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.