суббота, 8 декабря 2012 г.

Python: Автоматическая инициализация при доступе к атрибуту объекта

Не так давно пришлось столкнуться со следующей ситуацией: имелся довольно сложный и затратный по ресурсам алгоритм сбора таблицы соответствий (читай dict), при этом, хотелось иметь к элементам этой таблицы достаточно удобный доступ (по имени), так как в дальнейшем они довольно таки активно использовались в различных частях кода. Что так же немаловажно, сценариев исполнения самого кода присутствует великое множество и далеко не во всех из них эта таблица понадобится.
Приведу пример, чтобы стало немного понятнее:
  class SocialServices(object):
     def init(self):
       facebook, twitter = thirdparty.load_available_services_from_external_site()
       self.facebook = facebook
       self.twitter = twitter

  social_services = SocialServices()
  social_services.init()

  def like_it_on_facebook():
     global social_services
     social_services.facebook.like_it
В этом примере можно увидеть все обозначенные выше проблемы: 1. Все социальные сервисы получаются из одного источника одновременно 2. Сервисы загружаются с внешнего сайта, а это достаточно медленная операция. 3. Сервис понадобится только, если кто-нибудь захочет сделать like на facebook. Один из возможных вариантов, это использовать "ленивые" свойства, тогда код мог бы выглядеть следующим образом:
  class SocialServices(object):
     @lazy_property
     def _services(self):
       return thirdparty.load_available_services_from_external_site()

     def facebook(self):
       return self._services[0]

     def twitter(self):
       return self._services[1]

  social_services = SocialServices()

  def like_it_on_facebook():
     global social_services
     social_services.facebook.like_it
В этом случае сервисы загрузятся при первом обращении, а в дальнейшем будет использоваться их закэшированная версия. Единственное, чем этот код страдает, это резким снижением читабельности. Может быть это и не так заметно, при наличии всего двух сервисов, но, если их станет 10 или 20... Читабельность можно повысить, введя дополнительный result-объект, к которому мы сможем обращаться не по индексам, а по именам компонентов, но, тогда, это будет практически копия наших социальных сервисов. Так же этот метод будет плохо работать, если инициализация включает не только запрос, но и какое-то дополнительное изменение состояния объекта. Другой вариант, это проверять состояние объекта каждый раз при обращении к свойству, которое этого требует:
  class SocialServices(object):
     initialized = False

     def init(self):
       facebook, twitter = thirdparty.load_available_services_from_external_site()
       self._facebook = facebook
       self._twitter = twitter

     def ensure_initialized(self):
       if not self.initialized:
         self.init()
         self.initialized = True

     @property
     def facebook(self):
       self.ensure_initialized()
       return self._facebook

     @property
     def twitter(self):
       self.ensure_initialized()
       return self._twitter

  social_services = SocialServices()

  def like_it_on_facebook():
     global social_services
     social_services.facebook.like_it
Это метод решает все вышеперечисленные проблемы и можно было бы на этом остановиться, смирившись даже с тем, что необходимо дополнительно вводить для каждого атрибута свойство, содержащее проверку состояния объекта, но, постойте, мы ведь программируем на Питоне... На помощь нам приходят декораторы. Не буду и дальше растягивать интригу и приведу окончательное решение:
  def auto_init_by_access_to(class_, *fields):
    for field in fields:
      getter = lambda instance: instance.__dict__[field]
      getter.__name__ = field
      setattr(class_, field, auto_init_property(getter))

  class auto_init_property(object):
    def __init__(self, func):
      self._func = func
      self.__name__ = func.__name__
      self.__doc__ = func.__name__

    def __get__(self, instance, _):
      if instance is None: return None
      if not hasattr(instance, 'initialized') or not instance.initialized:
        instance.init()
        instance.initialized = True
      result = instance.__dict__[self.__name__] = self._func(instance)
      return result

  class SocialServices(object):
     def init(self):
       facebook, twitter = thirdparty.load_available_services_from_external_site()
       self.facebook = facebook
       self.twitter = twitter

  social_services = SocialServices()
  auto_init_by_access_to(SocialServices, "facebook", "twitter")

  def like_it_on_facebook():
     global social_services
     social_services.facebook.like_it
Нам пришлось добавить один вспомогательный класс и одну вспомогательную функцию. auto_init_property по сути выполняет роль свойства из предыдущего примера и мы могли бы использовать этот класс следующим образом:
  @auto_init_property
  def facebook(self):
    return self._facebook
при этом при первом же обращении свойство будет заменено значением self._facebook. Функция auto_init_by_access_to по сути создает эти свойства в указанном классе, при этом сам наш класс SocialServices вернулся к тому виду, в котором он впервые предстал в этом посте, давая нам возможность выбирать между автоматической и явной инициализацией.

UPD: переместил функцию load_available_services_from_external_site в модуль thirdparty, чтобы сделать более явным то, что она является сторонней и недоступна для модификаций.