12 March 2019
CATEGORY
FunctionalJ.io
TAGS
FunctionalJ.ioImmutable

Immutable data with FunctionalJ.io

Create immutable data object with FunctionalJ's Struct

Introduction

Immutability is an important principle of functional programming. Mutable objects hide changes. Hidden changes can lead to unpredictability and chaos.

FunctionalJ provides ways to create and manipulate immutable data. In this article, I discuss @Struct which generates custom immutable classes. On the surface, it is very similar in concept with Lombok's @Value, FunctionalJ's @Struct comes with its own unique features such as:

  • Compact form
  • Non-required fields and default values
  • Immutable modification
  • Lens
  • Exhaustive builder
  • Validation

Let explore these features!

NOTE: The companion VDO showing all this in action can be found on youtube -- here.

@Struct

FunctionalJ has a mechanism to create immutable data objects using @Struct annotation. This can be done in two forms: an expand form and a compact form. The expanded and form allows an opportunity to add additional methods. For brevity, we will use the compact form when possible. The following code shows how to define a struct using the compact form.

  package pkg;
  public class Models {
      @Struct
      void Person(String firstName, String lastName) { }
  }

Notice that @Struct is annotated on Person which is just a method. I call this annotated method a specification method (a Kotlin and Scala envy :-p). Specification methods can be in any class or interface. In this case, we put Person method in the class named Models which should make it is easy to locate.

With the above code, FunctionalJ generates a class called Person in the same package with this code (pkg package). This class has two fields: firstName and lastName.

With that, we can instantiate a Person object using its constructor.

  val person = new Person("John", "Doe");
  assertEquals("Person[firstName: John, lastName: Doe]", person.toString());

Noted that I use Lombok's val for brevity.

Common Methods

Common object methods such as toString(), hashCode() and equals(...) are automatically generated. The code above shows how toString() might return and the following code demonstrates that hashCode() and equals(...) behave as expected.

  val person1 = new Person("John", "Doe");
  val person2 = new Person("John", "Doe");
  val person3 = new Person("Jane", "Doe");
  assertTrue(person1.hashCode() == person2.hashCode());
  assertTrue(person1.equals(person2));
  assertFalse(person1.hashCode() == person3.hashCode());
  assertFalse(person1.equals(person3));

Accessing a Field

The fields can be accessed using its getter which is just the method with the same name.

  val person = new Person("John", "Doe");
  assertEquals("John", person.firstName());
  assertEquals("Doe",  person.lastName());

Changing a Field Value

Since the object is immutable, there is no way to actually change the value of the field in the object. So to change the field value, we create another object with the new field value (I call this "immutable modification" -- creating a new instance with the modification). The method withXXX(...) can be used to do just that.

  val person1 = new Person("John", "Doe");
  val person2 = person1.withLastName("Smith");
  assertEquals("Person[firstName: John, lastName: Doe]",   person1.toString());
  assertEquals("Person[firstName: John, lastName: Smith]", person2.toString());

In the code above, person2 is person1 with the new last name.

Null and Default Values

By default, null is not allowed as the property value. NullPointerException will be thrown if null is given as the field value.

  try {
      new Person("John", null);
      fail("Expect an NPE.");
  } catch (NullPointerException e) {
  }

In order to allow the field to accept null, the field must be annotated with @Nullable (functionalj.types.Nullable). So let say we add middleName field to the Person class and make it nullable.

  @Struct
  void Person(String firstName, @Nullable String middleName, String lastName) { }

Now, you can use null to specify the middle name.

  val person = new Person("John", null, "Doe");
  assertEquals("Person[firstName: John, middleName: null, lastName: Doe]", person.toString());

With this nullable field, we got another constructor that only have required fields.

  val person = new Person("John", "Doe");
  assertEquals("Person[firstName: John, middleName: null, lastName: Doe]", person.toString());

We can also give the fields default values by annotating with DefaultTo(...). Let say we want to add age field to the Person class and default it to -1.

  @Struct
  void Person(
          String firstName,
          @Nullable
          String middleName,
          String lastName,
          @DefaultTo(DefaultValue.MINUS_ONE)
          Integer age) {
  }

