Learning Platform
Урок 04.06 · 25 мин
Начальный
FunctionsArgumentsType hintsLambdaDocstrings
Функции в Python: closures, first-class и LEGB Type hints: система типов Python в деталях

Что такое функция (если встречаешь это слово впервые)

Функция — это многоразовый рецепт. Ты один раз описываешь последовательность действий и даёшь ей имя; дальше достаточно назвать имя — и действия выполнятся снова, сколько угодно раз. Другая аналогия: функция как кнопка на кофемашине. Внутри происходит много шагов (намолоть зёрна, нагреть воду, пролить), но тебе достаточно нажать кнопку «эспрессо» и получить результат. Ты можешь передать кнопке «настройки» (например, сколько порций) — это аргументы, а то, что кнопка отдаёт обратно (чашку кофе) — это возвращаемое значение.

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

Главный кирпичик кода

Если переменные хранят данные, а условия и циклы организуют поток, то функции — главный способ разделить программу на куски, которые имеют смысл по отдельности. Хорошие функции имеют чёткие границы: «вот вход — вот выход». На уровне junior DE 80% кода ETL — это функции, читаемые по сигнатуре и docstring. Этот урок про то, как такие функции писать в Python.

Базовая форма

def calculate_total(price: float, quantity: int) -> float:
    """Считает итоговую сумму с учётом количества."""
    return price * quantity

total = calculate_total(99.90, 3)
print(total)   # 299.7

Что мы здесь видим:

  • def — ключевое слово определения функции.
  • Имя calculate_total — в snake_case, как принято в Python (см. PEP 8).
  • Аргументы с type hints: price: float, quantity: int.
  • Возвращаемый тип через ->: -> float.
  • Docstring — строка-описание сразу после def. К нам ещё вернёмся.
  • Тело функции с отступом.
  • return — возврат значения. Без return функция вернёт None.

Type hints, как мы говорили, не проверяются интерпретатором. Они нужны для IDE, mypy/pyright и читателей кода. Без type hints на ревью могут попросить переписать.

Positional vs keyword arguments

В Python аргументы можно передавать по позиции и по имени:

def greet(name: str, greeting: str) -> str:
    return f"{greeting}, {name}!"

# по позиции
greet("Анна", "Привет")
# 'Привет, Анна!'

# по имени
greet(name="Анна", greeting="Привет")
greet(greeting="Привет", name="Анна")    # порядок не важен

# смешанно: позиционные первыми, keyword вторыми
greet("Анна", greeting="Привет")

Хорошее правило DE: 3+ аргументов — вызывать по имени. Иначе на ревью никто не вспомнит, что значит process(true, false, 5, "data").

# плохо — кто эти true и false?
upload_to_s3("data.csv", "my-bucket", True, False, 5)

# хорошо — читаемо, не нужно лезть в сигнатуру
upload_to_s3(
    path="data.csv",
    bucket="my-bucket",
    overwrite=True,
    compress=False,
    timeout=5,
)

Default arguments — и ловушка mutable defaults

Аргумент может иметь значение по умолчанию:

def greet(name: str, greeting: str = "Hello") -> str:
    return f"{greeting}, {name}!"

greet("Анна")              # 'Hello, Анна!'
greet("Анна", "Привет")    # 'Привет, Анна!'

Удобно. Но есть главная ловушка Python, на которой джуниоры разбиваются регулярно:

# ПЛОХО — никогда так не делайте
def add_item(item: str, target: list = []) -> list:
    target.append(item)
    return target

# первый вызов — ОК
print(add_item("a"))   # ['a']

# второй вызов — БАГ!
print(add_item("b"))   # ['a', 'b']  <-- ожидаемо было ['b']

Почему так? Значение по умолчанию вычисляется один раз при определении функции, а не при каждом вызове. Объект [] создаётся один. Каждый вызов без аргумента target получает тот же самый список. Все мутации в него накапливаются.

Это работает так же для любого mutable объекта в default: dict, set, bytearray, экземпляры классов.

Решение — использовать None как маркер и создавать новый объект внутри функции:

# ХОРОШО
def add_item(item: str, target: list | None = None) -> list:
    if target is None:
        target = []
    target.append(item)
    return target

print(add_item("a"))   # ['a']
print(add_item("b"))   # ['b']    — отдельный список
WARNING

Любой mutable объект в default — потенциальный баг. def f(x=[]), def f(x={}), def f(x=set()). Используйте None + проверку внутри. Это правило знают и проверяют все code-ревью утилиты (ruff B006).

*args и **kwargs

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

*args — собирает остаток позиционных в tuple:

def total(*nums: float) -> float:
    return sum(nums)

print(total(1, 2, 3))         # 6
print(total(1, 2, 3, 4, 5))   # 15
print(total())                # 0

**kwargs — собирает остаток именованных в dict:

def build_url(base: str, **params: str) -> str:
    query = "&".join(f"{k}={v}" for k, v in params.items())
    return f"{base}?{query}"

build_url("https://api.example.com", user="anna", page="2")
# 'https://api.example.com?user=anna&page=2'

Можно комбинировать:

def log(level: str, message: str, *tags: str, **fields: str) -> None:
    tag_str = ",".join(tags)
    field_str = " ".join(f"{k}={v}" for k, v in fields.items())
    print(f"[{level}] {message} tags=[{tag_str}] {field_str}")

log("INFO", "User logged in", "auth", "web", user_id="42", ip="1.2.3.4")
# [INFO] User logged in tags=[auth,web] user_id=42 ip=1.2.3.4

*args/**kwargs полезны:

  1. Для wrapper-функций, прокидывающих аргументы дальше: def wrapper(*args, **kwargs): return inner(*args, **kwargs).
  2. Для функций с неизвестным заранее набором полей (как в логировании).
  3. Для расширяемых API («можно передать любые дополнительные параметры»).

Не злоупотребляйте: если можно перечислить все аргументы — перечислите. Сигнатура def process(*args, **kwargs) неинформативна.

Keyword-only и positional-only

С Python 3.8 у функций есть три «зоны» аргументов, разделённые специальными символами / и *:

def func(positional_only, /, normal, *, keyword_only):
    ...
  • Что до /только позиционные. Нельзя передать по имени.
  • Между / и * — обычные, можно и так и так.
  • Что после *только keyword. Нельзя передать по позиции.

Зачем это нужно:

def upload(
    path: str,
    bucket: str,
    /,
    region: str = "us-east-1",
    *,
    overwrite: bool = False,
    compress: bool = True,
) -> None:
    ...

# OK
upload("data.csv", "my-bucket", overwrite=True)
upload("data.csv", "my-bucket", region="eu-west-1", compress=False)

# Ошибка — path и bucket только позиционные:
upload(path="data.csv", bucket="my-bucket")

# Ошибка — overwrite только по имени:
upload("data.csv", "my-bucket", "us-east-1", True)

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

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

def save_csv(records, *, sep=",", encoding="utf-8", overwrite=False):
    ...

save_csv(records)                            # дефолты
save_csv(records, encoding="cp1251")         # явно указываем кодировку
save_csv(records, ",", "cp1251", True)       # <-- так больше нельзя

В DE-коде это полезно: булевые флаги по позиции — главный источник ошибок. True/False без названия параметра — нечитаемо.

Lambda — анонимные функции

lambda — короткая безымянная функция-выражение:

square = lambda x: x ** 2
print(square(5))    # 25

# то же самое через def:
def square(x):
    return x ** 2

Lambda удобна для передачи функции одной строкой туда, где она нужна:

records = [
    {"name": "Анна", "age": 30},
    {"name": "Борис", "age": 25},
    {"name": "Вера", "age": 35},
]

# сортировка по возрасту
sorted_records = sorted(records, key=lambda r: r["age"])

# сортировка по убыванию имени
sorted_records = sorted(records, key=lambda r: r["name"], reverse=True)

Когда lambda не нужна:

# плохо — присвоение lambda через имя:
add = lambda a, b: a + b

# хорошо — обычное def:
def add(a: int, b: int) -> int:
    return a + b

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

Также lambda ограничена одним выражением — без присваиваний, без if-statement (только тернарный), без циклов. Если нужно — пишите def.

Type hints для функций

Базовые правила, которым следуем во всём курсе:

def parse_record(
    raw: dict[str, str],
    *,
    strict: bool = False,
) -> dict[str, int | str | None]:
    ...
  • Каждый аргумент аннотируется.
  • Возвращаемое значение через ->.
  • Современный синтаксис: list[str], dict[str, int], int | None — без from typing import List.
  • Если функция ничего не возвращает — -> None.
def log_event(event: dict) -> None:
    print(event)    # никакого return

Глубже в типах (Generics, Protocol, TypeAlias, Pydantic) — в модуле 5. Пока этих базовых аннотаций достаточно.

Docstrings

Docstring — это первая строка-литерал внутри функции. Стандарт оформления —

PEP 257
:

def calculate_tax(amount: float, rate: float = 0.2) -> float:
    """Считает НДС от суммы.

    Args:
        amount: сумма без НДС.
        rate: ставка НДС, по умолчанию 20%.

    Returns:
        Сумма НДС.

    Example:
        >>> calculate_tax(100.0)
        20.0
    """
    return amount * rate

Три популярных стиля docstring:

  • Google style — как в примере выше. Самый читаемый для людей.
  • NumPy style — заголовки с ---, чуть длиннее.
  • reST (reStructuredText):param x:, :return:. Старый Sphinx-стиль, в новом коде редко.

В этом курсе придерживаемся Google style. Главное правило — первая строка одна, описывает функцию в повелительном наклонении или 3-м лице: «Считает НДС», «Returns the user by id», «Парсит JSON-строку».

Подробные docstring нужны не для тривиальных функций (add(a, b) не требует пояснений), а для публичного API модуля: точек входа ETL, утилит, которые будут переиспользоваться.

DE-кейс: функция-валидатор записи

Соберём всё вместе на типовой DE-задаче — валидатор записи перед загрузкой в БД.

def validate_record(
    raw: dict,
    *,
    required: list[str] | None = None,
    strict_types: bool = True,
) -> dict | None:
    """Валидирует входящую запись и нормализует поля.

    Args:
        raw: входящая запись из источника (JSON / CSV row).
        required: список обязательных ключей. Если поле отсутствует
            или пустое — запись считается невалидной.
        strict_types: если True, не пытается приводить типы.
            При False попытается int('30') → 30 и т.п.

    Returns:
        Очищенная запись или None, если запись невалидна.
        Лог о причине отказа пишется в stderr.
    """
    if required is None:
        required = ["id", "name"]

    for field in required:
        if not raw.get(field):
            print(f"reject: missing {field}", flush=True)
            return None

    cleaned: dict = {}
    for k, v in raw.items():
        if isinstance(v, str):
            v = v.strip()
            if not v:
                v = None
        cleaned[k] = v

    if not strict_types and "age" in cleaned and isinstance(cleaned["age"], str):
        try:
            cleaned["age"] = int(cleaned["age"])
        except ValueError:
            print(f"reject: bad age={cleaned['age']!r}", flush=True)
            return None

    return cleaned


# пример использования
raw = {"id": "42", "name": "  Анна  ", "age": "30", "email": ""}
result = validate_record(raw, strict_types=False)
print(result)
# {'id': '42', 'name': 'Анна', 'age': 30, 'email': None}

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

  • required: list[str] | None = None — правильный паттерн для mutable default.
  • strict_types: bool = True после * — нельзя случайно передать позицией.
  • Type hint -> dict | None — функция явно может вернуть None.
  • Docstring описывает поведение, аргументы, возвращаемое значение.
  • Возвращение None при ошибке валидации — обычный паттерн «отфильтровать». В следующих модулях мы будем использовать исключения для серьёзных проблем и None для нормально-ожидаемых.

Упражнение

В файле functions_practice.py:

# 1. Напишите функцию parse_money(s: str) -> float:
# Преобразует строку "1 234,56 ₽" → 1234.56 (убрать пробелы, заменить запятую на точку, убрать символы валюты).
# Если на вход пришло "abc" — должна вернуть None.
# Используйте type hint, docstring, поддержите rate-like строки "1234.56".

# 2. Напишите функцию build_query_string(base: str, **params) -> str:
# build_query_string("https://api.com", page=1, limit=10) → "https://api.com?page=1&limit=10"
# Параметры со значением None должны не попадать в запрос.

# 3. Найдите и исправьте баг в функции:
def append_log(line, log=[]):
    log.append(line)
    return log

# 4. Перепишите следующую функцию, используя keyword-only аргументы для всех булевых флагов:
def upload(path, bucket, overwrite, compress, async_mode):
    ...

Критерии:

  • В (1) type hints, docstring, обработка некорректного ввода без exception.
  • В (2) None-значения отфильтрованы.
  • В (3) баг с mutable default исправлен.
  • В (4) булевые флаги — только через keyword arguments.

В следующем уроке — модули и импорты. Когда скрипт растёт больше 100 строк, его нужно разбивать на файлы.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 8