Атом — минимальный кирпичик реактивного приложения

Здравствуйте, меня зовут Дмитрий Карловский и я… клиент-сайд разработчик. За плечами у меня 8 лет поддержки самых различных сайтов и веб-приложений: от никому не известных интернет-магазинов, до таких гигантов как Яндекс. И всё это время я не только фигачу в продакшн, но и точу топор, чтобы быть на самом острие технологий. А теперь, когда вы знаете, что я не просто хрен с горы, позвольте рассказать вам про один архитектурный приём, которым я пользуюсь последний год.

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

Осторожно: чтение может вызвать вывих мозга, приступ холивара, а также бессонные ночи рефакторинга.

От простого к сложному


Эта глава вкратце демонстрирует типичную эволюцию достаточно простого приложения, постепенно подводя читателя к концепции атома.

Давайте представим себе для начала такую простую задачу: нужно написать пользователю приветственное сообщение. Реализовать это весьма не сложно:

	this.$foo = {}
	
	$foo.message = 'Привет, пользователь!'
	
	$foo.sayHello = function(){
		document.body.innerHTML = $foo.message
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

Но было бы не плохо обращаться к пользователю по имени. Допустим, имя пользователя у нас сохранено в localStorage, тогда реализация будет чуть сложнее:

	this.$foo = {}
	
	$foo.userName = localStorage.userName
	
	$foo.message = 'Привет, ' + $foo.userName + '!'
	
	$foo.sayHello = function(){
		document.body.innerHTML = $foo.message
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

Но постойте, вычисление userName и message происходит при инициализации, но что если к моменту вызова sayHello его имя уже поменяется? Получается мы поприветствуем его по старому имени, что не очень хорошо. Поэтому давайте перепишем код так, чтобы вычисление сообщения производилось только тогда, когда его действительно нужно будет показать:

	this.$foo = {}
	
	$foo.userName = function( ){
		return localStorage.userName
	}
	
	$foo.message = function( ){
		return 'Привет, ' + $foo.userName() + '!'
	}
	
	$foo.sayHello = function(){
		document.body.innerHTML = $foo.message()
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

Заметьте, что нам пришлось поменять интерфейс полей message и userName — теперь в них хранятся не сами значения, а функции, которые их возвращают.

Тезис 1: Дабы не обрекать себя и других разработчиков на утомительные рефакторинги при смене интерфейса, старайтесь сразу использовать такой интерфейс, который позволит вам наиболее свободно менять внутреннюю реализацию.

Мы могли бы спрятать вызов функции используя Object.defineProperty:

	this.$foo = {}
	
	Object.defineProperty( $foo, "userName", {
		get: function( ){
			return localStorage.userName
		} 
	})
	
	Object.defineProperty( $foo, "message", {
		get: function( ){
			return 'Привет, ' + $foo.userName + '!'
		} 
	})
	
	$foo.sayHello = function(){
		document.body.innerHTML = $foo.message
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

Но я бы рекомендовал явный вызов функции по следующим причинам:

* IE8 поддерживает Object.defineProperty только для dom-узлов.
* Функции можно выстраивать в цепочки вида $foo.title( 'Hello!' ).userName( 'Anonymous' ).
* Функцию можно передать в качестве колбэка куда-нибудь: $foo.userName.bind( $foo ) — при этом передано будет свойство целиком (и геттер, и сеттер).
* Функция в своих полях может хранить различную дополнительную информацию: от глобального идентификатора до параметров валидации.
* Если обратиться к несуществующему свойству, то возникнет исключение, вместо молчаливого возвращения undefined.

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

	this.$foo = {}
	
	$foo.userName = function( ){
		return localStorage.userName
	}
	
	$foo.message = function( ){
		return 'Привет, ' + $foo.userName() + '!'
	}
	
	$foo._sayHello_listening = false
	$foo.sayHello = function(){
		if( !$foo._sayHello_listening ){
			window.addEventListener( 'storage', function( event ){
				if( event.key === 'userName' ) $foo.sayHello()
			}, false )
			this._sayHello_listening = true
		}
		document.body.innerHTML = $foo.message()
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

И тут мы совершили страшный грех — реализация метода sayHello, внезапно, знает о внутренней реализации свойства userName (знает откуда оно получает своё значение). Стоит заметить, что в примерах они находятся рядом лишь для наглядности. В реальном приложении такие методы будут находиться в разных объектах, код будет лежать в разных файлах, а поддерживаться он будет разными людьми. Поэтому этот код следует переписать так, чтобы одно свойство могло подписываться на изменения другого через его публичный интерфейс. Дабы не переусложнять код, воспользуемся реализацией pub/sub из jQuery:

	this.$foo = {}
	$foo.bus = $({})
	
	$foo._userName_listening = false
	$foo.userName = function( ){
		if( !this._userName_listening ){
			window.addEventListener( 'storage', function( event ){
				if( event.key !== 'userName' ) return
				$foo.bus.trigger( 'changed:$foo.userName' )
			}, false )
			this._userName_listening = true
		}
		
		return localStorage.userName
	}
	
	$foo._message_listening = false
	$foo.message = function( ){
		if( !this._message_listening ){
			$foo.bus.on( 'changed:$foo.userName', function( ){
				$foo.bus.trigger( 'changed:$foo.message' )
			} )
			this._message_listening = true
		}
		
		return 'Привет, ' + $foo.userName() + '!'
	}
	
	$foo._sayHello_listening = false
	$foo.sayHello = function(){
		if( !this._sayHello_listening ){
			$foo.bus.on( 'changed:$foo.message', function( ){
				$foo.sayHello()
			} )
			this._message_listening = true
		}
		
		document.body.innerHTML = $foo.message()
	}
	
	$foo.start = function(){
		$foo.sayHello()
	}

В данном случае общение между свойствами реализовано через единую шину $foo.bus, но это может быть и россыпь отдельных EventEmitter-ов. Принципиально будет та же самая схема: если одно свойство зависит от другого, то оно должно где-то подписаться на его изменения, а если само оно меняется, то нужно разослать уведомление о своём изменении. Кроме того, в этом коде вообще не предусмотрен вариант отписки, когда слежение за значением свойства уже не требуется. Давайте введём свойство showName в зависимости от состояния которого мы будем показывать или не показывать имя пользователя в приветственном сообщении. Особенность такой, достаточно типичной, постановки задачи заключается в том, что если showName='false', то текст сообщения не зависит от значения userName и поэтому на это свойство нам не стоит подписываться. Более того, если мы уже на него подписались, потому что ранее было showName='true', то нам нужно отписаться от userName, после получения showName='false'. А чтобы совсем жизнь раем не казалась, добавим ещё такое требование: значения получаемых из localStorage свойств должны кэшироваться, чтобы лишний раз его не трогать. Реализация по аналогии с предыдущим кодом получится уже слишком объёмной для этой статьи, поэтому воспользуемся несколько более компактным псевдокодом:

	property $foo.userName :
		subscribe to localStorage
		return string from localStorage
		
	property $foo.showName :
		subscribe to localStorage
		return boolean from localStorage
		
	property $foo.message :
		subscribe to $foo.showName
		switch
			test $foo.showName
			when true
				subscribe to $foo.userName
				return string from $foo.userName
			when false
				unsubscribe from $foo.userName
				return string
	
	property $foo.sayHello :
		subscribe to $foo.message
		put to dom string from $foo.message
	
	function start : call $foo.sayHello

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

Тезис 2: Вовремя отписывайтесь от невлияющих зависимостей иначе рано или поздно приложение начнёт тормозить.

Описанная выше архитектура называется Event-Driven. И это ещё наименее ужасный её вариант — в более распространённом случае подписка, отписка и несколько способов вычисления значения разбросаны по разным местам проекта. Event-Driven архитектура очень хрупкая, потому что приходится вручную следить за своевременными подписками и отписками, а человек — существо ленивое и не сильно внимательное. Поэтому лучшее решение — минимизировать влияние человеческого фактора, скрыв от программиста механизм распространения событий, позволив ему тем самым сконцентрироваться на описании того, как одни данные получаются из других.

Давайте упростим код, оставив лишь минимально необходимую информацию о зависимостях:

	property userName : return string from localStorage
		
	property showName : return boolean from localStorage
		
	function $foo.message :
		switch
			test $foo.showName
			when true return string from $foo.userName
			when false return string
	
	property $foo.sayHello : put to dom string from $foo.message
	
	function start : call $foo.sayHello

Зоркие читатели уже скорее всего заметили, что, после избавления от ручной подписки-отписки, описания свойств представляют из себя так называемые «чистые функции». И действительно, у нас получилась FRP (Функциональная Реактивная Парадигма). Давайте разберем каждый термин подробней:

Функциональная — каждая переменная описывается в виде функции, которая генерирует её значение на основе значений других переменных.
Реактивная — изменение одной переменной автоматически приводит к обновлению значений всех зависящих от неё переменных.
Парадигма — программисту требуется несколько повернуть мышление, чтобы понять и принять принципы построения приложения.

Как видно, все описанное выше крутится вокруг переменных и зависимостей между ними. Назовём такие frp-переменные «атомами» и сформулируем их основные свойства:

1. Атом хранит в себе ровно одно значение. Этим значением может быть как примитив, так и любой объект в том числе и объект исключения.
2. Атом хранит в себе функцию для вычисления значения на основе других атомов через произвольное число промежуточных функций. Обращения к другим атомам при её исполнении отслеживаются так, чтобы атом всегда имел актуальную информацию о том, какие другие атомы влияют на его состояние, а также о том, состояние каких атомов зависит от его.
3. При изменении значения атома зависимые от него должны обновиться каскадно.
4. Исключительные ситуации не должны нарушать консистентность состояния приложения.
5. Атом должен легко интегрироваться с императивным окружением.
6. Так как чуть ли не каждый слот памяти заворачивается в атом, то реализация атомов должна быть максимально быстрой и компактной.


Проблемы при реализации атомов



1. Поддержание зависимостей в актуальном состоянии

Если при вычислении значения одного атома потребовалось значение другого, то можно с уверенностью утверждать, что первое зависит от второго. Если же не потребовалось, то прямой зависимости нет, но возможна косвенная. Но отслеживать необходимо и достаточно лишь прямые зависимости.

Реализуется это весьма просто: в момент старта вычисления одного атома где-нибудь в глобальной переменной запоминается, что он — текущий, а в момент получения значения другого помимо собственно возвращения этого значения осуществляется их линковка друг с другом. То есть у каждого атома помимо слота для собственно значения должно быть два множества: ведущих атомов (masters) и ведомых (slaves).

С отлинковкой всё несколько сложнее: в момент старта нужно подменить множество ведущих атомов на пустое, а по завершении вычисления сравнить полученное множество с предыдущим и отлинковать тех, кто не вошли в новое множество.

Примерно так и работает автотрекинг зависимостей в KnockOutJS и MeteorJS.

Но как атомы узнают, когда надо заново запускать вычисление значения? Об этом далее.


2. Каскадное непротиворечивое обновление значений

Казалось бы, что может быть проще? Сразу после изменения значения пробегаемся по зависимым атомам и инициируем их обновление. Именно так и поступает KnockOutJS и именно поэтому он тормозит при массовых обновлениях. Если один атом (A) зависит от, например, двух других (B,C), то если мы последовательно изменим их значения, то значение атома A будет вычислено дважды. А теперь представьте, что зависит он не от двух, а от двух тысяч атомов и каждое вычисление занимает хотя бы 10 миллисекунд.

image

В то время как для KnockOutJS разработчики в узких местах расставляют throttl-инги и debounce-еры, разработчики MeteorJS подошли к проблеме более системно: сделали отложенный вызов пересчёта зависимых атомов вместо немедленного. Для описанного выше случая атом A пересчитает своё значение ровно один раз причём сделает это по окончании текущего обработчика события, то есть после всех внеснных нами изменений в атомы B, C и любые другие.

Но это на самом деле не полное решени проблемы — она всплывает вновь, когда глубина зависимостей атомов становится больше 2. Проиллюстрирую это простым примером: атом A зависит от атомов B и C, а C в свою очередь зависит от D. В случае, если мы последовательно изменим атомы B и D, то отложенно будут пересчитаны атомы A и C и, если атом C при этом изменит своё значение, то снова будет запущено отложенное вычисление значения A. Это уже как правило не так фатально для скорости, но если вычисление A — довольно длительная операция, то её удвоение может выстрелить в самом неожиданном месте приложения.

image

Поняв проблему легко и придумать решение: достаточно при линковке атомов запоминать максимальную глубину среди ведущих плюс один, а при итерировании по отложенным для обновления атомам в первую очередь обновлять атомы с меньшей глубиной. Такая нехитрая техника позволяет гарантировать, что к моменту пересчёта значения атома все атомы от которых он непосредственно зависит имеют уже актуальное значение.

image

3. Обработка исключительных ситуаций

Представим себе такую ситуацию: атомы B и C зависят от A. Атом B начал вычисление значения и обратился к A. A — тоже начал вычислять своё значение, но в этот момент возникла исключительная ситуация — это может быть ошибка в коде или же отсутствие данных — не важно. Главное, что атом A должен запомнить это исключение, но позволить ему всплыть дальше, чтобы и B смог его запомнить или обработать. Почему это так важно? Потому, что когда C начнёт вычисление значение и обратится к A, то происходящие события для него должны быть такими же как и для B: при обращении к A всплыло исключение, которое можно перехватить и обработать, а можно и ничего не делать и тогда исключение должно быть поймано библиотекой реализующей атомы и сохранено в вычисляемом атоме. Если бы атомы не запоминали исключения, то всякое обращение к ним вызывало бы запуск одного и того же кода неизбежно приводя к одному и тому же исключению. Это лишние расходы процессорных ресурсов, так что лучше их кэшировать как и обычные значения.

image

Ещё один, и даже более важный, момент заключается в том, что при каскадном обновлении атомов вычисление значений оных происходит в обратном направлении. Например, атом A зависит от B, а тот зависит от C, а тот вообще от D. При инициализации A начинает вычислять своё значение и обращается к B, тот к C, а тот к D. Но состояние актуализируется в обратном порядке: D, потом C, потом B, и наконец A:

image

Впоследтствии кто-то меняет значение атома D. Тот уведомляет атом C что его значение уже не актуально. Тогда атом C вычисляет своё значение и если оно не равно предыдущему, то уведомляет атом B, который поступая аналогично уведомляет A. Если в какой-то из этих моментов мы не перехватим исключение и как следствие не уведомим зависимые атомы, то у нас получится ситуация, когда приложение находится в противоречивом состоянии: половина приложения содержит новые данные, половина старые, но уверено, что новые, а третья половина вообще упала и никак не может подняться, ожидая изменения данных.

image

4. Циклические зависимости

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

Детектируется это просто: при старте вычисления атом запоминает, что он сейчас вычисляется, а когда к его значению кто-либо обращается он проверяет не находится ли он в состоянии вычисления и если находится — бросает исключение.

5. Асинхронность

Асинхронный код — это всегда проблема, потому что превращает логику в спагетти, за хитросплетениями которой сложно уследить и легко ошибиться. При разработке на javascript приходится постоянно балансировать между простым и понятным синхронным кодом и асинхронными вызовами. Основная проблема асинхронности в том, что она как монада просачивается через интерфейсы: вы не можете написать синхронную реализацию модуля А, а потом незаметно от использующего его модуля В подменить реализацию А на асинхронную. Чтобы произвести такое изменение вам придётся изменить и логику модуля B, и зависящего от него C и D и так далее. Асинхронность — это как вирус, который пробивается через все наши абстракции и выпячивает внутреннюю реализацию наружу.

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

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

б) Вернуть локально закэшированную версию. Этот вариант подходит для относительно редко меняющихся данных. Например, имя пользователя из начала этой статьи — ничего страшного, если между запусками приложения он изменит своё имя и поэтому он какое-то непродолжительное время будет лицезреть предыдущее имя, зато он сможет начать работать с приложением практически сразу. Разумеется такой подход не годится для критически важных данных, особенно в условиях плохого соединения, когда обновление может идти очень долго.

в) Честно признаться, что данных нет и вернуть специальное значение означающее отсутствие данных. Для javascript это будет значение undefined. Но в этом случае везде в зависимом коде должна быть правильная обработка этого значения — это достаточно большой объём однотипного кода в котором достаточно легко допустить ошибку, так что выбрав этот путь готовьтесь к то и дело появляющимся Null Pointer Exception.

г) После запуска асинхронной задачи бросить специальное исключение, которое, как было описано выше, каскадно распространится по всем зависимым атомам до тех атомов, где оно может быть обработано. Например, атом, отвечающий за показ списка пользователей может перехватить исключение и вместо того, чтобы молча упасть нарисовать пользователю сообщение «идёт загрузка» или «ошибка загрузки». То есть начиная с какого-то удалённого атома исключительная ситуация становится вполне себе штатной. Преимущество такого способа в том, что обрабатывать отсутствие данных можно только в сравнительно небольшом числе мест кода, а не на всём пути к этим местам. Но тут важно помнить, что при зависимости от нескольких атомов, вычисление остановится после первого же бросившего исключение, а остальные так и не узнают о том, что их данные тоже нужны, хотя все ведущие атомы могли бы запросить свои данные в одном едином запросе. Благо эти моменты легко обнаруживаются по излишнему числу запросов к серверу и сранительно не хитро исправляются, расстановкой try-catch в нужных местах.

6. Интеграция с императивным кодом

Как бы ни красиво выглядела FRP, но она не является серебряной пулей и не решает всех задач. Кроме того, вокруг полно императивных библиотек и нативных апи с которыми надо уметь дружить. Поэтому, прежде всего должна быть возможность выполнить произвольный код по факту изменения значения атома. И кроме того, должна быть возможность изменить его значение напрямую, а не через встроенную в него функцию, как было описано выше. Однако, лучше минимизировать использование этих возможностей, так как они требуют повышенного внимания и аккуратности реализации.

Условно можно разделить атомы на 3 основных типа по стратегии использования:

а) Источники состояния — атомы, не умеющие вычислять своё значение. Они меняют его только, если кто-то специально в них его поместит.
б) Промежуточные — атомы, с ленивым состоянием. Когда к ним обращаются, они вычисляют своё состояние на основе других атомов. Но когда у них не остаётся ведомых, то и они отписываются от ведущих. При этом они могут оставаться в памяти на случай, если вновь понадобятся, а могут и удаляться, чтобы не занимать ресурсов.
в) Выводящие состояние — атомы, которые императивно отражают изменение своего состояния во вне.

7. Потребление памяти

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

Вот, что хранит типичный атом:

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

Довольно большая плата за точность графа зависимостей. Есть несколько стратегий, по экономии памяти:

а) Снижать точность, храня в одном атоме несколько значений. В результате будет некоторое количество ложных уведомлений, когда поменялись соседние данные, но чтобы это выяснисть нужно запустить вычисление значения. Получится аналог $digest из AngularJS, но только в рамках одного атома и только тогда, когда в атоме реально что-то поменялось, а не только лишь «могло».

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

в) Снижать число атомов-источников. Вместо нескольких источников можно иметь один, как в пункте (а), но доступ к нему производить не напрямую, а через промежуточные атомы, которые получают из источника данные и проверяют только нужную им часть. Кажется мы таким образом делаем только хуже — меняем, например, 10 источников, на 1 источник + 10 промежуточных. Но промежуточные могут самоуничтожаться, когда данные не нужны, при этом не теряя данные находящиеся в источнике, позволяя быстро восстановить эти атомы при необходимости.

г) Минимизировать число связей. Бывают случаи, когда введение нескольких промежуточных атомов, которые вычисляют своё значение на основе нескольких ведущих, позволяет уменьшить общее число связей.

д) Функции, уточняющие поведение, помещать не в сами атомы, а в субклассы. Если у вас есть пачка атомов, ведущая себя одинаково, то оптимальнее создать для них субкласс, где уточнить поведение, а в инстансах хранить только различающиеся данные.