So now we can create person with either a value or null (to use default value).

  // With value
  val person1 = new Person("John", null, "Doe", 30);
  assertEquals("Person[firstName: John, middleName: null, lastName: Doe, age: 30]", person1.toString());
  
  // With default value
  val person2 = new Person("John", null, "Doe", null);
  assertEquals("Person[firstName: John, middleName: null, lastName: Doe, age: -1]", person2.toString());

Of course, the constructor with only the required field is still there.

  val person = new Person("John", "Doe");
  assertEquals("Person[firstName: John, middleName: null, lastName: Doe]", person.toString());

Lens

A Lens is a function that allows access to a field for both reading and changing (using withXXX(...)). Just like functions in FunctionalJ, Lenses are greatly composable - you can use it to access deep into the sub-object. Consider the following code:

  @Struct
  void Employee(
          String firstName,
          @Nullable
          String middleName,
          String lastName) { }
          
  @Struct
  void Department(
          String   name,
          Employee manager) { };

Now you can use the lens to access the field in employee.

  import static pkg.Employee.theEmployee;
  ...
  
  val employee1 = new Employee("John", "Doe");
  assertEquals("John", theEmployee.firstName.apply(employee1));
  assertEquals("Doe",  theEmployee.lastName .apply(employee1));
  
  val employee2 = theEmployee.firstName.changeTo("Jonathan").apply(employee1);
  assertEquals("Employee[firstName: Jonathan, middleName: null, lastName: Doe]", employee2.toString());

Notice the static import for theEmployee. Another word, the lens is created as a static final field of the generated class.

Using lenses, it is possible to quickly access to field in the employee from the department.

  import static pkg.Department.theDepartment;
  import static pkg.Employee.theEmployee;
  ...
  
  val employee   = new Employee("John", "Doe");
  val department = new Department("Sales", employee);
  assertEquals(
          "Department[name: Sales, manager: Employee[firstName: John, middleName: null, lastName: Doe]]",
          department.toString());
  
  // Read
  assertEquals("John", theDepartment.manager.firstName.apply(department));
  assertEquals("Doe",  theDepartment.manager.lastName .apply(department));
  
  // Change
  val department2 = theDepartment.manager.firstName.changeTo("Jonathan").apply(department);
  assertEquals(
          "Department[name: Sales, manager: Employee[firstName: Jonathan, middleName: null, lastName: Doe]]",
          department2.toString());

This is more useful when using it with stream or FuncList. The following code extracts the list of manager family name.

  val departments = FuncList.of(
          new Department("Sales",   new Employee("John", "Doe")),
          new Department("R-and-D", new Employee("John", "Jackson")),
          new Department("Support", new Employee("Jack", "Johnson"))
  );
  assertEquals("[Doe, Jackson, Johnson]", departments.map(theDepartment.manager.lastName).toString());

Another example code gets the list of the department name with the manager last name but only when his name is "John".

  val departments = FuncList.of(
          new Department("Sales",   new Employee("John", "Doe")),
          new Department("R-and-D", new Employee("John", "Jackson")),
          new Department("Support", new Employee("Jack", "Johnson"))
  );
  assertEquals("[(Sales,Doe), (R-and-D,Jackson)]",
          departments
              .filter  (theDepartment.manager.firstName.thatEquals("John"))
              .mapTuple(theDepartment.name, theDepartment.manager.lastName)
              .toString());

See "Access and Lens" for more detail.

Builder

A struct is also comes with a builder. This builder is exhaustive meaning that all require fields are provided.

  val person = new Person.Builder()
          .firstName("John")
          .lastName("Doe")
          .build();
  assertEquals("Person[firstName: John, middleName: null, lastName: Doe, age: -1]", person.toString());

You can also put in non-required fields.

  val person = new Person.Builder()
          .firstName ("John")
          .middleName("F")
          .lastName  ("Doe")
          .build();
  assertEquals("Person[firstName: John, middleName: F, lastName: Doe, age: -1]", person.toString());

Using the builder makes it easy to see the name of the field and its value. Exhaustive builder can help reduce mistake as a compilation error will be raised when non-required fields are not given (like in the case of a newly added field). One limitation of this is that the fields must be given in order specified in the specification method.

Validation

Without setters, there is no direct way to ensure that the new value given is valid. This can be a big problem as the object might become inconsistent. To solve that, FunctionalJ makes it easy to ensure that the instantiated objects are valid. It provides 3 ways of doing validation for Struct. These ways differ in the way the exception is created.

The first way is to have the spec method return boolean indicating if the parameters are all valid.

  @Struct
  static boolean Circle(int x, int y, int radius) {
      return radius > 0;
  }
  
  val validCircle = new Circle(10, 10, 10);
  assertEquals("Circle[x: 10, y: 10, radius: 10]", validCircle.toString());
  
  try {
      val invalidCircle = new Circle(10, 10, -10);
      fail("Except a ValidationException.");
  } catch (ValidationException e) {
      assertEquals(
              "functionalj.result.ValidationException: Circle: Circle[x: 10, y: 10, radius: -10]", 
              e.toString());
  }

Notice that the specification method Circle now return boolean and is static. It is made static because the generated class will call this method.

If the radius is not negative, the circle is created without any problem. If the radius, on the other hand, is negative, a ValidationException is thrown with automatically generated message. This should be sufficient in most cases.

If a custom message is needed, the second way can be used and that is to make the specification method returns String message of the problem or null when valid.

  @Struct
  static String Circle(int x, int y, int radius) {
      return radius > 0 ? null : "Radius cannot be less than zero: " + radius;
  }
  
  try {
      new Circle(10, 10, -10);
      fail("Except a ValidationException.");
  } catch (ValidationException e) {
      assertEquals(
              "functionalj.result.ValidationException: Radius cannot be less than zero: -10", 
              e.toString());
  }

In this case, a ValidationException with the message returned by the specification method is thrown when the struct is invalid. If this is still not enough, for example, you want to return custom exception type, the third way can be utilized.

  @Struct
  static ValidationException Circle3(int x, int y, int radius) {
      return radius > 0
              ? null
              : new NegativeRadiusException(radius);
  }
  
  @SuppressWarnings("serial")
  public class NegativeRadiusException extends ValidationException {
      public NegativeRadiusException(int radius) {
          super("Radius: " + radius);
      }
  }
  
  try {
      new Circle3(10, 10, -10);
      fail("Except a ValidationException.");
  } catch (ValidationException e) {
      assertEquals(
              "pkg.NegativeRadiusException: Radius: -10",
              e.toString());
  }

Additional Functionalities

So far, we only generate struct class that only have value and default methods. If there is a need for additional methods or to make the generated class extending or implementing some classes/interfaces, we will need to use the extended form.

For example, an abstract class called Greeter that can greet people.

  public abstract class Greeter {
      
      public abstract String greetWord();
      
      public String greeting(String name) {
          return greetWord() + " " + name + "!";
      }
  }

Then you can create a type spec that extends Greeter.

  @Struct
  static abstract class FriendlyGuySpec extends Greeter {
      public abstract String greetWord();
  }

This will generate a class called FriendlyGuy in the same package (the name will be from the specification class name less "Spec" or "Model"). The generated class FriendlyGuy extends Greeter and inherits all methods.

  Greeter fiendlyGuy = new FriendlyGuy("Hi");
  assertEquals("Hi Bruce Wayne!", fiendlyGuy.greeting("Bruce Wayne"));

New methods can be added to the generated class by just adding them to the specification class.

  @Struct
  static abstract class FriendlyGuySpec extends Greeter {
      public abstract String greetWord();
      public void shakeHand() {
          ...
      }
  }

Basically, the generated class FiendlyGuy extends FriendlyGuySpec which intern extends Greeter.

Conclusion

With @Struct, it is much easier to have immutable data classes. There is now less excuse to not using immutable data. Also, I found myself, more and more, writing a component in one java file with all necessary data classes in that one file, which is much easier to comprehend. I hope you find these functionalities useful and any feedback is welcome. :-)

Happy coding!
Nawa Man

Note: To use @Struct with Eclipse, you may want to look at this article to set it up.

Comments

Thank you for keeping the comment section positive, constructive and respectful environment. I do appreciate constructive criticism & respectful disagreement! I have ZERO tolerant for disrespect, harassment, threats, cyber-bullying, racism, sexism attacks, foul language, and spam. Comments will be actively moderated and abusive users will be blocked. Keep it civil! :-)