javaconstructorfactoryjava-recordstatic-factory

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


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?


Solution

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

    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.