Что такое функция (если встречаешь это слово впервые)
Функция — это многоразовый рецепт. Ты один раз описываешь последовательность действий и даёшь ей имя; дальше достаточно назвать имя — и действия выполнятся снова, сколько угодно раз. Другая аналогия: функция как кнопка на кофемашине. Внутри происходит много шагов (намолоть зёрна, нагреть воду, пролить), но тебе достаточно нажать кнопку «эспрессо» и получить результат. Ты можешь передать кнопке «настройки» (например, сколько порций) — это аргументы, а то, что кнопка отдаёт обратно (чашку кофе) — это возвращаемое значение.
Зачем это нужно: без функций один и тот же кусок кода пришлось бы копировать в десяти местах, а при изменении — править во всех десяти. С функцией правишь один рецепт, и он чинится везде.
Главный кирпичик кода
Если переменные хранят данные, а условия и циклы организуют поток, то функции — главный способ разделить программу на куски, которые имеют смысл по отдельности. Хорошие функции имеют чёткие границы: «вот вход — вот выход». На уровне 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'] — отдельный список
Любой 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 полезны:
- Для wrapper-функций, прокидывающих аргументы дальше:
def wrapper(*args, **kwargs): return inner(*args, **kwargs). - Для функций с неизвестным заранее набором полей (как в логировании).
- Для расширяемых 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 — это первая строка-литерал внутри функции. Стандарт оформления —
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 строк, его нужно разбивать на файлы.