OSRust.007: Rust Slices and UTF-8 — Borrowed Text, Byte Boundaries, and Safe Indexing

Fictional older South Asian woman Rust instructor in computer laboratory explaining borrowed text ranges on stylized monitors, not real code output

OSRust.007 advances from Rust ownership and borrowing to slices, which provide borrowed views of contiguous sequences without transferring ownership of their underlying storage. A Rust string slice, &str, contains a reference to valid UTF-8 data. Its ranges use byte offsets, not user-visible character positions. Recognizing that distinction is fundamental for safe text parsing, filesystem tooling, structured logs, networking, and software that handles international text.

The lesson follows OSRust.006 on ownership, moves and borrowing, OSRust.004 on functions, and OSRust.003 on scalar and compound data types. The earlier borrowing lesson introduced references; the present lesson focuses on reference ranges and encoding-aware text boundaries rather than repeating ownership fundamentals.

Learning Objectives and Prerequisites

  • Distinguish owned String from borrowed &str, and explain the difference between a slice and its owner.
  • Use half-open slice ranges such as ..3, 3.., and 1..4 for valid boundaries.
  • Compute UTF-8 byte length separately from the number of Unicode scalar values returned by chars().
  • Use str::get to return Option<&str> instead of panicking when a user-provided range is invalid.
  • Write functions that accept &str and slices such as &[i32] without cloning data.
  • Explain how borrow checking prevents mutation or deallocation while an active borrowed view is still used.

Prerequisites: OSRust.006 borrowing and references, if and for syntax from OSRust.005, plus basic understanding of bytes and hexadecimal notation. Materials: a Rust toolchain installed through rustup, a text editor and terminal. A current stable Rust compiler supporting Edition 2021 is suitable. No external crate or elevated access is needed. The available publishing environment did not contain rustc or cargo, so Rust code in the lesson was checked against official specifications but was not compiled or executed here.

Why Borrowed Slices Matter

In Rust, a String owns a growable UTF-8 string allocation, while &str borrows a UTF-8 sequence. The borrowed view can point into a String or represent a string literal. Passing &str does not transfer ownership or require copying the selected text. The official Rust Book chapter on slices develops precisely that relationship and shows why a borrowed range is more reliable than retaining an unrelated byte index after an owned string changes.

Black gear-shaped letter R logo used by the Rust programming language
Rust Foundation / Wikimedia Commons, CC BY 4.0. Unmodified official project artwork; the educational lesson is independent and not endorsed by the Rust Project. Trademark restrictions also apply.

A &str value conceptually contains a pointer to the start of the selected bytes plus a byte length. It does not own the allocation behind those bytes. Rust checks that an ordinary safe reference does not outlive the data it points to. The exact physical layout of every variable or allocation is an implementation concern: the important source-level property is that ownership stays with the source while an immutable slice borrows its contents.

String Slices Use Half-Open Byte Ranges

The expression &message[0..4] creates a borrowed string slice from byte offset 0 up to but excluding offset 4. Valid UTF-8 string slicing requires both ends to be within the allocation and positioned at UTF-8 scalar boundaries. Rust’s usual range notation follows a half-open convention; the range length in bytes equals its end minus its start. Ranges with omitted bounds use the beginning or end of the string.

fn main() {
    let message = String::from("rust language");
    let first = &message[..4];
    let rest = &message[5..];

    println!("{first}");
    println!("{rest}");
}

Expected output (documentation-checked, not executed here):

rust
language

The source is entirely ASCII, so all its byte boundaries align with visible characters. The first slice borrows bytes 0–3; the second starts at offset 5, after the space at byte offset 4. A string literal such as "rust language" also has type &str. Code can therefore accept a borrowed view without requiring that callers allocate a new String.

Unicode Text Is Not an Array of One-Byte Characters

UTF-8 uses a variable number of bytes to represent a Unicode scalar value. For example, A is one byte, é is two bytes, and the crab emoji 🦀 is four bytes. Their concatenation "Aé🦀" contains seven bytes but three Unicode scalar values. Rust str::len() returns bytes, whereas chars().count() counts scalar values. A human-perceived grapheme cluster can contain several scalar values and is not counted by a standard-library grapheme API. The official Rust Book discussion of UTF-8 strings explains the differences.

