Concatenating strings¶
Level: 101 → 201 · working knowledge
One line: format!("{a} {b}") joins any two pieces of text, whoever owns them — and the reason there is no simpler answer is that + has exactly one impl, String + &str, so its left operand must own a buffer and its right must be borrowed.
| you write | left | right | result |
|---|---|---|---|
format!("{a}{b}") ↗ |
anything | anything | one new buffer, nothing consumed |
a + &b |
String |
&str |
a is consumed and its buffer becomes the answer |
parts.join(", ") ↗ |
a slice of either | one buffer, sized before it is filled | |
a + b |
String |
String |
E0308 — expected &str; borrow the right side |
"a" + "b" |
&str |
&str |
E0369 — neither side owns a buffer |
Joining two names¶
let first = "Ada";
let last = "Lovelace";
let a = format!("{first} {last}"); // "Ada Lovelace" — both still usable
let b = first.to_owned() + " " + last; // "Ada Lovelace" — an owned left operand
let c = String::from(first) + " " + last; // "Ada Lovelace" — same thing, spelled differently
All three allocate exactly one buffer, and all three leave first and last usable. to_owned() and String::from are the same call wearing different names — Making a String has the full set of five spellings and which one to prefer.
Reach for format! by default. It does not care who owns what, it takes as many pieces as you like, and it reads in the order the finished sentence does — where a + chain makes you sprinkle & and keep track of which variable you are about to lose.
The one impl there is¶
That single line is the whole subject. Left operand owned and consumed, right operand borrowed — no other combination has an implementation, so no other combination compiles.
Consuming the left side is not a wart. The result is that buffer, grown in place, so a chain like a + ", " + &b + "!" allocates nothing after the first piece:
let mut left = String::with_capacity(32);
left.push_str("Ada");
let full = left + " " + "Lovelace"; // "Ada Lovelace" — same heap buffer, no reallocation
The & on the right is compulsory for the same reason the left must be owned: &str is the only right-hand type there is.
…but what you actually pass is a &String¶
Every tutorial's version of this line has two Strings, so the right operand is a &String — and &String is not &str:
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // compiles: &String coerces to &str
It goes through because an argument position is a coercion site, and &String derefs to &str for free. The impl is still the only one there is; the compiler converted the argument to fit it.
That distinction has a visible edge. Coercion happens to a value at a call site; a trait bound needs a real impl — and String: Add<&String> is not one. So the expression compiles while a type annotation describing it does not:
error[E0277]: cannot add `&String` to `String`
|
| let s3: <String as Add<&String>>::Output = s1 + &s2;
| ^^^^^^ no implementation for `String + &String`
|
help: the trait `Add<&String>` is not implemented for `String`
but trait `Add<&str>` is implemented for it
The type of s1 + &s2 is <String as Add<&str>>::Output, which is String — and writing String is what anybody should actually put there. This one is worth meeting once because an IDE will occasionally offer the long form as a type hint, and accepting it turns working code into E0277 on the line that was only supposed to restate the type.
+= is push_str with an operator¶
let mut greeting = String::from("Hello, ");
greeting += first; // "Hello, Ada" — impl AddAssign<&str> for String
greeting += "!"; // "Hello, Ada!"
Same shape as Add, so the same rule: the thing on the left has to own a buffer.
More than two pieces¶
let parts = ["Ada", "Ben", "Cara"];
parts.concat(); // "AdaBenCara"
parts.join(", "); // "Ada, Ben, Cara"
Both work on a slice of &str or of String, and both walk the pieces once to size the buffer before filling it — one allocation, no growth. That is the thing a + chain cannot do, and the reason join is the right answer the moment the number of pieces stops being a number you typed.
The rest of the family¶
Beyond format!, +, concat and join, six more spellings turn up in real code. Only the first is genuinely different in kind:
| you write | what makes it different |
|---|---|
concat!("first", "second") ↗ |
a macro, evaluated at compile time — the result is a &'static str and costs nothing at run time. Literals only; it cannot see a variable |
s.push_str(x) ↗ · s.push(c) ↗ |
appends in place, no new value — see Building a String |
s += x |
AddAssign<&str>, the same rule as + with no rebinding |
s.extend([a, b]) ↗ |
Extend<&str>, for feeding an iterator into a string you already have |
iter.collect::<String>() ↗ · String::from_iter(..) ↗ |
build one from an iterator rather than into an existing buffer |
write!(&mut s, "{a}-{b}") ↗ |
format! semantics into a buffer you own; needs use std::fmt::Write and returns a Result |
str::repeat(n) is the odd one out — one piece, n times: "ab".repeat(2) is "abab".
concat! is the one worth remembering, because it is not doing the same job as the rest. It runs in the compiler, so there is no allocation and no runtime work at all — but its arguments must be literals, which is exactly why it cannot replace format!.
The leading piece is the one that must be owned¶
Because + only ever appends to its left operand, the first fragment of a sentence is the one that has to own bytes:
Pre-size that left operand with String::with_capacity and the whole four-piece chain performs zero allocations.
When the compiler refuses¶
Three error codes, one fact. Each of these is a line that does not compile, so they are shown commented out:
// let full = first + last; E0369: cannot add `&str` to `&str`
// let full = String::from(first) + String::from(last);
// E0308: expected `&str`, found `String`
// let mut t = "Hello, "; t += first; E0368: `+=` cannot be applied to `&str`
All three are the same complaint: + grows its left operand, so the left operand has to own bytes. E0308 is the near miss — the left side qualifies and the right side is one & away. E0369 and E0368 are the same missing buffer in two spellings.
The E0369 in full, because its note: line states the rule better than any prose here does:
error[E0369]: cannot add `&str` to `&str`
--> join.rs:4:22
|
4 | let full = first + last;
| ----- ^ ---- &str
| | |
| | `+` cannot be used to concatenate two `&str` strings
| &str
|
= note: string concatenation requires an owned `String` on the left
help: create an owned `String` from a string reference
|
4 | let full = first.to_owned() + last;
| +++++++++++
A &str is a pointer and a length — a view of bytes somebody else owns. Two literals point into the compiled binary itself, which is read-only and exactly the size of the text already in it. So first + last has nowhere to put the joined bytes, and rather than allocate one behind your back, Rust declines to have an operator for it. The help: is worth taking literally: .to_owned() on the left is the smallest edit that compiles, and format! is the one that stops the question arising.
If you are coming from another language¶
Python. + on two str just works, because every Python string owns its characters and the result is a third object — there is no borrowed-view type to be caught out by. Rust splits that one type in two, and + is only defined on the owning half.
| Python | Rust | |
|---|---|---|
a + b on two literals |
fine — both are str |
E0369 — both are &str |
str(a) + b |
a no-op, already a str |
a.to_owned() + b — now the left owns a buffer |
f"{a} {b}" |
new string, a and b untouched |
format!("{a} {b}") — the same, and the default answer |
", ".join(parts) |
separator first | parts.join(", ") — separator last, list first |
a += b |
rebinds a to a new object |
a += b — appends into a's existing buffer |
What actually changes: Python's + is always an allocation and never a mutation, so a + b costs the same whoever wrote it. Rust's is a move — a + &b consumes a, and using a afterwards is E0382. In exchange the operator is free of allocation, which is the trade the ownership system exists to offer. See Ownership and moves if that E0382 is the part that stings.
ABAP. && concatenates two strings into a third and never consumes either, exactly like Python's + — and exactly unlike Rust's.
| ABAP | Rust | |
|---|---|---|
lv = lv_a && lv_b. |
builds a new string | format!("{a}{b}") — the honest equivalent |
a + &b — cheaper, but a is gone afterwards |
||
CONCATENATE a b INTO c. |
statement form of the same | format!("{a}{b}") |
CONCATENATE LINES OF it INTO c SEPARATED BY ', '. |
joins an internal table | v.join(", ") |
\|Hello, { lv_name }!\| |
string template | format!("Hello, {name}!") |
lv = lv && lv_x. in a loop |
a new string each pass | lv.push_str(&x) — grows one buffer |
What actually changes: ABAP has one text type where Rust has two, so the question "does this side own its characters?" never comes up — && accepts any two strings, any two c fields, and any mix. Rust makes you answer it at every call. The payoff is the loop in the last row: ABAP's && is quadratic there and the standard advice is to avoid it, while push_str appends into the buffer you already have.
Practice¶
Greet two people three ways, then find the three refusals behind it. Build "Hello, Ada Lovelace!" with format!, with a chain of +, and with join — and for each, say which inputs are still usable afterwards. Then pre-size the + chain's left operand with String::with_capacity(64) and prove it never reallocates.
Now go the other way and make the compiler say no, on purpose, three times: first + last, then the same thing with two Strings and no &, then let mut t = "Hello, "; t += first;. Record the error code for each, and say in one line what all three are complaining about — it is the same fact three times.
Solution
concatenating_strings_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: greet two people three ways, then find the three refusals behind it.
//!
//! rustc --edition 2024 concatenating_strings_kata.rs -o /tmp/csk && /tmp/csk
fn main() {
let first = "Ada";
let last = "Lovelace";
println!("PART 1 — the greeting, three ways");
let by_format = format!("Hello, {first} {last}!");
println!(" format! {by_format:?}");
println!(" first = {first:?}, last = {last:?} — both still usable");
println!(" 1 buffer, sized from the finished string");
let by_plus = String::from("Hello, ") + first + " " + last + "!";
println!(" chained + {by_plus:?}");
println!(" first and last are only borrowed on the right, so both live");
println!(" 1 buffer, grown in four steps — see PART 3");
let by_join = ["Hello,", first, last].join(" ") + "!";
println!(" join {by_join:?}");
println!(" join sizes the buffer up front, then + adds the '!'");
println!("\n agree: {}", by_format == by_plus && by_plus == by_join);
println!("\nPART 2 — why the LEADING piece is the one that must be owned");
// `+` only ever appends to its left operand, so the first fragment of the
// sentence is the one that has to own a buffer. That is why PART 1 reads
// String::from("Hello, ") + ... and not "Hello, " + ...
let mut left = String::with_capacity(64);
left.push_str("Hello, ");
let ptr = left.as_ptr();
let cap = left.capacity();
let grown = left + first + " " + last + "!";
println!(" capacity {cap} before, {} after", grown.capacity());
println!(" same buffer the whole way: {}", grown.as_ptr() == ptr);
println!(" {grown:?}");
println!(" Four appends, zero new allocations — the left operand was pre-sized");
println!(" and every `+` handed the same buffer to the next one.");
println!("\nPART 3 — the three refusals");
// let x = first + last;
// error[E0369]: cannot add `&str` to `&str`
// = note: string concatenation requires an owned `String` on the left
//
// let a = String::from("Ada");
// let b = String::from("Lovelace");
// let y = a + b;
// error[E0308]: mismatched types — expected `&str`, found `String`
// help: consider borrowing here: a + &b
//
// let mut t = "Hello, ";
// t += first;
// error[E0368]: binary assignment operation `+=` cannot be applied to type `&str`
println!(" &str + &str E0369 neither side owns a buffer to grow");
println!(" String + String E0308 the ONE impl is Add<&str>; borrow the right side");
println!(" &str += &str E0368 same missing buffer, assignment spelling");
println!(" One fact, three spellings: `+` grows the left operand, so the left");
println!(" operand has to own bytes. PART 2 is that fact used deliberately.");
println!("\nPART 4 — what to write");
println!(" Reach for format! by default: it does not care who owns what, and it");
println!(" reads as the sentence it produces. Reach for + only when you already");
println!(" hold an owned String on the LEFT and are finished with it.");
}
Verified output of concatenating_strings_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
PART 1 — the greeting, three ways
format! "Hello, Ada Lovelace!"
first = "Ada", last = "Lovelace" — both still usable
1 buffer, sized from the finished string
chained + "Hello, Ada Lovelace!"
first and last are only borrowed on the right, so both live
1 buffer, grown in four steps — see PART 3
join "Hello, Ada Lovelace!"
join sizes the buffer up front, then + adds the '!'
agree: true
PART 2 — why the LEADING piece is the one that must be owned
capacity 64 before, 64 after
same buffer the whole way: true
"Hello, Ada Lovelace!"
Four appends, zero new allocations — the left operand was pre-sized
and every `+` handed the same buffer to the next one.
PART 3 — the three refusals
&str + &str E0369 neither side owns a buffer to grow
String + String E0308 the ONE impl is Add<&str>; borrow the right side
&str += &str E0368 same missing buffer, assignment spelling
One fact, three spellings: `+` grows the left operand, so the left
operand has to own bytes. PART 2 is that fact used deliberately.
PART 4 — what to write
Reach for format! by default: it does not care who owns what, and it
reads as the sentence it produces. Reach for + only when you already
hold an owned String on the LEFT and are finished with it.
The verified output¶
Verified output of concatenating_strings.rs — regenerated by tools/run_examples.py, never hand-typed.
1. Joining two names
format!("{first} {last}") "Ada Lovelace"
first.to_owned() + " " + last "Ada Lovelace"
String::from(first) + " " + last "Ada Lovelace"
All three allocate exactly one buffer, and all three leave
first = "Ada" and last = "Lovelace" usable afterwards.
Only format! reads in the order the finished sentence does.
2. The one impl there is: String + &str
left right compiles why
String &str yes the left buffer is taken and grown
String String NO E0308 — expected `&str`, borrow the right
&str &str NO E0369 — neither side owns a buffer
&str String NO E0369 — the left side still owns nothing
3. What `+` does to its left operand
capacity before 32, after 32
same heap buffer reused: true
"Ada Lovelace"
The answer IS the left buffer, grown — which is why `+` consumes it,
and why the right side, being only borrowed, survives: "Lovelace"
4. `+=` is push_str wearing an operator
"Hello, Ada!"
5. More than two pieces
parts.concat() "AdaBenCara"
parts.join(", ") "Ada, Ben, Cara"
owned.join(" | ") "Ada | Ben | Cara"
Both take a slice of &str OR of String, and both size the buffer
up front — which a chain of `+` cannot do.
6. The three refusals, and the one fact under them
&str + &str E0369 neither side owns a buffer to grow
String + String E0308 the one impl is Add<&str>; borrow the right
&str += &str E0368 same missing buffer, assignment spelling
All three say one thing: `+` grows its LEFT operand, so the left
operand has to be something that owns bytes.
6b. The right operand is usually a &String, not a &str
String + &String -> "Hello, world!"
The impl is Add<&str>. &String is not &str, but deref coercion
converts it at the argument position, so the call goes through.
Coercion is a call-site conversion, NOT an extra impl — which is
why <String as Add<&String>>::Output cannot be named: E0277.
6c. The rest of the family
concat!(..) "firstsecond" <- COMPILE TIME, a &'static str
s.extend([a, b]) "xfirstsecond"
[a, b].iter().collect() "firstsecond"
String::from_iter([a,b]) "firstsecond"
write!(&mut s, ..) "first-second" <- needs `use std::fmt::Write`
a.repeat(2) "firstfirst"
7. Which to reach for
two or three known pieces -> format!
a whole collection -> .join(sep) / .concat()
a left value you are done with -> + , and let it eat the buffer
appending in a loop -> push_str — see Building a String
Run it yourself:
rustc --edition 2024 14_Strings/concatenating_strings/examples/concatenating_strings.rs -o /tmp/cs && /tmp/cs
See also¶
- STRINGS.md — the map: every string lesson, in reading order
- Building a
String— the other half of this:push_str,write!, editing in the middle, and what to do inside a loop - Making a
String—to_ownedvsto_stringvsString::fromvsinto Stringvs&str— why the two types exist, if the split is still new- Ownership and moves — the
E0382you get from touching the left operand afterwards - The anatomy of a
String— the capacity the+chain is reusing std::ops::AddforString↗ ·rustc --explain E0369↗ · The Rust Book, ch. 8.2 ↗
Po polsku¶
W Javie, C#, Pythonie czy ABAP-ie + (albo &&) na napisach jest symetryczny: obie strony są równoprawne, nikt niczego nie traci, a wynikiem jest trzeci obiekt. W Ruscie jest inaczej i wszystko wynika z jednego wiersza w bibliotece standardowej — impl Add<&str> for String. To jedyna implementacja dodawania dla łańcuchów, więc lewy operand musi posiadać bufor (i zostaje skonsumowany), a prawy musi być pożyczonym wycinkiem. Stąd trzy różne kody błędu, które mówią dokładnie to samo: E0369 dla "a" + "b" (żadna ze stron nie ma bufora — literał leży w pliku wykonywalnym, w pamięci tylko do odczytu, dokładnie tej wielkości co tekst), E0308 dla a + b przy dwóch String-ach (brakuje & po prawej) i E0368 dla += na &str. Jedno zdanie do zapamiętania: + rozbudowuje lewy operand, więc lewy operand musi mieć co rozbudowywać.
Jest tu jednak drobiazg, na którym łatwo się przewrócić przy czytaniu cudzego kodu. W typowym przykładzie z podręcznika po prawej stronie stoi &s2, czyli &String — a &String nie jest &str. Kod się kompiluje, bo argument wywołania jest miejscem, w którym działa koercja przez Deref (deref coercion): kompilator sam zamienia &String na &str. Koercja dotyczy jednak wartości w konkretnym miejscu wywołania, a nie typu — dlatego wyrażenie s1 + &s2 działa, ale próba nazwania jego typu jako <String as Add<&String>>::Output kończy się E0277, bo takiej implementacji naprawdę nie ma. Warto to zobaczyć raz, bo IDE potrafi podpowiedzieć właśnie tę długą formę i „poprawić” działający kod na niedziałający. Prawidłowa nazwa tego typu to po prostu String.
Praktycznie wybór jest prosty i wygląda tak: domyślnie format! — nie interesuje go, kto jest właścicielem czego, przyjmuje dowolnie wiele kawałków i czyta się go w kolejności powstającego zdania. + bierz tylko wtedy, gdy już trzymasz String po lewej i nie będzie ci potrzebny — wtedy jest najtańszy ze wszystkich, bo wynikiem jest ten sam bufor, a przy String::with_capacity cały łańcuch doklejeń nie alokuje ani razu. Gdy kawałków jest tyle, ile akurat wyszło z kolekcji, właściwą odpowiedzią jest join(", ") albo concat(): obie najpierw przechodzą po elementach, żeby policzyć rozmiar, i dopiero potem alokują raz.
Osobno stoi concat! z wykrzyknikiem — to makro wykonywane w czasie kompilacji, którego wynikiem jest &'static str, więc w czasie działania programu nie kosztuje nic. Nie da się nim jednak skleić zmiennej: przyjmuje wyłącznie literały. Mimo bliźniaczej nazwy nie jest to zamiennik concat() ani format!.
Szukaj po polsku: łączenie łańcuchów znaków · koercja przez Deref · przeniesienie własności lewego operandu · rust E0369 cannot add &str to &str · rust String concatenation format!