Введение
Python — язык обманчиво простой. Мы любим его за то, что он позволяет набросать прототип за вечер: никакой тебе возни с компиляторами, строгой типизацией или управлением памятью вручную. Пишешь как думаешь, и оно работает. Кайф? Кайф.
Но у этой медали есть обратная сторона. Python прощает слишком многое. Он позволяет писать код, который выглядит рабочим, проходит базовые тесты и даже Code Review (если ревьюер устал или поторопился). Вы катите это в продакшен... а через неделю сервер ложится от переполнения памяти, или данные пользователей начинают загадочно перемешиваться.
Ошибка №1: Капкан с изменяемыми аргументами (Mutable Default Arguments)
Начнем с классики, которую любят спрашивать на собеседованиях. Казалось бы, каждый Python-разработчик знает: «Не пиши пустой список в аргументах по умолчанию!». Но знать правило и понимать физику процесса — разные вещи.
Давайте честно, у многих в проектах до сих пор проскакивает вот такой код:
def add_item_to_cart(item, cart=[]):
cart.append(item)
return cart
Выглядит безобидно. Логика автора проста: «Если корзину не передали, создай новую пустую и положи туда товар».
Что происходит под капотом?
Проблема в том, как Python воспринимает инструкцию def. Для интерпретатора def — это исполняемая команда. Она выполняется ровно один раз — в момент, когда Python впервые читает этот файл (момент определения функции).
Именно в этот момент Python создает объект функции и вычисляет значения аргументов по умолчанию. Он создает этот пустой список [] и «приклеивает» его к функции навсегда. Вы можете даже увидеть его своими глазами, заглянув в атрибут __defaults__:
print(add_item_to_cart.__defaults__)
# ([],) — вот он, наш список, живет внутри функции!
Каждый раз, когда вы вызываете функцию без второго аргумента, вы обращаетесь не к новому списку, а к тому самому, созданному при старте программы.
Почему это непростительно?
Локально это может не выстрелить. Вы запустили скрипт, проверили один раз — работает. Перезапустили — снова работает.
А теперь представьте, что этот код попадает в веб-сервис (Django, FastAPI — неважно). Приложение запускается и висит в памяти неделями.
Приходит Юзер А, добавляет товар. Список
cartвнутри функции становится['iPhone'].Приходит Юзер Б, добавляет свой товар. Он ожидает пустую корзину, но Python подсовывает ему тот же самый список-объект. Теперь в корзине
['iPhone', 'Samsung'].
Поздравляю, у вас утечка данных. Пользователи начинают видеть чужие заказы. В масштабах биллинга или банковских транзакций — это катастрофа, за которую увольняют.
Как делать правильно
Паттерн всего один — используйте None как маркер пустоты.
def add_item_to_cart(item, cart=None):
if cart is None:
cart = [] # Новый список создается ВНУТРИ функции при каждом вызове
cart.append(item)
return cart
Здесь список создается в теле функции (runtime), а не в заголовке (definition time). Каждый вызов гарантированно получает свежий, чистый объект.
Декораторы
Кстати, эта ловушка поджидает вас не только в функциях. Если вы пишете свои декораторы и инициализируете какую-то переменную в теле декоратора (а не внутри обертки wrapper), она тоже выполнится один раз при импорте модуля.
Это частая причина странных багов, когда состоя��ие (например, кеш или счетчик вызовов) становится общим для всех задекорированных функций, хотя вы планировали их изолировать. Механика та же: код исполняется один раз при чтении файла, а «память» остается навсегда.
Ошибка №2: «Слепой» перехват, или почему ваш код бессмертен
Есть у разработчиков соблазн — сделать так, чтобы скрипт никогда не падал. Ну, знаете, это ложное чувство безопасности: «Оберну-ка я всё в try-except, и если что-то пойдет не так, программа просто пойдет дальше».
Выглядит это обычно так:
try:
complex_data_processing()
save_to_db()
except: # Или except Exception:
pass
В кругах опытных питонистов за такой код бьют по рукам линейкой. И вот почему.
1. Вы создаете зомби, которого нельзя убить
Конструкция except: (без указания типа исключения) перехватывает всё. Вообще всё. Включая системные сигналы.
Когда вы нажимаете Ctrl+C в терминале, Python генерирует KeyboardInterrupt. Когда система просит процесс завершиться, летит SystemExit. Ваш «слепой» except радостно ловит эти сигналы и говорит: «Не, мы работаем дальше». В итоге скрипт превращается в неуправляемый процесс, который приходится убивать через kill -9.
2. Вы прячете реальные баги
Представьте, что внутри блока try вы допустили опечатку. Написали usre_id вместо user_id. Python честно кидает NameError.
Но ваш except ловит его и... просто молчит. Программа работает дальше, логика ломается, данные не сохраняются, а вы смотрите в консоль и не понимаете, что происходит. Ошибок нет, но ничего не работает. Дебажить такое — сущий ад.
Реальный кейс из жизни
Однажды нам пришлось разбирать инцидент. Сервис обработки платежей неделю (!) работал «вхолостую».
Пользователи нажимали «Оплатить», сайт говорил «Спасибо», но деньги не списывались, а заказы не создавались. Техподдержка молчала, потому что мониторинг был зеленый. Ошибок в логах — ноль.
Оказалось, джуниор обернул коннект к базе данных в такой вот try-except pass. В какой-то момент пароль от базы сменили. Скрипт просто перестал подключаться к БД, молча проглатывал ошибку OperationalError и шел дальше, делая вид, что всё хорошо. Неделя потерянной выручки из-за четырех строк кода.
Как делать правильно
Правило написано: перехватывайте только то, что ожидаете.
Если вы боитесь ошибки сети — ловите ConnectionError. Боитесь кривого JSON — ловите JSONDecodeError.
import logging
try:
save_to_db(data)
except (ConnectionError, TimeoutError) as e:
# Мы знаем, что это может случиться, и знаем, как реагировать
logging.error(f"База недоступна, пробуем повторить: {e}")
retry_later(data)
except Exception as e:
# На самый крайний случай — ловим всё остальное, НО обязательно логируем
logging.exception("Произошла непредвиденная ошибка!")
raise # И пробрасываем дальше, чтобы приложение всё-таки упало (или уведомило Sentry)
Запомните: упавшее приложение лучше, чем приложение, которое молча врет, что работает.
Ошибка №3: Синхронный код в асинхронном (Sync in Async)
Сейчас все бегут на FastAPI и aiohttp. Это модно, молодежно и производительно. Выучить синтаксис несложно: приписал async перед def, await перед вызовом — и ты великолепен.
Но именно здесь кроется самая коварная ловушка для тех, кто переезжает с Django или Flask. Старые привычки умирают тяжело, и в коде появляется вот это:
import requests
import time
@app.get("/process")
async def process_data():
# "Ну мне же надо просто дернуть стороннюю API..."
response = requests.get("https://slow-service.com/api")
# "...или подождать пару секунд"
time.sleep(5)
return {"status": "ok"}
Механика: Почему всё встало?
Чтобы понять, почему этот код — преступление, нужно вспомнить, как работает Event Loop (цикл событий).
Представьте, что ваш сервер — это один-единственный официант в ресторане (поток). В асинхронном режиме он работает гениально: принял заказ у столика №1, отдал на кухню, и пока там готовят, побежал принимать заказ у столика №2. Он не стоит и не ждет. Благодаря этому Python на одном ядре может держать тысячи соединений.
Но когда вы пишете requests.get(...) или time.sleep(...) внутри async def, вы заставляете этого официанта встать и ждать, пока повара пожарят котлету. Он не принимает новые заказы, он не отдает готовые блюда. Он просто стоит.
В этот момент ваш сервер «умирает» для всех остальных пользователей. Если запрос длится 5 секунд, то в течение 5 секунд сервер не ответит никому. Даже тем, кто просто запросил главную страницу. Health-check'и от Kubernetes начнут падать, балансировщик решит, что инстанс мертв, и начнет его перезагружать.
Почему это непростительно?
Потому что это убивает саму суть асинхронности. Один «тяжелый» синхронный запрос способен положить высоконагруженный сервис. Вы хотели производительности, а получили однопоточный скрипт, работающий хуже, чем старый добрый Flask на тредах.
Как делать правильно
Сетевые запросы (I/O):
Забудьте проrequests. Вообще. Если вы в асинхронном мире, ваши друзья —httpxилиaiohttp. Для работы с базой —asyncpgилиMotor.import httpx async with httpx.AsyncClient() as client: resp = await client.get("https://...") # Официант отдал заказ и ушел работать дальшеТяжелые вычисления (CPU-bound):
Если вам нужно обработать картинку, посчитать хэш или распарсить гигантский JSON,awaitвам не поможет (это нагрузка на процессор, а не ожидание сети).
Такие задачи нужно выбрасывать из Event Loop'а:Вариант "Быстро и грязно": Запустить в отдельно через
run_in_executor:loop = asyncio.get_running_loop() # Выполняем синхронную функцию в отдельно await loop.run_in_executor(None, heavy_function, arg1)Вариант "По-взрослому": Отправить задачу в очередь (Celery, TaskIQ, Dramatiq) и пусть её жуют отдельные воркеры. Главный сервер должен только принимать запросы.
Ошибка №4: Магия замыканий, или почему все ваши функции делают одно и то же
А вот здесь сыпятся даже опытные ребята. Это тонкий момент работы с областями видимости (scope), который выглядит как настоящий полтергейст.
Допустим, вам нужно динамически сгенерировать список функций. Например, создать 5 множителей: первый умножает на 0, второй на 1, и так далее. Вы пишете элегантный list comprehension:
# Создаем список функций: lambda x: x * 0, lambda x: x * 1 ...
multipliers = [lambda x: x * i for i in range(5)]
# Проверяем. Ожидаем: 2 * 0 = 0, 2 * 1 = 2 ...
results = [m(2) for m in multipliers]
Вы ожидаете увидеть [0, 2, 4, 6, 8].
Запускаете код.
Получаете: [8, 8, 8, 8, 8].
В этот момент хочется разбить монитор. Почему они все умножают на 4 (последнее значение i)? Вы же создавали их в цикле, когда i была разной!
Механика: Позднее связывание (Late Binding)
Проблема в том, как лямбда (или любая вложенная функция) «видит» внешние переменные.
В Python замыкания работают по ссылке, а не по значению. Когда вы пишете lambda x: x * i, функция не запоминает: «Ага, сейчас i равна нулю, запомню ноль». Она запоминает: «Мне нужно умножить x на переменную по имени i из внешней области видимости».
Функция ленива. Она не ищет значение i в момент создания. Она лезет за ним только в момент вызова.
А когда вы наконец вызываете эти функции (строкой ниже), цикл for уже давно закончился. Переменная i в глобальной области осталась висеть со значением 4. И все 5 ваших лямбд радостно лезут в одну и ту же переменную и видят там четверку.
Почему это непростительно?
Это логическая ошибка. Код валиден, ошибок нет, но логика работает совершенно не так, как задумано.
Особенно больно это бьет при создании UI-интерфейсов (например, в Tkinter или PyQt) или асинхронных колбэков, когда вы в цикле вешаете обработчики событий на кнопки:
Button 1, Button 2, Button 3...
И какую бы кнопку ни нажал юзер, срабатывает обработчик для последней.
Как делать правильно
Нам нужно заставить Python «заморозить» текущее значение i прямо в момент создания функции. Помните Ошибку №1 про аргументы по умолчанию? Там это было злом, а здесь — наше спасение.
Значения дефолтных аргументов вычисляются в момент определения функции. Используем это!
# Передаем i как аргумент по умолчанию
multipliers = [lambda x, i=i: x * i for i in range(5)]
Что здесь происходит:
При каждой итерации создается лямбда, у которой есть своя собственная локальная переменная i. Её значение берется из внешней i прямо сейчас (в момент создания) и «запекается» внутрь функции.
Теперь, даже когда внешняя i изменится, внутренняя локальная i останется той, какой была при рождении.
Ошибка №5: Игры со временем (Naive Datetime)
Если вы думаете, что время — это просто часы и минуты, вы ещё не дебажили биллинг в ночь перевода часов на зимнее время.
Новички (да и не только) обожают писать так:
from datetime import datetime
# "Просто дай мне текущее время"
created_at = datetime.now()
# Результат: 2023-10-05 14:30:00 (и никакой информации о часовом поясе)
В документации такие объекты называют Naive (наивные). Они не знают, где они находятся: в Москве, Нью-Йорке или на Гринвиче. И это главная причина головной боли при масштабировании.
В чем проблема?
Географический винегрет
Представьте цепочку:Ваш ноутбук в Москве (UTC+3).
Сервер приложения в Ирландии (UTC+0 или +1).
База данных (Postgres) настроена админом по дефолту в UTC.
Вы сохраняете
datetime.now()с сервера. В базу падает14:00. Потом вы читаете это время скриптом с ноутбука, Python думает, что это локальное время (МСК). В итоге событие «улетает» на 3 часа в будущее или прошлое. Вы никогда не узнаете, когда на самом деле произошла ошибка.Ад с переводом часов (DST)
В странах, где переводят часы, есть «волшебный» час осенью, когда время отматывается назад.
Было02:59, стало02:00.
Если вы используете Naive time, у вас в логах появятся две записи с меткой02:30. Какая была раньше? Какая позже? Порядок событий восстановить невозможно. Для финансовых транзакций это приговор.
Почему это непростительно?
Потому что исправить это задним числом почти невозможно. Если у вас в базе лежит миллион записей с датой 2023-10-05 10:00:00 без таймзоны, вы не знаете: это 10 утра по Лондону или по Токио? Ваша аналитика врет, графики врут, а пользователи жалуются, что подписка истекла на день раньше.
Как делать правильно
Золотое правило бэкенда: Внутри системы всегда UTC. Локальное время — только для отображения пользователю.
Нам нужны Aware (осведомленные) объекты.
from datetime import datetime, timezone
# ПРАВИЛЬНО: Берем время сразу с привязкой к UTC
now_utc = datetime.now(timezone.utc)
# Результат: 2023-10-05 14:30:00+00:00 (хвост +00:00 важен!)
Всегда создавайте даты с
timezone.utc.В базе данных используйте тип
TIMESTAMP WITH TIME ZONE(в Postgres).Если нужно показать время пользователю из Владивостока — конвертируйте UTC в его часовой пояс в самый последний момент (на фронтенде или в шаблонизаторе).
Вместо заключения
Python — дружелюбный язык. Он дает вам заряженный пистолет и не спрашивает, умеете ли вы стрелять.
Изменяемые дефолтные аргументы.
Exceptбез указания типа.Синхронный код в
async.Замыкания в циклах.
Naive datetime.
Это не просто «плохой стиль». Это баги, которые стоят денег. Проверьте свой проект прямо сейчас. Если нашли что-то из списка — вы знаете, чем заняться в ближайший спринт.
Анонсы новых статей, полезные материалы, а так же если в процессе у вас возникнут сложности, обсудить их или задать вопрос по этой статье можно в моём Telegram-сообществе. Смело заходите, если что-то пойдет не так, — постараемся разобраться вместе.
Пишите чистый код, и да пребудет с вами Zen of Python.
(P.S. Расскажите в комментариях, какой самый глупый баг ронял ваш прод? Мой фаворит — rm -rf не в той папке, но это уже совсем другая история).
