4

With immutable models, what would be the best way to batch several changes?

For example, let's have a Book that is immutable. I need to change both title and year. I could change one by one, but that would just create one additional object that is not going to be used. Is there any better way for this?

I can imagine only something with constructor:

new Book(oldBook, newTitle, newYear);

or to have external tool for cloning books, that we can use.

5
  • 1
    What is wrong with your idea of using immutable fields and calling a constructor to create a new snapshot of the object?
    – Giorgio
    Commented Nov 19, 2014 at 22:38
  • 4
    What language is this? Scala and F# both have a facility for this exact sort of thing defined on case classes and records, respectively. Scala: oldBookObject copy (title = newTitle, year = newYear). F#: oldBookObject with { title = newTitle; year = newYear }
    – KChaloux
    Commented Nov 19, 2014 at 22:52
  • Language is quite important. Clojure has some tricks of its own. Clojure is the new C talk. The particular portion about immutable and new objects is at 1:04:50
    – user40980
    Commented Nov 19, 2014 at 23:13
  • 1
    Since somebody mentioned Clojure, it actually has a feature specifically for this called transients clojure.org/transients so you could look into its internals. The gist of how to use it is you call transient on an existing persistent data structure which creates a transient version in O(1). You can then modify it using different functions than you would a persistent version. When finished you call persistent! to get a new persistent structure from the transient one in O(1). All subsequent attempts to modify the transient structure using the transient functions throw exceptions.
    – WuHoUnited
    Commented Nov 20, 2014 at 1:52
  • That is a shitty, shitty design. The constructor design is vastly superior.
    – DeadMG
    Commented Nov 20, 2014 at 23:33

3 Answers 3

5

Depending on the number of fields, you might find it easier to use the Builder pattern to combine these changes than to create a separate constructor for each possible set of changes. It does requiring one object that is not going to be used (returned), but you can use one builder instance for as many books as you need to change.

public final class Book {
    private final String title;
    private final Integer year;
    private final List<String> authors;

    public static final class Builder {
        private String title = null;
        private Integer year = null;
        private List<String> authors = null;

        public Book build() {
            return new Book(this);
        }

        public Builder from(Book other) {
            withTitle(other.title);
            withYear(other.year);
            withAuthors(other.authors);

            return this;
        }

        public Builder withTitle(String title) {
            this.title = title;

            return this;
        }

        public Builder withYear(Integer year) {
            this.year = year;

            return this;
        }

        public Builder withAuthors(List<String> authors) {
            if (authors.isEmpty())
                this.authors = Collections.emptyList();
            else
                this.authors = new ArrayList<String>(authors);

            return this;
        }
    }

    private Book(Builder builder) {
        this.title = builder.title;
        this.year = builder.year;
        this.authors = builder.authors;
    }

    public String getTitle() {
        return title;
    }

    public Integer getYear() {
        return year;
    }

    public List<String> getAuthors() {
        return Collections.unmodifiableList(authors);
    }
}

To make a new object from an old one:

Book oldBook = new Book.Builder()//
        .withTitle("Hello")//
        .withYear(1900)//
        .withAuthors(Arrays.asList("Alice"))//
        .build();
Book newBook = new Book.Builder()//
        .from(oldBook)//
        .withTitle("World")//
        .withYear(1901)//
        .build();
2

One useful pattern is to define a public interface which can report all of the properties associated with the class, as well as a package-private interface which extends it with a few additional members. A package-private version of the class should be mutable, but the public-facing one should be immutable. The public-facing class should hold a reference of the private interface type, and implement its members in terms of that private interface. Additionally, all classes in the package must maintain the invariant that no instance of the mutable type may ever be modified after a reference to it has been stored in a field which could be reachable on another thread.

Invoking a method like WithName(String name) on the public face of the class could cause it to call WithName on the encapsulated object, which would in most cases constructing a NameAdder object which holds the new Name value along with a reference to the encapsulated object itself. All methods other than GetName and WithName would chain to the encapsulated object; while GetName() would return the new Name, and WithName would return a new NameAdder object which holds the new Name and a reference to the object encapsulated in the NameAdder (rather than a reference to the NameAdder itself).

An obvious problem with this approach is that more and more operands get performed upon an object, the chains of method invocations would become longer and longer. To mitigate that, the internal interface (and perhaps the public one as well) should include a Flatten method. Calling Flatten on an implementation of the interface should yield a new object which applies all of the changes that have been added to it via methods like WithName. One way of accomplishing that would be to construct a new object where every field's value was computed by calling Get on all of the objects involved. That could be expensive and inefficient, however, if it gets invoked upon an object that's very "deep".

