Part 12

Функциональное программирование

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

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

Как уже упоминалось выше, функциональное программирование — это парадигма программирования, или стиль программирования. Существует много разных парадигм программирования, и с некоторыми из них мы уже встречались:

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

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

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

Рассмотрим некоторые инструменты функционального программирования, которые предоставляет Python.

map

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

Предположим, у нас есть список строк, который мы хотим преобразовать в список целых чисел:

str_list = ["123","-10", "23", "98", "0", "-110"]

integers = map(lambda x : int(x), str_list)

print(integers) # this tells us the type of object we're dealing with

for number in integers:
    print(number)
Пример вывода

<map object at 0x0000021A4BFA9A90> 123 -10 23 98 0 -110

Общий синтаксис функции map выглядит так:

map(<function>, <series>)

где function — это операция, которую мы хотим выполнить над каждым элементом в series.

Функция map возвращает объект типа map, который является итерируемым и может быть преобразован в список:

def capitalize(my_string: str):
    first = my_string[0]
    first = first.upper()
    return first + my_string[1:]

test_list = ["first", "second", "third", "fourth"]

capitalized = map(capitalize, test_list)

capitalized_list = list(capitalized)
print(capitalized_list)
Пример вывода

['First', 'Second', 'Third', 'Fourth']

Как видно из примеров выше, функция map принимает и анонимную лямбда-функцию, и именованную функцию, определенную ключевым словом def.

Того же результата можно добиться с помощью генератора списка:

def capitalize(my_string: str):
    first = my_string[0]
    first = first.upper()
    return first + my_string[1:]

test_list = ["first", "second", "third", "fourth"]

capitalized_list = [capitalize(item) for item in test_list]
print(capitalized_list)

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

Стоит подчеркнуть, что функция map возвращает не список, а объект-итератор типа map. Итератор во многом ведет себя как список, но есть исключения, что видно в следующем примере:

def capitalize(my_string: str):
    first = my_string[0]
    first = first.upper()
    return first + my_string[1:]

test_list = ["first", "second", "third", "fourth"]

# store the return value from the map function
capitalized = map(capitalize, test_list)

for word in capitalized:
  print(word)

print("print the same again:")
for word in capitalized:
  print(word)

Это вывело бы следующее:

Пример вывода

First Second Third Fourth print the same again:

Выше мы попытались вывести содержимое итератора map дважды, но вторая попытка ничего не вывела. Причина в том, что map — это итератор; проход по нему циклом for «исчерпывает» его, примерно так же, как генератор исчерпывается после достижения максимального значения. Когда элементы итератора уже были пройдены циклом for, проходить больше нечего.

Если нужно пройти по содержимому итератора map больше одного раза, можно, например, преобразовать map в список:

test_list = ["first", "second", "third", "fourth"]

# convert the return value of the map function into a list
capitalized = list(map(capitalize, test_list))

for word in capitalized:
  print(word)

print("print the same again:")
for word in capitalized:
  print(word)
Пример вывода

First Second Third Fourth print the same again: First Second Third Fourth

Функция map и собственные классы

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

class BankAccount:
    def __init__(self, account_number: str, name: str, balance: float):
        self.__account_number = account_number
        self.name = name
        self.__balance = balance

    def deposit(self, amount: float):
        if amount > 0:
            self.__balance += amount

    def get_balance(self):
        return self.__balance

a1 = BankAccount("123456", "Randy Riches", 5000)
a2 = BankAccount("12321", "Paul Pauper", 1)
a3 = BankAccount("223344", "Mary Millionaire ", 1000000)

accounts = [a1, a2, a3]

clients = map(lambda t: t.name, accounts)
for name in clients:
  print(name)

balances = map(lambda t: t.get_balance(), accounts)
for balance in balances:
  print(balance)
Пример вывода

Randy Riches Paul Pauper Mary Millionaire 5000 1 1000000

Здесь мы сначала собираем имена владельцев счетов с помощью функции map. Анонимная лямбда-функция используется, чтобы получить значение атрибута name из каждого объекта BankAccount:

clients = map(lambda t: t.name, accounts)

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

balances = map(lambda t: t.get_balance(), accounts)
Loading

filter

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

Посмотрим пример с использованием filter:

integers = [1, 2, 3, 5, 6, 4, 9, 10, 14, 15]

even_numbers = filter(lambda number: number % 2 == 0, integers)

for number in even_numbers:
    print(number)
Пример вывода

2 6 4 10 14

Возможно, пример выше станет немного понятнее, если использовать именованную функцию:

def is_it_even(number: int):
    if number % 2 == 0:
        return True
    return False

integers = [1, 2, 3, 5, 6, 4, 9, 10, 14, 15]

even_numbers = filter(is_it_even, integers)

for number in even_numbers:
    print(number)

Эти две программы функционально полностью идентичны. Какой подход считать лучшим — в основном вопрос мнения.

Рассмотрим другой пример фильтрации. Эта программа моделирует рыб и выбирает только тех, которые весят не меньше 1000 граммов:

class Fish:
    """ The class models a fish of a certain species and weight """
    def __init__(self, species: str, weight: int):
        self.species = species
        self.weight = weight

    def __repr__(self):
        return f"{self.species} ({self.weight} g.)"

if __name__ == "__main__":
    f1 = Fish("Pike", 1870)
    f2 = Fish("Perch", 763)
    f3 = Fish("Pike", 3410)
    f4 = Fish("Cod", 2449)
    f5 = Fish("Roach", 210)

    fishes = [f1, f2, f3, f4, f5]

    over_a_kilo = filter(lambda fish : fish.weight >= 1000, fishes)

    for fish in over_a_kilo:
        print(fish)
