Builder pattern in Rust: self vs. &mut self, and method vs. associated function

I would like to use the builder pattern for complex arguments and being able to maintain future extensibility.

However, there seem to be various different approaches. Sometimes, the methods on the builder work on &mut self, while others consume self and return it again.

It's also possible to create the final struct (Hello in case of my example below) through a method on the builder, or by using an associated function on the struct type to be created.

See the following four variants A, B, C, D:

pub struct Hello {
    doit: Box<dyn Fn() -> ()>,
}

impl Hello {
    pub fn doit(&self) {
        (self.doit)()
    }
    pub fn new1(builder: &HelloBuilderMut) -> Self {
        let mut suffix = String::new();
        for _ in 0..builder.strength {
            suffix.push('!');
        }
        Self {
            doit: Box::new(move || println!("Hello World{suffix}")),
        }
    }
    pub fn new2(builder: &HelloBuilderMove) -> Self {
        let mut suffix = String::new();
        for _ in 0..builder.strength {
            suffix.push('!');
        }
        Self {
            doit: Box::new(move || println!("Hello World{suffix}")),
        }
    }
}

pub struct HelloBuilderMut {
    strength: u32,
}

impl HelloBuilderMut {
    pub const fn new() -> Self {
        Self { strength: 1 }
    }
    // NOTE: this method cannot be `const`
    pub fn set_strength(&mut self, strength: u32) {
        self.strength = strength;
    }
    pub fn build(&self) -> Hello {
        let mut suffix = String::new();
        for _ in 0..self.strength {
            suffix.push('!');
        }
        Hello {
            doit: Box::new(move || println!("Hello World{suffix}")),
        }
    }
}

pub struct HelloBuilderMove {
    strength: u32,
}

impl HelloBuilderMove {
    pub const fn new() -> Self {
        Self { strength: 1 }
    }
    pub const fn strength(mut self, strength: u32) -> Self {
        self.strength = strength;
        self
    }
    pub fn build(&self) -> Hello {
        let mut suffix = String::new();
        for _ in 0..self.strength {
            suffix.push('!');
        }
        Hello {
            doit: Box::new(move || println!("Hello World{suffix}")),
        }
    }
}

fn main() {
    // Variant A:
    // - builder methods work on &mut Self
    // - build method in builder to build struct
    let mut builder_a = HelloBuilderMut::new();
    builder_a.set_strength(1);
    let hello_a = builder_a.build();

    // Variant B:
    // - builder methods work on &mut Self
    // - associated function to build struct
    let mut builder_b = HelloBuilderMut::new();
    builder_b.set_strength(2);
    let hello_b = Hello::new1(&builder_b);

    // Variant C:
    // - builder methods consume and return Self
    // - build method in builder to build struct
    let hello_c = HelloBuilderMove::new().strength(3).build();

    // Variant D:
    // - builder methods consume and return Self
    // - associated function to build struct
    let hello_d = Hello::new2(&HelloBuilderMove::new().strength(4));

    hello_a.doit();
    hello_b.doit();
    hello_c.doit();
    hello_d.doit();
}

(Playground)

Output:

Hello World!
Hello World!!
Hello World!!!
Hello World!!!!

I would like to state some pros and cons I witnessed so far:

  • If the builder works on &mut self (variants A and B), then I can't use const fn. This might be a disadvantage that goes beyond syntax. Is it possible and/or planned to lift this restriction in future?
  • Making the builder consume self (variants C and D) feels like a syntax trickery. It will backlash when you conditionally set an option like if x { builder = builder.method(…); } where you have to repeat builder (opposed to if x { builder.method(…); }, which is more straightforward).
  • Having a build-method (variants A and C) is shorter. However, I came upon a case where the same "builder" struct is used to create values of several other types (maybe it's more a "configuration" struct then, instead of a "builder pattern" then). In that case, the associated methods feel more reasonable to me in that case.

Is there some consensus on what's best to do? Is the const fn restriction a problem that might be solved in future?

Thanks in advance for your advice or ideas/thoughts/comments.

3 Likes

It's a matter of style, but owned/by-value receivers are the most likely ones to result in zero-cost abstraction for the style where everything is "piped".

When using &mut self "pipes", the final .build() method or equivalent ends up having to to go from borrowed state to an owned constructed instance, which will therefore require one of two things:

  • .clone()ing the fields. This is very unlikely to end up being zero-cost (unless the optimizer somehow sees through everything and elides the clones/copies) for the simple case of chained methods. This, personally, makes me very sad which is why I'm not fond of this pattern.

  • mem::replace() / mem::take() / Option::take() ing the fields, by "abusing" the &mut self receiver. This is something quite rarely seen, and yet would be the one allowing for conditional methods as well as something easier for the optimizer to manage (still potentially not as good as self: Self receivers).
    It does have the extra drawback of leading people into surprising behavior / logic bugs:

    let mut builder = Builder::new();
    builder.foo().bar();
    let a = builder.build(); // <- "consumes the state"
    let b = builder.build(); // <- uses an empty / consumed state to build an instance: probably buggy!
    

    So, all in all, mem::replace() is even worse than clone()ing.

The only drawback of the owned builder is that "builder's name repetition" in the conditional case:

let mut builder = Builder::new().foo().bar();
if bazable {
    builder = builder.baz();
}
builder.quux().build()

In that case, having something like tap - Rust 's .pipe() can be quite nifty:

Builder::new()
    .foo()
    .bar()
    .pipe(|it| if bazable {
        it.baz()
    } else {
        it
    })
    .quux()
    .build()

And, to be honest, for the simple else { it } case, a .pipe_if helper could also be featured:

Builder::new()
    .foo()
    .bar()
    .pipe_if(bazable, |it| it.baz())
    .quux()
    .build()

I personally find the latter more ergonomic even than the &mut self builder version:

// Ughh have to use a named variable
let mut builder =
    Builder::new()
    .foo()
    .bar()
;
if bazable {
    builder.baz()
}
builder
    .quux()
    .build() // clones stuff

There is one argument in favor of &mut self builders: being able to generate multiple instances of one thing / of keeping the builder's state around (that is, taking advantage of that otherwise needless clone). If, moreover, the performance penalty is negligible compared to the cost of .build() / .run() logic on its own, then doing this can make a lot of sense.

The best example of this whole situation is Command's builder pattern: spawning a new process is often costlier, especially if waiting for its completion, than just cloning the command invocation. Especially if within a Rust script, where performance is to be matched against a human's reaction time: if you don't mind the performance penalty of bash scripts, for instance, then you surely can't mind the performance penalty of cloning args or environments in a cmd.status() invocation (or in the underlying execve).

But I'd say that this is also possible with an owned builder, since nothing prevents the builder from being Cloneable itself!

So, if you need to, say, clone all of the builder's state to crate a second instance, you could do:

let (a, b) =
    Builder::new()
        .foo()
        .bar()
        .pipe_if(bazable, |it| it.baz())
        .quux()
        .pipe(|it| (it.clone().build(), it.build()))
;

That is, by being Clone, you have access to .clone().build() to feature a &Builder -> Buildee API with very explicit semantics.


Final tip: to avoid the .pipe_if() stuff, sometimes, you can have the builder's method take a "redundant" bool, as in:

Builder::new()
    .foo(true)
    .bar(true)
    .baz(bazable)
    .quux(true)
    .build()

and, in the case of actual params, you can take an impl Into<Option<Arg>> argument:

Builder::new()
    .foo(42)
    ...
    .baz(bazable.then(|| the_baz_param))

All in all, there are ways to make the erogonomics of owned builders not too bad :slightly_smiling_face:

17 Likes

The owned builder approach is also the first step towards the more advanced typestate builder pattern, where the builder type changes on each call. This lets some misconfigurations be caught at compile-time, at the expense of flexibility:

pub struct HelloBuilder<S> {
    strength: S,
}

impl HelloBuilder<()> {
    pub fn new() -> Self {
        Self { strength: () }
    }
}

impl<S> HelloBuilder<S> {
    pub fn strength(self, strength: u32) -> HelloBuilder<u32> {
        HelloBuilder { strength }
    }
}

impl HelloBuilder<u32> {
    pub fn build(&self) -> Hello {
        let mut suffix = String::new();
        for _ in 0..self.strength {
            suffix.push('!');
        }
        Hello {
            doit: Box::new(move || println!("Hello World{suffix}")),
        }
    }
}

fn main() {
    let builder = HelloBuilder::new();
    // builder.build() here is a copile error
    let builder = builder.strength(1);
    let hello = builder.build();

    hello.doit();
}
13 Likes

As you say, this applies to the final build method (or associated function in my cases B and D).

I would say that for the final build method, it strongly depends on what the builder has to do. If the values have to be copied anyway, e.g. because they will be arguments for further function calls, then making the build method work on &self shouldn't be a problem. If it will create a data structure using the values in the builder, then working with self would be better.

Perhaps I didn't clarify that I didn't worry about whether the final build method works on self or &self, or whether an associated function would take a Builder or &Builder. As you can see, all my four examples A, B, C, D use a reference to the builder for the final step. I would change that to work by-value if needbe.

What I'm more puzzled about is the intermediate calls on the builder. Should these work with &mut self or self? I think your considerations regarding zero-cost do not apply here, as builder.method(…) should be same efficient as builder = builder.method(…) right? To me it seems like it's only a matter of two issues:

  • style / syntax
  • const fn support

(P.S.: While typing this response, I just saw @2e71828's comment on the typestate builder pattern. That's definitely another valid issue to consider!)

I currently am in favor of the &mut self style because it feels like it's reflecting better what happens: A builder's state is mutated. But I guess that's a matter of preference.

However, the missing const fn support does worry me a bit, because even if I don't need it now, maybe some future user of my API might miss the ability to use constant builders. In terms of machine code, the &mut self and self versions (of the intermediate calls, not the final build call!) should be equivalent, right? Why does Rust support const fn in one case but not in the other? Is there some obstacle that's making this impossible or difficult by principle? And does this mean I should refrain from using builders whose intermediate methods calls work on &mut self?

There doesn't seem to be a clear standard. Looking into the build.rs file of my current project, I see that bindgen::Builder uses one style, while cc::Build uses the other. (Again, I care for the intermediate methods here, not the final build call.) Is it just a matter of style here that's causing the different approaches or is there some deeper reason behind it?

fn main() {
    // get configuration from environment
    let lua_include = get_env("LUA_INCLUDE");
    let lua_lib = get_env("LUA_LIB");
    let lua_libname = get_env_default("LUA_LIBNAME", "lua");

    // create automatic bindings
    // NOTE: intermediate methods use `self`
    // (and final `generate` method uses `self`)
    {
        let mut builder = bindgen::Builder::default();
        if let Some(dir) = &lua_include {
            builder = builder.clang_arg(format!("-I{dir}"));
        }
        builder = builder.header("src/cmach.c");
        builder = builder.parse_callbacks(Box::new(bindgen::CargoCallbacks));
        let bindings = builder.generate().expect("unable to generate bindings");
        let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
        bindings
            .write_to_file(out_path.join("ffi_cmach.rs"))
            .expect("unable to write bindings");
    }

    // build own C lib
    // NOTE: intermediate methods use `&mut self`
    // (and final `compile` method uses `&self`)
    {
        println!("cargo:rerun-if-changed=src/cmach.c");
        let mut config = cc::Build::new();
        if let Some(dir) = &lua_include {
            config.include(dir);
        }
        config.file("src/cmach.c");
        config.compile("libffi_cmach.a");
    }

    // link with Lua
    if let Some(dir) = lua_lib {
        println!("cargo:rustc-link-search=native={}", dir);
    }
    println!("cargo:rustc-link-lib={}", lua_libname);
}

Maybe the reason for bindgen::Builder working on self is because the final generate method also consumes the builder? So perhaps it's to keep the style consistent?

(cc::Build's intermediate method calls work on &mut self and the final compile method works with &self.)

Also my other question is still open: Use a method on the builder (variants A and C in my original example), or use an associated function on the type that I'm about to generate (variants B and D in my original example)? I think the latter is rarely seen.

I would still need to change the function signatures if I introduce it later, right? That might or might not be a breaking change, depending on how the builder is used, I guess.

1 Like

You are correct that there is no fundamental reason why &mut in const fn is not supported; it's just a matter of getting it implemented correctly. It's available on nightly under the feature gate const_mut_refs and you can read tracking issue #57349 for progress on that feature.

(That said, I personally prefer the by-value style of builder both to support typestate and because it feels inelegant for a builder returns a reference that is completely equivalent to a reference you already have — the builder is doing something semantically superfluous solely to support method chaining syntax.)

7 Likes

I've been focusing on the classic builder pattern, and it being usable in a cascading fashion.

In that case, whatever the receiver of the end-point may be, all the intermediary builder steps need to be of that same level or more strict (where self > &mut self > &self).

So, if the last step of the builder is owned (and I've advocated quite a lot in my previous post what the advantages of that are, and that is, indeed, without even mentioning type-state patterns you can embed into it, as @2e71828 put it :100:), then the intermediary steps ought to be owned as well; at least if you want to support the fully chained call style, which I'd say is what the pattern initially is about (although there may be variations of it, of course).

If, on the other hand, you have something like a final &self build step, as you put it, then you are kind of free to do whatever you want: if you care about avoiding misusage thanks to the typestate pattern, then keep the self nonetheless, otherwise feel free to go for the slightly-more-ergonomic-with-if-branches &mut self :slightly_smiling_face:

2 Likes

Very nice to know! :+1:

Yes, that's why the methods set_strength (in variants A and B of my original example) return ().

Oh, I wasn't sure what exactly resembles the "builder pattern". The Wikipedia article on "Builder pattern" doesn't imply any particular syntax style being used for it.

I would follow/interpret @kpreid's reasoning insofar that I don't think methods should return some value just for syntactic purposes. Isn't that what macros are for (in idiomatic Rust)?

Right now, I would conclude the following (but that includes my personal taste of course):

  • The decision on whether the final "build method" works on self or &self depends on the actual build process (self may avoid unnecessary clones, but &self may be helpful if one builder is used to create several instances). Using &mut self, however, could be abusive (as @Yandros pointed out), but I'm not entirely sure this holds in every case (there are other mutable methods that may leave a struct in an unusable state before further action is taken, like Vec::drain).
  • If you want to support method chaining, "all the intermediary builder steps need to be of that same level or more strict (where self > &mut self > &self)" (as pointed out by @Yandros, thanks for that!). I find it odd (like @kpreid does) to return &mut self solely for syntactical reasons. However, I'm not sure if this should be a reason to use self. Like I said, maybe that's what macros are for?
  • Consuming self and returning Self or a parameterized (or even entirely different) builder type during the build process may be used for the typestate builder pattern, as @2e71828 brought up. That seems to be the most substantial argument for making builders consume the current state and returning a new state. Being able to do method chaining then is a welcomed side-effect though.
2 Likes

I would say that the essential feature of the builder pattern is that there is a distinct type (FooBuilder vs. Foo, or std::process::Command vs. Child) which is used solely to aid construction, as opposed to either a constructor function with many arguments, or an object which must be constructed and then mutated in many steps.

The syntax is less essential, but nearly every example of a builder I have seen in any language takes the method-chaining approach, returning whatever the language's “self” is. Presumably languages which supported short syntax for a sequence of mutating calls without naming the receiver would discourage returning “self” unnecessarily. In general, I propose the frame that returning self is a pattern for making the builder pattern more ergonomic, in languages with method-call syntax. (It wouldn't help at all in C, for example, since there is no ordinary way to chain a sequence of function calls; you'd be better off just having a sequence of statements.)

It's so important in that case that it's ubiquitous, and you shouldn't design something meant to be a builder in Rust, C++, JavaScript or such without including it, but it still is not necessary to the pattern of “have a separate builder type in order to simplify the construction process”.

2 Likes

That's a nice summary, yes. Regarding, for instance, macros to soothen the syntax / ergonomics of builder pattern application, I'd say that, granted, macros can have an arbitrarily powerful syntactic sugar, thus necessarily ensuring there is un upper-bound on the ergonomic cost of an operation (by that being the ergonomics of calling and using said macro). That being said, such upper-bound is non-zero: there is an ergonomic hit for using macros. So sometimes it is "just more convenient" to tweak a bit the builder to get those ergonomics in a more transparent fashion. And method chaining is indeed a surprisingly nice way of achieving that. But yeah, to clarify, that's no hard rule at all (I don't believe in hard rules anyways: "only a sith deals in absolute", as Obi-Wan quite absolutely says).

But regarding the "builders are "abused" for syntactic sugar when they 'shouldn't'", there was an interesting article at the time:

Okay, agreed. Thanks for clarification.

I really like that idea, so I tried to go the path of making my builder supporting the method chaining approach, and I also tried to include the typestate builder pattern. I used type parameters to the builder, similar to @2e71828's example.

However, I'm running into some problems regarding const fns:

use std::marker::PhantomData;
use std::path::Path;

struct ReadOnly {}
struct ReadWrite {}

struct Builder<T, P> {
    target: PhantomData<T>,
    path: P,
    maxsize: usize,
}

impl Builder<(), ()> {
    pub const fn new() -> Self {
        Builder {
            target: PhantomData,
            path: (),
            maxsize: 1024,
        }
    }
}

impl<T, P> Builder<T, P> {
    pub const fn maxsize(mut self, maxsize: usize) -> Self {
        self.maxsize = maxsize;
        self
    }
    // Problem 1: these two cannot be `const`:
    pub fn read_only(self) -> Builder<ReadOnly, P> {
        Builder {
            target: PhantomData,
            path: self.path,
            maxsize: self.maxsize,
        }
    }
    pub fn read_write(self) -> Builder<ReadWrite, P> {
        Builder {
            target: PhantomData,
            path: self.path,
            maxsize: self.maxsize,
        }
    }
    // Problem 2: this cannot be `const`:
    pub fn path<'a>(
        self,
        path: &'a (impl AsRef<Path> + ?Sized),
    ) -> Builder<T, &'a Path> {
        Builder {
            target: PhantomData,
            path: path.as_ref(),
            maxsize: self.maxsize,
        }
    }
}

