Введение в Python и обработку данных

Спецкурс кафедры Теоретической информатики мехмата МГУ

Подробная информация по курсу: http://rmbk.me/mm_python/

Лекция 7: функциональное программирование

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

Очередная напоминалка: функции в Python тоже являются объектами как строки или списки.

Соответственно их тоже передавать в функции, возвращать оттуда, создавать на лету.

In [ ]:
# Передадим функцию в функцию

def caller(func, params):
    return func(*params)

def printer(greeting, name):
    print(f'{greeting}, {name}!')
    
caller(printer, ['Hello', 'Kitty'])
caller(printer, ['Hi', 'Mark'])
In [ ]:
# Функции можно создавать внутри функций!

def get_multiplier():
    def inner(a, b):
        return a * b
    return inner

multiplier = get_multiplier()
multiplier(5, 11)
In [ ]:
# В переменной multiplier теперь хранится функция inner:

print(multiplier.__name__)

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

Например, мы передаем get_multiplier двойку и получаем функцию, которая всегда умножает переданный ей аргумент на двойку. Эта концепция называется "замыканием".

In [ ]:
def get_multiplier(number):
    def inner(a):
        return a * number
    return inner

multiplier_by_2 = get_multiplier(2)
multiplier_by_2(11)

Применение функции: map

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

Одна из таких функций — это map, которая принимает функцию и какой-то итерабельный объект (например, список) и применяет полученную функцию ко всем элементам объекта.

In [ ]:
# class map(object)
#  |  map(func, *iterables) --> map object
#  |  
#  |  Make an iterator that computes the function using arguments from
#  |  each of the iterables.  Stops when the shortest iterable is exhausted.

# Давайте выведем квадраты последовательности

def squarify(x):
    return x ** 2

# Подход с map
a = list(map(squarify, range(5)))
print(a)

# Аналогичный "классический" код
a = []
for i in range(5):
    a.append(squarify(i))
print(a)

Обратите внимание

Мы вызываем list на map, потому что map возвращает не новый список, а итерируемый объект map. Соответственно мы явно вызываем list, чтобы превратить его в привычный список.

Удаление элементов: filter

Функция filter позволяет фильтровать по какому-то условию итерабельный объект. Она принимает на вход функцию-условие и сам итерабельный объект.

In [ ]:
# class filter(object)
#  |  filter(function or None, iterable) --> filter object
#  |  
#  |  Return an iterator yielding those items of iterable for which function(item)
#  |  is true. If function is None, return the items that are true.

def is_odd(x):
    return x % 2 == 1

# Использование filter
a = list(filter(is_odd, range(10)))
print(a)

# Аналогичный "классический" код
a = []
for i in range(10):
    if is_odd(i):
        a.append(i)
print(a)

Примечание

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

lambda-функции

Если мы хотим передать в map небольшую функцию, которая нам больше не понадобится, можно использовать анонимные функции (или lambda-функции). Lambda позволяет вам определить функцию in place, то есть без литерала def. Также в lambda-функциях сразу пишется возвращаемое значение без return, т.е. многострочную функцию

Синтаксис:

lambda АРГУМЕНТЫ: ВОЗВРАЩАЕМОЕ_ЗНАЧЕНИЕ
In [ ]:
a = list(map(lambda x: x ** 2, range(5)))
print(a)

a = list(filter(lambda x: x % 2 == 1, range(10)))
print(a)
In [ ]:
# Примечание: лямбды это обычные функции, но без имени
f = lambda x: x

print(type(f))
print(f)
In [ ]:
# Задачка! Написать код для превращения списка чисел в список строк и обратно.
a = list(range(10))

# Пиши код здесь:
a = list(map(str, a))
print(a)
a = list(map(int, a))
print(a)

Склейка нескольких итерируемых объектов: zip

zip помогает проитерироваться сразу по нескольким объектам, он создает генератор кортежей.

In [ ]:
# class zip(object)
#  |  zip(iter1 [,iter2 [...]]) --> zip object
#  |  
#  |  Return a zip object whose .__next__() method returns a tuple where
#  |  the i-th element comes from the i-th iterable argument.  The .__next__()
#  |  method continues until the shortest iterable in the argument sequence
#  |  is exhausted and then it raises StopIteration.

# Найти расстояние Хэмминга
a = 'GAGCCTACTAACGGGAT'
b = 'CATCGTAATGACGGCCT'

print(list(zip(a, b)))

count = 0
for x, y in zip(a, b):
    if x != y:
        count += 1

print(count)

Функции для работы с итерируемыми объектами

In [ ]:
# Прочие популярные функции
a = list(range(10))

print('min:', min(a))
print('max:', max(a))
print('sum:', sum(a))
print('sorted:', sorted(a))
In [ ]:
# К слову, в sorted, min и max тоже можно передавать аргумент-функцию key
a = list(zip(range(7), range(4, -3, -1)))

print('List:', a)
print()
print('min:', min(a, key=lambda x: x[1]))
print('max:', max(a, key=lambda x: x[1]))
print('sorted:', sorted(a, key=lambda x: x[1]))

Модуль functools: reduce

Модуль functools позволяет использовать функциональные особенности Python ещё лучше.

Функция reduce дает возможность пройтись по итерируемому объекту с сохранением результата предыдущего шага.

In [ ]:
# reduce(...)
#     reduce(function, sequence[, initial]) -> value
#     Apply a function of two arguments cumulatively to the items of a sequence,
#     from left to right, so as to reduce the sequence to a single value.
#     For example, reduce(lambda x, y: x+y, [1, 2, 3, 4, 5]) calculates
#     ((((1+2)+3)+4)+5).  If initial is present, it is placed before the items
#     of the sequence in the calculation, and serves as a default when the
#     sequence is empty.

from functools import reduce

def multiply(x, y):
    return x * y

a = reduce(multiply, [1, 2, 3, 4, 5])
print(a)

# Ну и, естественно, теперь умеем писать через лямбда-функции
a = reduce(lambda x, y: x * y, [1, 2, 3, 4, 5])
print(a)

Модуль functools: partial

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

In [ ]:
# class partial(builtins.object)
#  |  partial(func, *args, **keywords) - new function with partial application
#  |  of the given arguments and keywords.

import random
from functools import partial

# Можем зафиксировать новую функцию сортировки по модулям чисел
abs_sort = partial(sorted, key=lambda x: abs(x))

a = [random.randint(-10, 10) for _ in range(20)]
print(a)
print(abs_sort(a))

Декораторы

Декоратор) это паттерн проектирования, он принимает на вход объект и добавляет к нему дополнительное поведение.

В частности, в Python их часто используют как функции, принимающие функцию как аргумент и возвращающие функции.

In [ ]:
# Простейший декоратор, возвращающий ту же функцию
def decorator(func):
    return func

def decorated():
    return 'Hello!'

decorated = decorator(decorated)
print(decorated())
In [ ]:
# Запись вида `decorated = decorator(decorated)` можно сократить:
@decorator
def decorated():
    return 'Hello!'

print(decorated())

Давайте напишем декоратор возвращающий другую функцию:

In [ ]:
def logger(func):
    def wrapped(*args, **kwargs):
        print(f'[LOG] Вызвана функция {func.__name__} c аргументами: {args}, {kwargs}')
        result = func(*args, **kwargs)
        return result
    return wrapped

@logger
def list_sum(a):
    return sum(a)

@logger
def list_max(a):
    return max(a)

print(list_sum([1, 2, 3]))
print(list_max([5, 6, 7]))

Обратите внимание

Посмотрим что за функции у нас теперь вместо list_sum и list_max.

Видим wrapped — ту самую функцию внутри декоратора, а не нашу изначальную.

Чтобы вернуть нашей функции ее имя используем wraps из functools.

In [ ]:
# Названия функций:
print(f'list_sum:', list_sum.__name__)
print(f'list_max:', list_max.__name__)
In [ ]:
from functools import wraps

def logger(func):
    @wraps(func)
    def wrapped(*args, **kwargs):
        print(f'[LOG] Вызвана функция {func.__name__} c аргументами: {args}, {kwargs}')
        result = func(*args, **kwargs)
        return result
    return wrapped

@logger
def list_sum(a):
    return sum(a)

@logger
def list_max(a):
    return max(a)

print(f'list_sum:', list_sum.__name__)
print(f'list_max:', list_max.__name__)

We need to go deeper

Напишем декоратор, котторый принимает еще и параметры

In [ ]:
from functools import wraps

def logger(level):
    def decorator(func):
        @wraps(func)
        def wrapped(*args, **kwargs):
            print(f'[{level}] Вызвана функция {func.__name__} c аргументами: {args}, {kwargs}')
            result = func(*args, **kwargs)
            return result
        return wrapped
    return decorator

@logger(level='HIGH')
def list_sum(a):
    return sum(a)

@logger(level='LOW')
def list_max(a):
    return max(a)

print(list_sum([1, 2, 3]))
print(list_max([5, 6, 7]))

Генераторы

Генератор — это функция, содержащая оператор yield. Этот оператор возвращает результат, но не прерывает функцию.

In [ ]:
def even_range(start, end):
    current = start
    while current < end:
        yield current
        current += 2
        
for number in even_range(0, 30):
    print(number, end=' ')

Генератор even_range прибавляет к числу двойку и делает с ним операцию yield, пока current < end. Каждый раз, когда выполняется yield, возвращается значение current, и каждый раз, когда мы просим следующий элемент, выполнение функции возвращается к последнему моменту, после чего она продолжает исполняться.

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

In [ ]:
generator = even_range(0, 4)
print(generator)

print(next(generator))
print(next(generator))
print(next(generator))

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

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

Например, функция range в Python 2 возвращала честный список, но это дополнительное выделение памяти. И была функция xrange, которая уже была генератором и не выделяла память, а хранила лишь последний выданный элемент, как в примере выше. В Python 3 range сделали генератором, а xrange убрали.

Классический пример про числа Фибоначчи (для получения следующего элемента нужно помнить только два последних):

In [ ]:
def fibonacci(number):
    a, b = 1, 1
    for _ in range(number):
        yield a
        a, b = b, a + b

for curr in fibonacci(20):
    print(curr, end=' ')

Домашнее задание №7

  • (*) G1: Написать декоратор, сохраняющий последний результат функции в .txt файл с названием функции в имени
  • (*) G2: Написать генератор, выдающий простые числа