【问题标题】:How can I delay the __init__ call until an attribute is accessed?如何延迟 __init__ 调用,直到访问属性?
【发布时间】:2017-12-24 23:03:48
【问题描述】:

我有一个测试框架,它要求使用以下类模式定义测试用例:

class TestBase:
    def __init__(self, params):
        self.name = str(self.__class__)
        print('initializing test: {} with params: {}'.format(self.name, params))

class TestCase1(TestBase):
    def run(self):
        print('running test: ' + self.name)

当我创建并运行测试时,我得到以下信息:

>>> test1 = TestCase1('test 1 params')
initializing test: <class '__main__.TestCase1'> with params: test 1 params
>>> test1.run()
running test: <class '__main__.TestCase1'>

测试框架搜索并加载它可以找到的所有TestCase 类,实例化每个类,然后为每个测试调用run 方法。

load_test(TestCase1(test_params1))
load_test(TestCase2(test_params2))
...
load_test(TestCaseN(test_params3))

...

for test in loaded_tests:
    test.run()

但是,我现在有一些测试用例,在调用 run 方法之前我不希望调用 __init__ 方法,但我对框架结构或方法几乎没有控制权。如何在不重新定义__init__run 方法的情况下延迟对__init__ 的调用?


更新

这源于XY problem 的推测是正确的。不久前,当我维护所说的测试框架时,一位同事问了我这个问题。我进一步询问了他真正想要实现的目标,我们找到了一个更简单的解决方法,不涉及更改框架或引入元类等。

但是,我仍然认为这是一个值得研究的问题:如果我想创建具有“惰性”初始化的新对象(“惰性”,如 range 等惰性求值生成器中的“惰性”),什么是最好的实现它的方式?到目前为止,我的最佳尝试如下所示,我很想知道是否有更简单或更简洁的方法。

【问题讨论】:

  • 是否只是为了延迟一些与self.name相关的繁重初始化?
  • 在某些情况下,是的。
  • 然后更好地使它们成为属性并在它们的吸气剂中完成繁重的工作。现在听起来像是XY problem
  • 如果没有您禁止的更改,就无法实现您的要求,但如果您更深入地了解您的要求,我认为有人可以帮助您。
  • 你能添加超类或元类到TestBase吗?

标签: python python-3.x


【解决方案1】:

在我的回答中,我想关注一个人想要实例化一个初始化器(dunder init)有副作用的类的情况。例如,pysftp.Connection 创建了一个 SSH 连接,在实际使用之前可能不需要它。

在一个关于构思wrapt 包(nit-picky 装饰器实现)的精彩博客系列中,作者描述了Transparent object proxy。可以针对相关主题自定义此代码。

class LazyObject:

    _factory = None
    '''Callable responsible for creation of target object'''

    _object = None
    '''Target object created lazily'''

    def __init__(self, factory):
        self._factory = factory

    def __getattr__(self, name):
        if not self._object:
            self._object = self._factory()

        return getattr(self._object, name)

那么它可以用作:

obj = LazyObject(lambda: dict(foo = 'bar'))
obj.keys()  # dict_keys(['foo'])

但是len(obj)obj['foo'] 和其他调用 Python 对象协议的语言结构(dunder 方法,如 __len____getitem__)将不起作用。但是,对于许多仅限于常规方法的情况,这是一种解决方案。

要代理对象协议实现,既不能使用__getattr__,也不能使用__getattribute__(以通用方式进行)。后者的文档notes

在通过语言语法或内置函数隐式调用查找特殊方法时,仍可能绕过此方法。见Special method lookup

由于需要完整的解决方案,有手动实现的示例,例如 werkzeugLocalProxydjangoSimpleLazyObject。然而,一个聪明的解决方法是possible

幸运的是,有一个专用包(基于 wrapt)用于确切的用例,lazy-object-proxy,在this blog post 中进行了描述。

from lazy_object_proxy import Proxy

obj = Proxy(labmda: dict(foo = 'bar'))
obj.keys()     # dict_keys(['foo'])
len(len(obj))  # 1
obj['foo']     # 'bar'

