5

This is a two-part question, but the second part is dependent on the first part.

For educational purposes, I am trying to implement an abstract base class and test suite for groups (the concept from abstract algebra). Part of the definition of an algebraic group is equivalent to a type constraint, and I want to implement that type constraint on an ABC, and have something complain if the methods on the concrete classes don't conform to that constraint.

I've got a first-pass implementation for this for the group of Boolean values under logical and, but there are at least two things wrong with it, and I'm hoping you can help me fix it.

from __future__ import annotations
from abc import ABC, abstractmethod


class AbsGroup(ABC):
    @abstractmethod
    def op(self, other: AbsGroup) -> AbsGroup:   # <-- Line-of-interest #1
        pass


class Bool(AbsGroup):

    def __init__(self, val="False"):

        if val not in ["True", "False"]:
            raise ValueError("Invalid Bool value %s" % val)

        self.val = val

    def op(self, other):
        """Logical AND"""
        if self.val == "True" and other.val == "True":  # <-- Line-of-interest #2
            return Bool("True")
        return Bool("False")

    def __eq__(self, other):
        return self.val == other.val

    def __repr__(self):
        return self.val

Firstly: Line-of-interest #1 is what's doing the type-constraint work, but the current implementation is wrong. It only checks that the method receives and returns an AbsGroup instance. This could be any AbsGroup instance. I want it to check that for the concrete class it gets inherited by, it receives and returns an instance of that concrete class (so in the case of Bool it receives and returns an instance of Bool). The point of the exercise is to do this in one location, rather than having to set it specifically on each concrete class. I presume this is done with some type-hinting generics that are a little bit deeper than I've yet to delve with regard to type-hinting. How do I do this?

Secondly: how do I check the concrete method is complying with the abstract type hint? The type inspector in my IDE (PyCharm) complains at Line-of-interest #2, because it's expecting other to be of type AbsGroup, which doesn't have a val attribute. This is expected, and would go away if I could figure out the solution to the first problem, but my IDE is the only thing I can find that notices this discrepancy. mypy is silent on the matter by default, as are flake8 and pylint. It's great that PyCharm is on the ball, but if I wanted to incorporate this into a workflow, what command would I have to run that would fail in the event of my concrete method not complying with the abstract signature?

R Hill
  • 1,744
  • 1
  • 22
  • 35
  • The overriding method doesn't "inherit" the type hints. You are defining a *completely* separate function which happens to shadow the inherited function's name during attribute lookup. There's no relation between the type hints of the two. – chepner Nov 04 '19 at 13:56
  • You probably need the `--strict` option of mypy for it to complain. – Wombatz Nov 04 '19 at 14:01
  • @chepner I'd accept that if PyCharm wasn't warning me that a variable existing only in the scope of the concrete method was expected to have a class expressed only in the abstract method's type annotation. – R Hill Nov 04 '19 at 14:03
  • Python itself doesn't do any type checking; external tools like `mypy` and PyCharm do that using whatever hints you provide in the source and whatever they are programmed to infer. I'd argue that PyCharm is being overly aggressive; because of how method resolution works, you can't tell at compile time what the type of `self` or `other` will be, and thus can't make any solid predictions about how it should behave. If you, as the programmer, *want* `other` to be a `AbsGroup`, you need to be explicit. – chepner Nov 04 '19 at 14:07
  • I'm convinced type annotations as they exist can't reliably do what I want them to. Putting that aside for a moment, is there an answer to the first part of my question for non-abstract superclasses? If I have `class B(A)`, and `A` defines a method with a type hint, is there a way to specify, in `A`, that, e.g., when run as a member of `B`, that method returns type `B`, rather than a more generic `A`? – R Hill Nov 04 '19 at 14:31

1 Answers1

9

First tip: If mypy doesnt tell you enough, try mypy --strict.

You correctly realized that the type annotation for op in the base class is not restrictive enough and in fact would be incompatible with the child class.

Take a look at this not-working example.

from __future__ import annotations
from abc import ABC, abstractmethod


