Статья из моего телеграм канала о программировании.
Генераторы коллекций - короткий(относительно цикла for) способ создавать коллекции на основе других коллекций.
Эти генераторы позволяют нам:
Кратко и просто создавать коллекции(при несложной логике).
Экономить время(генераторы более эффективны, чем цикл for).
Подходит для адептов функционального программирования, так как происходит именно генерация новой коллекции, а не изменение существующей.
Сразу хочу упомянуть одну важную вещь - не стоит "прятать" важные бизнес правила в сложные генераторы коллекций:
Не все могут быстро понимать, что же происходит в генераторе. Не все умеют хорошо писать генераторы коллекций. Раньше, выполняя задачки на сайтах по типу CodeWars, я тоже думал "Как круто, что можно много всего запихнуть в одну строку", и даже стремился к этому. Сейчас я так не считаю.
При усложнении бизнес логики велик шанс, что человек вносящий изменения будет стараться сохранить выражение генератор, а не отдать предпочтение переписыванию всего на этапы через for для того, что бы сделать код более явным. В следствие чего выражение генератор может стать ещё сложнее, либо логика может обосноваться рядом в лучшем случае с этим выражением(начнёт расплываться по коду, то что должно выполняться вместе)
# Функциональное выражение генератор взятое из боевого кода,
# которое и по сей день кажется мне идеальным примером,
# когда бизнес логика замешана с попыткой сделать код проще(меньше).
# Попытка, как по мне, неудачна :)
discounts = (
load_result.map(self._collect_discounts)
.rescue(lambda ex: Success([None] * len(self._products)))
.unwrap()
)
Давайте же перейдём к примерам:
Кратко и просто создавать коллекции.
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
"Создание списка через стандартный цикл for"
numbers_squares = []
for number in numbers:
numbers_squares.append(number ** 2)
"Создание списка с помощью генератора списков"
numbers_squares_ = [number ** 2 for number in numbers]
print(numbers_squares) # -> [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
print(numbers_squares_) # -> [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
И правда, компактно. А что, если логика будет посложнее?
students = {
1: {
"age": 27,
"first_name": "Mark",
"last_name": "Loginov",
"subject_average_score": {
"history": 4.5,
"mathematics": 3.4,
},
},
2: {
"age": 32,
"first_name": "Igor",
"last_name": "Petrov",
"subject_average_score": {
"history": 4.2,
"literature": 5,
},
},
}
students_ = {
id: {
"full_name": f"{student_data['first_name']} {student_data['last_name']}",
"subject": [
subject
for subject in student_data["subject_average_score"].keys()
],
}
for id, student_data in students.items()
}
print(students_)
{
1: {'full_name': 'Mark Loginov', 'subject': ['history', 'mathematics']},
2: {'full_name': 'Igor Petrov', 'subject': ['history', 'literature']},
}
# Имея информацию о студентах, мы можем сделать выжимку и сжать данные до
# размера: что это за студент, и какие уроки он посещает.
numbers = [
[[1, 2, 3], [4, 5, 6], [7, 8, 9]],
[[10, 11, 12], [13, 14, 15], [16, 17, 18], [19, 20, 21]],
[[22, 23, 24], [25, 26, 27]],
]
numbers_split = [
number for numbers_level_two in numbers
for numbers_level_three in numbers_level_two
for number in numbers_level_three
]
print(numbers_split) # -> [1, 2, 3, 4, 5, 6, 7, 8, ..., 27]
# Если нам известна вложенность нашей структуры, то мы можем сделать
# из неё линейную последовательность.
peoples = [
{"name": "", "age": 29},
{"name": "Igor", "age": 27},
{"name": "Petr", "age": 31},
{"name": "Liza", "age": 20},
]
filtered_peoples_names = [
people["name"] if people.get("name") else "Unknown Person"
for people in peoples
if people["age"] < 30
]
print(filtered_peoples_names) # -> ['Unknown Person', 'Igor', 'Liza']
# Также обратите внимание, что if в конце служит для фильтрации данных,
# а if, else в начале для возможности выбора конечного действия над
# выбранным объектом. В этом примере мы убрали из конечной выборки всех
# кому менее 30 лет. Тех, у кого не было внесено имя, установили
# его в "Unknown Person".
Допустим, мы разобрались с базовым синтаксисом генератора списков, но что же там насчёт скорости?
def for_() -> list[int]:
numbers_squares = []
for i in range(100):
numbers_squares.append(i)
return numbers_squares
def list_comprehension() -> list[int]:
return [i for i in range(100)]
# python 3.10
print(min(timeit.repeat(list_comprehension, number=100000))) # -> ~0.1477
print(min(timeit.repeat(for_, number=100000))) # -> ~0.2755
# python 3.12
print(min(timeit.repeat(list_comprehension, number=100000))) # ~0.0841
print(min(timeit.repeat(for_, number=100000))) # ~0.1155
Обратите внимание какая разница в скорости у генерации списка относительно цикла:
python 3.10 ~87%
python 3.12 ~37%
Если у вас возник вопрос, почему так сильно сократилась разница в скорости между версиями python? В python 3.12 сильно увеличилась производительность относительно python 3.10 в подобных случаях:
list_comprehension ~75%
for ~139%
Увеличим объём генерируемых данных в 10 раз и снова проведём замеры:
python 3.10 ~40%
python 3.12 ~18%
list_comprehension ~82%
for ~116%
За счёт чего же появляется прирост в скорости?
Используя цикл нам, приходится на каждой итерации делать __getattribute__ и call метода append.
Создаваемый в цикле список заранее, не знает какое кол-во объектов в нём будет. Поэтому при большом наборе данных, он будет многократно "выниматься" из оперативной памяти, аллоцировать новый увеличенный объём и "вставляться" в новое место. Это достаточно затратная операция, занимающая O(n) времени.
Если вас интересует более глубокий разбор, что же происходит "под капотом", то предлагаю запустить в своём интерпретаторе код подобный этому:
import dis
def for_() -> list[int]:
numbers_squares = []
for _ in range(100):
numbers_squares.append(_)
return numbers_squares
def list_comprehension() -> list[int]:
return [_ for _ in range(100)]
print(dis.dis(for_))
print(dis.dis(list_comprehension))
Вы получите разобранный машинный код на языке assembler(код отражает действия процессора):
В завершение хочется упомянуть, что кроме генераторов списков, есть так же:
Генератор множества
Генератор словарей
Генератор генераторов :)
Работают они все по тому же принципу и тем же правилам.
print({number for number in [1, 2, 1]}) # -> {1, 2}
print({name: value for name, value in zip(["one", "two"], [1, 2])}) # -> {'one': 1, 'two': 2}
print((x for x in range(10))) # -> <generator object <genexpr> at xxx>