【问题标题】:Improper use of __new__ to generate class instances?不当使用 __new__ 生成类实例?
【发布时间】:2015-01-20 00:05:06
【问题描述】:

我正在创建一些类来处理各种类型的文件共享(nfs、afp、s3、本地磁盘)等中的文件名。当用户输入一个标识数据源的字符串(即"nfs://192.168.1.3" 或 @ 987654322@)等

我正在从具有通用代码的基类中继承特定的文件系统。我感到困惑的是对象创建。我有以下内容:

import os

class FileSystem(object):
    class NoAccess(Exception):
        pass

    def __new__(cls,path):
        if cls is FileSystem:
            if path.upper().startswith('NFS://'): 
                return super(FileSystem,cls).__new__(Nfs)
            else: 
                return super(FileSystem,cls).__new__(LocalDrive)
        else:
            return super(FileSystem,cls).__new__(cls,path)

    def count_files(self):
        raise NotImplementedError

class Nfs(FileSystem):
    def __init__ (self,path):
        pass

    def count_files(self):
        pass

class LocalDrive(FileSystem):
    def __init__(self,path):
        if not os.access(path, os.R_OK):
            raise FileSystem.NoAccess('Cannot read directory')
        self.path = path

    def count_files(self):
        return len([x for x in os.listdir(self.path) if os.path.isfile(os.path.join(self.path, x))])

data1 = FileSystem('nfs://192.168.1.18')
data2 = FileSystem('/var/log')

print type(data1)
print type(data2)

print data2.count_files()

我认为这将是 __new__ 的一个很好的用途,但我读到的大多数关于它的帖子都劝阻它。有没有更被接受的方法来解决这个问题?

【问题讨论】:

  • 您能否进一步解释一下您希望以这种方式使用 __new__() 实现什么目标?
  • 我删除了precode 的使用。不确定是你还是编辑,但它弄乱了空白
  • Patrick,我正在寻找一种方法来创建一个与数据源无关的类。我上面使用的例子是一个简单的例子,只计算目录中的文件数。我不希望用户担心幕后是如何处理的。
  • @Segsfault:您能否提供一些链接到您阅读的不鼓励使用__new__ 的帖子示例?谢谢。

标签: python factory factory-pattern


【解决方案1】:

认为使用__new__() 做你想做的事是不恰当的。换句话说,我不同意 accepted answerquestion 的说法,它声称工厂函数始终是“最好的方法”。

如果你真的想避免使用它,那么唯一的选择是元类或单独的factory 函数/方法(但是请参阅下面的Python 3.6+ 更新 .鉴于可用的选择,将__new__() 方法设为一种(因为它默认是静态的)是一种非常明智的方法。

也就是说,以下是我认为您的代码的改进版本。我添加了几个类方法来帮助自动查找所有子类。这些支持最重要的更好的方式——现在添加子类不需要修改__new__() 方法。这意味着它现在很容易扩展,因为它有效地支持您可以称为虚拟构造函数的东西。

类似的实现也可以用于将实例的创建从__new__() 方法转移到一个单独的(静态)工厂方法中——所以从某种意义上说,所展示的技术只是一种相对简单的可扩展泛型编码方法工厂函数,不管它的名字是什么。

# Works in Python 2 and 3.

import os
import re

class FileSystem(object):
    class NoAccess(Exception): pass
    class Unknown(Exception): pass

    # Regex for matching "xxx://" where x is any non-whitespace character except for ":".
    _PATH_PREFIX_PATTERN = re.compile(r'\s*([^:]+)://')

    @classmethod
    def _get_all_subclasses(cls):
        """ Recursive generator of all class' subclasses. """
        for subclass in cls.__subclasses__():
            yield subclass
            for subclass in subclass._get_all_subclasses():
                yield subclass

    @classmethod
    def _get_prefix(cls, s):
        """ Extract any file system prefix at beginning of string s and
            return a lowercase version of it or None when there isn't one.
        """
        match = cls._PATH_PREFIX_PATTERN.match(s)
        return match.group(1).lower() if match else None

    def __new__(cls, path):
        """ Create instance of appropriate subclass using path prefix. """
        path_prefix = cls._get_prefix(path)

        for subclass in cls._get_all_subclasses():
            if subclass.prefix == path_prefix:
                # Using "object" base class method avoids recursion here.
                return object.__new__(subclass)
        else:  # No subclass with matching prefix found (& no default defined)
            raise FileSystem.Unknown(
                'path "{}" has no known file system prefix'.format(path))

    def count_files(self):
        raise NotImplementedError


class Nfs(FileSystem):
    prefix = 'nfs'

    def __init__ (self, path):
        pass

    def count_files(self):
        pass


class LocalDrive(FileSystem):
    prefix = None  # Default when no file system prefix is found.

    def __init__(self, path):
        if not os.access(path, os.R_OK):
            raise FileSystem.NoAccess('Cannot read directory')
        self.path = path

    def count_files(self):
        return sum(os.path.isfile(os.path.join(self.path, filename))
                     for filename in os.listdir(self.path))


if __name__ == '__main__':

    data1 = FileSystem('nfs://192.168.1.18')
    data2 = FileSystem('c:/')  # Change as necessary for testing.

    print(type(data1).__name__)  # -> Nfs
    print(type(data2).__name__)  # -> LocalDrive

    print(data2.count_files())  # -> <some number>

Python 3.6+ 更新

上面的代码适用于 Python 2 和 3.x。然而,在 Python 3.6 中,object 添加了一个名为 __init_subclass__() 的新类方法,通过使用它自动创建它们的“注册表”,而不是像 @987654335 那样递归地检查每个子类,从而使查找子类变得更简单@方法在上面做的。

我从PEP 487 -- Simpler customisation of class creation 提案中的Subclass registration 部分得到了使用__init_subclass__() 执行此操作的想法。由于该方法将由所有基类的子类继承,因此子子类也将自动进行注册(而不是仅直接子类)——它完全消除了对方法的需要喜欢_get_all_subclasses()

# Requires Python 3.6+

import os
import re

class FileSystem(object):
    class NoAccess(Exception): pass
    class Unknown(Exception): pass

    # Pattern for matching "xxx://"  # x is any non-whitespace character except for ":".
    _PATH_PREFIX_PATTERN = re.compile(r'\s*([^:]+)://')
    _registry = {}  # Registered subclasses.

    @classmethod
    def __init_subclass__(cls, /, path_prefix, **kwargs):
        super().__init_subclass__(**kwargs)
        cls._registry[path_prefix] = cls  # Add class to registry.

    @classmethod
    def _get_prefix(cls, s):
        """ Extract any file system prefix at beginning of string s and
            return a lowercase version of it or None when there isn't one.
        """
        match = cls._PATH_PREFIX_PATTERN.match(s)
        return match.group(1).lower() if match else None

    def __new__(cls, path):
        """ Create instance of appropriate subclass. """
        path_prefix = cls._get_prefix(path)
        subclass = cls._registry.get(path_prefix)
        if subclass:
            return object.__new__(subclass)
        else:  # No subclass with matching prefix found (and no default).
            raise cls.Unknown(
                f'path "{path}" has no known file system prefix')

    def count_files(self):
        raise NotImplementedError


class Nfs(FileSystem, path_prefix='nfs'):
    def __init__ (self, path):
        pass

    def count_files(self):
        pass

class Ufs(Nfs, path_prefix='ufs'):
    def __init__ (self, path):
        pass

    def count_files(self):
        pass

class LocalDrive(FileSystem, path_prefix=None):  # Default file system.
    def __init__(self, path):
        if not os.access(path, os.R_OK):
            raise self.NoAccess(f'Cannot read directory {path!r}')
        self.path = path

    def count_files(self):
        return sum(os.path.isfile(os.path.join(self.path, filename))
                     for filename in os.listdir(self.path))


if __name__ == '__main__':

    data1 = FileSystem('nfs://192.168.1.18')
    data2 = FileSystem('c:/')  # Change as necessary for testing.
    data4 = FileSystem('ufs://192.168.1.18')

    print(type(data1))  # -> <class '__main__.Nfs'>
    print(type(data2))  # -> <class '__main__.LocalDrive'>
    print(f'file count: {data2.count_files()}')  # -> file count: <some number>

    try:
        data3 = FileSystem('c:/foobar')  # A non-existent directory.
    except FileSystem.NoAccess as exc:
        print(f'{exc} - FileSystem.NoAccess exception raised as expected')
    else:
        raise RuntimeError("Non-existent path should have raised Exception!")

    try:
        data4 = FileSystem('foobar://42')  # Unregistered path prefix.
    except FileSystem.Unknown as exc:
        print(f'{exc} - FileSystem.Unknown exception raised as expected')
    else:
        raise RuntimeError("Unregistered path prefix should have raised Exception!")

【讨论】:

  • Kolmar 和 martineau 都提出了很好的建议。我现在以不同的方式使用它们。但是对于这个特定问题,我采用了@martineau 的方法。谢谢!
  • @Bobby:当您对某人的回答中的代码进行编辑时,您还需要检查并查看它的描述是否需要任何相应的更改 - 您没有.
  • @Stabilo:我找到了一篇标题为 __init_subclass__ – a simpler way to implement class registries in Python 的博文,它很好地解释了正在发生的事情。
  • @Stabilo:这意味着从那时起的所有参数都是关键字(只能通过使用它们的名称指定它们来传递)。
  • @Stabilo:我将删除它的用法。它之所以存在,是因为更高版本的文档在显示的示例代码中开始使用它。
【解决方案2】:

在我看来,以这种方式使用__new__ 确实会让其他可能阅读您的代码的人感到困惑。此外,它还需要一些 hackish 代码来区分猜测文件系统和用户输入,并使用相应的类创建 NfsLocalDrive

为什么不使用这种行为创建一个单独的函数呢?甚至可以是FileSystem类的静态方法:

class FileSystem(object):
    # other code ...

    @staticmethod
    def from_path(path):
        if path.upper().startswith('NFS://'): 
            return Nfs(path)
        else: 
            return LocalDrive(path)

你这样称呼它:

data1 = FileSystem.from_path('nfs://192.168.1.18')
data2 = FileSystem.from_path('/var/log')

【讨论】:

  • 恕我直言,虽然这可能更容易理解,但它的一个重要限制(以及 OP 的实现)是工厂函数/方法必须提前知道所有派生类.从这个意义上说,它们并没有真正提供真正的类似 C++ 的“虚拟”构造函数可能提供的通用性(尽管它也不支持它们)。在任何一种语言中这样做都需要更复杂的东西,它允许子类以在满足适当条件时创建自身实例的方式覆盖或扩充基类构造函数。
  • @martineau 我选择了@staticmethod 方法,因为在最初的问题中,该类应该知道它的子类并调用它们的构造函数。在 Python 中,您描述的这种虚拟构造函数行为通常可以通过元类来实现(或者可能还有其他方法,例如模块级变量,不确定这是否可行)。子类通过元类注册在某个类级集合中,基类提供了一个类方法,该方法贯穿该集合并检查子类是否适合传递的参数。但我认为这超出了这个问题的范围。
  • 是的,为了提供我所描述的可扩展性,元类似乎是一个合乎逻辑的选择——尽管显然只需__new__ 就可以完成。但是,我不知道有任何惯用或规范的方式使用它们来真正实现虚拟构造函数,更不用说工厂方法了(这就是为什么我问 OP,他/她在哪里看到了不鼓励使用 __new__ 来实现甚至相对简单的静态工厂方法模式)。
  • @martineau 哦,哇,不知道__subclasses__。你每天都能学到新东西,嗯。尽管如此,即使使用它,我也会将工厂方法设为@classmethod,或者甚至可能是@staticmethod,而不是(ab)使用__new__
  • 谁说过__subclasses__? ;-)
