From 518269783130cf504122ddbd16c6595a76c66a69 Mon Sep 17 00:00:00 2001 From: John Hendrikx Date: Sat, 20 Mar 2021 02:27:48 +0100 Subject: [PATCH] PoC: Fluent bindings for ObservableValue --- .../javafx/beans/binding/ObjectBinding.java | 27 +++- .../java/javafx/beans/value/Bindings.java | 35 +++++ .../beans/value/ConditionalBinding.java | 58 +++++++ .../javafx/beans/value/FlatMapBinding.java | 43 ++++++ .../javafx/beans/value/LazyObjectBinding.java | 84 +++++++++++ .../javafx/beans/value/ObservableValue.java | 141 +++++++++++++++++- .../java/javafx/beans/value/Subscription.java | 43 ++++++ 7 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 modules/javafx.base/src/main/java/javafx/beans/value/Bindings.java create mode 100644 modules/javafx.base/src/main/java/javafx/beans/value/ConditionalBinding.java create mode 100644 modules/javafx.base/src/main/java/javafx/beans/value/FlatMapBinding.java create mode 100644 modules/javafx.base/src/main/java/javafx/beans/value/LazyObjectBinding.java create mode 100644 modules/javafx.base/src/main/java/javafx/beans/value/Subscription.java diff --git a/modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java b/modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java index 6368df52f1c..fc04510de68 100644 --- a/modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java +++ b/modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java @@ -155,7 +155,10 @@ public ObservableList getDependencies() { public final T get() { if (!valid) { value = computeValue(); - valid = true; + + if (allowValidation()) { + valid = true; + } } return value; } @@ -182,6 +185,28 @@ public final boolean isValid() { return valid; } + /** + * Returns {@code true} when this binding currently has one or more + * listeners, otherwise {@code false}. + * + * @return {@code true} when this binding currently has one or more + * listeners, otherwise {@code false} + */ + protected final boolean isObserved() { + return helper != null; + } + + /** + * Can be overriden in extending classes to prevent a binding from becoming + * valid. The default implementation always allows bindings to become valid. + * + * @return {@code true} if this binding is allowed to become valid, otherwise + * {@code false} + */ + protected boolean allowValidation() { + return true; + } + /** * Calculates the current value of this binding. *

diff --git a/modules/javafx.base/src/main/java/javafx/beans/value/Bindings.java b/modules/javafx.base/src/main/java/javafx/beans/value/Bindings.java new file mode 100644 index 00000000000..69e46d34250 --- /dev/null +++ b/modules/javafx.base/src/main/java/javafx/beans/value/Bindings.java @@ -0,0 +1,35 @@ +package javafx.beans.value; + +import java.util.function.Function; + +/** + * Support class which supplies varies types of bindings. + */ +class Bindings { + + public static ObservableValue mapping(ObservableValue source, Function mapper) { + return nullableMapping(source, v -> v == null ? null : mapper.apply(v)); + } + + public static ObservableValue conditional(ObservableValue source, ObservableValue condition) { + return new ConditionalBinding<>(condition, source); + } + + public static ObservableValue flatMapping(ObservableValue source, Function> mapper) { + return new FlatMapBinding<>(source, mapper); + } + + public static ObservableValue nullableMapping(ObservableValue source, Function mapper) { + return new LazyObjectBinding<>() { + @Override + protected Subscription observeInputs() { + return source.subscribeInvalidations(() -> invalidate()); // start observing source + } + + @Override + protected U computeValue() { + return mapper.apply(source.getValue()); + } + }; + } +} diff --git a/modules/javafx.base/src/main/java/javafx/beans/value/ConditionalBinding.java b/modules/javafx.base/src/main/java/javafx/beans/value/ConditionalBinding.java new file mode 100644 index 00000000000..a75abf510e6 --- /dev/null +++ b/modules/javafx.base/src/main/java/javafx/beans/value/ConditionalBinding.java @@ -0,0 +1,58 @@ +package javafx.beans.value; + +import java.util.Objects; + +public class ConditionalBinding extends LazyObjectBinding { + private final ObservableValue condition; + private final ObservableValue source; + + private Subscription subscription; + + public ConditionalBinding(ObservableValue condition, ObservableValue source) { + this.source = Objects.requireNonNull(source); + this.condition = Objects.requireNonNull(condition); + + condition.subscribeInvalidations(() -> { + invalidate(); + + if(!isActive()) { + getValue(); // make valid so last value is cached while conditional is false + } + }); + } + + @Override + protected boolean allowValidation() { + // This binding is valid when it is itself observed, or is currently inactive. + // When inactive, the binding has the value of its source at the time it became + // inactive. + return super.allowValidation() || !isActive(); + } + + @Override + protected T computeValue() { + unsubscribe(); + + if(isObserved() && isActive()) { + subscription = source.subscribeInvalidations(this::invalidate); + } + + return source.getValue(); + } + + @Override + protected Subscription observeInputs() { + return this::unsubscribe; // condition is always observed and never unsubscribed + } + + private boolean isActive() { + return Boolean.TRUE.equals(condition.getValue()); + } + + private void unsubscribe() { + if(subscription != null) { + subscription.unsubscribe(); + subscription = null; + } + } +} diff --git a/modules/javafx.base/src/main/java/javafx/beans/value/FlatMapBinding.java b/modules/javafx.base/src/main/java/javafx/beans/value/FlatMapBinding.java new file mode 100644 index 00000000000..da315b7f53c --- /dev/null +++ b/modules/javafx.base/src/main/java/javafx/beans/value/FlatMapBinding.java @@ -0,0 +1,43 @@ +package javafx.beans.value; + +import java.util.Objects; +import java.util.function.Function; + +public class FlatMapBinding extends LazyObjectBinding { + private final ObservableValue source; + private final Function> mapper; + + private Subscription mappedSubscription = Subscription.EMPTY; + + public FlatMapBinding( + ObservableValue source, + Function> mapper + ) { + this.source = Objects.requireNonNull(source); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + protected T computeValue() { + S value = source.getValue(); + ObservableValue mapped = value == null ? null : mapper.apply(value); + + if(isObserved()) { + mappedSubscription.unsubscribe(); + mappedSubscription = mapped == null ? Subscription.EMPTY : mapped.subscribeInvalidations(this::invalidate); + } + + return mapped == null ? null : mapped.getValue(); + } + + @Override + protected Subscription observeInputs() { + Subscription subscription = source.subscribeInvalidations(this::invalidate); + + return () -> { + subscription.unsubscribe(); + mappedSubscription.unsubscribe(); + mappedSubscription = Subscription.EMPTY; + }; + } +} diff --git a/modules/javafx.base/src/main/java/javafx/beans/value/LazyObjectBinding.java b/modules/javafx.base/src/main/java/javafx/beans/value/LazyObjectBinding.java new file mode 100644 index 00000000000..59dd0f0ef23 --- /dev/null +++ b/modules/javafx.base/src/main/java/javafx/beans/value/LazyObjectBinding.java @@ -0,0 +1,84 @@ +package javafx.beans.value; + +import javafx.beans.InvalidationListener; +import javafx.beans.binding.ObjectBinding; + +/** + * Extends {@link ObjectBinding} with the ability to lazily register + * and eagerly unregister listeners on its dependencies. + * + * @param the type of the wrapped {@code Object} + */ +public abstract class LazyObjectBinding extends ObjectBinding { + private Subscription subscription; + private boolean wasObserved; + + @Override + public void addListener(ChangeListener listener) { + super.addListener(listener); + + updateSubcription(); + } + + @Override + public void removeListener(ChangeListener listener) { + super.removeListener(listener); + + updateSubcription(); + } + + @Override + public void addListener(InvalidationListener listener) { + super.addListener(listener); + + updateSubcription(); + } + + @Override + public void removeListener(InvalidationListener listener) { + super.removeListener(listener); + + updateSubcription(); + } + + @Override + protected boolean allowValidation() { + return isObserved(); + } + + private void updateSubcription() { + boolean isObserved = isObserved(); + + if(!wasObserved && isObserved) { // was first observer registered? + subscription = observeInputs(); // start observing source + + /* + * Although the act of registering a listener already attempts to make + * this binding valid, allowValidation won't allow it as the binding is + * not observed yet. This is because isObserved will not yet return true + * when the process of registering the listener hasn't completed yet. + * + * As the binding must be valid after it becomes observed the first time + * 'get' is called again. + */ + + get(); // make binding valid as source wasn't tracked until now + } + else if(wasObserved && !isObserved) { // was last observer unregistered? + subscription.unsubscribe(); + subscription = null; + invalidate(); // make binding invalid as source is no longer tracked + } + + wasObserved = isObserved; + } + + /** + * Called when this binding was previously not observed and a new observer was added. Implementors + * must return a {@link Subscription} which will be cancelled when this binding no longer has any + * observers. + * + * @return a {@link Subscription} which will be cancelled when this binding no longer has any observers, never null + */ + protected abstract Subscription observeInputs(); +} 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 f8c8d439f99..dc51129f647 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 @@ -1,5 +1,5 @@ /* - * Copyright (c) 2010, 2020, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2010, 2021, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -25,6 +25,11 @@ package javafx.beans.value; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + import javafx.beans.InvalidationListener; import javafx.beans.Observable; @@ -137,4 +142,138 @@ public interface ObservableValue extends Observable { * @return The current value */ T getValue(); + + /** + * Returns an {@link ObservableValue} which holds a mapping of the value + * held by this {@code ObservableValue}, and is {@code null} when this + * {@code ObservableValue} is {@code null}. + * + * @param the type of values held by the resulting {@code ObservableValue} + * @param mapper a {@link Function} which converts a given value to a new value, cannot be null + * @return an {@link ObservableValue} which holds a mapping of the value + * held by this {@code ObservableValue}, and is {@code null} when this + * {@code ObservableValue} is {@code null}, never null + */ + default ObservableValue map(Function mapper) { + return Bindings.mapping(this, Objects.requireNonNull(mapper)); + } + + /** + * Returns an {@link ObservableValue} which holds a mapping of the value + * held by this {@code ObservableValue}, or the value held by + * {@code alternativeValue} when this {@code ObservableValue} is + * {@code null}. + * + * @param alternativeValue an alternative value to use when the value held + * by this {@code ObservableValue} is {@code null}, can be null + * @return an {@link ObservableValue} which holds a mapping of the value + * held by this {@code ObservableValue}, or the value held by {@code + * alternativeValue} when this {@code ObservableValue} is {@code null}, + * never null + */ + default ObservableValue orElse(T alternativeValue) { + return orElseGet(() -> alternativeValue); + } + + /** + * Returns an {@link ObservableValue} which holds a mapping of the value + * held by this {@code ObservableValue}, or the value supplied by {@code + * supplier} when this {@code ObservableValue} is {@code null}. + * + * @param supplier a {@link Supplier} to use when the value held by this + * {@code ObservableValue} is {@code null}, cannot be null + * @return an {@link ObservableValue} which holds a mapping of the value + * held by this {@code ObservableValue}, or the value supplied by + * {@code supplier} when this {@code ObservableValue} is {@code null}, + * never null + */ + default ObservableValue orElseGet(Supplier supplier) { + Objects.requireNonNull(supplier); + + return Bindings.nullableMapping(this, v -> v == null ? supplier.get() : v); + } + + /** + * Returns an {@link ObservableValue} which holds the value in the {@code + * ObservableValue} given by applying {@code mapper} on the value of this + * {@code ObservableValue}, and is {@code null} when this + * {@code ObservableValue} is {@code null}.

+ * + * Returning {@code null} from {@code mapper} will result in an + * {@code ObservableValue} which holds {@code null}. + * + * @param the type of values held by the resulting {@code ObservableValue} + * @param mapper a {@link Function} which converts a given value to an + * {@code ObservableValue}, cannot be null + * @return an {@link ObservableValue} which holds the value in the + * {@code ObservableValue} given by applying {@code mapper} on the value + * of this {@code ObservableValue}, and is {@code null} when this + * {@code ObservableValue} is {@code null}, never null + */ + default ObservableValue flatMap(Function> mapper) { + return Bindings.flatMapping(this, Objects.requireNonNull(mapper)); + } + + /** + * Returns an {@link ObservableValue} which holds the value in this + * {@code ObservableValue} whenever the given {@code condition} evaluates to + * {@code true}, otherwise holds the value held by this {@code ObservableValue} + * when {@code condition} became {@code false}.

+ * + * The returned {@code ObservableValue} only observes this + * {@code ObservableValue} when the given {@code condition} evaluates to + * {@code true}. This allows the returned {@code ObservableValue} to be + * garbage collected if not otherwise strongly referenced when + * {@code condition} becomes {@code false}.

+ * + * Returning {@code null} from the given {@code condition} is treated the + * same as returning {@code false}. + * + * @param condition a boolean {@code ObservableValue}, cannot be null + * @return an {@link ObservableValue} which holds the value in this + * {@code ObservableValue} whenever the given {@code condition} + * evaluates to {@code true}, otherwise holds the value held by this + * {@code ObservableValue} when {@code condition} became {@code false}, + * never null + */ + default ObservableValue conditionOn(ObservableValue condition) { + return Bindings.conditional(this, condition); + } + + /** + * Creates a {@link Subscription} on this {@link ObservableValue} which + * immediately provides its current value to the given {@code subscriber}, + * followed by any subsequent changes in value. + * + * @param subscriber a {@link Consumer} to supply with the values of this + * {@link ObservableValue}, cannot be null + * @return a {@link Subscription} which can be used to cancel this + * subscription, never null + */ + default Subscription subscribe(Consumer subscriber) { + ChangeListener listener = (obs, old, current) -> subscriber.accept(current); + + subscriber.accept(getValue()); // eagerly send current value + addListener(listener); + + return () -> removeListener(listener); + } + + /** + * Creates a {@link Subscription} on this {@link ObservableValue} which + * calls the given {@code runnable} whenever this {@code ObservableValue} + * becomes invalid. + * + * @param runnable a {@link Runnable} to call whenever this + * {@link ObservableValue} becomes invalid, cannot be null + * @return a {@link Subscription} which can be used to cancel this + * subscription, never null + */ + default Subscription subscribeInvalidations(Runnable runnable) { + InvalidationListener listener = obs -> runnable.run(); + + addListener(listener); + + return () -> removeListener(listener); + } } diff --git a/modules/javafx.base/src/main/java/javafx/beans/value/Subscription.java b/modules/javafx.base/src/main/java/javafx/beans/value/Subscription.java new file mode 100644 index 00000000000..92885b63461 --- /dev/null +++ b/modules/javafx.base/src/main/java/javafx/beans/value/Subscription.java @@ -0,0 +1,43 @@ +package javafx.beans.value; + +import java.util.Objects; + +/** + * A subscription encapsulates how to cancel it without having + * to keep track of how it was created.

+ * + * For example:

+ *

Subscription s = property.subscribe(System.out::println)
+ * The function passed in to {@code subscribe} does not need to be stored + * in order to clean up the subscription later. + */ +public interface Subscription { + + /** + * An empty subscription. Does nothing when cancelled. + */ + static final Subscription EMPTY = () -> {}; + + /** + * Cancels this subscription. + */ + void unsubscribe(); + + /** + * Combines this {@link Subscription} with the given {@code Subscription} + * and returns a new {@code Subscription} which will cancel both when + * cancelled. + * + * @param other another {@link Subscription}, cannot be null + * @return a combined {@link Subscription} which will cancel both when + * cancelled, never null + */ + default Subscription and(Subscription other) { + Objects.requireNonNull(other); + + return () -> { + unsubscribe(); + other.unsubscribe(); + }; + } +}