Эпилог


Подводя итог, давайте систематизируем интерфейсы атомов в виде следующей диаграмы:

image

A — некоторый атом. Внутренний круг — это его состояние. А внешний — граница его интерфейса.
S — ведомая часть приложения, тут могут быть не только атомы, но и всё, что так или иначе зависит от текущего атома.
M — ведущая часть, от которой зависит значение.

Стрелки означают поток данных. Жирные их концы символизируют инициатора взаимодействия. Красным помечены те интерфейсы, поведение которых можно уточнять с помощью пользовательских функций.

А теперь подробнее об интерфейсах:
get — запрос значения. Если значение не актуально, то происходит запуск pull для его актуализации.
pull — та самая функция для вычисления значения атома. Когда он решает обновить своё значение он вызывает именно эту функцию. В ней можно реализовать асинхронный запрос, который потом поместит значение в атом через push интерфейс.
push — установить новое значение атома, но оно будет сохранено не как есть, а будет сначала пропущено через merge интерфейс
merge — сливает новое и текущее значение. Тут может быть нормализация, dirty-checking, валидация.
notify — уведомление ведомых о об изменении.
fail — уведомление ведомых о об ошибке.
set — через это интерфейс ведомый атом может предложить ведущему новое значение и если новое значение после merge отличается от текущего, то вызывается put.
put — по умолчанию делает push, но его назвачение в том, чтобы предложить новое состояние ведущему атому.

