$ sudo teach IT
МОДУЛЬ 8 · УРОК 5 — ПРАКТИКА

Бесконечный счётчик и пагинация

Сегодня — чистая практика. Мы напишем бесконечный генератор, научимся его укрощать с помощью itertools.islice, а затем создадим полноценный класс Paginator, который разбивает любые данные на страницы. Эти паттерны встречаются в реальных проектах постоянно.

⏱ ~50 минут 🎓 Для новичков 🐍 Python 3

🎯 Постановка задачи: зачем нам эти паттерны?

Представьте два типичных сценария из реальной разработки:

🔢

Генерация уникальных ID

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

📄

Постраничный вывод

API возвращает 10 000 товаров. Показать всё сразу — невозможно. Нужны страницы по 20 штук.

🎲

Симуляции и стримы

Игровой движок генерирует события бесконечно. Датчики шлют данные непрерывно. Остановка — внешняя.

Для первого и третьего — бесконечный генератор. Для второго — класс Paginator. Поехали!

♾️ Шаг 1 — Бесконечный счётчик

Обычная функция всегда завершается. Но генератор с while True работает вечно — он будет отдавать значения, пока вы их запрашиваете. Поскольку генератор ленивый, бесконечный цикл внутри него не «зависает» программу — он просто ждёт следующего вызова next().

def counter(start=0, step=1):
    """
    Бесконечный счётчик.
    start — начальное значение (по умолчанию 0)
    step  — шаг (по умолчанию 1)
    """
    current = start
    while True:          # Бесконечный цикл — но это ОК для генератора!
        yield current    # Отдаём текущее значение и ЗАСЫПАЕМ
        current += step  # Просыпаемся только когда попросят следующее

Почему while True безопасен в генераторе? Каждый раз, когда выполнение доходит до yield, функция замораживается — она не занимает процессор, не жрёт память. Следующая итерация цикла начнётся только после явного запроса значения через next() или for.

Проверяем работу счётчика:

gen = counter(start=1, step=2)  # нечётные числа: 1, 3, 5, 7, ...

print(next(gen))  # 1
print(next(gen))  # 3
print(next(gen))  # 5

# Можно передавать параметры
id_gen = counter(start=1000)    # ID пользователей начиная с 1000
print(next(id_gen))  # 1000
print(next(id_gen))  # 1001
print(next(id_gen))  # 1002

Но что если нам нужно взять только первые N значений? Просто написать for x in counter() — бесконечный цикл! Здесь на помощь приходит itertools.islice.

✂️ Шаг 2 — itertools.islice: ограничиваем бесконечность

itertools.islice(iterable, stop) работает как срез списка, но для любого итерируемого объекта — в том числе для бесконечного генератора. Он берёт первые stop элементов и останавливается, не запрашивая остальные.

from itertools import islice

def counter(start=0, step=1):
    current = start
    while True:
        yield current
        current += step

# Берём первые 5 значений из бесконечного счётчика
first_five = list(islice(counter(1), 5))
print(first_five)  # [1, 2, 3, 4, 5]

# Первые 10 чётных чисел
evens = list(islice(counter(0, step=2), 10))
print(evens)  # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

# islice с тремя аргументами: islice(iterable, start, stop)
# Пропускаем первые 3, берём следующие 5
some = list(islice(counter(100), 3, 8))
print(some)  # [103, 104, 105, 106, 107]

💡 islice — это тоже генератор. Он не создаёт список сразу. Только когда вы вызываете list(), for или другую функцию-потребитель — начинается реальная работа. До этого момента ничего не вычисляется.

Это значит: islice(counter(), 1_000_000) не занимает памяти до тех пор, пока вы не начнёте итерацию!

Реальный пример — генерация уникальных ID для объектов:

from itertools import islice

def id_generator(prefix="user", start=1):
    """Генерирует ID вида user_1, user_2, ..."""
    n = start
    while True:
        yield f"{prefix}_{n}"
        n += 1

# Создаём 5 пользователей
user_ids = list(islice(id_generator("user"), 5))
print(user_ids)
# ['user_1', 'user_2', 'user_3', 'user_4', 'user_5']

# Создаём 3 заказа
order_ids = list(islice(id_generator("order", start=1000), 3))
print(order_ids)
# ['order_1000', 'order_1001', 'order_1002']

📦 Шаг 3 — Проектируем класс Paginator

Пагинация — разбивка данных на «страницы» фиксированного размера. Задача встречается везде: интернет-магазины, форумы, API-ответы. Реализуем её как класс, который умеет итерироваться — то есть его можно использовать прямо в цикле for.

Что нам нужно от класса:

  • Принимает список данных и размер страницы
  • Свойство total_pages — сколько страниц всего
  • Метод __iter__ — чтобы писать for page in paginator
  • Каждая итерация возвращает срез данных — одну «страницу»
class Paginator:
    def __init__(self, data, page_size):
        """
        data      — список (или любая последовательность) данных
        page_size — сколько элементов на одной странице
        """
        self.data = list(data)   # сохраняем данные как список
        self.page_size = page_size

    @property
    def total_pages(self):
        """Вычисляем количество страниц через целочисленное деление с округлением вверх."""
        # Трюк: (N + size - 1) // size  === math.ceil(N / size)
        # Например, 10 элементов, по 3: (10 + 2) // 3 = 4 страницы ✅
        return (len(self.data) + self.page_size - 1) // self.page_size

    def __iter__(self):
        """
        Возвращает итератор по страницам.
        Каждая страница — это срез self.data.
        Используем yield, чтобы не хранить все страницы сразу.
        """
        for page_num in range(self.total_pages):
            start = page_num * self.page_size
            end   = start + self.page_size
            yield self.data[start:end]  # срез: от start до end

Почему @property для total_pages? Свойство (@property) позволяет обращаться к нему как к атрибуту: p.total_pages — без скобок. При этом значение вычисляется каждый раз заново, что правильно, если данные могут меняться. Хранить его как обычный атрибут было бы менее гибко.

Используем класс:

products = list(range(1, 23))  # 22 товара: [1, 2, ..., 22]

paginator = Paginator(products, page_size=5)

print(f"Товаров: {len(paginator.data)}")   # Товаров: 22
print(f"Страниц: {paginator.total_pages}") # Страниц: 5

for page_num, page in enumerate(paginator, start=1):
    print(f"Страница {page_num}: {page}")

# Страница 1: [1, 2, 3, 4, 5]
# Страница 2: [6, 7, 8, 9, 10]
# Страница 3: [11, 12, 13, 14, 15]
# Страница 4: [16, 17, 18, 19, 20]
# Страница 5: [21, 22]          ← последняя страница может быть неполной

Можно также обратиться к конкретной странице напрямую, добавив метод get_page:

class Paginator:
    def __init__(self, data, page_size):
        self.data = list(data)
        self.page_size = page_size

    @property
    def total_pages(self):
        return (len(self.data) + self.page_size - 1) // self.page_size

    def __iter__(self):
        for page_num in range(self.total_pages):
            start = page_num * self.page_size
            yield self.data[start:start + self.page_size]

    def get_page(self, page_num):
        """
        Возвращает страницу по номеру (нумерация с 1).
        Если номер за пределами — возвращает пустой список.
        """
        if page_num < 1 or page_num > self.total_pages:
            return []
        start = (page_num - 1) * self.page_size
        return self.data[start:start + self.page_size]

# Получаем 3-ю страницу напрямую
p = Paginator(range(1, 23), page_size=5)
print(p.get_page(3))  # [11, 12, 13, 14, 15]
print(p.get_page(99)) # []

🔍 Шаг 4 — Пагинация с фильтрацией через генератор

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

Реализуем функцию-генератор filtered_paginator, которая сначала фильтрует данные, а потом разбивает их на страницы. Мы воспользуемся ленивостью генераторов: фильтрация произойдёт на лету, без создания промежуточного списка.

def filtered_paginator(data, page_size, predicate):
    """
    Генератор страниц с предварительной фильтрацией.

    data      — исходные данные (любой итерируемый объект)
    page_size — размер страницы
    predicate — функция-фильтр: принимает элемент, возвращает True/False
    """
    page = []  # текущая страница накапливается здесь

    for item in data:
        if predicate(item):      # проверяем условие
            page.append(item)
            if len(page) == page_size:  # страница заполнена
                yield page
                page = []        # сбрасываем для следующей страницы

    if page:                     # отдаём последнюю неполную страницу
        yield page

Почему это эффективно? Мы не создаём список «все отфильтрованные элементы», а затем разбиваем его. Мы обрабатываем каждый элемент один раз: либо добавляем на текущую страницу, либо пропускаем. Страница отдаётся сразу, как только накопилась — и память освобождается.

Проверяем с реальным примером — товары с ценой выше 500 рублей:

# Каталог товаров: (название, цена)
catalog = [
    ("Ноутбук",   45000),
    ("Мышь",        350),
    ("Монитор",   12000),
    ("Клавиатура",  800),
    ("Веб-камера",  2500),
    ("Коврик",      150),
    ("Наушники",   3200),
    ("USB-хаб",     600),
    ("Принтер",    8500),
    ("Сканер",     4200),
]

# Фильтр: только товары дороже 500 рублей
expensive = lambda item: item[1] > 500

# Страницы по 3 товара
for num, page in enumerate(filtered_paginator(catalog, 3, expensive), start=1):
    print(f"--- Страница {num} ---")
    for name, price in page:
        print(f"  {name}: {price} руб.")

