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

Kate

Administrator
Команда форума
Пару слов о статье.
Она не первой свежести – 2015 год. Вероятно, её уже кто-то перевёл до меня. Тем не менее, я не нашёл такого перевода (в том числе на Хабре - искал по имени автора, а как ещё искать переводы?).
Автор легко и со вкусом рассказывает об особенностях Python как языка программирования, в котором всё является объектом как таковым. В переводе, я постарался придерживаться стиля оригинала, в том числе, и речи от первого лица.
Итак, поехали.
Давайте проведём исследование некоторых взаимосвязей функций, объектов, генераторов и корутин в Python. На уровне теории, каждая из этих концепций очень сильно отличается от других; но динамическая природа языка позволяет им заменять друг друга на практике. Предупреждаю: мы рассмотрим рабочие, но очень странные примеры кода; я не советую вам применять их в реальных проектах!

Функции​

Функция – это объект, который может быть выполнен. При этом, она вызывается из одного места в коде, и может принимать другие объекты в качестве параметров (а может и вовсе обходиться без них). Функция существует только в одном месте и всегда возвращает лишь один объект.

Здесь, мы уже видим некоторые сложности; возможно, вы уже задались некоторыми из следующих вопросов:

  • А если в теле функции несколько ключевых слов return? (Да, но только одно из них приведёт к остановке работы функции в каждом единичном случае выполнения кода)
  • Что, если функция вообще ничего не возвращает? (В этом случае, она всё-таки возвращает кое-что: специальный объект None)
  • Разве мы не можем возвращать несколько объектов через запятую? (Можем, но все эти объекты, в сумме, являют собой кортеж – он и будет считаться возвращаемым объектом)
Взгляните на функцию:
def average(sequence):
avg = sum(sequence) / len(sequence)
return avg
print(average([3, 5, 8, 7]))
(вывод в консоль: 5.75)
Ничего необычного. Вероятно, вы уже знаете, что представляют из себя объекты и классы в Python. Я определяю объект как конкретные данные, у которых есть своё, особое, поведение. Класс – шаблон для объекта, где данные обычно представлены набором атрибутов, а поведение реализовано набором методов (тех же функций).

Но это не обязательно должно быть так ;)

