💻 Практические примеры

⚡ Минимальный рабочий пример

def greet(name: str) -> str:
    """Return a greeting for name."""
    return f"Hello, {name}!"
def factorial(n: int) -> int:
    return 1 if n <= 1 else n * factorial(n - 1)
print(greet.__doc__)   # Return a greeting for name.
print(greet("Alice"))  # Hello, Alice!
print(factorial(5))    # 120
ТемаСутьПример
docstring / help()документация функции в тройных кавычках сразу после заголовкаfunc.__doc__, help(func)
Аннотации типовподсказка для IDE и mypy, во время выполнения не проверяютсяdef add(a: int, b: int) -> int
Рекурсиянужен базовый случай и движение аргумента к немуif n <= 1: return 1
Хвостовая рекурсияв Python не оптимизируется — для больших n нужен циклfactorial_iterative(n)
deepcopy()рекурсивно копирует вложенные списки/словари/множестваfrom copy import deepcopy
Топ-3 ошибки: забытый базовый случай → RecursionError · type hints не проверяются в рантайме · copy() (поверхностная) вместо deepcopy() (глубокой).

Примеры идут в порядке лекций, которые повторяет этот урок: документация функций (docstrings, help(), аннотации типов) → работа со словарями → рекурсия (факториал, бинарный поиск, хвостовая рекурсия, обход вложенных структур) → глубокое копирование. Весь вывод в комментариях получен реальным запуском кода, а не набран по памяти.

Пример 1. Docstring и help()

Функция без документации понятна только тому, кто её написал. Docstring — встроенный способ объяснить, что делает функция, прямо в коде: строка в тройных кавычках сразу после заголовка сохраняется в атрибуте __doc__ и видна через help(), в отличие от обычного комментария #, который ни __doc__, ни help() не видят.

# docstring_example.py
def greet(name: str) -> str:
    """Возвращает строку приветствия.

    :param name: Имя пользователя.
    :return: Приветственное сообщение.
    """
    return f"Hello, {name}!"


print(greet("Alice"))
# Hello, Alice!

print(greet.__doc__)
# Возвращает строку приветствия.
#
#     :param name: Имя пользователя.
#     :return: Приветственное сообщение.

help(greet)
# Help on function greet in module __main__:
#
# greet(name: str) -> str
#     Возвращает строку приветствия.
#
#     :param name: Имя пользователя.
#     :return: Приветственное сообщение.

Что происходит: тройные кавычки сразу после заголовка функции не выполняются как код — интерпретатор сохраняет их в greet.__doc__. help(greet) идёт дальше: берёт сигнатуру функции вместе с аннотациями типов и её docstring и печатает их в едином человекочитаемом виде — так же, как help() работает для встроенных функций вроде help(sum).

Пример 2. Аннотации типов

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

# type_hints_example.py
def add(a: int, b: int) -> int:
    """Возвращает сумму двух целых чисел."""
    return a + b


def convert_to_celsius(fahrenheit: float) -> float:
    """Конвертирует температуру из Фаренгейта в Цельсий."""
    return (fahrenheit - 32) * 5 / 9


def is_even(number: int) -> bool:
    """Определяет, является ли число чётным."""
    return number % 2 == 0


def log_message(message: str) -> None:
    """Выводит сообщение в консоль."""
    print(f"LOG: {message}")


print(add(3, 5))                 # 8
print(convert_to_celsius(68.0))  # 20.0
print(is_even(4))                # True
log_message("start")             # LOG: start

Почему так: аннотации int, float, bool, None хранятся в __annotations__ функции и используются IDE и mypy для статического анализа. Во время выполнения Python их не проверяет: add("3", "5") вернёт строку "35", а не вызовет ошибку.

Пример 3. Инверсия словаря

Словарь отображает ключ в значение только в одну сторону. Если значения уникальны, полезно построить обратное отображение — например, чтобы по коду товара найти его название, имея словарь «название → код».

# invert_dict.py
original_dict = {"a": 1, "b": 2, "c": 3}
inverted_dict = {value: key for key, value in original_dict.items()}
print("Инверсированный словарь:", inverted_dict)
# Инверсированный словарь: {1: 'a', 2: 'b', 3: 'c'}

Что происходит: {value: key for key, value in original_dict.items()} проходит по парам исходного словаря и строит новый, меняя ключ и значение местами. Результат корректен, только если исходные значения уникальны и хешируемы — иначе более поздняя пара молча перезапишет более раннюю с тем же значением.

Пример 4. Замена чисел на слова

Часто нужно заменить значения словаря по правилам из другого словаря-сопоставления, а не по формуле. dict.get(value, value) делает это безопасно: если сопоставления нет, значение остаётся как есть, а не превращается в None.

