3

Suppose you have a concrete class

class Knight(object):
    def __init__(self, name):
        self._name = name
    def __str__(self):
        return "Sir {} of Camelot".format(self.name)

Now it happens that the class hierarchy has to change. Knight should become an abstract base class, with a bunch of concrete subclasses for knights of various castles. Easy enough:

class Knight(metaclass=ABCMeta):  # Python 3 syntax
    def __str__(self):
        return "Sir {} of {}".format(self.name, self.castle)
    @abstractmethod
    def sing():
        pass

class KnightOfTheRoundTable(Knight):
    def __init__(self, name):
        self.name = name
    @property
    def castle(self):
        return "Camelot"
    @staticmethod
    def sing():
        return ["We're knights of the round table",
                "We dance whenever we're able"]

But now all code that used Knight("Galahad") to construct a Knight is broken. We can instead keep Knight as it is and introduce a BaseKnight, but then code that checks for isinstance(x, Knight) and should work on any knight may have to be changed to check for BaseKnight instead.

How does one turn a concrete class into an abstract one, sparing both the constructor and the isinstance checks?

Fred Foo
  • 355,277
  • 75
  • 744
  • 836

2 Answers2

2

Make the existing class a base class, but overload __new__ to return a subclass when an attempt is made to instantiate the base class:

class Knight(metaclass=ABCMeta):
    def __new__(cls, *args, **kwargs):
        if cls is Knight:
            # attempt to construct base class, defer to default subclass
            return KnightOfTheRoundTable(*args, **kwargs)
        else:
            obj = super(Knight, cls).__new__(cls)
            obj.__init__(*args, **kwargs)
            return obj
    def __str__(self):
        return "Sir {} of {}".format(self.name, self.castle)
    @abstractmethod
    def sing():
        pass

Now Knight("Galahad") continues to work but returns a KnightOfTheRoundTable. isinstance(Knight("Robin"), Knight) return True, as does an isinstance(x, Knight) check on any other subclass instance.

Community
  • 1
  • 1
Fred Foo
  • 355,277
  • 75
  • 744
  • 836
2

Your solution messing with __new__ mostly works, but it has the downside where Knight(...) does not give you a Knight, and that Knight is just an ABC.

Writing BaseKnight is a bit cleaner, but then you have the problem

code that checks for isinstance(x, Knight) and should work on any knight may have to be changed to check for BaseKnight instead.

which can be patched by adding

    def __subclasscheck__(object):
        return issubclass(object, BaseKnight)

to Knight. You don't want this to affect the subclasses of Knight, though, so you do this also horrible hack:

    @classmethod
    def __subclasscheck__(cls, object):
        if cls is Knight:
            return issubclass(object, BaseKnight)
        else:
            return ABCMeta.__subclasscheck__(cls, object)

from abc import ABCMeta, abstractmethod

class BaseKnight(metaclass=ABCMeta):  # Python 3 syntax
    def __str__(self):
        return "Sir {} of {}".format(self.name, self.castle)

    @abstractmethod
    def sing():
        pass

That's the base, then you have the concrete Knight that redirects isinstance and issubclass checks:

class Knight(BaseKnight):
    def __init__(self, name, castle="Camelot"):
        self._name = name

    @abstractmethod
    def sing(self):
        return ["Can't read my,",
                "Can't read my",
                "No he can't read my poker face"]

    @classmethod
    def __subclasscheck__(cls, object):
        if cls is Knight:
            return issubclass(object, BaseKnight)
        else:
            return ABCMeta.__subclasscheck__(cls, object)

Finally, some tests:

class KnightOfTheRoundTable(Knight):
    def __init__(self, name):
        self.name = name

    @property
    def castle(self):
        return "Camelot"

    def sing():
        return ["We're knights of the round table",
                "We dance whenever we're able"]

class DuckKnight(BaseKnight):
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return "Knight Quacker of Quack"

    def sing():
        return ["Quack!"]

isinstance(KnightOfTheRoundTable("John"), Knight)
#>>> True

isinstance(DuckKnight("Quacker"), Knight)
#>>> True

With the edit to to delegate back to ABCMeta.__subclasscheck__(cls, object) I no longer thing either solution is particularly nice.

It's worth noting, though, that

  • You probably instantiate Knight way more than than you use isinstance against the concrete Knight type (prior to the ABC). It makes sense to keep crazy behaviour confined to small spaces.

  • Changing isinstance means you don't have to change what Knight is, which means less damage to the codebase.

Veedrac
  • 58,273
  • 15
  • 112
  • 169
  • +1 for another valid solution, although I don't see why returning a subclass of `Knight` from `Knight.__new__` is so horrible. After all, a subclass instance *is* a `Knight`, right? Is there any place where this can go terribly wrong? – Fred Foo Oct 21 '14 at 06:50
  • I've had to change a few things to fix a bug I overlooked. It's not really any nicer now, but I did give two benefits to the method. – Veedrac Oct 21 '14 at 13:10