Чистые и эффективные функции: Эффекты

January 19, 2021

Начало темы здесь и здесь.

Сегодня расскажу о том, что я понимаю под эффектами и обработкой сигналов.

Что я понимаю под эффектами и сигналами

Обработка сигнала - акт чтения глобальной изменяемой памяти.

Отправка сигнала - акт записи глобальной изменяемой памяти.

Глобальная изменяемая память - область памяти, на которую в момент чтения/записи замаплено изменяемое поле объекта или структуры, достижимого из GC root.

Эффект - операция отправки или обработки сигнала. Является подмножеством понятия побочный эффект, часто встречаемого в литературе.

Примечание

Термин обработка/отправка сигнала мне самому не очень нравится, но он не лишён смысла.

Если рассматривать ввод/вывод, то даже вроде бы синхронный вызов read, на деле асинхронный и внутри вызова выполняется обработка сигнала (прерывания) ``данные готовы''. А вызов write - это собственно отправка сигнала на запись устройству вывода.

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

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

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

Если что-то написал не понятно - пишите в комменты, поясню:)

Это всё ради эффектов

А теперь сюрприз-сюрприз - мы пишем программы ради эффектов. Конечному пользователю (в лице QA и продакта:) ) пофиг на технологии, архитектуру, крутые алгоритмы, стиль кода и т.п. Всё что он может сделать с программой - это отправить сигнал (который будет обработан программой) и обработать сигнал (отправленный программой) полученный в ответ. Т.е. какие-бы то ни было суждения о программе пользователь может делать только на основании эффектов её исполнения.

Поэтому в тест-планах всегда есть шаги (набор сигналов для отправки) и ожидаемый результат (набор сигналов для обработки и проверки). А в случае информационных систем 90-99% тест кейсов вообще не содержат каких-либо правил по проверке ожидаемых результатов помимо наблюдаемого эффекта, т.к. ожидаемый результат бинарный - логин удался или нет, введённая строка появилась в нужном месте или нет.

То есть именно на основании эффектов конечный пользователь (в лице QA или продакта:) ) будет принимать решение о том сделал программист свою работу или ``всё говно, переделывай''.

И при таком уровне важности Эффектов для работы программиста, абсолютно во всех проектах, которые я видел за 16 лет карьеры, они находились в слепой зоне и никто осознано не подходил к управлению ими. То есть я ни разу в жизни не видел осознанного подхода к своей работе. Справедливости ради, сам я тоже не Будда Просветлённый и осознанным подходом пока не отличаюсь:)

Наконец, имхо, игнорирование эффектов - это фактически игнорирование конечного пользователя. Потому что это ваше основное средство коммуникации с конечным пользователем.

Надеюсь, я вас убедил в необходимости осознаного управления эффектами, но что же это значит?

Осознанное же управление эффектами, это когда программист пишущий условный print или db.save держит в голове что-то вроде ``сейчас я программирую отправку сигнала А, в ответ на получение сигнала Б для удовлетворения пункта 5.2.52 требований''.

Управление эффектами - неотъемлемая часть моего Эргономичного Подхода и обязательная характеристика эргономичного кода.

А неосознанная генерация эффектов налево и направо ведёт к куче проблем, которые я видел абсолютно во всех проектах за 16 лет карьеры (пока не начал разрабатывать Эргономичный Подход:) ).

Это всё из-за эффектов

ВременнАя связность

Примечание

Это капец, товарищи! Об этом понятии на русском ваще ничего нагуглить не могу, в русской вики его вообще нет, а в английской, он упоминается мелким пунктом где-то в середине. При том что сама википедия ставит его на второе место по ``плохости''.

Эффекты могут обладать неявной временнОй связностью (temporal coupling) между собой. Это не в том смысле, что связность пропадает со временем, а в том смысле что, результат выполнения двух эффектов зависит от очерёдности их исполнения. Например, рассмотрим такую программу:

val buffer = StringBuilder()
buffer.append("a")
buffer.deleteCharAt(0)

В этой программе вызов deleteCharAt зависит от вызова append и если их переупорядочить, то программа закрешится. Здесь связь очевидна, но эти вызовы могут быть спрятаны внутри разных функций, внутри разных классов, внутри разных модулей. И если вы их случайно переупорядочите во время рефакторинга в таких обстоятельствах - могу лишь пожелать удачи в бою дебаге. Ну и чтобы баг нашли тесты или хотя бы QA, а не пользователи.

Чистая же версия этого кода такая: чистый код

val emptyString = ""
val aString = emptyString + "a"
val clearedString = aString.substring(1, aString.length)

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

Речь тут идёт не только о памяти - работа с любым изменяемым хранилищем вроде диска, СУБД, REST API и т.п., создаёт временнУю связность между кусками кода, которые пишут и читают это хранилище.

Нелокальность рассуждений

Из-за временнОй связности теряется локальность рассуждений о программе.