class AbsGroup(ABC):
    @abstractmethod
    def op(self, other: AbsGroup) -> AbsGroup:
        pass


class Bool(AbsGroup):
    def __init__(self, val: str = "False") -> None:
        self.val = val

    def op(self, other: Bool) -> Bool:
        ...

I annotated op in Bool with the correct type but now mypy complains:

file.py:15: error: Argument 1 of "op" is incompatible with supertype "AbsGroup "; supertype defines the argument type as "AbsGroup"

You have two options: Either make the base annotation even less restrictive (Any) or make your class a Generic one:

from __future__ import annotations
from abc import ABC, abstractmethod

from typing import TypeVar, Generic


T = TypeVar('T')


class AbsGroup(Generic[T], ABC):
    @abstractmethod
    def op(self, other: T) -> T:
        pass

# EDIT: ADDED QUOTES AROUND Bool
class Bool(AbsGroup['Bool']):
    def __init__(self, val: str = "False") -> None:
        self.val = val

    def op(self, other: Bool) -> Bool:
        ...

This involves several steps:

  1. create a type variable T (looks similar to generic type variables in other languages)
  2. let the base class also inherit from Generic[T] making it a generic class
  3. change the op method to take and return a T
  4. let the child class inherit from AbsGroup[Bool] (in C++ this is known as CRTP)

This silences mypy --strict and PyCharm correctly infers the return type of op.

Edit:

The previous child class definition looked like this class Bool(AbsGroup[Bool]): ... without quotes. But this does not work and will throw a NameError when creating the class:

NameError: name 'Bool' is not defined

This is expected behaviour as written in PEP 563.

[...] However, there are APIs in the typing module that use other syntactic constructs of the language, and those will still require working around forward references with string literals. The list includes: [...]

  • base classes:

    class C(Tuple['<type>', '<type>']): ...

So the quotes are still required in this case even though we used the future import.

Just a note: why are you using string symbols for the boolean values? There are already two perfectly working instances called True and False. This will make your code much simpler. E.g. the check in the constructor can be simplified to if type(val) is bool (I would not use isinstance here since you dont want val to be a custom type, probably?).

Wombatz
  • 4,958
  • 1
  • 26
  • 35
  • 1
    This is an extraordinarily helpful response for my purposes. Thank you. To answer your question, this is an extension of a project where I reimplemented numeric datatypes without using the implementation language's built-in numeric datatypes. (e.g. implementing ints without using ints). This is surprisingly illustrative for thinking about how you verify code behaviour, as about half of the 'spec' for integers (the Ring axioms) can be verified using tests, but the other half need to be verified with types. I decided to redo the project in Python with type hints, to better understand type hints. – R Hill Nov 04 '19 at 14:47
  • follow-up: if I implement the above as provided, the interpreter complains about the definition of `Bool`, specifically that `Bool` is an unresolved reference as the type passed to `AbsGroup`. This makes sense, as it isn't defined at that point (we're defining it right now!) and it's not an annotation, so presumably isn't affected by postponed annotation evaluation. One obvious way to fix this is to pass the string name `'Bool'` instead of the class object, but I presume you typed the above into an IDE and it didn't yell at you. I'm curious about how you did this. – R Hill Nov 04 '19 at 15:37
  • @R is it actually the interpreter that complains? I got the same "error" inside PyCharm but the code ran fine. I hoped that it was due to the old PyCharm version i am using. But maybe that is an error in PyCharm. And yes, it is strange that quoting `Bool` solves the problem. Lets hope that it is repaired soon. – Wombatz Nov 04 '19 at 16:22
  • I first noticed it in PyCharm, but it fails no matter where I call it from in Python 3.7.4. My understanding is that passing the classname as a string was how <3.3 type hinting got around self-referential type hints, and later versions should use postponed annotation evaluation as per PEP 563. Passing the classname seems fine for this purpose. – R Hill Nov 04 '19 at 16:49
  • @RHill Please see my edit. The quotes around `Bool` are actually required as per PEP 563. – Wombatz Nov 05 '19 at 00:38