Rust String vs str (&str) | Ownership· Slices
이 글의 핵심
Compare Rust String and str: heap vs slice, borrowing vs ownership, function signatures, conversions, and common lifetime mistakes. Learn which to use in function parameters, struct fields, and return types.
The Core Distinction
Rust has two main string types and beginners often wonder which to use. The short answer:
String: an owned, heap-allocated, growable UTF-8 string buffer&str: a borrowed reference to a UTF-8 string slice (a view into memory you don’t own)
They solve different problems. String is for when you need to own and possibly modify text. &str is for when you just need to read text that lives somewhere else.
fn main() {
let owned: String = String::from("hello"); // heap allocation
let slice: &str = "world"; // points into read-only static memory
let view: &str = &owned; // points into owned's heap buffer
println!("{} {}", owned, slice);
println!("{} {}", owned, view); // owned is still valid — we borrowed it
}
Memory Layout
Understanding the layout makes the rules feel less arbitrary.
String Layout
A String is essentially a wrapper around Vec<u8> that guarantees UTF-8 validity:
Stack: Heap:
┌─────────────────┐ ┌─────────────────────┐
│ ptr ────────────┼────►│ h e l l o │
│ len: 5 │ └─────────────────────┘
│ capacity: 8 │
└─────────────────┘
Three words on the stack: a pointer to heap data, the current length, and the allocated capacity.
&str Layout
A &str is a fat pointer — two words:
Stack:
┌─────────────────┐
│ ptr ────────────┼──► (points somewhere — static memory, String heap, local buffer)
│ len: 5 │
└─────────────────┘
Creating a &str from a String costs nothing — it just copies the pointer and length:
let s: String = String::from("hello world");
let slice: &str = &s[0..5]; // "hello" — no allocation, just a new fat pointer
Ownership and Borrowing in Practice
// Takes ownership — the caller's String is moved and dropped when this function returns
fn consume(s: String) {
println!("{}", s);
} // s dropped here
// Borrows — the caller keeps their data
fn print_it(s: &str) {
println!("{}", s);
}
fn main() {
let owned = String::from("hello");
print_it(&owned); // borrow — owned still valid
print_it("world"); // string literal works too — &'static str coerces to &str
consume(owned); // move — owned no longer valid after this
// println!("{}", owned); // compile error: value used after move
}
The key insight: &str is accepted from any string-like source — a String (via coercion), a string literal, or another &str. This makes &str parameters maximally flexible:
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
greet("Alice"); // &'static str literal — works
greet(&String::from("Bob")); // String reference — works
greet(&"Carol"[..]); // &str slice — works
}
If greet took String instead, callers would have to allocate a String even when they only have a literal.
Conversion Patterns
fn main() {
// &str → String (allocation)
let s1: String = "hello".to_string();
let s2: String = String::from("hello");
let s3: String = "hello".to_owned();
let s4: String = format!("{}", "hello"); // most flexible, always allocates
// String → &str (no allocation)
let owned = String::from("world");
let view1: &str = &owned;
let view2: &str = owned.as_str();
let view3: &str = &owned[..]; // full slice
// Partial slice
let partial: &str = &owned[1..4]; // "orl" — zero allocation
// Mutation — only on String
let mut mutable = String::from("hello");
mutable.push_str(", world");
mutable.push('!');
println!("{}", mutable); // hello, world!
}
Function Parameter Guidelines
The parameter type you choose determines who has to do the conversion:
// Read-only: always use &str
// Caller can pass literals, &String, or &str without converting
fn count_chars(s: &str) -> usize {
s.chars().count()
}
// Sink: function takes ownership and stores it
// Caller passes String directly (or a clone if they need to keep theirs)
struct Logger {
prefix: String,
}
impl Logger {
fn new(prefix: String) -> Self {
Logger { prefix } // stores ownership
}
}
// Mutation: must take &mut String — can't mutate through &str
fn shout(s: &mut String) {
s.make_ascii_uppercase();
s.push('!');
}
fn main() {
println!("{}", count_chars("hello")); // 5
println!("{}", count_chars(&String::from("world"))); // 5
let logger = Logger::new(String::from("[INFO]"));
let mut msg = String::from("hello");
shout(&mut msg);
println!("{}", msg); // HELLO!
}
Struct Fields
// Owned field — the struct owns the string data
struct User {
name: String,
email: String,
}
impl User {
fn new(name: impl Into<String>, email: impl Into<String>) -> Self {
User {
name: name.into(), // accepts both &str and String
email: email.into(),
}
}
// Return a view — no clone needed
fn name(&self) -> &str {
&self.name
}
}
// Borrowed field — the struct borrows from somewhere else
// Requires an explicit lifetime: the struct cannot outlive the borrowed data
struct UserRef<'a> {
name: &'a str,
}
fn main() {
// User owns its data — can be moved freely, no lifetime concerns
let u = User::new("Alice", "[email protected]");
println!("{}", u.name()); // returns &str — no allocation
// UserRef borrows — must not outlive the source
let name = String::from("Bob");
let uref = UserRef { name: &name };
println!("{}", uref.name);
// uref must go out of scope before name does
}
For most structs, prefer String fields. The ergonomics of lifetimes (UserRef<'a>) are worth it only when you are processing data in-place and want to avoid copies entirely — parsers, for example.
Return Type: When &str Works and When It Doesn’t
// GOOD: returning a slice of an input parameter
// The lifetime 'a says "returned &str lives as long as the input"
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(pos) => &s[..pos],
None => s,
}
}
// GOOD: returning a static literal
fn default_greeting() -> &'static str {
"Hello!"
}
// BAD: returning a reference to a local String
fn make_greeting(name: &str) -> &str {
let s = format!("Hello, {}!", name); // s is local
&s // compile error: cannot return reference to local variable
}
// CORRECT: return String when you create new data
fn make_greeting_correct(name: &str) -> String {
format!("Hello, {}!", name) // caller owns the result
}
fn main() {
let text = String::from("hello world how are you");
println!("{}", first_word(&text)); // "hello" — no allocation
println!("{}", default_greeting()); // "Hello!" — static
println!("{}", make_greeting_correct("Alice")); // "Hello, Alice!"
}
Cow<str>: When You Sometimes Need to Own
Cow<'a, str> (“clone on write”) holds either a &str or a String and avoids the allocation when borrowing suffices:
use std::borrow::Cow;
fn sanitize(s: &str) -> Cow<'_, str> {
if s.contains('<') || s.contains('>') {
// Needs modification — allocate
Cow::Owned(s.replace('<', "<").replace('>', ">"))
} else {
// Clean — just borrow
Cow::Borrowed(s)
}
}
fn main() {
let clean = "Hello, world!";
let dirty = "Hello, <world>!";
println!("{}", sanitize(clean)); // borrows — no allocation
println!("{}", sanitize(dirty)); // allocates — returns Cow::Owned
}
Use Cow<str> in library APIs when the result is sometimes borrowed and sometimes owned — it gives callers maximum flexibility without forcing an allocation.
Common Mistakes
Taking String When &str Suffices
// SLOW: forces allocation at every call site
fn log(msg: String) { println!("{}", msg); }
log("connecting".to_string()); // unnecessary allocation
// FAST: accepts anything string-like
fn log(msg: &str) { println!("{}", msg); }
log("connecting"); // literal — no allocation
Extra to_string() in Hot Paths
fn process(items: &[String]) {
for item in items {
// SLOW: allocates a new String every iteration
let lower = item.to_lowercase().to_string();
// FAST: to_lowercase() already returns String
let lower = item.to_lowercase();
// FASTER: if you only need a &str for reading
let lower_view = item.to_lowercase();
use_view(&lower_view);
}
}
Returning &str to Dropped Data
fn broken() -> &str { // compile error: missing lifetime
let s = String::from("hello");
&s // s will be dropped — dangling reference
}
// Fix: return String
fn fixed() -> String {
String::from("hello")
}
The borrow checker prevents dangling references at compile time. If you get a lifetime error on a return type, the usual fix is to return a String instead of &str.
Decision Summary
| Scenario | Use |
|---|---|
| Read-only function parameter | &str |
| Function that stores the string | String (by value) or impl Into<String> |
| Struct field with owned data | String |
| Struct field with borrowed data | &'a str (requires lifetime parameter) |
| Return: new data created inside function | String |
| Return: slice of input parameter | &str |
| Sometimes borrowed, sometimes owned | Cow<'_, str> |
Key Takeaways
Stringowns heap-allocated UTF-8 data;&strborrows a slice of UTF-8 from anywhere- Prefer
&strparameters — accepts literals,Stringreferences, and&strwithout conversion - Creating
&strfromStringis free — just copies the pointer and length &strcannot outlive its source — returning&strto a localStringis a compile error- Struct fields: use
Stringfor owned data;&'a stronly when the struct is tied to an external lifetime Cow<str>avoids allocation when borrowing suffices, allocates only when modification is neededformat!always allocates — avoid it in hot paths when string concatenation on an existingStringwould work
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Compare Rust String and str: heap vs slice, borrowing vs ownership, function signatures, conversions, and common lifetim… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
이 글에서 다루는 키워드 (관련 검색어)
Rust, String, str, Ownership, Borrowing, Comparison 등으로 검색하시면 이 글이 도움이 됩니다.