ООП в Python во всех подробностях

ООП в Python во всех подробностях

Приватность свойств. Name Mangling (_name и __name)

Для установления приватности для класса добавляют __ в начале имени свойства (переменной, функции)

from datetime import datetime


class Person:
    def __init__(self, name, surname, birthday):
        self._name = name
        self._surname = surname
        self.__birthday = birthday 
        self.name = f"{self._name} {self._surname}"

    @staticmethod
    def _get_current_time():
        return datetime.now()

    def _show_birthday(self):
        return self.__birthday

    def print_data(self):
        print(f"Now {self._get_current_time()}: {self.name} Birthday: {self._show_birthday()}")


people1 = Person("Dmitry", "Budaev", "14.04.1989")
print(people1.print_data)
# дословно - связный метод Person.print_data объекта Person
# <bound method Person.print_data of <__main__.Person object at 0x7fb9ef1a1690>>

print(hex(id(people1)))
# 0x7fb9ef1a1690s

print(people1.__dict__)
# {'_name': 'Dmitry', '_surname': 'Budaev', '_Person__birthday': '14.04.1989', 'name': 'Dmitry Budaev'}

#  '_Person__birthday' - это и есть защищённый метод self.__birthday

Переменные (свойства / атрибуты) класса

Область видимости:

Local -> Enclosing -> Global -> Builtins

name = "Ivan"


class Person:
	name = "Dima"

	def print_name(self):
		print(name)

p = Person()
p.print_name()

# Ivan

Переменная name в вызываемом методе print_name будет искаться в начале локально (Local), потом во вложенных функциях (Enclosing) (их нет в print_name) потом глобально (Global) - и тут, как раз находит  name = "Ivan"

[!warning] Использование nonlocal
Это поведение можно избежать, если обозначить переменную, как nonlocal
name = "Ivan"


class Person:
	def primer(self):
		name = "Dima"

		def print_name(self):
			nonlocal name # обозначим, что переменная не в локальной области видимости
			print(name)

p = Person()
p.print_name()

# Dima
[!warning] Применяется только во вложенных функциях
nonlocal может применяться только в функциях, имеющих вложенную область видимости.
Вы получаете вложенную область видимости только тогда, когда определяете свою функцию внутри другой функции.

Анотация типов

Аннотации типов – это способ объявить ожидаемые типы аргументов и возвращаемого значения функции, переменных и атрибутов.

Допустимые значения анотации типов

  • конкретный класс, например str или FrenchDeck;
  • параметризованный тип коллекции, например list[int], tuple[str, float] и т. д.;
  • typing.Optional, например Optional[str], для объявления поля, которое может принимать значения типа str или None.
  • Переменную можно также инициализировать значением. (var_name: some_type = a_value)
[!info] Важно
на этапе импорта, когда модель загружается, Python читает их и строит словарь annotations, который typing.NamedTuple и @dataclass затем используют для дополнения класса.
class DemoPlainClass:
	a: int # заносится в анотацию
	b: float = 1.1 # заносится в анотацию и проставляется значение атрибуту класса
	c = 'spam' # обычный атрибут класса, а не анотация
  • Для поля a заводится запись в __annotations__, но больше оно никак не используется: атрибут с именем a в классе не создается.
  • Поле b сохраняется в аннотациях и, кроме того, становится атрибутом класса со значением 1.1.
  • c – это самый обычный атрибут класса, а не аннотация.

@staticmethod

@staticmethod – используется для создания метода, который ничего не знает о классе или экземпляре, через который он был вызван. Он просто получает переданные аргументы, без неявного первого аргумента, и его определение неизменяемо через наследование.

class Person():

    @staticmethod
    def is_adult(age):  # нету self, т.к. метод не знает ничего о классе
        if age > 18:
            return True
        else:
            return False

@classmethod

[!info] Основное применение
Для создания нескольких инициализаторов в классе. Т.к. позволяет изменять свойства класса

Если захотим изменить переменную класса (которая находится в классе а не в __init__) ...

...
p = Person()
p.name = 'asdfadsfdsgdsfag'
...

Ничего не выйдет - как раз из-за пространства имен (LEGB)

Это можно сделать с помощью @classmethod

class Person:
	name = "Dima"

	@classmethod
	def change_name(cls, name):  # cls просто ставится вместо self
		cls.name = name          # чтобы показать, что речь идёт о классе а не о экземпляре

p = Person()
print(p.__dict__)
p.change_name("Ivan")

Еще пример кода:

...
@classmethod
def from_obj(cls, obj):
	if hasattr(obj, 'name'): # Проверяет наличие метода у объекта
		name = getattr(obj, 'name')
...

@property

Основная идея - создания буферных зон.

Чтобы скрыть от пользователя то, что должно быть скрыто. Либо для того чтобы создать интерфейс взаимодействия - его можно будет с  легкостью видоизменять в дальнейшем.

class Person:
	def __init__(self, name):
		self._name = name

	def get_name(self):
	print("From get_name: ")
	return self._name

	def set_name(self, name):
	print("From set_name: ")
	self._name = name

	name = property(fget=get_name, fset=set_name)

	# вариант 2
	# name = property()
	# name.setter(set_name)
	# name.getter(get_name) 

	# вариант 3
	# name = property(get_name)
	# name.setter(set_name)

p = Person("Dima")

# p.__dict__
# {'name': 'Dima'}

p.name
# From get_name:
# 'Dima'

p.name = "Ivan"
# From set_name:
# 'Ivan'

При использовании декоратора получится следующее

...
@property     # работает как name = property(name)
def name(self):
	return self._name

# теперь укажем сеттер
@name.setter             # работает как name = name.setter(set_name)
def name(self, value):   # при использовании декоратора функцию переименовываем
	self._name = value   # в name, как у @property

# Если имя функции у сеттера будет другим, а не таким же как у геттера (с @property), то получим исключение
[!info] Для чего можно использовать @property
  • методы класса со свойством только для чтения
  • метод класса для вычисляемых свойств (например, для конкатенации свойств класса  и его вывода)

Самое простое - это создать функцию с декоратором @property но только геттер (без сеттера не будет возможности записи значения).

Пример вычисляемого свойства:

class Person:
	def __init__(self, name, lastname):
		self._name = name
		self._lastname = lastname

	# вычисляемое свойство
	@property
	def full_name(self):
		return f"{self._name} {self._lastname}""

Вычисляемые свойства имеет смысл использовать тогда, когда нет смысла хранить состояния (они будут считаться каждый раз при вызове)

Кеширование результата вычисляемых свойств

class Person:
	def __init__(self, name, lastname):
		self._name = name
		self._lastname = lastname
		self._fullname = None  # для сохранения результата вычисления

	@property
	def name(self):
		return self._name
	
	@name.setter
	def name(self, value):
		self._name = value
		self._fullname = None

	@property
	def lastname(self):
		return self._lastname
	
	@lastname.setter
	def lastname(self, value):
		self._lastname = value
		self._fullname = None

	@property
	def full_name(self):
		# Проверяем и кешируем вычисляемое свойство
		if self._fullname is None:
			self._fullname = f"{self._name} {self._lastname}"
		return self._fullname

Наследование

Правила поиска имен (функций, переменных) в пространстве имен, при наследовании   работает по принципу (Local -> Enclosing -> Global -> Builtins)

class Person:
	def __init__(self, age):
		self.age = age

	def print_age(self):
		print(f{self.age})


class Student(Person):
	...

s = Student(10)

s.print_age()

print(s.__dict__)
# {'age': 10}

print(Student.__dict__)
# {'__module__': '__main__', '__doc__': None}

print(Person.__dict__)
# {'__module__': '__main__', '__init__': <function Person.__init__ at 0x7fe9358f7240>, 'print_age': <function Person.print_age at 0x7fe93596d1c0>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}

print(Student.__dict__) - покажет, что переменной age нет в классе Student, т.к. она определена в классе Person

[!info] Неявное наследование класса object
Все объекты (классы) которые создаются будут неявно наследоваться от базового класса object - наследоваться необходимые базовые методы (например, __module, __doc__ и т.д.)

Для проверки цепочки наследования класса, можно воспользоваться встроенной функцией issubclass:

class One:
	...

class Two(One):
	...

class Three(Two):
	...

issubclasss(Three, One)

# True

Определение одного родителя

Если нужно узнать принадлежат ли два объекта одному классу - один родитель. Можно сделать так (сравнить тип объекта с помощью isinstance или issubclass совместно с type()):

first_number = One()
number = Three()

isinstance(number, type(first_number)) 
# False
# т.к. экземпляр класса number не является экземпляром класса first_number

issubclasss(type(number), type(first_number))
# False

Перегрузка

Основная идея все та же - порядок разрешений и поиск имени в пространстве имен (LEGB)

class Person:
	def print_h(self):
		print("I am Human")

class Student(Person):
	def print_h(self):
		print("I am a student")

s = Student()

s.print_h()
# I am a student

Множественное наследование

При множественном наследовании, применяется правило MRO (Method Resolution Order) - порядок разрешения методов - тот кто левее, тот главнее

Будет искать метод сначала в Proffessor потом в Student. Т.к. в Proffessor  есть такой метод, то выведет его.

class Person:
	def hello(self):
		print("I am Human")

class Student(Person):
	def hello(self):
		print("I am a student")

class Proffessor(Person):
	def hello(self):
		print("I am a proffessor")

class SomeOne(Proffessor, Student):
	pass

s = SomeOne()

s.hello()
# I am a proffessor

Чтобы посмотреть, как будет произведен поиск в пространстве имен нужных свойств и методов, можно вызвать функцию mro() у экземпляра класса.

...
s.__class__.mro()

# [__main__.SomeOne, __main__.Prof, __main__.Student,__main__.Person, object]

Mixins

Сама идея миксинов - подразумевает расширение возможностей объекта или класса (самого же создания экземпляра класса для миксина не предполагается)

class FoodMixins(Person):
	food = None
	
	def get_food(self):
		if self.food is None:
			raise ValueError("Food should be set")
		print(f"I like {self.food}")

class Person:
	def hello(self):
		print("I am Human")

# Первым вставляем миксину для расширения класса
class Student(FoodMixins, Person):
	food = "Pizza"
	def hello(self):
		print("I am a student")

s = Student()

s.hello()
[!info] Когда применяются Mixins
Например, когда нам нужно добавить какую-нибудь фичу большому количеству не связных между собой классов

Полиморфизм

Полиморфизм - разное поведение для одного и того же метода для разных классов

Например, + - вызывает дандер метод __add__(), который для разных типов данных делает разные вещи. Его можно переопределить задать любое поведение.

За сравнение _=_ отвечает метод __eq__()

super().__init__() - Инициализация из родительского класса

Используется, в паттерне DRY, например.

class Person:
	def __init__(self, name)
		self.name = name

class Student(Person):
	def __init__(self, name, surname):
		super().__init__(name)
		self.surname = surname

s = Student("Ivan", "Ivanoff")

print(s.__dict__)
# {"name": "Ivan", "surname": "Ivanoff"}
[!info] super().__init__() - расположение имеет значение
Если он будет снизу всех переменных, то вызов __init__() родительского класса может переписать переменные с одинаковым именем.
class GreetingFormal:

    def __init__(self):
        self.formal_greeting = "Добрый день,"

    def greet_formal(self, name):
        return f"{self.formal_greeting} {name}!"


class GreetingInformal:

    def __init__(self):
        self.informal_greeting = "Привет,"

    def greet_informal(self, name):
        return f"{self.informal_greeting} {name}!"


class GreetingMix(GreetingFormal, GreetingInformal):

    def __init__(self):
        GreetingFormal.__init__(self)
        GreetingInformal.__init__(self)


mixed_greeting = GreetingMix()
print(mixed_greeting.greet_formal("Пользователь"))
print(mixed_greeting.greet_informal("Пользователь"))

В методе  __init__ класса GreetingMix не используется super()  - используется непосредственный вызов из базовых классов с указанием имён этих классов.

Из-за того, что метод __init__ присутствует в обоих базовых классах и происходит конфликт.

Интерпретатор при использовании функции super() в нашем примере использовал бы метод того класса, который стоит левее при перечислении в объявлении производного класса.



Хешированные объекты

Хешированные объекты - ключевые объекты при работе со словарями (dict) и множествами (set)

class Person:
	def hello(self, name):
		self._name = name

	@property
	def name(self):
		return self._name

	# позволит работать с хешированными объектами
	def _hash_(self):
		return hash(self.name)

	def _eq_(self, person_obj):
		return isinstance(person_obj, Person) and self.name == person_obj.name

p1 = Person("Ivan")
p2 = Person("Ivan")


# это позволит передавать объекты в качестве ключей словаря
d = {p1: "Roman Romanoff"}

d.get(p1)
# Roman Romanoff

Абстрактные классы

Абстрактные классы - это классы, которые предназначены для наследования, но избегают реализации конкретных методов, оставляя только сигнатуры методов, которые должны реализовывать подклассы.

from abc import ABCMeta

class AbstractClass(object):
    # атрибут метакласса всегда должен 
    # быть установлен как переменная класса 
    __metaclass__ = ABCMeta

   # декоратор abstractmethod регистрирует этот метод как undefined
   @abstractmethod 
   def virtual_method_subclasses_must_define(self):
       # Можно оставить полностью пустым
       # или предоставить базовую реализацию
       # Обратите внимание, что обычно пустая интерпретация
       # неявно возвращает `None`, но при регистрации
       # это поведение больше не применяется.

Причины использования абстрактных классов

class MontyPython:
    def joke(self):
        raise NotImplementedError()

    def punchline(self):
        raise NotImplementedError()

class ArgumentClinic(MontyPython):
    def joke(self):
        return "Hahahahahah"

Когда мы создаем объект и называем это два метода, мы получим ошибку (как и ожидалось) с punchline() метод.

sketch = ArgumentClinic() 
sketch.punchline() 

# AttributeError: 'ArgumentClinic' object has no attribute 'punchline'

Этого можно избежать, используя модуль Abstract Base Class (ABC).

from abc import ABCMeta, abstractmethod
class MontyPython(metaclass=ABCMeta):
  @abstractmethod
  def joke(self):
    pass
  @abstractmethod
  def punchline(self):
  	pass

# у наследуемого класса 2 @abstractmethod, а мы использовали только один
class ArgumentClinic(MontyPython):
  def joke(self):
    return "Hahahahahah"

c = ArgumentClinic()
# TypeError: "Can't instantiate abstract class ArgumentClinic with abstract methods punchline"

На этот раз, когда мы пытаемся создать экземпляр объекта из неполного класса, мы немедленно получаем TypeError!

class ArgumentClinic(MontyPython):
  def joke(self):
    return "Hahahahahah"
  
  def punchline(self):
    return "Send in the constable!"

c = ArgumentClinic()
c  

# <__main__.ArgumentClinic object at 0x7fee680d3640>

Простой пример с множественным наследованием

class A(object):
    __metaclass__ = abc.ABCMeta

    @abc.abstractmethod
    def do(self):
        pass

class B(object):
    def do(self):
        print "do"

class C(B, A):
    pass

c = C()

Абстрактный класс наследуется от абстрактного класса

from abc import ABCMeta, abstractmethod

class OriginalAbstractclass(metaclass=ABCMeta):
    @abstractmethod
    def sample_method(self):
        pass


