DEV Community

Discussion on: Rust builder pattern with types

Collapse
 
seanconnell profile image
Sean Connell

I enjoyed this a lot, here's a java adaptation relying on downcasts and type erasure to accomplish mostly (slightly less ergo) the same effect.

import java.util.Objects;
import java.util.Optional;

/**
 * Demonstrates compile time failure for unsafe construction of objects via builders allowing both required and optional parameters.
 *
 * Shamelessly stolen from https://dev.to/mindflavor/rust-builder-pattern-with-types-3chf and adapted to java.
 */
class Scratch {

    /**
     * A demonstration of all our efforts in a nicely usable compile time failure for the api if required args are omitted.
     * Try adding and removing them, and notice how the optional args don't effect the overall compilation but do effect
     * the runtime print messages.
     */
    public static void main(String[] args) {
        final ReadyToLaunchRocket rocket = SafeBuilderProvider.build(
                SafeBuilderProvider.builder()
                // these three (fuel, ignition, target) are required. If you omit any of these lines, you'll get a compiler error about it. It looks like:

                // Error:(153, 31) incompatible types: Scratch.SafeBuilder<Scratch.RequiredArgProvided,Scratch.RequiredArgOmitted,Scratch.RequiredArgProvided>
                // cannot be converted to Scratch.SafeBuilder<Scratch.RequiredArgProvided,Scratch.RequiredArgProvided,Scratch.RequiredArgProvided>

                // which is relatively readable, I think. (Obviously pardon the Scratch.prefix here, that would just be the classname normally)
                .loadFuel(new Fuel())
                .primeIgnition(new Ignition())
                .programTarget(new Target())
                // these two are optional. They can be before/during/after the required args, it doesn't matter
                .breakChampagneBottle(new ChampagneBottle())
                .addSweetDecal(new SweetDecal())
        );

        // use those safely constructed values!
        rocket.fire();
    }

    //The core type check which is more or less a phantom type boolean for "value is set on builder"
    interface ValueIsSet {}
    interface RequiredArgProvided extends ValueIsSet {}
    interface RequiredArgOmitted extends ValueIsSet {}

    //various classes required to construct a rocket
    static class Ignition {}
    static class Fuel {}
    static class Target {}
    //and two optional ones, to demonstrate how to add optional args that don't constrain the overall result
    static class SweetDecal {}
    static class ChampagneBottle {}

    /**
     * Our sample class that requires non-null values from the builder, always
     */
    static class ReadyToLaunchRocket {
        //pretend there's some use of these values. It doesn't matter for the demonstration which is all compiler tricks.
        private final Ignition i;
        private final Fuel f;
        private final Target t;

        public ReadyToLaunchRocket(Ignition i, Fuel f, Target t) {
            Objects.requireNonNull(i);
            Objects.requireNonNull(f);
            Objects.requireNonNull(t);
            this.i = i;
            this.f = f;
            this.t = t;
        }

        public void fire() {
            System.out.println("Rocket firing with ignition " + i + " using fuel " + f + " and heading towards " + t);
        }
    }

    /**
     * Our type safe builder that disallows expressing unsafe constructions by tracking required parameters as phantom types
     */
    interface SafeBuilder<IGNITION_PRIMED extends ValueIsSet, FUEL_LOADED extends ValueIsSet, TARGET_PROGRAMMED extends ValueIsSet> {
        SafeBuilder<RequiredArgProvided, FUEL_LOADED, TARGET_PROGRAMMED> primeIgnition(Ignition i);
        SafeBuilder<IGNITION_PRIMED, RequiredArgProvided, TARGET_PROGRAMMED> loadFuel(Fuel f);
        SafeBuilder<IGNITION_PRIMED, FUEL_LOADED, RequiredArgProvided> programTarget(Target t);
        SafeBuilder<IGNITION_PRIMED, FUEL_LOADED, TARGET_PROGRAMMED> addSweetDecal(SweetDecal d);
        SafeBuilder<IGNITION_PRIMED, FUEL_LOADED, TARGET_PROGRAMMED> breakChampagneBottle(ChampagneBottle b);
    }

