Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

In python, can I return a child instance when instantiating its parent?

Tags:

python

class

I have a zoo with animals, represented by objects. Historically, only the Animal class existed, with animal objects being created with e.g. x = Animal('Bello'), and typechecking done with isinstance(x, Animal).

Recently, it has become important to distinguish between species. Animal has been made an ABC, and all animal objects are now instances of its subclasses such as Dog and Cat.

This change allows me to create an animal object directly from one of the subclasses, e.g. with dog1 = Dog('Bello') in the code below. This is cheap, and I can use it as long as I know what kind of animal I'm dealing with. Typechecking isinstance(dog1, Animal) still works as before.

However, for usibility and backwards compatibility, I also want to be able to call dog2 = Animal('Bello'), have it (from the input value) determine the species, and return a Dog instance - even if this is computationally more expensive.

I need help with the second method.

Here is my code:

class Animal:
    def __new__(cls, name):
        if cls is not Animal:  # avoiding recursion
            return super().__new__(cls)

        # Return one of the subclasses
        if name.lower() in ['bello', 'fido', 'bandit']:  # expensive tests
            name = name.title()  # expensive data correction
            return Dog(name)
        elif name.lower() in ['tiger', 'milo', 'felix']:
            # ...

    name = property(lambda self: self._name)
    present = lambda self: print(f"{self.name}, a {self.__class__.__name__}")
    # ... and (many) other methods that must be inherited


class Dog(Animal):
    def __init__(self, name):
        self._name = f"Mr. {name}"  # cheap data correction
    # ... and (few) other dog-specific methods


class Cat(Animal):
    def __init__(self, name):
        self._name = f"Dutchess {name}"  # cheap data correction
    # ... and (few) other cat-specific methods


dog1 = Dog("Bello")
dog1.present()  # as expected, prints 'Mr. Bello, a Dog'.
dog2 = Animal("BELLO")
dog2.present()  # unexpectedly, prints 'Mr. BELLO, a Dog'. Should be same.

Remarks:

  • In my use-case, the second creation method is by far the more important one.

  • What I want to achieve is that calling Animal return a subclass, Dog in this case, initialized with manipulated arguments (name, in this case)

  • So, I'm looking for a way to keep the basic structure of the code above, where the parent class can be called, but just always returns a child instance.

  • Of course, this is a contrived example ;)

Many thanks, let me know if more information is helpful.


Suboptimal solutions

factory function

def create_animal(name) -> Animal:
    # Return one of the subclasses
    if name.lower() in ['bello', 'fido', 'bandit']: 
        name = name.title() 
        return Dog(name)
    elif name.lower() in ['tiger', 'milo', 'felix']:
        # ...

class Animal:
    name = property(lambda self: self._name)
    present = lambda self: print(f"{self.name}, a {self.__class__.__name__}")
    # ... and (many) other methods that must be inherited

class Dog(Animal):
    # ...

This breaks backward compatibility by no longer allowing the creation of animals with a Animal() call. Typechecking is still possible

I prefer the symmetry of being able to call a specific species, with Dog(), or use the more general Animal(), in the exact same way, which does not exist here.

factory funcion, alternative

Same as previous, but change the name of the Animal class to AnimalBase, and the name of the create_animal function to Animal.

This fixes the previous problem, but breaks backward compatibility by no longer allowing typechecking with isinstance(dog1, Animal).

like image 205
ElRudi Avatar asked Jun 30 '26 17:06

ElRudi


1 Answers

Updated answer after receiving more information in the comments and after the question has been updated:

class Animal:
    def __new__(cls, name):
        if cls is not Animal:  # avoiding recursion
            return super().__new__(cls)

        # Return one of the subclasses
        if name.lower() in ['bello', 'fido', 'bandit']:  # expensive tests
            name = name.title()  # expensive data correction
            return Dog(name)
        elif name.lower() in ['tiger', 'milo', 'felix']:
            name = name.title()  # expensive data correction
            return Cat(name)    # ...
    # ...


class Dog(Animal):
    def __init__(self, name):
        # Prevent double __init__
        if not hasattr(self, '_name'):
            self._name = f"Mr. {name}"  # cheap data correction
    # ... and (few) other dog-specific methods


class Cat(Animal):
    def __init__(self, name):
        # Prevent double __init__
        if not hasattr(self, '_name'):
            self._name = f"Dutchess {name}"  # cheap data correction
    # ... and (few) other cat-specific methods

Original answer below:


I would generally advocate for using a class method as an alternate constructor instead of writing a __new__ or __init__ that does nontrivial work. In this case, it would be a static method.

For example:

class Animal:
    @staticmethod
    def from_name(name):
        # Return one of the subclasses
        if name.startswith("dog_"):  # very expensive tests
            name = name[4:].lower()  # very expensive data correction
            return Dog(name)
        elif name.startwith("cat_"):
            pass
            # ..

# Use like this:
dog2 = Animal.from_name("dog_BELLO")
dog2.present()

However, in this particular case we have an example from Python's standard library: pathlib. If you instantiate a Path, it will actually return a WindowsPath or PosixPath depending on your platform, both of which are subclasses of Path. This is how it is done:

    def __new__(cls, *args, **kwargs):
        if cls is Path:
            cls = WindowsPath if os.name == 'nt' else PosixPath
        self = cls._from_parts(args)
        if not self._flavour.is_supported:
            raise NotImplementedError("cannot instantiate %r on your system"
                                      % (cls.__name__,))
        return self

(Where cls._from_parts calls object.__new__.)

In your case, that would be something like:

class Animal:
    def __new__(cls, name):
        if cls is Animal:
            if name.startswith("dog_"):  # very expensive tests
                name = name[4:].lower()  # very expensive data correction
                cls = Dog
            elif name.starstwith("cat_"):
                pass # ...
        self = object.__new__(cls)
        self.name = name
        return self

Note that in this case you should not define an __init__, because it would be called with the original arguments, not the modified ones.

like image 136
Jasmijn Avatar answered Jul 03 '26 09:07

Jasmijn