Java record serialization and repeated calls to canonical constructor
Asked Answered
D

1

5

In this post about serializable records it is stated that

Deserialization creates a new record object by invoking a record class’s canonical constructor, passing values deserialized from the stream as arguments to the canonical constructor. This is secure because it means the record class can validate the values before assigning them to fields, just like when an ordinary Java program creates a record object via new. “Impossible” objects are impossible.

This argues with a constructor that is used for validation only. However, when the constructor manipulates the arguments this results in rather strange behavior. Consider this very artificial simple example:

The following record manipulates a before saving it:

import java.io.Serializable;

public record TRecord (int a) implements Serializable {
    public TRecord {
        a = a-1;
    }
}

And the following program just saves the serialized record the first time and loads it the subsequent times:

import java.io.*;

public class TestRecords {

    public static void main(String args[]) {
        TRecord a1 = null;

        try {
            FileInputStream fileIn = new FileInputStream("tmp");
            ObjectInputStream in = new ObjectInputStream(fileIn);
            a1 = (TRecord) in.readObject();
            in.close();
            fileIn.close();
        } catch (IOException | ClassNotFoundException i) {
            // ignore for now
        }
        if (a1 == null) {
            try {
                a1 = new TRecord(5);
                FileOutputStream fileOut = new FileOutputStream("tmp");
                ObjectOutputStream out = new ObjectOutputStream(fileOut);
                out.writeObject(a1);
                out.close();
                fileOut.close();
                System.out.printf("Serialized data is saved in /tmp/employee.ser");
            } catch (IOException i) {
                i.printStackTrace();
            }
        }

        System.out.println(a1);
    }
}

The output for the first run is TRecord[a=4], and TRecord[a=3] in subsequent runs, so the state that I get from deserialization differs from what I put in there. Using a comparable class like the following instead would have gotten me the same result TClass[a=4] every time.

import java.io.Serializable;

public class TClass implements Serializable {
    private int a;

    public TClass(final int a) {
        this.a = a-1;
    }

    public int getA() {return a;}

    public String toString() {
        return "Class[" + a + "]";
    }
}

So my question is: Is there any rule for records that forbids/discourages using the constructor for anything other than validations (I am thinking for example about hashing a password before storing the input)? Or is there another way to deserialize an object so that the initial state is restored?

Demmy answered 10/5, 2021 at 21:35 Comment(1)
In addition to validation, it is allowable for the constructor to normalize the component values, such as reducing the numerator and denominator of rational numbers to lowest form.Feedback
T
14

If you look at the documentation for records it says the following:

For all record classes, the following invariant must hold: if a record R's components are c1, c2, ... cn, then if a record instance is copied as follows:

 R copy = new R(r.c1(), r.c2(), ..., r.cn());  

then it must be the case that r.equals(copy).

This is not the case for your record class however:

jshell> TRecord r1 = new TRecord(42);
r1 ==> TRecord[a=41]

jshell> TRecord copy = new TRecord(r1.a());
copy ==> TRecord[a=40]

jshell> r1.equals(copy)
$4 ==> false

In other words, your record type violates this invariant, and this is also the reason why you are seeing the inconsistent deserialization.

Tiger answered 10/5, 2021 at 21:59 Comment(3)
There are a few good reasons why you want to change component - the most common example is doing something akin to a = List.copyOf(a);. This does not invalidate the invariant, as a.equals(List.copyOf(a)). (Well, if the input List conforms to it's contract.)Wilfredowilfrid
@JohannesKuhn Exactly. The invariant was constructed to allow this case (normalization, defensive copies.) It may be necessary to override equals to preserve the invariant (e.g., arrays defensively copied need to be compared by contents.)Feedback
If you want to operate on the arguments before storing them as record components, you could create a static factory that does just that. Unfortunately, because records require their canonical constructor be at least as public as their class, it is difficult to require users to use a static factory instead of a constructor. If that is important, you may want to package-scope the record, exposing a public interface and public factory method instead.Devereux

© 2022 - 2024 — McMap. All rights reserved.