Spec-Zone.ru › Python 3.12

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

Автор:

A. M. Kuchling

Выпуск:

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) выбор подмножества элементов, удовлетворяющих определенному условию. Например, имея список строк, можно удалить конечные пробелы в каждой строке или извлечь все строки, содержащие заданную подстроку.

Включения списков и генераторные выражения (кратко: «listcomps» и «genexps») — это лаконичная запись таких операций, заимствованная из функционального языка программирования 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 истинно.

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

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. Python также вызывает close(), когда сборщик мусора удаляет генератор.

    Если при возникновении 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) возвращает итератор, выдающий все возможные комбинации элементов iterable по r элементов.

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() предполагает, что содержимое базового итерируемого объекта уже отсортировано по ключу. Обратите внимание: возвращаемые итераторы также используют базовый итерируемый объект, поэтому результаты iterator-1 нужно обработать до того, как запрашивать iterator-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().

Небольшие функции и лямбда-выражение

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

Если для этого подходит встроенная функция 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. Напишите лямбда-функцию.
  2. Напишите комментарий, объясняющий, что вообще делает эта лямбда-функция.
  3. Некоторое время обдумывайте комментарий и придумайте имя, отражающее его суть.
  4. Преобразуйте лямбда-функцию в инструкцию def, используя это имя.
  5. Удалите комментарий.

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

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

Автор хотел бы поблагодарить следующих людей за предложения, исправления и помощь с различными черновиками этой статьи: 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 года. Разделы о генераторных выражениях и включениях списков объединены. Исправлены опечатки.

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

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

Ссылки

Общие

«Структура и интерпретация компьютерных программ», авторы — Harold Abelson и Gerald Jay Sussman при участии Julie Sussman. Книга доступна по адресу 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/: в первой главе книги David Mertz Text Processing in Python рассматривается функциональное программирование при обработке текста, в разделе «Использование функций высшего порядка при обработке текста».

Mertz также написал серию из трёх статей о функциональном программировании для сайта DeveloperWorks компании IBM; см. часть 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