Spec-Zone.ru › Python 3.12

Руководство по функциональному программированию

Автор:

А. М. Кухлинг

Версия:

0.32

В этом документе мы рассмотрим возможности Python, подходящие для реализации программ в функциональном стиле. После введения в концепции функционального программирования мы рассмотрим языковые особенности, такие как итераторы и генераторы, а также соответствующие библиотечные модули, такие как itertools и functools.

Введение

В этом разделе объясняется основная концепция функционального программирования; если вас интересуют только языковые возможности Python, переходите к следующему разделу про Итераторы.

Языки программирования поддерживают разбиение задач различными способами:

  • Большинство языков программирования являются процедурными: программы представляют собой списки инструкций, которые сообщают компьютеру, что делать с входными данными программы. C, Pascal и даже оболочки Unix являются процедурными языками.
  • В декларативных языках вы записываете спецификацию, описывающую решаемую задачу, а реализация языка определяет, как выполнить вычисления эффективно. SQL — это декларативный язык, с которым вы, скорее всего, знакомы; запрос SQL описывает набор данных, который вы хотите получить, а движок SQL определяет, следует ли сканировать таблицы или использовать индексы, какие подзапросы следует выполнять первыми и т. д.
  • Программы на объектно-ориентированных языках манипулируют наборами объектов. Объекты имеют внутреннее состояние и поддерживают методы, которые запросом или изменяют это внутреннее состояние каким-то образом. Smalltalk и Java являются объектно-ориентированными языками. C++ и Python — это языки, поддерживающие объектно-ориентированное программирование, но не навязывают использование объектно-ориентированных возможностей.
  • В функциональном программировании задача разбивается на набор функций. В идеале функции принимают только входные данные и генерируют выходные данные, и у них нет внутреннего состояния, влияющего на выходные данные для данного входного значения. Известные функциональные языки включают семейство ML (Standard ML, OCaml и другие варианты) и Haskell.

Разработчики некоторых языков программирования выбирают упор на один определенный подход к программированию. Это часто затрудняет написание программ, использующих другой подход. Другие языки являются многопарадигменными языками, которые поддерживают несколько различных подходов. Lisp, C++ и Python являются многопарадигменными; вы можете писать программы или библиотеки, которые в значительной степени являются процедурными, объектно-ориентированными или функциональными на всех этих языках. В большой программе разные части могут быть написаны с использованием различных подходов; например, графический интерфейс пользователя может быть объектно-ориентированным, в то время как логика обработки является процедурной или функциональной.

В функциональной программе входные данные проходят через набор функций. Каждая функция обрабатывает входные данные и генерирует выходные данные. Функциональный стиль не поощряет функции со побочными эффектами, которые изменяют внутреннее состояние или вносят другие изменения, которые не видны в возвращаемом значении функции. Функции, у которых вообще нет побочных эффектов, называются чисто функциональными. Избегание побочных эффектов означает отказ от использования структур данных, которые обновляются по мере выполнения программы; выходные данные каждой функции должны зависеть только от её входных данных.

Некоторые языки очень строго относятся к чистоте и даже не имеют операторов присваивания, таких как a=3 или c = a + b, но трудно избежать всех побочных эффектов, таких как вывод на экран или запись в файл. Другой пример — вызов функции print() или time.sleep(), ни одна из которых не возвращает полезного значения. Обе вызываются только для своих побочных эффектов — отправки текста на экран или приостановки выполнения на секунду.

Программы Python, написанные в функциональном стиле, обычно не будут доходить до крайности, чтобы избегать всей ввода-вывода или всех присваиваний; вместо этого они будут предоставлять функциональный интерфейс, но будут использовать нефункциональные функции внутри. Например, реализация функции все равно будет использовать присваивания локальным переменным, но не будет изменять глобальные переменные или иметь другие побочные эффекты.

Функциональное программирование можно рассматривать как противоположность объектно-ориентированному программированию. Объекты — это небольшие капсулы, содержащие внутреннее состояние вместе с набором вызовов методов, которые позволяют вам изменять это состояние, а программы состоят из выполнения правильного набора изменений состояния. Функциональное программирование стремится по возможности избегать изменений состояния и работает с данными, передаваемыми между функциями. В Python вы можете объединить оба подхода, написав функции, которые принимают и возвращают экземпляры, представляющие объекты в вашем приложении (электронные письма, транзакции и т. д.).

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

  • Формальная доказуемость.
  • Модульность.
  • Композиционность.
  • Простота отладки и тестирования.

Формальная доказуемость

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

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

Метод, используемый для доказательства правильности программ, заключается в записи инвариантов, свойств входных данных и переменных программы, которые всегда верны. Для каждой строки кода вы затем показываете, что если инварианты X и Y верны до выполнения строки, несколько измененные инварианты X’ и Y’ верны после выполнения строки. Это продолжается до достижения конца программы, в котором инварианты должны соответствовать желаемым условиям выходных данных программы.

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

К сожалению, доказательство правильности программ в значительной степени непрактично и не актуально для программного обеспечения Python. Даже тривиальные программы требуют доказательств, которые занимают несколько страниц; доказательство корректности умеренно сложной программы было бы огромным, и мало или ни одна из программ, которые вы используете ежедневно (интерпретатор Python, ваш анализатор XML, ваш веб-браузер), не может быть доказана корректной. Даже если бы вы записали или сгенерировали доказательство, тогда возник бы вопрос о проверке доказательства; возможно, в нем есть ошибка, и вы ошибочно считаете, что доказали правильность программы.

Модульность

Более практическая выгода функционального программирования заключается в том, что это заставляет вас разбивать вашу проблему на небольшие части. Программы в результате становятся более модульными. Легче указать и написать небольшую функцию, выполняющую одно действие, чем одну большую функцию, выполняющую сложное преобразование. Маленькие функции также легче читать и проверять на ошибки.

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

Тестирование и отладка программы в функциональном стиле проще.

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

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

Композиционность

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

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

Итераторы

Начну с рассмотрения важной для функционального программирования особенности языка Python: итераторы.

Итератор — это объект, представляющий поток данных; этот объект возвращает данные по одному элементу за раз. Итератор Python должен поддерживать метод под названием __next__(), который не принимает аргументов и всегда возвращает следующий элемент потока. Если в потоке больше нет элементов, __next__() должен поднять исключение StopIteration. Итераторы необязательно должны быть конечными; вполне допустимо написать итератор, генерирующий бесконечный поток данных.

Встроенная функция iter() принимает произвольный объект и пытается вернуть итератор, который вернёт содержимое или элементы объекта, подняв TypeError, если объект не поддерживает итерацию. Несколько встроенных типов данных Python поддерживают итерацию, наиболее распространённые из которых — списки и словари. Объект называется итерируемым, если для него можно получить итератор.

Вы можете поэкспериментировать с интерфейсом итерации вручную:

>>> L = [1, 2, 3]
>>> it = iter(L)
>>> it  
<...iterator object at ...>
>>> it.__next__()  # same as next(it)
1
>>> next(it)
2
>>> next(it)
3
>>> next(it)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>

Python ожидает итерируемые объекты в нескольких контекстах, самым важным из которых является оператор for. В операторе for X in Y Y должен быть итератором или каким-либо объектом, для которого iter() может создать итератор. Эти два оператора эквивалентны:

for i in iter(obj):
    print(i)

for i in obj:
    print(i)

Итераторы можно материализовать в виде списков или кортежей, используя конструкторы list() или tuple():

>>> L = [1, 2, 3]
>>> iterator = iter(L)
>>> t = tuple(iterator)
>>> t
(1, 2, 3)

Распаковка последовательностей также поддерживает итераторы: если вы знаете, что итератор вернёт N элементов, вы можете распаковать их в N-кортеж:

>>> L = [1, 2, 3]
>>> iterator = iter(L)
>>> a, b, c = iterator
>>> a, b, c
(1, 2, 3)

Встроенные функции, такие как max() и min(), могут принять один аргумент-итератор и вернуть наибольший или наименьший элемент. Операторы "in" и "not in" также поддерживают итераторы: X in iterator истинно, если X найден в потоке, возвращаемом итератором. Вы столкнётесь с очевидными проблемами, если итератор бесконечен; max(), min() никогда не вернутся, и если элемент X никогда не появится в потоке, операторы "in" и "not in" тоже не вернут ничего.

Обратите внимание, что вы можете перемещаться только вперёд по итератору; нет способа получить предыдущий элемент, сбросить итератор или сделать его копию. Объекты итераторов могут по желанию предоставлять эти дополнительные возможности, но протокол итератора определяет только метод __next__(). Поэтому функции могут потреблять весь выходной поток итератора, и если вам нужно сделать что-то другое с тем же потоком, вам придётся создать новый итератор.

Типы данных, поддерживающие итераторы

Мы уже видели, как списки и кортежи поддерживают итераторы. Фактически, любой тип последовательности Python, такой как строки, автоматически поддерживает создание итератора.

Вызов iter() для словаря возвращает итератор, который переберёт ключи словаря:

>>> m = {'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
...      'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12}
>>> for key in m:
...     print(key, m[key])
Jan 1
Feb 2
Mar 3
Apr 4
May 5
Jun 6
Jul 7
Aug 8
Sep 9
Oct 10
Nov 11
Dec 12

Обратите внимание, что начиная с Python 3.7, порядок итерации словарей гарантированно совпадает с порядком вставки. В более ранних версиях поведение было неопределённым и могло меняться в разных реализациях.

Применение iter() к словарю всегда перебирает ключи, но словари имеют методы, которые возвращают другие итераторы. Если вы хотите перебрать значения или пары ключ/значение, вы можете явно вызвать методы values() или items(), чтобы получить соответствующий итератор.

Конструктор dict() может принять итератор, возвращающий конечный поток (key, value) кортежей:

>>> L = [('Italy', 'Rome'), ('France', 'Paris'), ('US', 'Washington DC')]
>>> dict(iter(L))
{'Italy': 'Rome', 'France': 'Paris', 'US': 'Washington DC'}

Файлы также поддерживают итерацию путём вызова метода readline() до тех пор, пока в файле не останется строк. Это означает, что вы можете прочитать каждую строку файла так:

for line in file:
    # do something for each line
    ...

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

>>> S = {2, 3, 5, 7, 11, 13}
>>> for i in S:
...     print(i)
2
3
5
7
11
13

Выражения-генераторы и списковые включения

Две распространённые операции над выходными данными итератора — 1) выполнение какой-либо операции для каждого элемента, 2) выбор подмножества элементов, удовлетворяющих некоторому условию. Например, если у вас есть список строк, вы можете захотеть удалить из каждой строки пробелы в конце или извлечь все строки, содержащие заданную подстроку.

Списковые включения и выражения-генераторы (короткая форма: «списковые включения» и «выражения-генераторы») — это краткая запись для таких операций, позаимствованная из функционального языка программирования Haskell (https://www.haskell.org/). Вы можете удалить все пробелы из потока строк следующим кодом:

>>> line_list = ['  line 1\n', 'line 2  \n', ' \n', '']

>>> # Generator expression -- returns iterator
>>> stripped_iter = (line.strip() for line in line_list)

>>> # List comprehension -- returns list
>>> stripped_list = [line.strip() for line in line_list]

Вы можете выбрать только определённые элементы, добавив "if" условие:

>>> stripped_list = [line.strip() for line in line_list
...                  if line != ""]

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

Выражения-генераторы заключены в скобки («()»), а списковые включения — в квадратные скобки («[]»). Выражения-генераторы имеют вид:

( expression for expr in sequence1
             if condition1
             for expr2 in sequence2
             if condition2
             for expr3 in sequence3
             ...
             if condition3
             for exprN in sequenceN
             if conditionN )

Опять же, для спискового включения отличаются только внешние скобки (квадратные скобки вместо круглых).

Элементами генерируемого вывода будут последовательные значения expression. if — все необязательные; если присутствуют, то expression оценивается и добавляется к результату только тогда, когда condition равно true.

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

obj_total = sum(obj.count for obj in list_all_objects())

for...in содержат последовательности, по которым нужно выполнить итерацию. Последовательности необязательно должны быть одинаковой длины, так как они перебираются слева направо, а не параллельно. Для каждого элемента в sequence1 sequence2 перебирается с начала. sequence3 перебирается для каждой полученной пары элементов из sequence1 и sequence2.

Другими словами, списковое включение или выражение-генератор эквивалентны следующему коду Python:

for expr1 in sequence1:
    if not (condition1):
        continue   # Skip this element
    for expr2 in sequence2:
        if not (condition2):
            continue   # Skip this element
        ...
        for exprN in sequenceN:
            if not (conditionN):
                continue   # Skip this element

            # Output the value of
            # the expression.

Это означает, что когда есть несколько for...in, но нет if, длина результирующего вывода равна произведению длин всех последовательностей. Если у вас есть два списка длиной 3, вывод будет содержать 9 элементов:

>>> seq1 = 'abc'
>>> seq2 = (1, 2, 3)
>>> [(x, y) for x in seq1 for y in seq2]  
[('a', 1), ('a', 2), ('a', 3),
 ('b', 1), ('b', 2), ('b', 3),
 ('c', 1), ('c', 2), ('c', 3)]

Чтобы избежать неоднозначности в грамматике Python, если expression создаёт кортеж, он должен быть заключён в скобки. Первое списковое включение ниже является синтаксической ошибкой, тогда как второе — правильное:

# Syntax error
[x, y for x in seq1 for y in seq2]
# Correct
[(x, y) for x in seq1 for y in seq2]

Генераторы

Генераторы — это особый класс функций, упрощающий задачу написания итераторов. Обычные функции вычисляют значение и возвращают его, но генераторы возвращают итератор, который возвращает поток значений.

Вы, несомненно, знакомы с тем, как работают обычные вызовы функций в Python или C. При вызове функции создаётся закрытое пространство имён, где создаются её локальные переменные. Когда функция достигает оператора return, локальные переменные уничтожаются, а значение возвращается вызывающей стороне. Повторный вызов той же функции создаёт новое закрытое пространство имён и новый набор локальных переменных. Но что, если локальные переменные не удалялись при выходе из функции? Что, если можно было бы позже продолжить выполнение функции с того места, где она остановилась? Именно это обеспечивают генераторы; их можно рассматривать как возобновляемые функции.

Вот самый простой пример функции-генератора:

>>> def generate_ints(N):
...    for i in range(N):
...        yield i

Любая функция, содержащая ключевое слово yield, является функцией-генератором; это определяется компилятором байткода Python, который компилирует функцию особым образом в результате.

При вызове функции-генератора она не возвращает одно значение; вместо этого она возвращает объект генератора, который поддерживает протокол итератора. При выполнении выражения yield генератор выводит значение i, аналогично оператору return. Главное отличие между yield и оператором return заключается в том, что при достижении оператора yield состояние выполнения генератора приостанавливается, а локальные переменные сохраняются. При следующем вызове метода генератора __next__(), функция возобновит выполнение.

Вот пример использования генератора generate_ints():

>>> gen = generate_ints(3)
>>> gen  
<generator object generate_ints at ...>
>>> next(gen)
0
>>> next(gen)
1
>>> next(gen)
2
>>> next(gen)
Traceback (most recent call last):
  File "stdin", line 1, in <module>
  File "stdin", line 2, in generate_ints
StopIteration

Вы могли бы также написать for i in generate_ints(5) или a, b, c = generate_ints(3).

Внутри функции-генератора return value вызывает StopIteration(value), который генерируется методом __next__(). После этого или по достижении конца функции поток значений завершается, и генератор не может выдать больше значений.

Вы могли бы добиться эффекта генераторов вручную, написав свой собственный класс и сохранив все локальные переменные генератора в качестве свойств экземпляра. Например, для возвращения списка целых чисел можно установить self.count в 0 и заставить метод __next__() инкрементировать self.count и вернуть его. Однако для достаточно сложного генератора написание соответствующего класса может быть намного более громоздким.

Тестовый набор, включённый в библиотеку Python, Lib/test/test_generators.py, содержит ряд более интересных примеров. Вот один генератор, который реализует обход дерева в порядке обхода в глубину, используя рекурсивные генераторы.

# A recursive generator that generates Tree leaves in in-order.
def inorder(t):
    if t:
        for x in inorder(t.left):
            yield x

        yield t.label

        for x in inorder(t.right):
            yield x

Два других примера в test_generators.py дают решения задачи N ферзей (размещение N ферзей на шахматной доске NxN так, чтобы ни один ферзь не угрожал другому) и задачи конного тура (поиск маршрута, который перемещает коня по каждой клетке шахматной доски NxN, не посещая одну и ту же клетку дважды).

Передача значений в генератор

В Python 2.4 и более ранних версиях генераторы только производили выходные данные. После вызова кода генератора для создания итератора не было способа передать новую информацию в функцию при возобновлении её выполнения. Можно было бы искусственно добавить эту возможность, заставив генератор обращаться к глобальной переменной или передать некоторый изменяемый объект, который вызывающие стороны затем модифицируют, но эти подходы неудобны.

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

val = (yield i)

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

(PEP 342 объясняет точные правила, заключающиеся в том, что выражение yield всегда должно быть заключено в скобки, за исключением случаев, когда оно является выражением верхнего уровня в правой части оператора присваивания. Это означает, что вы можете написать val = yield i, но должны использовать скобки, когда есть операция, как в val = (yield i) + 12.)

Значения передаются в генератор путём вызова его метода send(value). Этот метод возобновляет код генератора, а выражение yield возвращает указанное значение. Если вызывается обычный метод __next__(), yield возвращает None.

Вот простой счётчик, который увеличивается на 1 и позволяет изменить значение внутреннего счётчика.

def counter(maximum):
    i = 0
    while i < maximum:
        val = (yield i)
        # If value provided, change counter
        if val is not None:
            i = val
        else:
            i += 1

И вот пример изменения счётчика:

>>> it = counter(10)  
>>> next(it)  
0
>>> next(it)  
1
>>> it.send(8)  
8
>>> next(it)  
9
>>> next(it)  
Traceback (most recent call last):
  File "t.py", line 15, in <module>
    it.next()
StopIteration

Поскольку yield часто возвращает None, вы всегда должны проверять этот случай. Не используйте его значение в выражениях, если не уверены, что метод send() будет единственным методом, используемым для возобновления вашей функции-генератора.

Помимо send(), существуют ещё два метода для генераторов:

  • throw(value) используется для поднятия исключения внутри генератора; исключение поднимается выражением yield, где приостанавливается выполнение генератора.
  • close() поднимает исключение GeneratorExit внутри генератора, чтобы завершить итерацию. Получив это исключение, код генератора должен либо поднять GeneratorExit, либо StopIteration; перехват исключения и выполнение чего-либо ещё является незаконным и вызовет RuntimeError. close() также будет вызван сборщиком мусора Python при сборе мусора генератора.

    Если вам нужно выполнить код очистки при возникновении GeneratorExit, я предлагаю использовать блок try: ... finally: вместо перехвата GeneratorExit.

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

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

Встроенные функции

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

Две встроенные функции Python, map() и filter() дублируют возможности генераторных выражений:

map(f, iterA, iterB, ...) returns an iterator over the sequence

f(iterA[0], iterB[0]), f(iterA[1], iterB[1]), f(iterA[2], iterB[2]), ....

>>> def upper(s):
...     return s.upper()
>>> list(map(upper, ['sentence', 'fragment']))
['SENTENCE', 'FRAGMENT']
>>> [upper(s) for s in ['sentence', 'fragment']]
['SENTENCE', 'FRAGMENT']

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

filter(predicate, iter) возвращает итератор по всем элементам последовательности, удовлетворяющим определенному условию, и аналогичным образом дублируется списковыми включениями. **Предикат** — это функция, которая возвращает логическое значение какого-либо условия; для использования с filter() предикат должен принимать одно значение.

>>> def is_even(x):
...     return (x % 2) == 0
>>> list(filter(is_even, range(10)))
[0, 2, 4, 6, 8]

Это также можно записать как списковое включение:

>>> list(x for x in range(10) if is_even(x))
[0, 2, 4, 6, 8]

enumerate(iter, start=0) нумерует элементы в итерируемом объекте, возвращая кортежи длиной 2, содержащие счётчик (от start) и каждый элемент.

>>> for item in enumerate(['subject', 'verb', 'object']):
...     print(item)
(0, 'subject')
(1, 'verb')
(2, 'object')

enumerate() часто используется при проходе по списку и регистрации индексов, в которых выполняются определённые условия:

f = open('data.txt', 'r')
for i, line in enumerate(f):
    if line.strip() == '':
        print('Blank line at line #%i' % i)

sorted(iterable, key=None, reverse=False) собирает все элементы итерируемого объекта в список, сортирует список и возвращает отсортированный результат. Аргументы key и reverse передаются в метод sort() созданного списка.

>>> import random
>>> # Generate 8 random numbers between [0, 10000)
>>> rand_list = random.sample(range(10000), 8)
>>> rand_list  
[769, 7953, 9828, 6431, 8442, 9878, 6213, 2207]
>>> sorted(rand_list)  
[769, 2207, 6213, 6431, 7953, 8442, 9828, 9878]
>>> sorted(rand_list, reverse=True)  
[9878, 9828, 8442, 7953, 6431, 6213, 2207, 769]

(Для более подробного обсуждения сортировки см. Методы сортировки.)

Встроенные функции any(iter) и all(iter) рассматривают логические значения содержимого итерируемого объекта. any() возвращает True, если любой элемент в итерируемом объекте имеет значение «истина», и all() возвращает True, если все элементы имеют значение «истина»:

>>> any([0, 1, 0])
True
>>> any([0, 0, 0])
False
>>> any([1, 1, 1])
True
>>> all([0, 1, 0])
False
>>> all([0, 0, 0])
False
>>> all([1, 1, 1])
True

zip(iterA, iterB, ...) берёт по одному элементу из каждого итерируемого объекта и возвращает их в кортеже:

zip(['a', 'b', 'c'], (1, 2, 3)) =>
  ('a', 1), ('b', 2), ('c', 3)

Она не создаёт список в памяти и не извлекает все входные итераторы, прежде чем вернуть результат; вместо этого кортежи строятся и возвращаются только при запросе. (Технический термин для этого поведения — ленивая оценка.)

Этот итератор предназначен для использования с итерируемыми объектами одинаковой длины. Если итерируемые объекты имеют различную длину, результирующий поток будет иметь длину самого короткого итерируемого объекта.

zip(['a', 'b'], (1, 2, 3)) =>
  ('a', 1), ('b', 2)

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

Модуль itertools

Модуль itertools содержит ряд часто используемых итераторов, а также функции для объединения нескольких итераторов. Этот раздел представит содержимое модуля с помощью небольших примеров.

Функции модуля можно разделить на несколько основных категорий:

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

Создание новых итераторов

itertools.count(start, step) возвращает бесконечный поток равномерно распределённых значений. Вы можете дополнительно указать начальное число (по умолчанию 0) и интервал между числами (по умолчанию 1):

itertools.count() =>
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...
itertools.count(10) =>
  10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ...
itertools.count(10, 5) =>
  10, 15, 20, 25, 30, 35, 40, 45, 50, 55, ...

itertools.cycle(iter) сохраняет копию содержимого переданного итерируемого объекта и возвращает новый итератор, который возвращает его элементы от первого до последнего. Новый итератор будет повторять эти элементы бесконечно.

itertools.cycle([1, 2, 3, 4, 5]) =>
  1, 2, 3, 4, 5, 1, 2, 3, 4, 5, ...

itertools.repeat(elem, [n]) возвращает заданный элемент n раз или возвращает элемент бесконечно, если n не указано.

itertools.repeat('abc') =>
  abc, abc, abc, abc, abc, abc, abc, abc, abc, abc, ...
itertools.repeat('abc', 5) =>
  abc, abc, abc, abc, abc

itertools.chain(iterA, iterB, ...) принимает произвольное количество итерируемых объектов в качестве входных данных и возвращает все элементы первого итератора, затем все элементы второго и так далее, пока все итерируемые объекты не будут исчерпаны.

itertools.chain(['a', 'b', 'c'], (1, 2, 3)) =>
  a, b, c, 1, 2, 3

itertools.islice(iter, [start], stop, [step]) возвращает поток, являющийся слайсом итератора. С одним аргументом stop, он вернёт первые stop элементов. Если вы укажите начальный индекс, вы получите stop-start элементов, а если вы укажете значение для step, элементы будут пропускаться соответствующим образом. В отличие от срезов строк и списков в Python, вы не можете использовать отрицательные значения для start, stop или step.

itertools.islice(range(10), 8) =>
  0, 1, 2, 3, 4, 5, 6, 7
itertools.islice(range(10), 2, 8) =>
  2, 3, 4, 5, 6, 7
itertools.islice(range(10), 2, 8, 2) =>
  2, 4, 6

itertools.tee(iter, [n]) дублирует итератор; он возвращает n независимых итераторов, которые все будут возвращать содержимое исходного итератора. Если вы не укажете значение для n, по умолчанию оно равно 2. Дублирование итераторов требует сохранения части содержимого исходного итератора, поэтому это может потребовать значительного объёма памяти, если итератор большой, а один из новых итераторов используется больше, чем другие.

itertools.tee( itertools.count() ) =>
   iterA, iterB

where iterA ->
   0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...

and   iterB ->
   0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...

Вызов функций над элементами

Модуль operator содержит набор функций, соответствующих операторам Python. Некоторые примеры: operator.add(a, b) (сложение двух значений), operator.ne(a, b) (то же, что и a != b), и operator.attrgetter('id') (возвращает вызываемый объект, который извлекает .id атрибут).

itertools.starmap(func, iter) предполагает, что итерируемый объект будет возвращать поток кортежей и вызывает func, используя эти кортежи в качестве аргументов:

itertools.starmap(os.path.join,
                  [('/bin', 'python'), ('/usr', 'bin', 'java'),
                   ('/usr', 'bin', 'perl'), ('/usr', 'bin', 'ruby')])
=>
  /bin/python, /usr/bin/java, /usr/bin/perl, /usr/bin/ruby

Выбор элементов

Другая группа функций выбирает подмножество элементов итератора на основе предиката.

itertools.filterfalse(predicate, iter) является противоположностью filter(), возвращая все элементы, для которых предикат возвращает ложь:

itertools.filterfalse(is_even, itertools.count()) =>
  1, 3, 5, 7, 9, 11, 13, 15, ...

itertools.takewhile(predicate, iter) возвращает элементы до тех пор, пока предикат возвращает истину. Как только предикат вернёт ложь, итератор сообщит об окончании своих результатов.

def less_than_10(x):
    return x < 10

itertools.takewhile(less_than_10, itertools.count()) =>
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9

itertools.takewhile(is_even, itertools.count()) =>
  0

itertools.dropwhile(predicate, iter) отбрасывает элементы, пока предикат возвращает истину, а затем возвращает остальную часть результатов итератора.

itertools.dropwhile(less_than_10, itertools.count()) =>
  10, 11, 12, 13, 14, 15, 16, 17, 18, 19, ...

itertools.dropwhile(is_even, itertools.count()) =>
  1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...

itertools.compress(data, selectors) принимает два итератора и возвращает только те элементы data, для которых соответствующий элемент selectors имеет значение истина, останавливаясь, когда один из них исчерпан:

itertools.compress([1, 2, 3, 4, 5], [True, True, False, False, True]) =>
   1, 2, 5

Комбинаторные функции

Функция itertools.combinations(iterable, r) возвращает итератор, который даёт все возможные комбинации кортежей длины r элементов, содержащихся в iterable.

itertools.combinations([1, 2, 3, 4, 5], 2) =>
  (1, 2), (1, 3), (1, 4), (1, 5),
  (2, 3), (2, 4), (2, 5),
  (3, 4), (3, 5),
  (4, 5)

itertools.combinations([1, 2, 3, 4, 5], 3) =>
  (1, 2, 3), (1, 2, 4), (1, 2, 5), (1, 3, 4), (1, 3, 5), (1, 4, 5),
  (2, 3, 4), (2, 3, 5), (2, 4, 5),
  (3, 4, 5)

Элементы внутри каждого кортежа сохраняют тот же порядок, что и iterable. Например, число 1 всегда стоит перед 2, 3, 4 или 5 в примерах выше. Похожая функция itertools.permutations(iterable, r=None) снимает это ограничение на порядок, возвращая все возможные расположения длины r:

itertools.permutations([1, 2, 3, 4, 5], 2) =>
  (1, 2), (1, 3), (1, 4), (1, 5),
  (2, 1), (2, 3), (2, 4), (2, 5),
  (3, 1), (3, 2), (3, 4), (3, 5),
  (4, 1), (4, 2), (4, 3), (4, 5),
  (5, 1), (5, 2), (5, 3), (5, 4)

itertools.permutations([1, 2, 3, 4, 5]) =>
  (1, 2, 3, 4, 5), (1, 2, 3, 5, 4), (1, 2, 4, 3, 5),
  ...
  (5, 4, 3, 2, 1)

Если вы не укажете значение для r, используется длина итерируемого объекта, что означает, что все элементы будут перестановки.

Обратите внимание, что эти функции производят все возможные комбинации по положению и не требуют, чтобы содержимое iterable были уникальными:

itertools.permutations('aba', 3) =>
  ('a', 'b', 'a'), ('a', 'a', 'b'), ('b', 'a', 'a'),
  ('b', 'a', 'a'), ('a', 'a', 'b'), ('a', 'b', 'a')

Идентичный кортеж ('a', 'a', 'b') встречается дважды, но две строки 'a' взяты из разных позиций.

Функция itertools.combinations_with_replacement(iterable, r) ослабляет другое ограничение: элементы могут повторяться в одном кортеже. Понятийно, элемент выбирается для первой позиции каждого кортежа, а затем заменяется перед выбором второго элемента.

itertools.combinations_with_replacement([1, 2, 3, 4, 5], 2) =>
  (1, 1), (1, 2), (1, 3), (1, 4), (1, 5),
  (2, 2), (2, 3), (2, 4), (2, 5),
  (3, 3), (3, 4), (3, 5),
  (4, 4), (4, 5),
  (5, 5)

Группировка элементов

Последняя функция, о которой я расскажу, itertools.groupby(iter, key_func=None), является наиболее сложной. key_func(elem) — это функция, которая может вычислить значение ключа для каждого элемента, возвращаемого итерируемым объектом. Если вы не укажете функцию ключа, ключ — это просто каждый элемент сам по себе.

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

city_list = [('Decatur', 'AL'), ('Huntsville', 'AL'), ('Selma', 'AL'),
             ('Anchorage', 'AK'), ('Nome', 'AK'),
             ('Flagstaff', 'AZ'), ('Phoenix', 'AZ'), ('Tucson', 'AZ'),
             ...
            ]

def get_state(city_state):
    return city_state[1]

itertools.groupby(city_list, get_state) =>
  ('AL', iterator-1),
  ('AK', iterator-2),
  ('AZ', iterator-3), ...

where
iterator-1 =>
  ('Decatur', 'AL'), ('Huntsville', 'AL'), ('Selma', 'AL')
iterator-2 =>
  ('Anchorage', 'AK'), ('Nome', 'AK')
iterator-3 =>
  ('Flagstaff', 'AZ'), ('Phoenix', 'AZ'), ('Tucson', 'AZ')

groupby() предполагает, что содержимое базового итерируемого объекта уже отсортированы по ключу. Обратите внимание, что возвращаемые итераторы также используют базовый итерируемый объект, поэтому вы должны израсходовать результаты итератора-1 перед запросом итератора-2 и его соответствующего ключа.

Модуль functools

Модуль functools содержит некоторые высшего порядка функции. Функция высшего порядка принимает на вход одну или несколько функций и возвращает новую функцию. Наиболее полезным инструментом в этом модуле является функция functools.partial().

Для программ, написанных в функциональном стиле, иногда требуется создать варианты существующих функций, в которых некоторые параметры заполнены. Рассмотрим функцию Python f(a, b, c); возможно, вы захотите создать новую функцию g(b, c), которая эквивалентна f(1, b, c); вы заполняете значение для одного из параметров f(). Это называется «частичным применением функции».

Конструктор partial() принимает аргументы (function, arg1, arg2, ..., kwarg1=value1, kwarg2=value2). Результирующий объект является вызываемым, поэтому вы можете просто вызвать его, чтобы вызвать function с заполненными аргументами.

Вот небольшой, но реалистичный пример:

import functools

def log(message, subsystem):
    """Write the contents of 'message' to the specified subsystem."""
    print('%s: %s' % (subsystem, message))
    ...

server_log = functools.partial(log, subsystem='server')
server_log('Unable to open socket')

functools.reduce(func, iter, [initial_value]) кумулятивно выполняет операцию над всеми элементами итерируемого объекта и поэтому не может быть применена к бесконечным итерируемым объектам. func должна быть функцией, которая принимает два элемента и возвращает одно значение. functools.reduce() берет первые два элемента A и B, возвращаемые итератором, и вычисляет func(A, B). Затем он запрашивает третий элемент C, вычисляет func(func(A, B), C), объединяет этот результат с четвёртым возвращаемым элементом и продолжает до тех пор, пока итерируемый объект не будет исчерпан. Если итерируемый объект вообще не вернёт никаких значений, будет возбуждено исключение TypeError. Если начальное значение указано, оно используется в качестве отправной точки, и func(initial_value, A) - первое вычисление.

>>> import operator, functools
>>> functools.reduce(operator.concat, ['A', 'BB', 'C'])
'ABBC'
>>> functools.reduce(operator.concat, [])
Traceback (most recent call last):
  ...
TypeError: reduce() of empty sequence with no initial value
>>> functools.reduce(operator.mul, [1, 2, 3], 1)
6
>>> functools.reduce(operator.mul, [], 1)
1

Если вы используете operator.add() с functools.reduce(), вы сложите все элементы итерируемого объекта. Этот случай настолько распространён, что существует специальная встроенная функция sum() для его вычисления:

>>> import functools, operator
>>> functools.reduce(operator.add, [1, 2, 3, 4], 0)
10
>>> sum([1, 2, 3, 4])
10
>>> sum([])
0

Однако для многих случаев использования functools.reduce() яснее просто написать явный цикл for:

import functools
# Instead of:
product = functools.reduce(operator.mul, [1, 2, 3], 1)

# You can write:
product = 1
for i in [1, 2, 3]:
    product *= i

Связанная функция - itertools.accumulate(iterable, func=operator.add). Она выполняет то же самое вычисление, но вместо возвращения только конечного результата, accumulate() возвращает итератор, который также выдает каждый промежуточный результат:

itertools.accumulate([1, 2, 3, 4, 5]) =>
  1, 3, 6, 10, 15

itertools.accumulate([1, 2, 3, 4, 5], operator.mul) =>
  1, 2, 6, 24, 120

Модуль operator

Модуль operator был упомянут ранее. Он содержит набор функций, соответствующих операторам Python. Эти функции часто полезны в функциональном стиле кода, так как они избавляют от необходимости писать тривиальные функции, выполняющие одну операцию.

Некоторые из функций в этом модуле:

  • Математические операции: add(), sub(), mul(), floordiv(), abs(), …
  • Логические операции: not_(), truth().
  • Битовые операции: and_(), or_(), invert().
  • Сравнения: eq(), ne(), lt(), le(), gt() и ge().
  • Идентификация объектов: is_(), is_not().

Для получения полного списка обратитесь к документации модуля operator.

Небольшие функции и выражение lambda

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

Если есть встроенная функция Python или функция модуля, подходящая для этой цели, вам вообще не нужно определять новую функцию:

stripped_lines = [line.strip() for line in lines]
existing_files = filter(os.path.exists, file_list)

Если нужная вам функция отсутствует, вам нужно её написать. Один из способов написать небольшие функции — использовать выражение lambda. lambda принимает несколько параметров и выражение, объединяющее эти параметры, и создаёт анонимную функцию, которая возвращает значение выражения:

adder = lambda x, y: x+y

print_assign = lambda name, value: name + '=' + str(value)

Альтернатива — просто использовать инструкцию def и определить функцию обычным способом:

def adder(x, y):
    return x + y

def print_assign(name, value):
    return name + '=' + str(value)

Какой вариант предпочтительнее? Это вопрос стиля; я обычно избегаю использования lambda.

Одна из причин моего предпочтения заключается в том, что lambda весьма ограничен в определении функций. Результат должен вычисляться как одно выражение, что означает, что у вас не может быть многосторонних if... elif... else сравнений или инструкций try... except. Если вы попытаетесь сделать слишком много в инструкции lambda, вы получите слишком сложное выражение, которое трудно читать. Быстро, что делает следующий код?

import functools
total = functools.reduce(lambda a, b: (0, a[1] + b[1]), items)[1]

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

import functools
def combine(a, b):
    return 0, a[1] + b[1]

total = functools.reduce(combine, items)[1]

Но лучше всего было бы просто использовать цикл for:

total = 0
for a, b in items:
    total += b

Или встроенную функцию sum() и выражение-генератор:

total = sum(b for a, b in items)

Многие случаи использования functools.reduce() понятнее при написании в виде циклов for.

Фредрик Лундх однажды предложил следующий набор правил для рефакторинга случаев использования lambda:

  1. Напишите функцию lambda.
  2. Напишите комментарий, объясняющий, что делает эта функция lambda.
  3. Поизучайте комментарий некоторое время и придумайте имя, которое отражает суть комментария.
  4. Преобразуйте lambda в инструкцию def, используя это имя.
  5. Удалите комментарий.

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

История изменений и благодарности

Автор хотел бы поблагодарить следующих людей за предложения, исправления и помощь в различных черновиках этой статьи: Ian Bicking, Nick Coghlan, Nick Efford, Raymond Hettinger, Jim Jewett, Mike Krell, Leandro Lameiro, Jussi Salmela, Collin Winter, Blake Winton.

Версия 0.1: опубликована 30 июня 2006 года.

Версия 0.11: опубликована 1 июля 2006 года. Исправлены опечатки.

Версия 0.2: опубликована 10 июля 2006 года. Объединены разделы genexp и listcomp в один. Исправлены опечатки.

Версия 0.21: Добавлены ссылки, предложенные на списке рассылки tutor.

Версия 0.30: Добавлена секция о модуле functional, написанный Collin Winter; добавлена короткая секция о модуле operator; несколько других исправлений.

Ссылки

Общие

Структура и интерпретация компьютерных программ, написанная Гарольдом Абельсоном и Джеральдом Джеем Суссманом с Джули Суссман. Книга доступна по адресу https://mitpress.mit.edu/sicp. В этой классической книге по информатике в главах 2 и 3 обсуждается использование последовательностей и потоков для организации потока данных внутри программы. В книге используется Scheme для примеров, но многие подходы к проектированию, описанные в этих главах, применимы к функциональному стилю кода Python.

https://www.defmacro.org/ramblings/fp.html: Общее введение в функциональное программирование с примерами на Java и подробным историческим введением.

https://en.wikipedia.org/wiki/Functional_programming: Общая статья Википедии, описывающая функциональное программирование.

https://en.wikipedia.org/wiki/Coroutine: Статья о сопрограммах.

https://en.wikipedia.org/wiki/Partial_application: Статья о концепции частичного применения функции.

https://en.wikipedia.org/wiki/Currying: Статья о концепции каррирования.

Специфичные для Python

https://gnosis.cx/TPiP/: Первая глава книги Дэвида Мерца Text Processing in Python посвящена функциональному программированию для обработки текста в разделе «Использование функций высшего порядка в обработке текста».

Мерц также написал серию статей из 3 частей о функциональном программировании для сайта IBM DeveloperWorks; см. часть 1, часть 2 и часть 3,

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

Документация для модуля itertools.

Документация для модуля functools.

Документация для модуля operator.

PEP 289: «Выражения генераторов»

PEP 342: «Сопрограммы через расширенные генераторы» описывает новые возможности генераторов в Python 2.5.

© 2001–2024 Python Software Foundation
Licensed under the PSF License.
https://docs.python.org/3.12/howto/functional.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API