Именованные кортежи или 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 👈 

Что делает if __name__ == «__main__»?

В Python нет какой-то специально выделенной функции main(), вы можете создать любую функции сами и вызвать ее где-то на верхнем уровне кода (это значит с нулевым отступом слева). Когда Python читает файл py, он выполняет весь код, который в нем содержится (за исключением тел методов и функций, конечно). Причем код выполняется в обоих случаях: а) если файл запущен напрямую б) если импортирован из другого скрипта.

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

В процессе исполнения файла интерпретатор сам устанавливает особую строковую переменную __name__, которая будет равна "__main__", когда этот скрипт запущен непосредственно и будет равна названию модуля, если он импортирован из другого файла.

Рассмотрим пример. Файл one.py:

# one.py
def func():
    print("какая-то функция func() из one.py")

print("one.py: всегда")

if __name__ == "__main__":
    print("one.py запущен напрямую")
else:
	# здесь __name__ == "one"
    print("one.py импортируется из другого скрипта")

При запуске python one.py будет:

one.py: всегда
one.py запущен напрямую

Пусть в другом файле two.py импортируем one:

# two.py
import one
one.func()

Запуск python two.py даст:

one.py: всегда
one.py импортируется из другого скрипта
какая-то функция func() из one.py

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

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

3D-бродилка с трассировкой лучей в 184 строки на Python

184 строки – это еще с комментариями! И без использования сторонних движков! На самом деле все будет очень просто, в качестве средства отрисовки мы будем использовать любимую ASCII графику в консоли через библиотеку curses. Изображение будет строиться по принципам игры Wolfenstein 3D.

Готовая бродилка - иллюстрация
Что получим в итоге!

Библиотека curses – это интерфейс к Ncurses, консольной библиотеки для отрисовки всяческих квадратно-текстовых окошек, кнопок и прочих элементов управления в стиле Turbo Pascal или QBasic, кто помнит… Из нее нам понадобится только способность получать размеры терминала и рисовать в нужном месте символ нужного цвета.

Пользователи Windows, к сожалению, в вашей версии Python скорее всего не встроен модуль curses, поэтому вам придется установить пакет. Я использовал такой вариант:

pip install windows-curses

К сожалению, у мне не удалось добиться поддержки цвета на Windows, но я не слишком старался. Это не беда, потому что код был изначально заточен под имитацию оттенков серого через выбор символов разной плотности закраски. На Linux и Macos все должно работать на голом Python и сразу в цвете.

Код, который я вам представлю, является моим портом проекта 3D-Walk, что в свою очередь является портом проекта CommandLineFPS (по ссылке – видео) от javidx9 aka OneLoneCoder. Я внес в код небольшие модификации, исправления и поддержку цвета.

Итак, побежали по коду. Все что, нам надо импортировать:

import curses
import locale
from math import pi, cos, sin

Затем идут некоторые константы:

POS_X, POS_Y, POS_A = 2, 2, 0  # Положение и поворот игрока на карте (начальные)
ROTATION_SPEED = 0.1  # скорость поворота игрока в радианах
SPEED = 0.3  # Скорость игрока вперед назад за одно нажатие

FOV = pi / 2  # Ширина угла обзор в радинах
RESOLUTION = 0.1  # разрешение шага луча
DEPTH = 16  # Максимальная глубина прорисовки

# Наша карта строчкой
MAP = """
################
#..............#
#..............#
#...########...#
#.......#..#...#
#..........#...#
#..............#
#...########...#
#.......#..#...#
#.......####...#
#..............#
#....##..##....#
#...#...#..#...#
#....###...#...#
#..............#
################
"""

Карта представляет собой двумерную схему лабиринта, вид сверху. Карта записана многострочной строкой, где символом «решетка» обозначены непроходимые стены, а «точками» – пустое пространство. Позиция игрока – число блоков (вероятно, дробное) от угла карты. Угол поворота игрока измеряется в радианах, а широта взгляда по горизонтали FOV – это 90 градусов от левого края экрана до правого. В этим числом будет весело поиграть. Графически выглядит примерно так. Правда, здесь ошибка в точке отсчета, думаю, что внимательный читатель заметит.

Координаты и поворот игрока относительно карты
Положение игрока на карте, вид сверху

Главная функция получает параметром объект экрана (рабочего окна) и запускается через curses.wrapper:

def main_3dwalk(screen):
   ...

curses.wrapper(main_3dwalk)

В начале ее работы мы настраиваем curses:

# для корректного отображение юникода
locale.setlocale(locale.LC_ALL, '')

curses.noecho()  # нажатые клавиши не печатаются на экране
curses.curs_set(0)  # курсор убран
curses.start_color()  # цветной режим
curses.use_default_colors()  # стандартная палитра
# инициализация всех цветов!
for i in range(0, curses.COLORS):
    curses.init_pair(i, i, -1)

Следующая функция немного преобразует карту MAP, удаляя все переносы строк и считая количество строк и столбцов:

def make_map(string_map):
    rows = string_map.strip().split('\n')
    h = len(rows)
    w = len(rows[0])
    return string_map.replace('\n', ''), w, h

# форматируем карту и получаем ее размеры
level_map, map_width, map_height = make_map(MAP)

На выходе будет:

('#################..............##..............##...########...##.......#..#...##..........#...##..............##...########...##.......#..#...##.......####...##..............##....##..##....##...#...#..#...##....###...#...##..............#################', 16, 16)

Это нужно, чтобы функция get_block могла по координатам точки на карте быстро найти в этой строке символ соответствующего блока:

def get_block(x, y):
    x, y = int(x), int(y)
    if 0 <= x < map_width and 0 <= y < map_height:
        return level_map[y * map_width + x]
    else:
        return '#'

Если координаты попадут по какой-либо причине за пределы карты, что считается, что там всегда глухая стена непроходимого вещества, иными словами – символ '#'. Больше от карты ничего не требуется, только знание, есть ли стена в этой точке пространства или нет!

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

# текущие положение и угол
pos_x, pos_y, pos_a = POS_X, POS_Y, POS_A

exit_flag = False  # флаг выхода
while not exit_flag:
   # получаем размер экрана в каждом кадре, чтобы не глючить, если юзер изменил размер терминала
   screen_height, screen_width = screen.getmaxyx()
   ...

Изображение строится по столбцам. Пока номеру столбца мы находим угол отклонения луча от прямого взгляда. Левая колонка соответствует минимальному углу, то есть pos_a - FOV / 2, а самая правая колонка – pos_a + FOV / 2. Таким образом, вычисляя синус и косинус того угла, мы получаем вектор направления взгляда.

for col in range(screen_width):
    # 1. определим направление луча
    # угол сканирует от pos_a - FOV / 2 до pos_a + FOV / 2
    ray_angle = (pos_a - FOV / 2) + (col / screen_width) * FOV

    # вектор, куда смотрит луч на карте
    eye_x, eye_y = sin(ray_angle), cos(ray_angle)

Вдоль этого направления испускается луч, стартуя с позиции игрока pos_x, pos_y по направлению eye_x, eye_y. Небольшими шажками (размер шага – константа RESOLUTION), мы продвигаемся вдоль луча и проверяем, пользуясь картой, нет ли в этой точке стены. Как только луч натыкается на стену, в этот момент фиксируется дистанция, цикл прерывается, и алгоритм переходит к следующей колонке. Правильнее было бы называть его не трассировкой лучшей, а чем-то сродни ray casting. Вот код для определения дистанции:

# 2. Ищем ближайшую стену и дистанцию до нее
distance = 0.0
# пока не достигли стены и дистанция менее предельной
while distance < DEPTH:
    # луч делает шаг вперед
    distance += RESOLUTION
    
    # "текущее" положение на луче
    test_x = int(pos_x + eye_x * distance)
    test_y = int(pos_y + eye_y * distance)
    
    # смотрим карту, есть ли там стена или край
    if get_block(test_x, test_y) == '#':
        break  # стена. расстояние в distance

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

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

ceiling = int(screen_height / 2 - screen_height / distance)  # высота потолка
floor = int(screen_height - ceiling)  # высота пола
Сканирование колонки

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

# рисуем вертикальную линию
for row in range(screen_height):
    if row <= ceiling:  # Ряд выше или равен границе потолка
        shade = '`'
        color = curses.COLOR_RED
    elif floor >= row > ceiling:  # Кусок стены
        if distance <= DEPTH / 4:  # совсем близко
            shade = "█"
        elif distance <= DEPTH / 3:  # ближе
            shade = "▓"
        elif distance <= DEPTH / 2:  # дальше
            shade = "▒"
        elif distance <= DEPTH:  # еще дальше
            shade = "░"
        else:
            shade = " "  # совсем далеко
        # оттенок цвета, нормированный на предельную дистанцию
        color = color_by_distance(1 - (distance / DEPTH))
    else:
        # Оттенок пола, чем ближе к низу экрана, тем гуще заливка
        b = 1 - (row - screen_height / 2) / (screen_height / 2)
        if b < 0.25:
            shade = '#'
        elif b < 0.5:
            shade = "x"
        elif b < 0.75:
            shade = "."
        else:
            shade = ' '
        color = curses.COLOR_GREEN
    # заменяем символ в row/col на shade с цветом color
    screen.insstr(row, col, shade, curses.color_pair(color))

После циклов остается только отрисовать все изменения на экране:

screen.refresh()  # отрисуем все на экране

Дальше мы ожидаем нажатия игроком клавиш управления. Для перемещения используются клавиши WASD, W/S – вперед и назад, A/D – повороты влево и вправо. Esc – выход.

Не забудьте переключиться на английскую раскладку. На русской раскладке игра не будет реагировать!

Преобразовав код клавиши в символ, мы поворачиваем камеру либо смещаем игрока назад или вперед вдоль направления взгляда. Если новое положение игрока оказалось «внутри» стены, то такое движение отменяется. Так просто происходит обработка столкновений со стенами. Вот код, решающий задачу движения игрока:

key_code = screen.getch()  # ждем клавишу и обрабатываем
key = chr(key_code) if 0 < key_code < 256 else 0
if key in ('w', 's'):
    # шаг вперед или назад
    dx, dy = sin(pos_a) * SPEED, cos(pos_a) * SPEED
    if key == 's':  # назад - обратим вектор
        dx, dy = -dx, -dy

    # сдвинем игрока в направлении
    pos_x += dx
    pos_y += dy
    if get_block(pos_x, pos_y) == '#':  # упс, мы в стене
        # отменим движение
        pos_x -= dx
        pos_y -= dy
elif key == 'a':  # поворот налево
    pos_a -= ROTATION_SPEED
elif key == 'd':  # поворот направо
    pos_a += ROTATION_SPEED
elif key_code == 27:  # esc
    break  # выход из игры

В конце не забудем завершить работу curses корректно, восстановив все настройки терминала:

curses.endwin()

Вот и все! Наша бродилка готова! Теперь мы знаем немного больше про 3D графику!

Код программы я залил на gist.github.com. Там два файла: цветная версия для macOS и Linux, и черно-белая для Windows. Наслаждайтесь. Возможно, кто-то из читателей модифицирует этот код, добавив больше цветов, текстур, возможно, противников 🙂

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

Циклы и замыкания Python

Когда вы определяете функцию внутри другой функции и используете локальные переменные внешней функции во вложенной, вы создаете замыкание. Время жизни этих переменных «продляется» в особой области видимости enclosing даже после завершения работы внешней функции. Пример: make_adder возвращает функцию-прибавлятор. Объект из переменной a будет жить и работать даже после выхода из make_adder:

def make_adder(a):
    def adder(x):
        return a + x
    return adder

plus_5 = make_adder(5)
print(plus_5(3))  # 8

Здесь я хочу коснуться одной популярной проблемы. Дело в том, что если мы создадим несколько функций внутри одного контекста, то они будут разделять одну область видимости enclosing. Рассмотрим пример создания трех функций в цикле:

def make_adders():
    adders = []
    for a in range(3):
        def adder(x):
            return a + x
        adders.append(adder)
    return adders

adders = make_adders()
for adder in adders:
    print(adder(2))  # 4 4 4

Вместо функций прибавляющих разные числа от 0 до 2, мы получили 3 одинаковых функции, потому что внутри себя они поддерживают ссылку на одну и ту же переменную a, значение которой останется равным 2 после выполнения всего цикла целиком.

Есть простой прием, помогающий «зафиксировать» значения переменной в моменте: достаточно добавить во вложенную функцию дополнительный аргумент со значением по умолчанию, равным нужной переменной a=a:

def make_adders():
    adders = []
    for a in range(3):
        def adder(x, a=a):  # FIX!
            return a + x
        adders.append(adder)
    return adders

adders = make_adders()
for adder in adders:
    print(adder(2))  # 2 3 4

Еще лучше переименовать аргумент, чтобы избежать конфликтов имен и замечаний IDE, например, так:

def adder(x, that_a=a):  # FIX!
    return that_a + x

yield

Пока писал код для этого поста, я наткнулся на одну обманку. Люблю оформлять функции, возвращающие коллекции, как генераторы с ключевым словом yield. Вот так:

def make_adders():
    for a in range(3):
        def adder(x):
            return a + x
        yield adder

adders = make_adders()
for adder in adders:
    print(adder(2))  # 2 3 4

Видите, тут нет фикса a=a! Казалось бы, что код должен также содержать в себе баг и выводить «4 4 4», но он работает, как задумано изначально.

Однако, если мы применим list к генератору, извлекая все значения разом, то баг вернется:

adders = list(make_adders())
for adder in adders:
    print(adder(2))  # 4 4 4

Разгадка. В первом случае происходят следующие действия:

  • a = 0
  • yield функцию (a + x), make_adders становится на паузу
  • печать adder(2) = 0 + 2 = 2
  • make_adders запускается
  • a = 1
  • yield функцию (a + x), пауза
  • печать adder(2) = 1 + 2 = 2
  • … и так далее…

То есть мы запускаем adder только один раз в тот момент, пока переменная a еще равна нужному значению. 

Во втором код list прокручивает make_adders до конца, оставляя a = 2, и все функции выдают одинаковый результат.

Вывод мы должны сделать такой: yield не создает нового замыкания с отдельной переменной a и не освобождает нас от ответственности следить за переменными.

Еще кое-что. 

adders = make_adders()
for adder in adders:
    print(adder(2))  # 2 3 4

next(adders)  # StopIteration

После исполнения цикла в коде выше, генератор adders будет исчерпан. В нем больше не останется значений, и если еще раз запустить цикл по adders, то он пройдет ровно 0 итераций. 

Генератор – вещь одноразовая.

Специально для канала @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 👈