class InheritedAbstractClass(OriginalAbstractclass, metaclass=ABCMeta):
    @abstractmethod
    def another_method(self):
        pass

class ConcreteClass(InheritedAbstractClass):
    def some_method(self):
        pass

    def another_method(self):
        pass

__slots__ в классе и при наследовании

class AbstractA(ABC):
    __slots__ = ()

class AbstractB(ABC):
    __slots__ = ()


class BaseA(AbstractA): 
    __slots__ = ('a',)

class BaseB(AbstractB): 
    __slots__ = ('b',)

class Child(AbstractA, AbstractB): 
    __slots__ = ('a', 'b')

c = Child()

То есть мы вместо того, чтобы наследоваться от классов с конкретной реализацией (BaseA, BaseB), наследуемся от абстрактных классов

Однако __slots__ может вызвать проблемы при множественном наследовании.

class BaseA(object): 
    __slots__ = ('a',)

class BaseB(object): 
    __slots__ = ('b',) 

Создание дочернего класса от родителей с обоими непустыми слотами не удается:

>>> class Child(BaseA, BaseB): __slots__ = ()
Traceback (most recent call last):
  File "<pyshell#68>", line 1, in <module>
    class Child(BaseA, BaseB): __slots__ = ()
TypeError: Error when calling the metaclass bases
    multiple bases have instance lay-out conflict 

Если вы столкнетесь с этой проблемой, вы можете просто удалить __slots__ у родителей или, если вы контролируете родителей, дать им пустые слоты или выполнить рефакторинг для абстракций:

from abc import ABC

class AbstractA(ABC):
    __slots__ = ()

class BaseA(AbstractA): 
    __slots__ = ('a',)

class AbstractB(ABC):
    __slots__ = ()

class BaseB(AbstractB): 
    __slots__ = ('b',)

class Child(AbstractA, AbstractB): 
    __slots__ = ('a', 'b')

c = Child() # Нет ошибок

Добавьте '__dict__' к __slots__ чтобы получить динамическое назначение:

class Foo(object):
    __slots__ = 'bar', 'baz', '__dict__'

и сейчас

>>> foo = Foo()
>>> foo.boink = 'boink'

Таким образом, с '__dict__' в слотах мы теряем некоторые преимущества размера с преимуществом наличия динамического назначения и по-прежнему наличия слотов для имен, которые мы ожидаем

Когда вы наследуете объект, который не имеет слотов, вы получаете такую же семантику, когда используете __slots__ - имена, которые находятся в __slots__, указывают на значения, размещенные в слотах, тогда как любые другие значения помещаются в __dict__ экземпляра.

Избегать __slots__, потому что вы хотите иметь возможность добавлять атрибуты на лету, на самом деле не является хорошей причиной - просто добавьте '__dict__' в свой __slots__, если это необходимо.

Вы можете точно так же явно добавить __weakref__ в __slots__, если вам нужна эта функция.


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

Чтобы продемонстрировать, сначала давайте создадим код с классом, который мы хотели бы использовать при множественном наследовании.

class AbstractBase:
    __slots__ = ()
    def __init__(self, a, b):
        self.a = a
        self.b = b
    def __repr__(self):
        return f'{type(self).__name__}({repr(self.a)}, {repr(self.b)})'

Мы могли бы использовать вышесказанное непосредственно путем наследования и объявления ожидаемых слотов:

class Foo(AbstractBase):
    __slots__ = 'a', 'b' 

Но нас это не волнует, это тривиальное одиночное наследование, нам нужен другой класс, от которого мы также могли бы унаследовать, возможно, с шумным атрибутом:

class AbstractBaseC:
    __slots__ = ()
    @property
    def c(self):
        print('getting c!')
        return self._c
    @c.setter
    def c(self, arg):
        print('setting c!')
        self._c = arg

Теперь, если бы на обеих базах были непустые слоты, мы не смогли бы сделать следующее. (На самом деле, если бы мы хотели, мы могли бы дать AbstractBase непустые слоты a и b и исключить их из приведенного ниже объявления - оставлять их было бы неправильно):

class Concretion(AbstractBase, AbstractBaseC):
    __slots__ = 'a b _c'.split() 

И теперь у нас есть функциональность от обоих через множественное наследование, и мы все еще можем запретить создание экземпляров __dict__ и __weakref__:

>>> c = Concretion('a', 'b')
>>> c.c = c
setting c!
>>> c.c
getting c!
Concretion('a', 'b')
>>> c.d = 'd'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Concretion' object has no attribute 'd'

Композиция

С композицией класс Composite имеет экземпляр класса Component и может использовать его реализацию. Класс Component может быть повторно использован в других классах, совершенно не связанных с Composite.

Наследование vs Композиция

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

  • Используйте наследование вместо композиции для моделирования четких отношений. Во-первых, обоснуйте связь между производным классом и его базой. Затем измените отношения и попытайтесь их оправдать. Если вы можете оправдать отношения в обоих направлениях, то вам не следует использовать наследство между ними.
  • Используйте наследование вместо композиции, чтобы использовать как интерфейс, так и реализацию базового класса.
  • Используйте наследование вместо композиции для предоставления функций смешивания (mixin) нескольким несвязанным классам, когда может существовать только одна реализация этой функции.
  • Использование композиции вместо наследования для моделирования имеет отношение, которое использует реализацию класса компонента.
  • Используйте композицию вместо наследования для создания компонентов, которые могут повторно использоваться несколькими классами в ваших приложениях.
  • Используйте композицию вместо наследования для реализации групп поведений и политик, которые можно применять взаимозаменяемо к другим классам для настройки их поведения.
  • Используйте композицию вместо наследования, чтобы включить изменения поведения во время выполнения, не затрагивая существующие классы.

Дескрипторы (__get__(), __set__(), __del__())

Дескриптором - любой объект, который реализует хотя бы один из следующих методов: get, set или delete

Дескрипторы позволяют переиспользовать повторяющийся код.

Дескрипторы бывают:

  • No Data Descriptor - они не хранят данные а только отдают
  • Data Descriptor - хранят данные (в них обязательно реализованы методы __set__(). Метод __delete() необязательный и его может и не быть  )

No Data Descriptor

Геттер (__get__()) с аргументами:

  • instance = экземпляр класса с которого происходит обращение к свойству
  • owner_class = класс собственник
from time import time

class Epoch:
	# intance - экземпляр класса с которого происходит обращение к свойству
	# owner_class - класс собственник
	def __get__(self, instance, owner_class):
		return int(time())

class MyTime:
	epoch = Epoch()

m = MyTime()

print(m.epoch)
# 15984124424 - время в секундах

Можно использовать и обычный геттер на @property но в таком случае, будет возникать много повторяемого кода...

from random import choice


class Game:
	@property
	def rock_paper_scissors(self):
		return choice(["Rock", "Paper", "Scissors"])

	@property
	def dice(self):
		return choice(range(1, 7))

	@property
	def flip(self):
		return choice(["Head", "Tails"])

g = Game()

print(g.flip)
# Tails
...

В этом случае поможет определение отдельного класса (No Data Descriptor) на основе дандер метода __get__ ()

from random import choice


class Choise:
	# * - принимает любые аргументы и упаковывает их в список
	def __init__(self, *choice):
		self._choice = choice

	def __get__(self, instance, owner_class):
		return choice(self._choice)


class Game:
    dice = ChoiceGame(1, 2, 3, 4, 5, 6)
    flip = ChoiceGame("Head", "Tails")
    rock_paper_scissors = ChoiceGame("Rock", "Paper", "Scissors")

g = Game()

print(g.flip)
# Tails
...

Data Descriptor

from time import time


class Epoch:
    def __get__(self, instance, owner):
        print(f"Self object {self}")
        print(f"Instance object {instance}")
        print(f"Owner object {owner}")
        return int(time())


class MyTime:
    epoch = Epoch()


m = MyTime()

При вызове epoch через экземпляр m класса MyTime:

>>> m.epoch

Self object <__main__.Epoch object at 0x7fcd70bf6950>
Instance object <__main__.MyTime object at 0x7fcd70bf6ad0>
Owner object <class '__main__.MyTime'>

1686562054

В Self был передан сам объект класса Epoch (сам дескриптор - экземпляр дескриптора)
В Instance объект из которого произошло обращение к дескриптору (экземпляр класса MyTime)
В Owner объект, класс собственник MyTime

Мы можем обращаться к атрибуту класса (epoch) через сам класс (MyTime):

>>> MyTime.epoch

Self object <__main__.Epoch object at 0x7fcd70bf6950>
Instance object None
Owner object <class '__main__.MyTime'>

1686563123

В Instance  не был передан никакой объект - т.к. обращение произошло через класс (MyTime) а не через экземпляр (m)

Это напоминает работу @property:

  • Возвращать сам экземпляр класса (дескриптора), если обращение произошло через класс
  • Возвращать значение свойств, если обращение произошло через экземпляр
class Person:
     _name = 'Oleg'
     @property
     def name(self):
	    return self._name

# обращение через класс - вернул экземпляр класса
>>> Person.name

<property object at 0x7fcd70bea570>

# обращение через экземпляр - вернул значение свойства
>>> Person().name

'Oleg'
[!warning] Особенность хранения данных в дескипторах
Нельзя хранить данные в экземпляре дескриптора (реализующих методы get, set, delete), т.к. они делят общее состояние с экземплярами класса.
Имеется ввиду со стандартной реализацией set.

Для хранения данных в дескипторе можно определить словарь (dict) в инициализации и в методе __set__() передавать в него значение по ключу

class MyDescriptor:
	def __init__(self):
		self._values = {}

	def __set__(self, instance, value):
		self._values[instance] = value

	def __get__(self, instance, owner):
		if instance is None:
			return self
		return self._values.get(instance)


class Vector:
	x = MyDescriptor()
	y = MyDescriptor()

v1 = Vector()
v2 = Vector()

Но такой вариант реализации допустит утечку памяти - при пересохранении значений будут сохраняться ссылки на значения.

Чтобы предотвратить утечку памяти, следует использовать слабые ссылки (weakref).


Для подсчета ссылок, можно использовать:

import sys

val = 'Oleg'
sys.getrefcount(val)

Либо с библиотекой ctypes

import ctypes

def ref_count(obj):
	return ctypes.c_long.from_address(id(obj_id)).value

Описание утечки памяти из примера выше:

v1.x = 5

v2.x = 10

Vector.x._values

# {<__main__.Vector object at 0x7f621cddec10>: 5, <__main__.Vector object at 0x7f621cddec50>: 10}

v3 = Vector()

# вызываем верхнюю функцию для подсчёта ссылок
ref_count(v3)
# 1

v3.x = 5
ref_count(v3)
# 2

v3.y = 5
ref_count(v3)
# 3

del v3
ref_count(v3)
# 2

# тут и происходит утечка памяти - не все ссылки на объекты были удалены

Слабые ссылки (weakref)

Принцип работы слабых ссылок точно такой же, как и у сильных ссылок (strong ref), только они дополнительно учитываются сборщиком мусора

import weakref

class Person:
	pass

p = Person()

w = weakref.ref(p)

print(w)
<weakref at 0x7f5b301ce610; to 'Person' at 0x7f5b300eed50>
>>> hex(id(p))
# '0x7f5b300eed50'

print(p)
<__main__.Person object at 0x7f5b300eed50>

# удаляем ссылку
del p

print(w)
<weakref at 0x7f5b301ce610; dead>
# статус слабой ссылки изменился на dead - она была удалена. Хотя сам объект пока остается в памяти

# но если попытаться вызвать объект по мертвой ссылке (dead)...
print(w())

# python вернет None - тоесть ничего

В библиотеки (weakref) для работы со слабыми ссылками есть специальный класс для сохранения ссылок в словарь (WeakKeyDictionary())

from weakref import WeakKeyDictionary

d = weakref.WeakKeyDictionary()
d[p] = 10
d[p]
# 10

# для просмотра ссылок
d.keyrefs()
# [<weakref at 0x7f5b2e61d760; to 'type' at 0x5654f6296d40 (Person)>]

del p

d.keyrefs()
# []
[!info] Про ключи для словарей
Ключами для словарей, могут быть только хешируемые объекты! Т.е. объекты, которые реализуют метод __hash__.
Доступные дандер методы можно посмотреть в dir(obj)

Возвращаясь к примеру Data Descriptor, для устранения утечки памяти и работы дескриптора со слабыми ссылками, можно воспользоваться WeakKeyDictionary()

from weakref import WeakKeyDictionary

class MyDescriptor:
	def __init__(self):
		self._values = WeakKeyDictionary()

	def __hash__(self):
		return hash(self._values)

	def __set__(self, instance, value):
		self._values[instance] = value

	def __get__(self, instance, owner):
		if instance is None:
			return self
		return self._values.get(instance)


class Vector:
	x = MyDescriptor()
	y = MyDescriptor()

v1 = Vector()
v2 = Vector()

Проверим работу ссылок:

>>> v1.x = 10
>>> Vector.x._values.keyrefs()
[<weakref at 0x7f5e5c011b70; to 'Vector' at 0x7f5e5c0027d0>]

>>> del v1
>>> Vector.x._values.keyrefs()
[]

Метод __set_name__

Этот вызывается только один раз, когда создаётся дескриптор вызывается впервые (создаётся экземпляр дескриптора x = MyDescriptor()) и в него передаётся имя атрибута (x).

class ValidString:
	def __set_name__(self, owner_class, propetry_name):
		print(f"owner_class: {owner_class}")
		print(f"propetry_name: {propetry_name}")

class Person:
	my_new_name = ValidString()

При запуске этого кода, сразу будет запущен __set_name__(). Сразу на этапе компиляции, даже если не создавать экземпляр класса Person

class ValidString:
	def __set_name__(self, owner_class, propetry_name):
		self.propetry_name = propetry_name
	def __set__(self, instance, value):
		if not isinstance(value, str):
			raise ValueError(f"{self.propetry_name} must be a String, but {type(value)} was passed")
		#key = "_" + self.propetry_name
		#setattr(instance, key, value)
		# аналогичная запись
		instance.__dict__[self.propetry_name] = value

	def __get__(self, instance, owner):
		if instance is None:
			return self
		#key = "_" + self.propetry_name
		#return getattr(instance, key, None)
		# аналогичная запись
		instance.__dict__.get(self.propetry_name, None)

class Person:
	name = ValidString()
	surname = ValidString()

p = Person()

Примечательно то, что у дескриптора нет метода __init__ - т.к. есть __set_name__ который делает то, что нужно

p.name = "Oleg"

p.__dict__
{"name": "Oleg"}
[!warning] Особенность записи имен в пространство имен с дескрипторами
Значение name запишется сразу в локальное пространство имен экземпляра класса p, а не класса Person - это особенность работы дескриптора.

Если проделать подобное с обычным классом - name запишется в сам класс (если в самом классе существует name, она будет перезаписана):

>>> class Person:
...     name = "Ivan"
...
>>> p = Person()
>>> p.name
'Ivan'
>>> p.__dict__
{}
>>> p.name = "Dima"
>>> p.name
'Dima'
>>> p.__dict__
{'name': 'Dima'}
>>> Person.name
'Ivan'
>>> Person.__dict__
mappingproxy({'__module__': '__main__', 'name': 'Ivan', '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None})

Чтение имени значения и его запись происходит всегда через дескриптор, поэтому, такой проблемы в дескрипторе нет.