The code in this answer is a simplified reduction of my full open-source version, which I have published on Github (please look at the unmerged genesis
branch).
Please also checkout a very nicely packaged similar solution available in this StackOverflow Answer.
This answer assumes Java 16 or later
For prior versions of Java, a refactoring is required to remove dependencies to features introduced into Java and the Java platform up to and including Java 16.
Detecting Immutability in Java
Since the Java language and platform provide nothing explicit with which to reliably detect immutability, it must be custom written.
Before rolling a custom Java immutability detector, there are several useful distinctions to understand.
Distinction 1:
There are types of immutability:
- compile-time-vs-runtime
- compile-time immutability, while desirable, isn't (yet?) available with Java
- providing run-time immutability detection has a significant impact on enhancing code comprehension, reducing reasoning efforts, and enhancing security
- shallow-vs-deep
- shallow - a Java
record
- middle - a Java
enum
- deep - java.lang.
String
- total-vs-effective
- class-level versus instance-level immutability
- deals with the runtime timing of when the instance becomes irreversibly immutable
- verified-vs-trusted
- deals with reversing an instance's immutability via Java reflection (innocent or malicious)
Detecting immutability in Java isn't an either/or. It is predicated on a spectrum of anticipated trust. To the degree one has control of one's environment correlates strongly to the degree the trust of any detected immutability. This trust continuum has been discretized into the Java enum
, ImmutabilityType
, below. The enum
enables client code to decide upon what level of trust it would prefer to rely.
While it would be highly desirable to a large number of software engineers, Java does not provide compile-time enforced immutability detection. As an analog to Rust's beautiful memory management solution to C/C++'s undesirable memory utilization potholes, it would be a huge leap forward if/when Java acquired compile-time enforced immutability detection. However, this doesn't currently appear on any known Java roadmaps (as of 2023/Jan).
Distinction 2:
The use of the word "calcification" will describe the process of an instance moving from a mutable (writable or fluid) to an irreversibly immutable (read-only or calcified) state.
Every instantiation of a class involves a period of time where the instance is mutable. IOW, the default state of an instance, upon creation, is mutability. This means it is the responsibility of the class designer to decide if, and then when, the class becomes irreversibly immutable. If desiring immutability, this actually simplifies to deciding whether the instance remains mutable past the end of the execution of its constructor.
Distinction 3:
Because immutability is about permanently calcifying the state of an instance of a class, it is important to distinguish at run-time BY WHEN the state has become irreversibly immutable.
The archetype, or ideal, for implementing immutability is to design the state to be calcified by the end of the execution of the class's constructor for a given instance. However, there are contexts where it is required to delay the calcification until sometime after the constructor completes execution. Whether this is due to poor design choices is outside the scope of this response, as the use cases exist and must be addressed.
Distinction 4:
Due to the implications of the last distinction, detecting immutability must be done at each of the class and the instance levels. The description of class level immutability applies to all instance of that class. The description of instance level immutability applies to some subset of instances from their parent class.
As such, defining two different functions is needed for detecting immutability. One which takes a Class<?>
as a parameter and returns the class level of immutability. And another which takes an Object
instance as a parameter returns the instance-level of immutability at that point during runtime.
Working on the problem space
public enum ImmutabilityType {
UNDEFINED, //unable to assert any trustable degree of deep immutability
PROPERTY_IRREVERSIBLY_IMMUTABLE, //trusting a post-instantiation window of mutability requiring an ImmutableEffective.isIrreversiblyImmutable() style of check
BEFORE_END_OF_CLASS_CONSTRUCTOR, //trusting the implementor to properly enforce the Immutable contract, see Immutable interface Javadoc
JAVA_CONSTANT //trusting the Java compiler and platform
}
This Java enum
describes the kinds of immutability detected.
The JavaDoc for the "immutability contract" referenced by the comment following the ImmutabilityType.BEFORE_END_OF_CLASS_CONSTRUCTOR
enum instance is located here.
Two Target Functions:
Using the ImmutabilityType
enum as their return type, the two target functions are defined below. The first function, classIs()
, detects class-level immutability. And the second function, instanceIs()
, detects instance-level immutability.
public static ImmutabilityType classIs(Class<?> class___) { ... }
public static ImmutabilityType instanceIs(Object o) { ... }
To implement the two target functions, the infrastructure supporting them must be defined.
Subset of well-known Java Platform immutable classes:
private static final Set<Class<?>> SUBSET_OF_KNOWN_JAVA_PLATFORM_IMMUTABLE_CLASSES =
Set.of(
//Meta-classes
Class.class, MethodType.class,
//primitive class wrappers
Boolean.class, Byte.class, Short.class, Character.class, Integer.class, Long.class, Float.class, Double.class,
//classes
String.class, BigInteger.class, BigDecimal.class
);
This is just a small subset of the many immutable classes defined in the Java platform. Many more can and should be added, like java.time.ZonedDateTime
, etc.
Java Platform Types:
public static Optional<ImmutabilityType> classIsJavaConstant(Class<?> class___) {
return SUBSET_OF_KNOWN_JAVA_PLATFORM_IMMUTABLE_CLASSES.contains(class___)
|| Constable.class.isAssignableFrom(class___) //Covers: MethodHandle
|| ConstantDesc.class.isAssignableFrom(class___) //Covers: ClassDesc, MethodTypeDesc, MethodHandleDesc, and DynamicConstantDesc
|| class___.isEnum()
? Optional.of(ImmutabilityType.JAVA_CONSTANT)
: Optional.empty();
}
It is somewhat risky to include the class___.isEnum()
check. A Java enum is technically too open to assume by-default-immutabililty, even though the vast majority of its actual in the field usages are for the default enum which is immutable at the level of the Java platform.
While this particular implementation example doesn't offer the option, a design enhancement would enable some form of registering of an enum as mutable (ImmutabilityType.UNDEFINED
), overriding the return value from this function.
Java Record:
private static ImmutabilityType internalClassIsRecord(Class<Record> classRecord) {
var resolvedImmutabilityType = ImmutabilityType.JAVA_CONSTANT;
var recordComponents = Arrays.stream(classRecord.getRecordComponents()).iterator();
while ((resolvedImmutabilityType != ImmutabilityType.UNDEFINED) && recordComponents.hasNext()) {
var recordComponent = recordComponents.next();
if (!recordComponent.getType().isPrimitive()) { //within a Record, a primitive is always immutable
var immutabilityType = classIs(recordComponent.getType());
if (immutabilityType.ordinal() < resolvedImmutabilityType.ordinal())
resolvedImmutabilityType = immutabilityType;
}
}
return resolvedImmutabilityType;
}
public static Optional<ImmutabilityType> classIsRecord(Class<?> class___) {
return class___.isRecord()
? Optional.of(internalClassIsRecord((Class<Record>) class___))
: Optional.empty();
}
Java records only provide shallow immutability. To detect deep immutability, the record must be recursively validated. The recursion occurs with the call to classIs(recordComponent.getType())
(defined further down) in the function internalClassIsRecord
.
Via JCIP Annotation or Marker Interfaces:
public interface Immutable {}
public interface ImmutableEffective {
boolean isIrreversiblyImmutable();
}
public static Optional<ImmutabilityType> classIsJcipAnnotationOrImmutableInterface(Class<?> class___) {
return class___.isAnnotationPresent(net.jcip.annotations.Immutable.class) || Immutable.class.isAssignableFrom(class___)
? Optional.of(ImmutabilityType.BEFORE_END_OF_CLASS_CONSTRUCTOR)
: Optional.empty();
}
public static Optional<ImmutabilityType> classIsImmutableEffective(Class<?> class___) {
return ImmutableEffective.class.isAssignableFrom(class___)
? Optional.of(ImmutabilityType.PROPERTY_IRREVERSIBLY_IMMUTABLE)
: Optional.empty();
}
Using everything defined above, it's time to implement the two target functions.
Class-oriented classIs
function:
public static ImmutabilityType classIs(Class<?> class___) {
return class___.isArray()
? ImmutabilityType.UNDEFINED //java.lang.Array is always mutable
: classIsJavaConstant(class___)
.or(() -> classIsRecord(class___))
.or(() -> classIsJcipAnnotationOrImmutableInterface(class___))
.or(() -> classIsImmutableEffective(class___))
.orElse(ImmutabilityType.UNDEFINED);
}
Instance-oriented instanceIs
function:
private static boolean internalIsRecordHasAtLeastOneIrreversiblyImmutableStillUnset(Record record) throws InvocationTargetException, IllegalAccessException {
boolean result = false; //when set to true, it has hit an isIrreversiblyImmutable() that remains false
var classRecord = record.getClass();
var recordComponents = Arrays.stream(classRecord.getRecordComponents()).iterator();
while (!result && recordComponents.hasNext()) {
var recordComponent = recordComponents.next();
if (ImmutableEffective.class.isAssignableFrom(recordComponent.getType())) {
var object = recordComponent.getAccessor().invoke(record);
result = !((ImmutableEffective)object).isIrreversiblyImmutable();
} else if (recordComponent.getType().isRecord()) {
result = internalIsRecordHasAtLeastOneIrreversiblyImmutableStillUnset((Record)recordComponent.getAccessor().invoke(record));
}
}
return result;
}
public static ImmutabilityType instanceIs(Object o) {
var result = classIs(o.getClass());
if (result == ImmutabilityType.PROPERTY_IRREVERSIBLY_IMMUTABLE)
if (o instanceof ImmutableEffective immutableEffective) {
if (!immutableEffective.isIrreversiblyImmutable())
result = ImmutabilityType.UNDEFINED;
} else
if (o instanceof Record record) {
boolean hasAtLeastOneIrreversiblyImmutableStillUnset;
try {
hasAtLeastOneIrreversiblyImmutableStillUnset = internalIsRecordHasAtLeastOneIrreversiblyImmutableStillUnset(record);
if (hasAtLeastOneIrreversiblyImmutableStillUnset)
result = ImmutabilityType.UNDEFINED;
} catch (InvocationTargetException | IllegalAccessException ignored) {
result = ImmutabilityType.UNDEFINED; //unable to resolve
}
} else
result = ImmutabilityType.UNDEFINED; //unable to resolve
return result;
}
Example usages:
//Setup definitions
static class ImmutableEffectiveImpl implements ImmutableEffective {
private boolean irreversiblyImmutable;
public ImmutableEffectiveImpl(boolean irreversiblyImmutable) {
this.irreversiblyImmutable = irreversiblyImmutable;
}
public boolean isIrreversiblyImmutable() {
return irreversiblyImmutable;
}
public void setAsIrreversiblyImmutable() {
irreversiblyImmutable = true;
}
}
@net.jcip.annotations.Immutable
public static class JcipAnnotationImmutable {}
//Code to produce output
Stream.of(
new Object(),
new ImmutableEffectiveImpl(false),
new ImmutableEffectiveImpl(true),
new JcipAnnotationImmutable(),
new Immutable() {},
"Jim"
).forEachOrdered(
o -> {
var oC = o.getClass();
var className = oC.toString().substring(6);
System.out.println(className + " - " + o);
System.out.println(" isClass(): " + classIs(oC));
System.out.println(" isInstance(): " + instanceIs(o));
}
);
And below is what the above code produces.
Please specifically notice the contrast between line 6 (isInstance(): UNDEFINED
) and line 9 (isInstance(): PROPERTY_IRREVERSIBLY_IMMUTABLE
).
This is where the difference in the returned ImmutabilityType
s is exhibited between the class-level and instance-level function calls.
java.lang.Object - java.lang.Object@6e8cf4c6
isClass(): UNDEFINED
isInstance(): UNDEFINED
immutability_so.Main$ImmutableEffectiveImpl - immutability_so.Main$ImmutableEffectiveImpl@66a29884
isClass(): PROPERTY_IRREVERSIBLY_IMMUTABLE
isInstance(): UNDEFINED
immutability_so.Main$ImmutableEffectiveImpl - immutability_so.Main$ImmutableEffectiveImpl@4769b07b
isClass(): PROPERTY_IRREVERSIBLY_IMMUTABLE
isInstance(): PROPERTY_IRREVERSIBLY_IMMUTABLE
immutability_so.Main$JcipAnnotationImmutable - immutability_so.Main$JcipAnnotationImmutable@cc34f4d
isClass(): BEFORE_END_OF_CLASS_CONSTRUCTOR
isInstance(): BEFORE_END_OF_CLASS_CONSTRUCTOR
immutability_so.Main$1 - immutability_so.Main$1@277050dc
isClass(): BEFORE_END_OF_CLASS_CONSTRUCTOR
isInstance(): BEFORE_END_OF_CLASS_CONSTRUCTOR
java.lang.String - Jim
isClass(): JAVA_CONSTANT
isInstance(): JAVA_CONSTANT
The code in this answer is a simplified reduction of my full open-source version, which I have published on Github (please look at the unmerged genesis
branch).
Please also checkout a very nicely packaged similar solution available in this StackOverflow Answer.