Return Subclass instance from Base class

905 Views Asked by At

Summary

TLDR: I have an ABC with severel subclasses. The ABC has a method that returns a subclass instance. I want to put the ABC and the subclasses in distinct files.

Example

In one file, this works:

from abc import ABC, abstractmethod


class Animal(ABC):

    # Methods to be implemented by subclass.

    @property
    @abstractmethod
    def name(self) -> str:
        """Name of the animal."""
        ...

    @abstractmethod
    def action(self):
        """Do the typical animal action."""
        ...

    # Methods directly implemented by base class.

    def turn_into_cat(self):
        return Cat(self.name)


class Cat(Animal):
    def __init__(self, name):
        self._name = name

    name = property(lambda self: self._name)
    action = lambda self: print(f"{self.name} says 'miauw'")


class Dog(Animal):
    def __init__(self, name):
        self._name = name

    name = property(lambda self: self._name)
    action = lambda self: print(f"{self.name} says 'woof'")

>>> mrchompers = Dog("Mr. Chompers")

>>> mrchompers.action()
Mr. Chompers says 'woof'

>>> mrchompers.turn_into_cat().action()
Mr. Chompers says 'miauw'

Issue

I want to put the Animal class definition in base.py, and the Cat and Dog class definitions in subs.py.

The problem is, that this leads to cyclic imports. base.py must include a from .subs import Cat, and subs.py must include a from .base import Animal.

I've incountered cyclic import errors before, but usually when type hinting. In that case I can put the lines

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .base import Animal

However, that is not the case here.

Any ideas as to how to split this code up into 2 files?

3

There are 3 best solutions below

1
chepner On

I'm not sure making Animal depend on a subclass defined in another file is a great idea in the first place, but since the actual value of Cat isn't needed until turn_into_cat is actually called, one hack would be to give base.Cat a dummy value that subs patches once Cat is defined.

# base.py
from abc import ABC, abstractmethod

_Cat = None  # To be set by subs.py

class Animal(ABC):

    ...

    def turn_into_cat(self):
        return _Cat(self.name)

Note that base no longer needs to know anything about subs, but Animal won't be fully ready to use until subs.py is executed

# subs.py

import base  # Not sure this is necessary to bring the name base into scope
from .base import Animal


class Cat(Animal):
    def __init__(self, name):
        self._name = name

    name = property(lambda self: self._name)
    action = lambda self: print(f"{self.name} says 'miauw'")


base._Cat = Cat


class Dog(Animal):
    def __init__(self, name):
        self._name = name

    name = property(lambda self: self._name)
    action = lambda self: print(f"{self.name} says 'woof'")

As soon as Cat is defined, the name base._Cat is updated to the class which Animal.turn_into_cat needs in order to create its return value.

0
ElRudi On

I think I have found a way, though I'm not quite sure, why it works.

In base.py, I changed the following:

  • In imports: from .subs import Cat --> from . import subs.

  • In turn_into_cat function: return Cat(self.name) --> return subs.Cat(self.name)

Apparently, importing the class Cat causes more/different code to be executed than importing the module subs that contains it. If anyone has a take on this, I'm happy to hear it.

So, this solution is:

base.py:

from abc import ABC, abstractmethod
import subs


class Animal(ABC):

    # Methods to be implemented by subclass.

    @property
    @abstractmethod
    def name(self) -> str:
        """Name of the animal."""
        ...

    @abstractmethod
    def action(self):
        """Do the typical animal action."""
        ...

    # Methods directly implemented by base class.

    def turn_into_cat(self):
        return subs.Cat(self.name)

subs.py:

from base import Animal


class Cat(Animal):
    def __init__(self, name):
        self._name = name

    name = property(lambda self: self._name)
    action = lambda self: print(f"{self.name} says 'miauw'")


class Dog(Animal):
    def __init__(self, name):
        self._name = name

    name = property(lambda self: self._name)
    action = lambda self: print(f"{self.name} says 'woof'")

The disadvantage here is mentioned by @martineau - it requires the Animal class to know about the existence of the subclass Cat, which is not optimal.

I have actually thought of another solution, which I've also added.

1
ElRudi On

We do not have to define the turn_into_cat method at the same location as where the rest of the Animal class is defined.

Here, I add the method in subs.py:

base.py:

#NB: no mentioning of any subclass

from abc import ABC, abstractmethod


class Animal(ABC):

    # Methods to be implemented by subclass.

    @property
    @abstractmethod
    def name(self) -> str:
        """Name of the animal."""
        ...

    @abstractmethod
    def action(self):
        """Do the typical animal action."""
        ...

subs.py:

from base import Animal


class Cat(Animal):
    def __init__(self, name):
        self._name = name

    name = property(lambda self: self._name)
    action = lambda self: print(f"{self.name} says 'miauw'")


class Dog(Animal):
    def __init__(self, name):
        self._name = name

    name = property(lambda self: self._name)
    action = lambda self: print(f"{self.name} says 'woof'")


def turn_into_cat(animal: Animal) -> Cat:
    return Cat(animal.name)

Animal.turn_into_cat = turn_into_cat  # attach method to base class.

This is cleaner, but comes with another issue: if Cat and Dog are, at a later point in time, put into their own files cat.py and dog.py, it is no longer certain that a Dog instance has the .turn_into_cat() method - because this depends on whether or not cat.py has been imported/run.

This issue is the same as the one @chepner's answer suffers from, as I mention in the comment to his answer.

If anyone has a solution for that last problem, I think this is the solution I prefer.

(I could from . import cat in dog.py, but that only works as long as dog.py does not have attach a .turn_into_dog method that should always be available to Cat instances - because in that case we need a from . import dog in cat.py, and we have cyclic imports all over again.)