Избавляемся от состояний
Избавляемся от состояний: как работать без def
внутри функций
В функциональном программировании мы стремимся к написанию чистых функций — таких, которые возвращают один и тот же результат для одних и тех же аргументов и не имеют побочных эффектов. Это делает код предсказуемым, легче тестируемым и менее подверженным багам.
Один из главных врагов чистоты — использование изменяемого состояния (например, def
или atom
) внутри функций. Это создаёт неявную зависимость от внешнего мира, которая усложняет рассуждения о коде.
Давайте разберёмся, как обходиться без этого, передавая данные явно по цепочке.
Проблема: функция с внутренним состоянием
Допустим, мы пишем функцию для обработки данных, которой нужно учитывать какой-то накапливающийся контекст. Классический императивный подход с использованием def
:
(def last-price (atom nil)) ; Глобальное состояние — источник всех бед (defn process-signal [signal] (let [current-price (:price signal)] (if-let [prev @last-price] ; Чтение из глобального атома (do (println "Изменение цены:" (- current-price prev)) (reset! last-price current-price) ; Запись в глобальный атом ; Какая-то логика с сравнением current-price и prev (:action signal)) (do (reset! last-price current-price) nil)))) ; Для первого вызова ;; Вызовы (process-signal {:price 100 :action :buy}) ; => nil (process-signal {:price 105 :action :hold}) ; => :hold (и печатает "Изменение цены: 5")
- Неявная зависимость: Функция
process-signal
зависит от внешнего, скрытого состоянияlast-price
. Чтобы понять, что она делает, нужно знать состояние этого атома. - Трудное тестирование: Перед каждым тестом нужно сбрасывать состояние атома
(reset! last-price nil)
, иначе тесты будут влиять друг на друга. - Ненадёжность: В многопоточном окружении такое глобальное состояние может быть изменено из другого потока в любой момент, что приведёт к трудноуловимым багам.
Решение: явная передача состояния по цепочке
Вместо того чтобы прятать состояние снаружи, мы будем явно принимать его как аргумент и возвращать его новую версию как результат.
Шаг 1: Чистая функция с явным состоянием
Перепишем нашу функцию. Теперь она будет принимать два аргумента: текущее состояние (последнюю цену) и новый сигнал. И возвращать она будет не просто результат, а вектор [результат новое-состояние]
.
;; state здесь - это последнее известное значение цены (last-price) (defn process-signal-pure [state signal] (let [current-price (:price signal)] (if-let [prev state] ; Теперь state — это просто аргумент ;; Если состояние было, возвращаем результат и НОВОЕ состояние [(:action signal) current-price] ;; Если состояния не было (nil), возвращаем nil и новое состояние [nil current-price]))) ;; Пример использования одного вызова: (process-signal-pure nil {:price 100 :action :buy}) ;; => [nil 100] ;; Как это использовать в цепочке? (let [[result-1 new-state-1] (process-signal-pure nil {:price 100 :action :buy}) [result-2 new-state-2] (process-signal-pure new-state-1 {:price 105 :action :hold})] (println "Result 2:" result-2) ; => :hold (println "New state 2:" new-state-2)) ; => 105
Уже лучше! Функция стала чистой. Её результат зависит только от аргументов. Её легко протестировать:
(deftest test-process-signal (is (= [nil 100] (process-signal-pure nil {:price 100 :action :buy}))) (is (= [:hold 105] (process-signal-pure 100 {:price 105 :action :hold}))))
Шаг 2: Автоматизируем передачу состояния
Вручную передавать состояние new-state-1
в new-state-2
неудобно, особенно для длинных цепочек. Здесь на помощь приходит мощнейший инструмент — функция reduce
.
reduce
по своей сути идеально подходит для нашего паттерна: он принимает функцию (fn [acc element] ...)
, где acc
— это аккумулятор (наше состояние), а element
— очередной элемент коллекции. И возвращает новое состояние аккумулятора.
;; Допустим, у нас есть поток сигналов (def signals [ {:price 100 :action :buy} {:price 105 :action :hold} {:price 103 :action :sell} {:price 110 :action :hold} ]) ;; Нам нужны только результаты (actions), состояние нас не интересует (let [results (map first ; Достаём первый элемент из каждого результата ([result state]) (rest ; Пропускаем первый шаг, где результат был nil (reductions ; reductions, а не reduce, чтобы получить всю историю (fn [state signal] (process-signal-pure state signal)) nil ; Начальное состояние signals)))] (println (vec results))) ; => [nil :hold :sell :hold] ;; Или если нам нужно только конечное состояние: (let [[final-result final-state] (reduce (fn [state signal] (process-signal-pure state signal)) nil signals)] (println "Final state:" final-state)) ; => 110
Шаг 3: Обобщаем паттерн
Этот подход применим к любым задачам, где нужно сохранять промежуточный контекст:
- Расчёт running total (скользящей суммы).
- Агрегация данных (например, подсчёт среднего значения).
- Трансдукторы и цепочки обработки данных.
- Реализация конечных автоматов.
Главный вывод: Состояние — это не враг. Враг — это неявное, скрытое состояние. Превращая его в явный аргумент и возвращаемое значение, вы делаете свой код:
- Понятным: Все входы и выходы функции видны как на ладони.
- Тестируемым: Не нужно готовить сложное окружение, просто передайте нужный аргумент.
- Безопасным: Функция независима от внешнего мира и потокобезопасна по своей природе.
Перестаньте хранить состояние внутри системы. Начните прогонять его через чистейшие функции, и вы откроете для себя новый уровень контроля над сложностью ваших программ.
Реальный рефакторинг
Когда я писал своего первого торгового робота, то столкнулся с проблемой. У меня была функция, которая решала, находимся ли мы в сделке или нет. Выглядела она примерно так:
(def stop-loss-atom (atom nil)) (def take-profit-atom (atom nil)) (def current-side-atom (atom nil)) (defn in-the-deal? [price new-sl new-tp side] (let [current-sl @stop-loss-atom] ; Тайное знание 1 current-tp @take-profit-atom] ; Тайное знание 2 ;; Тут куча логики, которая меняет эти атомы ;; Спустя 20 строк... (reset! stop-loss-atom new-sl) ; Изменение глобального состояния ))
Такой код работал. Но с ним было две беды:
- Нельзя было понять его сходу. Что сделает вызов
(in-the-deal? 100 95 105 :buy)
? Неизвестно. Чтобы ответить, нужно знать, что лежало в атомахstop-loss-atom
иtake-profit-atom
прямо перед вызовом. - Его было невозможно нормально тестировать. Перед каждым тестом надо было вручную настраивать атомы
reset!
, а после — сбрасывать их.
Я долго не понимал, в чём проблема. Пока не осознал простую вещь.
Откровение: Функция не должна быть шпионом
Проблема была в том, что моя функция вела себя как шпион:
- Крала информацию: сама доставала данные из глобальных переменных (
@stop-loss-atom
) - Оставляла следы: тайком меняла глобальное состояние (
reset!
)
Решение оказалось до смешного простым. Нужно было перестать быть шпионом и начать быть простым работягой.
Работяга не крадёт информацию. Её ему выдают на руки.Работяга не оставляет следов. Он отдаёт результат начальнику.
Как это работает на практике
Шаг 1. Объявляем все нужные данные одной структурой — пусть это будет state
(состояние).
Шаг 2. Заставляем функцию принимать это состояние явно, как аргумент.
Шаг 3. Функция возвращает не только результат, но и новое состояние.
;; Никаких глобальных атомов тут нет! ;; Функция работает только с тем, что в неё передали (defn in-the-deal? [state price new-sl new-tp side] ;; Вся информация здесь — в аргументах функции (if state ;; Если состояние есть — проверяем цену (if (or (<= price (:sl state)) (>= price (:tp state))) [false nil] ; Выходим из сделки [true state]) ; Остаёмся в сделке ;; Если состояния нет — возможно, входим в сделку (if (and new-sl new-tp side) [false {:sl new-sl :tp new-tp :side side}] ; Входим [false state]))) ; Ничего не делаем
Теперь функция — это простой преобразователь.(in-the-deal? состояние цена новый-sl новый-tp сторона)
→ [результат новое-состояние]
;; Первый вызов: входим в сделку (in-the-deal? nil 100 95 105 :buy) ;; => [false {:sl 95, :tp 105, :side :buy}] ;; Второй вызов: проверяем сделку (in-the-deal? {:sl 95 :tp 105 :side :buy} 102 nil nil nil) ;; => [true {:sl 95, :tp 105, :side :buy}] ;; Третий вызов: выходим по тейк-профиту (in-the-deal? {:sl 95 :tp 105 :side :buy} 106 nil nil nil) ;; => [false nil]
А где же хранить состояние?
Состояние никуда не делось. Мы просто вынесли его на уровень выше — туда, откуда вызывается функция.
;; Теперь состояние хранится в одном атоме (def app-state (atom nil)) (defn process-price [price] ;; 1. Достаём состояние из хранилища (let [current-state @app-state ;; 2. Отдаём его функции + новые данные [in-deal new-state] (in-the-deal? current-state price nil nil nil)] ;; 3. Сохраняем новое состояние (reset! app-state new-state) ;; 4. Используем результат in-deal))
Что это дало?
- Понятность. Теперь можно смотреть на любой вызов
in-the-deal?
и сразу понимать, что произойдёт. Не нужно знать историю предыдущих вызовов. - Тестируемость. Функция стала чистой. Её можно тестировать без подготовки:
(is (= [false nil] (in-the-deal? {:sl 95 :tp 105 :side :buy} 106 nil nil nil)))
Надёжность. Функция больше не зависит от скрытого состояния. Её поведение полностью определяется аргументами.
Вывод
;; ❌ ПЛОХО, НЕ НАДО ТАК (def stop-loss-atom (atom nil)) ; Атом здесь (def take-profit-atom (atom nil)) ; И здесь (def current-side-atom (atom nil)) ; И тут же (defn in-the-deal? [price new-sl new-tp side] ; А функция тут ;; ... и внутри функции лезу в эти атомы (@stop-loss-atom, reset!) )
Это плохо, потому что: Функция in-the-deal?
становится заложником этих конкретных атомов. Она жёстко привязана к ним. Её нельзя использовать в другом месте программы, где состояние хранится иначе. Её невозможно нормально протестировать.
Беру за правило делать вот так:
;; ✅ ХОРОШО, ДЕЛАЕМ ТАК ;; 1. Объявляем функцию. Она НЕ знает о существовании каких-либо атомов. ;; Она работает только с тем, что ей передали. (defn in-the-deal? [state price new-sl new-tp side] ;; Вся её работа происходит только с аргументом `state` и другими аргументами. ;; Никаких @ и reset! внутри нет! (if (:sl state) ;; ... логика ... [result new-state] ; Возвращаем результат И новое состояние [result new-state])) ;; 2. В другом месте, где это удобно (например, в -main или в модуле управления состоянием), ;; создаём ОДИН атом для хранения всего состояния приложения. (def app-state (atom nil)) ; Храним здесь либо nil, либо {:sl ... :tp ... :side ...} ;; 3. В коде, который вызывает функцию (например, главный цикл), ;; мы сами управляем этим атомом. (defn process-tick [price] (let [current-state @app-state ; МЫ сами достаём состояние [result new-state] (in-the-deal? current-state price nil nil nil)] ; МЫ передаём его в функцию (reset! app-state new-state) ; МЫ же сохраняем новое состояние, которое функция вернула result))
- Функция чистая и независимая. Её можно вынести в отдельный модуль, протестировать в изоляции и использовать где угодно.
- Контроль остаётся у нас. Мы сами решаем, где и как хранить состояние. Сегодня это атом
app-state
, а завтра мы можем захотеть хранить состояние в базе данных или передавать по сети — и для этого не придётся переписывать функциюin-the-deal?
. - Код понятнее. Связи явные, а не скрытые.
Это один из самых важных шагов к написанию чистого, понятного и поддерживаемого кода на Clojure.
Цепочки — это самое интересное
Когда функция возвращает не просто результат, а результат и новое состояние, это позволяет строить из них последовательности вычислений, где состояние передаётся по цепочке, как эстафетная палочка.
Была одна функция-монстр, которая внутри себя управляла состоянием через атомы. Никаких цепочек не было — был просто периодический вызов этой функции.
;; Вызов 1 (in-the-deal? 100 95 105 :buy) ; => false, атомы изменились внутри ;; Вызов 2 (in-the-deal? 102 nil nil nil) ; => true, состояние атомов позволило это сделать ;; Вызов 3 (in-the-deal? 106 nil nil nil) ; => false, атомы снова изменились внутри
Связь между вызовами была невидимой и скрытой в атомах.
Стало (как надо):
Появилась возможность явно видеть, как состояние меняется от вызова к вызову. Это и есть цепочка.
Вручную (для понимания):
;; Имитируем три последовательных вызова ;; Вызов 1: Вход в сделку (let [[result-1 state-1] (in-the-deal? nil 100 95 105 :buy)] (println "Шаг 1. Результат:" result-1 "Состояние:" state-1) ;; => Шаг 1. Результат: false Состояние: {:sl 95, :tp 105, :side :buy} ;; Вызов 2: Проверка цены (передаём состояние, полученное из первого вызова) (let [[result-2 state-2] (in-the-deal? state-1 102 nil nil nil)] (println "Шаг 2. Результат:" result-2 "Состояние:" state-2) ;; => Шаг 2. Результат: true Состояние: {:sl 95, :tp 105, :side :buy} ;; Вызов 3: Выход из сделки (передаём состояние из второго вызова) (let [[result-3 state-3] (in-the-deal? state-2 106 nil nil nil)] (println "Шаг 3. Результат:" result-3 "Состояние:" state-3) ;; => Шаг 3. Результат: false Состояние: nil )))
Это и есть цепочка: nil
-> state-1
-> state-2
-> state-3
. Состояние явно передаётся от одного вызова к следующему.
Автоматически (как это делается на практике):
Вручную писать такие let
неудобно. Для работы с такими цепочками в Clojure есть идеальные инструменты — reduce
и reductions
.
Пример с reduce
(получим только конечный результат):
;; Есть поток цен (let [prices [100 102 106 103 107] ;; Проходим по всем ценам, накапливая состояние [final-result final-state] (reduce (fn [current-state price] ;; current-state - это "эстафетная палочка" ;; price - очередное значение из потока (let [[result new-state] (in-the-deal? current-state price nil nil nil)] new-state)) ; reduce просит вернуть "палочку" для следующего шага nil ; Начальное состояние "палочки" - nil prices)] ; Поток цен (println "Конечное состояние после всех цен:" final-state))
Пример с reductions
(получим ВСЮ историю состояний и результатов):
(let [prices [100 102 106 103 107] history (reductions (fn [current-state price] (let [[result new-state] (in-the-deal? current-state price nil nil nil)] new-state)) ; Возвращаем состояние для следующего шага nil prices)] ;; history будет содержать всю цепочку: [nil {:sl 95, ...} {:sl 95, ...} nil ...] (println "История изменений состояния:" history))
В чём мощь этого подхода?
- Вся история вычислений у нас в кармане. Мы можем буквально "проиграть" все шаги работы кода на исторических данных и посмотреть, в какой момент и почему состояние менялось.
- Композируемость. Мы можем строить цепочки не только из одной функции, но и из нескольких.
Например:[signal, new-state] -> (risk-manager new-state signal) -> [final-signal, final-state]
Одна функция передаёт состояние следующей. - Декларативность. Код описывает поток данных ("обработай вот этот поток цен, передавая состояние"), а не процедуру ("сделай вот это, потом вот то, проверь атомы...").
Итог: Цепочки появляются не вместо нашей функции, а благодаря тому, что мы переделали её в чистую. Это открывает совершенно новые возможности по анализу и компоновке логики, которые были недоступны, когда функция тайно работала с атомами.