impl<'a> Builder<ReadOnly, &'a Path> {
    pub fn build(&self) -> ReadOnly {
        let _ = self.path; // let's assume we use `path` to open something
        ReadOnly {}
    }
}

impl<'a> Builder<ReadWrite, &'a Path> {
    pub fn build(&self) -> ReadWrite {
        let _ = self.path; // let's assume we use `path` to open something
        ReadWrite {}
    }
}

fn main() {
    let _ = Builder::new()
        .maxsize(4096)
        .path("/tmp")
        .read_write()
        .build();
    let _ = Builder::new()
        .maxsize(4096)
        .path("/tmp")
        .read_only()
        .build();
}

(Playground)

This runs without problems, but if I try to turn the read_only function into a const fn, then I get:

   Compiling playground v0.0.1 (/playground)
error[E0493]: destructors cannot be evaluated at compile-time
  --> src/main.rs:29:28
   |
29 |     pub const fn read_only(self) -> Builder<ReadOnly, P> {
   |                            ^^^^ constant functions cannot evaluate destructors
...
35 |     }
   |     - value is dropped here

For more information about this error, try `rustc --explain E0493`.
error: could not compile `playground` due to previous error

(Playground with read_only being a const fn)

I tried to fix this by restricting read_only and read_write to operate on Builder<(), _> only, but this didn't help:

         self.maxsize = maxsize;
         self
     }