Если вы работаете с кодом ``неосознанного'' проекта, то у вас объекты будут мутабельными. И вы не можете свободно вызывать какие-либо методы передавая свой объект в качестве параметра, потому как в таком проекте с высокой долей вероятности вызываемой код так или иначе изменит ваш объект и далеко не факт, что так, как вам надо, а не иначе.

В результате передавая объект в чужой метод вы вынуждены ``покинуть'' метод над которым работаете, и пройтись по стэку вызовов, чтобы удостовериться, что там никто вам не изгадит ваш объект. И дай вам бог, чтобы на вашем пути не встретились драконы полиморфные и рефлексивные вызовы:) Наконец, если сейчас передача объекта безопасна, то не значит что завтра ваш коллега или вы сами не подложите себе свинью, в виде неожиданной мутации.

Регрессии

Но положа руку на сердце, вы меняя код задумываетесь о том, как эти изменения повлияют на порядок эффектов, на какое состояние эти эффекты направлены, и какие ещё эффекты направлены на это состояние? Прям каждый раз и прям тщательно всё изучая? Я - нет.

В итоге я регулярно меняя одно место, ломаю другое. Это ведёт к страху рефакторинга. Это ведёт к загниваюнию кодовой базы и превращению её в Big Ball of Mud.

Тестирование

С тестированием изменяемых объектов особых проблем не припоминаю. Ну ток что в комплекте с изменяемыми объектами зачастую идёт только конструктор без параметров и пачка сеттеров - это неудобно, но можно полечить просто добавив конструктор.

А вот тестирование ввода-вывода - это да, беда. Тестировать ио больно потому что такие тесты: 1. могут потребовать запуска внешнего сервиса (СУБД, например) 2. как минимум на порядок, а то и пять медленнее тестов только в памяти 3. намного менее стабильны 4. вообще непонятно как писать для устройств отличных от диска и сетевой карты

Отсюда началась движуха про пирамиду тестов в которой львиная доля тестов должна быть юнит-тестами и про ``давайте замочим всю систему''.

Только оби этих практики ведут к тестам, которые ломаются при любом мало мальском рефакторинге. Это ведёт к страху рефакторинга. Это ведёт к загниванию кодовой базы и преваращению её в Big Ball of Mud (ссылка выше:) ).

И не понятно как это поможет ловить баги в системах ориентированных на ввод-вывод, где из логики ток тривиальная валидация отданая на откуп либе.

Но чёт я отвлёкся от темы, про тесты будет своя серия постов.

Производительность

Опять же это касается только эффектов ио, т.к. они существенно медленнее работы с памятью.

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

Или словить печально известную проблему N+1 и даже не заметить этого, пока количество таких проблем не положит систему намертво.

Или случайно через полиморфный вызов засунуть сетевой вызов внутрь транзакции БД. После того как уже захватили пачку локов.

Все эти проблемы опять же вызваны нелокальностью рассуждений, если в кодовой базе нет дисциплины в работе с эффектами, то они могут стрельнуть где угодно. А просматривать стэк каждого вызова - замучаешься. Да и если сегодня вызов чистый, без дисциплины нет гарантий, что завтра он не станет эффективным.

Конкурентное программирование

Если у вас есть эффекты, то их надо упорядочивать, а для этого надо идти в конкурентное программирование. А это очень сложно, поверьте мне на слово, если ещё сами не убедились в этом на своём опыте:)

Чистый же код можно спокойно параллелить как угодно и вообще не греть голову об этом.

Кэширование

Ну и опять же с эффектами появляется одна из двух самых сложных задач в программировании. После конкурентности, конечно :)


В итоге мы пришли к Дилемме Эффектов - без эффектов никак, а с ними ещё хуже. Как же быть? Для начала, надо присмотреться к эффектам поближе.

Эффекты бывают разные

Из списка проблем вызываемых эффектами видно, что есть два типа эффектов: 1. работа с глобальным изменяемыми состоянием (измененяемым переменными) 2. ввод/вывод

На самом деле, второй тип (ио) это частный случай первого типа (изменяемой памяти), т.к. любой ввод-вывод начинается с работы с изменяемыми блоками памяти и в конечном итоге заканчивается там же. Но между ними есть существенная разница - минимизировать ио без минимизации функциональности системы для конечного пользователя - нельзя, а вот минимизировать изменяемое состояние без потерь для конечного пользователя - можно.

В программах ориентированных на вычисления (компиляторы, например) количество изменяемого состояния можно вообще свести к нулю.

В программах ориентированных на хранение данных (90% бэков) в принципе можно свести изменяемое состояние к одной переменной - изменяемой ссылке на неизменяемую структуру данных- см. Redux и Datomic. И qbit - примажусь к известным и популярным:)

Это наблюдение подсказывает нам как разрешить Дилемму Эффектов

Разрешение Дилеммы Эффектов

Для разрешения Дилеммы Эффектов Эргономичный Подход Сводит К Минимуму Количество Эффектов В Программе За Счёт (эм, чёт я Тайтл-кейсом увлёкся:)) минимизации изменяемого состояния, а оставшиеся эффекты берёт под контроль за счёт дисциплины и ряда других техник, о которых я напишу позже.

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