How to hide constructor on a Java record that offers a public static factory method?
Asked Answered
P

4

11

I have this simple record in Java:

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?

Proffitt answered 5/8, 2023 at 21:2 Comment(1)
The constructor is part of the interface of an record. Removing it would break all kinds of deserialization frameworks that expect a public canonical constructor.Wolfie
O
9

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).

The essential nature of records

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;
}

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:

  • Deconstruct obj.
  • For each item that the deconstruction has produced, declare a local variable.
  • Run block. The local vars are available, and aren't final - change whatever you want.
  • Construct a new object of the same type as 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).

Good news!

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.

Overlive answered 5/8, 2023 at 21:43 Comment(5)
Alternatively, create a public sealed interface declaring the static factory method and the accessor methods and a non-public record type as the only implementation.Christensen
@Holger, I have added an answer where I explicitly thought out and implemented your suggestion of using a public sealed interface. If you have time to review it, could you please look through it and let me know if it aligns with what you were thinking? https://mcmap.net/q/1003931/-how-to-hide-constructor-on-a-java-record-that-offers-a-public-static-factory-methodBoreas
The initial assertion in this Answer is incorrect; i.e. "This is essentially impossible, in the sense that this just isn't how records work." @holger's comment effectively falsifies it. The author of the answer appears to be unaware the Java architects explicitly designed the Java record to be as close to a FP ADT Product as they could.Boreas
@Boreas No, the statement is correct. I have no idea what you are talking about. Hiding the entire type doesn't mean the constructor is now hidden somehow, and I use the word 'essentially' to cover exactly that kind of pedantic hackery, and it really is simply not how records work. Throwing fancy terms around isn't going to change basic facts here.Overlive
@Overlive Attempting to downplay both DbC and FP ADT Product as fancy terms doesn't work. Those are robust software engineering practices that transcend Java, and apply to Java. Especially versions of Java from 8 forward. As such, your answer is still largely incorrect.Boreas
S
1

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.

Sovereignty answered 5/8, 2023 at 21:26 Comment(1)
”implicit” is used 25 times in JEP 395: Records, and “explicit” 21 times. For example, “if the canonical constructor is implicitly declared”.Proffitt
C
1

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.

Clydeclydebank answered 5/8, 2023 at 21:44 Comment(1)
See my answer - "if you with a DatePair instance and change its days value, absolutely nothing happens whatsoever" is exactly the kind of "do not do this - your API will be bizarre and feels obsolete and you needs loads of docs to explain why all sorts of stuff that records just get / gain over time doesn't work" situation I describe.Overlive
B
-2

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.

However, there are ways to come closer.


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:

  • At a KISS level, this appears to be the most aligned with the intention of the Java Architects

Cons:

  • Isn't properly managing DbC as the days value is not ensured to be aligned with the provided start and end values
  • Isn't a properly defined ADT of the kind Product
    • Incorrectly adds days to the method equals()
    • Incorrectly adds days to the method hashCode()
    • Incorrectly persists the derivable days value during serialization

Adding 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:

  • This appears to remain better aligned with the intention of the Java Architects
  • It's appropriately managing DbC as the days value is ensured to be aligned with the provided start and end values

Cons:

  • Isn't a properly defined ADT of the kind Product
    • Incorrectly adds days to the method equals()
    • Incorrectly adds days to the method hashCode()
    • Incorrectly persists the derivable days value during serialization

ADT Product

Or 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:

  • Correctly defined ADT, Product
    • Correctly adds only start and end to the method equals()
    • Correctly adds only start and end to the method hashCode()
    • Correctly prevents the days value from being serialized/deserialized

Cons:

  • The 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 expense

Sealed 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:

  • This appears to remain better aligned with the intention of the Java Architects
  • It's appropriately managing DbC as the days value is ensured to be aligned with the provided start and end values

Cons:

  • Isn't a properly defined ADT of the kind Product
    • Incorrectly adds days to the method equals()
    • Incorrectly adds days to the method hashCode()
    • Incorrectly persists the derivable days value during serialization

Expensive Compute Caching

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:

  • Is properly managing DbC as the days value is ensured to be aligned with the provided start and end values
  • Correctly defined ADT Product
    • Correctly adds only start and end to the method equals()
    • Correctly adds only start and end to the method hashCode()
    • Correctly prevents the days value from being serialized/deserialized

Cons:

  • Isn't currently compilable
Boreas answered 4/9 at 17:25 Comment(6)
The combination of sealed interface and record still has compiler generated equals(), hashCode(), and toString() and supports Serialization. There’s also the option of maintaining a map with a kind of Map<DatePair,Integer> but it’s also possible that future Java versions do a similar thing under the hood, so that ChronoUnit.DAYS.between(start, end) becomes a cheap operation…Christensen
I have corrected/updated the "Sealed Interface" section as a result of the excellent feedback provided by @holger.Boreas
I do not agree about the “moves towards being too ‘boilerplate’”. It can be as simple as public sealed interface DatePairPsi permits DatePairPsiRecord { LocalDate start(); LocalDate end(); long days(); static DatePairPsi of(LocalDate start, LocalDate end) { return new DatePairPsiRecord(start, end, -1); } } record DatePairPsiRecord(LocalDate start, LocalDate end, long days) implements DatePairPsi { DatePairPsiRecord { days = ChronoUnit.DAYS.between(start, end); } }Christensen
@Christensen Tysvm for the added example. I was unaware that a field could be assigned during the same constructor with which I was checking values and throwing an IllegalArgumentException. I will update my answer accordingly.Boreas
Technically, this code is assigning the local variable holding the parameter value, before it gets assigned to the field. Ignoring an actual parameter value is not the best design but since the resulting value is invariant (as long as our calendar system doesn’t change), the code still fulfills the contract of record types. And this quirk is hidden from the public API.Christensen
@Christensen Agreed about it ultimately meeting the formal definition of a DbC specification. As long as the client visible state meets the contract, any contortions of the internal state are acceptable.Boreas

© 2022 - 2024 — McMap. All rights reserved.