На этом пока всё, статья и так получилась довольно длинной и в основном теоретической. В продолжении будет больше практики с использованием javascript библиотеки "$jin.atom". Вы узнаете как она устроена, какие использовались оптимизации, помимо тех, что описаны выше. И конечно же будут практические примеры.

В ожидании продолжения, предлагаю вам попробовать реализовать атомы самостоятельно. Потом будет интересно сравнить наши решения.
Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 36
  • +18
    Вот так легко и непринужденно function sayHello() { alert('Привет, ' + localStorage.name + '!'); } растянулся на 28 строк кода :)
    • +1
      Не, там же надо было обновлять текст надписи когда имя юзера меняется.
      • +1
        Да понятно, я же шучу — работа проделана автором действительно гигантская.
    • +3
      Спасибо большое за публикацию!
      Понравился ваш стиль повествования — тема развивается очень гармонично,
      читается ясно и просто, вопросы по ходу раскрываются.
      Пишите еще про frp, с удовольствием ознакомлюсь.
      • +1
        > 5. Асинхронность

        д) почему бы атому не вернуть deferred/promise, очевидный и родной для асинхронщины?
        • +2
          Потому что promise — это, по сути, тоже атом, но способный сменить состояние только 1 раз. Смысл возвращать один атом из другого?

          Если вернуть promise — то вызывающий код будет вынужден подписаться аж на два события — во-первых, событие change атома — а во-вторых, событие done обещания. Это точно будет удобно?
          • 0
            Достаточно будет только done обещания.

            Ну и, пожалуй, это будет удобнее, чем дефолтное значение/закэшированное/undefined/исключение, после котрых всё либо приходит в неконсистентность, либо нужно догадываться совершать дополнительные действия.
            • +1
              Если done обещания нам достаточно, то зачем нам вообще атомы? Использовать атомы бессмысленно, если не подписываться на change, прямо или косвенно (через автотрекинг).
              • +1
                Не нужно никаких действий. Вся технология рассчитана на то, что атомы могут меняться сами в зависимости от тех или иных неизвестных нам причин. Так какая, нафиг, разница: изменил пользователь своё имя сам или строка с его именем «Вася Пупкин» пришла, наконец-таки, из облака? Любая программа обрабатывающая первую проблему (а для решения её, напомним, и созданы атомы) автоматически способна решить вторую (обратное неверно).
            • +2
              1. Атом — обобщение над обещаниями, так что ничто не мешает ему поддерживать их интерфейс. Для интеграции с императивным кодом это действительно удобно.

              2. Атом абстрагирует от асинхронщины, позволяя в любой момент прервать вычисление не потеряв проделанной работы, а потом продолжить, когда нужные данные появятся, не начиная всё с начала.
            • 0
              *deleted*
              • +1
                Спасибо, интересно!

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

                И, что интересно, кода получается меньше. Прям намного меньше.
                • 0
                  Теперь я знаю, как называется тот подход, который я применяю уже пару лет)

                  На самом деле, это совершенно замечательный паттерн для работы с данными в сложносвязных системах. В своей практике использую его для построения слоя доступа к данным, т.е. как обертку для серверными API. Например, если в spa у нас есть возможность работать как незалогиненным, так и в рамках конкретного пользователя, то атомы это идеальный способ описать состояние логина системы. Аналогично, это очень хорошо работает для всех долгоживущих данных.

                  Но атомы не отменяют события и промисы. Если есть необходимость просто получить данные, то атомы избыточны и даже вредны.
                  • 0
                    По моему опыту «просто получить данные» — достаточно редкий случай. Обычно надо не просто получить, но и закешировать. А если закешировали, то и вовремя инвалидировать кэш, а потом снова грузить. Так что обещания использовать нет никакого смысла, если есть возможность использовать атомы.
                    • 0
                      Это очень сильно зависит от задачи, на самом деле. Грубо говоря, данные можно поделить на 2 категории: доменные, которые живут постоянно и описываются атомами, и контекстно-зависимые. Соотношение очень сильно зависит от типа системы. В корпоративном приложении или b2c системах, типа яндекс денег, практически не будет контекстных данных. В соц. сетях, наоборот, 90% времени это просмотр чужих страниц, котиков и т.п., которые существуют только в рамках текущего запроса пользователя. Их просто бессмысленно кэшировать и, более того, это прямой путь к неработоспособности на слабых клиентах. В любом случае, тут разумно использовать совершенно другие механизмы кэширования с меньшими издержками по памяти и производительности.
                      • 0
                        Я бы не разделял эти две категории. В любой соц сети есть навигация, чаты под каждым котиком, отображение лайков в реальном времени, куча разнообразных кнопочек: пожаловаться, удалить из ленты, восстановить, приглушить свет на всём сайте, кроме этой фоточки и тд и тп. И это всё состояния. А кэш — это лишь частный случай состояния, которое может очищаться, когда ничего зависимого от него не отображается на экране.
                        • 0
                          Мы с вами немного про разные вещи говорим.

                          Вы про UI, я про обертку над API. По сути, с вами согласен, если говорить именно про UI слой.
                          • 0
                            Ну так UI использует обёртку над API (модель), которая кэширует и буферизирует запросы и инвалидирует кэш, когда данные меняются. И чтобы всё это отражалось на UI обёртка тоже должна быть реактивной.
                  • 0
                    Поняв проблему легко и придумать решение: достаточно при линковке атомов запоминать максимальную глубину среди ведущих плюс один, а при итерировании по отложенным для обновления атомам в первую очередь обновлять атомы с меньшей глубиной.
                    Начав ради интереса делать свою реализацию под C#, я столкнулся с такой проблемой: при смене списка зависимостей в результате работы автотрекинга может измениться глубина. На уменьшение глубины можно забить, а вот увеличение глубины надо распространять всем мастерам вверх по графу. В итоге, мы возвращаемся к той же самой проблеме, с которой и начинали: глубина может обновляться много раз у одного и того же атома. Конечно, операция обновления глубины более дешевая, чем операция пересчета состояния атома (поскольку вторая может и запрос на сервер выполнять) — но все равно лишние квадраты в алгоритмах мне не нравятся.

                    К счастью, решение я нашел очень простое, не требующее существенной переделки архитектуры и даже упрощающее код: надо глубину вычислять не при линковке, а непосредственно при помещении атома в очередь.
                    • 0
                      Видимо я плохо осветил этот момент. Глубина может измениться и как правило это делает, но это не важно, пока атом не помещается в очередь на обновление. А он туда не помещается, поскольку только что актуализировал своё состояние. И все атомы меньшей глубины тоже его актуализировали. Так что следующий атом в очереди может смело вычислять своё значение и быть уверенным, что атомы с меньшей глубиной не изменятся, если он конечно сам не полезет их зачем-то менять.
                      • 0
                        То есть вы тоже вычисляете глубину в момент помещения атома в очередь?
                        • 0
                          Нет, в момент линковки ведомый спрашивает у ведущего его глубину и сравнивает со своей.Если своя глубина не больше глубины ведущего, то устанавливается на 1 больше, чем у него. А при помещении в очередь атом ставится в очередь соответствующей своей глубине на момент помещения.
                          • 0
                            Что-то я запутался. Жду тогда библиотеку, чтобы протестировать…
                    • +1
                      Если кому-то интересна реализация атомов для C# — можете глянуть мою. К сожалению, мне лень делать тесты, так что ошибки там наверняка есть. Но я обещаю быстро исправить все найденные.
                      • +3
                        Вспомнился доклад про кложуру.
                      • 0
                        Спасибо!
                        • 0
                          > Для описанного выше случая атом A пересчитает своё значение ровно один раз причём сделает это по окончании текущего обработчика события, то есть после всех внеснных нами изменений в атомы B, C и любые другие.

                          Получается, что в текущем обработчике мы не сможем использовать атом А, т. к. он будет иметь значение, не согласованное с В и С. Или всё-таки нет?
                          • 0
                            Если нам надо использовать значение ровно одного атома — то оно всегда будет согласовано с самим собой. Если же нам надо использовать значения нескольких атомов — то надо делать новый атом, использующий те значения — и возвращающий ровно одно. Этот атом будет обновляться только в те моменты времени, когда его входы будут согласованы — ну а вызывающий код вернулся к случаю номер 1.

                            Тут возможна вторая проблема — в случае автотрекинга зависимостей, атом может обратиться к другому атому, к которому он не обращался раньше — и состояние которого еще не согласовано с остальными входами. В таком случае надо не отдавать старое значение — а дождаться пока запрошенный атом довычислится. Посмотрим, удалось ли автору справиться с этим случаем. Мне вот, к примеру, в своей библиотеке этого сделать не удалось…
                            • 0
                              Он будет помечен как «не актуальный», а значит при обращении актуализирует своё значение на основе B и C. Тут есть более сложный случай: D зависит от А, А только что запланировал к обновиться, а значит D ещё не знает, что ему тоже потребуется обновление (а может и не потребуется, А-то ещё не вычислился). Поэтому, если мы тут же обратимся к D, то получим старое значение, которое вскоре возможно(!) изменится.
                              • 0
                                Если у нас есть статус «не актуальный», то логично его продвигать вверх по иерархии до конца. То есть, если у нас A зависит от B и C, а D зависит от A, то при изменении B не актуальными должны становиться и A и D.

                                А что, действительно реальна ситуация, когда обновляются 2000 атомов, от которых зависит 2001-й?
                                • 0
                                  Тогда у нас получится что на каждый чих у нас будет обновляться всё приложение, что гораздо хуже.

                                  Конечно, выводим список на 2000 элементов с фильтрацией по какому-либо параметру — получаем одну зависимость от самого списка и 2000 зависимостей от соответствующего параметра каждого из элементов. При изменении параметра у любого из элементов список перефильтруется. Это может показаться расточительным, ведь можно просто проверить подходит изменившийся элемент под фильтр и либо добавить, либо удалить. Но когда к фильтрации добавляется сортировка, группировка, да ещё и иерархия с разворачиванием ветвей и дублированием узлов, то задача «поместить в нужное место списка» становится неоправданно сложной.
                              • 0
                                > Так как чуть ли не каждый слот памяти заворачивается в атом, то реализация атомов должна быть максимально быстрой и компактной.

                                Сразу напомнило Datomic — это очень приятная база данных, похожая на RDF, OrientDB или Node4j — только с ещё более простыми и фундаментальными концепциями
                                • 0
                                  Ух ты, я уж было собрался запилить свою иммутабельную графовую субд, а её уже оказывается пилят. Спасибо :-)
                                  • 0
                                    Хотя… это всего лишь фронтенд к другим субд со всеми их болезнями.

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.