Метка: class

Именованные кортежи или namedtuple

namedtuple

from collections import namedtuple
# новый класс User с полями name и phone
User = namedtuple('User', ['name', 'phone'])

Тип namedtuple определен в стандартном модуле collections. Этот класс расширяет понятие кортежа, добавляя ему и его полям имена и, таким образом, наделяя их смысловой нагрузкой. Он позволяет писать более читаемый и самодокументируемый код. nametuple можно использовать и как обычный кортеж, или получать доступ к полям по именам, а не только индексам.

Вывоз конструктора namedtuple по сути вернет новый тип с определенными настройками. Пример:

from collections import namedtuple

# новый класс User с полями name и phone
User = namedtuple('User', ['name', 'phone'])

# конкретный юзер - экземпляр
user1 = User('John', phone='+79991002030')
print(user1)  # User(name='John', phone='+79991002030')

Первый аргумент namedtuple – название класса, а второй – список параметров, который также может быть и строкой типа 'name, phone' или даже 'name phone'. Имена полей любые, кроме зарезервированных слов и имен, начинающихся с подчеркивания.

Работа с namedtuple:

>> Point = namedtuple('Point', ['x', 'y'])
>> p = Point(x=11, y=22)  # создание
>>> p[0] + p[1]    # доступ по индексам
33
>>> p.x + p.y   # доступ по именам
33
>>> x, y = p   # распаковка, как обычный кортеж
>>> x, y
(11, 22)
>>> p    # читабельное repr
Point(x=11, y=22)
>>> Point._make((22, 33))  # создание из обычного кортежа или другого итерируемого объекта
Point(x=22, y=33)
>>> p._asdict()  # представление в форме словаря
{'x': 11, 'y': 22}

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

Кроме описанных выше возможностей, namedtuple позволяет также:

1. Задавать значения поле по умолчанию

2. Добавлять док-стринги (подсказки) к самому классу и отдельным полям

3. Создавать копии кортежа с заменой полей

4. Расширять функциональность путем наследования

Значения по умолчанию для namedtuple (Python 3.7+)

from collections import namedtuple
User = namedtuple('User', ['name', 'phone'])
user = User(name='Jack')  # ошибка! не указан phone!

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

User = namedtuple('User', ['name', 'phone'], defaults=('NoName', 'NoPhone'))
user = User(name='Jack') 
>> user
User(name='Jack', phone='NoPhone')
>>> User()  # вообще без параметров
User(name='NoName', phone='NoPhone')

Чтобы устранить исключение о недостающих полях в конструкторе, достаточно передать defaults как кортеж из None:

fields = ('val', 'left', 'right')
Node = namedtuple('Node', fields, defaults=(None,) * len(fields))

>>> Node()
Node(val=None, left=None, right=None)

🤸 Вообще говоря, defaults могут быть и короче, чем список полей. В таком случае значения по умолчанию применяются только к самым правым полям. Чтобы было понятно, вот пример:

Node = namedtuple('Node', ('val', 'left', 'right'), defaults=(100, 200))

Так как defaults из двух элементов, а полей – три, то они применятся только к последним полям ‘left‘ и ‘right‘, а ‘val‘ останется без дефолтного значения, вынуждая нас всегда его указывать:

>>> Node()
TypeError: __new__() missing 1 required positional argument: 'val'
>>> Node(13)
Node(val=13, left=100, right=200)
>>> Node(val=42)
Node(val=42, left=100, right=200)

💣 Важно! namedtuple, как и обычный tuple является неизменяемым типом (immutable), однако, вам не запрещено в качестве поля использовать изменяемый тип, к примеру, list или dict, так как кортеж содержит лишь ссылку на объект, а за содержимое самого объекта он не отвечает. Поэтому, не рекомендуется делать значением по умолчанию списки, сеты и словари, а также пользовательские изменяемые объекты.

Посмотрите, здесь все экземпляры Group будут разделять один и тот же список по умолчанию для поля ‘users‘:

Group = namedtuple('Node', ('name', 'users'), defaults=('5B', []))  # плохо!
g = Group()
g.users.append('Vanya')  # повлияет на g2 тоже!
g2 = Group()
print(g2.users)  # ['Vanya']

Лучше указать None. Или создать отдельную функцию, которая каждый раз будет создавать новый пустой список:

def new_empty_group(name='5B'):
    return Group(name=name, users=[])

Замена поля в namedtuple

namedtuple, будучи кортежем, является неизменяемым типом. Однако, метод _replace возвращает новый объект, в котором отредактированы выбранные поля, а все остальные равны значениям из предыдущего кортежа.

from collections import namedtuple
Book = namedtuple('Book', ['id', 'title', 'authors'])
book1 = Book(1, 'Игрок', 'Достоевский Ф.М.')
book2 = book1._replace(id=2, title='Преступление и наказание')
>>> book1
Book(id=1, title='Игрок', authors='Достоевский Ф.М.')
>>> book2
Book(id=2, title='Преступление и наказание', authors='Достоевский Ф.М.')

⚠️ Метод replace делает поверхностную копию данных, то есть копирует ссылки. Если со строчками и числами все будет в порядке (они скопируются), то ссылка на список будет разделяться между обоими объектами.

Документация namedtuple

Имеется возможность снабдить сам класс и его поля документацией (doc-strings):

# сам класс
Book.__doc__ += ': Hardcover book in active collection'
# его поля
Book.id.__doc__ = '13-digit ISBN'
Book.title.__doc__ = 'Title of first printing'
Book.authors.__doc__ = 'List of authors sorted by last name'

Наберите в интерпретаторе help(Book) и увидите, что у Book есть теперь описание класса и всех полей. А также по умолчанию namedtuple добавляет кучу стандартной документации по методам класса. Вот вывод подсказок о классе:

Help on class Book in module __main__:

class Book(builtins.tuple)
 |  Book(id, title, authors)
 |
 |  Book(id, title, authors): Hardcover book in active collection
 |
 |  Method resolution order:
 |      Book
 |      builtins.tuple
 |      builtins.object
 |
 |  Methods defined here:
 |
 |  __getnewargs__(self)
 |      Return self as a plain tuple.  Used by copy and pickle.
 |
 |  __repr__(self)
 |      Return a nicely formatted representation string
 |
 |  _asdict(self)
 |      Return a new dict which maps field names to their values.
 |
 |  _replace(self, /, **kwds)
и так далее...

Расширение namedtuple

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

class Point(namedtuple('Point', ['x', 'y'])):
    __slots = ()
    @property
    def hypot(self):
        return (self.x  2 + self.y  2) ** 0.5
    def __str__(self):
        return f'Point: x={self.x}  y={self.y}  hypot={self.hypot}'

>>> print(Point(1, 2))
Point: x=1  y=2  hypot=2.23606797749979

Однако, вы не можете менять значения полей внутри методов, так как кортеж – неизменяемый тип данных. 

Существует еще один экзотический способ создать класс от namedtuple, причем с «типизированными» полями:

from typing import NamedTuple

class Employee(NamedTuple):
    name: str
    id: int

Это эквивалент такой записи:

Employee = namedtuple('Employee', ['name', 'id'])

Однако, первый вариант смотрится выразительнее и помогает IDE анализировать код.

По сравнению с обычными классами namedtuple также неплохо экономит оперативную память. Вот посмотрите этот пример. Обычный класс с двумя атрибутами занимает целых 328, когда как схожий namedtuple – всего 120.

Строка __slots = () здесь играет особую роль. Я еще не рассказывал про слоты, но если вкратце, то таким образом мы предотвращаем создание внутреннего словаря для данного класса, что экономит еще 8 байт.

namedtuple – производная от tuple, который написан на Си, что дает дополнительный буст производительности по сравнению с классами, написанными на чистом Python.

Вывод: namedtuple позволяет быстро и удобно создавать небольшие неизменяемые классы для хранения данных. Такие классы эффективны по объему используемой памяти и уже содержат большое количество вспомогательных функций, таких как инициализация, сравнение, хэширование, представление данных, преобразование в словарь и так далее. Также, namedtuple наследует поведение обычного кортежа tuple, в том плане, что можно обратиться к полям по индексам или распаковать их в отдельные переменные.

Но не спешите переделывать все ваши классы на namedtuple, потому что:

1. namedtuple не изменяем

2. без танцев с бубнами невозможно наследование

3. много прочих проблем с ООП

Если вам нужен изменяемый аналог namedtuple, то советую присмотреться к dataclass. О нем я расскажу в будущих постах.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

super() – супер класс в Python

super() – это встроенная функция языка Python. Она возвращает прокси-объект, который делегирует вызовы методов классу-родителю (или собрату) текущего класса (или класса на выбор, если он указан, как параметр).

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

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

Давайте рассмотрим пример наследования. Есть какой-то товар в классе Base с базовой ценой в 10 единиц. Нам понадобилось сделать распродажу и скинуть цену на 20%. Хардкодить – это непрофессионально и негибко:

class Base:
    def price(self):
        return 10

class Discount(Base):
    def price(self):
        return 8

Гораздо лучше было бы получить цену из родительского класса Base и умножить ее на коэффициент 0.8, что даст 20% скидку. Однако, если мы вызовем self.price() в методе price() мы создадим бесконечную рекурсию, так как это и есть один и тот же метод класса Discount! Тут же нужен метод Base.price(). Тогда его и вызовем по имени класса:

class Discount(Base):
    def price(self):
        return Base.price(self) * 0.8

Здесь, надо не забыть указать self при вызове первым параметром явно, чтобы метод был привязан к текущему объекту. Это будет работать, но этот код не лишен изъянов, потому что необходимо явно указывать имя предка. Представьте, если иерархия классов начнет разрастаться? Например, нам нужно будет вставить между этими классами еще один класс, тогда придется редактировать имя класса-родителя в методах Discount:

class Base:
    def price(self):
        return 10

class InterFoo(Base):
    def price(self):
        return Base.price(self) * 1.1

class Discount(InterFoo):  # <-- 
    def price(self):
        return InterFoo.price(self) * 0.8  # <-- 

Тут на помощь приходит super()! Супер он не потому что, подобно Супермэну, помогает всем людям, а потому что обращается к атрибутам классов стоящих над ним в порядке наследования (кто учил матан, вспомнят понятие супремум).

Будучи вызванным без параметров внутри какого-либо класса, super() вернет прокси-объект, методы которого будут искаться только в классах, стоящих ранее, чем он, в порядке MRO. То есть, это будет как будто бы тот же самый объект, но он будет игнорировать все определения из текущего класса, обращаясь только к родительским:

class Base:
    def price(self):
        return 10

class InterFoo(Base):
    def price(self):
        return super().price() * 1.1

class Discount(InterFoo):
    def price(self):
        return super().price() * 0.8
super calls

Для Discount порядок MRO: Discount - InterFoo - Base - object. Вызов super().method() внутри класса Discount будет игнорировать Discount.method(), а будет искать method в InterFoo, затем, если не найдет, то в Base и object.

Когда нельзя забыть super?

Очень часто super вызывается в методе __init__. Метод инициализации класса __init__, как правило задает какие-либо атрибуты экземпляра класса, и если в дочернем классе мы забудем его вызвать, то класс окажется недоинициализированным: при попытке доступа к родительским атрибутам будет ошибка:

class A:
    def __init__(self):
        self.x = 10

class B(A):
    def __init__(self):
        self.y = self.x + 5

# print(B().y)  # ошибка! AttributeError: 'B' object has no attribute 'x'

# правильно:

class B(A):
    def __init__(self):
        super().__init__()  # <- не забудь!
        self.y = self.x + 5

print(B().y)  # 15

Параметры super

Функция может принимать 2 параметра. super([type [, object]]). Первый аргумент – это тип, к предкам которого мы хотим обратиться. А второй аргумент – это объект, к которому надо привязаться. Сейчас оба аргумента необязательные. В прошлых версиях Python приходилось их указывать явно:

class A:
    def __init__(self, x):
        self.x = x

class B(A):
    def __init__(self, x):
        super(B, self).__init__(x)
        # теперь это тоже самое: super().__init__(x)

Теперь Python достаточно умен, чтобы самостоятельно подставить в аргументы текущий класс и self для привязки. Но старая форма тоже осталась для особых случаев. Она нужна, если вы используете super() вне класса или хотите явно указать с какого класса хотите начать поиск методов.

Действительно, super() может быть использована вне класса. Пример:

d = Discount()
print(super(Discount, d).price())

В этом случае объект, полученный из super(), будет вести себя как класс InterFoo (родитель Discount), хотя привязан он к переменной d, которая является экземпляром класса Discount.

Это редко используется, но, вероятно, кому-то будет интересно узнать, что функция super(cls), вызванная только с одним параметром, вернет непривязанный к экземпляру объект. У него нельзя вызывать методы и обращаться к атрибутам. Привязать его можно будет так:

super_d = super(Discount)
d = Discount()
binded_d = super_d.__get__(d, Discount)  # привязка
print(binded_d.price())  # 11.0

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

В случае множественного наследования super() необязательно указывает на родителя текущего класса, а может указывать и на собрата. Все зависит от структуры наследования и начальной точки вызова метода. Общий принцип остается: поиск начинается с предыдущего класса в списке MRO. Давайте рассмотрим пример ромбовидного наследования. Каждый класс ниже в методе method печатает свое имя. Плюс все, кроме первого, вызывают свой super().method():

class O:
    def method(self):
        print('I am O')

class A(O):
    def method(self):
        super().method()
        print('I am A')

class B(O):
    def method(self):
        super().method()
        print('I am B')


class C(A, B):
    def method(self):
        super().method()
        print('I am C')

Если вызвать метод C().method(), то в терминале появится такая распечатка:

# C().method()
I am O
I am B
I am A
I am C

Видно, что каждый метод вызывается ровно один раз и ровно в порядке MRO. C вызывает родителя A, а A вызывает своего брата B, а B вызывает их общего родителя O. Но! Стоит нам вызвать A().method(), он уже не будет вызывать B().method(), так как класса B нет среди его родителей, он брат, а родитель у класс А только один – это O. А о братьях он и знать не хочет:

# A().method()
I am O
I am A

Таким образом, вызов super() сам автоматически догадывается, к кому обращаться: к родителю или к брату. Все зависит от иерархии класса и начальной точки вызова. Эта фишки снимают с программиста головную боль, связанную с заботой о поддержании цепочки вызовов в иерархии классов.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

MRO – порядок разрешения методов в Python

Погорим о наследовании. Если один класс унаследован от другого, то он от него перенимает себе методы и атрибуты своего родителя. Вы, конечно, можете переопределить некоторые из них или добавить свою новую функциональность – в этом и есть смысл наследования. Но те методы, которые вы не переопределяли, так и останутся родительскими. Таким образом, когда вы вызываете метод экземпляра класса, Python должен посмотреть, есть ли в нем этот метод. Если есть – он и будет вызван, а если его нет, то ему придется проверить классы-родители данного класса. Вдруг, у них есть?

class Parent:
    def earn_money(self):
        print('Родитель зарабатывает')

class Child(Parent):
    def play(self):
        print('Ребенок играет')

c = Child()
c.play()  # Ребенок играет
c.earn_money()  # Родитель зарабатывает

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

Сложнее ситуация становится, когда иерархия классов разрастается. Не будем забывать, что Python поддерживает множественное наследование, что сделает граф отношений между классами весьма запутанным. Методы с одинаковыми именами могут быть определены в любых классах из всей иерархии. И если ответ на вопрос «где искать?» довольно прост: сначала посмотри в самом классе, а потом в его родителях; то ответ на вопрос «в каком порядке искать?» не такой тривиальный. Например, взгляните на такую иерархия классов:

class O: ...
class A(O): ...
class B(O): ...
class C(O): ...
class D(O): ...
class E(O): ...

class K1(A, B, C): ...
class K2(B, D): ...
class K3(C, D, E): ...

class Z(K1, K2, K3): ...
Простой пример иерархии классов.
«Простой» пример

Сможет сходу назвать порядок поиска методов в этой иерархии? Я вот ошибся с первой попытки, и даже со второй. Узнаем правду методом mro():

print(Z.mro())
# [<class '__main__.Z'>, <class '__main__.K1'>, <class '__main__.A'>, <class '__main__.K2'>, <class '__main__.B'>, <class '__main__.K3'>, <class '__main__.C'>, <class '__main__.D'>, <class '__main__.E'>, <class '__main__.O'>, <class 'object'>]

# сделаем понагляднее вывод, печатая только имена классов со стрелочками:
def print_mro(T):
    print(*[c.__name__ for c in T.mro()], sep=' -> ')

print_mro(Z)
# Z -> K1 -> A -> K2 -> B -> K3 -> C -> D -> E -> O -> object

Что же такое этот MRO?

Аббревиатура MRO – method resolution order. А по-русски это переводится как «порядок разрешения методов». Но! Тоже самое относится не только к методам, но и к прочим атрибутам класса, так как методы – это частный случай более общего понятия «атрибут».

Метод класса Z.mro() возвращает нам список классов ровно в том порядке, в котором Python будет искать методы в иерархии классов пока не найдет нужный или не выдаст ошибку.

Конечный класс в цепочке всегда – object; от него неявно наследуются все объекты в Python 3. Поэтому любое множественное наследование (когда у класса более одного непосредственного родителя) порождает ромбовидные структуры, потому что все цепочки в конечном счете сходятся в object.

Для простого ромбовидного наследования MRO будет следующим: C -> A -> B -> object. Сначала методы ищутся в C, потом в A и B (потому что class C(A, B):), в конце, естественно object.

Ромбовидное наследование
Ромбовидное наследование

В более сложных иерархиях потребуется специальный алгоритм.

Алгоритм C3-линеаризации

Какие критерии должны быть для алгоритма разрешения методов?

  1. Каждый класс должен входить в список ровно 1 раз.
  2. Если какой-то класс D наследуется от нескольких классов, допустим, A, B, C (class D(A, B, C):), в таком же порядке они должны появиться в MRO. D -> ... -> A -> ... -> B -> ... -> C -> ... Между ними могут оказаться и другие классы, но исходный порядок должен быть соблюден.
  3. Родители данного класса должны появляться по порядку старшинства. Сначала идут непосредственные родители, потом дедушки и бабушки, но не наоборот.

Алгоритм, который удовлетворяет этим условиям был предложен в 1996 года и называется C3 superclass linearization. Линеаризация в данном случае – это процесс превращения графа наследования в плоский список. А С3 он называется из-за наличия трех основных свойств. Важнейшее свойство здесь – это монотонность – это свойство, которое требует соблюдения в линеаризации класса-потомка того же порядка следования классов-прародителей, что и в линеаризации класса-родителя.

В Python данный алгоритм появился еще в далекой версии 2.3.

Если вы обладаете навыками чтения английской технической литературы, то можете ознакомиться с оригиналом статьи, PDF я нашел на просторах интернета. Есть и замечательное описание алгоритма на русском языке в статье на Хабре. Там же есть и примеры составления линеаризаций.

Почему именно так?

Вернемся к исходному примеру.

class O: ...
class A(O): ...
class B(O): ...
class C(O): ...
class D(O): ...
class E(O): ...

class K1(A, B, C): ...
class K2(B, D): ...
class K3(C, D, E): ...

class Z(K1, K2, K3): ...

Почему Z -> K1 -> A -> K2 -> B -> K3 -> C -> D -> E -> O -> object, а не, например, Z -> K1 -> K2 -> K3 -> A -> B -> C -> D -> E -> O -> object? На самом деле обе из них имею место быть, но по реализации алгоритма получается именно первый вариант. Графически MRO на диаграмме выглядит так:

Нумерация MRO в примере

Попробуем обосновать такой порядок. C начала, конечно же, положим в MRO-список оконечный класс Z. Потом класс K1, так как он идет первым в списке наследования Z. Далее, видим, что идет класс A. Этот класс больше никому не является родителем, кроме как K1, следовательно алгоритм добавляет A сразу после K1, не нарушив никаких правил. После A непосредственно не может идти класс B, так как за ним пришлось бы где-то еще воткнуть K2, и получилось бы так, что K2 будет позже B, что запрещено. Нет! Ставим тогда сначала K2, потом только B. Далее, по схожей причине нужно поставить K3, дабы он не оказался после своего родителя C. Дополняем список классами D и E в их порядке. И остается только завершить список классами O, который общий родитель для всех прочих классов, и object, который родитель для O. Как видите никакой родитель не стоит перед стоим потомком (но может стоять перед чужим). А также порядок следования классов в MRO согласован с порядком наследования.

Вариант реализации алгоритма и его работу на этом примере я разместил здесь (можно запустить и поиграться прямо браузере. Автор реализации – не я. Нашел на Github.

Когда нельзя линеаризовать?

Думаю, такой вопрос имеет место быть на собеседовании. Вас могут попросить привести пример иерархии классов, не поддающихся линеризации по стандартному алгоритму. Вот первый неразрешимый пример:

class X: ...
class Y: ...
class A(X, Y): ...
class B(Y, X): ...
class G(A, B): ...

Для A порядок X -> Y, а для B – обратный Y -> X. Класс G обязан удовлетворить обоим порядкам наследования, что невозможно, так как они противоречат друг другу. Возникнет ошибка в строке объявления класса G:

    class G(A, B): ...
TypeError: Cannot create a consistent method resolution
order (MRO) for bases X, Y

Или вот второй пример:

class X: ...
class Y(X): ...
class A(X, Y): ...

Здесь класс X наследуется дважды, и куда мы его не поместили в цепочке MRO, он либо нарушит правило старшинства (A -> X -> Y -> object), либо порядка наследования (A -> Y -> X -> object).

Как задать свой порядок MRO?

Это возможно, используя метаклассы. Для «конфликтного» класса мы определим особый метакласс, который переопределяет явно метод mro(), указывая вручную, какой именно должен быть порядок разрешения методов. На первом «неразрешимом» примере решение будет такое:

class X: ...
class Y: ...
class A(X, Y): ...
class B(Y, X): ...

class MyMRO(type):  # наследование type = это метакласс
    def mro(cls):
        return (cls, A, B, X, Y, object)  # явно задаем порядок!

class G(A, B, metaclass=MyMRO):  
    ...

print_mro(G)  # G -> A -> B -> X -> Y -> object
# никаких ошибок!

Ты super()!

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

class C(A, B):
    def __init__(self):
        A.__init__(self)
        B.__init__(self)

Гораздо удобнее обратиться к следующему в цепочка MRO классу-родителю через super().

super() – это особенный прокси-класс к нужному родительскому классу. Вот так правильно можно обратиться к родительскому классу:

class C(B, A):
    def __init__(self):
        super().__init__()

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

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Переопределение свойств класса

В заметке расскажу, как переопределить свойства (@property) в классе-наследнике. Как оказалось, это не тривиально и существуют несколько вариантов, различных между собой.

Мем про property

Допустим есть базовый класс со свойством:

class Base:
    def __init__(self):
        self._x = 100

    @property
    def x(self):
        print('Base get x')
        return self._x

    @x.setter
    def x(self, value):
        print('Base set x')
        self._x = value

Ситуация А. В классе-наследнике мы хотим переопределить ТОЛЬКО сеттер, чтобы он делал что-то еще помимо того, что умеет в базовом классе. Это не так и тривиально:

class Derived1(Base):
    @Base.x.setter
    def x(self, value):
        print('Derived1 set x')
        Base.x.fset(self, value)

Ситуация B. Хотим переопределить ТОЛЬКО геттер:

class Derived3(Base):
    @Base.x.getter
    def x(self):
        print('Derived3 get x')
        return super().x

Ситуация C. Хотим переопределить и геттер, и сеттер.  

class Derived2(Base):
    @property
    def x(self):
        print('Derived2 get x')
        return super().x
    @x.setter
    def x(self, value):
        print('Derived2 set x')
        Base.x.fset(self, value)

В этом случае мы определяем свойство как бы с нуля. Старый геттер вызывается при доступе к super().x, а старый сеттер вызываем аналогично с ситуацией A. Разница только в @x.setter вместо @Base.x.setter (по-старому не работает, проверял).

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

Как сделать проще? Писать все геттеры и сеттеры явно, а потом делать из них property; или вообще отказаться от property.

class Base:
    def __init__(self):
        self._x = 0
    def set_x(self, v):
        print('Base set x')
        self._x = v
    def get_x(self):
        print('Base get x')
        return self._x
    x = property(get_x, set_x)

class Derived(Base):
    def set_x(self, v):
        print('Derived set x')
        super().set_x(v)
    def get_x(self):
        print('Derived get x')
        return super().get_x()
    x = property(get_x, set_x)

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Перезагрузка модулей Python

Пусть в файле my_module.py написано определение класса:

class A: ...

Пишем такой код в другом файле:

from my_module import A
a = A()
from my_module import A

print(isinstance(a, A))

Ответ – True. Система модулей Python только единожды будет запускать каждый импорируемый файл. Второй import не возымеет действия, и класс А будет тем же, что и был раньше.

Бонус: если вам нужно принудительно перезагрузить модуль – воспользуйтесь функцией reload из importlib. Попробуем. В файл mymodule.py напишем:

class A:
    # будем видеть, когда класс загружен
    print('loaded class A')

В другой файле:

from importlib import reload

import mymodule
a = my_module.A()
mymodule = reload(mymodule)

print(isinstance(a, mymodule.A))

Вывод программы:

loaded class A
loaded class A
False

Запустить ход.

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