Reputation: 339837
I have this simple record in Java (a contrived example):
public record DatePair( LocalDate start , LocalDate end , long days ) {}
I want all three properties (start
, end
, & days
) to be available publicly for reading, but I want the third property (days
) to be automatically calculated rather than passed-in during instantiation.
So I add a static factory method:
public record DatePair( LocalDate start , LocalDate end , long days )
{
public static DatePair of ( LocalDate start , LocalDate end )
{
return new DatePair ( start , end , ChronoUnit.DAYS.between ( start , end ) ) ;
}
}
I want only this static factory method to be used for instantiation. I want to hide the constructor. Therefore, I explicitly write the implicit constructor, and mark it private
.
public record DatePair( LocalDate start , LocalDate end , long days )
{
private DatePair ( LocalDate start , LocalDate end , long days ) // <---- ERROR: Canonical constructor access level cannot be more restrictive than the record access level ('public')
{
this.start = start;
this.end = end;
this.days = days;
}
public static DatePair of ( LocalDate start , LocalDate end )
{
return new DatePair ( start , end , ChronoUnit.DAYS.between ( start , end ) ) ;
}
}
But marking that contractor as private
causes a compiler error:
java: invalid canonical constructor in record DatePair (attempting to assign stronger access privileges; was public)
๐๐ผ How to hide the implicit constructor of a record if the compiler forbids marking it as private
?
Upvotes: 19
Views: 7842
Reputation: 103608
This is essentially impossible, in the sense that this just isn't how records work. You may be able to hack it together but that is very much not what records were meant to do, and as a consequence, if you do, the code will be confusing, the API will need considerable extra documentation to explain it doesn't work the way you think it does, and future lang features will probably make your API instantly obsoleted (it feels out of date and works even more weirdly now).
Records are designed to be deconstructable and reconstructable, and to support these features intrinsically, as in, without the need to write any code to enable any of this. Records just 'get all that stuff', for free, but at a cost: They are inherently defined by their 'parts'. This has all sorts of effects - they cannot extend anything (Because that would mean they are defined by a combination of their parts and the parts their supertype declares, that's more complex than is intended), and the parts are treated as final, and you can't make it act like somehow it isn't actually just a thing that groups together its parts, which is the problem with your code.
Let's make that 'if you try to do it, future language features will ruin your day' aspect of it and focus on deconstruction and the with
concept.
Yes, this mostly isn't part of java yet, but the record feature is specifically designed to be expanded to encompass deconstruction and all the features that it brings, and work is far along. Specifically, Brian Goetz, who is in charge of this feature and various features that expand on a more holistic idea (records is merely a small part of that idea), really loves this stuff and has repeatedly written about it. Including quite complete feature proposals.
Specifically, for records, you are soon going to be able to write this (note, as is usual with OpenJDK feature proposals, don't focus on the syntax or about concerns such as '.. but, does that mean 'with' is now a keyword?' - actual syntax is the very last thing that is fleshed out.
DatePair dp = ....;
DatePair newDp = dp with {
days = 20;
}
See JEP 468: Derived Record Creation (Preview).
The concept of a 'deconstruction' is the same as a constructor, but in reverse: Take an object and break it apart into its constituent parts. For records, this is obvious, and in fact (and this is the key, why you can't do what you want in this way), baked in - records deconstruct by way of the elements you listed for the record (so, here, start
, end
, and days
) and you probably won't be able to change this.
The obj with {block;}
operation is syntax sugar for:
obj
.block
. The local vars are available, and aren't final - change whatever you want.obj
, using those local vars to pass to its constructor.Your idea can only work if the deconstructor deconstructs solely into LocalDate start
and LocalDate end
, leaving long days
out of it entirely. It would require records to be able to state: Actually, this is my constructor, with different arguments from the record's component list, and once you open the door to writing your own constructor, you therefore then also have to write your own deconstructor.
The thing is, there is no syntax for deconstructors right now. Thus, if records did allow you to write your own constructor (with a different list of params than the record components), that means that if in the future a language feature is released such as with
, that requires a deconstructor, that some records can't support it. That's annoying to the java lang team: They'd love to say: "We introduced with
, which works with all records already! In the future once we release the deconstructor feature it should also be usable for classes that have a deconstructor".
That is to say, while I can't say I 100% know exactly what Brian and the OpenJDK team is thinking, I'd be incredibly surprised if they aren't thinking like the above.
The point of the above dive into OpenJDK's plans for near-future java is to explain that [A] why you can't make your own constructor in records, and [B] why there won't be a language feature coming along that will, unless it is part of a set of very significant updates (including deconstructor syntax, and that won't happen unless there are features that use it).
Fortunately, there are very simple alternate strategies you can use here.
This seems to be the best fit for your needs, usable in today's java:
public record DatePair(LocalDate start, LocalDate end) {
public long days() {
return ChronoUnit.DAYS.between(start, end);
}
}
This removes days
from being treated as a component part of DatePair, but then, that is the point - components in records fundamentally can be changed independently of the other parts of it (possibly you can add code that then says the new state is invalid, but now you force folks to set start
and days
both simultaneously, you can't 'calculate it out', which seems like API so bad you wouldn't want such a thing).
It also suffers from the notion that days is now calculated every time instead of being 'cached'. You can solve that by writing your own cache e.g. with guava cachebuilder but that's quite a big bazooka to kill a mosquito. If this truly is the calculation you need, it's relatively cheap, I'd just write it like this and not worry about performance unless you are holding a profiler report that says this days calculation is the key culprit.
If this still isn't acceptable, then the thing you want just isn't what record
represents. You might as well ask how you represent arbitrary strings with an enum
. You just cannot do that - that is not what enums are about. Hence, you end up at:
import lombok.*;
@Value
@lombok.experimental.Accessors(fluent = true)
public class DatePair {
@With private final LocalDate start, end;
@With private final long days;
public DatePair(LocalDate start, LocalDate end) {
this.start = start;
this.end = end;
this.days = ChronoUnit.DAYS.between(start, end);
}
}
I strongly recommend you don't add the accessors line (this turns getStart()
into just start()
. Records notwithstanding, get
is just better (it plays far better with auto-complete which is ubiquitous in java editor environments, and is more common. In fact, the java core libs themselves do it 'right' and use get
, see e.g. java.time.LocalDate
). But, if you really really want it - that's how you do it. Or add a lombok.config
file and say there that you want fluent style accessor names.
Or let your IDE generate it all, which will be a ton of code you'll have to maintain. I understand the 'draw' of using records here, but records can't do lots of things. They can't extend anything either. They can't memoize calculated stuff by way of a field either.
Upvotes: 13
Reputation: 5924
Coming from Scala and using its case class
extensively, I understand how and why you desire the Java record
to behave as similarly as possible.
Alas, there isn't any clean KISS (Keep It Simple & Straightforward) to achieve your desired effects, specifically those around DbC (Design by Contract) and the FP (Functional Programming) concept of an ADT (Algebraic Data Type) Product.
OP Solution
First, let's review your original solution...
public record DatePairOP(
LocalDate start,
LocalDate end,
long days
) {
public static DatePairOP of(
LocalDate start,
LocalDate end
) {
return new DatePairOP(
start,
end,
ChronoUnit.DAYS.between(start, end));
}
}
Pros:
Cons:
days
value is not ensured to be aligned with the provided start
and end
valuesdays
to the method equals()
days
to the method hashCode()
days
value during serializationAdding DbC
We can improve it by adding DbC to it.
public record DatePairDbc(
LocalDate start,
LocalDate end,
long days
) {
//Protects against deserialization attacks
public DatePairDbc {
//prevent construction of invalid instances where the days value is out of
// alignment with the provided start and end values
var daysCalculated = ChronoUnit.DAYS.between(start, end);
if (days != daysCalculated) {
throw new IllegalArgumentException("days [%d] must be equal to ChronoUnit.DAYS.between(start, end) [%d]".formatted(
days,
daysCalculated));
}
}
//While not used by the serializer/deserializer, all other client code is
// encouraged to use this instead of the default constructor.
// This method improves DRY (Don't Repeat Yourself) and eliminates
// accidental incorrect values from being passed.
public static DatePairOP of(
LocalDate start,
LocalDate end
) {
return new DatePairOP(
start,
end,
ChronoUnit.DAYS.between(start, end));
}
}
Pros:
days
value is ensured to be aligned with the provided start
and end
valuesCons:
days
to the method equals()
days
to the method hashCode()
days
value during serializationOr we can ensure it is an adequately defined ADT, Product.
public record DatePairAdtProduct(
LocalDate start,
LocalDate end
) {
public long days() {
return ChronoUnit.DAYS.between(start, end);
}
}
Pros:
start
and end
to the method equals()
start
and end
to the method hashCode()
days
value from being serialized/deserializedCons:
days()
method must (expensively) recompute the value every time it is called; i.e., there is no means to cache the value to save the recomputing expenseSealed Interface by @Holger
public sealed interface DatePairPsiHolger permits DatePairPsiHolgerRecord {
LocalDate start();
LocalDate end();
long days();
static DatePairPsiHolger of(LocalDate start, LocalDate end) {
return new DatePairPsiHolgerRecord(start, end, -1);
}
}
record DatePairPsiHolgerRecord(LocalDate start, LocalDate end, long days) implements DatePairPsiHolger {
DatePairPsiHolgerRecord {
days = ChronoUnit.DAYS.between(start, end);
}
}
Pros:
days
value is ensured to be aligned with the provided start
and end
valuesCons:
days
to the method equals()
days
to the method hashCode()
days
value during serializationExpensive Compute Caching - Leveraging Function/Lambda
UPDATE 2024.09.18: This does not work. Do not use.
Here's a somewhat simple derived properties caching mechanism which leverages the Memoization work at this StackOverflow Answer.
public static <T> Supplier<T> lazyInstantiation(Supplier<T> executeExactlyOnceSupplierT) {
Objects.requireNonNull(executeExactlyOnceSupplierT);
return new Supplier<T>() {
private boolean isInitialized;
private Supplier<T> supplierT = this::executeExactlyOnce;
private synchronized T executeExactlyOnce() {
if (!isInitialized) {
try {
var t = executeExactlyOnceSupplierT.get();
supplierT = () -> t;
} catch (Exception exception) {
supplierT = () -> null;
}
isInitialized = true;
}
return supplierT.get();
}
public T get() {
return supplierT.get();
}
};
}
public record DatePairLambda(
LocalDate start,
LocalDate end,
Supplier<Long> fDaysOverriddenPlaceHolder
) {
public static DatePairLambda from(
LocalDate start,
LocalDate end
) {
return new DatePairLambda(
start,
end,
() -> 1L); //this provided function is ignored and overwritten in the constructor below
}
public DatePairLambda {
//ignore the passed value, and overwrite it with the DbC ensuring function/lambda
fDaysOverriddenPlaceHolder =
lazyInstantiation(() ->
ChronoUnit.DAYS.between(start, end));
}
public long days() {
return fDaysOverriddenPlaceHolder.get();
}
}
Pros:
days
value is ensured to be aligned with the provided start
and end
valuesstart
and end
to the method equals()
start
and end
to the method hashCode()
start
and end
to serialization/deserializationCons:
lazyInstantiation
static functionExpensive Compute Caching - Enhancement Request
Lastly, we can dream in the hopes that the Java Architect Gods were moved to provide a caching mechanism for records that looks something like this. (UPDATE: Reddit answer indicating for sure not before the Withers release)
public record DatePairAdtProductIdealButCurrentlyIllegal(
LocalDate start,
LocalDate end
) {
private transient final long daysInternal = ChronoUnit.DAYS.between(start, end);
public long days() {
return this.daysInternal;
}
}
Pros:
days
value is ensured to be aligned with the provided start
and end
valuesstart
and end
to the method equals()
start
and end
to the method hashCode()
days
value from being serialized/deserializedCons:
Upvotes: -2
Reputation: 19555
The JLS specifies in 8.10.4. Record Constructor Declarations that the canonical constructor must have at least the visibility as the record itself:
Either way, an explicitly declared canonical constructor must provide at least as much access as the record class, as follows:
If the record class is public, then the canonical constructor must be public; otherwise, a compile-time error occurs.
If the record class is protected, then the canonical constructor must be protected or public; otherwise, a compile-time error occurs.
If the record class has package access, then the canonical constructor must not be private; otherwise, a compile-time error occurs.
If the record class is private, then the canonical constructor may be declared with any accessibility.
Otherwise you cannot deserialize the record, as indicated in 8.10. Record Classes:
The serialization mechanism treats instances of a record class differently than ordinary serializable or externalizable objects. In particular, a record object is deserialized using the canonical constructor (ยง8.10.4).
Would be difficult for the serialization mechanism to deserialize such a (visible) class, if it can't call the canonical constructor to initialize the fields. However, you can set the "correct" days
field in the canonical constructor, ignoring the days
parameter coming into the constructor:
public record DatePair( LocalDate start , LocalDate end , long days )
{
public DatePair ( LocalDate start , LocalDate end , long days )
{
this.start = start;
this.end = end;
this.days = ChronoUnit.DAYS.between ( start , end );
}
public static DatePair of ( LocalDate start , LocalDate end )
{
return new DatePair ( start , end , ChronoUnit.DAYS.between ( start , end ) ) ;
}
}
See example on https://www.jdoodle.com/iembed/v0/KEU. You can keep the static DatePair.of()
method for convenience.
Upvotes: 1
Reputation: 12817
How to hide the implicit constructor of a record if the compiler forbids marking it as private?
I would argue that the canonical constructor of a record is very explicit (but that may be semantics). The record specification makes it clear that the canonical constructor is public. Changing that would go against the record's reason to exist.
You can achieve what you want with a regular class.
Upvotes: 1