Готовые методы реализаций

Готовый helper для дебаунса и троттлинга вызовов функций (по аналогии с JS)

Введение

Дебаунс (debounce) и троттлинг (throttle) — широко известные в JavaScript-разработке техники ограничения частоты вызовов функции: дебаунс откладывает вызов до тех пор, пока не пройдёт заданный интервал без новых попыток вызова (полезно для обработки ввода в поле поиска — запрос отправляется только после того, как пользователь перестал печатать), а троттлинг гарантирует, что функция вызывается не чаще, чем раз в заданный интервал, независимо от частоты запросов (полезно для обработчиков скролла или resize). В статье реализуем оба паттерна на основе QTimer.

Концепция

Дебаунс реализуется через единственный таймер, который перезапускается (start()) при каждом новом вызове — реальное действие выполняется только тогда, когда таймер успевает сработать без прерывания новым вызовом, то есть когда поток вызовов прекратился. Троттлинг работает по-другому: первый вызов выполняется немедленно и запускает «период тишины» — все вызовы в течение этого периода игнорируются (или запоминается последний для гарантированного финального выполнения), а по истечении периода функция снова готова к немедленному вызову.

Пример кода

#pragma once
#include <QObject>
#include <QTimer>
#include <functional>

class Debouncer : public QObject
{
    Q_OBJECT
public:
    explicit Debouncer(int delayMs, QObject *parent = nullptr)
        : QObject(parent), m_delayMs(delayMs)
    {
        m_timer.setSingleShot(true);
        connect(&m_timer, &QTimer::timeout, this, [this]() {
            if (m_pendingAction) {
                m_pendingAction();
            }
        });
    }

    void call(std::function<void()> action)
    {
        m_pendingAction = std::move(action);
        m_timer.start(m_delayMs); // перезапуск таймера отменяет предыдущее ожидание
    }

private:
    QTimer m_timer;
    int m_delayMs;
    std::function<void()> m_pendingAction;
};

class Throttler : public QObject
{
    Q_OBJECT
public:
    explicit Throttler(int intervalMs, QObject *parent = nullptr)
        : QObject(parent), m_intervalMs(intervalMs)
    {
        m_timer.setSingleShot(true);
        connect(&m_timer, &QTimer::timeout, this, [this]() {
            m_canCallImmediately = true;
            if (m_pendingAction) {
                auto action = std::move(m_pendingAction);
                m_pendingAction = nullptr;
                m_timer.start(m_intervalMs);
                m_canCallImmediately = false;
                action();
            }
        });
    }

    void call(std::function<void()> action)
    {
        if (m_canCallImmediately) {
            m_canCallImmediately = false;
            m_timer.start(m_intervalMs);
            action();
        } else {
            m_pendingAction = std::move(action); // запоминаем последний вызов в "тихом" окне
        }
    }

private:
    QTimer m_timer;
    int m_intervalMs;
    bool m_canCallImmediately = true;
    std::function<void()> m_pendingAction;
};
// Использование: дебаунс для поля поиска
#include "debouncer.h"
#include <QLineEdit>
#include <QDebug>

void setupSearchField(QLineEdit *searchField)
{
    auto *debouncer = new Debouncer(400, searchField); // 400 мс после последнего ввода

    QObject::connect(searchField, &QLineEdit::textChanged, [debouncer](const QString &text) {
        debouncer->call([text]() {
            qDebug() << "Выполняется поиск по запросу:" << text;
        });
    });
}

Пояснения к коду

Debouncer::call() сохраняет переданное действие и перезапускает singleshot-таймер при каждом вызове — если новый вызов приходит раньше, чем сработал предыдущий таймер, start() просто сбрасывает отсчёт заново, и реальное выполнение m_pendingAction происходит только после паузы длительностью m_delayMs без новых вызовов. В примере с полем поиска это означает, что фактический поиск запускается только после того, как пользователь перестал печатать минимум на 400 миллисекунд.

Throttler::call() устроен иначе: первый вызов в «свободном» состоянии (m_canCallImmediately == true) выполняется сразу и переводит троттлер в «занятое» состояние на m_intervalMs; вызовы, поступившие в течение этого периода, не выполняются немедленно, а запоминаются как m_pendingAction — когда период истекает, запомненное последнее действие (если было) выполняется, и снова запускается период ожидания, гарантируя, что финальное состояние данных не теряется, даже если промежуточные вызовы были «поглощены».

Подводные камни

  • Потенциальная утечка указателя в захваченной лямбде, если объект, на состояние которого ссылается переданное в call() действие, удаляется раньше, чем сработает таймер дебаунса/троттлинга — особенно опасно при захвате this указателя на недолговечный объект; безопаснее захватывать необходимые данные по значению или использовать QPointer для проверки валидности перед выполнением.
  • Различие в семантике дебаунса и троттлинга легко спутать. Дебаунс гарантированно «съедает» промежуточные вызовы во время активного потока событий и выполняет действие только один раз после паузы; троттлинг выполняет действие периодически, не дожидаясь полной остановки потока событий — выбор неподходящей техники для конкретной задачи (например, дебаунс для индикатора прогресса скролла, где нужна периодическая, а не отложенная реакция) даёт неожиданный с точки зрения пользователя результат.
  • Несостояние гонки при многопоточном вызове call(). Реализация в примере не потокобезопасна — QTimer и связанные с ним переменные предполагают, что call() вызывается из того же потока, где живёт объект Debouncer/Throttler; вызов из другого потока без дополнительной синхронизации — гонка данных.
  • Накопление «последнего» действия в троттлере при очень частых вызовах с тяжёлыми захваченными данными. Если каждый вызов call() захватывает заметный объём данных по значению, частая перезапись m_pendingAction создаёт дополнительные накладные расходы на конструирование/разрушение std::function — для очень высокочастотных источников событий стоит измерить реальное влияние на производительность.