Immutable Data With FunctionalJ.io
With @Struct, it is much easier to have immutable data classes.
Join the DZone community and get the full member experience.
Join For FreeImmutability is an important principle of functional programming. Mutable objects hide changes. And 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. However, 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: A companion VDO can be found on youtube.
@Struct
FunctionalJ has a mechanism to create immutable data objects using the @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!). Specification methods can be in any class or interface. In this case, we put the 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());
Please note 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's 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's 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
. In other words, the lens is created as a static final field of the generated class.
Using lenses, it is possible to quickly access a 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" in this link 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. The exhaustive builder can help reduce a 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 three 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 returns 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 an 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 return a 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 a struct
class that only has 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 the intern extends Greeter.
Conclusion
With @Struct
, it is much easier to have immutable data classes. There is now no excuse to not use immutable data. Also, I found myself writing a component in one Java file more and more, with all the necessary data classes in that one file, which is much easier to comprehend. I hope you find these functionalities useful and any feedback is always welcome!
Happy coding!
Published at DZone with permission of Nawa Manusitthipol. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments