본문으로 건너뛰기
Previous
Next
Rust String vs str (&str) | Ownership· Slices

Rust String vs str (&str) | Ownership· Slices

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('<', "&lt;").replace('>', "&gt;"))
    } 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

ScenarioUse
Read-only function parameter&str
Function that stores the stringString (by value) or impl Into<String>
Struct field with owned dataString
Struct field with borrowed data&'a str (requires lifetime parameter)
Return: new data created inside functionString
Return: slice of input parameter&str
Sometimes borrowed, sometimes ownedCow<'_, str>

Key Takeaways

  • String owns heap-allocated UTF-8 data; &str borrows a slice of UTF-8 from anywhere
  • Prefer &str parameters — accepts literals, String references, and &str without conversion
  • Creating &str from String is free — just copies the pointer and length
  • &str cannot outlive its source — returning &str to a local String is a compile error
  • Struct fields: use String for owned data; &'a str only when the struct is tied to an external lifetime
  • Cow<str> avoids allocation when borrowing suffices, allocates only when modification is needed
  • format! always allocates — avoid it in hot paths when string concatenation on an existing String would 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 등으로 검색하시면 이 글이 도움이 됩니다.