Программы
Python: Функции

Python: Функции

Функции в python - основной механизм разбиения кода на части. Деля что-то на части, мы уменьшаем сложность. Поэтому давайте делить код на функции!

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

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

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

Если программу поделить на части, о них можно и думать отдельно, при этом оставшаяся программа станет проще.

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

  1. Названия функций должны понятно и лаконично описывать действия, которые в этих функциях происходят. К слову, поэтому стоит начинать название с глагола, ведь это именно действие.
  2. Функции должны совершать одно простое действие. Речь не об одном Python выражении, но об одном действии в терминах задачи: "получить пользовательский ввод", "рассчитать дискриминант квадратного уравнения", "найти кратчайший путь".

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

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

В простейшем случае функция в Python имеет следующую структуру: дуль collections

def имя_функции_в_snake_case(параметр1, параметр2=значение_по_умолчанию):
    """
    Описание функции
    Описание параметров функции
    Описание ожидаемых исключений и возвращаемых значений
    """
    код

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

  • Читает ввод информации от пользователя.
  • Проверяет ввод на правильность (поддерживаем ли мы операцию), числа ли операнды.
  • Подготавливает входные параметры для работы.
  • Команды:
    • Сложение.
    • Умножение.
  • Выводит сообщения об ошибках.
  • Выводит результат выполнения команды.
def sum_numbers(operator_a, operator_b):
    return operator_a + operator_b


def mul_numbers(operator_a, operator_b):
    return operator_a * operator_b


OPERATIONS = {
    '+': sum_numbers,
    '*': mul_numbers,
}


def get_arguments():
    command = input('Операция> ')
    first_operand = input('операнд 1> ')
    second_operand = input('операнд 2> ')
    return command, first_operand, second_operand


def is_valid_operation(operation):
    if operation in OPERATIONS:
        return True

    print("Неизвестная операция.")
    return False


def is_valid_operator(operator):
    if operator.isdigit():
        return True

    print(operator, " - неподдерживаемый формат числа.")
    return False


while True:
    operation, operator_a, operator_b = get_arguments()

    if (
        not is_valid_operation(operation)
        or not is_valid_operator(operator_a)
        or not is_valid_operator(operator_b)
    ):
        continue

    operator_a = int(operator_a)
    operator_b = int(operator_b)

    operation_action = OPERATIONS[operation]
    result = operation_action(operator_a, operator_b)

    print("Результат=", result)

Программа явно стала больше. Стала ли она сложнее? В каждой строчке количество информации, которую надо держать в голове, стало меньше. Нам не требуется думать о том, как одно значение будет преобразовано в другое. Благодаря понятным названиям функций и логичному разделению кода на функции для восприятия сути программы нам достаточно прочитать блок while True.

Добавлять новые операции также стало проще, а изменить текст взаимодействия с пользователем -- проще не бывает.

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

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

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

Реализуйте операцию взятия наибольшего общего делителя в рамках данного кода.

Изображение Python 3.11. Что нового?

Анонимные функции

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

К примеру, напишем небольшую функцию, которая будет принимать число x и возвращать x в степени x, в виде lambda:

>>> super_self = lambda x: x ** x
>>> super_self(3)
27
>>> super_self.__name__
'<lambda>'

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

>>> sum_numbers.__name__
'sum_numbers'
>>> mul_numbers.__name__
'mul_numbers'

Мы можем её выполнить даже не присваивая какой-либо переменной:

>>> (lambda x: x ** x)(3)
27

Либо использовать переменную для рекурсивного вызова:

>>> factorial = lambda x: x * factorial(x - 1) if x > 1 else 1
>>> factorial(5)
120

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

Из-за направленности лямбд на исполнение только одного выражения, недоступно использование различных конструкций типа while, for, try-except, raise, if (к слову, тернарный оператор if-else использовать можно). Оператор присвоения (=) также запрещён синтаксисом lambda.

Читателю предлагается реализовать while на lambda, используя хвостовую рекурсию.

Ну а мы можем спокойно переписать определение словаря операций калькулятора:

OPERATIONS = {
    '+': lambda x, y: x + y,
    '*': lambda x, y: x * y,
}

Если же вы используете lambda, но при создании присваиваете её переменной (решаете дать имя или использовать несколько раз) -- скорее всего, вам нужна обычная функция. Не поленитесь определить обычную функцию!

Примеры логичного использования лямбда-функций

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

people = [
    {'name': 'Batman', 'age': 45, 'balance': 50_000_000_000},
    {'name': 'Ironman', 'age': 38, 'balance': 100_000_000_000},
    {'name': 'Spiderman', 'age': 15, 'balance': 500},
]

Чтобы получить самого молодого из списка, можно написать небольшую функцию, которая будет итеративно проверять ключ age... Либо использовать встроенную функцию min. Для целых чисел её поведение очевидно -- вернёт наименьшее. Но что делать со словарём? Для этого у функции min есть аргумент key, в котором мы можем передать функцию, принимающую каждое очередное значение списка (в нашем случае -- словарь), и возвращающую значение, по которому и надо искать минимум.

>>> min(people, key=lambda x: x['age'])
{'name': 'Spiderman', 'balance': 500, 'age': 15}

Казалось бы, давайте тогда передавать не функцию, а ключ словаря, но не только словари есть в Python. Также, хоть и python zen рекомендует обратное, не все структуры плоские. Ну и, в конце концов, мы можем захотеть сравнивать не по самому значению. Как например, при поиске самого длинного имени, ведь с точки зрения Python 'abc' < 'z'.

>>> max(people, key=lambda x: len(x['name']))
{'name': 'Spiderman', 'balance': 500, 'age': 15}

-- здесь мы использовали близнеца функции min -- функцию max. Также полезно знать об их "двоюродном брате" -- sorted, который также использует key для указания способа сортировки списка. К примеру, отсортируем всех по балансу средств на их "счету":

>>> sorted(people, key=lambda x: x['balance'])
[{'name': 'Spiderman', 'age': 15, 'balance': 500},
{'name': 'Batman', 'age': 45, 'balance': 50000000000},
{'name': 'Ironman', 'age': 38, 'balance': 100000000000}]

Выведите список от большего баланса к меньшему, не используя reversed.

Позиционные и именованные аргументы, значения по умолчанию

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

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

>>> def say_hello(first_name, last_name):
...     print('Hello,', first_name, last_name)
... 
>>> say_hello('James', 'Bond')
Hello, James Bond
>>> say_hello(last_name='Bond', first_name='James')
Hello, James Bond

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

def say_hello(first_name, last_name, middle_name=None):
    if middle_name is None:
        print('Hello,', first_name, last_name)
    else:
        print('Hello,', first_name, middle_name, last_name)

Мы даже можем потребовать, чтобы какие-то параметры вызывались строго именовано. Для этого указываем перед такими аргументами "специальный аргумент" *. Само собой, это не аргумент, а особенность синтаксиса, но выглядит именно так:

>>> def say_hello(first_name, *, last_name, middle_name=None):
...     if middle_name is None:
...         print('Hello,', first_name, last_name)
...     else:
...         print('Hello,', first_name, middle_name, last_name)
... 
>>> say_hello('Jack', 'Black')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: say_hello() takes 1 positional argument but 2 were given
>>> say_hello('Jack', last_name='Black')
Hello, Jack Black
>>> say_hello(first_name='Jack', last_name='Black')
Hello, Jack Black

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

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

Функции с переменным числом аргументов

Теперь приглядимся к функции print, которую мы довольно часто используем: мы ей передаём различное количество аргументов, а она их склеивает пробельным символом и выводит. Но как она умудряется обрабатывать разное количество аргументов? В Python можно при объявлении функции указать, чтобы список позиционных аргументов передавался в функцию в виде массива. Для этого нужно определить аргумент с * перед ним (традиционно его называют "args") -- тогда Python сложит в него оставшиеся позиционные аргументы:

>>> def print_as_column(header, *strings):
...     max_length = 0
...     for string in strings:
...         if len(string) > max_length:
...             max_length = len(string)
...     
...     print('|', header.ljust(max_length), '|')
...     print('+', '-' * max_length, '+')
...     
...     for string in strings:
...         print('|', string.rjust(max_length), '|')
... 
>>> print_as_column('Heroes', 'Iron man', 'Hulk', 'Spider man')
| Heroes     |
+ ---------- +
|   Iron man |
|       Hulk |
| Spider man |

Аналогичным образом можно собрать позиционные аргументы в словарь, однако, нужно использовать уже двойной символ *. Традиционно этот аргумент называют "kwargs" -- keyword-arguments:

>>> def print_as_rows(**kwargs):
...     max_len = len(max(kwargs, key=len))
...     for name, value in kwargs.items():
...         print(name.rjust(max_len), ':', value)
... 
>>> print_as_rows(
...     name='Batman',
...     orig_name='Bruce Wayne',
...     city='Gotham',
...     power='money',
... )
     name : Batman