Original byte map showing the UTF-8 sequence A, e acute and crab emoji and valid slice offsets 0, 1, 3 and 7
Original BitcoinVersus.Tech source-grounded chart. Aé🦀 encodes as 41 C3 A9 F0 9F A6 80 and has valid scalar boundaries at byte offsets 0, 1, 3, and 7. Bytes independently checked using UTF-8 encoding; Rust execution was not available.

Offsets 2, 4, 5 and 6 fall inside multibyte scalar values, not between complete scalars. Therefore &text[1..3] is a valid slice of "Aé🦀" representing "é", while &text[2..3] would panic at runtime rather than form an invalid &str. By contrast, text.get(2..3) returns None safely. The standard-library str documentation states both the boundary requirement and the non-panicking behavior of get.

Byte Count, Scalar Count, and Grapheme Count

Visual appearance cannot determine the number of bytes or Unicode scalar values. A precomposed é is two UTF-8 bytes and one scalar value; an e followed by a combining acute accent is three bytes and two scalar values. Both can look similar when rendered with appropriate font support, but their byte sequences differ. Unicode normalization and grapheme segmentation are separate topics, not automatically performed by ordinary string slicing.

Original horizontal bar chart comparing UTF-8 byte lengths with Unicode scalar counts for A, e acute, crab emoji and combining-accent text
Original data chart from independently calculated UTF-8 encoding: A 1 byte/1 scalar, é 2/1, 🦀 4/1, and e plus combining acute accent 3/2. Standard Rust counts scalars with chars(); it does not provide user-perceived grapheme segmentation by default.

Use get() for Untrusted Byte Offsets

Direct indexing with a range checks boundaries but may panic if the range is invalid. For text offsets derived from a file, network message, or user input, return an Option instead. The get method reports valid ranges with Some(&str) and invalid ones with None. It does not make arbitrary offsets meaningful: the caller still needs to decide whether those positions represent words, protocol fields, or whole Unicode scalar values.

fn safe_prefix(text: &str, end: usize) -> Option<&str> {
    text.get(..end)
}

fn main() {
    let text = "Aé🦀";
    println!("bytes = {}", text.len());
    println!("scalars = {}", text.chars().count());
    println!("valid = {:?}", safe_prefix(text, 3));
    println!("invalid = {:?}", safe_prefix(text, 2));
}

Expected output (not compiler-tested in the present environment):

bytes = 7
scalars = 3
valid = Some("Aé")
invalid = None

The signature Option<&str> uses lifetime elision: the returned borrowed view is tied to the input text, not to new owned storage. A program must not return a slice of a temporary String that has already been destroyed. When an application actually needs to keep text independently, it may use an explicitly allocated owned String. Do not replace safe indexing with get_unchecked to bypass boundary checks without a carefully proven unsafe contract.

Build a Function That Borrows Its First Word

A reusable function may accept &str regardless of whether the caller has a String or a literal. The example uses split_whitespace to find a word according to Rust’s Unicode whitespace behavior and returns a borrowed slice of the original input. It does not allocate a new word string. For an empty input or input containing only whitespace, the chosen API returns an empty slice.

fn first_word(text: &str) -> &str {
    text.split_whitespace().next().unwrap_or("")
}

fn main() {
    let owned = String::from("café 🦀 tools");
    let word = first_word(&owned);
    println!("word = {word}");
    println!("word bytes = {}", word.len());
    println!("word scalars = {}", word.chars().count());
    println!("empty = {:?}", first_word("  "));
}

Expected output (source reviewed, not executed):

word = café
word bytes = 5
word scalars = 4
empty = ""

The whitespace iterator uses a language-defined property; applications needing punctuation-aware or locale-specific tokenization must implement a more explicit policy. The borrowed word stays tied to the original allocation. A later attempt to mutate the underlying String while the borrowed word remains in use can be rejected by the borrow checker. Under modern non-lexical lifetimes, the borrow can end after its last actual use; mutation later in the same block may be permitted when the reference is no longer used.

Array Slices Work Without Unicode Complications

Slices are not limited to text. The type &[i32] views a contiguous region of integer elements while retaining ownership elsewhere. Its indices are element positions rather than UTF-8 byte positions. The same safe borrowing principle applies to a borrowed array view, an allocated Vec<i32>, or a portion of either. The Rust slice primitive reference documents the type and its methods.

fn sum(values: &[i32]) -> i32 {
    values.iter().copied().sum()
}

fn double(values: &mut [i32]) {
    for value in values.iter_mut() {
        *value *= 2;
    }
}

fn main() {
    let numbers = [3, 5, 8, 13, 21];
    println!("sum = {}", sum(&numbers[1..4]));

    let mut other = [2, 4, 6, 8];
    double(&mut other[1..3]);
    println!("doubled = {:?}", other);
}

Expected output from the shown values (calculated, not executed):

sum = 26
doubled = [2, 8, 12, 8]

The sum includes elements with indices 1, 2 and 3: 5 + 8 + 13 = 26. The mutable slice modifies elements 1 and 2 in other, leaving the owner of the array intact. Real input processing must consider signed integer overflow: sum() with i32 is not safe for every conceivable array. For arbitrary data, use checked arithmetic, an appropriately wider type, or a documented overflow policy.

Practical Lab and Reproducibility

Use a local training directory and the standard Rust toolchain; no external crates, administrator access, network service, or real customer information are required. The demonstrations are copyable and use Rust’s 2021 edition. In this publishing environment, Rust compiler execution was unavailable because rustc and cargo were not installed; compilation and test results are instructions for the learner to verify, not a test record falsely attributed to the publisher.

  1. Run rustc --version and cargo --version to document your toolchain. If unavailable, install using the official rustup instructions for your operating system.
  2. Create a new package using cargo new slice_lab, move into its folder, and replace src/main.rs with one complete example above at a time. Run cargo run.
  3. Confirm "Aé🦀".len() == 7, chars().count() == 3, get(1..3) == Some("é"), and get(2..3).is_none().
  4. Change a requested prefix byte offset from 3 to 7 and record why the result changes from Some("Aé") to Some("Aé🦀").
  5. Call first_word on "café 🦀 tools", an empty string, and a whitespace-only input. Document all three results.
  6. Explain why an array slice &numbers[1..4] has three elements even though it may occupy many bytes.

Add Automated Tests to Verify the Claims

To make the boundary behavior independently testable, add the following code to src/lib.rs in a local Cargo package, then run cargo test. These assertions cover normal Unicode slicing, an invalid mid-scalar offset, empty input, and the word-borrowing contract. The tests have not been run by the publisher and should be treated as a reproducible verification plan.

pub fn safe_prefix(text: &str, end: usize) -> Option<&str> {
    text.get(..end)
}

pub fn first_word(text: &str) -> &str {
    text.split_whitespace().next().unwrap_or("")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn utf8_boundaries() {
        let text = "Aé🦀";
        assert_eq!(text.len(), 7);
        assert_eq!(text.chars().count(), 3);
        assert_eq!(safe_prefix(text, 1), Some("A"));
        assert_eq!(safe_prefix(text, 3), Some("Aé"));
        assert_eq!(safe_prefix(text, 2), None);
        assert_eq!(safe_prefix(text, 7), Some(text));
    }

    #[test]
    fn words_borrow_input() {
        assert_eq!(first_word("café 🦀 tools"), "café");
        assert_eq!(first_word(""), "");
        assert_eq!(first_word("   "), "");
    }
}

A successful local cargo test should report two passing test functions, while the assertions inside them verify several properties. If compilation fails because there is already a function named safe_prefix or first_word in the same module, avoid duplicate definitions by using the library tests separately from the earlier main.rs examples. If the editor inserts invalid Unicode replacement symbols, save the source in UTF-8 and inspect the original literal.

Troubleshooting Common Errors

  • Panic from a range: a range like &text[2..3] splits a multibyte scalar. Use get to handle invalid positions and work with actual scalar boundaries.
  • Cannot index str by integer: Rust does not implement direct numeric character indexing like text[0]. Choose chars(), a byte-range slice, or a protocol-specific parser.
  • Borrow checker rejects a mutation: an immutable borrowed view is still used after a proposed mutation. Rearrange the final use, return an owned value, or redesign the data flow instead of bypassing memory safety.
  • Unexpected byte count: len() counts UTF-8 bytes. Compare it with chars().count() and do not assume grapheme clusters equal scalar values.
  • Empty result from word parsing: leading whitespace or empty input requires a defined application policy. split_whitespace() skips whitespace, and unwrap_or("") provides the chosen empty fallback.
  • Out-of-bounds array range: array and vector slices have bounds checks too, but use element positions; get also provides safe optional access for slices.
  • Compiler not found: rustc and cargo are not installed or missing from the path. Use official rustup documentation and record the installed toolchain before claiming any test was executed.

Knowledge Check with Answers

  1. Who owns bytes referenced by an &str? The owner of the original allocation or static data, not the borrowed slice.
  2. How many bytes and scalar values are in “Aé🦀”? Seven bytes and three scalar values.
  3. What is the difference between text.get(2..3) and &text[2..3] for that string? The former returns None; the latter panics because offset 2 lies inside é.
  4. Why can first_word(&owned) return a &str without a clone? It borrows a contiguous part of the caller’s UTF-8 data.
  5. Can chars() reliably count how many letters a user sees? Not always. Combining scalar values can form one grapheme cluster.
  6. What does a half-open range 1..4 include? Positions 1, 2, and 3; index 4 is excluded.
  7. What sum results from [3,5,8,13,21][1..4]? 26.
  8. What must be true before keeping a borrowed slice? Its underlying data must remain valid for the borrow, subject to Rust’s rules.
  9. Was Rust compilation verified by the publisher? No. UTF-8 byte calculations were independently verified, and the Rust source follows official references, but a Rust compiler was unavailable.

References and Further Study

Primary references: The Rust Programming Language — The Slice Type; References and Borrowing; Storing UTF-8 Encoded Text With Strings; Rust standard-library str documentation; Rust primitive slice documentation; and Cargo test documentation. The logo’s trademark and reuse policy is maintained by the Rust Foundation. Earlier lessons include OSRust.001 on installing the toolchain and OSRust.006 on ownership.

Conclusion

A slice lets Rust code work with borrowed regions of memory without copying or taking ownership. Text slices add an essential restriction: valid UTF-8 must remain intact, and byte ranges cannot split a Unicode scalar. Safe optional indexing, borrowed function parameters, careful handling of Unicode and reproducible local tests form a foundation for future lessons on collections, structs and robust parser design.

Editor’s Note: OSRust.007 continues the published OSRust.006 ownership lesson. The featured 1200 × 630 illustration depicts a fictional older South Asian female instructor, rotating the instructor representation from OSC.006’s middle-aged male programming mentor. The Rust Foundation logo is genuine, credited and used without implying affiliation. Two original charts visualize independently checked UTF-8 byte and scalar-value calculations; they are explanatory graphics, not outputs of a Rust runtime. Because the available environment lacked rustc and cargo, no successful Rust compile or test execution is claimed. No unverified YouTube ID or video playback is included; independent public-browser pixel inspection remains separate from WordPress markup checks.

Original BitcoinVersus.Tech optional donation QR for 5,000 satoshis
Optional original BitcoinVersus.Tech 5,000-satoshi donation QR. Confirm the destination and amount before authorizing a transfer.

On a mobile wallet, display the QR on another screen or use a supported saved-image scanner. Confirm the payment recipient and 5,000-satoshi amount before sending.

We volunteer daily to ensure the information on this platform is Verifiably True. If you would like to support the research, please scan the QR code.

Leave a Reply