# numbers_to_words.py
number_to_word = {1: "один", 2: "два", 3: "три"}
data = {"x": 1, "y": 2, "z": 3}

translated = {
    key: number_to_word.get(value, value)
    for key, value in data.items()
}
print(translated)
# {'x': 'один', 'y': 'два', 'z': 'три'}

Почему так: number_to_word.get(value, value) ищет value как ключ в словаре сопоставлений; если не находит — возвращает второй аргумент, то есть само value. Это надёжнее, чем number_to_word[value], который упал бы с KeyError на любом числе вне словаря.

Пример 5. Факториал

Факториал — классический пример рекурсии с одним базовым случаем (0! = 1! = 1) и одним рекурсивным (n! = n × (n-1)!). Аргумент n - 1 в каждом вызове приближается к базовому случаю — без этого получилась бы бесконечная рекурсия.

# factorial.py
def factorial(n: int) -> int:
    """Возвращает факториал неотрицательного целого числа."""
    if n == 0 or n == 1:
        return 1
    return n * factorial(n - 1)


print(factorial(5))  # 120

Что происходит: каждый вызов factorial(n) откладывает умножение и вызывает factorial(n - 1), пока не дойдёт до n == 1. После этого стек вызовов разворачивается снизу вверх: 1, затем 2 × 1, затем 3 × 2, и так до 5 × 24 = 120.

Пример 6. Бинарный поиск

Бинарный поиск находит элемент в отсортированном списке за O(log n) вместо O(n) у линейного перебора — на каждом шаге отсекается половина диапазона поиска.

# binary_search.py
from typing import Optional


def binary_search(
    arr: list[int],
    target: int,
    left: int,
    right: int
) -> Optional[int]:
    """Ищет target в отсортированном списке arr. Возвращает индекс или None."""
    if left > right:
        return None
    mid = (left + right) // 2
    if arr[mid] == target:
        return mid
    if arr[mid] < target:
        return binary_search(arr, target, mid + 1, right)
    return binary_search(arr, target, left, mid - 1)


array = [1, 3, 5, 7, 9, 11, 13]
print(binary_search(array, 5, 0, len(array) - 1))   # 2
print(binary_search(array, 13, 0, len(array) - 1))  # 6
print(binary_search(array, 8, 0, len(array) - 1))   # None

Почему так: на каждом рекурсивном вызове диапазон [left, right] сужается вдвое: если arr[mid] меньше target, интересующая половина — правая, иначе — левая. Базовый случай left > right означает, что диапазон опустел и элемента в массиве нет, поэтому функция возвращает None.

Пример 7. Хвостовая рекурсия и итерация

Здесь важно показать, что «хвостовая» форма рекурсии (рекурсивный вызов — последняя операция функции) не даёт в Python выигрыша по сравнению с обычной рекурсией — в отличие от языков вроде Scheme, где такую рекурсию оптимизирует компилятор.

# tail_vs_iterative.py
def factorial_tail(n: int, accumulator: int = 1) -> int:
    """Хвостовая рекурсия (не оптимизируется в Python)."""
    if n == 0 or n == 1:
        return accumulator
    return factorial_tail(n - 1, n * accumulator)


def factorial_iterative(n: int) -> int:
    """Итеративный факториал."""
    accumulator = 1
    while n > 1:
        accumulator *= n
        n -= 1
    return accumulator


print(factorial_tail(5))        # 120
print(factorial_iterative(5))   # 120

Что происходит: оба варианта дают одинаковый результат, но накладные расходы разные. factorial_tail всё равно создаёт n кадров стека, потому что Python не убирает кадр до фактического возврата из рекурсии — оптимизации хвостовых вызовов (TCO) в CPython нет. factorial_iterative работает в одном кадре и с постоянной памятью, поэтому именно её стоит использовать при больших n.

Пример 8. Собственный аналог deepcopy

Здесь видно, что deepcopy() — не магия, а рекурсивный обход структуры: списки, кортежи, множества и словари копируются поэлементно, а неизменяемые простые типы (числа, строки) возвращаются как есть, потому что их и так нельзя изменить на месте.

# custom_deepcopy.py
def deep_copy(data):
    """Рекурсивно копирует списки, кортежи, множества и словари."""
    if isinstance(data, list):
        return [deep_copy(item) for item in data]
    if isinstance(data, dict):
        return {key: deep_copy(value) for key, value in data.items()}
    if isinstance(data, set):
        return {deep_copy(item) for item in data}
    if isinstance(data, tuple):
        return tuple(deep_copy(item) for item in data)
    return data


original = [[1, 2], (4, [5, 6], {7, 8}), {"a": 9, "b": [10, 11]}]
copied = deep_copy(original)
original[1][1][0] = 0
print("Оригинал:", original)
# Оригинал: [[1, 2], (4, [0, 6], {8, 7}), {'a': 9, 'b': [10, 11]}]
print("Копия:   ", copied)
# Копия:    [[1, 2], (4, [5, 6], {8, 7}), {'a': 9, 'b': [10, 11]}]

Почему так: deep_copy проверяет тип через isinstance и для каждого контейнера строит новый объект, рекурсивно копируя элементы. Когда original[1][1][0] меняется (вложенный список внутри кортежа), это не задевает copied, потому что кортеж внутри copied ссылается на отдельно созданный список. Множество при печати может показывать элементы в другом порядке, чем при создании ({8, 7} вместо {7, 8}) — это особенность хранения set в CPython, а не ошибка кода.

Пример 9. Guard clause и что происходит без базового случая

Рекурсия без базового случая — не абстрактная опасность, а реальная ошибка (см. типичные ошибки, ошибка 1). А для отрицательного n у факториала базового случая нет вообще — это математически не определено, и корректный код должен явно сообщить об этом через исключение, а не тихо уйти в бесконечную рекурсию. Задание 3 в разделе заданий просит реализовать именно guard clause для отрицательных чисел.

# factorial_guard.py
def factorial_safe(n: int) -> int:
    """Возвращает факториал неотрицательного целого числа.

    :raises ValueError: если n отрицательное.
    """
    if n < 0:
        raise ValueError("n must be >= 0")
    if n == 0 or n == 1:
        return 1
    return n * factorial_safe(n - 1)


def factorial_broken(n: int) -> int:
    """Без базового случая — рекурсия никогда не остановится."""
    return n * factorial_broken(n - 1)


print(factorial_safe(5))  # 120

try:
    factorial_safe(-3)
except ValueError as error:
    print("ValueError:", error)
    # ValueError: n must be >= 0

try:
    factorial_broken(5)
except RecursionError as error:
    print("RecursionError:", error)
    # RecursionError: maximum recursion depth exceeded

Что происходит: factorial_safe отдельно проверяет n < 0 и поднимает ValueError — вызывающий код сразу получает понятную ошибку вместо неверного результата или зависания. factorial_broken — та же ошибка, что в разделе типичных ошибок: у него вообще нет условия остановки, поэтому Python рано или поздно поднимает RecursionError, когда стек вызовов достигает предела глубины.

⚠️ Проверить по документации: предел глубины рекурсии по умолчанию и способ его узнать или изменить — sys.getrecursionlimit() и sys.setrecursionlimit() — за рамками материалов этого урока. Сверьтесь с docs.python.org: sys.getrecursionlimit.

Пример 10. Сумма цифр числа (рекурсия по разрядам)

Рекурсия — не только про списки и деревья, но и про числа: если число больше одной цифры, его можно разбить на «последнюю цифру» и «всё остальное» через % 10 и // 10 и вызвать себя от остатка. Задание 5 просит реализовать именно эту функцию.

# sum_digits.py
def sum_digits(n: int) -> int:
    """Возвращает сумму цифр неотрицательного целого числа."""
    if n < 10:
        return n
    return n % 10 + sum_digits(n // 10)


print(sum_digits(43197))  # 24

Почему так: n % 10 отделяет последнюю цифру, а n // 10 отбрасывает её, уменьшая число на один разряд с каждым вызовом — это и есть движение к базовому случаю n < 10 (однозначное число, сумма цифр которого равна ему самому). Для 43197 стек разворачивается как 7 + (9 + (1 + (3 + 4))) = 24.

Пример 11. Сумма чисел во вложенных списках

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

# sum_nested.py
def sum_nested(numbers: list) -> int:
    """Рекурсивно суммирует числа во вложенных списках."""
    total = 0
    for item in numbers:
        if isinstance(item, list):
            total += sum_nested(item)
        else:
            total += item
    return total


nested_numbers = [1, [2, 3], [4, [5, 6]], 7]
print(sum_nested(nested_numbers))  # 28

Что происходит: функция проходит по элементам numbers и проверяет каждый через isinstance(item, list): для вложенного списка она рекурсивно вызывает sum_nested(item) и прибавляет результат, для числа — прибавляет само число. Глубина вложенности значения не имеет: [4, [5, 6]] разворачивается в 4 + (5 + 6), а весь список — в 1 + (2 + 3) + (4 + (5 + 6)) + 7 = 28.

Как запустить в VS Code

  1. Создайте файл, например summary_12_examples.py.
  2. Скопируйте код одного из примеров выше.
  3. Откройте терминал в VS Code (Ctrl + `).
  4. Убедитесь, что активировано виртуальное окружение.
  5. Запустите: python summary_12_examples.py