Всем привет. Сегодня хотим поделиться одним полезным переводом, подготовленным в преддверии запуска курса «Web-разработчик на Python». Писать код эффективный по времени и по памяти на Python особенно важно, когда занимаешься созданием Web-приложения, модели машинного обучения или занимаешься тестированием.



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


Функции-генераторы позволяют объявить функцию, которая будет вести себя как итератор. Они позволяют программистам создавать быстрые, простые и чистые итераторы. Итератор – это объект, который может быть повторен (зациклен). Он используется для того, чтобы абстрагировать контейнер данных и заставить его вести себя как итерируемый объект. Например, примером итерируемого объекта могут быть строки, списки и словари.


Генератор выглядит как функция, но использует вместо return ключевое слово yield. Давайте рассмотрим пример, чтобы стало понятнее.


def generate_numbers():
    n = 0
    while n < 3:
        yield n
        n += 1

Это функция-генератор. Когда вы ее вызываете, она возвращает объект-генератор.


>>> numbers = generate_numbers()
>>> type(numbers)
<class 'generator'>

Важно обратить внимание на то, как состояние инкапсулируется в теле функции генератора. Вы можете итерироваться по одному, используя встроенную функцию next():


>>> next_number = generate_numbers()
>>> next(next_number)
0
>>> next(next_number)
1
>>> next(next_number)
2

Что произойдет, если вы вызовете next() после окончания выполнения?


StopIteration – это встроенный тип исключения, которое возникает автоматически, как только генератор перестает возвращать результат. Это сигнал остановки для цикла for.


Оператор yield


Его основная задача – управлять потоком функции генератора так, чтобы это было похоже на оператор return. При вызове функции генератора или использовании выражения генератора он возвращает специальный итератор, который называется генератором. Чтобы использовать генератор, присвойте его какой-либо переменной. При вызове специальных методов в генераторе, таких как next(), код функции будет выполняться до yield.


При попадании в инструкцию yield, программа приостанавливает выполнение функции и возвращает полученное значение объекту, который инициировал выполнение. (Тогда как return прекращает выполнение функции полностью.) Когда работа функции приостанавливается, ее состояние сохраняется.


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


Постановка проблемы


Предположим, нам нужно пройтись по большому списку чисел (например, 100000000) и сохранить квадраты всех чисел, которые нужно хранить отдельно в другом списке.


Обычный подход


import memory_profiler
import time
def check_even(numbers):
    even = []
    for num in numbers:
        if num % 2 == 0: 
            even.append(num*num)

    return even
if __name__ == '__main__':
    m1 = memory_profiler.memory_usage()
    t1 = time.clock()
    cubes = check_even(range(100000000))
    t2 = time.clock()
    m2 = memory_profiler.memory_usage()
    time_diff = t2 - t1
    mem_diff = m2[0] - m1[0]
    print(f"It took {time_diff} Secs and {mem_diff} Mb to execute this method")

После запуска кода выше, мы получим следующее:


It took 21.876470000000005 Secs and 1929.703125 Mb to execute this method

С использованием генераторов


import memory_profiler
import time
def check_even(numbers):
    for num in numbers:
        if num % 2 == 0:
            yield num * num 

if __name__ == '__main__':
    m1 = memory_profiler.memory_usage()
    t1 = time.clock()
    cubes = check_even(range(100000000))
    t2 = time.clock()
    m2 = memory_profiler.memory_usage()
    time_diff = t2 - t1
    mem_diff = m2[0] - m1[0]
    print(f"It took {time_diff} Secs and {mem_diff} Mb to execute this method")

После запуска кода выше, мы получим следующее:


It took 2.9999999995311555e-05 Secs and 0.02656277 Mb to execute this method

Как мы видим, время выполнения и затраченная память значительно сократились. Генераторы работают по принципу, известному как «ленивые вычисления». Это значит, что они могут экономить ресурсы процессора, памяти и других вычислительных ресурсов.


Заключение


Надеюсь, в этой статье я смог показать, как генераторы в Python можно использовать для экономии таких ресурсов как память и время. Это преимущество появляется из-за того, что генераторы не сохраняют все результаты в памяти, а вычисляют их на лету, а память используется только в случае, если мы запрашиваем результат вычислений. Также генераторы позволяют абстрагировать большое количество шаблонного кода, который нужен для написания итераторов, поэтому они также помогают сократить количество кода.

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


  1. kuza2000
    28.11.2019 19:38

    Странный пример. Второй вариант и не считал ничего. Попробуйте хотя бы сумму квадратов посчитать. Тогда и будет видно, что дает генератор. Вот память он в этом случае сэкономит, это да, так как все числа хранить не будет. Или я не прав?


    1. ardraeiss
      29.11.2019 16:32

      Там ещё и не квадраты чисел — а проверка на чётность.


      Но это уже вопрос к автору оригинала, там тоже речь про квадраты — а код про чётность. Индус, которому код писал другой индус?


  1. vbaydikov
    28.11.2019 20:51
    +3

    код с использованием генератора ничего не делает. почитайте коммент от 11 ноября к оригиналу статьи medium.com/@rt.van.der.ham/i-am-afraid-the-timing-conclusion-from-this-article-143c2a013e45


  1. Seigert
    28.11.2019 20:51
    +2

    Вы во втором примере просто создали генератор, не получив из него ни одного значения, и уж тем более не «сохранили квадраты всех чисел, которые нужно хранить отдельно в другом списке». Естественно, и время выполнения было меньше, и память не занята.


  1. Telemak
    28.11.2019 20:51
    +3

    Как мы видим, время выполнения

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

    Также и тут, в случае с генератором мы не храним квадраты всех чисел в отдельном списке, мы можем получить слудующий квадрат при необходимости.

    Как мне кажется лучше было бы добавить:
    for i in cubes: 
        do_something()

    так сравнение будет более честным


  1. Woodoodoo
    28.11.2019 20:51
    +1

    Еще ими можно перебирать большие файлы.
    Примерно так:

    def get_data_from_big_file():
        with open('big_file.txt', 'r', encoding='utf-8') as f:
            while True:
                line = f.readline()
                if not line:
                    break
                yield line


  1. Dim0v
    28.11.2019 21:51

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

    Нет, это значит, что они производят вычисления (и тратят "ресурсы процессора, памяти и других вычислительных ресурсов") по мере необходимости, а не наперед вне зависимости от того, нужен ли кому-то результат этих вычислений.
    И экономят ресурсы ленивые вычисления только в том случае, когда результаты вычислений не запрашиваются (частично, или, как в статье, совсем). Если же используются все результаты, то ленивые впечатления ничего не сэкономят. В лучшем случае они лишь отложат использование такого-же количества ресурсов. А иногда даже наоборот — добавят накладных расходов.


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


  1. ArsenAbakarov
    28.11.2019 22:36

    Вообще то, когда вы вызываете объект генератора, он сразу доходит до yield, и отдает управление (кооперативная многозадачность), ничего не возвращается, когда вы вызываете next() (py 3) или gen.__next__() (py 2), то возвращается значение после yield и поток выполняется до другого yield или return, если был return и генератор нормально завершился, то будет StopIteration, и ваш return будет в StopIteration().value, есть еще сопрограммы, это двухсторонняя связь, но тут не об этом


    1. Kell
      29.11.2019 22:06

      Нет, это не так. При создании объекта внутри объекта никаких вычислений не производится, а впервые интерпретатор зайдет внутрь лишь при первом next. Именно поэтому требуется при создании корутин первым вызовом всегда делать next или send(None). И, соответственно, потому останавливается сразу ПОСЛЕ инструкции yield, а не до.


  1. technic93
    28.11.2019 23:59
    +1

    Упоминали что ещё один студент остался с переводами на хабр, но походу незачет ;)


  1. megagnom37
    29.11.2019 14:12
    +1

    Данное сравнение времени и памяти совершенно неверное.

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

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