【解决方案3】:

编辑[BLUF]:@martineau 提供的答案没有问题,这篇文章只是为了跟进完成,讨论在类定义中使用附加关键字时遇到的潜在错误不是由元类管理的。

我想提供一些关于使用__init_subclass__ 以及使用__new__ 作为工厂的附加信息。 @martineau 发布的答案非常有用,我在自己的程序中实现了它的修改版本,因为我更喜欢使用类创建序列而不是向命名空间添加工厂方法;与pathlib.Path 的实现方式非常相似。

为了跟进 @martinaeu 的评论跟踪,我从他的回答中提取了以下 sn-p:

import os
import re

class FileSystem(object):
    class NoAccess(Exception): pass
    class Unknown(Exception): pass

    # Regex for matching "xxx://" where x is any non-whitespace character except for ":".
    _PATH_PREFIX_PATTERN = re.compile(r'\s*([^:]+)://')
    _registry = {}  # Registered subclasses.

    @classmethod
    def __init_subclass__(cls, /, **kwargs):
        path_prefix = kwargs.pop('path_prefix', None)
        super().__init_subclass__(**kwargs)
        cls._registry[path_prefix] = cls  # Add class to registry.

    @classmethod
    def _get_prefix(cls, s):
        """ Extract any file system prefix at beginning of string s and
            return a lowercase version of it or None when there isn't one.
        """
        match = cls._PATH_PREFIX_PATTERN.match(s)
        return match.group(1).lower() if match else None

    def __new__(cls, path):
        """ Create instance of appropriate subclass. """
        path_prefix = cls._get_prefix(path)
        subclass = FileSystem._registry.get(path_prefix)
        if subclass:
            # Using "object" base class method avoids recursion here.
            return object.__new__(subclass)
        else:  # No subclass with matching prefix found (and no default).
            raise FileSystem.Unknown(
                f'path "{path}" has no known file system prefix')

    def count_files(self):
        raise NotImplementedError


