Skip to content

NumBuffer<T> ties a buffer to one integer type; no reusable buffer for mixed-integer formatting #159341

Description

@rosymati

The std-replacement-data PR rust-lang/std-replacement-data#19 flagged core::fmt::NumBuffer as the std replacement for itoa. I tried to migrate off itoa and couldn't: the current API doesn't let me reuse one buffer across integer types.


NumBuffer<T> is generic over the concrete integer type, and format_into takes &mut NumBuffer<Self>, so a buffer is bound to exactly one integer type. Code that formats several integer types has to hold a separate buffer per type, and can't hide that behind a single generic helper, since the caller still has to supply the matching NumBuffer<T>.

itoa (and zmij for floats) don't have this: Buffer is a single non-generic type and format is generic over the value, so one buffer instance serves every integer (or float) type.


I hit this in a printf implementation. With itoa, one buffer covers every integer the format string throws at it:

use core::ffi::{c_char, c_double, c_int, c_long, c_uint};

// parsing/writing omitted for brevity
unsafe fn printf_internal(mut args: va_list) {
    let mut ints = itoa::Buffer::new();   // one buffer, every integer type
    let mut floats = zmij::Buffer::new(); // one buffer, every float type

    let buf: &[u8] = match spec {
        b'd' | b'i' => ints.format(unsafe { args.next_arg::<c_int>() }).as_bytes(),
        b'u'        => ints.format(unsafe { args.next_arg::<c_uint>() }).as_bytes(),
        b'l'        => ints.format(unsafe { args.next_arg::<c_long>() }).as_bytes(),
        b'f'        => floats.format(unsafe { args.next_arg::<c_double>() }).as_bytes(),
        b's'        => unsafe { CStr::from_ptr(args.next_arg::<*const c_char>()) }.to_bytes(),
        _ => todo!(),
    };
}

The NumBuffer translation needs one buffer per integer type, and the list grows with every width/signedness a real printf supports (%lld, %zu, %jd, %td, ...):

use core::ffi::{c_char, c_double, c_int, c_long, c_uint};
use core::fmt::NumBuffer;

unsafe fn printf_internal(mut args: va_list) {
    let mut floats = zmij::Buffer::new(); // NumBuffer is integers only

    let mut i = NumBuffer::<c_int>::new();
    let mut u = NumBuffer::<c_uint>::new();
    let mut l = NumBuffer::<c_long>::new();
    // + one NumBuffer per additional type, up to 10 to cover all widths

    let buf: &[u8] = match spec {
        b'd' | b'i' => unsafe { args.next_arg::<c_int>() }.format_into(&mut i).as_bytes(),
        b'u'        => unsafe { args.next_arg::<c_uint>() }.format_into(&mut u).as_bytes(),
        b'l'        => unsafe { args.next_arg::<c_long>() }.format_into(&mut l).as_bytes(),
        b'f'        => floats.format(unsafe { args.next_arg::<c_double>() }).as_bytes(),
        b's'        => unsafe { CStr::from_ptr(args.next_arg::<*const c_char>()) }.to_bytes(),
        _ => todo!(),
    };
}

What I'd like

Some sanctioned way to drive one reusable buffer for every integer type, matching itoa's Buffer::format.

#143636 attempted this by making format_into generic (one NumBuffer<u128> backing any narrower integer). From what I can tell that was rejected, at least in that form: the suggestion was instead a method on NumBuffer that converts it to a NumBuffer of a different type, gated by trait bounds, and tracked separately from the rest of int_format_into. This issue could be that follow-up. I don't have a preference on the mechanism, I just need one buffer to serve multiple integer types without a distinct binding per type.

Refs: #138215 (tracking issue), #143636 (rejected generic format_into attempt).

cc @GuillaumeGomez

