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

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)

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

Спасибо за внимание!

Комментарии (29)


  1. gudvinr
    04.10.2017 01:55
    +5

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


    1. Ryder95 Автор
      04.10.2017 08:17

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


      1. mayorovp
        04.10.2017 09:55
        +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


  1. mayorovp
    04.10.2017 06:19
    +3

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


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


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


    1. Ryder95 Автор
      04.10.2017 08:08

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


      1. fireSparrow
        04.10.2017 08:42
        +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


      1. mayorovp
        04.10.2017 08:57

        Повторный вызов конструктора — это уже вторая проблема. Ее вам уже предложили выше решать с помощью метакласса.


  1. Zagrebelion
    04.10.2017 08:44
    +4

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


    1. TyVik
      04.10.2017 08:51
      +3

      А также за отсутствие пробелов вокруг = и использование табов.


      1. Desprit
        04.10.2017 10:54
        +2

        И даже хрен с ним, с табами, будь они там в единичном экземпляре, так ведь их там минимум по два и каждый длинною в 4 пробела. Вот так объявишь класс, функцию, какой-нибудь цикл и рабочий код на пол экрана уедет :)


  1. dom1n1k
    04.10.2017 09:19

    Вопрос-оффтоп к специалистам. В питоне это нормально?
    Animal.__cache__[id].__class__.__init__=Animal.__fake_init__

    Если да, то у кого-то еще есть претензии к БЭМ-у?


    1. fireSparrow
      04.10.2017 09:24
      +7

      Это нормально в той версии питона, которой пользуется автор статьи.
      Но в питоне здорового человека так не делает никто.


  1. chemiron
    04.10.2017 09:50
    +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
    


    1. Ryder95 Автор
      04.10.2017 09:59

      А ведь действительно… Единственное, что с наследованием не будет работать, а так ведь полностью решает проблему. А я и не подумал, спасибо)


  1. ZyXI
    04.10.2017 10:04
    +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

    , что и нужно.


    1. mayorovp
      04.10.2017 10:07

      Да с метаклассом можно еще проще, см. мой ответ сверху


  1. yks
    04.10.2017 20:39

    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. Ryder95 Автор
      04.10.2017 20:42
      +1

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


      1. yks
        05.10.2017 10:17

        Да, пардон, пропустил кусок в __fake_init__.
        Но всё равно метаклассы тут имхо совершенно лишний оверхед. Только ради того, чтобы иметь визуально «красивый» вызов, оно того не стоит. Потом начинаются проблемы с расширением, с наследниками и т.п.


  1. artinnok
    04.10.2017 20:51

    Если вы хотите поведение, когда невозможно создать 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)
    


    1. Acheron
      05.10.2017 10:11

      Если нужно исключить ситуацию с одинаковым 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)
      


  1. vobo
    04.10.2017 22:20

    звучит как ad-hoc задача: сделать и выбросить

    Хотелось красивого решения
    — людям данные нужны, а не красивые решения.


  1. Bragaman
    04.10.2017 23:22

    За статью спасибо, было интересно почитать. Но, как уже заметили выше, Singleton напрашивался с самого начала.


  1. MaxKot
    06.10.2017 21:49
    +2

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


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

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


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


    1. ZyXI
      06.10.2017 23:47

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


    1. Ryder95 Автор
      07.10.2017 01:59

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


      1. ZyXI
        07.10.2017 02:52

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


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


      1. mayorovp
        07.10.2017 08:23

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


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


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


      1. MaxKot
        07.10.2017 12:31

        Судя по описанию исходной задачи с аккаунтами, у вас по смыслу должно происходить не создание объектов типа "аккаунт", а именно получение. Конструктор же подразумевает именно создание нового. Поэтому здесь всё же лучше подходит некий репозиторий аккаунтов — он как раз может нести семантику 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()