class Nfs(FileSystem, path_prefix='nfs'):
    def __init__ (self, path):
        pass

    def count_files(self):
        pass


class LocalDrive(FileSystem, path_prefix=None):  # Default file system.
    def __init__(self, path):
        if not os.access(path, os.R_OK):
            raise FileSystem.NoAccess('Cannot read directory')
        self.path = path

    def count_files(self):
        return sum(os.path.isfile(os.path.join(self.path, filename))
                     for filename in os.listdir(self.path))


if __name__ == '__main__':

    data1 = FileSystem('nfs://192.168.1.18')
    data2 = FileSystem('c:/')  # Change as necessary for testing.

    print(type(data1).__name__)  # -> Nfs
    print(type(data2).__name__)  # -> LocalDrive

    print(data2.count_files())  # -> <some number>

    try:
        data3 = FileSystem('foobar://42')  # Unregistered path prefix.
    except FileSystem.Unknown as exc:
        print(str(exc), '- raised as expected')
    else:
        raise RuntimeError(
              "Unregistered path prefix should have raised Exception!")

此答案是书面作品,但我希望解决其他人可能因缺乏经验或团队要求的代码库标准而遇到的一些问题(潜在的陷阱)。

首先,对于__init_subclass__ 上的装饰器,根据PEP

可能需要在 __init_subclass__ 装饰器上显式使用 @classmethod。它是隐含的,因为没有合理的解释可以忽略它,并且无论如何都需要检测到这种情况才能给出有用的错误消息。

这不是问题,因为它已经暗示了,而禅宗告诉我们“显性大于隐性”;无论如何,当遵守 PEP 时,就可以了(并且进一步解释了理性)。

在我自己的类似解决方案的实现中,子类没有使用额外的关键字参数定义,例如@martineau 在此处所做的:

class Nfs(FileSystem, path_prefix='nfs'): ...
class LocalDrive(FileSystem, path_prefix=None): ...

浏览PEP时:

作为第二个更改,新的type.__init__ 只是忽略关键字参数。目前,它坚持没有给出关键字参数。如果元类不处理关键字参数给类声明,这会导致(想要的)错误。想要接受关键字参数的元类作者必须通过覆盖 __init__ 来过滤掉它们。

为什么这(可能)有问题?好吧,有几个问题(特别是this)描述了围绕类定义中的附加关键字参数、元类的使用(随后是metaclass= 关键字)和被覆盖的__init_subclass__ 的问题。但是,这并不能解释为什么它在当前给定的解决方案中有效。答案:kwargs.pop()

如果我们看以下内容:

# code in CPython 3.7

import os
import re

class FileSystem(object):
    class NoAccess(Exception): pass
    class Unknown(Exception): pass

    # Regex for matching "xxx://" where x is any non-whitespace character except for ":".
    _PATH_PREFIX_PATTERN = re.compile(r'\s*([^:]+)://')
    _registry = {}  # Registered subclasses.

    def __init_subclass__(cls, **kwargs):
        path_prefix = kwargs.pop('path_prefix', None)
        super().__init_subclass__(**kwargs)
        cls._registry[path_prefix] = cls  # Add class to registry.

    ...

class Nfs(FileSystem, path_prefix='nfs'): ...

这仍然可以正常运行,但如果我们删除 kwargs.pop():

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)  # throws TypeError
        cls._registry[path_prefix] = cls  # Add class to registry.

抛出的错误是已知的并在 PEP 中描述:

在新代码中,抱怨关键字参数的不是__init__,而是__init_subclass__,其默认实现不带参数。在使用方法解析顺序的经典继承方案中,每个__init_subclass__ 都可以取出它的关键字参数,直到没有留下任何关键字参数,这由__init_subclass__ 的默认实现进行检查。

正在发生的事情是 path_prefix= 关键字正在从 kwargs 中“弹出”,而不仅仅是访问,因此 **kwargs 现在为空并传递了 MRO,因此符合默认实现(接收 no关键字参数)。

为了完全避免这种情况,我建议不要依赖kwargs,而是使用对__init_subclass__ 的调用中已经存在的内容,即cls 参考:

# code in CPython 3.7

import os
import re

class FileSystem(object):
    class NoAccess(Exception): pass
    class Unknown(Exception): pass

    # Regex for matching "xxx://" where x is any non-whitespace character except for ":".
    _PATH_PREFIX_PATTERN = re.compile(r'\s*([^:]+)://')
    _registry = {}  # Registered subclasses.

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        cls._registry[cls._path_prefix] = cls  # Add class to registry.

    ...

class Nfs(FileSystem):
    _path_prefix = 'nfs'

    ...

如果需要引用子类使用的特定前缀(通过self._path_prefix),将prior 关键字添加为类属性还扩展了在以后的方法中的使用。据我所知,您不能再引用定义中提供的关键字(没有一些复杂性),这似乎微不足道且有用。

因此,对于 @martineau,我为我的 cmets 看起来令人困惑而道歉,只有这么多的空间可以输入它们,并且如图所示,它更详细。

【讨论】:

  • 作为记录,path_prefix 弹出的 正在被访问——即使它的值是 None。在开发当前答案的某一时刻,我考虑使用子类属性,就像在非__init_subclass__() 版本中所做的那样,但决定我喜欢通过在声明子类时传递关键字参数来使事情更明确。我认为你的方法没有错,但像往常一样,这两种方法之间需要权衡取舍,我认为这主要是个人喜好。
  • @martineau 确实是个人喜好。我只是想避免引发这种“更深”的 TypeError 的可能性;考虑到您的回答激发了我的变化并最终使我发现了对kwargs.pop 的需求以及使用关键字参数或类属性之间的区别以及这如何影响__init_subclass__ 的实现,因此想在此处发布。跨度>
  • 由于__init_subclass__() 是在需要pop() 的基类中定义的,派生类需要注意参数,因此要求关键字参数对子类来说似乎不是太大的负担得到“正确”。如果您想要更好的错误消息,我想如果缺少关键字参数,基类实现可能会引发错误消息。
  • @martineau 这绝不是一种负担,只是关于您的不同方法的轶事回复(因为您显示一个具有类属性而一个没有);只是为了扩展不能使用 __init_subclass__ 和包含关键字的类定义而不使用 pop() 并解释为什么会这样。我更喜欢将 class 属性与__init_subclass__ 一起使用,但正如您所说,这是优先考虑的。
  • 我已经稍微更新了我的答案,因此不再需要 pop() — 类似于 documentation 中显示的示例 — 这样就少了一件需要担心的事情。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2013-01-24
  • 1970-01-01
  • 2019-11-26
  • 2020-12-05
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多