DEV Community

Cover image for Rust builder pattern with types

Rust builder pattern with types

Francesco Cogno on July 26, 2018

Intro Rust has a very rich type system. It also has move semantics. Using these two features together you can build your APIs using the ...
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!

Collapse
 
tzachshabtay profile image
tzachshabtay

I'm not convinced this is a better way. Like you said, the compilation error is an issue. Also, while the call itself might be more readable, if you turn to read the trait to understand what it does, you're in for a world of pain- so much noise. And third, it won't scale: if I want to cook pasta a million times, there's a lot of unneeded copies and allocations. I think the real solution should be in the language itself, with named and default arguments: internals.rust-lang.org/t/pre-rfc-...

Collapse
 
mindflavor profile image
Francesco Cogno

I agree, the copies are ugly. I expect them to be optimized away by the compiler tough (I haven't checked, it should be an interesting thing to do).

Named and default arguments just postpone the problem. If you have many optional fields your code will likely include a lot of if Some(val)... to handle them.
These ifs will be a runtime penalty - unless the compiler can again optimize away. Using types you can achieve static dispatch and incur in no runtime penalty (at expense at slower compilation and bigger code size).

Collapse
 
charlesjohnson profile image
Charles Johnson • Edited

Did you consider

impl<A, B, C, D, E, F> From<CookPastaBuilder<A, B, C>> for CookPastaBuilder<D, E, F> {
   ...
}
Enter fullscreen mode Exit fullscreen mode

to reduce the lines of code needed for CookPastaBuilder methods?