Код обычного объекта:
class Statistics:
def __init__(self, sequence):
self.sequence = sequence
def calculate_average(self):
return sum(self.sequence) / len(self.sequence)
def calculate_median(self):
length = len(self.sequence)
is_middle = int(not length % 2)
return (
self.sequence[length // 2 - is_middle] +
self.sequence[-length // 2]) / 2
statistics = Statistics([5, 2, 3])
print(statistics.calculate_average())
(вывод в консоль: 3.3333333…)
Последовательность (sequence), переданная в такой класс при создании – это единственные данные, которыми оперирует объект. После инициализатора __init__ записаны два метода.

Но только один из них был использован в этом конкретном примере; и как сказал Джек Дидерих (Jack Diederich) в своей известной речи Stop Writing Classes,

Класс с единственным методом после инициализатора должен быть реализован как простая функция
Поэтому, я включил в пример кода второй метод, чтобы вывод о необходимости реализации в виде класса показался вам правдоподобным. (Хотя это не так; здесь вам поможет модуль statistics в Python 3.4+. Никогда не пишите того, что уже до вас было написано, отлажено и протестировано).

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

Например: вы знали, что функция – это тоже объект? По факту, всё, с чем вы можете взаимодействовать в Python, определяется в исходном коде для интерпретатора CPython как структура «PyObject».

Это значит, что мы можем задать атрибутом для функции любой объект. Даже функцию. Пример ниже не рекомендуется для выполнения в домашних условиях (а на работе – тем более):

Издеваемся над функциями
def statistics(sequence):
statistics.sequence = sequence
return statistics
def calculate_average():
return sum(statistics.sequence) / len(statistics.sequence)
statistics.calculate_average = calculate_average
print(statistics([1, 5, 8, 4]).calculate_average())
(вывод в консоль: 4.5)
Это довольно безумный пример (хотя мы только начали). Сама функция statistics объявляется как объект с двумя атрибутами: лист sequence и calculate_average как другой объект-функция. Для забавы, функция возвращает саму себя, поэтому, мы можем вызывать calculate_average в одной строке с print.

Имейте в виду, что statistics – это объект, а не класс. Мы не старались скопировать класс как паттерн из предыдущего примера; скорее, текущий пример похож на экземпляр класса.

Сложно представить какой-либо повод писать такой код в реальных проектах. Возможно – для реализации (анти-)паттерна Singleton, который популярен в некоторых других ЯП. Поскольку может существовать только одна функция statistics, то невозможно реализовать два разных экземпляра этой функции, с двумя разными атрибутами sequence, в отличие от того, как это легко делается с классом.

Но мы можем более точно симулировать структуру класса (как паттерна), используя функцию как конструктор:

Почти класс
def Statistics(sequence):
def self():
return self.average()
self.sequence = sequence
def average():
return sum(self.sequence) / len(self.sequence)
self.average = average
return self
statistics = Statistics([2, 1, 1])
print(Statistics([1, 4, 6, 2]).average())
print(statistics())
(вывод в консоль: 3.25 и 1.333333333…)
Очень смахивает на Javascript, не так ли? Функция Statistics ведёт себя как конструктор, который возвращает объект (в нашем случае – функция self). Этот возвращаемый объект-функция, в свою очередь, имеет несколько связанных с нею атрибутов – так что у нас есть объект с данными и неким поведением. В последних трёх строках мы можем создать два отдельных «экземпляра» Statistics, прямо так, как будто мы использовали класс. Наконец, раз Statistics это функция – мы можем напрямую вызвать её. Такой вызов передаётся функции average (или это уже считается методом? Я не берусь ручаться).

Прежде, чем мы продолжим, обратите внимание, что такая симуляция функционала классов вовсе не означает, что в результате мы получаем точно такое же поведение этой структуры вне интерпретатора Python. Все функции – это объекты; но не всякий объект – это функция. Базовая реализация их различна, и если использовать такой код в реальном проекте, это быстро приведёт к разного рода казусам. В обычном коде, вариант привязки атрибутов к функциям редко бывает полезен. Я встречал такие реализации для интересных вариантов диагностики и тестирования, но в основе своей это лишь трюки забавы ради.

Однако понимание, что функция — это объект, позволяет нам передавать их для вызова.

Рассмотрим основу частичной реализации паттерна наблюдателя (observer):
class Observers(list):
register_observer = list.append
def notify(self):
for observer in self:
observer()
observers = Observers()
def observer_one():
print('Первый был вызван')
def observer_two():
print('Второй был вызван')
observers.register_observer(observer_one)
observers.register_observer(observer_two)
observers.notify()
(вывод в консоль: 'Первый был вызван' 'Второй был вызван')
На второй строке, я специально сделал код менее понятным, чтобы подтвердить свой изначальный тезис «большинство примеров в этой статье – смешные». Здесь, я создаю новый атрибут класса register_observer, который хранит ссылку на функцию list.append. Так как текущий класс наследуется от базового класса списков (list), всё, что делает вторая строка – создаёт ссылку на метод, которая могла бы выглядеть так:

def register_observer(self, item):

self.append(item)

И именно код выше – правильный способ реализации такого функционала, иначе никто не поймёт, какая логика заложена в вашем коде. А вот 2 и 3 строки (в спойлере - с конца) вы, возможно, захотите использовать на практике. Здесь две callback-функции передаются в регистрирующую функцию, и это распространённая практика. Не будь функции объектами, пришлось бы создавать группу классов с одним простецким методом внутри, названным, например, execute, и уже их и передавать.

Но что-то мы слишком стали серьёзны. Давайте сделаем нечто действительно глупое, например, функцию, которая возвращает объект функции:

Тут глупость
silly():
print("глупость")
return silly
silly()()()()()()
(вывод в консоль: шестикратная "глупость" + адрес объекта в памяти)
Я не использую такую фичу слишком часто, но иногда это бывает полезно.

Например: если вы пишете функцию и вызываете её из множества мест в коде, а позднее обнаруживается, что ей необходимо сохранять какое-то состояние. Для этого, лучше заменить функцию объектом с реализованным магическим (dunder) методом __call__() без изменения всех мест вызова.

Или, если у вас есть callback-реализация, которая обычно передаётся функции, вы можете использовать вызываемый объект, когда необходимо сохранить более сложное состояние.

Также, я видел декораторы, сделанные из объектов, и они сохраняли дополнительное состояние или поведение.

Генераторы​

Поговорим о генераторах. Разумеется, реализовать их мы собираемся самым топорным образом. Мы можем взять за основу идею, в которой функция возвращает объект, для создания элементарного генератора-объекта, который вычисляет последовательность Фибоначчи:

Фибоначчи
def FibFunction():
a = b = 1
def next():
nonlocal a, b
a, b = b, a + b
return b
return next
fib = FibFunction()
for i in range(8):
print(fib(), end=' ')
(вывод в консоль: 2 3 5 8 13 21 34 55)
Делать так не очень умно. Важно другое: мы можем составить функцию, сохраняющую своё состояние между вызовами. Оно хранится в области видимости функции, к которому мы получаем доступ через ключевое слово nonlocal.

Мы могли бы реализовать такой же функционал через класс:
class FibClass():
def __init__(self):
self.a = self.b = 1
def __call__(self):
self.a, self.b = self.b, self.a + self.b
return self.b
fib = FibClass()
for i in range(8):
print(fib(), end=' ')
Ни один из этих примеров не подчиняется протоколу итератора. И как бы я ни бился, FibFunction не удастся заставить работать в связке со встроенным функционалом языка – next(). Даже после просмотра исходного кода CPython в течение нескольких часов.

Как я упоминал ранее, использование синтаксиса функций для построения псевдо-объектов быстро приводит к разочарованию.

А вот основанный на FibClass объект можно легко настроить для выполнения протокола итератора:
class FibIterator():
def __init__(self):
self.a = self.b = 1
def __next__(self):
self.a, self.b = self.b, self.a + self.b
return self.b
def __iter__(self):
return self
fib = FibIterator()
for i in range(8):
print(next(fib), end=' ')
Это, кстати, стандартная реализация итератора, как есть. Но выглядит как-то некрасиво и многословно. К счастью, мы можем получить тот же эффект при помощи функции, которая включает оператор yield:

Елдим!
def FibGenerator():
a = b = 1
while True:
a, b = b, a + b
yield b
fib = FibGenerator()
for i in range(8):
print(next(fib), end=' ')
print('n', fib)
Версии с генератором уже более читабельны, чем две первые. Здесь следует держать в голове то, что генератор не является функцией – объект FibGenerator возвращает объект (как иллюстрация фразы “generator object” в консольном выводе).

В отличие от обычной функции, генераторная не выполняет никакого кода, когда мы её вызываем. Вместо этого, она собирает объект-генератор и возвращает его. Вы можете думать об этом как о неявном декораторе: интерпретатор видит ключевое слово yield и заключает его в декоратор, который возвращает объект. А вот чтобы код внутри функции начал выполняться, мы должны использовать функцию next() – либо явно, либо через цикл for или же yield from.

Генератор, конечно, является объектом. Но намного удобнее думать о создающей его функции как об обычной: которая может передавать данные в одно место и возвращать значения несколько раз. Это что-то вроде универсальной функции: очень легко реализовать генератор, который ведёт себя не совсем как функция, и возвращает лишь одно значение:

Реализация странного генератора
def average(sequence):
yield sum(sequence) / len(sequence)
print(next(average([1, 2, 3])))
(вывод в консоль: 2.0)
К сожалению, момент вызова функции не так легко воспринимается, так как в связке с yield мы обязаны вставить этот надоедливый next(). Очевидный способ обхода этого ограничения – добавить __call__() к генератору, но это не удастся, если мы попытаемся использовать присвоение атрибутов или наследование. Существуют оптимизации, которые позволяют генераторам работать в коде С быстрее, а также не позволяют нам назначать атрибуты. Однако, мы можем обернуть генератор в объект, похожий на функцию, используя нелепый декоратор:

Усложняем генератор!
def gen_func(func):
def wrapper(*args, **kwargs):
gen = func(*args, **kwargs)
return next(gen)
return wrapper
@gen_func
def average(sequence):
yield sum(sequence) / len(sequence)
print(average([1, 6, 3, 4]))
Делать так – полный бред. Я имею в виду, просто напишите обычную функцию, ради всего святого!

Хотя, возникает соблазн создать кое-что поинтереснее:

И ещё усложняем!
def callable_gen(func):
class CallableGen:
def __init__(self, *args, **kwargs):
self.gen = func(*args, **kwargs)
def __next__(self):
return self.gen.__next__()
def __iter__(self):
return self
def __call__(self):
return next(self)
return CallableGen
@callable_gen
def FibGenerator():
a = b = 1
while True:
a, b = b, a + b
yield b
fib = FibGenerator()
for i in range(8):
print(fib(), end=' ')
(вывод в консоль: 2 3 5 8 13 21 34 55)
Чтобы полностью обернуть генератор в декоратор, нам необходимо предоставить ему доступ к некоторым другим методам – send, close и throw. Такой декоратор можно использовать для вызова генератора любое количество раз, обходясь без next(). У меня был соблазн сделать это, чтобы мой код выглядел чище, если в нём много вызовов этой функции… Но я советую от такого воздерживаться. Программисты (включая вас), которые будут читать ваш код, сойдут с ума, пытаясь понять, что же делает этот «вызов функции». Лучше всего – просто привыкнуть к функционалу next()

Корутины​

Итак, мы провели некоторые параллели между генераторами, объектами и функциями. Теперь, поговорим об одной из самых запутанных концепций в Python – корутинах (coroutines, или «сопрограммы»). Обычно, их определение звучит, как «генераторы, в которые можно отправлять значения». На уровне реализации это, видимо, самое разумное определение. Однако, в теоретическом аспекте, более точно определить корутины можно как конструкции, которые могут как принимать, так и возвращать значения – и то, и другое возможно в одном или нескольких местах программы.

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

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

Вот пример корутины:
def LineInserter(lines):
out = []
for line in lines:
to_append = yield line
out.append(line)
if to_append is not None:
out.append(to_append)
return out
emily = """I died for beauty, but was scarce
Adjusted in the tomb,
When one who died for truth was lain
In an adjoining room.
He questioned softly why I failed?
“For beauty,” I replied.
“And I for truth,—the two are one;
We brethren are,” he said.
And so, as kinsmen met a night,
We talked between the rooms,
Until the moss had reached our lips,
And covered up our names.
"""
inserter = LineInserter(iter(emily.splitlines()))
count = 1
try:
line = next(inserter)
while True:
line = next(inserter) if count % 4 else inserter.send('-------')
count += 1
except StopIteration as ex:
print('\n' + '\n'.join(ex.value))
(вывод в консоль: текст, разделённый '-------' через каждые 4 строки)
Объект LineInserter зовётся корутиной потому, что, в отличие от генератора, ключевое слово yield находится справа от оператора присваивания. Только и всего. Теперь, когда yield возвращает строку, объект сохраняет это значение, чтобы иметь возможность отправить его обратно в сопрограмму, прямиком в to_append.

Мы можем отправить значение обратно при помощи inserter.send в исполняющем коде (он находится под текстом поэмы, в спойлере). Если вместо этого использовать next(), то в to_append придёт None. И не спрашивайте меня, почему next – это функция, а send – метод, если они оба делают почти одно и то же!

Итак, вызов send успешно разделяет поэму Эмили Дикинсон (Emily Dickinson) на блоки из 4 строк в консольном выводе. Таким образом, сопрограммы могут предоставить дополнительный функционал, когда обычная «односторонняя» (one way) итерация не совсем подходит.

Код корутины мне очень нравится, но исполняющий её код можно улучшить. Недавно, я эмулировал функционал мененджера контекста contextlib.suppress для замены пункта except на callback-функционал:

Делаем замену в блоке try-except
def LineInserter(lines):
out = []
for line in lines:
to_append = yield line
out.append(line)
if to_append is not None:
out.append(to_append)
return out
from contextlib import contextmanager
@contextmanager
def generator_stop(callback):
try:
yield
except StopIteration as ex:
callback(ex.value)
def lines_complete(all_lines):
print('\n' + '\n'.join(all_lines))
emily = """ ТОТ ЖЕ ТЕКСТ ПОЭМЫ """
inserter = LineInserter(iter(emily.splitlines()))
count = 1
with generator_stop(lines_complete):
line = next(inserter)
while True:
line = next(inserter) if count % 4 else inserter.send('-------')
count += 1
Теперь отловом лишнего занимается generator_stop, и такой менеджер контекста можно использовать в самых разных ситуациях, когда необходимо обработать StopIteration.

Поскольку корутины и генераторы – практически одно и то же, они оба могли бы эмулировать работу функции. Мы даже можем вызвать одну и ту же корутину несколько раз, как если бы она была функцией:

Делаем корутину вызываемой:
def IncrementBy(increment):
sequence = yield
while True:
sequence = yield [i + increment for i in sequence]
sequence = [10, 20, 30]
increment_by_5 = IncrementBy(5)
increment_by_8 = IncrementBy(8)
next(increment_by_5)
next(increment_by_8)
print(increment_by_5.send(sequence))
print(increment_by_8.send(sequence))
print(increment_by_5.send(sequence))
(вывод в консоль: [15, 25, 35] при каждом вызове increment_by)
Обратите внимание на два вызова next(). Они эффективно «подготавливают» генератор, продвигая его к первому yield. Тогда каждый вызов send выглядит, по факту, как одиночный вызов функции. Исполняющий программу код не совсем похож на вызов функции, но секунду, сейчас мы применим чёрную магию декораторов:

Щепотка чёрной магии
def evil_coroutine(func):
def wrapper(*args, **kwargs):
gen = func(*args, **kwargs)
next(gen)
def gen_caller(arg=None):
return gen.send(arg)
return gen_caller
return wrapper
@evil_coroutine
def IncrementBy(increment):
sequence = yield
while True:
sequence = yield [i + increment for i in sequence]
sequence = [10, 20, 30]
increment_by_5 = IncrementBy(5)
increment_by_8 = IncrementBy(8)
print(increment_by_5(sequence))
print(increment_by_8(sequence))
print(increment_by_5(sequence))
Декоратор принимает функцию и возвращает новую – wrapper, которая назначается переменной IncrementBy. Теперь, каждый раз при вызове этой переменной, она собирает генератор, используя исходную функцию, и продвигает его к первому оператору yield, используя next. Он возвращает новую функцию, которая вызывает send у генератора каждый раз, когда он вызывается. Эта функция делает аргумент по умолчанию равным None, чтобы он также мог работать, если мы вызываем next вместо send.

Не смотря на очевидную читабельность исполняющего кода, я крайне не рекомендую использовать такой стиль кода, в котором корутины ведут себя как гибридные объекты \ функции. Аргумент, в котором другие программисты не смогут залезть вам в голову, остаётся в силе. Да и потом: send может принимать лишь один аргумент, и вызываемый объект весьма ограничен.

Прежде, чем мы закончим обсуждение, взглянем коротко на исполняющий код, который мог бы обработать строфу emily без использования корутин, генераторов, функций или объектов:

for index, line in enumerate(emily.splitlines(), start=1):

print(line)

if not index % 4:

print('------')

Максимально просто.

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

Конечно, для сопрограмм есть случаи, когда их применение - незаменимо..

Например, моделирование системы с переходом между состояниями; или выполнение асинхронной работы с библиотекой asyncio (которая оборачивает все возможные сумасшествия с помощью StopIteration, каскадных исключений и т.д.). Или – вот такие реализации, как выше, ради баловства.

Итак, стало ясно, что в Python существует не один способ сделать множество вещей. К счастью, большинство из них не так очевидны, и обычно есть «один, и желательно только один, очевидный способ сделать что-то», цитируя Дзен Python.

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


 
Сверху