Skip to content

S32: String building with + in a loop, format! used only to convert

S32Severity: Low

Extra allocations and noise when building or converting strings.

Clippy: string_add, string_add_assign, to_string_in_format_args, implicit_clone

pub fn report(orders: &[Order]) -> String {
let mut text = String::new();
for order in orders {
text = text + &format!("{}", order.id) + ": " + &order.customer.to_string();
text = text + &format!(" ({} cents)\n", order.total_cents.to_string());
}
text
}
pub fn order_key(order: &Order) -> String {
format!("{}", order.id)
}
  • It is Java’s += loop. In Java, text += id + ": " + customer is the first thing that people write, and it works. The agent translates it with + and format!.
  • format!("{}", x) works for every type, so it is a safe guess for “convert to a string”. The agent does not need to know that to_string() exists.
  • .to_string() is added to make types match. + needs a &str on the right, and the agent adds conversions until the compiler accepts it.
  • Extra allocations. In the first line of the loop, format!("{}", order.id) allocates a new String, and order.customer.to_string() copies a name that was already a string. Each + can grow the buffer of text and copy it. None of this is necessary.
  • Noise. format!(" ({} cents)\n", order.total_cents.to_string()) converts the number to a string, and then formats the string. The reader must check each conversion to see that it does nothing.
  • The text format is hard to see. The line format is spread across two statements and four + operators. With one writeln!, the format is one string.

The cost is small for a short report. It grows with the size of the data, and in a loop that runs for every request, it adds up. Do not guess: measure before you call it a performance problem. In a review, it is mainly a readability finding.

  1. Worse+ with format! and to_string() in a loop, and format!("{}", x) to convert a number.

    pub fn report(orders: &[Order]) -> String {
    let mut text = String::new();
    for order in orders {
    text = text + &format!("{}", order.id) + ": " + &order.customer.to_string();
    text = text + &format!(" ({} cents)\n", order.total_cents.to_string());
    }
    text
    }
    pub fn order_key(order: &Order) -> String {
    format!("{}", order.id)
    }
  2. BetterOne writeln! for each line into the same buffer, and to_string() to convert.

    pub fn report(orders: &[Order]) -> String {
    let mut text = String::new();
    for order in orders {
    // `writeln!` on a `String` cannot fail, so `expect` documents an invariant.
    writeln!(
    text,
    "{}: {} ({} cents)",
    order.id, order.customer, order.total_cents
    )
    .expect("writing to a String never fails");
    }
    text
    }
    pub fn order_key(order: &Order) -> String {
    order.id.to_string()
    }

write! and writeln! format directly into an existing String, with no temporary String. They need use std::fmt::Write. Writing to a String cannot fail, but write! returns a fmt::Result for all writers, so the result must be handled: an expect with a message is the usual choice. Some teams prefer let _ = writeln!(...) here. Both are fine, and the choice should be the same in the whole codebase.

For lists, join does it all:

pub fn customer_list(orders: &[Order]) -> String {
orders
.iter()
.map(|o| o.customer.as_str())
.collect::<Vec<_>>()
.join(", ")
}
  • One + outside a loop is clear, and it reuses the buffer of the left string:
/// One `+` outside a loop is clear and allocates no more than `format!`.
pub fn with_suffix(name: String) -> String {
name + " (VIP)"
}
  • format! with a real template, such as format!("{customer}.csv"), is the clearest way to build a short string.
  • Code that runs once, for example the text of a startup error, does not need optimization. Still prefer the clearer form.
  • Clippy string_add (restriction group, off by default) reports + on a String. Clippy string_add_assign (pedantic group) reports s = s + ....
  • Clippy to_string_in_format_args (performance group, on by default) reports .to_string() inside format! arguments. Clippy implicit_clone (pedantic group) reports .to_string() on a value that is already a String.
  • No default lint reports format!("{}", x) used only to convert a number. Search for format!("{}", with a single argument.
  • In a review, look for String::new() before a loop, and + or format! inside it.
In report, build the text with one writeln! call for each order, into the
same String (use std::fmt::Write). Remove the format!("{}", ...) and
.to_string() calls that only convert. In order_key, use order.id.to_string()
instead of format!. Use join for lists of names.

Related: S05: Owned parameters (String, Vec<T>) where a borrow is sufficient, and Strings.