An alternative approach would be to have the private interface include an AsFlattenedMutable method which would an instance of the mutable class to which all appropriate changes had been applied, and to which no reference had ever been stored in any class field anywhere. The mutable class could implement AsFlattenedMutable to return a clone of itself; a class like NameAdder would implement AsFlattenedMutable to invoke AsFlattenedMutable upon the encapsulated object, modify the name stored in that mutable object, and return the object after the mutation. This would thus allow even a rather deep modification chain to be resolved rather quickly.

The only slight "gotcha" with that approach is if code modifies an object and then stores a reference into a non-final field, the generated code could potentially resequence the operations so that the reference to the object gets stored before all the modifications are complete. To properly solve that may require having a new object store a reference to the mutable object into a final field, and then copying the reference from that final-field into the non-final one. By my understanding of the JVM specification, storing a reference to an object into a final field of an object under construction would prevent the deferral of any pending requests beyond the return of the constructor, and reading the value of that field would prevent the non-final field from being assigned until after the execution of the constructor [if code didn't read the reference from the new object, but instead simply used a pre-existing copy, then the call to the constructor--as well as any pending mutations to the object of interest--could be deferred until after the assignment].

1

Here's a twist on Matt's answer, using the builder pattern combined with Java 8's new functional capabilities:

Book.java

import java.util.*;
import java.util.function.Consumer;

public class Book
{
    private String title;
    private List<String> authors;
    private int year;

    public Book(String title, List<String> authors, int year) {
         this.title = title;
         this.authors = new ArrayList<>(authors);
         this.year = year;
    }

    /*
     * Start with the current Book, create a BookBuilder with all the same properties, then send that through a functional lambda for changing what needs changed.
     */
    public Book cloneWith(Consumer<BookBuilder> bookRebuilder) {
        BookBuilder bookBuilder = new BookBuilder(getTitle(), getAuthors(), getYear());
        bookRebuilder.accept(bookBuilder);
        return new Book(bookBuilder.getTitle(), bookBuilder.getAuthors(), bookBuilder.getYear());
    }

    public String getTitle() {
        return title;
    }

    public int getYear() {
        return year;
    }

    public List<String> getAuthors() {
        return new ArrayList<String>(authors);
    }

    public String toString() {
        StringBuilder str = new StringBuilder();

        str.append(title);

        str.append(", by ");
        for(String author : authors) {
            str.append(author);
            str.append(", ");
        }

        str.append("published ");
        str.append(year);

        return str.toString();
    }
}

BookBuilder.java (this could just as easily be a static internal class)

import java.util.*;

public class BookBuilder
{
    private String title;
    private List<String> authors;
    private int year;

    public BookBuilder(String title, List<String> authors, int year) {
        this.title = title;
        this.authors = authors;
        this.year = year;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public List<String> getAuthors() {
        return authors;
    }

    public void setAuthors(List<String> authors) {
        this.authors = authors;
    }

    public int getYear() {
        return year;
    }

    public void setYear(int year) {
        this.year = year;
    }
}

Main.java

import java.util.*;

public class Main
{
    public static void main(String[] args) {
        Book book1 = new Book("Pride and Prejudice", Arrays.asList("Jane Austen"), 1813);

        Book book2 = book1.cloneWith((baseBook)-> {
            baseBook.setTitle("Pride and Prejudice and Zombies");
            baseBook.setYear(2009);
            List<String> authors = baseBook.getAuthors();
            authors.add("Seth Grahame-Smith");
            baseBook.setAuthors(authors);
        });

        System.out.println(book1.toString());
        System.out.println(book2.toString());
    }
}

One big difference is that, instead of having to use a chain of fluent calls that might be hard to break, we can interject other logic within the lambda closure. Here BookBuilder could also be made fluent so it can be just as terse.

3
  • Like this approach. Isn't in this case BookBuilder pretty much a copy of a Book? That is a lot of code duplication in large code base. What do you think about it?
    – lawpert
    Commented Nov 24, 2014 at 12:13
  • @lawpert Originally, I had BookBuilder extending Book to reduce duplication, but then BookBuilder would be a Book, meaning if I got a hold of one, I could use it anywhere a Book can be used (having a package-private constructor would mitigate this to a degree). If you are confident that others will stay in line, you could use extension. There is heavy overlap because Book only has getters. If it had other behavior, that behavior would not be added to BookBuilder. To produce a more terse class, one could use public properties on BookBuilder instead.
    – cbojar
    Commented Nov 24, 2014 at 16:48
  • 1
    (This is a comment on using the builder approach) @lawpert Actually this is a lot of code duplication, but it serves a high purpose. Using a builder is a well known pattern to deal with immutable objects. Have a look into 'Consider a builder when faced with many constructor parameters' in 'Effective Java' (Chapter 1, Item 2) by Joshua Bloch to read more about this. However, you can avoid the drawbacks of code duplication by using a code generator like PojoBuilder.
    – Michael
    Commented Dec 18, 2014 at 5:55

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.