orig_name : Bruce Wayne
     city : Gotham
    power : money

Документирование функций

Мы уже пользовались документацией различных встроенных функций. Получить её можно с помощью функции help. Теперь пришло время самим заняться документированием функций, ведь недолог день, когда уже вашими функциями начнут пользоваться!

Вспомним, как выглядит в первом приближении функция:

def имя_функции_в_snake_case(параметр1, параметр2=значение_по_умолчанию):
    """
    Описание функции
    Описание параметров функции
    Описание ожидаемых исключений и возвращаемых значений
    """
    код

Документация по функции -- это строка, располагающаяся перед кодом в теле функции. Потому её часто также называют "docstring". Как можно заметить из примера, важно описать задачу функции, если она не ясная целиком из названия. Так могут быть непонятны для стороннего читателя: выбор алгоритма, которым решается задача, вычислительная сложность (если это критичная особенность реализации), причины возникновения функции (например, ссылка на задачу), или же дополнительный контекст бизнес-логики.

Далее идёт описание параметров функции. Здесь логика описания аналогична. Стоит также указывать область допустимых значений. Например, имея тип str, параметр может отвечать за телефонный номер, который должен быть записан в определённом формате.

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

Для примера, опишем функцию sum_numbers из начала главы:

def sum_numbers(operator_a, operator_b):
    """Сумма

    Арифметическая сумма двух чисел.

    Args:
        operator_a (int): первый операнд.
        operator_b (int) второй операнд.

    Returns:
        int: число, являющееся суммой аргументов.

    Examples:
        >>> sum_numbers(1, 2)
        3
    """
    return operator_a + operator_b

Тогда help(sum_numbers) вернёт:

Help on function sum_numbers in module __main__:

sum_numbers(operator_a, operator_b)
    Сумма

    Арифметическая сумма двух чисел.

    Args:
        operator_a (int): первый операнд.
        operator_b (int) второй операнд.

    Returns:
        int: число, являющееся суммой аргументов.

    Examples:
        >>> sum_numbers(1, 2)
        3

Теперь наша функция документирована в Google-стиле. Он довольно прост, но достаточен в большинстве случаев.

Сам же текст документации функции содержится в атрибуте sum_numbers.__doc__. Во многих проектах документация также используется для генерации различных представлений, например, HTML (в случае сложного форматирования -- ознакомьтесь с форматом ReStructed Text). Или даже для описания простых тестов -- doctest.

Указание типа функции

В случае функции sum_numbers документация выглядит избыточной. Особенно, если использовать type-hinting. Type hinting -- это ещё один способ увеличить читаемость функции через описание типов переменных и возвращаемого значения.

def sum_numbers(operator_a: int, operator_b: int) -> int:
    return operator_a + operator_b

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

В случае более сложного типа данных, чем обычное целое число, нам могут потребоваться объекты из модуля typing. Так, например, описание типа "список целых чисел" будет выглядеть как List[int]. Словарь с ключами -- строками, а значениями -- списками целых чисел выглядит как Dict[str, List[int]].

Напишем функцию, которая принимает на вход другую функцию и список чисел, фильтрует его и отдаёт отфильтрованный список чисел:

from typing import Callable, List


def filter(
    is_pass: Callable[[int], bool],
    collection: List[int],
) -> List[int]:
    result: List[int] = []

    for item in collection:
        if is_pass(item):
            result.append(item)

    return result


filter(lambda x: x % 2, [1, 2, 3, 4, 5])  # [1, 3, 5]

Из интересного тут описание типа функции для фильтрации:

Callable[[типы параметров], тип возвращаемого значения]

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

from typing import Callable, List, TypeVar

A = TypeVar('A')


def filter(
    is_pass: Callable[[A], bool],
    collection: List[A],
) -> List[A]:

Стоит отметить ещё Union и Optional из модуля typing. Если какое-то значение может быть целым числом, списком чисел или строкой, то это плохая идея -- такой код сложно поддерживать. Но если всё же надо -- описанием такого типа будет Union[int, List[int], str].

Аналогично с Optional, но в случае, если значение может быть None. Например, значение может быть списком строк или None: Optional[List[str]].

В Python старше версии 3.9 можно использовать напрямую list[A], tuple[A, B, C], dict[A, B] вместо аналогичных описаний типов из модуля typing.