bon icon indicating copy to clipboard operation
bon copied to clipboard

feat: custom final `build`( `build_into`, "build hook", etc) can be manual

Open dzmitry-lahoda opened this issue 7 months ago • 4 comments

  1. i declared big complicated struct
  2. i set all needed fields
  3. build does not actually to instantiate the exact struct with bon macro, but something else

options:

  • for example it can inject fields into set of arrays under some index (see how this allows to work with struct as SoA or Aos https://github.com/tim-harding/soa-rs)
  • write it to some binary stream

why is needed:

  • may be better performance (why would I allocate struct on stack if i will immediately split and inject it into vectors). so not sure if it is case.

i guess feature can be closed as not planned if no more usecases found tbh. may be it is just edge case, so it would replicate something what I would do manually without builder.

dzmitry-lahoda avatar Jun 17 '25 11:06 dzmitry-lahoda

Hi! If you don't need to instantiate a struct, then you can avoid using a struct to derive the builder, and use function-based syntax which basically gives a custom finishing function:

#[bon::builder]
fn push_to_stream(
    output: &mut dyn std::io::Write,
    a: u32,
    b: u32,
) -> std::io::Result<()> {
    write!(output, "{a} {b}")
}

fn main() {
    let result = push_to_stream()
        .output(&mut std::io::stdout())
        .a(1)
        .b(2)
        .call();
}

The same can also be achieved with the derive(Builder) syntax although with a bit more boilerplate. You'll need to rename the default generated build() method and make it private. Instead, write an impl block with your own custom build() method that uses the generated one internally:

#[derive(bon::Builder)]
#[builder(finish_fn(name = build_internal, vis = ""))]
struct PushToStream<'a> {
    output: &'a mut dyn std::io::Write,
    a: u32,
    b: u32,
}

impl<S: push_to_stream_builder::IsComplete> PushToStreamBuilder<'_, S> {
    fn build(self) -> std::io::Result<()> {
        let params = self.build_internal();
        write!(params.output, "a: {}, b: {}", params.a, params.b)
    }
}

fn main() {
    let result = PushToStream::builder()
        .output(&mut std::io::stdout())
        .a(1)
        .b(2)
        .build();
}

the naming can be improved with this approach - you don't have to call it literally build - maybe push(), or something else.

Veetaha avatar Jun 17 '25 11:06 Veetaha

  1. function approach is good, but is not sugar. For example I already have strut declared by soa-rs. soa-rs allows me low boilerplate maintenance of soa in rust (feels very natural). other scenario is having view declared via https://github.com/mcmah309/view-types . so i will need to maintain some code for function.

  2. yes, looks decent. thanks.

dzmitry-lahoda avatar Jun 25 '25 10:06 dzmitry-lahoda

  1. but still need to let params = self.build_internal(); , so got intermediate struct. can it be avoided? it macro on struct evaluated before impl block, it may be achivable, so will make internal API to be public?

again, feel free to close. i just think there is something, but fine some perf hit for now.

i just believe that direct integration with soa or view-types is eventually way to go.

dzmitry-lahoda avatar Jun 25 '25 10:06 dzmitry-lahoda

so got intermediate struct. can it be avoided

I don't think so, because #[derive(Builder)] creates a builder for a struct (as this is the main idea of deriving a builder from a struct). Also, there is not a big difference (at least at a high level) between accepting multiple parameters in a function or accepting a single parameter as a struct - anyway, all values will be stack-allocated and the compiler is pretty smart with optimising by inlining and eliminating intermediate structs.

it macro on struct evaluated before impl block, it may be achivable, so will make internal API to be public?

Not sure what you mean by this. Which internal API do you think could be made public? Like making builders' fields public? That is definitely not possible and not needed because all fields are wrapped in an Option internally, plus all the fields' default values are calculated inside the build_internal() method. And... we also use unwrap_unchecked() internally in the finishing function, which is unsafe to do unless fields are private.

i just believe that direct integration with soa or view-types is eventually way to go.

I understand the willingness to have nicer interoperability between bon and other crates, that's why bon provides a stable and flexible typestate API that allows anybody to wrap it with their own macros if they want. I, for example, don't use soa or view-types personally, or maybe if someone else comes with another idea of supporting "crate foo" that I don't use or even never heard about - that would scale poorly if we put every "foo" crate support into bon directly.

What I'm more interested in is to provide all the necessary APIs for people to build their own wrappers and syntax on top of the crates they are interested in, and bon - the typestate API and all the existing attributes is the way to do that for now.

For example, one could make a macro that provides nicer syntax for writing an impl block like the one I showed earlier:

impl<S: push_to_stream_builder::IsComplete> PushToStreamBuilder<'_, S> {
    fn build(self) -> std::io::Result<()> {
        let params = self.build_internal();
        write!(params.output, "a: {}, b: {}", params.a, params.b)
    }
}

I, find this syntax good enough, but if anyone wants to shorten it, they are free to generate this code with their own wrapper macro

Veetaha avatar Jun 25 '25 12:06 Veetaha