【讨论】:

  • 出于我的目的(仅在第一次访问类实例方法时调用__init__),这种解决方案似乎比创建元类或类装饰器要简单得多。
【解决方案2】:

我认为您可以使用包装类来保存您想要实例化的真实类,并在您的代码中自己使用 call __init__,例如(Python 3 代码):

class Wrapper:
    def __init__(self, cls):
        self.cls = cls
        self.instance = None

    def your_method(self, *args, **kwargs):
        if not self.instance:
            self.instnace = cls()
        return self.instance(*args, **kwargs)

class YourClass:
    def __init__(self):
        print("calling __init__")

但这是一种转储方式,但没有任何技巧。

【讨论】:

    【解决方案3】:

    覆盖__new__

    您可以通过覆盖 __new__ 方法并用自定义函数替换 __init__ 方法来做到这一点。

    def init(cls, real_init):
        def wrapped(self, *args, **kwargs):
            # This will run during the first call to `__init__`
            # made after `__new__`. Here we re-assign the original
            # __init__ back to class and assign a custom function
            # to `instances.__init__`.
            cls.__init__ = real_init
            def new_init():
                if new_init.called is False:
                    real_init(self, *args, **kwargs)
                    new_init.called = True
            new_init.called = False
            self.__init__ = new_init
        return wrapped
    
    
    class DelayInitMixin(object):
        def __new__(cls, *args, **kwargs):
            cls.__init__ = init(cls, cls.__init__)
            return object.__new__(cls)
    
    
    class A(DelayInitMixin):
        def __init__(self, a, b):
            print('inside __init__')
            self.a = sum(a)
            self.b = sum(b)
    
        def __getattribute__(self, attr):
            init = object.__getattribute__(self, '__init__')
            if not init.called:
                init()
            return object.__getattribute__(self, attr)
    
        def run(self):
            pass
    
        def fun(self):
            pass
    

    演示:

    >>> a = A(range(1000), range(10000))    
    >>> a.run()
    inside __init__    
    >>> a.a, a.b
    (499500, 49995000)    
    >>> a.run(), a.__init__()
    (None, None)    
    >>> b = A(range(100), range(10000))    
    >>> b.a, b.b
    inside __init__
    (4950, 49995000)    
    >>> b.run(), b.__init__()
    (None, None)
    

    使用缓存属性

    这个想法是通过缓存结果只进行一次繁重的计算。如果延迟初始化的全部目的是提高性能,这种方法将导致代码更具可读性。

    Django 带有一个漂亮的装饰器,叫做@cached_property。我倾向于在代码和单元测试中大量使用它来缓存重属性的结果。

    cached_propertynon-data descriptor。因此,一旦在实例的字典中设置了键,对属性的访问将始终从那里获取值。

    class cached_property(object):
        """
        Decorator that converts a method with a single self argument into a
        property cached on the instance.
    
        Optional ``name`` argument allows you to make cached properties of other
        methods. (e.g.  url = cached_property(get_absolute_url, name='url') )
        """
        def __init__(self, func, name=None):
            self.func = func
            self.__doc__ = getattr(func, '__doc__')
            self.name = name or func.__name__
    
        def __get__(self, instance, cls=None):
            if instance is None:
                return self
            res = instance.__dict__[self.name] = self.func(instance)
            return res
    

    用法:

    class A:
        @cached_property
        def a(self):
            print('calculating a')
            return sum(range(1000))
    
        @cached_property
        def b(self):
            print('calculating b')
            return sum(range(10000))
    

    演示:

    >>> a = A()
    >>> a.a
    calculating a
    499500
    >>> a.b
    calculating b
    49995000
    >>> a.a, a.b
    (499500, 49995000)
    

    【讨论】:

      【解决方案4】:

      在 Python 中,当你实例化一个类 cls 时,你无法避免调用 __init__。如果调用cls(args) 返回cls 的实例,则该语言保证将调用cls.__init__

      因此,实现与您所要求的类似的唯一方法是引入另一个类,该类将推迟在原始类中调用__init__,直到正在访问实例化类的属性。

      这是一种方法:

      def delay_init(cls):
          class Delay(cls):
              def __init__(self, *arg, **kwarg):
                  self._arg = arg
                  self._kwarg = kwarg
              def __getattribute__(self, name):
                  self.__class__ = cls
                  arg = self._arg
                  kwarg = self._kwarg
                  del self._arg
                  del self._kwarg
                  self.__init__(*arg, **kwarg)
                  return getattr(self, name)
          return Delay
      

      此包装函数通过捕获任何访问实例化类的属性的尝试来工作。进行此类尝试时,它将实例的__class__ 更改为原始类,使用创建实例时使用的参数调用原始__init__ 方法,然后返回正确的属性。这个函数可以用作TestCase1 类的装饰器:

      class TestBase:
          def __init__(self, params):
              self.name = str(self.__class__)
              print('initializing test: {} with params: {}'.format(self.name, params))
      
      
      class TestCase1(TestBase):
          def run(self):
              print('running test: ' + self.name)
      
      
      >>> t1 = TestCase1("No delay")
      initializing test: <class '__main__.TestCase1'> with params: No delay
      >>> t2 = delay_init(TestCase1)("Delayed init")
      >>> t1.run()
      running test: <class '__main__.TestCase1'>
      >>> t2.run()
      initializing test: <class '__main__.TestCase1'> with params: Delayed init
      running test: <class '__main__.TestCase1'>
      >>> 
      

      但在应用此功能时要小心。如果你用delay_init装饰TestBase,它不会起作用,因为它会将TestCase1实例变成TestBase实例。

      【讨论】:

        【解决方案5】:

        第一个解决方案:使用property.python中setter/getter的优雅方式。

        class Bars(object):
            def __init__(self):
                self._foo = None
        
            @property
            def foo(self):
                if not self._foo:
                    print("lazy initialization")
                    self._foo =  [1,2,3]
                return self._foo
        
        if __name__ == "__main__":
            f = Bars()
            print(f.foo)
            print(f.foo)
        

        第二种解决方案:代理解决方案,始终由装饰器实现。

        简而言之,Proxy 是一个包装你需要的对象的包装器。代理可以为其包装的对象提供附加功能,并且不会更改对象的代码。它是提供对对象的控制访问权的代理项。代码来自user Cyclone

        class LazyProperty:
            def __init__(self, method):
                self.method = method
                self.method_name = method.__name__
        
            def __get__(self, obj, cls):
                if not obj:
                    return None
                value = self.method(obj)
                print('value {}'.format(value))
                setattr(obj, self.method_name, value)
                return value
        
        class test:
            def __init__(self):
                self._resource = None
        
            @LazyProperty
            def resource(self):
                print("lazy")
                self._resource = tuple(range(5))
                return self._resource
        if __name__ == '__main__':
            t = test()
            print(t.resource)
            print(t.resource)
            print(t.resource)
        

        用于真正的一次性计算惰性属性。我喜欢它,因为它避免了在对象上粘贴额外的属性,并且一旦激活就不会浪费时间检查属性是否存在

        【讨论】:

        • 如果您将打印输出添加到答案中会很酷
        【解决方案6】:

        回答您最初的问题(以及我认为您实际上试图解决的问题),“我怎样才能延迟 init 调用直到访问属性?”:不要调用 init 直到您访问该属性。

        换一种说法:你可以让类初始化与属性调用同时进行。您似乎真正想要的是 1) 创建一个 TestCase# 类及其相关参数的集合; 2) 运行每个测试用例。

        您最初的问题可能来自于您认为必须初始化所有 TestCase 类才能创建可以迭代的列表。但实际上您可以将类对象存储在listsdicts 等中。这意味着您可以使用任何方法来查找所有TestCase 类并将这些类对象及其相关参数存储在dict 中。然后只需迭代 dict 并使用其 run() 方法调用每个类。

        它可能看起来像:

        tests = {TestCase1: 'test 1 params', TestCase2: 'test 2 params', TestCase3: 'test 3 params'}
        
        for test_case, param in tests.items():
            test_case(param).run()
        

        【讨论】:

          【解决方案7】:

          元类选项

          您可以使用元类拦截对__init__ 的调用。使用__new__创建对象并覆盖__getattribute__方法,检查__init__是否被调用,如果没有调用它。

          class DelayInit(type):
          
              def __call__(cls, *args, **kwargs):
          
                  def init_before_get(obj, attr):
                      if not object.__getattribute__(obj, '_initialized'):
                          obj.__init__(*args, **kwargs)
                          obj._initialized = True
                      return object.__getattribute__(obj, attr)
          
                  cls.__getattribute__ = init_before_get
          
                  new_obj = cls.__new__(cls, *args, **kwargs)
                  new_obj._initialized = False
                  return new_obj
          
          class TestDelayed(TestCase1, metaclass=DelayInit):
              pass
          

          在下面的示例中,您将看到在执行 run 方法之前不会发生初始化打印。

          >>> new_test = TestDelayed('delayed test params')
          >>> new_test.run()
          initializing test: <class '__main__.TestDelayed'> with params: delayed test params
          running test: <class '__main__.TestDelayed'>
          

          装饰器选项

          您也可以使用与上述元类具有相似模式的装饰器:

          def delayinit(cls):
          
              def init_before_get(obj, attr):
                  if not object.__getattribute__(obj, '_initialized'):
                      obj.__init__(*obj._init_args, **obj._init_kwargs)
                      obj._initialized = True
                  return object.__getattribute__(obj, attr)
          
              cls.__getattribute__ = init_before_get
          
              def construct(*args, **kwargs):
                  obj = cls.__new__(cls, *args, **kwargs)
                  obj._init_args = args
                  obj._init_kwargs = kwargs
                  obj._initialized = False
                  return obj
          
              return construct
          
          @delayinit
          class TestDelayed(TestCase1):
              pass
          

          这将与上面的示例相同。

          【讨论】:

          • 阅读你的问题,但你为什么不把 run 方法作为你的初始化器,这是懒惰的?我有一些方法可以执行非常昂贵的操作(例如网络访问)。您所要做的就是使实例方法变得惰性,一旦它完成工作,您只需使该方法用非惰性方法替换自己。也许我会很快就我对我的程序所做的事情写一个答案。
          • 您的装饰器选项是一个损坏的代码。它引发了RecursionError: maximum recursion depth exceeded while calling a Python object。检查 Python 3.10。
          【解决方案8】:

          另一种方法是编写一个包装器,将一个类作为输入并返回一个延迟初始化的类,直到访问任何成员。例如,可以这样做:

          def lazy_init(cls):
              class LazyInit(cls):
                  def __init__(self, *args, **kwargs):
                      self.args = args
                      self.kwargs = kwargs
                      self._initialized = False
          
                  def __getattr__(self, attr):
                      if not self.__dict__['_initialized']:
                          cls.__init__(self,
                                       *self.__dict__['args'], **self.__dict__['kwargs'])
                          self._initialized = True
          
                      return self.__dict__[attr]
          
              return LazyInit
          

          然后可以这样使用

          load_test(lazy_init(TestCase1)(test_params1))
          load_test(lazy_init(TestCase2)(test_params2))
          ...
          load_test(lazy_init(TestCaseN)(test_params3))
          
          ...
          
          for test in loaded_tests:
              test.run()
          

          【讨论】:

          • 唯一的缺点是创建了一个新类LazyInit。如果有人使用登录类,那么"[class A]: something happened""[class B]: something happened",s(he) 将始终在日志中看到"[class LazyInit]: something happened"。换句话说,被修饰的类名现在被隐藏了。
          猜你喜欢
          • 2014-07-08
          • 1970-01-01
          • 1970-01-01
          • 2011-08-20
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2011-04-26
          • 1970-01-01
          相关资源
          最近更新 更多