Activity

  1. added
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Jul 15, 2026
  2. added
    T-libs-api[DEPRECATED; DO NOT USE]
    C-feature-requestCategory: A feature request, i.e: not implemented / a PR.
    and removed
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Jul 15, 2026
  3. GuillaumeGomez commented on Jul 15, 2026

    @GuillaumeGomez
    Member

    Thanks for opening this issue! I'll send a PR to add the method conversion between different NumBuffer types so the @rust-lang/libs-api can decide which approach they prefer.

  4. hanna-kruppe commented on Jul 15, 2026

    @hanna-kruppe
    Contributor

    Can you share a little more of the surrounding code? I assume there’s a reason for the buf variable pointing into a longer-lived buffer, as opposed to the usual foo(n.format_into(&mut NumBuffer::new())) pattern, but it would be good to know for sure what the reasons are.

  5. rosymati commented on Jul 15, 2026

    @rosymati
    ContributorAuthor

    Can you share a little more of the surrounding code? I assume there's a reason for the buf variable pointing into a longer-lived buffer, as opposed to the usual foo(n.format_into(&mut NumBuffer::new())) pattern, but it would be good to know for sure what the reasons are.

    I can't share the full code since it's not open yet, but here's an outdated but still relevant snippet for context:

    unsafe fn printf_internal<Fd: AsFd>(fd: Fd, format: *const c_char, mut args: va_list) -> c_int {
        die!(format);
    
        let str = unsafe { CStr::from_ptr(format) }.to_bytes();
    
        let mut bytes_iter = str.iter().peekable();
    
        let mut number_buffer = itoa::Buffer::new();
        let mut float_buffer = ryu::Buffer::new();
    
        let mut written = 0usize;
    
        'spec_loop: while let Some(byte) = bytes_iter.next() {
            'spec_blk: {
                if *byte == b'%'
                    && let Some(spec) = bytes_iter.peek()
                {
                    let buf = match *spec {
                        b'd' | b'i' => {
                            let arg: c_int = unsafe { args.next_arg() };
                            number_buffer.format(arg).as_bytes()
                        }
                        b'u' => {
                            let arg: c_uint = unsafe { args.next_arg() };
                            number_buffer.format(arg).as_bytes()
                        }
                        b's' => {
                            let arg: *const c_char = unsafe { args.next_arg() };
                            unsafe { CStr::from_ptr(arg) }.to_bytes()
                        }
                        b'f' => {
                            let arg: c_double = unsafe { args.next_arg() };
                            float_buffer.format(arg).as_bytes()
                        }
                        _ => {
                            break 'spec_blk;
                        }
                    };
    
                    written += try_io!(write_all(&fd, buf));
    
                    bytes_iter.next();
                    continue 'spec_loop;
                }
            }
    
            written += try_io!(write_all(&fd, &[*byte]));
        }
    
        written as _
    }

    The buffer needs to outlive the match. Each arm resolves to one &[u8] that's consumed after the match, so if the buffer lived inside the match it would be dropped before that and the borrow wouldn't compile.

    The real implementation is larger: there's shared code for padding and buffering that's more involved than a plain write_all, and keeping the buffer outside the match lets that run once afterward. Otherwise it would have to be duplicated into every arm.

    Technically I could push a helper call into each arm (duplicating that logic) or keep ~10 separate buffers, but both are clearly less ergonomic than just using itoa. If this worked in std I'd be able to drop the dependency.

  6. esther-ff commented on Jul 16, 2026

    @esther-ff

    My 2 cents is that in #143636 there is a very similar case being presented about the need for a reusable buffer.

    I have an idea, making a specific ZST (something like struct Variable) implement NumBufferTrait with it's BUF_SIZE being large enough to hold an i128 and exposing a specific impl (like impl NumBuffer<Variable>) with a generic format_into method, which would make NumBuffer very versatile.

  7. GuillaumeGomez commented on Aug 4, 2026

    @GuillaumeGomez
    Member

    I opened #160514 to try out a new approach.

  8. added
    T-libsRelevant to the library team, which will review and decide on the PR/issue.
    and removed
    T-libs-api[DEPRECATED; DO NOT USE]
    on Aug 12, 2026
  9. Rodrigodd commented on Oct 20, 2026

    @Rodrigodd

    I am not completely familiar with the NumBuffer API, but would it be possible to do something like impl From<&mut Buffer<u64>> for &mut Buffer<u32>?

    I was actually wondering whether it would be possible to impl TryFrom<&mut [u8]> for &mut Buffer<u32> so that any generic scratch buffer could be reused as a NumBuffer. My actual use case was to format numbers into a buffer in place, but that would only work for writing right-aligned literals.

  10. GuillaumeGomez commented on Oct 20, 2026

    @GuillaumeGomez
    Member

    It's possible. However we'd like to make it more flexible in the way that any NumBuffer<T> can be used as a NumBuffer<U> as long as T::size >= U::size. For now we're blocked on some missing const generics features to make it work nicely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-feature-requestCategory: A feature request, i.e: not implemented / a PR.T-libsRelevant to the library team, which will review and decide on the PR/issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions