0

Summary

TLDR: Is it possible to have calling/instantiating the base class actually return an initialized subclass instance?

Example

Consider this Animal base class and Cat and Dog subclasses:

from abc import ABC, abstractmethod

class Animal(ABC):

    @property
    @abstractmethod
    def weight(self) -> float:
        """weight of the animal in kg."""
        ...


class Dog(Animal):
    def __init__(self, weight: float = 5):
        if not (1 < weight < 90):
            raise ValueError("No dog has this weight")
        self._weight = weight

    weight: float = property(lambda self: self._weight)


class Cat(Animal):
    def __init__(self, weight: float = 5):
        if not (0.5 < weight < 15):
            raise ValueError("No cat has this weight")
        self._weight = weight

    weight: float = property(lambda self: self._weight)

This works as intended:

c1 = Cat(0.7)  # no problem
c2 = Cat(30)  # ValueError

Now, I want to extend this so that calling of the Animal class should return one of its subclasses, and namely the first that does not raise an error.

So, I want c3 = Animal(0.7) to return a Cat instance.

Attempt

I know how to return an instance from a subclass when instantiating the base class, but only if it can be determined before running __init__, which one it is.

So, this does not work...

class Animal(ABC):

    def __new__(cls, *args, **kwargs):
        if cls in cls._subclasses():
            return object.__new__(cls)

        for cls in [Dog, Cat]:  # prefer to return a dog
            try:
                return object.__new__(cls)
            except ValueError:
                pass

    @property
    @abstractmethod
    def weight(self) -> float:
        """weight of the animal in kg."""
        ...

...because the ValueError is only raised when the instance is already created and returned:

c3 = Animal(0.7) # ValueError ('no dog has this weight') instead of Cat instance.

Is there a way to achieve this?

Current workaround

This works but is detached from the classes and feels badly integrated / highly coupled.

def create_appropriate_animal(*args, **kwargs) -> Animal:
    for cls in [Dog, Cat]:
        try:
            return cls(*args, **kwargs)
        except ValueError:
            pass  
    raise("No fitting animal found.")

c4 = create_appropriate_animal(0.7)  # returns cat

EDIT:

  • Thanks @chepner for the __subclasses__() suggestion; I've integrated it into the question.
ElRudi
  • 2,122
  • 2
  • 18
  • 33
  • 1
    I'd suggest using separate Factory class, I'm really not sure making an abstract class aware of its inheritants is a good practice – matszwecja Jan 31 '22 at 15:02
  • 2
    *"the first that does not raise an error"* That sounds like a terrible idea and a nightmare to debug, especially when you are going to rely on the class definition order (or whatever order `_subclasses` decides to maintain) – DeepSpace Jan 31 '22 at 15:04
  • You aren't really using any information specific to `Animal`, so I don't see a problem with a separate function. (`Animal.__subclasses__()` can be used to get all the direct subclasses of `Animal` without having to use `__init_subclass__` to (re)register them.) What I *do* see a problem with is a hierarchy where the distinction between two subclasses is irrelevant to which you choose to use. Why bother having `Cat` and `Dog` if it doesn't matter which you use to model a particular entity? – chepner Jan 31 '22 at 15:49
  • Thanks for your comments. I agree the example is a bit contrived; I've changed it a bit so the order of the subclasses is predetermined. In my actual use-case it makes sense - data is passed when instantiating a class. When the user choses the base class (`Animal`) instead of a subclass (`Dog` or `Cat`), it should be decided on the data, which subclass is appropriate. I don't think it's good practice to put the logic for that in the base class. Also, the real use-case does not have such simple and uniform criteria that they can be put into a `MIN_WEIGHT` and `MAX_WEIGHT` constant. – ElRudi Jan 31 '22 at 17:39
  • What would a factory class look like in this case, @matszwecja; could point me in the right direction? Do you mean an additional class whose `__new__` method returns an instance of either `Dog` or `Cat` - basically, moving the `__new__` method from `Animal` to a new class ("`AnimalCreator`")? Or do you have something else in mind? – ElRudi Feb 02 '22 at 10:16

2 Answers2

0

This is indeed a weird requirement, but it can be met by customization of __new__:

class Animal(ABC):
    subclasses = []

    @property
    @abstractmethod
    def weight(self) -> float:
        """weight of the animal in kg."""
        ...

    def __new__(cls, *args, **kwargs):
        if cls == Animal:
            for cls in Animal.__subclasses__():
                try:
                    return cls(*args, **kwargs)
                except TypeError:
                    pass
        return super().__new__(cls)

You can now successfully write:

a = Animal(2)   # a will be Dog(2)
b = Animal(0.7) # b will be Cat(0.7)

BTW, if all subclasses raise an error, the last one will be used (and will raise its own error)

Serge Ballesta
  • 143,923
  • 11
  • 122
  • 252
  • Thanks, that's what I was looking for! I've used it exactly like that. Just a few questions/suggestions. (a) wouldn't it be better to compare with `is`, so `if cls is Animal`, (b) I think you mean `except ValueError` instead of `except TypeError`, and (c) before the final line (at the deeper indentation, after the loop) I think it makes sense to raise `NotImplementedError` - as none of the subclasses can handle the data. If you disagree I'd love to hear why. Many thanks again! – ElRudi Jan 31 '22 at 18:28
0

@ElRudi: As requested, solution using Factory pattern. While it is probably possible to automatically detect all the classes inheriting from Animal to avoid having to declare the list of possible animals when creating factory, I don't think it's a good idea and I didn't bother with doing that.

class AnimalFactory:
    def __init__(self, animalTypes : list[Animal] = None):
        if animalTypes == None:
            self.animals = []
        else:
            self.animals = animalTypes
    def createAnimal(self, weight: float) -> Animal:
        for AnimalType in self.animals:
            try:
                return AnimalType(weight)
            except ValueError:
                pass
        raise ValueError("No animal has this weight")


af = AnimalFactory([Dog, Cat])

c1 = af.createAnimal(0.7)  # Cat
print(c1)
c2 = af.createAnimal(30)  # Dog
print(c2)
c3 = af.createAnimal(100) # ValueError
matszwecja
  • 6,357
  • 2
  • 10
  • 17
  • I see, thanks for coding it up. What is a bit of a shame is that the syntax is changed. Before, I kinda liked that the user would call `Dog()` or `Cat()` if they know the animal type, and `Animal()` if know don't; it's very similar. With your solution, the latter is changed to first creating an `AnimalFactory` instance and then using its `.createAnimal` method. – ElRudi Feb 02 '22 at 14:33
  • ...Though I guess we could use the `__init__()` method to append each `Animal` in the list to the class with `for animalType in animalTyes: setattr(self, animalType.__name__, animalType)`... and rename the `.createAnimal()` method to `Animal()`. Then, we have `af.Dog()`, `af.Cat()`, `af.Animal()`. Cool. Unless unwise for reasons that didn't occur to me. – ElRudi Feb 02 '22 at 14:36
  • I mean, it's not something I'd personally use (when asking for `Animal` I'd expect the code to actually give me an `Animal` instance, not one of its children) but if gated with factory it's not as bad. I don't see any immediate problems with your approach so if it suits your needs go for it. – matszwecja Feb 02 '22 at 14:47