String: build, grow and search
In Rust String is the owned text type: a pointer, a length and a capacity into a UTF-8 buffer on the heap. &str is the borrowed view of the same bytes, and the two are the text pair of the language. This lesson is about the owned one and the methods that grow and change it.
Coming from C++? Strings writes append, index, find and substr in both languages, and shows the one line that C++ accepts and rustc refuses: writing a character by index.
The methods below are the ones a first program needs: build, append, find, replace, split, truncate. Every one of them keeps the text valid UTF-8, which is why some of them work on boundaries rather than on bytes.
Building a String
String::new() starts empty, push_str appends a slice and push appends one character. The buffer grows as needed, and len reports the bytes written. The macro format! builds a string from a template, which the lesson on formatting covers.
The program builds two words and a space, then prints the text and its length. Eleven bytes is the length of the five letters, the space and the five letters.
fn main() {
let mut text = String::new();
text.push_str("hello");
text.push(' ');
text.push_str("world");
println!("{text} {}", text.len());
}
rustc --edition 2024 build.rs
./build
hello world 11
The buffer may have more capacity than length, which is why a string built in a loop is cheap after the first few pushes. A capacity is not part of the text: comparing two strings compares their bytes and their lengths, never the room behind them, and a clone copies the text but not the spare space. The lesson on Vec explains the same growth for a vector.
Finding and replacing
contains answers yes or no, find returns the byte index of the first match as an Option, and replace returns a new string with every match changed. None of them changes the original.
The program looks for two words in a sentence and prints the answer, the index and the replaced text. The index is ten, which is the byte where the second word starts.
fn main() {
let text = String::from("the quick brown fox");
println!("{}", text.contains("quick"));
println!("{:?}", text.find("brown"));
println!("{}", text.replace("quick", "slow"));
}
rustc --edition 2024 search.rs
./search
true
Some(10)
the slow brown fox
The Some(10) form is the debug print of an Option, and the lesson on Option shows how to handle the None case without unwrapping.
Counting words
split_whitespace divides a string on runs of whitespace, and each item is a slice of the original. The loop counts them one by one, which is the smallest complete use of the method.
The sentence has six words, three of them repeated. The count is the answer, and it does not allocate: the slices point into the original string.
fn main() {
let text = String::from("one two two three three three");
let mut count = 0;
for _word in text.split_whitespace() {
count += 1;
}
println!("words: {count}");
}
rustc --edition 2024 word_count.rs
./word_count
words: 6
Counting distinct words needs a map, which the lesson on HashMap builds. The slice iterator is the input to that program as well.
Changing a string in place
A String may be truncated, extended and edited at a byte index, as long as the result is valid UTF-8. truncate cuts it, insert_str inserts at a position, and both keep the length in step.
The program cuts the text to five bytes and then inserts a prefix. The result is the old first word with a new beginning, and the length changed twice.
fn main() {
let mut text = String::from("hello world");
text.truncate(5);
text.insert_str(0, "oh ");
println!("{text}");
}
rustc --edition 2024 modify.rs
./modify
oh hello
An index inside a multi-byte character panics, because the operation would produce invalid UTF-8. The panic names the byte index and the length of the text, and it happens at run time because the bytes only exist then. The next lesson shows how to find a safe boundary before slicing, and why every method that takes an index either checks the boundary or documents that it panics.
A literal is not a String
A string literal has the type &str, so assigning it to a String binding is E0308. The compiler offers to_string() in the help, which allocates a buffer and copies the bytes.
The program below asks for a String and writes a literal. The error names both types and the conversion, which is the whole fix.
fn main() {
let text: String = "hello";
println!("{text}");
}
rustc --edition 2024 str_err.rs
error[E0308]: mismatched types
--> str_err.rs:2:24
|
2 | let text: String = "hello";
| ------ ^^^^^^^ expected `String`, found `&str`
| |
| expected due to this
|
help: try using a conversion method
|
2 | let text: String = "hello".to_string();
| ++++++++++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.
String::from and to_owned do the same job as to_string, and the lesson on &str explains when no conversion is needed at all.
Exercises
- Build a sentence from three words with push_str and print its length after each step.
- Replace every space in a sentence with a dash and print both versions. Which method returns a new string?
- Truncate a string in the middle of a multi-byte character and read the panic. Which index would be safe?
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does a String own?
Answer
A UTF-8 buffer on the heap
String is the owned text type: pointer, length and capacity into a heap buffer.
-
Which method appends a string slice to a String?
Answer
push_str
push_str takes a &str and copies its bytes onto the end; push takes one char.
-
What does
findreturn?Answer
An Option with the byte index of the first match
The match may be absent, so the result is an Option with the index.
-
What does
replacedo to the original String?Answer
Nothing; it returns a new String
replace builds and returns a new string, and the receiver is only borrowed.
-
What does this program print?
fn main() { let mut text = String::new(); text.push_str("hello"); text.push(' '); text.push_str("world"); println!("{text} {}", text.len()); }Answer
hello world 11
The two words and the space make eleven bytes.
-
What does this program print?
fn main() { let text = String::from("the quick brown fox"); println!("{}", text.contains("quick")); println!("{:?}", text.find("brown")); }Answer
true / Some(10)
The word is present, and it starts at byte ten after the first two words and spaces.
-
What does this program print?
fn main() { let text = String::from("one two two three three three"); let mut count = 0; for _word in text.split_whitespace() { count += 1; } println!("words: {count}"); }Answer
words: 6
The sentence has six whitespace-separated words, including the repeats.
-
What does this program print?
fn main() { let mut text = String::from("hello world"); text.truncate(5); text.insert_str(0, "oh "); println!("{text}"); }Answer
oh hello
The truncation keeps hello, and the insert puts the new prefix in front of it.
-
What is the first line of the compiler output for this program?
fn main() { let text: String = "hello"; println!("{text}"); }Answer
error[E0308]: mismatched types
The literal is a &str, and the help suggests to_string as the conversion.
-
What does this program print?
fn main() { let text = String::from("a b"); let replaced = text.replace(" ", "-"); println!("{text} {replaced}"); }Answer
a b a-b
replace returns a new string, so the original keeps its space and the copy has a dash.
The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev