[thoughts] Сайд-эффекты: можно ли лучше чем redux-saga и ngrx/effects?
Я некоторое время работал с redux-saga и ngrx/effects. От этого у меня слегка припекло и я начал раздумывать о более простой модели сайд-эффектов.
Критиковать эти инструменты — утомительно. Если вкратце, то они приносят больше сложности, чем необходимо для решения задач. Но все же, я попробую критику превратить в “чего хочется”.
У redux-saga одна из проблем заключается в том, что там слишком легко пропустить событие (см. “Non-blocking calls”). Другая проблема — использование генераторов, что порождает трудности дебага и типизации. С другой стороны, redux-saga позволяет описать понятным образом сайд-эффекты (если не обращать внимание на нейминг).
Проблему ngrx/effects сформулировать трудно. Если быть точнее, то проблема заключается в сложности модели rxjs, заточенной под все-подряд. Сходите на документацию ngrx/effects, проскролльте до конца и убедитесь сами в том, что оно ебанутое. С другой стороны, фишки вроде возможности сделать filter, map, debounce на потоке событий — крутая штука.
Чутка терминологии
Дальше в статье сайд-эффекты будут упоминаться только в такой трактовке:
Сайд-эффект — это логика, которая делает что-то в ответ на изменение модели (стора) или в ответ на событие, связаное с моделью (стором).
Любые иные сайд-эффекты рассматриваться не будут — ими пускай занимается соответствующий слой.
Дисклеймер
Сразу предупрежу, что если вам нужны очень примитивные сайд-эффекты, то вы пишете небольшой сахарок поверх чего угодно и радуетесь жизни. А статья о том, чтобы можно было писать и вещи посложнее.
Чего хочется
- Я хочу легко диспатчить ноль/один/бесконечно экшенов в ответ на случившийся экшен (ngrx/effects плох)
- Я хочу знать легко знать стейт до экшена и стейт после экшена (ngrx/effects плох)
- Я хочу легко дебажить и понятные стектрейсы (ngrx/effects плох)
- Я не хочу ощущать себя ракетостроителем (redux-saga и ngrx/effects плохи)
- Я хочу понятный код, совместимый с основными концептами typescript (redux-saga и ngrx/effects плохи)
- Я хочу, чтобы пропустить событие было трудно (redux-saga плох)
- Я хочу иметь гипотетическую возможность реагировать на последовательность экшенов (ngrx/effects плох)
- Я хочу, чтобы простые сайд-эффекты описывались простым кодом, а сложные — соизмеримым по сложности (ngrx/effects плох)
Истоки проблем
Почти вся история развития redux-saga, rxjs и прочего говна основана на том, что в языках программирования нету средств для удобной работы со стримами.
Соответственно, люди придумывают различные костыли для работы с ними. Подобную историю вы можете вспомнить насчет концепции Promise/Task. Одни ребята улучшали убожество промисов добавлением новых методов на все случаи жизни. Другие ребята пытались с помощью yield приделать императивный вид асинхронному коду. А потом разработчики языков проснулись и убрали нужду в костылях.
А разве у нас реально сейчас есть нужда в костылях?
Улучшаем redux-saga
Итак, для начала просто. Нам нужно убрать к черту генераторы. По-сути, нужда в генераторах не так сильна, если убрать часть фич (которые мне не нужны).
Для начала ограничимся функциями take, select, put, call, fork. А функционал отмены тасков и коммуникацию через каналы — уберем.
Мы можем с легкостью заменить все вызовы yield smth(. ) на вызовы await smth(. ) или даже smth(…) . Но, нужно будет передавать эти функции внутрь описываемого эффекта (чтобы была привязка к стору).
Так, например, функция на redux-saga:
Превратится в такую функцию:
Я переименовал take -> waitFor , select -> getState для большей ясности.
Как должна работать функция waitFor? Мы где-то будем хранить буффер всех произошедших экшенов, но еще необработанных экшенов. Вызов waitFor будет выдавать следующий произошедший экшен и убирать его из буффера. Если экшенов нет — будет ждать пока появятся. Придется произвести такие же манипуляции с getState для консистентности. Прим.: оригинальная функция take работает не так — она не хранит буффер, просто ждет новый таск.
Нужда в функции call (и apply) пропадает — сейчас можно просто вызвать функцию и не капать себе на мозг.
Функция fork будет создавать для подсаги независимый буффер экшенов/стейта — чтобы независимые саги не конкурировали за события.
Вообще, строго говоря, функция fork не очень-то и нужна. Подсаги можно описывать и регистрировать независимо, не пытаясь их скомбинировать в root-сагу. Конечно, пропадает куча интересных возможностей.
При наличии функции fork, реализовать функции takeEvery, takeLatest, takeLeading является тривиальной задачей (это всего-лишь сахар). Без fork уже нетривиально, но все еще реально.
Функционал throttle и debounce тоже можно реализовать, но есть риск, что сильно изменится синтаксис.
Улучшаем ngrx/effects
Мое предложение такое: а давайте больше не будем выебываться, и будем использовать ныне нативный AsyncIterable вместо Observable.
Для начала, мы можем написать конвертер Observable → AsyncIterable. Этот конвертер должен буфферизировать все поступающие события и выдавать их по требованию.
Также вместо потока “экшенов” мы будем работать с потоком “экшен + + новое состояние”. Можно еще добавить даже предыдущие состояния, но это чтобы совсем все засахарилось.
Это даст нам возможность писать следующий код:
Окей, что насчет filter/map? Ну гипотетически, мы можем их добавить к нашему AsyncIterable, как и остальные необходимые нам методы. Ну это если нам очень нужно: ведь внутри сайд-эффектов нет большой необходимости в этом.
Либо еще проще: передавать внутрь сайд-эффекта Observable, а сайд-эффект сам пускай конвертирует его в AsyncIterable, предварительно вызывая любые необходимые операторы типа filter/map.
Гипотетически мы можем написать функцию pipe, которая приводит AsyncIterable к Observable, применяет оператор, и конвертирует обратно. Ну так, чисто в порядке бреда.
Если мы хочем дожидаться цепочку ивентов, то нам нужно, чтобы events были не просто AsyncIterable, а AsyncIterableIterator. Тогда мы сможем написать такой код:
Мы можем пойти чуть дальше, и сделать так, чтобы нашему потоку событий нельзя было сказать “горшочек, не вари”. Для этого придется убедиться, что функция return у нашего AsyncIterator делает ничего (или отсутствует). Тогда мы сможем использовать несколько for await.
В частности, это сделает возможным реализацию подобного кода (это переделанный пример из документации redux-saga):
И да, в подобной модели придется явно диспатчить экшены, но это к лучшему.
Что такое side эффект?
Sideeffect — это что-то, что может повлиять на «чистоту» вашей функции. Редьюсер же — функция. Чистая функция, это значит такая, что если ей на вход подать одни и те же параметры, то результат будет всегда один и тот же.
Пример: есть у вас в localStorage имя пользователя. И вы в коде пишите, что-нибудь такое:
Следовательно, если вы подадите на вход функции, имя Вася, то оно вам вернет Васю только если «в sideeffect локал_сторадже» нет ничего. Здесь вы не можете быть уверены, что если подать Васю, вам всегда вернется Вася.
По примеру с комментариями — не думаю что хороший пример. Айдишники генерировать будет бэкэнд ваш. Вы добавляете новый комментарий путем отправки его на сервер, с сервера приходит статус «ОК» и ваш комментарий уже с айдишником.
Бывает, что айдишники нужно генерировать самому, тогда они отлично генерятся в acftionCreator’ax. Например, делаете вы систему уведомлений, и у каждого уведомления должен быть свой id (например, тут сервер вам не нужен, вы ничего туда не отправляете, просто визуальная часть). В таком случае, я бы не стал генерировать id через middleware, а просто делал бы это в «экшенах».
Тем не менее, с генерацией айди все тоже самое, что и с localStorage. Вы не уверены, что подав на вход: имя, текст комментария и почту — получите ТОТ же результат, что и в прошлый раз с такими же входными параметрами (айдишники то разные будут!)
Осторожно! Возможны побочные эффекты
Начинать нужно с того, что сеет сомнение.
— Братья Стругацкие
Типичный фронтенд
Давайте посмотрим на простой пример фронтенд-кода:
Что является результатом работы этой функции?
- функция делает HTTP запрос при помощи fetch ;
- функция устанавливает атрибут src тегу img при помощи .setAttribute .
От чего зависит эта функция?
- функция зависит от событий, полученных из document.addEventListener ;
- функция зависит от результата HTTP запроса, полученного из fetch .
Теперь давайте скроем реализацию функции и ещё раз посмотрим на неё:
Хм, теперь кажется, что функция ни от чего не зависит и вообще ничего не делает. В целом всё логично — не видя реализации функции, мы не можем определить её действие. Давайте посмотрим на другую функцию:
И скроем её реализацию:
В этом случае мы видим и от чего зависит функция, и что возвращает: она явно что-то делает и возвращает результат, вычисленный от переданного ей list .
Почему так? В чем разница между app и sum ? Что это за два разных вида функций?
Два вида функций
sum берёт зависимые значения только из списка аргументов и возвращает результат только при помощи оператора return — то есть взаимодействует с окружающим кодом только через стандартные механизмы вызова функции.
Такие функции называются чистыми (pure).
app же, наоборот, в качестве аргументов использует данные, которые попадают в неё неявно (не через список аргументов), и возвращает результат своей работы неявно (не через return ).
Про такие функции говорят, что они обладают сайд-эффектами, и в противоположность чистым функциям их называют грязными.
Cайд-эффектами называют неявные зависимости функции или неявные результаты её работы.
Звучит довольно просто, однако на практике в языках без явных ограничений на чистоту функции данное определение предстаёт не таким однозначным.
Чтобы избежать софистики, давайте вместо попытки дать определение сайд-эффектам попробуем выделить их ключевые свойства. То, что будет удовлетворять этим свойствам, мы и будем считать сайд-эффектом .
Сайд-эффект — что ты такое?
Первое и самое главное:
Сайд-эффект — это не первоклассный объект
Что это значит? Первоклассный объект (first-class object) — это сущность в языке программирования, которую:
- можно сохранить в переменной или структурах данных;
- можно передать в функцию как аргумент;
- можно вернуть из функции как результат.
Проще говоря, первоклассный объект можно легко представить в виде некоторого значения. Очевидно, что для сайд-эффектов это не так. Мы не можем просто взять и переписать функцию app в виде:
Сайд-эффекты зависят от/влияют на внешнее окружение
Это довольно очевидное свойство. Для того, чтобы корректно выполнить некоторый сайд-эффект, нам необходимо корректное окружение: для HTTP запроса нужна рабочая сеть, для запроса к DOM необходим сформированный DOM и так далее.
Сайд-эффекты лишают функции ссылочной прозрачности
Ссылочная прозрачность (referential transparency) — свойство функции, благодаря которому можно всегда и везде вместо результата работы функции подставить её вызов.
То есть вместо var result = sum(list); return [result, result]; можно спокойно написать return [sum(list), sum(list)]; .
Функции с сайд-эффектами не обладают этим свойством — вызвав app два раза, мы получим совершенно другие результаты (будет два listener вместо одного, два HTTP запроса при клике вместо одного и так далее).
Сайд-эффекты изменяют свойства кода, в котором используются, до самой вершины стека вызовов
Функция с сайд-эффектом внутри сама становится в некотором смысле сайд-эффектом . Что такое fetch ? Это функция, которая внутри себя содержит сайд-эффекты , или это целиком сайд-эффект? А если завернуть её в дополнительную обёртку? Для нас важно то, что использование сайд-эффектов внутри других функций приводит к приобретению этими функциями всех свойств сайд-эффектов .
На самом деле, скорее всего, последние два свойства вытекают из первого — но это только догадка, и поэтому я решил вынести их в отдельные пункты.
Что всё это значит для нас?
Ну, окей, сайд-эффекты имеют какие-то свои специфичные свойства. Но что эти свойства означают для нас на практике?
Код с сайд-эффектами сложен для анализа (как человеком, так и машиной)
Для начала давайте посмотрим на код без сайд-эффектов — все функции в нём чистые.
Мы можем легко увидеть, какие значения от каких зависят, а какие не играют роли. К примеру, мы видим, что от a ничего не зависит и его вычисление можно смело удалить.
Более того, мы можем построить граф вычислений:
А теперь попробуем проделать то же самое с кодом, в котором, возможно, содержатся сайд-эффекты:
Мы всё ещё можем сказать, что, к примеру, b зависит от value , но мы не можем со всей уверенностью утверждать, что b не зависит от вычисления a . Представьте, что doSomethingA записывает что-то в файл, из которого затем читает doSomethingB . Соответственно, в коде с сайд-эффектами любое вычисление потенциально может зависеть от любого, так как все они влияют на один и тот же внешний мир.
Это оказывает влияние на:
- машинный анализ (IDE не сможет подсказать нам, правильно ли мы используем ту или иную функцию);
- анализ человеком (зачастую код-ревью просто не работает, потому что правки кода в одном месте влияют на другую часть системы).
Также в случае кода с сайд-эффектами значительно усложняется рефакторинг. К примеру, удаление не использующегося кода:
Код с сайд-эффектами сложно переиспользовать
Это менее очевидное следствие из свойств сайд-эффектов. Давайте вновь взглянем на код, состоящий из чистых функций.
Мы написали функцию, которая считает длину и сумму массива:
Используя её, мы можем легко написать функцию, вычисляющую только длину списка:
Или его среднее:
Чистые функции крайне легко переиспользуются: даже если они делают что-то лишнее или что-то немного не так, мы всегда можем исправить это, просто добавив обёртку из ещё одной чистой функции.
Любую проблему можно решить путём введения дополнительного уровня абстракции, кроме проблемы слишком большого количества уровней абстракции.
Давайте посмотрим на функцию с сайд-эффектами, которая делает HTTP запрос и записывает результат в некий файл:
Теперь где-то в коде нам понадобилось отправлять тот же запрос, но не записывать его в файл.
Cкорее всего, мы добавим специальную опцию в sendRequestAndWriteFile :
То же самое для ситуации, когда нам захотелось отправлять запросы на другой URL:
Так как сайд-эффект — это неявный результат, мы не можем повлиять на него за пределами места его создания — отбросить его часть или как-то преобразовать, как в случае с результатом calcLengthAndSum .
Это заставляет нас вместо простого переиспользования добавлять множество опций в функцию, что очень сильно увеличивает цикломатическую сложность кода a.k.a complexity.
Количество вариантов выполнения растёт со скоростью 2^n: для функции всего с двумя булевыми опциями мы получаем уже 4 варианта исполнения, для трёх опций — уже 8 и так далее.
Cайд-эффекты сложно тестировать
С этим наверняка знакомы все. Сравните:
Выглядит немного сложнее, да? Но постойте, мы не проверили тот факт, что наша функция делает ровно один HTTP запрос. Исправим:
Зачем мы вообще пишем автоматизированные тесты? Для того, чтобы контролировать изменения кодовой базы. В случае изменения поведения кода мы сразу увидим, что старые тесты не пройдут. Дело в том, что при достаточно большой кодовой базе различные изменения могут конфликтовать между собой и «ломать» друг друга. Автоматические тесты защищают наши изменения от случайной поломки при каких-либо правках (это может быть банальный мерж конфликтов).
Давайте изменим тестируемые функции и посмотрим, что произойдёт в обоих случаях:
Тест, конечно же, упадёт — 4 не равно
Теперь давайте изменим remoteAdd :
Результат работы функции изменился — теперь она ещё и пишет в файл. Однако наш тест совершенно этого не заметил и продолжает проходить как ни в чём не бывало.
Так как функции с сайд-эффектами зависят от внешнего мира и влияют на него неявно, то, соответственно, чтобы корректно протестировать такую функцию, необходимо:
- полностью смоделировать весь окружающий мир (сетевые запросы, состояние DOM , состояние файловой системы) в виде некоторого значения до вызова функции;
- вызвать функцию;
- проверить состояние значения, моделирующего весь внешний мир.
Очевидно, что сделать это полностью корректно, скорее всего, невозможно, так как количество типов сайд-эффектов в JS не ограничено.
Ситуацию может немного исправить соглашение, что все зависимости, которые могут исполнять сайд-эффекты , должны явно передаваться в функцию, то есть:
Наш тест упадёт с undefined is not function , так как мы не передали writeFile . Отлично!
Какие проблемы у подобного подхода:
мы не можем точно определить, какие зависимости делают сайд-эффект , а какие нет. Поэтому мы будем передавать абсолютно все зависимости таким образом. Очевидно, что делать это самостоятельно невозможно, поэтому нам понадобится специальная система модулей. Данный подход получил название Dependency Injection.
даже с такой системой мы всё ещё можем написать плохой мок в тестах — к примеру, полениться проверить вызов метода .put у HttpClient . Да, мы сделали описание зависимости от внешнего мира более явным. Но у нас всё ещё нет стандартного способа сравнить состояние мира до и после, как мы можем сделать это с данными при помощи стандартной операции глубокого сравнения. Эту операцию мы будем вынуждены писать заново каждый раз для каждой зависимости и, скорее всего, для каждого теста. И рано или поздно мы, вероятней всего, где-нибудь допустим ошибку.
Если бы мы хотели протестировать код с сайд-эффектами «честно», мы должны были написать что-то подобное:
Тогда бы мы получили возможность сверять выполненные сайд-эффекты в функции с ожидаемыми при помощи стандартного .toEqual (обычного глубокого сравнения объектов). Простая передача зависимостей через аргументы (и DI как её следствие) не даёт нам такой возможности — это половинчатое решение. С одной стороны, оно не решает проблему тестирования до конца, с другой — вносит определённое усложнение в наш код: к статической системе модулей добавляется ещё одна, динамическая. Так случилось потому, что вместо устранения причины проблемы (наличия сайд-эффектов ) мы пытались исправить лишь её последствия (сложности тестирования).
Альтернативным способом решения проблемы является написание полностью интеграционных тестов с полноценным браузерным или серверным окружением. Помимо всё тех же проблем со сложностью создания и сравнения состояния окружения (к примеру, как сравнить весь DOM до некоторой операции и после?) добавляются ещё и следующие проблемы:
- подобные тесты медленно запускаются, долго работают, тратят кучу электричества, работают ненадёжно;
- нет возможности хоть как-то локализовать проблему: что-то не работает, а почему — непонятно.
Cайд-эффекты непредсказуемы и не воспроизводимы
Всё течёт, всё меняется, никто не может дважды войти в один и тот же поток, и к смертной сущности никто не прикоснется дважды!
— Гераклит
Станете ли вы заворачивать вызов add(2, 2) в блок try/catch ? Думаю, нет — в этом нет смысла. А divide(a, b) ? Да, конечно — при b === 0 произойдет ошибка.
Может ли произойти ошибка при вызове remoteAdd(2, 2) , и если да, то при каких входных параметрах? Да, может, при любых параметрах. А может и не произойти. Мы не знаем и никак не можем повлиять на это. Внешний мир непредсказуем, он может сломаться в любой момент: браузер может упасть, сеть может погаснуть, а сервер — сгореть.
Из-за того, что внешний мир непредсказуем и постоянно изменяется, мы также не можем воспроизвести результаты вычислений, содержащих сайд-эффекты.
add(a, b) === add(a, b) будет всегда истинно в любых условиях и окружении. Мы можем легко воспроизвести результаты некоторой проблемы с продакшена, просто взяв, к примеру, входные данные с мониторинга и запустив вычисления с этими параметрами. Сайд-эффекты приводят к невоспроизводимым багам: чтобы понять, в чём была проблема, нам надо проанализировать не только наш код, но и состояние всего окружающего мира в тот момент. Это намного более трудоёмко, а порой и вообще невозможно.
Cайд-эффекты не типизируются
Одним из способов контроля кодовой базы и доказательства отсутствия нежелательного поведения программы является статическая типизация.
Очень много копий сломано вокруг того, нужна ли она вообще или нет.
Моё мнение простое: статическая типизация — это инструмент, незаменимый для написания некоторого типа ПО — такого, где очень много кода, много программистов, много связанных подсистем.
Однако вопрос не в этом, а в том, как на использование типизации влияют сайд-эффекты. Рассмотрим пример: предположим, мы имеем функцию, которая по описанию изменения как-то модифицирует DOM :
Неявно эта функция зависит от существования DOM . И её результатом является его изменение. Однако мы никак не можем описать эту информацию в типах — ни о неявной зависимости от DOM , ни о том, что её результатом будет его изменение. В результате, если мы случайно применим эту функцию в окружении без DOM, то получим ошибку при исполнении:
Сайд-эффекты за счет своей неявности не поддаются описанию типами, и тайп-чекер не может помочь найти проблемы с ними.
Возможным решением является использование имитации Higher-Kinded types для реализации типа Eff при помощи Flow :
И, соответственно, serverProgram просто не скомпилируется, если у неё в типе не будет указан Eff типа < write: DOM >, а внутри неё будет использоваться patchDOM .
- этот способ полагается на не самые очевидные механизмы Flow и довольно сложен для понимания;
- он не поможет в случае наличия сайд-эффектов в коллбэках или других местах, из которых нельзя вернуть результат;
- такие типы не будут выведены для javascript API ( console , XMLHttpRequest и так далее).
В общем, решение не общее и не самое простое.
Усложнение интерактивной разработки кода с сайд-эффектами
Интерактивная разработка начинает набирать популярность. Практически все более-менее популярные языки имеют в стандартной поставке REPL (отдельно или в составе дебаггера). Современные браузеры вообще позволяют писать код прямо в них.
Появляются и отдельные IDE, нацеленные именно на интерактивную разработку. К примеру, Light table, позволяющая в реальном времени следить за результатами вычислений:
Давайте посмотрим, какие коррективы вносят сайд-эффекты в подобную практику. Допустим, мы разрабатываем функцию, осуществляющую запрос на удаление некоторого юзера — deleteUser . Очевидно, что мы не сможем запустить эту функцию несколько раз для одного и того же юзера, чтобы проверить её работу в REPL. Более того, для проверки результатов её работы нам понадобится каждый раз смотреть текущее состояние сервера.
Главное преимущество интерактивной разработки — быстрая ответная реакция от только что написанного кода — в таком случае сводится на нет тем, что нам необходимо постоянно наблюдать состояние окружающего мира и периодически исправлять его. Например, восстанавливать удалённого юзера.
Возможным решением здесь будет REPL, интегрированный в тестовый фреймворк: jest-repl , mocha debug repl со встроенной возможностью устанавливать моки для определённых сайд-эффектов.
Для кода с сайд-эффектами сложно применить тестирование, основанное на проверке свойств
Тестирование, основанное на проверке свойств, или генеративное тестирование, или property-based тестирование — техника, позволяющая описывать свойства какой-то программной сущности (функции, к примеру) и проверять её при помощи генерации входных параметров.
Это очень мощная техника, которая позволяет доказать (с некоторой долей вероятности, конечно же) некоторые утверждения о программном коде. Она становится особенно важной в языках без сильной статической системы типов.
Но, как я отметил в предыдущей своей статье, крайне сложно определить какое-либо свойство для кода с сайд-эффектами — за счет их непредсказуемости.
Мелкие вредители, вредящие по-крупному
Сайд-эффекты — как гремлины, ломают все доступные программисту инструменты, до которых доберутся: типизация, тесты, интерактивные среды, статические анализаторы в IDE, код-ревью.
Но почему это всё действительно важно? Ну да, что-то стало сложнее сделать, но программисты привыкли к борьбе со сложностями. И в отдельных пунктах я приводил результаты такой борьбы — инструменты, которые призваны хоть как-то уменьшить негативное влияние сайд-эффектов.
Проблема в том, что сайд-эффекты не только усложняют написание и работу с самим кодом, но и «ломают» два базовых способа разработки ПО. И это уже реальная проблема.
Два сломанных способа разработки ПО
На самом высоком уровне, по большому счету, существует только два способа разработки. Всё остальное — либо их комбинации, либо производные.
Давайте рассмотрим на простом примере оба способа. Предположим, что нам надо разработать систему printSum , которая будет выводить на экран сумму какого-то списка.
«Сверху-вниз» a.k.a Нисходящий стиль a.k.a Top-Down
Определяем спецификацию самого верхнего уровня API — описываем входное и выходное воздействие:
Затем определяем спецификации API уровнем ниже, которые необходимы, чтобы из Array получить PrintedSumToScreenEffect . Очевидно, что нам требуются две функции:
Теперь, просто глядя на спецификации, мы понимаем, что сначала необходимо вызывать от list функцию sum , а затем от её результата — printToScreen .
Осталось придумать, как записывать спецификацию для описания входных и выходных данных:
В идеале она должна описывать все возможные входные воздействия и результаты довольно общим образом — тесты подходят не очень хорошо, так как они описывают отдельные кейсы вместо общего поведения системы.
Мы должны иметь возможность проверить, что разработанная нами программа соответствует изначальной спецификации, иначе при имплементации есть большой шанс получить значительное расхождение со спецификацией. Диаграммы и прочие способы, связанные с ИЗО, не дадут нам такой возможности.
Вы, наверно, уже догадались, что лучше всего здесь подойдёт хорошая система типов.
Люди иногда спрашивают: «Что служит аналогом UML для Haskell?». Когда меня впервые спросили об этом 10 лет назад, я подумал: «Ума не приложу. Может быть, нам стоит придумать свой UML». Сейчас я думаю: «Это просто типы!». Люди рисуют UML-диаграммы, чтобы понять общую схему программы. Именно этим занимаются программисты на функциональных языках и на Haskell, когда придумывают сигнатуры типов для модулей и функций в этих модулях.
— Саймон Пейтон Джонс
Однако, как мы выяснили, без определённых уловок большая часть систем типов не способны работать с сайд-эффектами и уж точно не могут вывести типы таких эффектов из контекста. Можно было бы, конечно, заменить типы тестами (что, например, сделано во всем известном Test Driven Development), но, как мы уже увидели, тесты не очень хорошо подходят для этого из-за своей дискретной природы, к тому же они тоже страдают от сайд-эффектов.
Таким образом сайд-эффекты ломают первый базовый способ разработки ПО. Но, может, со вторым нам повезёт больше?
«Снизу-вверх» a.k.a Восходящий стиль a.k.a Bottom-Up
Мы можем пойти с другой стороны.
Уже по описанию задачи видно, что нам надо будет уметь выводить что-то на экран и надо уметь складывать. Мы не будем пытаться определить точные спецификации. Вместо этого просто напишем общие и минимально необходимые функции для этого. Больше всего эти функции будут похожи на отдельные небольшие библиотеки (очень малоспецифичные и очень переиспользуемые единицы), так как мы ещё не знаем, что за API нам придётся с их помощью строить.
Затем строим из этих функций API более высокого уровня:
Для такого итеративного процесса нам необходим инструмент, позволяющий легко экспериментировать с небольшими кусочками кода и иметь возможность быстро запустить отдельные функции на разных входных данных. При этом в процессе разработки нам не так важно зафиксировать некоторый результат и уметь его воспроизводить — вероятней всего, мы редко будем менять имплементацию уже написанного API. Тесты тут будут скорее мешать низкой скоростью ответной реакции и своей хрупкостью.
Лучше всего для такого стиля разработки подойдёт REPL . Собственно, такой вид разработки и получил распространение в языках с богатой практикой использования REPL : Lisp , SmallTalk .
Однако, как мы помним, REPL теряет свое главное преимущество (быстрый отклик) при разработке кода с сайд-эффектами. Второй фундаментальный способ тоже не выдержал этой битвы.
Но ведь не весь наш код содержит сайд-эффекты? И, может, все эти неприятности касаются только тех участков, в которых мы их используем? Может, просто стараться использовать поменьше «грязных» функций и побольше «чистых»?
Лед-9 для программного кода
— Ты читал «Колыбель для кошки»?
— Нет.
— Итак, в этом романе мир погибает потому, что во льду обнаружена молекула, которая при соприкосновении с водой превращает её в лед. А поскольку все воды мира связаны — пруд с ручьем, ручей с рекой, река с озером, озеро с океаном — таким образом, весь мир замерзает и погибает. И эта молекула называется «Лед-9».
Программисты, заставшие JS без Promise и async-await , могут почувствовать что-то знакомое в описании недостатков сайд-эффектов. Эти же проблемы зачастую упоминались в обсуждении асинхронных функций, основанных на коллбэках.
Асинхронность и сайд-эффекты выглядят довольно связанными проблемами — решив только одну из них, вы не избавитесь от всех их недостатков, они лишь переместятся. С другой стороны, хорошее решение одной из этих проблем может помочь решить другую. В дальнейшем мы увидим, что это не случайно и на самом деле оба этих явления представляют собой лишь частные случаи более фундаментальной проблемы отсутствия общего способа абстракции control-flow программы и выражения его first-class значениями.
Но самое ужасное в таких функциях было то, что они заражали весь код, в котором использовались. Обычная функция при использовании в ней функции с коллбэком переставала возвращать результат через return и начинала прокидывать его в коллбэк — и, в свою очередь, сама становилась «ядом» для использующего её кода.
Появилась даже метафора двухцветного языка:
Все функции в языке делятся на «красные» и «синие». И чтобы вызвать «красную» функцию в «синей», нам необходимо перекрасить «синюю» функцию в красный цвет.
Происходило это из-за того, что подобные асинхронные функции для взаимодействия с кодом не использовали стандартные способы взаимодействия — возврат значения через return . Как мы помним, функции с сайд-эффектами ведут себя так же и возвращают свои результаты неявно.
Поэтому всё то же самое применимо и к ним:
Функция с сайд-эффектом внутри сама становится сайд-эффектом . Следовательно, используя такую функцию внутри другой (чистой), мы автоматически превращаем её в грязную — она начинает возвращать часть результата неявно. И так далее до самой вершины стека вызовов.
Рассмотрим пример. Допустим, у нас есть такая иерархия вызовов:
Все вызовы чистые и предсказуемые. С ними нет никаких проблем.
Но неожиданно нам понадобилось кэшировать результаты вычисления функции calcForItem в localStorage :
И наша иерархия стала выглядеть так (красным отмечены функции с сайд-эффектами ):
Изменив код всего одной функции, мы изменили свойства (в плане тестируемости, надёжности, композируемости) для всего стека вызовов.
В некотором смысле мы теряем контроль над своим кодом. Его поведение может измениться, хотя он сам останется прежним — просто API, на котором основан наш код, внезапно станет «грязным» и «заразит» его.
In Soviet Russia side-effects control you!
Если мы не контролируем ПО, то оно начинает контролировать нас (разработчиков). В результате всего вышесказанного у нас получается код, который:
нельзя полноценно протестировать, да и, чтобы протестировать хоть как-то, нужно приложить много усилий — из-за этого мы пишем недостаточно тестов. Проверьте свое покрытие кода с сайд-эффектами и кода, работающего только с данными — скорее всего, во втором случае цифра будет намного выше;
нельзя полноценно типизировать, потому что значительную часть фронтенд-кода занимают функции типа handleClick(): void , dispatch(): void , setState(): void ;
нельзя верифицировать или попробовать доказать его свойства при помощи property-based тестов — у большей его части просто нет каких-либо предсказуемых свойств;
неудобен для работы в интерактивной среде ( REPL ), потому что там не получится работать с DOM-элементами или безопасно послать HTTP-запрос;
практически не поддается рефакторингу, так как сайд-эффекты создают неявные зависимости;
непредсказуем — невозможно создать надёжные инструменты воспроизведения поведения приложения. Обычно не является проблемой по результатам мониторинга понять, что произошо. Но вот почему так произошло — может быть совершенно неясно, так как части приложения влияют друг на друга неявно;
очень тяжело переиспользуется. Иногда без внесения правок в исходники нельзя переиспользовать то или иное решение. Поэтому наученные горьким опытом разработчики зачастую стремятся не переиспользовать крупные части своего приложения (не говоря уже о больших и сложных сторонних компонентах), так как не ясно, возможно ли будет избавиться от некоторых нежелательных действий, если они вдруг станут не нужны.
При этом разработчики сами по себе не хотят писать такой код. Почти любой разработчик скажет, что код должен быть хорошо тестируемым, переиспользуемым и так далее. Нас учат этому с самого начала работы в индустрии.
Однако сайд-эффекты в нашем коде вынуждают нас частично отказаться от всех хороших практик и практически всех доступных программисту инструментов. Софт, который контролирует людей? Я знаю, кому это точно понравится:
При этом мы не пытаемся избавиться от этого контроля и воспринимаем его как что-то само собой разумеющееся. Все эти проблемы давно известны, но мы не пытаемся воздействовать на их причину, а только исправляем отдельные симптомы:
React стал популярен во многом благодаря тому, что абстрагировал часть сайд-эффектов , возникающих при работе с DOM . Но только часть — работа с событиями и работа с отдельными элементами всё так же происходят при помощи сайд-эффектов.
Redux позволил описывать преобразования глобального мутабельного стейта при помощи чистых функций. Однако сами изменения вызываются при помощи грязной функции dispatch . Да и, к примеру, то же самое сетевое взаимодействие всё так же продолжает порождать сайд-эффекты (хотя это можно исправить при помощи redux-loop , redux-saga ).
Angular 2 , напротив, позволяет обрабатывать события без использования коллбэков (то есть без сайд-эффектов). Но он не имеет какого-либо решения для абстракции остальных сайд-эффектов.
I have a dream.
Попытки решать фундаментальную проблему, борясь только с её отдельными проявлениями, вряд ли могут увенчаться успехом.
Нам необходимо фундаментальное решение проблемы сайд-эффектов в нашем коде. Причём оно должно было максимально общим — не привязанным к какому-то фреймворку или инфраструктуре и не навязывающим какую-либо конкретную архитектуру. Оно должно решать ровно одну проблему и делать это хорошо.
Но как мы можем создать такое решение?
Мы не можем избавиться от сайд-эффектов совсем или даже уменьшить их число — нельзя вдруг начать делать меньше HTTP запросов или меньше работать с DOM . Сайд-эффекты — взаимодействие с внешним миром — это вообще самая главная часть нашего приложения. Если приложение не делает их, значит, скорее всего, оно вообще ничего не делает.
Но мы можем полностью отделить логику нашего приложения от сайд-эффектов.
Всё наше приложение станет полностью чистой функцией — будет явно принимать все входящие воздействия и явно возвращать выходные. А сайд-эффекты будут исполняться отдельно от основного приложения.
Существует минимум 3 способа сделать это, и все они основаны на теоретических основах Computer Science, разработанных около 40 лет назад.
Все эти способы объединяет то, что они созданы для абстракции control-flow программы. Control-flow — это скелет нашей программы, её базис, поэтому эти способы возникают при решении большей части проблем в Computer Science — не только при решении проблемы сайд-эффектов. Однако это тема для следующей статьи.
Побочный эффект (информатика) — Side effect (computer science)
В информатике, операция, функция или выражение, как говорят, имеют побочный эффект, если они изменяют какое-либо значение переменной state (s) вне своего локального окружения, то есть имеет наблюдаемый эффект помимо возврата значения (основного эффекта) вызывающей стороне операции. Данные состояния, обновленные «вне» операции, могут поддерживаться «внутри» объекта с отслеживанием состояния или более широкой системы с отслеживанием состояния, в которой выполняется операция. Примеры побочных эффектов включают изменение нелокальной переменной, изменение статической локальной переменной , изменение изменяемого аргумента , переданного по ссылке, выполнение ввода-вывода или вызов других функций побочного эффекта. При наличии побочных эффектов поведение программы может зависеть от истории; то есть порядок оценки имеет значение. Понимание и отладка функции с побочными эффектами требует знания контекста и его возможных историй.
Степень использования побочных эффектов зависит от парадигмы программирования. Императивное программирование обычно используется для создания побочных эффектов, чтобы обновить состояние системы. Напротив, Декларативное программирование обычно используется для сообщения о состоянии системы без побочных эффектов.
В функциональном программировании побочные эффекты используются редко. Отсутствие побочных эффектов упрощает формальную проверку программы. Функциональные языки, такие как Standard ML, Scheme и Scala, не ограничивают побочные эффекты, но программисты обычно их избегают. Функциональный язык Haskell выражает побочные эффекты, такие как ввод-вывод и другие вычисления с сохранением состояния, используя монадические действия.
Программисты на языке ассемблера должны знать скрытых побочных эффектов — инструкции, которые изменяют части состояния процессора, не упомянутые в мнемонике инструкции. Классическим примером скрытого побочного эффекта является арифметическая инструкция, которая неявно изменяет коды условий (скрытый побочный эффект), а также явно изменяет регистр (явный эффект). Одним из потенциальных недостатков набора инструкций со скрытыми побочными эффектами является то, что, если многие инструкции имеют побочные эффекты на одну часть состояния, например коды условий, то логика, необходимая для последовательного обновления этого состояния, может стать производительностью. горлышко бутылки. Проблема особенно остро стоит на некоторых процессорах, разработанных с использованием конвейерной обработки (с 1990 года) или с выполнением вне очереди. Такому процессору может потребоваться дополнительная схема управления для обнаружения скрытых побочных эффектов и остановки конвейера, если следующая инструкция зависит от результатов этих эффектов.
Содержание
- 1 Ссылочная прозрачность
- 2 Временные побочные эффекты
- 3 Идемпотентность
- 4 Пример
- 5 См. Также
- 6 Ссылки
Ссылочная прозрачность
Отсутствие побочных эффектов является необходимым, но не достаточным условием ссылочной прозрачности. Ссылочная прозрачность означает, что выражение (например, вызов функции) может быть заменено его значением. Для этого требуется, чтобы выражение было чистым, то есть выражение должно быть детерминированным (всегда давать одно и то же значение для одного и того же ввода) и побочный эффект свободно.
Временные побочные эффекты
Побочные эффекты, вызванные временем, затрачиваемым на выполнение операции, обычно игнорируются при обсуждении побочных эффектов и ссылочной прозрачности. В некоторых случаях, например, с аппаратной синхронизацией или тестированием, операции вставляются специально из-за их временных побочных эффектов, например sleep (5000) или for (int i = 0; i . Эти инструкции не изменяют состояние, за исключением времени для завершения.
Idempotence
Функция f с побочными эффектами называется идемпотентной при последовательной композиции f; f , если при двойном вызове с одним и тем же списком аргументов второй вызов имеет без побочных эффектов и возвращает то же значение, что и при первом вызове (при условии, что между концом первого вызова и началом второго вызова не было вызвано никаких других процедур).
Например, рассмотрите следующий Код Python :
Здесь setx идемпотентен, потому что второй вызов setx (с тем же аргументом) не изменяет видимое состояние программы: x уже был установлен на 5 при первом вызове и снова установлен на 5 при втором вызове, таким образом сохраняя то же значение. Обратите внимание, что это отличается от идемпотентности в составе функции f ∘ f . Например, абсолютное значение идемпотентно в составе функции:
Пример
Одной из распространенных демонстраций поведения побочного эффекта является оператор присваивания в C ++. Например, присвоение возвращает правильный операнд и имеет побочный эффект присвоения этого значения переменной. Это позволяет синтаксически чистое множественное присваивание:
Поскольку оператор связывает справа, это равняется
, где результат присвоения 3 в j затем присваивается в i . Это представляет собой потенциальное зависание для начинающих программистов, которые могут запутать