    /**
     * This can be considered the "unsafe" layer. The only trick we're using here
     * downcasting to allow us to re-use this object for the next step in the validation chain.
     *
     * Because all of the phantom types/generics are erased at runtime, this will never throw.
     *
     * The only thing the author of classes like this needs to be sure of is that they set the
     * correct "RequiredArgProvided" and "No" values on the return, or you'll be unsafe. Since this is
     * relatively easy to get right/see in CR this is a reasonble solution.
     *
     * This still can't save us from the evil of nullable types, so if somebody tries to
     * defeat us with .primeIgnition(null) we'll only catch it at runtime the usual way.
     * Hopefully the compiler error will help people not do that.
     */
    static class SafeBuilderImpl implements SafeBuilder<ValueIsSet, ValueIsSet, ValueIsSet> {
        Ignition i;
        Fuel f;
        Target t;
        Optional<SweetDecal> d = Optional.empty();
        Optional<ChampagneBottle> b = Optional.empty();

        @Override
        public SafeBuilder<RequiredArgProvided, ValueIsSet, ValueIsSet> primeIgnition(Ignition i) {
            Objects.requireNonNull(i);
            this.i = i;
            return (SafeBuilder<RequiredArgProvided, ValueIsSet, ValueIsSet>) (SafeBuilder) this;
        }

        @Override
        public SafeBuilder<ValueIsSet, RequiredArgProvided, ValueIsSet> loadFuel(Fuel f) {
            Objects.requireNonNull(f);
            this.f = f;
            return (SafeBuilder<ValueIsSet, RequiredArgProvided, ValueIsSet>) (SafeBuilder) this;
        }

        @Override
        public SafeBuilder<ValueIsSet, ValueIsSet, RequiredArgProvided> programTarget(Target t) {
            Objects.requireNonNull(t);
            this.t = t;
            return (SafeBuilder<ValueIsSet, ValueIsSet, RequiredArgProvided>) (SafeBuilder) this;
        }

        @Override
        public SafeBuilder<ValueIsSet, ValueIsSet, ValueIsSet> addSweetDecal(SweetDecal d) {
            this.d = Optional.of(d);
            return this;
        }

        @Override
        public SafeBuilder<ValueIsSet, ValueIsSet, ValueIsSet> breakChampagneBottle(ChampagneBottle b) {
            this.b = Optional.of(b);
            return this;
        }
    }

    /**
     * This is the beginning and the end of the type flow. It starts us out with a builder
     * that has all "No" phantom types and receives the fully constructed "RequiredArgProvided" types.
     */
    static abstract class SafeBuilderProvider {
        static SafeBuilder<RequiredArgOmitted, RequiredArgOmitted, RequiredArgOmitted> builder() {
            return (SafeBuilder<RequiredArgOmitted, RequiredArgOmitted, RequiredArgOmitted>) (SafeBuilder) new SafeBuilderImpl();
        }

        //Unlike rust, which has type specific static dispatch, we can't make the full builder
        //experience work. We need to pass back to another reciever method that can constrain the types
        //of the builder to "fully constructed"
        static ReadyToLaunchRocket build(SafeBuilder<RequiredArgProvided, RequiredArgProvided, RequiredArgProvided> builder) {
            SafeBuilderImpl privateBuilder = (SafeBuilderImpl)(SafeBuilder)builder;
            if (privateBuilder.b.isPresent()) {
                System.out.println("Let's celebrate this occasion by christening the new ship with some champagne! " + privateBuilder.b.get());
            }
            if (privateBuilder.d.isPresent()) {
                System.out.println("Cubert says that sweet decals make this ship go faster, let's put this one on! " + privateBuilder.d.get());
            }
            return new ReadyToLaunchRocket(privateBuilder.i, privateBuilder.f, privateBuilder.t);
        }
    }
}
Collapse
 
mindflavor profile image
Francesco Cogno

Really nice! Thank you! The static ReadyToLaunchRocket build trick is really clever!