Rust: Macon, a new derive builder
As announced on Twitter/X:
📢 Annoucement 📢 I'm proud to announce public availability of my first #Rust crate 🦀📦: Macon (crates.io/crates/macon/1…) 🗣️ It is pronounced: \ma.sɔ̃\ 📔 It provides derive macro to generate builder for your structs. 📰 Post coming soon on @MonkeyPatch_io blog. Stay tuned!
I have released the first public release of my very first crate (name given to packages in Rust ecosystem): Macon.
It is pronounced: \ma.sɔ̃\ .
A story of yak
(If you miss the ref: https://en.wiktionary.org/wiki/yak_shaving)
Before talking about crate usage, I would like to speak a little bit about its story.
As I was working on a toy project, I needed to deal with Podman and found no crate to consume its API. So, I started to create one of my own. After writing down several large structs to represent API payloads, I hadn't enough strength to create whole bunch of getters and setters.
Knowing power of Rust macros, I started to find out existing crates: https://crates.io/keywords/builder?sort=downloads
There were two candidates:
derive_builder
: an historical derive macro with several features. No release since 1 year (2022-11-28
), but Git repository is still active (2024-01-22
).
Which one to pick !? Let's bench them. So, I started to write some blueprints of what I expect, to list wanted features/behaviors and to compare selected crates. If you are curious, result is published here: https://github.com/loganmzz/rust-benchmark-setter.
Honestly, I was a little bit disappointed by the lack of defaults. I had to add a lot of code (i.e. attributes) on several large structs. The conclusion was: I need my own crate with much more convention over configuration.
A definition of Rust macros
Let's delve into the technical details. In Rust, macros are a way to generate code. Being more precise, it's about to generate tokens. There are three kinds: function
, derive
and attribute
. If you remember, I said derive macro.
A function
macro is called like a function but with a !
. There are few common ones every Rustacean (surname for people using Rust) knows: println
, panic
, assert
, ... While call syntax looks like a function, it can appear almost anywhere.
A derive
macro is attached to a struct
(custom data type, similar to class in object-oriented language) and add code right after it. The most common use case is to generate default implementation for a trait. Examples from standard library: Debug
, PartialEq
, Defaut
, ...
An attribute
macro is used as an attribute attached to any element. It's the most complex one, as it replaces the attached element. The most used one is (or would be) surely test
.
If you want to learn more about Rust macros, feel free to contact us. We have a talk for you !
A usage of Macon
So, Macon is a derive
macro that generate struct builder:
use macon::Builder;
#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct Person {
first_name: String,
last_name: String,
age: u8,
}
let builder: PersonBuilder = Person::builder();
Then, properties can be set using function (called setters) with same name as property ones. Such functions can be chained:
let builder: PersonBuilder = builder
.first_name("Logan")
.last_name("Mauzaize")
.age(38);
There are few important things to note here:
- We passed
&'static str
forString
properties. It's because setter parameters are generics overInto
. - Chain-call is implemented by consuming and returning the builder.
Finally, construct a new Person
instance:
let author: Person = builder.build();
assert_eq!(
Person {
first_name: String::from("Logan"),
last_name: String::from("Mauzaize"),
age: 38,
},
author,
);
A favor of tuples
Person
is a named struct: fields have name and are unordered. Tuple struct fields, on the opposite, just have order:
#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct Info(
String,
String,
u8,
);
Instances can still be built in unordered manner using set${ORDER}
:
let info = Info::builder()
.set1("Mauzaize")
.set2(38)
.set0("Logan")
.build();
assert_eq!(
Info(
String::from("Logan"),
String::from("Mauzaize"),
38,
),
info,
);
But the preferred way is to use ordered setters:
let info = Info::builder()
.set("Logan")
.set("Mauzaize"
.set(38)
.build();
assert_eq!(
Info(
String::from("Logan"),
String::from("Mauzaize"),
38,
),
info,
);
A lot of defaults
What happens if you try to build an incomplete instance? A compilation error. build()
method is accessible only when mandatory values are set.
As stated, Macon supports convention over configuration. If following field types are detected, they are considered optional and initialize with default value:
- primitive types (
bool
,usize
,i32
, ...) - strings:
String
,str
- collections:
Vec
,HashMap
,HashSet
Option
As macros does operate at token level. It doesn't have access to type information, only their name. Short or fully qualified names are supported (e.g. Option
, ::core::option::Option
, std::option::Option
). Such behavior can be enforced (enable or disable), using builder
attribute:
#[derive(Debug,Default,PartialEq)]
struct MyWrapper(String);
impl<T: ::core::convert::Into<String>> ::core::convert::From<T> for MyWrapper {
fn from(value: T) -> Self {
Self(value.into())
}
}
#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct DefaultOptions {
#[builder(Default,)]
wrapped: MyWrapper,
#[builder(Default=!)]
mandatory: String,
}
DefaultOptions::builder()
.wrapped("optional")
.build(); // Compilation error !
assert_eq!(
DefaultOptions {
wrapped: MyWrapper(String::from("")),
mandatory: String::from("some value"),
},
DefaultOptions::builder()
.mandatory("some value")
.build(),
);
assert_eq!(
DefaultOptions {
wrapped: MyWrapper(String::from("")),
mandatory: String::from("another value"),
},
DefaultOptions::builder()
.wrapped_default() // Explicit default
.mandatory("another alue")
.build(),
)
Finally, Default
detection can apply on whole struct:
#[derive(Builder)]
#[derive(Debug, Default, PartialEq)]
struct AutoDefaultStruct {
boolean: bool,
numeric: usize,
string: String,
vec: Vec<usize>,
}
assert_eq!(
AutoDefaultStruct {
boolean: false,
numeric: 0,
string: String::from(""),
vec: vec![],
},
AutoDefaultStruct::builder().build(),
);
And enforce with builder
attribute at struct level:
#[derive(Builder, Debug, PartialEq)]
#[builder(Default,)]
struct EnforceDefaultStruct {
boolean: bool,
numeric: usize,
string: String,
vec: Vec<usize>,
}
impl ::core::default::Default for EnforceDefaultStruct {
fn default() -> Self {
Self {
boolean: true,
numeric: 42,
string: String::from("default"),
vec: vec![0, 1, 2 ,3],
}
}
}
assert_eq!(
EnforceDefaultStruct {
boolean: true,
numeric: 42,
string: String::from("default"),
vec: vec![0, 1, 2, 3,],
},
EnforceDefaultStruct::builder()
.build()
,
);
assert_eq!(
EnforceDefaultStruct {
boolean: true,
numeric: 0,
string: String::from("override"),
vec: vec![0, 1, 2, 3],
},
EnforceDefaultStruct::builder()
.boolean_keep() // Keep value from default instance
.numeric_default() // Use default from field type
.string("override") // Override value
.build()
,
);
Note: keep
and default
are also available for tuple ordered setter.
A piece of option
Option
is not also a discretionary, but also treated differently. Builder setter is then generic over Into
for wrapped type, not option one:
#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct FullOption {
string: Option<String>,
list: Option<Vec<usize>>,
}
assert_eq!(
FullOption {
string: Some(String::from("optional")),
list: Some(vec![0, 1, 1, 2, 3, 5, 8,]),
},
FullOption::builder()
.string("optional")
.list([0, 1, 1, 2, 3, 5, 8,])
.build()
,
);
Just as in the case of Default
, detection can be enforce with builder
attribute. But in this case, you must specified wrapped type:
type OptString = Option<String>;
#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct EnforceOption {
#[builder(Option=String)]
string: OptString,
}
assert_eq!(
EnforceOption {
string: Some(String::from("enforced optionnal")),
},
EnforceOption::builder()
.string("enforced optionnal")
.build()
,
);
And one more time, you can set explicitly to None
:
#[derive(Builder)]
#[derive(Debug,PartialEq)]
struct OptionTuple(
Option<String>,
);
assert_eq!(OptionTuple(None), OptionTuple::builder().build());
assert_eq!(OptionTuple(None), OptionTuple::builder().set0_none().build());
assert_eq!(OptionTuple(None), OptionTuple::builder().none().build());
A plan of features
There are some other features, I let you browse documentation to discover them.
By now, I want to talk about some coming features.
Support for collection
The idea is to provide helpers similar to types wrapped into Option
, but for Extend
(including HashMap
):
//Disclaimer: may not reflect final syntax
#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct ExtendSupport {
listitems: Vec<String>,
mapitems: HashMap<String, String>,
}
assert_eq!(
ExtendSupport {
listitems: vec![String::from("one"), String::from("two"), String::from("three"),],
mapitems: HashMap::from([
(String::from("A"), String::from("eɪ")),
(String::from("B"), String::from("biː")),
(String::from("C"), String::from("siː")),
(String::from("D"), String::from("diː")),
]);
},
ExtendSupport::builder()
.listitem("one")
.listitem("two")
.listitem("three")
.mapitem("A", "eɪ")
.mapitem("B", "biː")
.mapitem("C", "siː")
.mapitem("D", "diː")
.build()
,
);
Support for buildable
The idea is to ease initialization of buildable nested structs:
//Disclaimer: may not reflect final syntax
#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct Nested {
foo: String,
bar: usize,
}
#[derive(Builder)]
#[derive(Debug,PartialEq,)]
struct Root {
#[builder(Builder,)]
nested: Nested,
}
assert_eq!(
Root {
nested: Nested {
foo: String::from("nested foo"),
bar: 0,
},
},
Root::builder()
.nested_build(|nested| nested.foo("nested foo"))
,
);
Support for enum variants
The idea is to select a variant when building an enum struct:
//Disclaimer: may not reflect final syntax
#[derive(Builder)]
#[derive(Debug,PartialEq,)]
enum Value {
String(String),
Numeric(u64),
Point {
x: i64,
y: i64,
},
}
assert_eq!(
Value::Point {
x: -2,
y: 1,
},
Value::builder()
.Point()
.x(-2)
.y( 1)
.build()
,
);
A call for participation
If you wanna contribute to the project, the easiest way is to open RFEs (Request For Enhancement) or bug reports.
There are many levels of proposal:
- Usage syntax
- Blueprint concept
- or even, macro implementation
See you soon on https://github.com/loganmzz/macon-rs/issues !