Add an easier way to append `T: Display` to `String` to stdlib?

I am regularly annoyed when I need to append new formatted content to an existing buf: &mut String.

I think the official way to do this is

use std::fmt::Write;

write!(buf, "{}", data).unwrap();

The problem with this is:

  • You need to import Write
  • It's hard to remember which Write should be imported
  • there's unwrap that can't actually fire

For this reason, I often just do buf += &format!("...") or buf += &data.to_string(), which is nicer to write, but is horrible semantics-wise.

Do other folks percieve this as a problem that needs to be solved? My proposal would be to add a second form to the format macro, so that the original example could be written as

format!(buf, "{}", data)
9 Likes

There's a couple of obnoxious questions, I think, mostly of the form "are just strings allowed in the new format!() overload"? Vec<u8> is, technically, a reasonable choice here. I suspect that in this case we wind up with a variant of Write that does not return errors.

1 Like

I share your annoyance to some degree. Your format! idea is a nice hack, but it feels mildly uncomfortable. Namely, the addition of buf turns something that once returned a String to something that returns nothing, but modifies an argument in place. It feels a little jarring to me at first blush, and I don't know whether that would subside after more use of it. (I can't think of any widely used macro with similarish behavior.)

4 Likes

Yeah, it's definitely true that such macro overloading is unusual. An obvious boring alternative is to add a new macro, like format_to!(buf, "{fmt}", args...). One more option is to use log's syntax: format!(to: buf, "{fmt}", args).

I think I mildly prefer the overloading version though: it's unusal for Rust, but the general idea of "operation allocates by default, but can use a provided buffer" seems sound. For example, Kotlin's alternative to .collect works this way: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/to-map.html.

Inb4 "this should be a crate": this is a crate :slight_smile:

However I feel that such small convenience utilities belong to std.

I don't share this sentiment; there are only ever two Writes you need to chose from, and you know it's fmt::Write because you want to write to a String, which, being guaranteed UTF-8, can't accept arbitrary bytes like the bytestream-oriented io::Write.

However, we could add fmt::Write to the prelude instead, as wanting to append to a buffer in-place is fairly common. A separate append_format! macro would also be nice to hide the spurious unwrap. I'm most definitely against "overloading" the already-existing format! macro though, for the reasons listed above plus the fact that it's wildly inconsistent with every other formatting purpose having its very own macro (think format!, write!, format_args!, even println! vs. eprintln!).

2 Likes

Hm, eprintln! example makes me think, that, if we go with a separate macro, sprint / sprintln might be a good choice, as it resembles printf / sprintf.

However, we could add fmt::Write to the prelude instead

I think this wouldn't be backwards compatible in practice: both io::Write and fmt::Write have the same signature for write_fmt (precisely to make write!s duck typing work), so having both in scope could lead to method selection ambiguity errors.

What we could do though, is to add write_fmt inherent method for String. We'll have to use the -> Result signature though to be backwards compatible, so that won't rid us from .unwrap().

Except that's what C and Go call format!()... =(

Something something Result<T, !> something something hack #[must_use] to not fire on Result<T, !>?

1 Like

.unwrap() could just be part of the macro (or a lower-level formatting function it calls), as it is in the case of other infallible formatting macros like format!() itself, which generates calls to alloc::fmt::format, which in turn expect()s the returned Result.

I find this tangentially related to https://users.rust-lang.org/t/crates-that-use-the-inherent-trait-method-pattern/30381 ; what about

impl String {
   #[inline]
   fn push_fmt (self: &'_ mut Self, args: fmt::Arguments)
   {
       fmt::Write::write_fmt(self, args)
           .expect("a formatting trait implementation returned an error");
   }
}
buf.push_fmt(format_args!("{}", data));

?


Otherwise the format_to! option is not too bad: with a hack it could use the kind of string literal to choose which Write to use:

  • a string literal to use fmt::Write;

  • a bytestring literal for io::Write.

I'm not sure I'm too happy about that? I would hope that write!(w, ..) would return any IO errors, rather than crashing.

That's not at all what I'd expect format!(buf, ...) to mean; I'd expect that to replace the contents of buf, not append to them.

I do absolutely agree that having to import Write feels onerous. If we had only one Write rather than two, I'd expect fmt::Write to appear in the prelude. We have two Write traits, however.

In the standard library itself, we don't have any overlapping implementations of the two Write traits. In theory, someone could implement both traits on one type, in which case importing either trait in the prelude could break compatibility. In practice, I wonder if anything would actually break if the prelude imported both Write traits.

2 Likes

That’s an interesting observation! I see how replace could be an expected behavior here. I have a strong expectation for append because it matches the standard rust pattern: BufRead in std::io - Rust . But that is because I misuse read_line every single time :smiley:

1 Like

I’d like to reiterate that if we want to solve the import problem, it would be sufficient to add write_fmt inherent method for string. This is exactly how fmt::Formatter works: the method is inherent, macro uses “duck typing”, so no import is required.

2 Likes

I think that's only an expectation if you're using sprintf(), I think? In C++ I'm used to using Abseil for string handling, where everything matches the Rust convention (std::string*s are appended to). Case in point, absl::StrAppendFormat is exactly like write!.

(I mean yes it says Append in the name but appending is 99% of the times what I want and std::string::clear exists, exactly like in Rust.)

A provided method on ToString seems like a potential solution.

Formatting into an in-memory string buffer is not supposed to ever return an Err, though. That's why format!() and the ToString blanket impl for Display can directly return a String, instead of a fmt::Result<String>, according to what is explicitly stated in the official documentation.

1 Like

It's interesting to me that a Display implementation returning Err is defined as an "incorrect Display implementation". Why is Display not allowed to fail?

Does the same principle apply to the Formatter trait and any custom implementations of that?

If that's something we want to guarantee, is there some way we could update the types or guarantees involved (perhaps involving the introduction/deprecation of functions) to guarantee that? For instance, could we introduce a new infallible method to Display, with a default implementation that calls fmt and .expect?

If I interpret this earlier explanation correctly, it's that formatting is by itself considered an infallible operation (and thus Display impls aren't supposed to produce errors on their own), but there is one reason it might fail: if an error is generated in the underlying writer itself, which only needs to be able to be propagated, hence the Result return type. However, writing to a memory buffer is expected never to fail.

3 Likes