Rust track

Lesson 10 · Part 2: Ownership, Borrowing & Lifetimes

String & &str

Owned vs borrowed strings and conversion methods

Part 2: Ownership, Borrowing & Lifetimes5 min readFree lesson

When you first encounter Rust's strings, you might think, "Why is this so complicated?" You can't get the first character like in other languages with s[0], and String and &str can be confusing too. The reason is clear — Rust strings are always UTF-8, and a single "character" can take 1 to 4 bytes. In this article, we summarize that principle and common handling patterns.

Learning Objectives

  • Understand the memory model of String and &str.
  • Choose the appropriate method among 4 string concatenation types (push_str/push/+/format!) depending on the situation.
  • Know the difference between UTF-8 byte length and the number of Unicode characters.
  • Understand why string indexing is restricted and the safe traversal methods (chars()/bytes()).

Key Concepts

1) String vs &str

Type Own? Mutable? Memory
String O O (possible) Data on the heap, (ptr, len, cap) on the stack
&str X X (ptr, len) pointing to UTF-8 bytes elsewhere
&'static str X X Static data in the binary

For function parameters, &str is almost always the correct choice — it’s compatible with String, &String, and string literals.

2) Four Ways of Combining Strings

Method Code Remarks
push_str s.push_str("..."); Attach slice
push s.push('!'); One character
+ let c = a + &b; Left-hand side String move, right-hand side &str
format! let c = format!("{a}{b}"); New String, retain argument ownership

3) The Pitfalls of UTF-8

let s = "Han"; //3 bytes in UTF-8
s.len();          //3 (bytes)
s.chars().count(); //1 (Unicode scalar)

len() is always the number of bytes. If you need the number of characters, use chars().count(). If you need a graphic grapheme (e.g., combined emoji) unit, use the external crate unicode-segmentation.

4) Why is it not being indexed?

If possible, a user might try to take only 1 byte, but that may not be a valid UTF-8 character. Rust enforces handling only valid UTF-8 at all times, so direct indexing is prohibited, and only explicit methods (chars(), bytes(), slicing) are allowed.

5) Slicing

&s[a..b] is in byte units. If you cut in the middle of a multibyte character, it causes a runtime panic. Safely, you identify the character boundary with s.char_indices() and then cut.

Examples

Example 1 — ex01_combine.rs: Coupling Method

//Example 1 of Chapter 10: Methods of String Concatenation
//
//push_str / push / + operator / format! macro

fn main() {
    //Appending directly to a mutable String
    let mut greeting = String::from("Hello");
    greeting.push_str(", ");      //Adding string slices
    greeting.push('R');           //Single character (char)
    greeting.push_str("ust!");
    println!("Push series: {greeting}");

    //+ operator — left side owns the String, right side is &str
    let a = String::from("Hello,");
    let b = String::from("World!");
    let ab = a + &b; //a is moved, b is borrowed
    println!("+ operator: {ab}");

    //format! — creates a new String and takes no ownership of any argument
    let name = String::from("Rust");
    let lang = String::from("Korean");
    let sentence = format!("I am taking the {} lecture in {}.", name, lang);
    println!("format!: {sentence}");
    println!("(Can be used as is) name={name}, lang={lang}");
}

Example 2 — ex02_iterate.rs: Iteration and Indexing

//Example 2 of Chapter 10: String Iteration and Indexing
//
//Strings are UTF-8 variable-length encoded, so direct indexing is blocked.
//Instead, use `chars()` (Unicode scalars) and `bytes()` (raw bytes).

fn main() {
    let s = String::from("Rust one");

    //Indexing like s[0] causes a compile error
    // let c = s[0]; // ❌

    //Iteration by character (char)
    print!("chars: ");
    for c in s.chars() {
        print!("{c} ");
    }
    println!();

    //Iteration by byte (UTF-8 raw bytes)
    print!("bytes: ");
    for b in s.bytes() {
        print!("{b} ");
    }
    println!();

    println!("byte len  = {}", s.len());
    println!("char count = {}", s.chars().count());

    //Safe slicing: only use when you know character boundaries
    //Get only the 4 bytes of "Rust"
    let head = &s[..4];
    println!("head = {head}");
}

Example 3 — ex03_owned_vs_borrowed.rs: Conversion of Ownership and Borrowing

//Example 3 of Chapter 10: Converting String (ownership) ↔ &str (borrowing)

fn describe(s: &str) -> String {
    format!("The length of input "{s}" is {} bytes", s.len())
}

