Uploaded image for project: 'JDK'
  1. JDK
  2. JDK-8311123

Subscription based listeners

XMLWordPrintable

    • Icon: CSR CSR
    • Resolution: Approved
    • Icon: P4 P4
    • jfx21, jfx22
    • javafx
    • None
    • source
    • minimal
    • Introduces new default methods `subscribe` on the `Observable` and `ObservableValue` interfaces.
    • Java API
    • JDK

      Summary

      Introduce a new API for attaching listeners to Observable and ObservableValue which return a Subscription once attached. The Subscription then can be to used to unsubscribe such a listener.

      This is a more convenient API when used in combination with lambda's and method references as it doesn't require tracking the original listener to cancel it, as you would need to with the addListener removeListener API.

      Problem

      Observable and ObservableValue offer addListener(ChangeListener) and addListener(InvalidationListener) for listening to changes or invalidations. When using these methods, great care must be taken to call their removeListener counterpart with the exact same value as was used to register the listener. Using an incorrect value will not result in an exception or warning, but is silently ignored.

      With the introduction of Lambda's and Method references, it is much more common to write listeners in a very compact form:

      property.addListener((obs, old, current) -> { ... });
      property.addListener(this::method);

      Unregistering listeners registered in the above way is not possible as the reference used cannot be recreated (Java does not guarantee they are the same reference if created again), hence this code is incorrect:

      property.removeListener((obs, old, current) -> { ... });
      property.removeListener(this::method);

      This is made worse when unregistration of listeners that didn't exist does not result in an exception or warning.

      Therefore, when wanting to unregister these listeners, the created listeners must be stored in a variable:

      ChangeListener<String> cl = (obs, old, current) -> { ... };
      InvalidationListener il = this::method;

      All in all, this makes listener management quite verbose, as well as error prone when removing them.

      Solution

      Listener management can be made less error prone by not requiring the original listener reference in order to unregister it. Instead, registering a listener results in a Subscription which can be used to undo the registration. As the Subscription embeds a reference to the original listener, it is always the correct one, making use of Lambda's and Method references easier and safer:

      // Add an invalidation listener:
      Subscription s = property.subscribe(() -> System.out.println("invalidated!"));
      
      // Unsubscribe the listener:
      s.unsubscribe();

      The subscription here represents the action or actions that must be taken to undo a previous action or group of actions. Multiple actions can be combined into a single subscription, allowing all these actions to be triggered with a single call to unsubscribe.

      A Subscription class already exists as part of the private API, and this proposal would make it public. In its simplest form its API looks like this:

      @FunctionalInterface
      interface Subscription {
          void unsubcribe();
      }

      The unsubscribe method should be idempotent, and subsequent calls should not result in any actions taken.

      Methods wishing to return a Subscription to undo a registration can often simply return a Lambda of the form:

      return () -> property.removeListener(listener);

      If the registration involves other resources or tracking, the returned Subscription can clean those up as well as long as care is taken that these are idempotent.

      To allow aggregation of multiple subscriptions, two additional methods are provided and implemented by the interface:

      /**
       * Returns a subscription which combines all given subscriptions.
       */
      static Subscription combine(Subscription... subscriptions) { ... }
      
      /**
       * Returns a new subscription which combines this subscription
       * and another subscription.
       */
      default Subscription and(Subscription other) { ... }

      Usage examples:

      Subscription s1 = subscriptionSource.subscribe( ... );
      Subscription s2 = subscriptionSource2.subscribe( ... );
      
      Subscription all = s1.and(s2);
      
      Subcription s = Subscription.combine(
          subscriptionSource.subscribe( ... ),
          subscriptionSource2.subscribe( ... )
      );

      Specification

      diff --git a/modules/javafx.base/src/main/java/javafx/beans/Observable.java b/modules/javafx.base/src/main/java/javafx/beans/Observable.java
      index 4404557ad2..6fa7df1db7 100644
      --- a/modules/javafx.base/src/main/java/javafx/beans/Observable.java
      +++ b/modules/javafx.base/src/main/java/javafx/beans/Observable.java
      @@ -93,4 +94,23 @@ public interface Observable {
            */
           void removeListener(InvalidationListener listener);
      
      +    /**
      +     * Creates a {@link Subscription} on this {@code Observable} which calls
      +     * {@code invalidationSubscriber} whenever it becomes invalid. If the same
      +     * subscriber is subscribed more than once, then it will be notified more
      +     * than once. That is, no check is made to ensure uniqueness.
      +     * <p>
      +     * Note that the same subscriber instance may be safely subscribed for
      +     * different {@code Observables}.
      +     * <p>
      +     * Also note that when subscribing on an {@code Observable} with a longer
      +     * lifecycle than the subscriber, the subscriber must be unsubscribed
      +     * when no longer needed as the subscription will otherwise keep the subscriber
      +     * from being garbage collected.
      +     *
      +     * @param invalidationSubscriber a {@code Runnable} to call whenever this
      +     *     value becomes invalid, cannot be {@code null}
      +     * @return a {@code Subscription} which can be used to cancel this
      +     *     subscription, never {@code null}
      +     * @throws NullPointerException if the subscriber is {@code null}
      +     * @see #addListener(InvalidationListener)
      +     * @since 21
      +     */
      +    default Subscription subscribe(Runnable invalidationSubscriber) {

      And:

      diff --git a/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java b/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
      index ddaf4acf1e..a8df61aba2 100644
      --- a/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
      +++ b/modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
      @@ -297,4 +301,50 @@ public interface ObservableValue<T> extends Observable {
           default ObservableValue<T> when(ObservableValue<Boolean> condition) {
               return new ConditionalBinding<>(this, condition);
           }
      +
      +    /**
      +     * Creates a {@link Subscription} on this {@code Observable} which calls the given
      +     * {@code changeSubscriber} with the old and new value whenever its value changes.
      +     * <p>
      +     * The parameters supplied to the {@link BiConsumer} are the old and new value
      +     * respectively.
      +     * <p>
      +     * Note that the same subscriber instance may be safely subscribed for
      +     * different {@code Observables}.
      +     * <p>
      +     * Also note that when subscribing on an {@code Observable} with a longer
      +     * lifecycle than the subscriber, the subscriber must be unsubscribed
      +     * when no longer needed as the subscription will otherwise keep the subscriber
      +     * from being garbage collected. Considering creating a derived {@code ObservableValue}
      +     * using {@link #when(ObservableValue)} and subscribing on this derived observable value
      +     * to automatically decouple the lifecycle of the subscriber from this
      +     * {@code ObservableValue} when some condition holds.
      +     *
      +     * @param changeSubscriber a {@code BiConsumer} to supply with the old and new values
      +     *     of this {@code ObservableValue}, cannot be {@code null}
      +     * @return a {@code Subscription} which can be used to cancel this
      +     *     subscription, never {@code null}
      +     * @throws NullPointerException if the subscriber is {@code null}
      +     * @see #addListener(ChangeListener)
      +     * @since 21
      +     */
      +    default Subscription subscribe(BiConsumer<? super T, ? super T> changeSubscriber) {
      +      ...
      +    }
      +
      +    /**
      +     * Creates a {@link Subscription} on this value which immediately provides
      +     * the current value to the given {@code valueSubscriber}, followed by any
      +     * subsequent values whenever its value changes.
      +     * <p>
      +     * Note that the same subscriber instance may be safely subscribed for
      +     * different {@code Observables}.
      +     * <p>
      +     * Also note that when subscribing on an {@code Observable} with a longer
      +     * lifecycle than the subscriber, the subscriber must be unsubscribed
      +     * when no longer needed as the subscription will otherwise keep the subscriber
      +     * from being garbage collected. Considering creating a derived {@code ObservableValue}
      +     * using {@link #when(ObservableValue)} and subscribing on this derived observable value
      +     * to automatically decouple the lifecycle of the subscriber from this
      +     * {@code ObservableValue} when some condition holds.
      +     *
      +     * @param valueSubscriber a {@link Consumer} to supply with the values of this
      +     *     {@code ObservableValue}, cannot be {@code null}
      +     * @return a {@code Subscription} which can be used to cancel this
      +     *     subscription, never {@code null}
      +     * @throws NullPointerException if the subscriber is {@code null}
      +     * @since 21
      +     */
      +    default Subscription subscribe(Consumer<? super T> valueSubscriber) {

      And the new Subscription interface:

       diff --git a/modules/javafx.base/src/main/java/javafx/beans/Subscription.java b/modules/javafx.base/src/main/java/javafx/beans/Subscription.java
       new file mode 100644
       index 0000000000..c0b81fcfec
       --- /dev/null
       +++ b/modules/javafx.base/src/main/java/javafx/beans/Subscription.java
       +/**
       + * A subscription encapsulates how to cancel it without having
       + * to keep track of how it was created.
       + *
       + * @since 21
       + */
       +@FunctionalInterface
       +public interface Subscription {
       +
       +    /**
       +     * An empty subscription. Does nothing when cancelled.
       +     */
       +    static final Subscription EMPTY = () -> {};
       +
       +    /**
       +     * Returns a {@code Subscription} which combines all of the given
       +     * subscriptions.
       +     *
       +     * @param subscriptions an array of subscriptions to combine, cannot be {@code null} or contain {@code null}
       +     * @return a {@code Subscription}, never {@code null}
       +     * @throws NullPointerException when {@code subscriptions} is {@code null} or contains {@code null}
       +     */
       +    static Subscription combine(Subscription... subscriptions) {
       +        ...
       +    }
       +
       +    /**
       +     * Cancels this subscription, or does nothing if already cancelled.<p>
       +     *
       +     * Implementors must ensure the implementation is idempotent.
       +     */
       +    void unsubscribe();
       +
       +    /**
       +     * Combines this {@link Subscription} with the given {@code Subscription}
       +     * and returns a new {@code Subscription} which will cancel both when
       +     * cancelled.
       +     *
       +     * <p>This is equivalent to {@code Subscription.combine(this, other)}.
       +     *
       +     * @param other another {@code Subscription}, cannot be {@code null}
       +     * @return a combined {@code Subscription} which will cancel both when
       +     *     cancelled, never {@code null}
       +     * @throws NullPointerException when {@code other} is {@code null}
       +     */
       +    default Subscription and(Subscription other) {
       +        ...
       +    }
       +}

            jhendrikx John Hendrikx
            jhendrikx John Hendrikx
            Kevin Rushforth
            Votes:
            0 Vote for this issue
            Watchers:
            3 Start watching this issue

              Created:
              Updated:
              Resolved: