Pull to refresh

Comments 30

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

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

Таки можно. Вам же указали: есть такой метод, __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. Это противоречивые требования, один объект не может быть сразу двух классов.


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

Классы то наследуемы друг от друга, поэтому у них общий кэш. Я, наверное, просто неявный пример привёл, где непонятно, зачем у двух родственных классов один кэш. Плюс даже если этого не делать, как я описал, то при повторном вызове Animal(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
Повторный вызов конструктора — это уже вторая проблема. Ее вам уже предложили выше решать с помощью метакласса.
А также за отсутствие пробелов вокруг = и использование табов.
И даже хрен с ним, с табами, будь они там в единичном экземпляре, так ведь их там минимум по два и каждый длинною в 4 пробела. Вот так объявишь класс, функцию, какой-нибудь цикл и рабочий код на пол экрана уедет :)
Вопрос-оффтоп к специалистам. В питоне это нормально?
Animal.__cache__[id].__class__.__init__=Animal.__fake_init__

Если да, то у кого-то еще есть претензии к БЭМ-у?
Это нормально в той версии питона, которой пользуется автор статьи.
Но в питоне здорового человека так не делает никто.
Я конечно не до конца понимаю вашу бизнес логику но как насчет такого декоратора
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
А ведь действительно… Единственное, что с наследованием не будет работать, а так ведь полностью решает проблему. А я и не подумал, спасибо)

Я как‐то не вижу проблемы вообще. Зачем запрещать __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

, что и нужно.

Да с метаклассом можно еще проще, см. мой ответ сверху
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 символа, я полагаю, не проблема, зато вы точно уверены, что конструктор создаёт инстанс, а не берёт уже существующий (что он и должен делать).
Нет, не вызовет) так как буквально после присвоения оригинальный конструктор встаёт на своё место. Но это не суть, я почитал комментарии и понял, что мои труды были напрасны и всё можно много проще сделать с помощью метаклассов. В общем-то, рабочие примеры уже были приведены
Да, пардон, пропустил кусок в __fake_init__.
Но всё равно метаклассы тут имхо совершенно лишний оверхед. Только ради того, чтобы иметь визуально «красивый» вызов, оно того не стоит. Потом начинаются проблемы с расширением, с наследниками и т.п.
Если вы хотите поведение, когда невозможно создать 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)

Если нужно исключить ситуацию с одинаковым 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)
звучит как ad-hoc задача: сделать и выбросить
Хотелось красивого решения
— людям данные нужны, а не красивые решения.
За статью спасибо, было интересно почитать. Но, как уже заметили выше, Singleton напрашивался с самого начала.

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


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

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


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

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

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

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


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

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


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


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

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

Articles