Зачем мне гибкость Python, если мне запрещают ей пользоваться?

    Здравствуйте! Есть Была у меня следующая задача: надо было спарсить кучу данных и организовать их в классы, а позже загрузить в БД. Вроде бы, ничего сложного, но в этот день я даже забыл поесть, а почему — смотрите под кат, потому что я сделяль.

    image

    Данных, конечно же, было много, но задачу это никак не усложнило, усложнило то, что один и тот же элемент можно было найти в разных уголках сайта. Эти данные можно сравнить с аккаунтами в социальных сетях. Один и тот же аккаунт может оставить свой след везде — и лайки на разных страничках пооставлять, и комментарии везде написать, и на стенку разным людям что-нибудь повесить. И нужно, чтобы всё это был один и тот же объект в нашей программе и чтобы он никак не дублировался. Вроде бы, всё просто, проверяй себе, был ли найден этот элемент уже — и всё. Но это некрасиво, это не тру. Да и противоречит философии Python. Хотелось красивого решения, что-то, что просто запрещало бы создание элемента, который уже существует или просто не создавало бы его, всю инициализацию игнорировало бы, а внутренний конструктор возвращал уже существующий элемент.

    Приведу пример. У меня есть, например, сущность.

    class Animal:
    	def __init__(self, id):
    		self.id=id
    

    И каждая такая сущность имеет свой уникальный id.

    В итоге, находя две одинаковых сущности в разных местах, мы создаём 2 абсолютно одинаковых объекта. Первое, что нужно, это добавить какое-то хранилище объектов:

    class Animal:
    	__cache__=dict()
    
    	def __init__(self, id):
    		self.id=id
    

    Новый объект в python создаётся в функции __new__ класса, эта функция должна вернуть новый созданный объект, и именно в ней нам и надо копаться для переопределения поведения создания элемента.

    class Animal:
    	__cache__=dict()
    
    	def __new__(cls, id):
    		if not id in Animal.__cache__:
    			Animal.__cache__[id]=super().__new__(cls)
    		return Animal.__cache__[id]
    
    	def __init__(self, id):
    		self.id=id
    

    Вот, вроде бы, и всё, задача решена. Думал я первые 20 минут. При расширении программы и увеличении классов я стал получать ошибку наподобии: __init__() required N positional argument

    Проблема заставила меня выйти в google с поиском того, что, может, я сделал совсем всё против правил. Оказалось, да. Они мне говорят, чтобы я не лез в метод __new__ без нужды, а альтернативу предложили Factory pattern.

    Вкратце, Factory pattern состоит в том, что мы выделяем место, которое управляет созданием объектов. Для Python они предложили вот такой пример

    class Factory:      
        def register(self, methodName, constructor, *args, **kargs):
            """register a constructor"""
            _args = [constructor]
            _args.extend(args)
            setattr(self, methodName,apply(Functor,_args, kargs))
            
        def unregister(self, methodName):
            """unregister a constructor"""
            delattr(self, methodName)
    
    class Functor:
        def __init__(self, function, *args, **kargs):
            assert callable(function), "function should be a callable obj"
            self._function = function
            self._args = args
            self._kargs = kargs
            
        def __call__(self, *args, **kargs):
            """call function"""
            _args = list(self._args)
            _args.extend(args)
            _kargs = self._kargs.copy()
            _kargs.update(kargs)
            return apply(self._function,_args,_kargs)
    

    Нам позволено создавать объекты только с помощью методов класса Factory. При том, что мы можем абсолютно его не использовать и создавать объекты напрямую. В общем, такое решение, может, и правильное, но мне не понравилось, поэтому я решил поискать решение в собственном коде.

    Немного изучения процесса создания дало мне ответ. Создание объекта (вкратце) происходит следующим образом: сначала вызывается метод __new__, в который передаётся класс и все аргументы конструктора, этот метод создаёт объект и возвращает его. Позже вызывается метод __init__ класса, к которому принадлежит объект.

    Абстрагированный код:

    def __new__(cls, id, b, k, zz):
    	return super().__new__(cls)
    def __init__(self, id, b, k, zz):
    	# anything
    	self.id=id
    obj=Animal.__new__(Animal, 1, 2, k=3, zz=4)
    obj.__class__.__init__(obj, 1, 2, k=3, zz=4)
    

    Проблема вылезла при следующем действии. Например, я добавляю класс Cat

    class Cat(Animal):
    	data="data"
    	
    	def __init__(self, id, b, k, zz, variable, one_more_variable):
    		# anything
    		pass
    

    Как видите, конструкторы у классов разные. Представим, что мы уже создали объект Animal с id=1. Позже создаём элемент Cat с id=1.

    Объект класса Animal с id=1 уже существует, так что по логике вещей объект класса Cat не должен создаться. В общем, он этого и не делает, а завершает ошибку с тем, что __init__ передано разное количество аргументов.

    Как Вы поняли, он пытается создать элемент класса Cat, но позже вызывает конструктор класса Animal. Мало того, что он вызывает не тот конструктор, совсем плохим результатом является то, что даже если бы мы снова создавали Animal с id=1, конструктор для одного и того же объекта вызвался повторно. И, возможно, перезаписал бы все данные и сделал бы нежелательные действия.

    Нехорошо. Ещё есть смысл отступить и создать фабрику по производству объектов.
    Но ведь мы пишем на Python, самом гибком и красивом языке, почему мы должны идти на уступки.

    Как оказалось, решение есть:

    class Animal:
    	__cache__=dict()
    	__tmp__=None
    
    	def __fake_init__(self, *args, **kwargs):
    		self.__class__.__init__=Animal.__tmp__
    		Animal.__tmp__=None
    
    	def __new__(cls, id):
    		if not id in Animal.__cache__:
    			Animal.__cache__[id]=super().__new__(cls)
    		else:
    			Animal.__tmp__=Animal.__cache__[id].__class__.__init__
    			Animal.__cache__[id].__class__.__init__=Animal.__fake_init__
    		return Animal.__cache__[id]
    
    	def __init__(self, id):
    		self.id=id
    

    Вызов конструктора отключить было невозможно, после выполнения __new__ беспрекословно шёл вызов функции __init__ из класса созданного (или нет, как в нашем случае) объекта. Выход был один — заменить __init__ в классе созданного объекта. Чтобы не потерять конструктор класса, я его сохранил в какую-нибудь переменную и позже вместо него подсунул фейковый конструктор, который потом вызывался при «создании» объекта. Но фейковый конструктор не пустой, он именно и занимается тем, что возвращает старый конструктор на своё место.

    Скажу напоследок, что, возможно, я крайне не прав, я заочно понял, что мой код противоречит предостережениям, даже в официальных сообществах разработчиков Python говорят, что трогать __new__ можно только при наследовании от итеративных типов, типа списков, кортежей и т.п. Но, как мне кажется, иногда стоит перейти рамки приличия лишь для того, чтобы позже можно было спокойно писать.

    an1=Animal(1)
    an2=Animal(1)
    cat1=Cat(1)
    

    и не беспокоиться о проблемах.

    Спасибо за внимание!
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 29

      +5

      Вы сталкивались с метаклассами в Python? Выглядит как задача, где бы это пришлось к месту.
      https://docs.python.org/3/reference/datamodel.html#metaclasses
      Метод call метакласса как раз позволяет контролировать вызовы new и init без лишних телодвижений внутри самих классов.

        0
        Метаклассы никак не подходят, я пробовал, так как в метаклассе нельзя управлять тем, что метод __init__ вызывается сразу после метода __new__. Метакласс создаёт классы, а не объекты, а значит с созданием объектов надо копаться в классе.
          +2

          Таки можно. Вам же указали: есть такой метод, __call__. Будучи определен в метаклассе, он будет вызываться при создании объектов класса, вместо __new__ и __init__ вместе взятых.


          Получается как-то вот так:


          class cached(type):
            def __new__(cls, name, bases, dct):
              self = type.__new__(cls, name, bases, dct)
              self.cache = dict()
              return self
          
            def __call__(self, id, *args, **kwargs):
              if not id in self.cache:
                self.cache[id]=type.__call__(self, id, *args, **kwargs)
              return self.cache[id]

          Пример: https://repl.it/MD9Z

        +3

        Мне кажется, вы решаете не ту проблему.


        Настоящая проблема в том, что вы пытаетесь избежать создания объектов с одинаковым id, но при этом создаете объекты разных классов с одним id. Это противоречивые требования, один объект не может быть сразу двух классов.


        Вам надо разделить кеши — у каждого класса должен быть свой кеш.

          0
          Классы то наследуемы друг от друга, поэтому у них общий кэш. Я, наверное, просто неявный пример привёл, где непонятно, зачем у двух родственных классов один кэш. Плюс даже если этого не делать, как я описал, то при повторном вызове Animal(1) конструктор выполнится для старого объекта повторно, что не есть гуд
            +1
            Так вы всё-таки определитесь, какое поведение должно быть у дочернего класса, если мы вызываем его с айдишником, который уже был в родительском классе?
            Если вы не можете непродуманную и запутанную логику реализовать на питоне изящно и лаконично, то это не потому, что питон плохой, а потому, что логика непродуманная и запутанная.

            Если вам нужно держать в одном кэше одинаковые айдишники для разных классов, и чтобы они не конфликтовали, то можно использовать класс как часть ключа:

            class Animal:
                pass
            
            class Cat(Animal):
                pass
            
            cache = {
                      (Animal, 1): 'some_animal_object',
                      (Cat, 1): 'some_cat_object'
                    }
            
            print(cache[Animal, 1])
            # Напечатает: some_animal_object
            print(cache[Cat, 1])
            # Напечатает: some_cat_object
              0
              Повторный вызов конструктора — это уже вторая проблема. Ее вам уже предложили выше решать с помощью метакласса.
            +4

            Сейчас проснутся pep8-nazi и за поля __cache__ и __tmp__ сожгут автора на костре.

              +3
              А также за отсутствие пробелов вокруг = и использование табов.
                +2
                И даже хрен с ним, с табами, будь они там в единичном экземпляре, так ведь их там минимум по два и каждый длинною в 4 пробела. Вот так объявишь класс, функцию, какой-нибудь цикл и рабочий код на пол экрана уедет :)
              0
              Вопрос-оффтоп к специалистам. В питоне это нормально?
              Animal.__cache__[id].__class__.__init__=Animal.__fake_init__

              Если да, то у кого-то еще есть претензии к БЭМ-у?
                +7
                Это нормально в той версии питона, которой пользуется автор статьи.
                Но в питоне здорового человека так не делает никто.
                +1
                Я конечно не до конца понимаю вашу бизнес логику но как насчет такого декоратора
                def singleton(cls):
                    instances = {}
                
                    def get_instance(id_, *args, **kwargs):
                        if id_ not in instances:
                            instances[id_] = cls(id_, *args, **kwargs)
                        return instances[id_]
                    return get_instance
                
                  0
                  А ведь действительно… Единственное, что с наследованием не будет работать, а так ведь полностью решает проблему. А я и не подумал, спасибо)
                  +1

                  Я как‐то не вижу проблемы вообще. Зачем запрещать __init__, вызывая неочевидное поведение, просто напишите в аргументах __new__ *args, **kwargs и спокойно игнорируйте эти аргументы? А проблема с __init__ легко решается метаклассом:


                  class MetaAnimal(type):
                      def __new__(cls, name, bases, namespace, **kwargs):
                          old_init = namespace.get('__init__')
                  
                          if old_init:
                              def __init__(self, id, *args, **kwargs):
                                  if not self._called_init:
                                      old_init(self, id, *args, **kwargs)
                                      self._called_init = True
                  
                              namespace['__init__'] = __init__
                  
                          return type.__new__(cls, name, bases, namespace)
                  
                  class Animal(metaclass=MetaAnimal):
                      _cache = dict()
                      _called_init = False
                  
                      def __new__(cls, id, *args, **kwargs):
                          if not id in Animal._cache:
                              Animal._cache[id] = super().__new__(cls)
                          return Animal._cache[id]
                  
                      def __init__(self, id):
                          self.id=id
                          print('THERE')
                  
                  class Cat(Animal):
                      data="data"
                  
                      def __init__(self, id, b):
                          super(Cat, self).__init__(id)
                          print('HERE')
                  
                  print(id(Animal(1)))
                  print(id(Animal(1)))
                  print(id(Cat(1)))
                  print(id(Cat(2, 1)))
                  print(id(Cat(2)))

                  Печатает


                  THERE
                  139883603787504
                  139883603787504
                  139883603787504
                  THERE
                  HERE
                  139883603787672
                  139883603787672

                  , что и нужно.

                    0
                    Да с метаклассом можно еще проще, см. мой ответ сверху
                    0
                    Animal.__tmp__=Animal.__cache__[id].__class__.__init__
                    Animal.__cache__[id].__class__.__init__=Animal.__fake_init__

                    Это баг.
                    Вы присваиваете __fake_init__ классу Animal и в дальнейшем Animal(id) у вас вызовет ошибку.

                    Выше в комментариях было правильно замечено, что вы решаете не ту проблему.
                    Достаточно просто добавить классметод Animal.get(id) который будет создавать и класть инстанс в кеш или брать из кеша, да вы потеряете при этом синтаксис Animal(id), но написать лишние 4 символа, я полагаю, не проблема, зато вы точно уверены, что конструктор создаёт инстанс, а не берёт уже существующий (что он и должен делать).
                      +1
                      Нет, не вызовет) так как буквально после присвоения оригинальный конструктор встаёт на своё место. Но это не суть, я почитал комментарии и понял, что мои труды были напрасны и всё можно много проще сделать с помощью метаклассов. В общем-то, рабочие примеры уже были приведены
                        0
                        Да, пардон, пропустил кусок в __fake_init__.
                        Но всё равно метаклассы тут имхо совершенно лишний оверхед. Только ради того, чтобы иметь визуально «красивый» вызов, оно того не стоит. Потом начинаются проблемы с расширением, с наследниками и т.п.
                      0
                      Если вы хотите поведение, когда невозможно создать Animal с id = 1 и Cat с id = 1:
                      class Animal:
                          __cache__ = dict()
                      
                          def __new__(cls, id, *args, **kwargs):
                              if id not in cls.__cache__:
                                  cls.__cache__[id] = id
                      
                                  return super().__new__(cls)
                              else:
                                  raise Exception('ID уже существует')
                      
                          def __init__(self, id):
                              self.id = id
                      
                      
                      class Cat(Animal):
                          def __init__(self, id, a, b, c):
                              self.a = a
                              self.b = b
                              self.c = c
                              super().__init__(id)
                      
                      
                      if __name__ == '__main__':
                          for item in range(10000):
                              a = Animal(item)
                              print(a)
                      
                          for item in range(10000, 20000):
                              b = Cat(item, 1, 2, 3)
                              print(b)


                      Если вы хотите поведение, когда можно создать Animal с id=1 и Cat с id=1:
                      class Animal:
                          __cache__ = dict()
                      
                          def __new__(cls, id, *args, **kwargs):
                              if id not in cls.__cache__:
                                  cls.__cache__[id] = id
                      
                                  return super().__new__(cls)
                              else:
                                  raise Exception('ID уже существует')
                      
                          def __init__(self, id):
                              self.id = id
                      
                      
                      class Cat(Animal):
                          __cache__ = dict()
                      
                          def __init__(self, id, a, b, c):
                              self.a = a
                              self.b = b
                              self.c = c
                              super().__init__(id)
                      
                      
                      if __name__ == '__main__':
                          for item in range(10000):
                              a = Animal(item)
                              print(a)
                      
                          for item in range(10000):
                              b = Cat(item, 1, 2, 3)
                              print(b)
                      

                        0
                        Если нужно исключить ситуацию с одинаковым id для разных классов, можно и без явной передачи id в __init__ дочернего класса обойтись:

                        class Animal(type):
                        
                            ID_COUNTER = 0
                        
                            def __new__(cls, name, bases, dct):
                                dct['_id'] = -1
                                return type.__new__(cls, name, bases, dct)
                        
                            def __call__(self, *args, **kwargs):
                                inst = type.__call__(self, *args, **kwargs)
                                Animal.ID_COUNTER += 1
                                inst._id = Animal.ID_COUNTER
                                return inst
                        
                        class Cat(metaclass=Animal):
                        
                            pass
                        
                        
                        class Dog(metaclass=Animal):
                        
                            pass
                        
                        c1 = Cat()
                        c2 = Cat()
                        d1 = Dog()
                        d2 = Dog()
                        
                        print(c1._id)
                        print(c2._id)
                        print(d1._id)
                        print(d2._id)
                        
                        0
                        звучит как ad-hoc задача: сделать и выбросить
                        Хотелось красивого решения
                        — людям данные нужны, а не красивые решения.
                          0
                          За статью спасибо, было интересно почитать. Но, как уже заметили выше, Singleton напрашивался с самого начала.
                            +2

                            Я правильно понимаю, что если в Cat добавить метод meow, и попробовать его вызвать:


                            с1 = Cat(1)
                            c1.meow()

                            … то можно получить ошибку "'Animal' object has no attribute 'meow'"? А можно и не получить, если где-то ещё не был создан Animal с таким же идентификатором?


                            Мне бы не хотелось поддерживать такой код.

                              0

                              Верно, но это на самом деле вопрос того, как классы используются на самом деле. Стилем кодирования (и метаклассом чтобы наверняка) в принципе можно запретить определение новых методов, как и переопределение старых с несовместимыми сигнатурами. Хотя я лично всё же нахожу задачу несколько странной, и просто написал бы функцию получения животного по id, возможно даже вида def get_animal(cls, id, *args, **kwargs) (дополнительные аргументы — для конструктора).

                                0
                                Действительно. Даже не знаю, как это исправить) честно говоря, сделал теперь эту задачу через метаклассы, но эта же проблема остаётся(
                                  0

                                  У вас с этой проблемой нет особого выбора. Просто запретите метаклассом создавать метод meow(), лучше вы даже на уровне ниже¹ не напишете: не будет этой проблемы, будет проблема двойной инициализации, либо проблема её отсутствия, либо проблема гонки (кстати, ваш оригинальный код должен был удерживать блокировку в new и освобождать её в _fake_init (и не должен был использовать двойные подчёркивания где не надо)), либо проблема нарушения контракта «один id ссылается всегда на один и тот же объект».


                                  ¹ «На уровне ниже» можно существующему объекту и тип изменить, в т.ч. временно; при достаточном знании внутренностей CPython можно даже в процессе ничего не поломать до следующего релиза.

                                    0

                                    Варианта исправления тут два.


                                    1. Просто откажитесь от единого хранилища всех объектов. Пусть Animal(1) и Cat(1) будут разными объектами.


                                    2. Сделайте класс Animal "абстрактным" — пусть Animal(1) кидает ошибку если нужный объект не лежит в кеше.
                                      0

                                      Судя по описанию исходной задачи с аккаунтами, у вас по смыслу должно происходить не создание объектов типа "аккаунт", а именно получение. Конструктор же подразумевает именно создание нового. Поэтому здесь всё же лучше подходит некий репозиторий аккаунтов — он как раз может нести семантику get-or-add. И тогда можно явно запретить ситуацию, когда объект с идентификатором X неявным образом меняет свой тип.


                                      Если смена типа во время жизни объекта нужна, то можно


                                      1. Сделать некий метод вроде "become", но в этому случае все члены иерархии Animal должны знать друг о друге и уметь превращаться друг в друга. Опять же, будет проблема, если в одном месте объект будет сохранён как Dog с методом bark, а в другом из него сделают Cat с методом meow.
                                      2. Можно реализовать наследование не через систему типов, а полем внутри Animal. То есть вместо объявления разных классов просто сделать поле вида 0 — Animal, 1 — Cat, 2 — Dog и в публичных методах сделать if. Это позволит в одном месте — в классе Animal — однозначно определить, можно ли добавлять meow, bark и другие методы, и можно сделать более понятную ошибку, когда метод не поддерживается текущим типом.
                                        На эту тему написано в Analysis Patterns Фаулера, глава 14.2.3.

                                      Касательно замечания ZyXI про блокировку. Если надо обязательно сохранить синтаксис вызова конструктора, то можно сделать "теневую" иерархию и превратить Animal в прокси. Тогда подмена конструктора не понадобится и надо будет сделать только потоко-безопасный get-or-add для словаря-кеша:


                                      import threading
                                      
                                      class AnimalImpl:
                                          def __init__(self, id):
                                              self._id = id
                                              self._name = None
                                      
                                          def roar(self):
                                              return '{}: {}'.format(self._id, self)
                                      
                                          def tell_name(self):
                                              if self._name is None:
                                                  raise Exception('I am nameless!')
                                              return self._name
                                      
                                          def give_name(self, name):
                                              self._name = name
                                      
                                      class Animal:
                                          """An animal proxy
                                      
                                          Animal proxies with the same id are the same:
                                      
                                          >>> a1 = Animal(1)
                                          >>> a2 = Animal(1)
                                          >>> a1.roar() == a2.roar()
                                          True
                                          >>> a1.give_name('Baloo')
                                          >>> a2.tell_name()
                                          'Baloo'
                                          """
                                          __cache__ = dict()
                                          __lock__ = threading.Lock()
                                      
                                          def __init__(self, id):
                                              Animal.__lock__.acquire()
                                              try:
                                                  if id in Animal.__cache__:
                                                      self._impl = Animal.__cache__[id]
                                                  else:
                                                      impl = AnimalImpl(id)
                                                      Animal.__cache__[id] = impl
                                                      self._impl = impl
                                              finally:
                                                  Animal.__lock__.release()
                                      
                                          def roar(self):
                                              self._impl.roar()
                                      
                                          def tell_name(self):
                                              return self._impl.tell_name()
                                      
                                          def give_name(self, name):
                                              self._impl.give_name(name)
                                      
                                      if __name__ == "__main__":
                                          import doctest
                                          doctest.testmod()

                                  Only users with full accounts can post comments. Log in, please.