在Python中,我可以在实例化其父类时返回子类实例吗?

4

我有一个动物园,里面的动物用对象表示。历史上,只有 Animal 类存在,例如通过 x = Animal('Bello') 创建动物对象,并使用 isinstance(x, Animal) 进行类型检查。

最近,区分物种变得很重要。 Animal 已成为 ABC,并且所有动物对象现在都是其子类(例如 DogCat)的实例。

这个改变允许我直接从其中一个子类创建动物对象,例如在下面的代码中使用 dog1 = Dog('Bello')。这很便宜,只要我知道我正在处理什么类型的动物。类型检查 isinstance(dog1, Animal) 仍然像以前一样工作。

然而,为了可用性和向后兼容性,我还想能够调用 dog2 = Animal('Bello'),让 (来自输入值)确定物种,并返回 Dog 实例 - 即使这会计算更多。

我需要第二种方法的帮助。

这是我的代码:

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.

备注:

  • 在我的用例中,第二种创建方法是最重要的。

  • 我想要实现的是调用 Animal 后返回一个子类实例,例如 Dog,并用操纵后的参数(如本例中的 name)进行初始化。

  • 因此,我正在寻找一种方式来保留上述代码的基本结构,其中可以调用父类,但始终返回一个子类实例。

  • 当然,这只是一个虚构的示例;)

非常感谢,如果需要更多信息,请告诉我。


次优解决方案

工厂函数

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):
    # ...

这会破坏后向兼容性,不再允许使用Animal()来创建动物。类型检查仍然是可能的。

我更喜欢能够以完全相同的方式调用特定物种的Dog()或使用更通用的Animal()的对称性,但在此处不存在这种情况。

工厂函数,备选方案

与以前相同,但将Animal类的名称更改为AnimalBase,将create_animal函数的名称更改为Animal

这解决了先前的问题,但会破坏后向兼容性,不再允许使用isinstance(dog1, Animal)进行类型检查。


你是否想要实现工厂模式? - JonSG
5
你可能可以使用__new__来实现这个,但我强烈建议使用单独的工厂方法来减少循环依赖。 - mousetail
嗯,也许我是。您正在谈论解决方案1a,对吗?它会破坏对称性,导致无法以完全相同的方式使用“Dog()”来调用特定物种或使用更一般的“Animal()”。如果可能的话,我更喜欢保持这种对称性。 - ElRudi
1
我同意其他人的观点:工厂函数是正确的选择。我还建议将Animal定义为抽象类。 - Pablo Henkowski
试图像这样使用__new__的问题在于,由Dog继承的Animal.__new__仍然是创建Dog新实例的方法。现在你有一个方法尝试着做两件完全不同的事情,取决于它的cls参数的值。 - chepner
4个回答

1

在评论区收到更多信息并且问题已经更新后,更新的答案如下:

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

以下是原始答案:


一般情况下,我倾向于使用类方法作为一个替代的构造函数而不是编写一个执行非平凡工作的__new____init__方法。在这种情况下,它将成为一个静态方法。
例如:
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()

然而,在这个特定的案例中,我们有一个来自Python标准库的示例:pathlib。如果你实例化一个Path对象,它实际上会返回一个WindowsPath或者PosixPath对象,取决于你的平台,这两个对象都是Path对象的子类。这就是它的实现方式
    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

(在 cls._from_parts 调用 object.__new__ 的地方。)
(在你的情况下,应该是这样的:)
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

请注意,这种情况下不应定义一个__init__,因为它将使用原始参数而不是修改后的参数进行调用。

请注意最后一个例子(其中有一个拼写错误,需要返回self)。即使使用x = Animal(...),当Animal.__new__返回任何Animal实例(包括Animal的子类实例)时,仍将导致调用__init__ - chepner
这就是我匆忙写作的结果,你说得对。如果你需要Animal('dog_lassie')返回与Dog('lassie')相同的结果,则需要在__new__中设置名称,而不是在__init__中设置,因为否则它会像初始化Dog('dog_lassie')一样进行初始化。 - Jasmijn
1
非常感谢您的回答!在“路径”示例中,只要具有相同的方法和属性,用户就不关心他们收到的是WindowsPath还是PosixPath。这很像我的实际用例;我的用户关心创建一个Animal,而不关心实现细节以及他们是否接收到一只Dog或一只Cat。(也许在这方面我的例子没有被认真考虑) - ElRudi
无法使用子类的__init__方法来初始化对象吗?在存储它之前,我确实需要对名称进行一些轻微的处理(在我的示例中未显示)。另外,我正在尝试让这些类成为冻结类,因此在对象初始化后不能设置dog.name。对于额外的条件感到抱歉; 我没想到它们会相关。 - ElRudi
(我稍微修改了我的问题以反映我的先前观点。我还会在__setitem____delitem__上引发错误以确保冻结,但我不想从主要问题中分心。) - ElRudi
@ElRudi __init__ 接收传递给类型的原始参数。我想我可能知道你需要什么,我会编辑我的回答。 - Jasmijn

