Основы функционального программирования на Python

  • Tutorial

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

  • Принципы функционального программирования

  • Оператор lambda, функции map, filter, reduce и другие

  • Включение в последовательность

  • Замыкание

  • Рекомендации по ФП на языке Python

Принципы функционального программирования

КЛЮЧЕВЫЕ ПОЛОЖЕНИЯ:

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

В последние годы почти все известные процедурные и объектно-ориентированные языки программирования стали поддерживать средства функционального программирования (ФП). И язык Python не исключение.

Когда говорят о ФП, прежде всего имеют в виду следующее:

  • Функции – это «граждане более высокого сорта», т.е., все, что можно делать с «данными», можно делать и с функциями (в том числе передача функции другой функции в качестве аргумента).

  • Использование рекурсии в качестве основной структуры контроля потока управления. В некоторых языках не существует иной конструкции цикла, кроме рекурсии.

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

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

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

  • ФП акцентируется на том, что должно быть вычислено, а не как.

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

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

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

Оператор lambda, функции map, filter, reduce и другие

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

Оператор lambda

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

lambda список_аргументов: выражение

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

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

и

lambda x, y: x + y

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

>>> (lambda x, y: x+y)(5, 7)
12

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

>>> lambda_function = lambda x, y: x + y
>>> lambda_function(5,7)
12
>>> func = lambda_function
>>> func(3,4)
7
>>> dic = {'функция1': lambda_function}
>>> dic['функция1'](7,8)
15

Здесь в строке 1 определяется лямбда-функция и присваивается переменной, которая теперь ссылается на лямбда-функцию. В строке 2 она применяется с двумя аргументами. В строке 4 ссылка на эту функцию присваивается еще одной переменной, и затем пользуясь этой переменной данная функция вызывается еще раз. В строке 7 создается словарь, в котором в качестве значения задана ссылка на эту функцию, и затем, обратившись к этому значению по ключу, эта функция применяется в третий раз.

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

Функция map

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

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

map(функция, последовательности)

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

>>> seq = (1, 2, 3, 4, 5, 6, 7, 8, 9)
>>> seq2 = (5, 6, 7, 8, 9, 0, 3, 2, 1)
>>> result = map(lambda_function, seq, seq2)
>>> result
<map object at 0x000002897F7C5B38>
>>> list(result)
[6, 8, 10, 12, 14, 6, 10, 10, 10]

В приведенном выше интерактивном сеансе в строках 1 и 2 двум переменным, seq и seq2, присваиваются две итерируемые последовательности. В строке 3 переменной result присваивается результат применения функции map, в которую в качестве аргументов были переданы ранее определенная лямбда-функция и две последовательности. Обратите внимание, что функция map возвращает объект-последовательность map, о чем говорит строка 5. Особенность объекта-последовательности map состоит в том он может предоставлять свои элементы, только когда они требуются, используя ленивые вычисления. Ленивые вычисления – это стратегия вычисления, согласно которой вычисления следует откладывать до тех пор, пока не понадобится их результат. Программистам часто приходится обрабатывать последовательности, состоящие из десятков тысяч и даже миллионов элементов. Хранить их в оперативной памяти, когда в определенный момент нужен всего один элемент, не имеет никакого смысла. Ленивые вычисления позволяют генерировать ленивые последовательности, которые при обращении к ним предоставляют следующий элемент последовательности. Чтобы показать ленивую последовательность, в данном случае результат работы примера, необходимо эту последовательность «вычислить». В строке 6 объект map вычисляется во время преобразования в список.

Функция filter

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

filter(предикативная_функция, последовательность)

В данном формате предикативная_функция – это ссылка на стандартную функцию либо лямбда-функция, которая возвращает истину либо ложь, и последовательность – это итерируемая последовательность, т.е. список, кортеж, диапазон или строковые данные.

Например, ниже приведена однострочная функция is_even для определения четности числа:

is_even = lambda x: x % 2 == 0

Чтобы отфильтровать все числа последовательности и оставить только четные, применим функцию filter:

>>> seq = (1, 2, 3, 4, 5, 6, 7, 8, 9)
>>> filtered = filter(is_even, seq)
>>> list(filtered)
[2, 4, 6, 8]

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

>>> filtered = iter(filter(lambda x: x % 2 == 0, seq))
>>> list(filtered)
[2, 4, 6, 8]

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

>>> next(filtered)
2
>>> next(filtered)
4
...

Примечание. Для предотвращения выхода за пределы ленивой последовательности необходимо отслеживать возникновение ошибки StopIteration. Например,

seq = sequence
try:
    total = next(seq)
except StopIteration:
    return

Функция reduce

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

def reduce(fn, seq, initializer=None):
    it = iter(seq)
    value = next(it) if initializer is None else initializer
    for element in it:
        value = fn(value, element)
    return value

Вот общий формат функции reduce:

reduce(функция, последовательность, инициализатор)

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

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

>>> seq = (1, 2, 3, 4, 5, 6, 7, 8, 9)
>>> get_sum = lambda a, b: a + b
>>> summed_numbers = reduce(get_sum, seq)
>>> summed_numbers
45

Вот еще один пример. Если sentences – это список предложений, и требуется подсчитать общее количество слов в этих предложениях, то можно написать, как показано в приведенном ниже интерактивном сеансе:

>>> sentences = ["Варкалось.", 
>>> ...          "Хливкие шорьки пырялись по наве, и", 
>>> ...          "хрюкотали зелюки, как мюмзики в мове."]
>>> wsum = lambda aсс, sentence: aсс + len(sentence.split())
>>> number_of_words = reduce(wsum, sentences, 0)
>>> number_of_words
13

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

В чем преимущества функций более высокого порядка?

  • Они нередко состоят из одной строки.

  • Все важные компоненты итерации – объект-последовательность, операция и возвращаемое значение – находятся в одном месте.

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

  • Они представляются собой элементарные операции. Глядя на цикл for, приходится построчно отслеживать его логику. При этом в качестве опоры для создания своего понимания программного кода приходится отталкиваться от нескольких структурных закономерностей. Напротив, функции более высокого порядка одновременно являются строительными блоками, которые могут быть интегрированы в сложные алгоритмы, и элементами, которые читатель кода может мгновенно понять и резюмировать в своем уме. «Этот код преобразовывает каждый элемент в новую последовательность. Этот отбрасывает некоторые элементы. А этот объединяет оставшиеся элементы в единый результат».

  • Они имеют большое количество похожих функций, которые предоставляют возможности, которые служат дополнением к их основному поведению. Например, any, all или собственные их версии.

Приведем еще пару полезных функций.

Функция zip

Встроенная функция zip объединяет отдельные элементы из каждой последовательности в кортежи, т.е. она возвращает итерируемую последовательность, состоящую из кортежей. Вот общий формат функции zip:

zip(последовательность, последовательность, ...)

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

>>> x = 'абв'
>>> y = 'эюя'
>>> zipped = zip(x, y)
>>> list(zipped)
[('а', 'э'), ('б', 'ю'), ('в', 'я')]

В сочетании с оператором * эта функция используется для распаковки объединенной последовательности (в виде пар, троек и т.д.) в отдельные кортежи. Приведенный ниже интерактивный сеанс это демонстрирует:

>>> x2, y2 = zip(*zip(x, y))
>>> x2
('а', 'б', 'в')
>>> y2
('э', 'ю', 'я')
>>> x == ''.join(x2) and y == ''.join(y2)
True

Функция enumerate

Встроенная функция enumerate возвращает индекс элемента и сам элемент последовательности в качестве кортежа. Вот общий формат функции enumerate:

enumerate(последовательность)

В данном формате последовательность – это итерируемая последовательность, т.е. список, кортеж, диапазон или строковые данные. Функция enumerate возвращает ленивый объект-последовательность, который нужно вычислить, чтобы увидеть результат.

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

>>> lazy = enumerate(['а','б','в'])
>>> list(lazy)
[(0, 'а'), (1, 'б'), (2, 'в')]

В строке 2 применена функция list, которая преобразовывает ленивую последовательность в список. Функция enumerate также позволяет применять заданную функцию к каждому элементу последовательности с учетом индекса:

>>> convert = lambda tup: tup[1].upper() + str(tup[0])
>>> lazy = map(convert, enumerate(['а','б','в']))
>>> list(lazy)
['А0', 'Б1', 'В2']

Функция convert в строке 1 переводит строковое значение второго элемента кортежа в верхний регистр и присоединяет к нему преобразованное в строковый тип значение первого элемента. Здесь tup – это кортеж, в котором tup[0] – это индекс элемента, и tup[1] – строковое значение элемента.

Включение в последовательность

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

squared_numbers = [x*x for x in numbers]

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

[выражение for переменная in список if выражение2]

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

>>> numbers = [1, 2, 3, 4, 5]
>>> squared_numbers = [x*x for x in numbers]
>>> squared_numbers
[1, 4, 9, 16, 25]

Приведенное выше включение в список эквивалентно следующему ниже фрагменту программного кода:

>>> squared_numbers = []
>>> for x in numbers:
>>>     squared_numbers.append(x*x)
>>> squared_numbers
[1, 4, 9, 16, 25]

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

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

Таблица 1. Формы описания интенсионала

Выражение

Описание

[x*x for x in numbers]

Описание списка

{x:x*x for x in numbers}

Описание словаря

{x*x for x in numbers}

set(x*x for x in numbers)

Описание множества

(x*x for x in numbers)

Описание последовательности. Такая форма записи создает генератор последовательности. Генератор – это объект, который можно последовательно обойти (обычно при помощи инструкции for), но чьи значения предоставляются только тогда, когда они требуются, используя ленивое вычисление.

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

Таким образом, примеры из разделов о функциях map и filter легко можно переписать с использованием включения в последовательность. Например, в строке 3 приведенного ниже интерактивного сеанса вместо функции map применена операция включения в список:

>>> seq = (1, 2, 3, 4, 5, 6, 7, 8, 9)
>>> seq2 = (5, 6, 7, 8, 9, 0, 3, 2, 1)
>>> result = [x + y for x, y in zip(seq, seq2)]
>>> result
[6, 8, 10, 12, 14, 6, 10, 10, 10]

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

Включение в список применено и в приведенном ниже примере вместо функции filter:

>>> result = [x for x in seq if is_even(x)]
>>> result
[2, 4, 6, 8]

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

Замыкание

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

Используя замыкания, можно разделить исполнение функции со многими аргументами на большее количество шагов. Эта операция называется каррированием и обязана своим названием Хаскелю Каррингу. Каррирование – это преобразование функции многих аргументов в функцию, берущую свои аргументы по одному. Например, предположим, ваш программный код имеет приведенную ниже стандартную функцию adder:

def adder(n, m):
    return n + m

Чтобы сделать ее каррированной, она должна быть переписана следующим образом:

def adder(n):
    def fn(m):
        return n + m
    return fn

Это же самое можно выразить при помощи лямбда-функций:

adder = lambda n: lambda m: n + m

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

>>> sum_three = adder(3)
>>> sum_three
<function __main__.<lambda>.<locals>.<lambda>>
>>> sum_three(1)
4

В приведенном выше примере каррированная функция adder(3) присваивается переменной sum_three, которая теперь на нее ссылается. Если вызвать функцию sum_three, передав ей второй аргумент, то она вернет результат сложения двух аргументов 3 и 1.

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

def power_generator(base):
    return lambda x: pow(x, base)

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

>>> square = power_generator(2)  # функция возведения в квадрат
>>> square(2)
4
>>> cube = power_generator(3)    # функция возведения в куб
>>> cube(2)
8

Отметим, что функции square и cube сохраняют значение переменной base. Эта переменная существовала только в среде power_generator, несмотря на то, что эти возвращенные функции абсолютно независимы от функции power_generator. Напомним еще раз: замыкание – это функция, которая имеет доступ к некоторым переменным за пределами своей собственной среды.

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

COUNT = 0

def count_add(x):
    global COUNT
    COUNT += x
    return COUNT

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

def make_adder():
    n = 0    

    def fn(x):
        nonlocal n
        n += x
        return n

    return fn

Такой подход позволяет создавать несколько счетчиков без применения глобальных переменных. Обратите внимание, что в этом примере использовано ключевое слово nonlocal, которое объявляет, что переменная n не является локальной для вложенной функции fn. В приведенном ниже интерактивном сеансе показано, как это работает:

>>> my_adder = make_adder()
>>> print(my_adder(5))     # напечатает 5
>>> print(my_adder(2))     # напечатает 7 (5 + 2)
>>> print(my_adder(3))     # напечатает 10 (5 + 2 + 3)

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

Рекомендации по ФП на языке Python

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

Программирование в функциональном стиле не тождественно функциональному программированию.

Что делает функции нечистыми?

  • Глобальные мутации, т.е. внесение изменений в глобальное состояние,

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

  • Операции ввода-вывода.

Пример глобальной мутации:

def append_one(xs):
    xs.append(1)
	return xs
	
xs = []
print(append_one(xs))  # Результат: [1]
print(append_one(xs))  # Результат: [1, 1]

Пример недетерминированности:

import random

print(random.random())
# Результат: 0.2334551699781765
print(random.random())
# Результат: 0.7073046734532323

Пример операции ввода-вывода:

with open('temp.txt', 'w') as f:
    f.write('Hello!')
	
with open('temp.txt', 'r') as f:
    print(f.read())  # Результат: Hello!
	
with open('temp.txt', 'w') as f:
    f.write('Hi!')
	
with open('temp.txt', 'r') as f:
    print(f.read())  # Результат: Hi!

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

Ссылочная прозрачность (1) улучшает тестопригодность программ, т.е. поведение подпрограмм не зависит от контекста, повторный запуск приложения дает одинаковый результаты как следствие отсутствия мутаций, (2) обеспечивается модульность, т.е. поведение функций не зависит от контекста, и чистые функции можно легко составлять в композиции, строя новые формы поведений, (3) упрощает обеспечение конкурентности из-за отсутствия необходимости в синхронизации, т.к. отсутствие совместных мутируемых данных делает синхронизацию ненужной.

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

Эффективное функциональное программирование на Python вполне возможно.

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

  • Правило композиции - строить программы так, чтобы иметь возможность легко их соединять с другими программами.

  • Правило модульности - писать простые части, которые можно соединять чистыми интерфейсами.

Указанные выше два простых правила делают ненужными архитектурные шаблоны и принципы ООП, заменяя их функциями! А что, спросите вы, и классы тоже? В Python использование классов не противоречит ФП, если в них отсутствует мутирующие интерфейсы.

Пример класса с мутирующим интерфейсом:

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age  = age

    def bark(self):
        print('bark bark!')   # !!

    def birthday(self):
        self.age += 1         # !!

    def setbuddy(self, buddy):
        self.buddy = buddy    # !!

Пример класса без мутирующего интерфейса:

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age  = age

    def description(self):
        return f'{self.name} is {sound} years old'   

    def speak(self):
        return f'{self.name} says {sound}'

Но лучше использовать замороженные dataclasses и копирование, где необходимо. Иными словами, все классы должны быть замороженными dataclasses.

from dataclasses import dataclass

@dataclass(frozen=True)
class Position:
    name: str
 	  lon:  float = 0.0
	  lat:  float = 0.0
	
>>> pos = Position('Oslo', 10.8, 59.9)
>>> pos.name
'Oslo'
>>> pos.name = 'Stockholm'
dataclasses.FrozenInstanceError: cannot assign to field 'name'

При всем при этом dataclasses могут быть вполне себе умными!

@dataclass(frozen=True)
class Coord:
    row: int
    col: int

    def to_tupple(self) -> Tuple[int, int]:
        return self.row, self.col

    @classmethod
    def from_str(cls, str_coord: str) -> 'Coord':
        xl_row, xl_col = coordinate_ro_tuple(str_coord)
        return cls(xl_row - 1, xl_col - 1)

    def shift_down(self, shift: int) -> 'Coord':
        return self.__class__(self.row + shift, self.col)

Также следует использовать сторонние функциональные библиотеки (например, toolz), которые обеспечивают более оптимальную композиционность функций.

from toolz import pipe

# h(g(f(data))) --> pipe(data, f, g, h)

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

И применять архитектурный шаблон «немутируемое ядро - мутируемая оболочка» (aka «функциональное ядро - императивная оболочка»), который позволяет выносить мутацию во вне и производить ее на границах приложения.

Выводы

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

Данный пост служит дополнением к моему предыдущему посту о конвейере данных. Приведенный выше материал был опубликован в качестве авторского в переводе книги Starting Out with Python и дополнен материалами Энтони Хвона.

Средняя зарплата в IT

120 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 9 072 анкет, за 1-ое пол. 2021 года Узнать свою зарплату
Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 39

    0
    Жутковато выглядит
    lambda_function = lambda x, y: x + y
    lambda_function(5,7)
    
      +1
      Да еще и PEP8 нарушает
        –2
        Применение def-функций в некоторых случаях не работает в стиле ФП.
          0
          и вот этому «ужасному» и «нарушающему» в книжке учат детей. автор что и в соседней статье про пайтон а там он честно признался что публикует главы своей книжки тут в качестве бесплатной рекламы
            0
            «в качестве бесплатной рекламы» — это ваши домыслы.

            А вот свой авторский материал я считаю интересным, вот и хотел им поделиться, и надо сказать, что некоторым он нравится.
              0
              нравится или нет — это вопрос личного отношения. оценка "-1" к статье показывает что пока не понравилось в среднем большему количеству людей.

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

              учебник должен учить правильному. если вам хочется творческой самореализации — то придумайте свои примеры и задачи. но синтаксис это святое и часть языка, а вы именно в него свои творческие видения запускаете. да PEP8 это формально свод рекомендаций, а не жестких правил, но именно это позволяет потом другим людям читать код и не плеваться в сторону автора. и так полно говнокода кругом, а вы еще учебник пишете о том как его сделать больше
                +3
                PEP8 — это рекомендация, НЕ догма. ФП — это парадигма, которая, кстати, поддерживается языком. Если вы не хотите заниматься ФП, то вам не сюда.

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

                Не стоит предписывать мне, что мне делать. Я — свободный человек, как и вы, надеюсь.

                Вы выступаете тут, как окончательный арбитр. Не кажется ли вам, что такая роль — ущербна?
            0

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

              +1
              Скажите, а причем тут отладчик, если речь об исполнимом коде, т.е. причем тут время отладки, когда речь о времени исполнения?
                0

                неверно выразился, не отладчик, а для отладки,
                правило же чисто декоративное — вывод имени функции в трассировке

          +3

          В питоне у функциональных подходов очень много минусов и поэтому почти никто в питоне их не применяет.


          Я сам пришёл в Питон с языка где функциональные подходы были естественными и первое что начал — применять их в Питоне. И затем спустя какое-то время… отказался от этого.


          Вместо фильтров, мапов, теперь использую генераторы [ x for x in bla if foo], {x: y for x, y in bla} итп


          Проблемы:


          • Выравнивание кода отступами.

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


          Как правило запись генератора куда компактнее каскада map-filter-lambda


          • у питона нет лямбда функций. Только лямбда-оператор. То есть простую функцию из двух-пяти строк в лямбде не описать. Да и выравнивание тут снова мешает. То есть если в другом языке например для сортировки вполне применяли лямбду, то тут — нет


          • функциональные функции (сори за тафталогию) часто возвращают итераторы. Часто это крайне неудобно и нужны полноценные списки. Приходится оборачивать их в list и это получается громоздко и генератор выигрывает:



          >>> a = [1,2,3]
          >>> filter(lambda x: x > 2, a)
          <filter object at 0x7f821f693b00>
          >>> list(filter(lambda x: x > 2, a))
          [3]
          >>> [x for x in a if x > 2]
          [3]

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

            0
            Но почему же редко. Все зависит. Как вам такое, например? Обратите внимание на конвейеры (про них в предыдущем посте)

            # Множественная диспетчеризация (мультиметод)
            
            import pandas as pd
            
            class Data:
                uk, uk_scrbd, ru = range(3)
                
            def load_data(identity):
                '''имплементация мультиметода на Python; загружает
                данные в зависимости от значения идентификатора'''
                return {
                    Data.uk: lambda: do('ch01/UK2010.xls', 
                                        pd.read_excel 
                                       ),
                    Data.ru: lambda: do('ch01/UK2010.xls', 
                                        pd.read_excel, 
                                        lambda o: o[o['Election Year'].notnull()]
                                       )
                }[identity]()
            
            load_data(Data.uk)
              0

              самописный конвейер может быть хорош, почему бы и нет. Хотя бы потому что он возвращает значение.


              а вот map(filter плох итератором (выше я говорил)


              PS: у Вас неплохие статьи, непонятно почему в минусах

                +1
                Благодарю за оценку))
                Дело в том, что в первых двух постах я поднял важную тему отсутствия правильного названия для ML, и что оно выбивается из общемировой парадигмы. Для хорошей эволюции требуется хорошее потрясение. Если я их потряс, то я справился), но получил ответку)))
                  0
                  В минусах — за оформление кода. Прошлые варианты были просто вырвиглаз. Я два дня подождал, пока читать начал, хотя поначалу пытался в IDE копировать и сам руками форматировать.
                    0
                    Ушло немного времени, чтобы освоиться.

                    Можно было просто подсказать. Это и называется отсутствием сотрудничества.
                0
                Часто это крайне неудобно и нужны полноценные списки.

                Сударь, из какого языка вы пришли? Генераторы (вероятно это что, что имеется ввиду) — это ленивый подход, и он предпочтителен в ФП. Да даже в технически императивном C# подобные функции возвращают итерируемые ленивые объекты, а вовсе не окончательный массив или список. А вы хотите, чтобы за вас мапы и фильтры создавали новый список?

                  –1

                  эм, какая разница откуда я пришёл, если именно в питоне часто нужны полноценные списки?


                  часто функция (в т.ч. библиотечная) принимает список, а не итератор.


                  а пришел я из perl


                  А вы хотите, чтобы за вас мапы и фильтры создавали новый список?

                  ага
                  причем если собирают ковейер из map,filter, итп, то пусть бы генерировал
                  а в итоге должен быть список

                    0
                    часто функция (в т.ч. библиотечная) принимает список, а не итератор.

                    Это очень странно, обычно принимают генераторы. Но если это так — то есть на то причины, и это никак не проблема map/filter/и т д!


                    ага
                    причем если собирают ковейер из map,filter, итп, то пусть бы генерировал
                    а в итоге должен быть список

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


                    def naturals():
                        i = 0
                        while (True):
                            i += 1
                            yield i
                    
                    # Вот здесь твоя идея дает бесконечный список и все умирает.
                    even_naturals = filter(lambda x: x % 2 == 0, naturals())
                    
                    for n in even_naturals:
                        print(n)
                        if n > 5:
                            break

                    Но попробуй запустить этот код в питоне, и он отработает так, как ожидается.

                      0
                      Но если это так — то есть на то причины, и это никак не проблема map/filter/и т д!

                      не проблема map/filter, но map/filter становится обязательным применять совместно с list


                      def naturals():

                      только в реальной жизни программировать генератор натуральных чисел нафиг не надо


                      Вот здесь твоя идея дает бесконечный список и все умирает

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


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


                      поэтому в библиотеках списки файлов, хостов итп как правило принимаются диктами/листами а не итераторами

                        0

                        Вот в перле и программируй свою бухгалтерию, и файлы свои забери.) Мы здесь о высоких материях.)

                          0

                          так бухгалтерию или склад гораздо сложнее запрограммировать, нежели ряд чисел

                0

                next(filter) — опечатка?

                  0
                  Это ошибка. Материал писался 3 года назад. Все работало.
                  Должно быть так:
                  >>> filtered = iter(filter(lambda x: x % 2 == 0, seq))
                  

                  А потом вот так:
                  >>> next(filtered)
                  2
                  >>> next(filtered)
                  4

                  Благодарю, что подметили. Текст исправлен.
                  +1
                  Нормальное и практически полное описание «ФП на питоне» было довольно давно сделано в «междельмашевских» материалах.

                  Всё, что было позже на русском языке, обычно сводится к упоминанию map, filter, reduce без контекста, почему именно они выступают примером ФП.

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

                  Но данный материал не решает основной проблемы — как и зачем этим пользоваться?
                  И это проблема «синтетических» примеров. Кто немного в теме, тому материал не очень интересен, и так читано перечитано. Новичок, мало чего поймёт, кроме фишек в виде list comprehension, которые он и так скорее всего знает безотносительно ФП.
                    –1
                    Вы смотрели пост про функциональный конвейер? Он представляет собой прекрасный пример практического применения ФП на практике. При доработке он может содержать ветвления — все в функциональном стиле. Помимо этого, у меня есть примеры имплементаций моделей ML в виде конвейеров, простенькой экспертной системы и прочие вкусности типа графического интерфейса)) Хотел было продолжить серию. Будет время сделаю.

                    Кстати, обратите внимание на преимуществе конвейера в отладке кода — все оченнннно получается модульно.

                    И еще, этот материал не переводной, авторский)

                    Что касается list comprehension, то в английском питоновском языке еще есть dict comprehension и set comprehension, поэтому держитесь подальше от «спискового включения» ибо близоруко, т.к. dict и set по русски атрибутивно у вас не получится. Лучше говорить включение в список, в словарь, в множество
                    +2

                    А зачем вы добавили этот и предыдущий посты в хабы F# и Clojure? Из-за одного примера конвейера на F#? Думаю, не стоит засорять хабы материалом, не имеющим к их темам отношения.

                      0
                      Резонно. Но темы коррелированы, а людей в ФП мало.
                        +1

                        Думаю, пользователи, посещающие F# и Clojure, наверняка заходят на Функциональное программирование. Кроме того, ещё есть Haskell, Scala, Erlang, Elm, Lisp.


                        а людей в ФП мало

                        Да, это правда

                          0
                          Python крут! Он дает кучу возможностей отстрелить себе ногу почесав ухо. Так что функциональные фичи Python нужно применять с осторожностью. Примерно на уровне регулярных выражений. Когда нет альтернативы и когда альтернатива требует кучи кода, да и пожалуй все. Работая с Python надо помнить про duck typing. Даже такой кривой итератор как в примере ниже, работает и с map, и с filter, и с reduce.
                          from functools import reduce
                          from random import randrange
                          
                          
                          class MyIterator():
                          
                              def __iter__(self):
                                  return self
                          
                              def __next__(self):
                                  result = randrange(10)
                                  print("Generated:", result)
                                  if result == 0:
                                      raise StopIteration()
                                  return result
                          
                          
                          if __name__ == '__main__':
                          
                              my_iterator = MyIterator()
                          
                              print("Map example call:", list(map(lambda x: x, my_iterator)))
                              print("Filter example call:", list(filter(lambda x: x % 2 == 0, my_iterator)))
                              print("Reduce example call:", reduce(lambda acc, x: acc + x, my_iterator, 0))
                          

                          Правда, добавляет перчинки:
                          ...
                              seq = (1,2,3,4,5,6,7,8,9)
                              mapped_static = map(lambda x: x, seq)
                              print("First call of mapped static:", list(mapped_static))
                              print("Second call of mapped static:", list(mapped_static))
                          
                              my_iterator = MyIterator()
                              mapped_dynamic = map(lambda x: x, my_iterator)
                              print("First call of mapped dynamic:", list(mapped_dynamic))
                              print("Second call of mapped dynamic:", list(mapped_dynamic))
                          

                          Результат будет вроде такого:
                          ...
                          First call of mapped static: [1, 2, 3, 4, 5, 6, 7, 8, 9]
                          Second call of mapped static: []
                          Generated: 2
                          Generated: 6
                          Generated: 6
                          Generated: 9
                          Generated: 8
                          Generated: 2
                          Generated: 6
                          Generated: 5
                          Generated: 7
                          Generated: 5
                          Generated: 4
                          Generated: 4
                          Generated: 0
                          First call of mapped dynamic: [2, 6, 6, 9, 8, 2, 6, 5, 7, 5, 4, 4]
                          Generated: 6
                          Generated: 5
                          Generated: 1
                          Generated: 1
                          Generated: 6
                          Generated: 8
                          Generated: 0
                          Second call of mapped dynamic: [6, 5, 1, 1, 6, 8]

                          Не то чтобы ужас-ужас, но неприятно.

                          А уж что там на самом деле за объект в более-менее сложном проекте — это вопрос на миллион. Иногда смотришь на код — вроде обычный for item in some_list:, но присмотревшись к этому some_list понимаешь, что там внутри нифига не список. А нечто навороченное, читающее данные из очереди сообщений в дополнительных потоках, с вычиткой данных наперед и буферизацией в памяти. Ну или какой-нибудь словарь с конфигом. Присматриваешься — а там Содом с Гоморрой с получением данных из переменных окружения, локальных конфигурационных файлов и базы данных. Все это смешивается, но не взбалтывается и время от времени перечитывается. Но при этом по большей части снаружи выглядит как обычный дикт. А меньшая часть — иногда стреляет, даря незабываемые ощущения батхерта на вроде бы ровном месте.
                          Впрочем, проблем может доставить даже изменение типа с тапла на лист.

                          Так что функциональные возможности Python знать нужно, но применять — только четко понимая, что ты делаешь и к каким последствиям это приведет.
                            0
                            Как известно, из любой конфетки можно сделать «не то». )
                            Однако, пример — показателен.
                            0
                            Вот очень неплохая библиотека github.com/kachayev/fn.py с недостающими структурами данных и функциями, рекомендую.
                              0
                              Благодарю;)
                              0

                              Зачем этой статье метка F#, если в статье ни слова об этом? Поставил минус — это вероятно у меня единственный способ хоть как-то повлиять на спам меток в статьях у подобных хайпожоров.

                                0
                                На портале «хабр» у меня зарегистрирована моя страница. Вы являетесь на мою страницу и прилюдно начинаете обзываться. Так кто тут «хайпожер» тогда?

                                Поясняю, я указал две дополнительные метки: F# и clojure, чтобы привлечь внимание ф-программистов, в ситуации когда материалов по ФП и так мало, в надежде, что кто-то из смежных областей заинтересуется. Читайте внимательнее заголовки…

                                По поводу минуса. Это все претензии? А по существу темы будут? Мне поставили двойку по «матану», потому что я пришел без пиджака ;))
                                  +1

                                  Спасибо, но я тщательно выбрал какие метки я хочу читать, а все остальные "я хочу привлечь из смежных областей" расцениваю как хайп. Мне не нужны другие метки и меня вполне устраивает пяток статей по теме вместо сотен ненужных мне статей. Вы не цените моё время — я ставлю минус вашей статье. Других претензий у меня нет, я не планирую даже вчитываться и терять своё время на чтение ненужных мне материалов. Пусть статью по существу оценивают участники из профильных меток, я же просто голосую ногами против спама меток. Не надо за меня решать, какие бы ещё непрофильные статьи мне воткнуть в голову и отобрать время — тут и так хватает рекламных статей, которым просто откровенно наплевать на тематичность — но если вы как программист считаете, что вам с ними по пути — ну так и получайте минусы. Если вы почитаете мои предыдущие комментарии — я вполне последователен в своём мнении, это не первый комментарий подобного рода. Не хотите? Жалко времени?


                                  Ну так вот из-за таких как вы мне приходится после чтения заголовка делать уточнение, есть ли в тексте хоть одно упоминание F# — и если его нет, то уж извините. У меня свои принципы, я в карму не ставлю минусов, но уж если мне кажется, что кто-то планировал отнять моё время — пусть разменивается на минус. В конце концов, в предыдущей статье вы привели пример на F# — и я не стал минус ставить.


                                  Хотите привлечь внимание из смежных меток — ну здорово! Придумывайте интересные сравнения нескольких языков, но блин, когда вообще ни одного упоминания — имейте совесть!

                                    –2
                                    Самодовольство, напыщенность, надменность, высокомерие, «не читал, но осуждаю» — уникальная коллекция комплексов. Какой замечательный экземпляр для психоаналитика…
                                      0

                                      Можете продолжать навешивать ярлыки: вам факты — вы ярлык, вам идею, как улучшить статьи — вы ярлык, вам по делу — вы ярлык. Не вижу смысла продолжать разговор с человеком который развешивает на окружающих ярлык "придурки", "уроды" и подобное.

                                        0
                                        Ни разу никого не обзывал и не собираюсь. Более того, ни разу никого не унижал и тоже не собираюсь. И не следует все ставить с ног на голову. Подумайте лучше о правилах приличного поведения в научпоп сообществе, прежде чем писать комменты.

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

                              Самое читаемое