fn main() {
    let owned: String = String::from("Free");
    let lit: &str = "Literal";

    //&String → &str is automatic via deref
    println!("{}", describe(&owned));
    println!("{}", describe(lit));

    //&str → String (creating an owned copy)
    let copied: String = lit.to_string();
    let copied2: String = String::from(lit);
    println!("copied = {copied}, copied2 = {copied2}");

    //String → &str (creating a slice)
    let borrowed: &str = owned.as_str();
    println!("borrowed = {borrowed}");
}

Common Mistakes

Q. It is s.len() == 3, but only one character is visible on the screen.

A. len() is the number of UTF-8 bytes. A single Korean character usually takes 3 bytes, which is why it appears that way. If you need the number of characters, use s.chars().count().

Q. &s[0..2] causes a panic.

A. String slicing operates at the byte level, and it panics because it was cut at a point that is not a UTF-8 character boundary. To safely slice, use a pattern like s.char_indices().nth(N).map(|(i, _)| &s[..i]).

Q. Is there a difference between String::new() and String::from("")?

A. The result is the same, an empty String. The difference is just in the way of expressing intent — use new() for an empty buffer you plan to fill soon, and from for literal-based.

Q. Which is better, + or format!?

A. If you are combining 2–3 pieces and it's okay to transfer the ownership of the left-hand String, + is faster. Otherwise, format! is safer and more readable.

Summary

  • String is owned and mutable, &str is borrowed. Function parameters are usually &str.
  • For combining, use push_str/push/+/format! according to the situation.
  • len() counts bytes, chars().count() counts characters.
  • Direct indexing is prohibited, slicing is at the byte level, so be careful with character boundaries.

Try it yourself

Solve the assignment below. The reference solution is shown below.

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/strings/Cargo.tomlTOML
[package]
name = "strings"
version = "0.1.0"
edition = "2021"
default-run = "strings"

[dependencies]
src/strings/src/main.rsRust
fn main() {
    println!("This crate has three executable examples.");
    println!();
    println!("  cargo run --bin ex01_combine");
    println!("  cargo run --bin ex02_iterate");
    println!("  cargo run --bin ex03_owned_vs_borrowed");
}

Assignment

Problem 1 — Reversing a String

  • File name: homework_01/src/main.rs
  • Key concepts: chars(), rev(), collect

Requirements

  • Implement the function reverse(s: &str) -> String.
  • Print the results for the two inputs "Hello" and "Rust Korean".

Expected output

reverse("Hello") = olleH
reverse("Rust code") = edoc tsuR

Hint

  • s.chars().rev().collect().
  • Since the return is String, collect::<String>() or explicit type on the left-hand side is required.

Problem 2 — Counting Vowels

  • File name: homework_02/src/main.rs
  • Key concepts: chars() + filter, ASCII vowel detection

Requirements

  • Implement the function count_vowels(s: &str) -> usize.
  • Count both uppercase and lowercase a/e/i/o/u as vowels.
  • Print the results for "Programming in Rust" input in main.

Expected output

Number of vowels in "Programming in Rust" = 5

Hint

  • After c.to_ascii_lowercase(), then matches!(c, 'a'|'e'|'i'|'o'|'u').
  • filter().count().

Check your answers

Try solving it yourself first, then compare your answer with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

homework_01/Cargo.tomlTOML
[package]
name = "homework_01"
version = "0.1.0"
edition = "2021"

[dependencies]
homework_01/src/main.rsRust
//Key Points:
//- It is safer to reverse by characters (char) rather than bytes to handle multibyte characters.
//- `chars().rev()` creates a character-level reverse iterator.

fn reverse(s: &str) -> String {
    s.chars().rev().collect()
}

fn main() {
    println!("reverse(\"Hello\") = {}", reverse("Hello"));
    println!("reverse("Rust Korean") = {}", reverse("Rust Korean"));
}
homework_02/Cargo.tomlTOML
[package]
name = "homework_02"
version = "0.1.0"
edition = "2021"

[dependencies]
homework_02/src/main.rsRust
//Key Points:
//- The `matches!` macro is a convenient tool that allows you to express pattern matching in a single line.
//- If you unify the case and then compare, the branching becomes simpler.

fn count_vowels(s: &str) -> usize {
    s.chars()
        .filter(|c| matches!(c.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u'))
        .count()
}

fn main() {
    let input = "Programming in Rust";
    println!(""{input}" number of vowels = {}", count_vowels(input));
}

Learn this lesson with a mentor

Work through the Rust track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now