0

我能够通过检查实例是否已经通过Dog.__init__方法来创建所需的功能。为此,在该方法的代码中添加了下面指示的3行。

class Dog(Animal):
    def __init__(self, name):
        if hasattr(self, '_initialized'):  # <-- new
            return                         # <-- new
        self._initialized = True           # <-- new
        self._name = f"Mr. {name}"


dog1 = Dog("Bello")
dog1.present()  # as expected, prints 'Mr. Bello, a Dog'.
dog2 = Animal("BELLO")
dog2.present()  # as wanted, also prints 'Mr. Bello, a Dog'.  # <--!

由于这些行需要添加到每个子类中,因此创建一个装饰器可能是有意义的,如下所示:

def dont_initialize_twice(__init__):
    def wrapped(self, *args, **kwargs):
        if hasattr(self, "_initialized"):
            return
        __init__(self, *args, **kwargs)
        self._initialized = True

    return wrapped

class Dog:
    @dont_initialize_twice
    def __init__(self, name):
        self._name = f"Mr. {name}"

class Cat:
    @dont_initialize_twice
    def __init__(self, name):
        self._name = f"Dutchess {name}"

如果有充分的理由证明这是不好的做法,并且存在更好的解决方案,我真的很想听听它。

啊,我看到你在此期间已经找到了一个类似的解决方案。 - Jasmijn

0
最终,我选择了一个类装饰器:
# defining the decorator

def dont_init_twice(Class):
    """Decorator for child classes of Animal, to allow Animal to return a child instance."""
    original_init = Class.__init__
    Class._initialized = False

    def wrapped_init(self, *args, **kwargs):
        if not self._initialized:
            object.__setattr__(self, "_initialized", True)  #works also on frozen dataclasses
            original_init(self, *args, **kwargs)

    Class.__init__ = wrapped_init

    return Class


# decorating the child classes

@dont_init_twice
class Dog:
    def __init__(self, name):
        self._name = f"Mr. {name}"

@dont_init_twice
class Cat:
    def __init__(self, name):
        self._name = f"Dutchess {name}"

在我看来,这是最干净、最少侵入性的解决方案。


0
这是一个有趣的问题,所以我想出了一个可能适合你的答案。
这里的思路是覆盖 __call__ 来解析 name 参数,以获取类和动物名称,然后使用动物名称分派到正确的类中。
from __future__ import annotations


class AnimalMeta(type):
    def __call__(cls, name):
        cls_name, *animal_name = cls._parse_name(name)
        if res := cls._registry.get(cls_name):
            name = animal_name.pop()
        return type.__call__(res or cls, name)


class Animal(metaclass=AnimalMeta):
    _registry = {}

    def __init__(self, name):
        self.name = name

    def __init_subclass__(cls, **kw):
        super().__init_subclass__(**kw)
        cls._registry[cls.__name__.lower()] = cls

    @classmethod
    def _parse_name(cls, name) -> tuple[str, str]:
        return name.split("_", maxsplit=1)

    def __repr__(self):
        return f"{type(self).__name__}(name={self.name!r})"


class Dog(Animal):
    ...


class Wombat(Animal):
    ...



animal = Animal("jeb")
dog = Animal("dog_doggo")
wombat = Animal("wombat_wombotron")

print(Animal._registry)
print(animal)
print(dog)
print(wombat)

输出:

{'dog': <class '__main__.Dog'>, 'wombat': <class '__main__.Wombat'>}
Animal(name='jeb')
Dog(name='doggo')
Wombat(name='wombotron')

注意:这通常对我来说太过神秘,但这是实现你想要的方式。


非常感谢你的回答 @acushner!我同意这确实是很神奇的 :) 我确实在跟进它方面遇到了一些困难。有没有办法“尊重”/使用子类的__init__方法?我稍微修改了示例以更好地反映我的实际用例。 - ElRudi
__new__已经作为在类中自定义对象创建的方式存在。 - antont
@ElRudi 是的,你只需要编写__init__方法,它就会被调用... - acushner

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接