5

A relative Java newbie question.

I have a TableView with extractors and a ListChangeListener added to the underlying ObservableList.

If I have a StringProperty column in the data model, the change listener doesn't detect changes if I double-click the cell and then hit ENTER without making any changes. That's good.

However, if I define the column as ObjectProperty<String> and double-click and then hit ENTER, the change listener always detects changes even when none have been made.

Why does that happen? What's the difference between ObjectProperty<String> and StringProperty from a change listener's point of view?

I've read Difference between SimpleStringProperty and StringProperty and JavaFX SimpleObjectProperty<T> vs SimpleTProperty and think I understand the differences. But I don't understand why the change listener is giving different results for TProperty/SimpleTProperty and ObjectProperty<T>.

If it helps, here is a MVCE for my somewhat nonsensical case. I'm actually trying to get a change listener working for BigDecimal and LocalDate columns and have been stuck on it for 5 days. If I can understand why the change listener is giving different results, I might be able to get my code working.

I'm using JavaFX8 (JDK1.8.0_181), NetBeans 8.2 and Scene Builder 8.3.

package test17;

import javafx.application.Application;
import static javafx.application.Application.launch;
import javafx.beans.Observable;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.util.converter.DefaultStringConverter;

public class Test17 extends Application {

    private Parent createContent() {

        ObservableList<TestModel> olTestModel = FXCollections.observableArrayList(testmodel -> new Observable[] {
                testmodel.strProperty(),
                testmodel.strObjectProperty()
        });

        olTestModel.add(new TestModel("A", "a"));
        olTestModel.add(new TestModel("B", "b"));

        olTestModel.addListener((ListChangeListener.Change<? extends TestModel > c) -> {
            while (c.next()) {
                if (c.wasUpdated()) {
                    System.out.println("===> wasUpdated() triggered");
                }
            }
        });

        TableView<TestModel> table = new TableView<>();

        TableColumn<TestModel, String> strCol = new TableColumn<>("strCol");
        strCol.setCellValueFactory(cellData -> cellData.getValue().strProperty());
        strCol.setCellFactory(TextFieldTableCell.forTableColumn(new DefaultStringConverter()));
        strCol.setEditable(true);
        strCol.setPrefWidth(100);
        strCol.setOnEditCommit((CellEditEvent<TestModel, String> t) -> {
                ((TestModel) t.getTableView().getItems().get(
                        t.getTablePosition().getRow())
                        ).setStr(t.getNewValue());
        });

        TableColumn<TestModel, String> strObjectCol = new TableColumn<>("strObjectCol");
        strObjectCol.setCellValueFactory(cellData -> cellData.getValue().strObjectProperty());
        strObjectCol.setCellFactory(TextFieldTableCell.forTableColumn(new DefaultStringConverter()));
        strObjectCol.setEditable(true);
        strObjectCol.setPrefWidth(100);
        strObjectCol.setOnEditCommit((CellEditEvent<TestModel, String> t) -> {
            ((TestModel) t.getTableView().getItems().get(
                    t.getTablePosition().getRow())
                    ).setStrObject(t.getNewValue());
        });

        table.getColumns().addAll(strCol, strObjectCol);
        table.setItems(olTestModel);
        table.getSelectionModel().setCellSelectionEnabled(true);
        table.setEditable(true);

        BorderPane content = new BorderPane(table);
        return content;
    }

    public class TestModel {

        private StringProperty str;
        private ObjectProperty<String> strObject;

        public TestModel(
            String str,
            String strObject
        ) {
            this.str = new SimpleStringProperty(str);
            this.strObject = new SimpleObjectProperty(strObject);
        }

        public String getStr() {
            return this.str.get();
        }

        public void setStr(String str) {
            this.str.set(str);
        }

        public StringProperty strProperty() {
            return this.str;
        }

        public String getStrObject() {
            return this.strObject.get();
        }

        public void setStrObject(String strObject) {
            this.strObject.set(strObject);
        }

        public ObjectProperty<String> strObjectProperty() {
            return this.strObject;
        }

    }

    @Override
    public void start(Stage stage) throws Exception {
        stage.setScene(new Scene(createContent()));
        stage.setTitle("Test");
        stage.setWidth(350);
        stage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }

}
GreenZebra
  • 362
  • 6
  • 14

1 Answers1

4

The difference can be seen by looking at the source code of StringPropertyBase and ObjectPropertyBase—specfically, their set methods.

StringPropertyBase

@Override
public void set(String newValue) {
    if (isBound()) {
        throw new java.lang.RuntimeException((getBean() != null && getName() != null ?
                getBean().getClass().getSimpleName() + "." + getName() + " : ": "") + "A bound value cannot be set.");
    }
    if ((value == null)? newValue != null : !value.equals(newValue)) {
        value = newValue;
        markInvalid();
    }
}

ObjectPropertyBase

@Override
public void set(T newValue) {
    if (isBound()) {
        throw new java.lang.RuntimeException((getBean() != null && getName() != null ?
                getBean().getClass().getSimpleName() + "." + getName() + " : ": "") + "A bound value cannot be set.");
    }
    if (value != newValue) {
        value = newValue;
        markInvalid();
    }
}

Notice the difference in how they check if the new value is equal to the old value? The StringPropertyBase class checks by using Object.equals whereas the ObjectPropertyBase class uses reference equality (==/!=).

I can't answer for certain why this difference exists, but I can hazard a guess: An ObjectProperty can hold anything and therefore there's the potential for Object.equals to be expensive; such as when using a List or Set. When coding StringPropertyBase I guess they decided that potential wasn't there, that the semantics of String equality was more important, or both. There may be more/better reasons for why they did what they did, but as I was not involved in development I'm not aware of them.


Interestingly, if you look at how they handle listeners (com.sun.javafx.binding.ExpressionHelper) you'll see that they check for equality using Object.equals. This equality check only occurs if there are currently ChangeListeners registered—probably to support lazy evaluation when there are no ChangeListeners.

If the new and old values are equals the ChangeListeners are not notified. This doesn't stop the InvalidationListeners from being notified, however. Thus, your ObservableList will fire an update change because that mechanism is based on InvalidationListeners and not ChangeListeners.

Here's the relevant source code:

ExpressionHelper$Generic.fireValueChangedEvent

@Override
protected void fireValueChangedEvent() {
    final InvalidationListener[] curInvalidationList = invalidationListeners;
    final int curInvalidationSize = invalidationSize;
    final ChangeListener<? super T>[] curChangeList = changeListeners;
    final int curChangeSize = changeSize;

    try {
        locked = true;
        for (int i = 0; i < curInvalidationSize; i++) {
            try {
                curInvalidationList[i].invalidated(observable);
            } catch (Exception e) {
                Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), e);
            }
        }
        if (curChangeSize > 0) {
            final T oldValue = currentValue;
            currentValue = observable.getValue();
            final boolean changed = (currentValue == null)? (oldValue != null) : !currentValue.equals(oldValue);
            if (changed) {
                for (int i = 0; i < curChangeSize; i++) {
                    try {
                        curChangeList[i].changed(observable, oldValue, currentValue);
                    } catch (Exception e) {
                        Thread.currentThread().getUncaughtExceptionHandler().uncaughtException(Thread.currentThread(), e);
                    }
                }
            }
        }
    } finally {
        locked = false;
    }
}

And you can see this behavior in the following code:

import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;

public class Main {

  public static void main(String[] args) {
    ObjectProperty<String> property = new SimpleObjectProperty<>("Hello, World!");
    property.addListener(obs -> System.out.printf("Property invalidated: %s%n", property.get()));
    property.addListener((obs, ov, nv) -> System.out.printf("Property changed: %s -> %s%n", ov, nv));
    property.get(); // ensure valid

    property.set(new String("Hello, World!")); // must not use interned String
    property.set("Goodbye, World!");
  }

}

Output:

Property invalidated: Hello, World!
Property invalidated: Goodbye, World!
Property changed: Hello, World! -> Goodbye, World!
Slaw
  • 37,820
  • 8
  • 53
  • 80
  • 2
    interesting ... seems to be unspecified whether identity or equality change constitutes a change of the value of an ObservableValue (at least I couldn't find anything) - and the implementations don't specify how they are implemented. Hmmm... – kleopatra Sep 19 '18 at 12:54
  • @Slaw Thanks very much for your explanation. At least I now understand why I've not been able to get a change listener working for `BigDecimal` and `LocalDate` columns. My current workaround is to define them as `StringProperty` and handle them in my code but I'll now try and write my own "ObjectProperty" class. I suspect that's beyond my current Java skills but nothing ventured, nothing gained. Thanks again. – GreenZebra Sep 19 '18 at 20:13
  • @kleopatra I couldn't find a clear specification either. I notice, though, that _changes_ seem to be implemented based on `equals` but _invalidation_ seems to be implemented by reference equality—except for `StringPropertyBase`. Maybe a changed reference is an invalidation? It seems to fit with the documentation of `ObservableValue`: "_A change event [...] value has changed. An invalidation event [...] value not valid anymore. This distinction becomes important [...] because for a lazily evaluated value one does not know if an invalid value really has changed until it is recomputed._". – Slaw Sep 19 '18 at 22:08
  • @GreenZebra May I ask what problems are being caused by extra invalidation events? You may not need to worry about them. – Slaw Sep 19 '18 at 22:14
  • @Slaw Yes, sure. I'm writing an app for home use that records financial data in a SQLite DB, hence the need for `BigDecimal`s and `ObjectProperty`. When data changes in the TableView, I use the change listener to record the type of change (INSERT, UPDATE or DELETE) and the affected DB rowID (which is in the TableView's model). When the SAVE button is hit, I write the SQL to make the changes by rowID and process them in a single, atomic transaction. If there's a better way of doing all of this, I'd love to know about it. And if I can avoid the invalidation issues, even better! Thanks. :-) – GreenZebra Sep 19 '18 at 23:25
  • @Slaw PS: I forgot to say that the invalidation issue is having two impacts: 1) I'm incorrectly recording rowIDs as being updated when they're not, and 2) I don't allow the form to close if there are uncommitted changes, which there aren't, so I'm forced to discard non-existent changes. – GreenZebra Sep 19 '18 at 23:34
  • @Slaw I've just kludged around the invalidation issue by doing my own value checks for `BigDecimal`s and `LocalDate`s in my `commitEdit()` method. If the values are equal, I `super.commitEdit` using `getItem()` rather than `item`, in effect committing the original `ObjectProperty` variable (object reference?) back to itself. If the values are not equal, or if `item` is not a `BigDecimal` or `LocalDate`, I use `super.commitEdit(item)`. The kludge works but does anything strike you as glaringly wrong with the approach? – GreenZebra Sep 20 '18 at 00:46
  • 1
    @GreenZebra I can't think of any issues regarding your workaround. In fact, it's more elegant/simple than what I was thinking: to keep a map of original values and comparing new values with the mapped ones. Note that, if you want, you can make that behavior `TableColumn` based rather than `TableCell` based if you use `TableColumn.onEditCommit`; you'd have to implement the actual set-the-property-with-the-new-value behavior yourself if you go that route, though. – Slaw Sep 20 '18 at 01:26
  • @Slaw Terrific, thanks again for your help. It was invaluable. – GreenZebra Sep 20 '18 at 01:54