Статья из моего телеграм канала о программировании.

Генераторы коллекций - короткий(относительно цикла 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>

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


  1. MountainGoat
    12.04.2024 17:55
    +4

    Я поставил плюс, но догадываюсь, почему минусуют: чтобы понять, что здесь написано, нужно знать, что здесь написано.


    1. KarmanovichDev Автор
      12.04.2024 17:55

      Здравствуйте. У меня сложные примеры?


  1. longclaps
    12.04.2024 17:55
    +7

    Кратко и просто создавать коллекции.

    numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

    Это называется словом "литерал", в данном случае - списочный литерал.

    Знание терминологи не обязательно для простых смертных, но весьма желательно для python разработчика с многолетним опытом и держателя профильного телеграм-канала. Объясню почему:

    Литерал, как способ создания питон-объекта, существует не для только лишь всех. Хрестоматийный пример: множество из нескольких элементов можно задать литералом, напр {1, 2, 3}, но пустое множество создаётся только вызовом конструктора set(). Причина проста и естественна - для пустого множества не удалось придумать органично выглядящий и однозначно распознаваемый литерал.

    В древности литералы существовали лишь для самых базовых типов, например чисел - ну как ты число сконструируешь? Т.е. можно конечно конструктором, вызов int() порождает 0, но один только ноль - этого мало. В питон литералы сразу завезли для списков и словарей, а множества (которые кстати тоже появились не сразу) изначально создавались только через конструктор, set([1, 2, 3]), и лишь потом для них придумали литералы, как синтаксический сахар.

    В завершение хочется упомянуть, что кроме генераторов списков, ...

    Есть и другие хорошие новости. Например, хотя для коллекции типа deque (очередь) нет литерала, и создавать её экземпляр приходится древним способом, deque([1, 2, 3]), для создания очереди можно воспользоваться генераторным выражением в качестве аргумента конструктора, deque(i * 2 for i in range(3)), что выглядит почти так же мило, как списочный генератор.


    1. KarmanovichDev Автор
      12.04.2024 17:55

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


    1. RedEyedAnonymous
      12.04.2024 17:55

      Причина проста и естественна - для пустого множества не удалось
      придумать органично выглядящий и однозначно распознаваемый литерал.

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


      1. KarmanovichDev Автор
        12.04.2024 17:55

        Квадратные скобки используются также при: list[0], str[::], dict["key"].

        Круглые скобки используются ещё для генерации генераторов (x for x on y), выделения условий и мат. операций (2 + 2) * 2, средством переноса кода без применения "/", вызов любого callable объекта происходит через ().

        Фигурные скобки используются в f-string f"{argument}".

        Итог - на всех не хватит разных видов скобок :)


      1. ol_mur
        12.04.2024 17:55

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


        1. KarmanovichDev Автор
          12.04.2024 17:55

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

          Также операции и задачи в которых используются эти типы данных, отличаются.


        1. KarmanovichDev Автор
          12.04.2024 17:55

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


    1. SwetlanaF
      12.04.2024 17:55

      Спасибо за ценную информацию. Автору тоже спасибо и лайк)). Если нуль-функция порождается как int(), то нужен литерал только для единицы, чтобы записать другую базовую примитивно-рекурсивную функцию следования s(x)= x+1. Тогда любое натуральное число записывается как s...s(int(0))...).


  1. Kuzrus
    12.04.2024 17:55

    Тот случай., когда комментарии интересней поста;).

    П.С. Пост тоже хорош!;)