Records

A concise way to define classes with immutable objects.

Let’s write a class

We want instances of this class to be immutable.

public class Point {

  private final int x;
  private final int y;

  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }
}

We’ll need some getters

public class Point {

  // other stuff ...

  public int x() {
    return x;
  }

  public int y() {
    return y;
  }

}

And some other amenities would be nice

  • toString

  • equals

  • hashCode

Default toString not that great

new Point(10, 20).toString() => "Point@133e16fd"

Would be nice to give our objects a human-readable String format. like:

new Point(10, 20).toString() ⟹ "Point[x=10, y=20]"

toString

public class Point {

  // other stuff ...

  @Override public String toString() {
    return "Point[x=" + x + ", y=" + y + "]";
  }

}

equals

If we want to consider two different Point objects with the same x and y values to be equivalent, we want to override equals.

In particular, if we want to be able to use Points as keys in a HashMap, we need equivalent Points to be equals.

equals is hard to get right

The equals contract is actually kind of tricky to get right in ways we don’t talk about in CSA.

There are five characteristics that a correct equals method must have.

Reflexive

x.equals(x), i.e. every object is equals to itself.

Symmetric

x.equals(y) if and only if y.equals(x).

Transitive

If x.equals(y) and y.equals(z), then x.equals(z).

Consistent

Multiple invocations of x.equals(y) return the same value, provided no information used in the equals comparison on the objects is modified.

Defining equals on mutable objects is a bit fraught.

Not equals to null

For any non-null x, x.equals(null) should return false.

(And if x is null we’d get a NullPointerException.)

Why this matters

The equals method is used in lots of places (e.g. List.contains()) and those places may depend on one or more aspects of this contract being fulfilled.

equals implementation

pubic class Point {

  // other stuff ...

  @Override public boolean equals(Object o) {
    if (this == o) return true;
    if (o instanceof Point other) {
      return x == other.x && y == other.y;
    } else {
      return false;
    }
  }

 }

P.S. Don’t try to override equals in a subclass of Point as you’ll almost certainly violate the symmetry requirement.

hashCode

In CSA you learned about equals but not hashCode.

But in practice they go together and whenever you override equals you also need to override hashCode.

But why?

Remember HashMap?

A HashMap gives almost constant time lookup of arbitrary keys, i.e. about as efficient as indexing into an array and potentially much faster than even a binary search.

How does it work?

Really short version

We want to store n key/value pairs so we can look up a value by its key.

And we want equals key objects to be considered the same key.

Suppose we could map every key to an int index in the range [0, n).

Then we could just use an n-element array, storing each pair at the index for the key.

Collisions

But if there are more than n keys, by the pigeonhole principle there are at least some not-equals keys that would be assigned the same index.

Instead of storing the key/value pairs directly in the array, at each spot in the array store a list of key value pairs.

To look up a key in the table, use the key’s index to find the right spot in the array and then look through the list until we find the pair whose key is equals to the one we are looking for.

Performance

As long as there aren’t too many collisions, this is about as fast as a simple array lookup.

If we end up with too many collisions, we can reduce the number of collisons by making a bigger array and moving every key/value pair to the correct bucket in the bigger array, based on the new remainder.

How to get the index

If we can assign a int value to every key, lets call it h, such that most objects have different hs we can use Math.floorMod(h, array.length) to get an index into an array where that key should be stored.

That’s what hashCode is for; it returns the int whose modulus we get based on the size of the array in the HashMap.

Requirements of hashCode

Main requirement is that it be “consistent with equals”, i.e. two objects that are equals must return the same value from hashCode.

But good hashCode implementations also maximize the chance that objects that are not equals will have different hash codes.

hashCode

A reasonable hashCode method incorporates information from all the fields that are used in the equals method, mushing up the bits to be fairly random seeming.

public class Point {

  // other stuff ...

  @Override public int hashCode() {
    return 31 * x + y;
  }

}

Back to records

A proper immutable Point class requires:

  • Instance variables

  • A constructor

  • Getters for all the instance variables

  • A toString method

  • And correct equals and hashCode methods

Full code

public final class Point {

  private final int x;
  private final int y;

  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int x() {
    return x;
  }

  public int y() {
    return y;
  }

  @Override public boolean equals(Object o) {
    if (this == o) return true;
    if (o instanceof Point other) {
      return x == other.x && y == other.y;
    } else {
      return false;
    }
  }

  @Override public int hashCode() {
    return 31 * x + y;
  }

  @Override public String toString() {
    return "Point[x=" + x + ", y=" + y + "]";
  }
}

Forty-three lines.

Or we could just write this

public record Point(int x, int y) {}

This gives us everything we wrote above for free.

But this is actually a class

We can …

  • Add methods and constructors

  • Add static variables

  • Implement interfaces

Add some stuff

public record Point(int x, int y)
  implements Comparable<Point> {

  public static final Point ORIGIN = new Point(0, 0);

  public double distanceFrom(Point other) {
    return Math.hypot(x - other.x, y - other.y);
  }

  public int compareTo(Point other) {
    var byX = Integer.compare(x, other.x);
    return byX != 0 ? byX : Integer.compare(y, other.y);
  }
}

Augmenting the constructor

We automatically get a constructor whose signature matches the list of fields in the record. But we can augment it like this:

public record Zoo(List<Animal> animals) {
  public Zoo {
    var toSort = new ArrayList<>(animals);
    Collections.sort(toSort);
    animals = Collections.unmodifiableList(toSort);
    // Actual assignment to field happens here
    // Implicitly: this.animals = animals
  }
}

Adding constructors

Or we can add other constructors with different signatures.

public record Point(int x, int y)
  implements Comparable<Point> {

  public Point() { this(0, 0); }

  // other stuff as before

}

When to use records

Whenever you want immutable objects.

So, often.

However, records are mostly used to hold data and usually don’t have a ton of complex behaviors.

Records are also commonly defined as nested classes.

Example code

public class DB {

  public record Book(String title, int pages, String authorName) {}

  public record Author(String name, String birthday, boolean alive) {}

}

Exercise

Define at least two record classes that are related in some way.

Populate collections of each kind of record.

Write some methods that return collections of records that match some criteria.

Write some methods that return collections of records from one collection based on criteria applied to related records from another collection, e.g. books by living authors.

Hints

You may want to use streams. But not required.

You way want to use some of the generic function interfaces.

You can also define local records within a method which may be useful for temporarily combining values from two different record classes for use in a stream pipeline.