paliwodar
paliwodar

Reputation: 91

Groovy: Is there a better way of handling @Immutable objects than copyWith method

I am looking for a flexible way of "modifying" (copying with some values changed) immutable objects in groovy. There is a copyWith method but it allows you only to replace some properties of the object. It doesn't seem to be convenient enough.

Let's say we have a set of classes representing a domain design of some system:

@Immutable(copyWith = true)
class Delivery {
    String id
    Person recipient
    List<Item> items
}

@Immutable(copyWith = true)
class Person {
    String name
    Address address
}

@Immutable(copyWith = true)
class Address {
    String street
    String postalCode
}

Let's assume I need to change street of delivery recipient. In case of regular mutable object it is just fine to perform:

delivery.recipient.address.street = newStreet

or (perhaps useful in some cases):

delivery.with {recipient.address.street = newStreet}

When it comes to do the same with immutable objects the best way according to my knowledge would be:

def recipient = delivery.recipient
def address = recipient.address
delivery.copyWith(recipient:
                      recipient.copyWith(address:
                                             address.copyWith(street: newStreet)))

It is actually needed for Spock integration test code so readability and expressiveness matters. The version above cannot be used "on the fly" so in order to avoid creating tons of helper methods, I have implemented my own copyOn (since copyWith was taken) method for that which makes it possible to write:

def deliveryWithNewStreet = delivery.copyOn { it.recipient.address.street = newStreet }

I wonder however if there is an ultimate solution for that, present in groovy or provided by some external library. Thanks

Upvotes: 6

Views: 884

Answers (1)

paliwodar
paliwodar

Reputation: 91

For the sake of completeness I provide my implementation of copyOn method. It goes as follows:

class CopyingDelegate {
    static <T> T copyOn(T source, Closure closure) {
        def copyingProxy = new CopyingProxy(source)
        closure.call(copyingProxy)
        return (T) copyingProxy.result
    }
}

class CopyingProxy {
    private Object nextToCopy
    private Object result
    private Closure copyingClosure

    private final Closure simplyCopy = { instance, property, value -> instance.copyWith(createMap(property, value)) }
    private final def createMap = { property, value -> def map = [:]; map.put(property, value); map }

    CopyingProxy(Object nextToCopy) {
        this.nextToCopy = nextToCopy
        copyingClosure = simplyCopy
    }

    def propertyMissing(String propertyName) {
        def partialCopy = copyingClosure.curry(nextToCopy, propertyName)
        copyingClosure = { object, property, value ->
            partialCopy(object.copyWith(createMap(property, value)))
        }
        nextToCopy = nextToCopy.getProperties()[propertyName]
        return this
    }

    void setProperty(String property, Object value) {
        result = copyingClosure.call(nextToCopy, property, value)
        reset()
    }

    private void reset() {
        nextToCopy = result
        copyingClosure = simplyCopy
    }
}

It is then just a matter of adding the delegated method in Delivery class:

Delivery copyOn(Closure closure) {
    CopyingDelegate.copyOn(this, closure)
}

High level explanation:

First of all it is required to notice that the code of: delivery.recipient.address.street = newStreet is interpreted as:

  1. Accessing recipient property of delivery object
  2. Accessing address of what was the result of the above
  3. Assigning property street with the value of newStreet

Of course the class CopyingProxy does not have any of those properties, so propertyMissing method will be involved.

So as you can see it is a chain of propertyMissing method invocations terminated by running setProperty.

Base case

In order to implement the desired functionality we maintain two fields: nextToCopy (which is delivery at the beginning) and copyingClosure (which is initialised as a simple copy using copyWith method provided by @Immutable(copyWith = true) transformation).

At this point if we had a simple code like delivery.copyOn { it.id = '123' } then it would be evaluated as delivery.copyWith [id:'123'] according to simplyCopy and setProperty implementations.

Recursive step

Let's now see how would it work with one more level of copying: delivery.copyOn { it.recipient.name = 'newName' }.

First of all we will set initial values of nextToCopy and copyingClosure while creating CopyingProxy object same way as in the previous example.

Let's now analyse what would happen during first propertyMissing(String propertyName) call. So we would capture current nextToCopy (delivery object), copyingClosure (simple copying based on copyWith) and propertyName (recipient) in a curried function - partialCopy.

Then this copying will be incorporated in a closure

{ object, property, value -> partialCopy(object.copyWith(createMap(property, value))) }

which becomes our new copyingClosure. In the next step this copyingClojure is invoked in the way described in Base Case part.

Conclusion

We have then executed: delivery.recipient.copyWith [name:'newName']. And then the partialCopy applied to the result of that giving us delivery.copyWith[recipient:delivery.recipient.copyWith(name:'newName')]

So it's basically a tree of copyWith method invocations.

On top of that you can see some fiddling with result field and reset function. It was required to support more than one assignments in one closure:

delivery.copyOn { 
    it.recipient.address.street = newStreet
    it.id = 'newId' 
}

Upvotes: 1

Related Questions