Пример вывода

Pike (1870 g.) Pike (3410 g.) Cod (2449 g.)

С таким же успехом можно использовать генератор списка и получить тот же результат:

over_a_kilo = [fish for fish in fishes if fish.weight >= 1000]

Возвращаемое значение filter — итератор

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

f1 = Fish("Pike", 1870)
f2 = Fish("Perch", 763)
f3 = Fish("Pike", 3410)
f4 = Fish("Cod", 2449)
f5 = Fish("Roach", 210)

fishes = [f1, f2, f3, f4, f5]

over_a_kilo = filter(lambda fish : fish.weight >= 1000, fishes)

for fish in over_a_kilo:
    print(fish)

print("print the same again:")

for Fish in over_a_kilo:
    print(Fish)

Это вывело бы следующее:

Пример вывода

Pike (1870 g.) Pike (3410 g.) Cod (2449 g.) print the same again:

Если нужно пройти по содержимому итератора filter больше одного раза, можно преобразовать результат в список:

fishes = [f1, f2, f3, f4, f5]

# convert the return value of the filter function into a list
over_a_kilo = list(filter(lambda fish : fish.weight >= 1000, fishes))
Loading

reduce

Третья ключевая функция в этом введении в принципы функционального программирования — reduce из модуля functools. Как следует из названия, ее цель — свести элементы последовательности к одному значению.

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

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

from functools import reduce

my_list = [2, 3, 1, 5]

sum_of_numbers = reduce(lambda reduced_sum, item: reduced_sum + item, my_list, 0)

print(sum_of_numbers)
Пример вывода

11

Посмотрим внимательнее, что здесь происходит. Функция reduce принимает три аргумента: функцию, последовательность элементов и начальное значение. В этом случае последовательность — список целых чисел, и поскольку мы вычисляем сумму, подходящее начальное значение равно нулю.

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

lambda reduced_sum, item: reduced_sum + item

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

Может быть проще понять, что на самом деле делает функция reduce, если использовать обычную именованную функцию вместо лямбда-функции. Так мы также можем добавить полезные выводы:

from functools import reduce

my_list = [2, 3, 1, 5]

# a helper function for reduce, adds one value to the current reduced sum
def sum_helper(reduced_sum, item):
  print(f"the reduced sum is now {reduced_sum}, next item is {item}")
  # the new reduced sum is the old sum + the next item
  return reduced_sum + item

sum_of_numbers = reduce(sum_helper, my_list, 0)

print(sum_of_numbers)

Программа выводит:

Пример вывода

the reduced sum is now 0, next item is 2 the reduced sum is now 2, next item is 3 the reduced sum is now 5, next item is 1 the reduced sum is now 6, next item is 5 11

Сначала функция обрабатывает элемент со значением 2. В начале сведенная сумма равна 0, то есть начальному значению, переданному функции reduce. Функция вычисляет и возвращает сумму этих двух значений: 0 + 2 = 2.

Это значение хранится в reduced_sum, когда функция reduce обрабатывает следующий элемент списка со значением 3. Функция вычисляет и возвращает сумму этих двух значений: 2 + 3 = 5. Затем этот результат используется при обработке следующего элемента и так далее.

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

from functools import reduce

my_list = [2, 2, 4, 3, 5, 2]

product_of_list = reduce(lambda product, item: product * item, my_list, 1)

print(product_of_list)
Пример вывода

480

Поскольку мы работаем с умножением, начальное значение не равно нулю. Вместо этого используется 1. Что произошло бы, если бы мы использовали 0 как начальное значение?

Выше мы в основном работали с целыми числами, но map, filter и reduce могут обрабатывать коллекцию объектов любого типа.

В качестве примера с помощью reduce вычислим общую сумму балансов всех счетов в банке:

class BankAccount:
    def __init__(self, account_number: str, name: str, balance: float):
        self.__account_number = account_number
        self.name = name
        self.__balance = balance

    def deposit(self, amount: float):
        if amount > 0:
            self.__balance += amount

    def get_balance(self):
        return self.__balance

a1 = BankAccount("123456", "Randy Riches", 5000)
a2 = BankAccount("12321", "Paul Pauper", 1)
a3 = BankAccount("223344", "Mary Millionaire ", 1000000)

accounts = [a1, a2, a3]

from functools import reduce

def balance_sum_helper(balance_sum, account):
  return balance_sum + account.get_balance()

balances_total = reduce(balance_sum_helper, accounts, 0)

print("The total of the bank's balances:")
print(balances_total)

Эта программа вывела бы:

Пример вывода

The total of the bank's balances: 1005001

Функция balance_sum_helper получает баланс каждого банковского счета с помощью метода, специально предназначенного для этого в определении класса BankAccount:

def balance_sum_helper(balance_sum, account):
  return balance_sum + account.get_balance()

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

balances_total = reduce(balance_sum_helper, accounts)

привела бы к ошибке:

TypeError: unsupported operand type(s) for +: 'BankAccount' and 'int'

В случае выше, когда reduce впервые пытается выполнить функцию balance_sum_helper, аргументами становятся два первых элемента списка, и оба они имеют тип BankAccount. В частности, значение, присвоенное параметру balance_sum, — это первый элемент списка. Функция balance_sum_helper пытается прибавить к нему целое число, но сложение целого числа напрямую с объектом BankAccount не поддерживается.

Loading
Вы дошли до конца этого раздела! Перейти к следующему разделу:

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