-    // Problem 1: these two cannot be `const`:
-    pub fn read_only(self) -> Builder<ReadOnly, P> {
+}
+
+impl<P> Builder<(), P> {
+    pub const fn read_only(self) -> Builder<ReadOnly, P> {
         Builder {
             target: PhantomData,
             path: self.path,
             maxsize: self.maxsize,
         }
     }
-    pub fn read_write(self) -> Builder<ReadWrite, P> {
+    pub const fn read_write(self) -> Builder<ReadWrite, P> {
         Builder {
             target: PhantomData,
             path: self.path,
             maxsize: self.maxsize,
         }
     }
+}
+
+impl<T, P> Builder<T, P> {
     // Problem 2: this cannot be `const`:
     pub fn path<'a>(
         self,

(Playground)

As the first type parameter is only used for a PhantomData, I think the compiler should not complain, right? Could this be considered a bug or feature request?

Regarding "Problem 2" in my source, I guess there is no way to provide a constant Path like we can do with &'static str?


Apparently there is a solution for "Problem 1", but it seems a bit ugly to me. Take a look:

 struct ReadOnly {}
 struct ReadWrite {}
 
-struct Builder<T, P> {
+struct Builder<'a, T, P: ?Sized> {
     target: PhantomData<T>,
-    path: P,
+    path: &'a P,
     maxsize: usize,
 }
 
-impl Builder<(), ()> {
+impl Builder<'static, (), ()> {
     pub const fn new() -> Self {
         Builder {
             target: PhantomData,
-            path: (),
+            path: &(),
             maxsize: 1024,
         }
     }
 }
 
-impl<T, P> Builder<T, P> {
+impl<'a, T, P: ?Sized> Builder<'a, T, P> {
     pub const fn maxsize(mut self, maxsize: usize) -> Self {
         self.maxsize = maxsize;
         self
     }
-    // Problem 1: these two cannot be `const`:
-    pub fn read_only(self) -> Builder<ReadOnly, P> {
+    // Problem 1 solved. But why?
+    pub const fn read_only(self) -> Builder<'a, ReadOnly, P> {
         Builder {
             target: PhantomData,
             path: self.path,
             maxsize: self.maxsize,
         }
     }
-    pub fn read_write(self) -> Builder<ReadWrite, P> {
+    pub const fn read_write(self) -> Builder<'a, ReadWrite, P> {
         Builder {
             target: PhantomData,
             path: self.path,
@@ -41,10 +41,10 @@
         }
     }
     // Problem 2: this cannot be `const`:
-    pub fn path<'a>(
+    pub fn path<'b>(
         self,
-        path: &'a (impl AsRef<Path> + ?Sized),
-    ) -> Builder<T, &'a Path> {
+        path: &'b (impl AsRef<Path> + ?Sized),
+    ) -> Builder<'b, T, Path> {
         Builder {
             target: PhantomData,
             path: path.as_ref(),
@@ -53,14 +53,14 @@
     }
 }
 
-impl<'a> Builder<ReadOnly, &'a Path> {
+impl<'a> Builder<'a, ReadOnly, Path> {
     pub fn build(&self) -> ReadOnly {
         let _ = self.path; // let's assume we use `path` to open something
         ReadOnly {}
     }
 }
 
-impl<'a> Builder<ReadWrite, &'a Path> {
+impl<'a> Builder<'a, ReadWrite, Path> {
     pub fn build(&self) -> ReadWrite {
         let _ = self.path; // let's assume we use `path` to open something
         ReadWrite {}

(Playground)

I don't even understand why this solves the problem.

"destructors cannot be evaluated at compile-time" will be triggered when you try to destructure types containing types that might have destructors (here P) in const fn. This is because (if I understand correctly) the compiler's analysis isn't yet relevantly precise enough to (in a way predictable to you) know that you aren't dropping any values of type P.

Your first nonworking code compiles with the unstable feature flag #![feature(const_precise_live_drops)] which refines the analysis.

The reason &P helps is because the compiler knows that dropping an & never needs a destructor.

3 Likes

Ah, I wrongly assumed it was related to T, because I ran into the problem when adding T, but I made a wrong conclusion then.

There's no problem in Rust that can't be solved by adding a feature flag. :laughing:

Thank you very much! I think I'll go that way for now.

Thanks for the article, it covers many aspects. Though I'm not sure what to think about it. I guess it would be "better" if method chaining was built into the language. But perhaps the problem is also not big enough to pose a real issue.

:man_shrugging:

The macro approach doesn't feel so nice to me. For some reason the syntax of the cascade crate confuses me (as also discussed in that thread you referenced).

Right now I'll likely use the moving approach, i.e. consuming self and returning Self or a parametrized type to enable typestate builders.

It wasn't the source of your error, but PhantomData being owning is this conversation again. It's either an intentional "bug" (that makes more things sound, not less) or incorrect documentation, depending on your POV.

Thank you very much for teaching me the typestate builder pattern.

I applied the knowledge (hopefully correct), and created these builders:

  • EnvBuilder to set options for opening an LMDB environment. A path must be set before the environment can be opened (not setting a path results in a compile-time error).
  • DbOptions specifying the options and name of a database inside the environment. Unless a name has been set (or the "unnamed" database has been selected), the builder cannot be used to open a database (again, with checks performed at compile-time). Additionally, certain methods (currently DbOptions::reversedup to select that values are sorted in reverse order) are only available when certain other options have been selected (namely DbOptions::keys_duplicate, which permits that a key may have multiple values at all).

Beside gaining these extra checks at compile-time, I also need to consume self because the type of DbOptions will change during building. DbOptions comes with type arguments that reflect the stored types inside the database (methods DbOptions::key_type and value_type). The methods setting the types will need to store a type inside the builder, rather than a value. Making the methods take &mut self won't work here.

Thus, I ended up making my builder consuming self – for multiple reasons.