Spec-Zone.ru › Python 3.14

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

Автор:

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

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 iterable 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().

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

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

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

Автор хотел бы поблагодарить следующих людей за предложения, исправления и помощь при подготовке различных вариантов этой статьи: Иэн Биккинг, Ник Коглан, Ник Эффорд, Рэймонд Хеттингер, Джим Джуэтт, Майк Крелл, Леандро Ламейро, Юсси Салмела, Коллин Уинтер и Блейк Уинтон.

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

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

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

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

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

Ссылки

Общие сведения

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

https://defmacro.org/2006/06/19/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 обсуждается функциональное программирование для обработки текста, в разделе «Применение функций высшего порядка при обработке текста».

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

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

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

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

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

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

PEP 342: «Корутины с помощью расширенных генераторов» описывает новые возможности генераторов в Python 2.5.

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

Spec-Zone.ru

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