Вы и не представляете, насколько Python оптимизировал код для вас.
Если вы никогда раньше не слышали о ленивых вычислениях, то знайте: это такая стратегия, которая откладывает эти самые вычисления до тех пор, пока не потребуется их результат. Это позволяет избежать повторных вычислений.
Давайте разберем эту теорию на конкретном примере. Пусть у вас имеется простое выражение: sum = 1 + 2
. Python попытается понять реальный смысл этого выражения и прийти к выводу, что sum = 3
. Этот процесс называется вычислением и он безусловно требует неких вычислительных ресурсов. В данном случае вычисление происходит немедленно. Такие вычисления называются строгими.
Но есть и другой тип вычислений, который называется ленивым. Различие состоит в том, что ленивое вычисление производится не сразу, а только когда программе потребуется его результат. Это несколько напоминает ленивого студента, который делает задание в последний момент, когда его уже нужно сдавать учителю.
Но лень совсем не обязательно плохая штука, с ее помощью можно повысить эффективность вашего кода и сэкономить большое количество ресурсов. К счастью, Python незаметно включил ленивые вычисления во многие встроенные функции. И мы уверены, что вы знаете эти функции, даже не будучи знакомы с ленивыми вычислениями.
В этой статье мы разберем, как работают ленивые вычисления в Python, какие функции от этого выигрывают и почему. А еще мы покажем, как вы можете писать свои собственные ленивые функции и классы. Итак, начнем!
Функция range()
В большинстве случаев Python все же вычисляет выражения немедленно. Давайте рассмотрим следующий пример. Как вы думаете, сколько времени займет выполнение данного кода?
print([time.sleep(0), time.sleep(1), time.sleep(2)][0])
Три секунды. Дело в том, что когда вы создаете список, Python немедленно вычисляет каждый элемент данного списка, даже если нам нужен только первый.
Начиная с Python 3 такие процедуры были значительно оптимизированы с точки зрения расхода памяти и времени. Это очень хорошо видно на примере функции range()
. Мы не сомневаемся, что каждый разработчик хоть раз, но применял данную функцию.
В Python 2 функция range(5)
вернет список из пяти элементов. При увеличении длины списка будет требоваться все больше и больше памяти.
Python 2.7.16 >>> range(5) [0, 1, 2, 3, 4] >>> import sys >>> sys.getsizeof(range(5)) 112 >>> sys.getsizeof(range(500)) 4072
А вот в Python 3 функция range(5)
возвращает уже объект типа range
. Чтобы получить последовательность чисел, этот объект надо проитерировать. Независимо от того, насколько велика последовательность чисел, сам объект всегда имеет один и тот же размер.
Дело в том, что range(5)
хранит только значения start
, stop
и step
, а каждый элемент вычисляет по мере необходимости.
Python 3.7.7 >>> range(5) range(0, 5) >>> import sys >>> sys.getsizeof(range(5)) 48 >>> sys.getsizeof(range(500)) 48
Если вы незнакомы с понятиями генератора и итератора, то, пожалуйста, внимательно прочтите следующий раздел. В противном случае его можно пропустить.
[python_ad_block]iterator > generator
Попросту говоря, итератор — это более широкая концепция, чем генератор. Итератором называется объект, класс которого имеет методы __next__
и __iter__
. Каждый раз, когда вы вызываете метод next()
у объекта-итератора, вы получаете следующий элемент последовательности. Когда последовательность закончится, будет вызвана ошибка типа StopIteration
.
А генератор является просто функцией, которая возвращает объект типа итератор. Она ничем не отличается от обычной функции, только вместо ключевого слова return
используется yield
.
При выполнении оператора yield
программа приостанавливает выполнение данной функции и возвращает ее текущее значение. В этом то и состоит ключевая идея ленивых вычислений! Значение вычисляется и возвращается по мере надобности, а следующее значение ожидает своего часа и не требует никаких ресурсов.
Есть два способа создания генератора:
# Вариант 1 generator1 = (time.sleep(x) for x in range(3)) def sleep(): for x in range(3): yield time.sleep(x) # Вариант 2 generator2 = sleep()
Давайте улучшим первый вариант, использующий функцию range().
Но прежде чем мы придем к окончательному результату, следует остановиться на одной ловушке.
В следующем примере есть две функции: use_generator()
и use_list()
. Они выглядят почти одинаково, за исключением того, что use_generator()
использует ()
в функции islice()
, а use_list()
использует[]
. Однако такое небольшое различие оказывает огромное влияние на время работы программы.
Объяснение этого воздействия есть уже в имени последней функции. Выражение (time.sleep(x) for x in range(3))
является генератором, в то время как выражение [time.sleep(x) for x in range(3)]
— это список, несмотря на использование функции range()
. А выполнение функции, использующей список, занимает намного больше времени.
import time from itertools import islice def timing(f): def wrap(*args, **kwargs): time1 = time.time() ret = f(*args, **kwargs) time2 = time.time() print( "{:s} function took {:.3f} ms".format(f.__name__, (time2 - time1) * 1000.0) ) return ret return wrap @timing def use_generator(): return list(islice((time.sleep(x) for x in range(3)), 1)) @timing def use_list(): return list(islice([time.sleep(x) for x in range(3)], 1)) print(use_generator()) # use_generator function took 0.048 ms # [None] print(use_list()) # use_list function took 3003.090 ms # [None] print(type((time.sleep(x) for x in range(3)))) # <class 'generator'> print(type([time.sleep(x) for x in range(3)])) # <class 'list'>
Если вы это понимаете, то поздравляем: вы уже знаете половину всего, что стоит знать про ленивые вычисления.
Функция zip()
Очень похожий пример — функция zip()
. Эта функция объединяет итерируемые объекты и возвращает последовательность кортежей.
В Python 2 она возвращала список кортежей:
Python 2.7.16 >>> type(zip([1,2],[3,4])) <type 'list'> >>> import sys >>> sys.getsizeof(zip([1,2],[3,4])) 88 >>> sys.getsizeof(zip([i for i in range(500)],[i for i in range(500)])) 4072
В Python 3 функция была улучшена. Теперь она возвращает итерируемый объект типа zip
, подобный объекту типа range
.
Python 3.7.7 >>> type(zip([1,2],[3,4])) <class 'zip'> >>> import sys >>> sys.getsizeof(zip([1,2],[3,4])) 72 >>> sys.getsizeof(zip([i for i in range(500)],[i for i in range(500)])) 72
Тут мы не будем повторяться: причина замены списка на генератор такая же, как и в функции range()
.
Функция open()
Это еще одна встроенная функция, которую мы, вероятно, используем ежедневно и принимаем как должное. При открытии файла мы обычно пишем:
with open("file.csv", "r") as f: for line in f: print(line)
Выражение with open(...)
не считывает файл целиком и не запоминает его в памяти. Вместо этого оно возвращает итерируемый объект типа file
. Благодаря этому можно читать огромные файлы и не беспокоиться о памяти.
Лямбда-выражения
Пару недель назад мы получили вопрос от читателя, который и сподвиг нас на написание данной статьи. Вопрос был такой: «Почему объект типа x = map(lambda x: x * 2, [1 ,2, 3, 4, 5])
совсем не занимает места, а вот после применения функции list(x)
к данному объекту в памяти оказываются все значения этого выражения?»
Мы надеемся, что на данный момент вы уже понимаете, что тут происходит. Объект на выходе функции map
также ленивый и может быть проитерирован. Вычисление x * 2
выполняется только для одного элемента в каждом цикле. Если вы примените функцию list(x)
, то вычислите сразу все элементы списка. Таким образом, если вы хотите итерировать объект map
, функцию list()
применять не нужно.
map_obj = map(lambda x: x*2, [1,2,3,4,5]) for i in map_obj: print(i) # 2,4,6,8,10 import sys print(sys.getsizeof(x)) # 48 print(list(x)) # [2, 4, 6, 8, 10] print(sys.getsizeof(list(x))) # 56
Как написать собственную ленивую функцию или класс?
В заключительной части данной статьи мы выйдем на новый уровень и напишем собственную ленивую функцию. Это поможет нам расширить возможности встроенных функций Python.
Как вы уже поняли, ключевая часть ленивых вычислений это ничто иное, как генератор. Следовательно, мы можем просто написать нашу функцию в виде генератора.
Ленивая функция — генератор
def lazy_loading(items): for i in items: # здесь у вас может быть сложная логика yield i ** 2 items = [i for i in range(100)] for i in lazy_loading(items): print(i)
Ленивое свойство — декоратор
Другой распространенный способ организации ленивых вычислений состоит в инициализации свойств класса.
При инициализации класса некоторые свойства могут довольно долго вычисляться. В следующем примере свойство cities
отнимает много времени, потому что ему необходимо вызвать API для получения списка названий городов. А если для некоторых стран нам эти значения не пригодятся, их получение станет потерей времени.
Отличным решением будет создать ленивый декоратор для этого свойства, чтобы затратные по времени операции выполнялись только по мере необходимости. Как мы можем видеть из результатов выполнения данного кода, свойство cities
вызывается только после того, как мы выводим в консоль china.cities
.
def lazy_property(fn): attr_name = '_lazy_' + fn.__name__ @property def _lazy_property(self): if not hasattr(self, attr_name): setattr(self, attr_name, fn(self)) return getattr(self, attr_name) return _lazy_property class Country: def __init__(self, name, capital): self.name = name self.capital = capital @lazy_property def cities(self): # expensive operation to get all the city names (API call) print("cities property is called") return ["city1", "city2"] china = Country("china", "beijing") print(china.capital) # beijing print(china.cities) # cities property is called # ['city1', 'city2']
Итак, в этой статье мы рассмотрели, как ленивые вычисления помогли оптимизировать встроенные функции Python. Надеемся, что эта статья даст вам возможность по-новому взглянуть на проблему оптимизации кода.
Перевод статьи «What is Lazy Evaluation in Python?».