Lex Kravetski (lex_kravetski) wrote,
Lex Kravetski
lex_kravetski

Categories:

Про механизм подписки

Как известно, мы можем вызвать метод объекта напрямую. Если, скажем, написать что-то типа myObject.f(10), то у объекта myObject действительно вызовется функция f с параметром 10. Всё казалось бы отлично, чему примером миллиарды строк кода, где вызовы исключительно прямые. Однако рассмотрим следующую ситуацию: объект А умеет чего-то там считать. Считает он это, мало что долго, так ещё и непредсказуемо долго. При этом где-то рядом объект B, желающий время от времени получать промежуточные результаты. Как быть? Ну, например, объект A в ключевых точках мог бы вызывать некоторую функцию объекта B и передавать в неё всё это самое, насчитанное. То есть, где-то внутри A должно быть написано b.getAndFuckOut(myMegaData). Что отсюда следует? Отсюда следует, что A, во-первых, должен знать про существование некоторого класса B, а во-вторых ещё и иметь ссылку на его экземпляр.

Студент, только что сошедший со стадии «Hello World», конечно, не видит тут проблемы: «ну так передадим ссылку в конструктор», — думает он, — «и всего делов». Однако делов тут на самом деле полно. Штука в том, что хотеть от A какие-то промежуточные сведения в общем случае может не только объект класса B, но и сто тысяч других объектов. Про которые разработчик класса A даже и не подозревает. Поэтому ни импортировать их описания, ни завести на на все эти объекты ссылки в описании класса A решительно невозможно. Так разработчик объекта «Кнопка диалога» должен был бы в момент разработки каким-то образом узнать про все диалоги, в которых эта кнопка когда-либо будет использоваться.

Отсюда вытекает необходимость сменить подход и отказаться от прямого вызова методов классов, заменив его «обратным вызовом». В этом подходе не класс A c самого начала знает про класс B и дёргает у него методы, а наоборот, исключительно класс B знает про класс A и говорит тому: «ты это, если что случится — мне свистни». Иными словами, класс B передаёт классу A ссылку на метод, который класс A должен вызвать, если вдруг наступит какое-то событие.

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

Перво-наперво программистов тормознули антиконцептуальной проблемой — отсутствием возможности легко и непринуждённо передавать методы куда захочется. То есть, уже в С были указатели на функции, в С++ появились и указатели на методы тоже (само собой, одновременно с появлением самих методов), но ситуация осложнилась, во-первых, совершенно уродским синтаксисом объявления указателей, во-вторых, не менее идиотским синтаксисом их использования, а в-третьих — необходимостью передавать не только указатель на метод, но и сам объект, в котором этот метод предполагается вызывать. А такой комплект — совсем даже не то, о чём говорили большевики. В Яве, в свою очередь, нет концепции ссылок на функции. Оное можно сымитировать через рефлекшены, но оно, обратно, выглядит по-уродски, как в момент написания, так и в момент использования. Другие же языки временами располагали механизмом ссылок на функции, однако в них, напротив, для создания привычных нам объектов (в рамках которых как раз и имеет смысл механизм подписки) надо было прыгать с бубном. В общем, халява слегонца отменилась и человечество, поигравшись немного с хитровывернутыми методами эмуляции ссылок, принялось искать другие пути реализации сей, безусловно правильной идеи.

Результатом поисков стали два довольно распространённых подхода. Один из них, наиболее наверно распространённый, выглядит примерно вот так:

public class SomeNotifier {

	public static class PointEvent {
		int x;

		int y;

		public PointEvent(int x, int y) {
			super();
			this.x = x;
			this.y = y;
		}
	}

	public static interface IPointEventListener {
		void onPointEvent(PointEvent event);
	}

	List<IPointEventListener> listenersPointEvent = new LinkedList<IPointEventListener>();

	public void addPointEventListener(IPointEventListener listener) {
		listenersPointEvent.add(listener);
	}

	public void removePointEventListener(IPointEventListener listener) {
		listenersPointEvent.remove(listener);
	}

	protected void notifyPointEvent(PointEvent event) {
		for (IPointEventListener listener : listenersPointEvent) {
			listener.onPointEvent(event);
		}
	}
}


Таким образом, задача вроде как решена, и результатом вроде как можно и воспользоваться.

class MyNotifier extends SomeNotifier {
	public void f() {
		notifyPointEvent(new PointEvent(10, 10));
	}
}

class MyListener implements SomeNotifier.IPointEventListener {
	public void subscribe(MyNotifier notifier) {
		notifier.addPointEventListener(this);
	}

	public void onPointEvent(SomeNotifier.PointEvent event) {
		System.out.println("Это была точка!!!");
	}
}


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

	public static class AllIsLostEvent {
		Date when;

		public AllIsLostEvent(Date when) {
			this.when = when;
		}
	}

	public static interface IAllIsLostEventListener {
		void onAllIsLostEvent(AllIsLostEvent event);
	}

	List<IAllIsLostEventListener> listenersAllIsLostEvent = new LinkedList<IAllIsLostEventListener>();

	public void addAllIsLostEventListener(IAllIsLostEventListener listener) {
		listenersAllIsLostEvent.add(listener);
	}

	public void removeAllIsLostEventListener(IAllIsLostEventListener listener) {
		listenersAllIsLostEvent.remove(listener);
	}

	protected void notifyAllIsLostEvent(AllIsLostEvent event) {
		for (IAllIsLostEventListener listener : listenersAllIsLostEvent) {
			listener.onAllIsLostEvent(event);
		}
	}


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

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

К такому варианту рано или поздно приходит где-то так каждый третий программист, имевший дело с механизмом подписки. Этот вариант (я не буду приводить его код, в силу повышенной его сложности) — гораздо более компактный в использовании, однако и он тоже страдает рядом недостатков.

Во-первых, в Java нет множественного наследования, поэтому в общем случае унаследоваться от «Обобщённого Нотификатора» нельзя — следует его, вместо этого, хранить в виде поля класса, а наружу выдавать либо как public final поле, либо же — через делегирование ему соответствующих методов класса. Не фонтан, ну да ладно. При множественном же наследовании, как в С++, придётся тщательно отслеживать виртуальность наследования — чтобы не напороться потом на дублирование Нотификаторов из разных родительских веток. Тоже не фонтан, но тоже можно пережить.

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

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

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

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


В условиях языка Java пункт второй, увы, противоречит пункту пятому — мы либо вынуждены сохранить подписку через интерфейсы, а не через функции, либо же реализовывать подписку через рефлекшены, чем добрую половину возможных ошибок перенести в рантайм, вместо времени компиляции. Однако за исключением вот этого вот противоречия, механизм, удовлетворяющий означенным условиям, может быть реализован и на Яве тоже. На языке же Scala возможна и реализация, полностью удовлетворяющая условиям. Кроме того, Scala позволяет значительно упростить код реализации.

trait Notifier {

	protected class EventNotifier[E] {

		private val listeners = new ListBuffer[(E) => Unit]

		def subscribe(method: (E) => Unit) {
			listeners + method
		}

		def send(event: E) {
			listeners.foreach(method => method(event))
		}
	}

	protected def event[E] = new EventNotifier[E]
}


Поскольку Notifier — trait, он может присутствовать и в случае со множественным наследованием (как в Java это позволяется при имплементации интерфейсов): class MyClass extends MyParent with Notifier

Вот и весь, собственно, код — в первом приближении. Использовать его предполагается примерно так:

case class PointEvent(x: Int, y: Int)

class MyClassWithNotifier extends Notifier {

	val onPointEvent = event[PointEvent]

	def someFunction {
		// Тут что-то считается и вдруг оба-на - событие
		onPointEvent send PointEvent(10, 20)
	}
}

class SomeListener {

	def reactPointEvent(pointEvent: PointEvent) {
		println("Это же точка!!!")
	}

	def subscribe(notifier: MyClassWithNotifier) {
		notifier.onPointEvent subscribe reactPointEvent
	}
}


Весьма, надо отметить, удобно. При этом кратко и легкочитаемо. Но всё ещё несовершенно.

Сейчас, как легко видеть, рассылать налево и направо нотификации о событиях может любой, кто долезет до объекта onPointEvent — например, тот, кто на это событие подписывается. Возможно, рассылка событий откуда-то снаружи в определённых случаях и имеет смысл, но вообще-то такое надо сурово пресекать. Рассылать события должен только наследник Notifier, а не кто угодно. В Java мы бы просто объявили метод send как protected. Увы, в Скале протектед методы внутренних классов не видны внешним — весьма странное, надо отметить, решение. И даже если мы объявим метод как protected[Notifier], всё равно он будет виден только самому Notifier-у, но не его наследникам. По счастью, есть один мощный чит, позволяющий зарамсить проблему.

trait Notifier {

	protected class EventNotifier[E] {

		private val listeners = new ListBuffer[(E) => Unit]

		def subscribe(method: (E) => Unit) {
			listeners + method
		}

		protected[Notifier] def send(event: E) {
			listeners.foreach(method => method(event))
		}
	}

	protected implicit def sendWrapper[E](en: EventNotifier[E]) = {en.send _}

	protected def event[E] = new EventNotifier[E]
}


Тут во имя вящей хитрости, внутри Notifier мы завели implicit-преобразование любого EventNotyfier-а сразу в функцию send. Теперь, повстречав вызов несуществующего (точнее, в данном случае просто невидимого) метода apply у EventNotifier, компилятор попытается обернуть его в метод-преобразование и если оно сработает — он так и оставит. Метод apply вызывается в том случае, когда мы сразу после имени переменной пишем скобки. То есть, цепочка трансформаций будет такой:

пишем в нашем коде: onPointEvent(PointEvent(10, 20))
первая попытка компиляции: onPointEvent.apply(PointEvent(10, 20)) — ой, нет такого метода.
вторая попытка компиляции: sendWrapper(onPointEvent).apply(PointEvent(10, 20)) — сработало.

Сработало потому что sendWrapper вернул ссылку на функцию, у которой метод apply с самого начала есть — именно так функция по ссылке и вызывается.

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

class MyClassWithNotifier extends Notifier {

	val onPointEvent = event[PointEvent]

	def someFunction {
		// Тут что-то считается и вдруг оба-на - событие
		onPointEvent(PointEvent(10, 20))
	}
}


Поскольку implicit-преобразование — протектед, подписчикам оно будет не видно́, зато будет видно́ наследникам. Что, собственно, и требовалось. С помощью похожего трюка, кстати, можно «вывести» из внутреннего класса в наружный прямо целый блок протектед-методов — одной командой. Но об этом как-нибудь после.

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

Однако настораживает отсутствие метода unsubscribe — куда он, спрашивается, делся? Отвечаю: а его у нас сделать вообще вот так сразу не получится — чтобы удалить прежнюю функцию из контейнера, надо где-то запомнить ссылку именно что на добавляемую в контейнер. SomeListener.reactPointEvent, написанные в разных местах кода, дадут два нетождественных объекта и по второму крайне затруднительно будет разыскать первый. Либо же при подписке надо получать некоторый идентификатор, который потом будет использован для отписки. Можно, в принципе, оба варианта реализовать.

Но перед этим задумаемся, а зачем, собственно, нам вообще отписка? Если для временного отказа от получения событий — мы можем прямо в вызываемом методе такое предусмотреть. Обычно же объект «слушает» другой объект вплоть до своей кончины. Или же до кончины того объекта, к которому он подписался.

Вот эта самая кончина — как раз и есть то, что побуждает реализовывать отписку: если не отписаться, то ссылка на объект останется от Нотификатора навсегда. И, следовательно, сборщик мусора никогда не удалит подписчика, даже если по логике программы он должен быть уже удалён. Поэтому зачастую в подписчике реализуют метод а-ля «явно вызываемый деструктор», отписывающий данный объект от всех подписок. Но сиё, мало что требует написания в каждом подписчике десятков лишних строк кода, так ещё и подобно пороховой бочке в плане утечек памяти: проглядел всего-то одну отписку и гудбай — получите, как говорится, меморилик. Вдобавок чтобы отписаться нужно ссылки на Нотификаторов где-то хранить. А это — отличный шанс предотвратить ещё и их удаление. Объекты с круговыми ссылками друг на друга, конечно, тоже удаляются, однако в этой системе хоть одна ссылку снаружи может испортить всю халяву.

Отсюда напрашивается вывод: хранить следует слабые ссылки. Те, которые не мешают сборщику мусора удалять объекты. Перед вызовом же функции у подписчика проверять, не дохлый ли по ссылке лежит объект. Если дохлый, то и ссылку вполне можно из списка подписчиков убрать — чем не вариант отписки? Практичный такой вариант.

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