# --- Страница 1 ---
#   Ноутбук: 45000 руб.
#   Монитор: 12000 руб.
#   Клавиатура: 800 руб.
# --- Страница 2 ---
#   Веб-камера: 2500 руб.
#   Наушники: 3200 руб.
#   USB-хаб: 600 руб.
# --- Страница 3 ---
#   Принтер: 8500 руб.
#   Сканер: 4200 руб.

Мышь (350 руб.) и Коврик (150 руб.) отфильтрованы — их нет ни на одной странице. Всё остальное аккуратно разбито по страницам.

⚠️ Важный момент с if page: после цикла может остаться накопленная страница, которая не достигла page_size. Это последняя, неполная страница. Без if page: yield page в конце — последние элементы будут потеряны!

📋 Шаг 5 — Финальный код: всё вместе

Собираем все части в один модуль — так, как это могло бы выглядеть в реальном проекте:

from itertools import islice


# ─── 1. Бесконечный счётчик ──────────────────────────────────────────────────

def counter(start=0, step=1):
    """Бесконечный генератор чисел."""
    current = start
    while True:
        yield current
        current += step


def id_generator(prefix="id", start=1):
    """Генерирует строковые ID: prefix_1, prefix_2, ..."""
    for n in counter(start):
        yield f"{prefix}_{n}"


# ─── 2. Класс Paginator ───────────────────────────────────────────────────────

class Paginator:
    """Разбивает данные на страницы фиксированного размера."""

    def __init__(self, data, page_size):
        self.data = list(data)
        self.page_size = page_size

    @property
    def total_pages(self):
        return (len(self.data) + self.page_size - 1) // self.page_size

    def __iter__(self):
        for i in range(self.total_pages):
            start = i * self.page_size
            yield self.data[start:start + self.page_size]

    def get_page(self, page_num):
        if page_num < 1 or page_num > self.total_pages:
            return []
        start = (page_num - 1) * self.page_size
        return self.data[start:start + self.page_size]


# ─── 3. Пагинация с фильтрацией ───────────────────────────────────────────────

def filtered_paginator(data, page_size, predicate):
    """Страницы из элементов, прошедших фильтр."""
    page = []
    for item in data:
        if predicate(item):
            page.append(item)
            if len(page) == page_size:
                yield page
                page = []
    if page:
        yield page


# ─── Демонстрация ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    # 1. Первые 5 ID пользователей
    print("=== ID пользователей ===")
    users = list(islice(id_generator("user"), 5))
    print(users)
    # ['user_1', 'user_2', 'user_3', 'user_4', 'user_5']

    # 2. Пагинация списка из 13 элементов по 4 штуки
    print("\n=== Paginator (13 элементов, страница = 4) ===")
    p = Paginator(range(1, 14), page_size=4)
    print(f"Всего страниц: {p.total_pages}")
    for i, page in enumerate(p, 1):
        print(f"  Страница {i}: {page}")

    # 3. Фильтрация + пагинация
    print("\n=== Filtered paginator (чётные, страница = 3) ===")
    data = range(1, 21)
    for i, page in enumerate(filtered_paginator(data, 3, lambda x: x % 2 == 0), 1):
        print(f"  Страница {i}: {page}")

🏋️ Самостоятельная работа

Попробуйте решить эти задачи самостоятельно — опираясь на код из урока:

⭐ Задача 1

Напишите генератор fibonacci(), который бесконечно генерирует числа Фибоначчи: 0, 1, 1, 2, 3, 5, 8, 13... Выведите первые 10 с помощью islice.

⭐⭐ Задача 2

Добавьте в класс Paginator метод __len__, возвращающий количество страниц, и метод __repr__, который выводит Paginator(items=22, pages=5, page_size=5).

⭐⭐⭐ Задача 3

Напишите генератор chunked_reader(filename, chunk_size), который читает текстовый файл и отдаёт его построчно, страницами по chunk_size строк. Используйте подход из filtered_paginator.

💡 Подсказка к задаче 1: хранить нужно только два последних числа Фибоначчи — a и b. После каждого yield a — обновляете: a, b = b, a + b.

🏁 Итоги урока

  • Бесконечный генератор = while True + yield. Безопасен, потому что ленив — работает только когда его спрашивают
  • itertools.islice позволяет взять первые N элементов из бесконечного генератора, не «разрывая» его
  • Применение бесконечных генераторов: генерация ID, симуляции, потоки событий — всё, где конец заранее неизвестен
  • Класс Paginator с методом __iter__ позволяет разбивать данные на страницы и итерироваться по ним как по обычному объекту
  • @property total_pages — вычисляется через формулу (N + size - 1) // size (целочисленное деление с округлением вверх)
  • filtered_paginator — комбинирует фильтрацию и разбивку на страницы за один проход без промежуточных списков
  • Все три паттерна широко используются в реальных проектах: API, базы данных, обработка файлов