Команда Python for Devs подготовила перевод статьи о том, как делать код на Python быстрее без переписывания проектов с нуля. В статье 10 практичных приёмов — от sets и bisect до локальных функций и предвыделения памяти — которые дают реальный прирост скорости в типовых сценариях.


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

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

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

Поехали — прокачаем твоё питонячье мастерство!

Приём 1: Используйте sets для проверки принадлежности

Когда нужно узнать, содержится ли элемент в наборе данных, использование списка может быть неэффективным — особенно по мере его роста. Проверка принадлежности в списке (x in some_list) требует последовательного обхода всех элементов, что даёт линейную сложность по времени (O(n)):

big_list = list(range(1000000))
big_set = set(big_list)
start = time.time()
print(999999 in big_list)
print(f"List lookup: {time.time() - start:.6f}s")
start = time.time()
print(999999 in big_set)
print(f"Set lookup: {time.time() - start:.6f}s")

Измеренное время:

  • List lookup: ~0.015000s

  • Set lookup: ~0.000020s

В отличие от списков, sets в Python реализованы как хеш-таблицы, что обеспечивает в среднем постоянное время поиска (O(1)). Поэтому проверка того, существует ли значение в set, значительно быстрее, особенно при работе с большими объемами данных.

Для задач вроде удаления дублей, валидации входных данных или сопоставления элементов между коллекциями sets куда эффективнее списков. Они ускоряют не только проверку принадлежности, но и такие операции, как объединение, пересечение и разность — делая их и быстрее, и выразительнее.

Заменив списки на sets в местах, где важна производительность проверки принадлежности, можно получить ощутимый прирост скорости, практически не меняя логику программы.

Приём 2: Избегайте лишних копий

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

Когда это возможно, изменяйте объекты на месте вместо создания дубликатов. Это сокращает потребление памяти и повышает производительность, поскольку не требует выделения и заполнения новых структур. Многие встроенные структуры данных Python имеют методы для изменения на месте (sort, append, update), что позволяет обходиться без копий.

numbers = list(range(1000000))
def modify_list(lst):
    lst[0] = 999
    return lst
start = time.time()
result = modify_list(numbers)
print(f"In-place: {time.time() - start:.4f}s")
def copy_list(lst):
    new_lst = lst.copy()
    new_lst[0] = 999
    return new_lst
start = time.time()
result = copy_list(numbers)
print(f"Copy: {time.time() - start:.4f}s")

Измеренное время:

  • In-place: ~0.0001s

  • Copy: ~0.0100s

В производительно критичном коде важно осознавать, когда и как создаются копии объектов — это может заметно повлиять на скорость. Используя ссылки и операции “на месте”, вы можете писать более быстрый и экономный по памяти код, особенно при работе с крупными или сложными структурами данных.

Приём 3: Используйте slots для экономии памяти

По умолчанию экземпляры классов в Python хранят свои атрибуты в динамическом словаре (__dict__). Это даёт гибкость, но сопровождается лишними расходами памяти и чуть более медленным доступом к атрибутам.

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

Хотя slots ограничивает возможность динамического добавления атрибутов, в условиях ограниченной памяти или требований к производительности эта жертва себя оправдывает. Для “лёгких” классов или контейнеров данных использование slots — простой способ сделать код эффективнее.

class Point:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y
start = time.time()
points = [Point(i, i+1) for i in range(1000000)]
print(f"With slots: {time.time() - start:.4f}s")

Измеренное время:

  • With slots: ~0.1200s

  • Without slots: ~0.1500s

With __slots__: ~0.1200s  
Without __slots__: ~0.1500s

Приём 4: Используйте функции модуля math вместо операторов

Для численных вычислений модуль math предлагает функции, реализованные на C, — они работают быстрее и точнее, чем аналогичные операции, написанные на чистом Python.

Например, math.sqrt() обычно быстрее и точнее, чем возведение числа в степень 0.5 через оператор **. Точно так же функции math.sin(), math.exp(), math.log() и другие сильно оптимизированы по скорости и надёжности.

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

Используйте функции math вместо операторов. PyCharm ещё больше упрощает использование модуля math благодаря умному автодополнению. Стоит ввести math., и вы получите выпадающий список доступных математических функций и констант — таких как sqrt(), sin(), cos(), log(), pi и множество других — с встроенной документацией.

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

import math
numbers = list(range(10000000))
start = time.time()
roots = [math.sqrt(n) for n in numbers]
print(f"Math sqrt: {time.time() - start:.4f}s")
start = time.time()
roots = [n ** 0.5 for n in numbers]
print(f"Operator: {time.time() - start:.4f}s")

Измеренное время:

  • math.sqrt: ~0.2000s

  • Operator: ~0.2500s

Приём 5: Предварительно выделяйте память, если известен размер

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

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

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

start = time.time()
result = [0] * 1000000
for i in range(1000000):
    result[i] = i
print(f"Pre-allocated: {time.time() - start:.4f}s")
start = time.time()
result = []
for i in range(1000000):
    result.append(i)
print(f"Dynamic: {time.time() - start:.4f}s")

Измеренное время:

  • Pre-allocated: ~0.0300s

  • Dynamic: ~0.0400s

Приём 6: Избегайте обработки исключений в горячих циклах

Механизм исключений в Python мощный и чистый, он отлично подходит для обработки неожиданных ситуаций. Но он не предназначен для использования в высокочастотных участках кода. Генерация и перехват исключений требует разворачивания стека и переключения контекста, что является довольно дорогой операцией.

В горячих циклах — участках кода, которые выполняются многократно или обрабатывают большие объёмы данных — использование исключений в качестве управляющей логики может серьёзно замедлить выполнение. Вместо этого лучше использовать условные проверки (if, in, is и т.п.), чтобы предотвратить ошибку до её возникновения. Такой подход гораздо быстрее и работает предсказуемее.

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

numbers = list(range(10000000))
start = time.time()
total = 0
for i in numbers:
    if i % 2 != 0:
        total += i // 2
    else:
        total += i
print(f"Conditional: {time.time() - start:.4f}s")
start = time.time()
total = 0
for i in numbers:
    try:
        total += i / (i % 2)
    except ZeroDivisionError:
        total += i
print(f"Exception: {time.time() - start:.4f}s")

Измеренное время:

  • Conditional: ~0.3000s

  • Exception: ~0.6000s

Приём 7: Используйте локальные функции для повторяющейся логики

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

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

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

def outer():
    def add_pair(a, b):
        return a + b
    result = 0
    for i in range(10000000):
        result = add_pair(result, i)
    return result
start = time.time()
result = outer()
print(f"Local function: {time.time() - start:.4f}s")
def add_pair(a, b):
    return a + b
start = time.time()
result = 0
for i in range(10000000):
    result = add_pair(result, i)
print(f"Global function: {time.time() - start:.4f}s")

Измеренное время:

  • Local function: ~0.4000s

  • Global function: ~0.4500s

Приём 8: Используйте itertools для комбинаторных операций

Когда нужно работать с перестановками, комбинациями, декартовым произведением и другими задачами, основанными на итераторах, модуль itertools в Python предоставляет набор очень эффективных инструментов, оптимизированных на C специально под такие случаи.

Функции вроде product(), permutations(), combinations() и combinations_with_replacement() генерируют элементы лениво — то есть не хранят весь результат в памяти. Это позволяет работать с большими или даже бесконечными последовательностями без тех накладных расходов по памяти и скорости, которые возникают у ручных реализаций.

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

from itertools import product
items = [1, 2, 3] * 10
start = time.time()
result = list(product(items, repeat=2))
print(f"Itertools: {time.time() - start:.4f}s")
start = time.time()
result = []
for x in items:
    for y in items:
        result.append((x, y))
print(f"Loops: {time.time() - start:.4f}s")

Измеренное время:

  • itertools: ~0.0005s

  • Loops: ~0.0020s

Приём 9: Используйте bisect для работы с отсортированными списками

При работе с отсортированными списками линейный поиск или ручная логика вставки могут быть неэффективны — особенно по мере роста списка. Модуль bisect в Python предоставляет быстрые и удобные инструменты для поддержания отсортированного порядка на базе двоичного поиска.

С помощью функций bisect_left(), bisect_right() и insort() можно выполнять вставки и поиск за O(log n), в отличие от O(n) при простом последовательном обходе. Это особенно полезно в задачах вроде поддержки таблиц лидеров, временных линий событий или реализации эффективных диапазонных запросов.

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

import bisect
numbers = sorted(list(range(0, 1000000, 2)))
start = time.time()
bisect.insort(numbers, 75432)
print(f"Bisect: {time.time() - start:.4f}s")
start = time.time()
for i, num in enumerate(numbers):
    if num > 75432:
        numbers.insert(i, 75432)
        break
print(f"Loop: {time.time() - start:.4f}s")

Измеренное время:

  • bisect: ~0.0001s

  • Loop: ~0.0100s

Приём 10: Избегайте повторных вызовов функции в циклах

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

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

Приём очень простой, но эффективный. Он не только ускоряет выполнение, но и делает код понятнее — сразу видно, что значение внутри цикла постоянно. Кэширование результата функции — один из самых лёгких способов убрать лишние вычисления и сделать код эффективнее.

def expensive_operation():
    time.sleep(0.001)
    return 42
start = time.time()
cached_value = expensive_operation()
result = 0
for i in range(1000):
    result += cached_value
print(f"Cached: {time.time() - start:.4f}s")
start = time.time()
result = 0
for i in range(1000):
    result += expensive_operation()
print(f"Repeated: {time.time() - start:.4f}s")

Измеренное время:

  • Cached: ~0.0010s

  • Repeated: ~1.0000s

Русскоязычное сообщество про Python

Друзья! Эту статью подготовила команда Python for Devs — канала, где каждый день выходят самые свежие и полезные материалы о Python и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!

Комментарии (0)