A read-only function that requires String makes its caller surrender ownership or allocate a copy. The opposite mistake is just as concrete: a &str cut from a request body cannot be stored in a database or job queue after that body is dropped. The useful rule for Rust text APIs is short. Borrow while reading or parsing; own text that must outlive its input.
This article builds a parser for endpoint records written as name | URL | tags. It returns &str fields without copying the input, then converts them to String only at the storage boundary. Korean names and URL paths exercise the UTF-8 cases that ASCII examples tend to hide. The finished example is an independent Rust 2024 project.
cd examples/article-09-slices-strings
cargo run --quiet -- '서울 API | https://example.com/상태 | critical, internal'
name=서울 API
url=https://example.com/상태
tags=critical, internal
preview=서울
1. Start with Text Retention
String is an owned, growable UTF-8 string. It owns its buffer, so it can move into stored state and can be mutated. A &str is a borrowed view of valid UTF-8 text stored elsewhere. A string literal is a &str; borrowing part or all of a String also produces a &str.
These defaults work well at public API boundaries:
- Accept
&strwhen a function only reads text. Both literals and borrowedStringvalues work. - Use
Stringfor owned state or a newly assembled return value. - Take
Stringby value when the caller already owns text that the function must keep. Moving it needs no copy. - Accept
&[T]when a function only reads a sequence. Arrays and vectors can both provide that view.
&String is not invalid, but it accepts fewer inputs than &str for a read-only parameter. Changing a parameter from &String to &str lets one function accept owned strings through borrowing as well as string slices directly.
2. Borrow While Parsing
The endpoint parser builds a struct whose fields point into the original line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BorrowedEndpoint<'a> {
pub name: &'a str,
pub url: &'a str,
tags: &'a str,
}
impl BorrowedEndpoint<'_> {
pub fn tags(&self) -> impl Iterator<Item = &str> {
self.tags
.split(',')
.map(str::trim)
.filter(|tag| !tag.is_empty())
}
}
pub fn parse_endpoint_line(line: &str) -> Result<BorrowedEndpoint<'_>, ParseError> {
let mut fields = line.split('|').map(str::trim);
let name = fields
.next()
.filter(|value| !value.is_empty())
.ok_or(ParseError::MissingName)?;
let url = fields
.next()
.filter(|value| !value.is_empty())
.ok_or(ParseError::MissingUrl)?;
let tags = fields.next().ok_or(ParseError::MissingTags)?;
if fields.next().is_some() {
return Err(ParseError::ExtraField);
}
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err(ParseError::UnsupportedScheme);
}
Ok(BorrowedEndpoint { name, url, tags })
}
Neither split nor trim creates a new String. The name, url, and tags fields all refer to spans inside line. The '_ in the return type asks the compiler to infer that the result cannot outlive the input. If the caller tries to clear or mutate the source String too early, the borrow checker rejects that code.
The tags stay lazy as well. tags() splits them when iterated instead of immediately building a Vec<String>. If the request buffer lives for the whole operation, the borrowed representation may be all the application needs.
3. Ownership at the Storage Boundary
Copying becomes necessary when parsed data must outlive the request buffer. A cache, background job, or database record creates that boundary. The example puts the conversion in one From implementation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnedEndpoint {
pub name: String,
pub url: String,
pub tags: Vec<String>,
}
impl From<BorrowedEndpoint<'_>> for OwnedEndpoint {
fn from(endpoint: BorrowedEndpoint<'_>) -> Self {
Self {
name: endpoint.name.to_owned(),
url: endpoint.url.to_owned(),
tags: endpoint.tags().map(str::to_owned).collect(),
}
}
}
The to_owned() calls mark the allocations. Parsing stays allocation-free for these fields; only callers that choose storage pay for copies. If a function instead receives a String by value and can place it directly into a struct, another to_owned() would be wasteful.
A batch API can borrow a slice rather than name a concrete container.
pub fn parse_endpoint_lines(lines: &[&str]) -> Result<Vec<OwnedEndpoint>, ParseError> {
lines
.iter()
.map(|line| parse_endpoint_line(line).map(OwnedEndpoint::from))
.collect()
}
&[&str] borrows a contiguous sequence of string slices. A caller can pass &lines when lines is an array, or borrow a Vec<&str> as &lines or lines.as_slice(). The function owns neither the container nor its elements, and its type does not fix the number of records.
4. Why Strings Cannot Be Indexed
Rust's String and str contain UTF-8. text.len() therefore reports bytes, not a character count. The Korean string "상태" contains two Unicode scalar values but occupies 6 bytes in UTF-8. A lone index such as 0 cannot say whether the caller wants the first byte, the first Unicode scalar value, or the first user-perceived character.
This code does not compile. Running rustc --edition 2024 compile_fail/string_index.rs from the example directory produced E0277. The text block below is an abbreviated excerpt containing only the first line of the complete stderr capture.
fn main() {
let first = "상태"[0];
println!("{first}");
}
error[E0277]: the type `str` cannot be indexed by `{integer}`
Character indexing also has a performance mismatch. Finding the nth character requires decoding from the start, so character indexing cannot promise the constant-time behavior expected of indexing. An API should name the unit it intends to process.
- Use
bytes()oras_bytes()for raw bytes. - Use
chars()orchar_indices()for Unicode scalar values. - Use a Unicode segmentation crate when the domain needs grapheme clusters, the closest unit to a user-perceived character. One Rust
chardoes not always equal one visible character.
5. Slice at UTF-8 Boundaries
Range slicing is available, but its numbers are byte offsets. &text[..1] panics at runtime when byte 1 falls inside a Korean code point. For a byte range supplied by external input, get is safer because it returns Option<&str>. The example tests that "상태".get(..1) is None, while get(..3) is Some("상") because byte 3 is a valid boundary.
To make a preview by scalar-value count, first obtain a valid byte boundary from char_indices().
#[must_use]
pub fn preview_chars(text: &str, limit: usize) -> &str {
let end = text
.char_indices()
.nth(limit)
.map_or(text.len(), |(index, _)| index);
text.get(..end).unwrap_or(text)
}
char_indices() yields the byte position at which each char starts, so get(..end) never splits its UTF-8 encoding. A limit beyond the available characters returns the whole string. This function deliberately counts Unicode scalar values, not grapheme clusters. UI truncation for combining marks or family emoji needs grapheme-aware segmentation.
6. Testing UTF-8 Text and Ownership
The example has six tests covering invalid records, UTF-8 boundaries, slices, and the ownership transition. These two tests pin down the main contract: parsing borrows Korean text, while conversion to the owned form lets the value survive after its source buffer is dropped.
#[test]
fn parser_borrows_korean_and_unicode_fields() {
let line = "서울 API | https://example.com/상태 | 중요, 내부";
let endpoint = parse_endpoint_line(line).expect("valid endpoint line");
assert_eq!(endpoint.name, "서울 API");
assert_eq!(endpoint.url, "https://example.com/상태");
assert_eq!(endpoint.tags().collect::<Vec<_>>(), ["중요", "내부"]);
}
#[test]
fn owned_endpoint_outlives_the_input_buffer() {
let endpoint = {
let line = String::from("검색 | https://example.com/검색 | public");
OwnedEndpoint::from(parse_endpoint_line(&line).expect("valid endpoint line"))
};
assert_eq!(endpoint.name, "검색");
assert_eq!(endpoint.tags, ["public"]);
}
Run formatting, compilation, Clippy with warnings denied, tests, and the sample program. The text block below is an abbreviated excerpt from cargo test --quiet --all-features, limited to the six unit tests. The complete output continues with the binary and documentation targets, each of which runs zero tests.
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --quiet --all-features
cargo run --quiet -- '서울 API | https://example.com/상태 | critical, internal'
running 6 tests
......
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
7. API Boundary Rule
Start with &str for parsers, validators, and formatters that only observe text during a call. Return String when constructing new owned text. Convert at the boundary when a value enters long-lived state or an asynchronous job. The collection equivalent is &[T] for a borrowed read-only sequence and Vec<T> when ownership and growth belong to the callee.
Handle byte offsets directly only when the protocol itself defines bytes. Code that truncates text for people must decide whether it means Unicode scalar values or grapheme clusters. Rust's refusal to support single-number string indexing forces that decision into the API instead of leaving it as an encoding bug.
Full source code
The complete runnable source for this article is available in the Chapter 09 project on GitHub.
Leave a Reply