Лучшие практики использования функционального программирования в Python

Оглавление

Введение

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

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

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

Что такое функциональное программирование?

Функциональное программирование, или ФП, - это парадигма кодирования, в которой строительными блоками являются неизменяемые значения и "чистые функции", не имеющие общего состояния с другими функциями. Каждый раз, когда чистая функция имеет заданный вход, она возвращает один и тот же выход - без изменения данных или возникновения побочных эффектов. В этом смысле чистые функции часто сравнивают с математическими операциями. Например, 3 плюс 4 всегда будет равно 7, независимо от того, какие другие математические операции выполняются, или сколько раз вы уже складывали что-то вместе.

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

Хотя функциональное программирование существует с 1950-х годов и реализовано в длинном ряду языков, оно не полностью описывает язык программирования. Clojure, Common Lisp, Haskell и OCaml - все это функционально-первобытные языки с различными позициями в отношении других концепций языка программирования, таких как система типов, строгая или ленивая оценка. Большинство из них также поддерживают побочные эффекты, такие как запись в файлы и чтение из них тем или иным способом - обычно все они очень тщательно помечены как нечистые.

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

Что поддерживает Python?

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

def add(a, b):
    return a + b

plus = add

plus(3, 4)  # returns 7

Lambda

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

(lambda a, b: a + b)(3, 4)  # returns 7

addition = lambda a, b: a + b
addition(3, 4)  # returns 7

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

authors = ['Octavia Butler', 'Isaac Asimov', 'Neal Stephenson', 'Margaret Atwood', 'Usula K Le Guin', 'Ray Bradbury']
sorted(authors, key=len)  # Returns list ordered by length of author name
sorted(authors, key=lambda name: name.split()[-1])  # Returns list ordered alphabetically by last name.

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

Functools

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

val = [1, 2, 3, 4, 5, 6]

# Multiply every item by two
list(map(lambda x: x * 2, val)) # [2, 4, 6, 8, 10, 12]
# Take the factorial by multiplying the value so far to the next item
reduce(lambda: x, y: x * y, val, 1) # 1 * 1 * 2 * 3 * 4 * 5 * 6

Существует куча других функций более высокого порядка, которые манипулируют функциями другими способами, в частности partial, которая фиксирует некоторые параметры функции. Это также известно как "currying", термин, названный в честь пионера FP Хаскелла Карри.

def power(base, exp):
     return base ** exp
cube = partial(power, exp=3)
cube(5)  # returns 125

Для подробного обзора вводных концепций FP в Python, написанных так, как их использовал бы функционально-первобытный язык, я рекомендую статью Мэри Роуз Кук здесь.

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

Декораторы

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

def retry(func):
    def retried_function(*args, **kwargs):
        exc = None
        for _ in range(3):
            try:
               return func(*args, **kwargs)
            except Exception as exc:
               print("Exception raised while calling %s with args:%s, kwargs: %s. Retrying" % (func, args, kwargs).

        raise exc
     return retried_function

@retry
def do_something_risky():
    ...

retried_function = retry(do_something_risky)  # No need to use `@`

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

Как и многие промежуточные или продвинутые техники Python, эта очень мощная и часто запутанная. Имя функции, которую вы вызвали, будет отличаться от имени в трассировке стека, если вы не используете декоратор functools.wraps для аннотации. Я видел, как декораторы делают очень сложные или важные вещи, например, разбирают значения из json-блобов или обрабатывают аутентификацию. Я также видел несколько уровней декораторов на одном и том же определении функции или метода, для понимания которых требуется знание приложения декоратора. Я думаю, что может быть полезно использовать встроенные декораторы, такие как `staticmethod`, или писать простые, четко названные декораторы, которые избавляют от большого количества шаблонов, но особенно если вы хотите, чтобы ваш код был совместим с проверкой типов, все, что изменяет входные или выходные типы, может легко перейти в разряд "слишком умных".

Мои рекомендации

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

  • Для того чтобы начать использовать Python, не обязательно понимать FP. Скорее всего, вы запутаете других читателей или свое будущее "я".
  • У вас нет гарантии, что любой код, на который вы полагаетесь (модули pip или код ваших коллег), является функциональным и чистым. Вы также не знаете, является ли ваш собственный код настолько чистым, насколько вы надеетесь - в отличие от функционально-первобытных языков, синтаксис или компилятор не помогают обеспечить чистоту и устранить некоторые типы ошибок. Смешение побочных эффектов и функций более высокого уровня может быть чрезвычайно запутанным, потому что в итоге вы получаете два вида сложности, о которых нужно рассуждать, а затем мультипликативный эффект от их совместного использования.
  • Использование функций высшего порядка с комментариями типов - это продвинутый навык. Сигнатуры типов часто становятся длинными и громоздкими гнездами Callable. Например, правильный способ типизации простого декоратора высшего порядка, который возвращает входную функцию, заключается в объявлении F = TypeVar[‘F’, bound=Callable[..., Any]], а затем аннотировании как def transparent(func: F) -> F: return func. Или вы можете поддаться искушению и использовать Any вместо того, чтобы пытаться выяснить правильную сигнатуру.

Итак, какие части функционального программирования следует использовать?

Чистые функции

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

Вот нефункциональный пример.

dictionary = ['fox', 'boss', 'orange', 'toes', 'fairy', 'cup']
def puralize(words):
   for i in range(len(words)):
       word = words[i]
       if word.endswith('s') or word.endswith('x'):
           word += 'es'
       if word.endswith('y'):
           word = word[:-1] + 'ies'
       else:
           word += 's'
       words[i] = word

def test_pluralize():
    pluralize(dictionary)
    assert dictionary == ['foxes', 'bosses', 'oranges', 'toeses', 'fairies', 'cups']

При первом запуске test_pluralize она пройдет, но каждый последующий раз будет неудачным, так как s и es будут добавляться ad infinitum. Чтобы сделать его чистой функцией, мы могли бы переписать его так:

dictionary = ['fox', 'boss', 'orange', 'toes', 'fairy', 'cup']
def puralize(words):
   result = []
   for word in words:
       word = words[i]
       if word.endswith('s') or word.endswith('x'):
           plural = word + 'es')
       if word.endswith('y'):
           plural = word[:-1] + 'ies'
       else:
           plural = +  's'
       result.append(plural)
    return result

def test_pluralize():
    result = pluralize(dictionary)
    assert result == ['foxes', 'bosses', 'oranges', 'toeses', 'fairies', 'cups']

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

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

Понимание (и избегание) мутабельности

Скажите, какие из следующих структур данных являются изменяемыми?

Почему это важно? Иногда списки и кортежи кажутся взаимозаменяемыми, и возникает соблазн написать код, который использует случайную комбинацию этих двух элементов. Затем кортежи выходят из строя, как только вы пытаетесь выполнить операцию мутации, такую как присвоение элементу. Или вы пытаетесь использовать список в качестве ключа словаря и видите TypeError, что происходит именно потому, что списки мутабельны. Кортежи и строки можно использовать в качестве ключей словаря, потому что они неизменяемы и могут быть детерминированно хэшированы, а все остальные структуры данных - нет, потому что их значение может измениться даже при неизменной идентичности объекта

Самое главное, когда вы передаете по кругу dicts/lists/sets, они могут быть неожиданно изменены в каком-то другом контексте. Это очень сложно отлаживать. Мутируемый параметр по умолчанию является классическим примером этого:

def add_bar(items=[]):
    items.append('bar')
    return items

l = add_bar()  # l is ['bar']
l.append('foo')
add_bar() # returns ['bar', 'foo', 'bar']

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

Ограничение использования классов

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

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

from collections import namedtuple
VerbTenses = namedtuple('VerbTenses', ['past', 'present', 'future'])
# versus
class VerbTenses(object):
    def __init__(self, past, present, future):
        self.past = past,
        self.present = present
        self.future = future

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

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

class Bus(object):
     passengers = set()
     def add_passenger(self, person):
        self.passengers.add(person)

bus1 = Bus()
bus2 = Bus()
bus1.add_passenger('abe')
bus2.add_passenger('bertha')
bus1.passengers  # returns ['abe', 'bertha']
bus2.passengers  # also ['abe', 'bertha']

Идемпотентность

В любой реалистичной, большой и сложной системе бывают случаи, когда ей приходится терпеть неудачи и повторять попытки. Понятие "идемпотентность" существует в дизайне API и матричной алгебре, но в рамках функционального программирования идемпотентная функция возвращает одно и то же при передаче предыдущего вывода. Поэтому повторение чего-либо всегда сходится к одному и тому же значению. Более полезная версия функции 'pluralize', приведенная выше, проверяла бы, было ли что-то уже во множественном числе, прежде чем пытаться вычислить, как сделать его множественным, например,

Бережное использование лямбд и функций высшего порядка

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

Генераторы и функции более высокого уровня, когда это необходимо

Иногда вы сталкиваетесь с абстрактным генератором или итератором, возможно, таким, который возвращает большую или даже бесконечную последовательность значений. Хорошим примером такого генератора является диапазон. В Python 3 он по умолчанию является генератором (эквивалент xrange в Python 2), отчасти для того, чтобы спасти вас от ошибок, связанных с нехваткой памяти, когда вы пытаетесь итерировать большое число, например range(10**10). Если вы хотите выполнить какую-то операцию над каждым элементом в потенциально большом генераторе, то использование таких инструментов, как map и filter, может быть лучшим вариантом.

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

Заключительные мысли

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

Вернуться на верх