Что делать, придётся нам при регистрации передавать некий guard в функцию подписки и считать, что функция жива до тех пор, пока жив этот самый guard — это его мы обернём в слабую ссылку. Это, по-видимому, наименьшее из зол — для отписки от всех объектов нам надо лишь занулить (или вывести из зоны видимости) guard, с которым мы на всё это дело подписывались. Лишнее действие, конечно, да и при подписке тоже добавляется лишний параметр, но иного выхода нет. Сделать проще нам уже язык не позволяет. Мы можем, впрочем, утешить себя тем, что взамен мы получили некоторую гибкость — позволяющую нам поделить подписки по guard-ам и отписываться от некоторых групп прямо скопом, сохраняя подписку на другие группы. Вдобавок к гибкости, как это обычно бывает, получили и бочку с порохом: если мы guard заведём где-то не там, то он может удалиться раньше времени и тем самым незаметно для нас отписать наш объект. Однако при соблюдении минимальной осторожности такое нам вроде бы не грозит.

В дополнение можно guard-у сделать метод kill, убивающий guard-a не только при удалении сборщиком мусора, но и прямо сразу — в обоих случаях guard вызовет отпишет соответствующие ему объекты от Нотификаторов.

Вот получившаяся в результате реализация:

object Notifier {
	def guard = new Guard

	final class Guard {
		private val notifiers = new HashSet[WeakReference[EventNotifier[_]]]

		var alive = true

		def kill {
			alive = false
			notifiers.foreach {
				_.get match {
					case Some(notifier) => notifier.removeDead
					case None =>
				}
			}
		}

		private[Notifier] def register(notifier: WeakReference[EventNotifier[_]]) {
			notifiers + notifier
		}

		override def finalize = kill
	}

	protected class EventNotifier[E] {
		type Func = (E) => Unit
		type ListenersBuffer = ListBuffer[Tuple2[WeakReference[Notifier.Guard], Func]]

		private var listeners = new ListenersBuffer

		private val weakReference = new WeakReference(this)

		def subscribe(guard: Notifier.Guard, method: Func) {
			guard register weakReference
			listeners += ((new WeakReference(guard), method))
		}

		protected[Notifier] def send(event: E) {
			for ((guard, method) <- listeners) {
				if (alive(guard)) method(event)
			}
		}

		private[Notifier] def removeDead {
			val newListeners = new ListenersBuffer
			newListeners ++= listeners.projection.filter {case (guard, method) => alive(guard)}
			listeners = newListeners
		}

		private def alive(guardRef: WeakReference[Notifier.Guard]) = {
			guardRef.get match {
				case Some(g) => g.alive
				case None => false
			}
		}
	}
}

trait Notifier {
	protected implicit def sendWrapper[E](en: Notifier.EventNotifier[E]) = en.send _

	protected def event[E] = new Notifier.EventNotifier[E]
}


И пример использования:

case class PointEvent(x: Int, y: Int)

class MyClassWithNotifier extends Notifier {
	val onPointEvent = event[PointEvent]

	def someFunction {
		onPointEvent(PointEvent(10, 20))
	}
}

class SomeListener {
	private var guard = Notifier.guard

	def reactPointEvent(pointEvent: PointEvent) {
		println("Point!!!")
	}

	def subscribe(notifier: MyClassWithNotifier) {
		notifier.onPointEvent subscribe (guard, reactPointEvent)
	}

	def die {
		guard.kill
	}
}


Tuple2 — это тот класс, который имеет пара чисел, написанная в скобках. То есть, (1, 2) имеет класс Tuple2[Int, Int]. В большинстве случаев нам не надо его в явном виде указывать, но вот при параметризации некоторого типа — да, приходится.

Для сокращения кода в реализации определены типы Func и ListenersBuffer — удобная штука, имеющаяся в С++, но отсутствующая в Java. В Scala, как мы видим, удобную штуку возвернули.


Если какие-то моменты непонятны, можно спрашивать.
Tags: faq, программирование
Subscribe
  • Post a new comment

    Error

    Anonymous comments are disabled in this journal

    default userpic

    Your reply will be screened

    Your IP address will be recorded 

  • 31 comments