-
Notifications
You must be signed in to change notification settings - Fork 473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
8274771: Map, FlatMap and OrElse fluent bindings for ObservableValue #675
Conversation
👋 Welcome back jhendrikx! A progress list of the required criteria for merging this PR into |
Webrevs
|
This will need an API review followed by an implementation review. /csr |
@kevinrushforth has indicated that a compatibility and specification (CSR) request is needed for this pull request. |
@kevinrushforth |
@kevinrushforth I've created the CSR here: https://bugs.openjdk.java.net/browse/JDK-8277456 -- it is my first CSR, if you any feedback on it I'd appreciate it. |
c5d704b
to
d9bfefe
Compare
I've rebased this PR on the current master to pull in the JUnit 5 upgrade, and I've updated the tests in this PR to use JUnit 5. |
I will start my review this week. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the review of the API. I suggested also adding the examples in the POC or similar to the relevant methods.
I have already commented on the implementation while we were discussing it in the mailing list, so that review will be minimal.
I will review the tests soon, but after a quick look they seem fine.
Don't worry about the CSR for now. When all reviewers agree on the API you can copy the final result to the CSR.
Unrelated to the review, will it makes sense in the future to make all bindings lazily register listeners like LazyObjectBinding
, perhaps when we introduce Subscription
?
modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/binding/ObjectBinding.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/LazyObjectBinding.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/MappedBinding.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/MappedBinding.java
Outdated
Show resolved
Hide resolved
That would need to be very well tested. There are some noticeable differences in behavior vs the standard bindings:
These behave very different. The first listener keeps working as you'd expect, while the second one can stop working as soon the GC runs. This is because
This keeps working and will not be GC'd by accident.
In effect, Lazy bindings behave the same as standard bindings when they're observed but their behavior changes when not observed: they never become valid and they stop caching values This has pros and cons: Pro: Lazy bindings can be garbage collected when not referenced and not actively being used without the use of weak listeners. See the first example where the binding keeps working when used by Pro: Lazy bindings don't register unnecessary listeners to be aware of changed or invalidated values that are not used by anyone. A good example is the problems we saw about a year ago where Con: lazy bindings never become valid when not observed. This means that In summary: I think lazy bindings are far superior in the experience that they offer, but it does come at a cost that values may need to be recomputed every time when the bindings are unobserved. However, doing substantial work in |
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
* @return an {@link ObservableValue} which provides a mapping of the value | ||
* held by this {@code ObservableValue}, and provides {@code null} when | ||
* this {@code ObservableValue} holds {@code null}, never null |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good idea to mention that it can hold null
.
I slightly prefer to say that the returned ObservableValue
holds the result of the mapping rather than holds the mapping. I don't really mind it, but it's the phrasing used in the method description "holds the result of applying a mapping". "The mapping" itself could be mistaken for the mapping Function
in my opinion. If you think it's clear, you can change it to that phrasing, it's also fine.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The changes look good. I added some minor grammar comments. I think that the API is in a good spot.
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The API looks good to me. After you get the other reviewers to look at it you can update the CSR.
As for the fluent binding tests:
- The
private
fields inObservableValueFluentBindingsTest
can befinal
. - Most tests that I have seen in JavaFX use the assert overloads that include a message that explains what the value should be or what it means if the assertion failed. I don't know how much of a requirement it is. I can help write these if you want.
- There is a utility class
JMemoryBuddy
that was added to JavaFX to test for memory leaks. This would have been helpful in the GC tests, but it seems that the class is not suited too well for this case (it requires you tonull
you own reference etc.). I think the way you did it is fine, but maybe that class should be updated (in a different patch) to be more welcoming for these checks.
I would also add tests of chaining the observables in common use cases. I wrote some myself to test it for map
:
ObservableValue<String> observableValue1 = property.map(v -> v + "Z");
ObservableValue<String> observableValue2 = observableValue1.map(v -> v + "X");
assertEquals("AZ", observableValue1.getValue());
assertEquals("AZX", observableValue2.getValue());
property.set("B");
assertEquals("BZ", observableValue1.getValue());
assertEquals("BZX", observableValue2.getValue());
for orElse
:
ObservableValue<String> observableValue1 = property.map(v -> v + "Z");
ObservableValue<String> observableValue2 = observableValue1.orElse("K");
assertEquals("AZ", observableValue1.getValue());
assertEquals("AZ", observableValue2.getValue());
property.set("B");
assertEquals("BZ", observableValue1.getValue());
assertEquals("BZ", observableValue2.getValue());
property.set(null);
assertNull(observableValue1.getValue());
assertEquals("K", observableValue2.getValue());
You can clean these up and use them or write your own. flatMap
should also have one. I can clean mine up and post it if it helps (I did some dirty testing there).
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The LazyObjectBindingTest
tests look good. I was thinking about adding a test that checks the compyteValue
calls when a listener is attached (should increase) and when not attached (should not increase), but I think this behavior is already tested in ObjectBinding
itself, so I'll leave it to you.
modules/javafx.base/src/main/java/javafx/beans/value/LazyObjectBinding.java
Outdated
Show resolved
Hide resolved
modules/javafx.base/src/test/java/test/javafx/beans/value/LazyObjectBindingTest.java
Show resolved
Hide resolved
Rewrote and extended ObservableFluentBindingsTest for more clarity Use JMemoryBuddy in ReferenceAsserts helper
I'm a bit at a loss there, many of the asserts are think are self explanatory already, and a text describing the events that led to this assert would basically describe the same as the path of all
I've however cleaned up the entire test using different values that more clearly show what is happening (like Please let me know if that helps.
I've applied it in the helper class I was using.
I added additional nested classes for Map which adds another nesting that applies another Map or an OrElse. Same for FlatMap. I see the fluent binding test mostly as a test to ensure the basic functionality and to ensure things are getting garbage collected when they should and not when they shouldn't. If we want to test more specific cases without having to worry about GC, perhaps another test class is better suited.
That's good, I hope you didn't find anything surprising :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tests look good. I'm happy with the coverage and added one comment about a missing case. My own sanity checks also work as I expect.
Will approve when these are addressed as I have already reviewed the API and implementation.
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Outdated
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Outdated
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Outdated
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Outdated
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks very good. I left a few minor optional comments after doing a quick re-review of everything. You can wait until the other reviews with these if you want.
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Outdated
Show resolved
Hide resolved
...les/javafx.base/src/test/java/test/javafx/beans/value/ObservableValueFluentBindingsTest.java
Show resolved
Hide resolved
modules/javafx.base/src/main/java/javafx/beans/value/ObservableValue.java
Outdated
Show resolved
Hide resolved
I'm not entirely in agreement here, but also not entirely disagreeing. I mean, if there was a way to make this work perfectly, then I agree that this "automatic" unsubscribing is a great mechanism. However, because you are relying on the GC the code is actually subtly broken right from the start -- you just don't notice it so easily:
The GC issues are some of the hardest to debug, as the problem may appear or disappear (depending on the type) at any time during debugging if the GC kicks in. Bug reports from users are going to be even harder to analyze as they're basically going to be describing something that occurs infrequently and without any pattern to it, and you can't reproduce it. You'll have to ask things like the exact GC used, any GC parameters, all things that are normally completely irrelevant but now may have serious repercussions. Conservative GC's may not even be suitable to use in combination with JavaFX (when not using explicit unbinding/unsubscribing). With a regular memory leak I can make a heap dump and see that a piece of UI has 500 copies in memory. While not fun, finding a GC root then is often sufficient to find the offending reference and solve the problem.
I don't disagree that is a big source of bugs, however the trade off made introduces a new category of bugs that is fleeting and an order of magnitude harder to reproduce and reason about.
Just to make sure we are on same page, it leaks a bit more memory than it would if it was using
I think something might be possible. I personally however find that for anything but the most trivial FX apps, explicit management (although not for the case you highlighted above) is preferable to avoid hard to pin down bugs related to untimely GC's. Explicit management could be far simpler than it currently is, with a little bit of help from JavaFX. An
Note that if caption never changes, just binding it regularly will leave stubs behind with the new and old mechanism. Not a big memory leak, but it may add up if the binding is in something like a frequently occurring dialog or progress indicator or something. |
You mean by I think this is a very nice idea to potentially tackle this problem. |
From @mstr2 analysis, it is slightly more than just the stub in the case of the fluent bindings. See illustration below, assuming It might be good to fix this right away. |
I think a model with the following properties would be more consistent and easier to understand than that we currently have:
The second property ensures that it will be sufficient to keep a reference to the last It is true that manually added listeners may suffer from unexpected collection, but something has to give. It seems like a reasonable trade-off to make listeners more complicated than binding expressions (which also hopefully discourages the use of manually added listeners). I would also argue that it is easier to explain that it's not the listener that's different in properties v. expressions, it's the reachability of the
Thanks for this clarification, I wasn't aware that the leaked listener is actually removed when the source property is changed. That makes the issue a little bit less concerning, but I'd still prefer a solution that doesn't require the source property to change. |
We already have |
I didn't get into the fine details of the GC discussion yet, but I have a few points I would like to share:
|
I've done some experimenting using a
Doing 100.000 throwaway binds on a single property, this mechanism manages to keep up quite well using standard GC settings. Within seconds, all 100.000 stubs have been cleaned up (note: 100.000 is already an insane amount). If I go higher, the cleaning mechanism can't quite keep up, but that's primarily because the operation becomes quite expensive due to the large listener list involved (cleaning doesn't start immediately, and if the list of listeners managed by
From what I can see, we'd need to change all locations where If there is agreement on this approach, I could create a separate MR for this (since it could be implemented and merged before this one) or integrate the changes into this one. |
I need to read through the recent GC discussion one more time, but I like the approach of using a Cleaner to get rid of stale bindings that would otherwise be released when the base property changes. I think the cleanup could be done as a follow-on fix, in which case it wouldn't block getting this in. The cleanup should also go into JavaFX 19, but that could go in after RDP1 (whereas this feature cannot). |
I was concerned that the current implementation might leave behind a stale listener that is never collected. If that was the case, I would not be in favor of integrating this PR. But as it turns out, the stale listener is cleared when the source property changes, which doesn't make it as serious an issue as I originally thought. I see no problem with integrating this PR now, and fix the cleanup issue later. |
Remember this program? :)
I modified Performance looks now like this: The price for this is increased memory use (you can see that having 10.000.000 binding stubs took around 400 MB before, and now it takes about twice that). The extra memory use is only paid when there is more than 1 listener (as per how |
@hjohn If there are no more questions or concerns raised in the next few hours, you can integrate this PR. Please file the follow-up JBS bug to address the cleanup. |
Additionally, can you file a docs bug to clarify the GC behavior of bindings and mappings as suggested by @nlisker in point 3 of this comment? |
@hjohn If the followup work on this issue is too much and you want to focus on the implementation, you can assign it to me. |
Yes, this must be implemented for all private static class Listener implements InvalidationListener, WeakListener {
private final WeakReference<StringPropertyBase> wref;
private final Cleaner.Cleanable cleanable;
public Listener(StringPropertyBase ref, Observable observable) {
this.wref = new WeakReference<>(ref);
if (observable instanceof ConcurrentListenerAccess) {
cleanable = CLEANER.register(ref, this::removeListener);
} else {
cleanable = null;
}
}
// Note: dispose() must be called in StringPropertyBase.unbind()
public void dispose() {
if (cleanable != null) {
cleanable.clean();
} else {
removeListener();
}
}
private void removeListener() {
StringPropertyBase ref = wref.get();
if (ref != null) {
ref.observable.removeListener(this);
}
}
@Override
public void invalidated(Observable observable) {
StringPropertyBase ref = wref.get();
if (ref == null) {
dispose();
} else {
ref.markInvalid();
}
}
@Override
public boolean wasGarbageCollected() {
return wref.get() == null;
}
}
Not sure I'm following here. Do you want to implement this pattern for all property implementations? If we just want to implement it for mapped bindings, only the Note that it's not enough for the But if we do that, the lock tax must be paid on every single access to the However, I think we might get away with an implementation that only requires locking for the private volatile ConcurrentExpressionHelper<T> helper = null;
@Override
public synchronized void addListener(InvalidationListener listener) {
helper = ConcurrentExpressionHelper.addListener(helper, this, listener);
}
@Override
public synchronized void removeListener(InvalidationListener listener) {
helper = ConcurrentExpressionHelper.removeListener(helper, listener);
}
@Override
public synchronized void addListener(ChangeListener<? super T> listener) {
helper = ConcurrentExpressionHelper.addListener(helper, this, listener);
}
@Override
public synchronized void removeListener(ChangeListener<? super T> listener) {
helper = ConcurrentExpressionHelper.removeListener(helper, listener);
}
protected void fireValueChangedEvent() {
ConcurrentExpressionHelper.fireValueChangedEvent(helper);
} This implementation works by creating immutable specializations of ConcurrentExpressionHelper. When a listener is added, a new
|
/integrate |
/sponsor |
Going to push as commit 60c75b8.
Your commit was automatically rebased without conflicts. |
@kevinrushforth @hjohn Pushed as commit 60c75b8. 💡 You may see a message that your pull request was closed with unmerged commits. This can be safely ignored. |
Yes, true, only making it thread-safe for that class should remove a chain of fluent bindings. I think however that it would be good to implement this for as many classes as we can as the stub cleaning is normally only triggered on invalidation/changes (and as I recently discovered, when
Yes, true, I only fixed synchronization issues in my experiment and didn't look much further than that yet.
Yeah, we shouldn't do that, it synchronizes all accesses to all property lists everywhere, it would be an easy "fix" as it's in one place, but it doesn't feel right.
I see you have been playing with improving The situation where
Yes, I think that's a good idea, I considered using And the changes in |
@hjohn |
@chengenzhao The only way to do this right now using the fluent bindings is like this:
It is not very pretty. |
@hjohn textProperty.bind(Bindings.reduce(widthProperty, heightProperty, (w, h) -> "Area is "+w.doubleValue()*h.doubleValue())); my implementation @FunctionalInterface
public interface Callable2<T0, T1, T> {
T call(T0 t0, T1 t1);
}
public static <T0, T1, T> ObjectBinding<T> reduce(ObservableValue<T0> t0, ObservableValue<T1> t1, Callable2<T0, T1, T> callable) {
return javafx.beans.binding.Bindings.createObjectBinding(() -> callable.call(t0.getValue(), t1.getValue()), t0, t1);
} |
This is an implementation of the proposal in https://bugs.openjdk.java.net/browse/JDK-8274771 that me and Nir Lisker (@nlisker) have been working on. It's a complete implementation including good test coverage.
This was based on #434 but with a smaller API footprint. Compared to the PoC this is lacking public API for subscriptions, and does not include
orElseGet
or theconditionOn
conditional mapping.Flexible Mappings
Map the contents of a property any way you like with
map
, or map nested properties withflatMap
.Lazy
The bindings created are lazy, which means they are always invalid when not themselves observed. This allows for easier garbage collection (once the last observer is removed, a chain of bindings will stop observing their parents) and less listener management when dealing with nested properties. Furthermore, this allows inclusion of such bindings in classes such as
Node
without listeners being created when the binding itself is not used (this would allow for the inclusion of atreeShowingProperty
inNode
without creating excessive listeners, see this fix I did in an earlier PR: #185)Null Safe
The
map
andflatMap
methods are skipped, similar tojava.util.Optional
when the value they would be mapping isnull
. This makes mapping nested properties withflatMap
trivial as thenull
case does not need to be taken into account in a chain like this:node.sceneProperty().flatMap(Scene::windowProperty).flatMap(Window::showingProperty)
. Instead a default can be provided withorElse
.Some examples:
Note that this is based on ideas in ReactFX and my own experiments in https://github.com/hjohn/hs.jfx.eventstream. I've come to the conclusion that this is much better directly integrated into JavaFX, and I'm hoping this proof of concept will be able to move such an effort forward.
Progress
Issues
Reviewers
Reviewing
Using
git
Checkout this PR locally:
$ git fetch https://git.openjdk.org/jfx pull/675/head:pull/675
$ git checkout pull/675
Update a local copy of the PR:
$ git checkout pull/675
$ git pull https://git.openjdk.org/jfx pull/675/head
Using Skara CLI tools
Checkout this PR locally:
$ git pr checkout 675
View PR using the GUI difftool:
$ git pr show -t 675
Using diff file
Download this PR as a diff file:
https://git.openjdk.org/jfx/pull/675.diff