Strings in Depth
Strings are one of the first places where Rust’s ownership model becomes tangible in everyday code. Both Swift and Rust treat strings as complex, Unicode-aware types rather than simple byte buffers, and both refuse to let you index into a string with an integer. But the way they achieve this differs significantly, and the distinction between owned and borrowed strings in Rust has no direct equivalent in Swift.
String vs &str: owned and borrowed
Section titled “String vs &str: owned and borrowed”Rust has two primary string types:
String: a heap-allocated, growable, owned string. This is the Rust equivalent of Swift’sString.&str(pronounced “string slice”): a borrowed, immutable view into a sequence of UTF-8 bytes. It consists of a pointer and a length – no allocation, no ownership.
// Rustlet owned: String = String::from("hello"); // heap-allocated, ownedlet borrowed: &str = "hello"; // points to static data, borrowedEvery string literal in Rust has type &str (specifically &'static str, meaning it lives for the entire program). In Swift, every string literal creates a String value. This is a fundamental difference: Rust distinguishes between “I own this text” and “I’m just looking at some text that someone else owns.”
The relationship between String and &str is analogous to Vec<T> and &[T]. A String is essentially a Vec<u8> that is guaranteed to contain valid UTF-8. A &str is a &[u8] with the same guarantee.
Converting between String and &str
Section titled “Converting between String and &str”Going from String to &str is cheap – it is just borrowing:
// Rustlet owned = String::from("hello");let borrowed: &str = &owned; // borrow the whole stringlet slice: &str = &owned[0..3]; // borrow a substring: "hel"Going from &str to String requires allocation – you are creating an owned copy:
// Rustlet borrowed: &str = "hello";let owned: String = borrowed.to_string(); // one waylet also_owned: String = String::from(borrowed); // another waylet third: String = borrowed.to_owned(); // yet anotherAll three approaches produce the same result. to_string() is the most common in practice.
Which to use in function signatures
Section titled “Which to use in function signatures”A function that only needs to read a string should accept &str:
// Rustfn greet(name: &str) { println!("Hello, {name}!");}
fn main() { let owned = String::from("Alice"); greet(&owned); // String coerces to &str automatically greet("Bob"); // &str passed directly}A function that needs to store or return an owned string should use String:
// Rustfn make_greeting(name: &str) -> String { format!("Hello, {name}!")}This is similar to how a Swift function might accept some StringProtocol for read-only access and return String when creating new strings, but the distinction is sharper in Rust because it maps directly to ownership.
UTF-8 encoding
Section titled “UTF-8 encoding”Rust strings are always valid UTF-8. Swift’s String also uses UTF-8 as its internal storage (since Swift 5), so the two languages are aligned on encoding. However, the APIs they expose on top of that encoding differ.
In Swift, you access different views of a string’s contents:
// Swiftlet text = "cafe\u{0301}" // decomposed form of "café"text.count // 4 (grapheme clusters)text.unicodeScalars.count // 5 (Unicode scalars)text.utf8.count // 6 (UTF-8 bytes)text.utf16.count // 5 (UTF-16 code units)In Rust, you get a similar set of views through methods on str:
// Rustlet text = "cafe\u{0301}";text.chars().count() // 5 (Unicode scalar values, like Swift's unicodeScalars)text.bytes().count() // 6 (UTF-8 bytes, like Swift's utf8)text.len() // 6 (byte length, not character count)Rust does not have a built-in grapheme cluster view. The chars() iterator yields Unicode scalar values (Rust’s char type), which is equivalent to Swift’s unicodeScalars view, not its default character view. For grapheme cluster segmentation, you need an external crate like unicode-segmentation.
The len() method on a Rust string returns the byte length, not the number of characters. This is a common source of confusion for newcomers. Swift’s count returns the grapheme cluster count, which is the most linguistically meaningful measure but the most expensive to compute.
Why you cannot index by integer
Section titled “Why you cannot index by integer”Neither Swift nor Rust lets you write text[2] to get the third character. The reasons are similar – characters are variable-width in both UTF-8 and grapheme clusters – but the mechanics differ.
In Swift:
// Swiftlet text = "hello"// text[2] // compile errorlet index = text.index(text.startIndex, offsetBy: 2)let ch = text[index] // "l" (a Character, i.e., grapheme cluster)In Rust:
// Rustlet text = "hello";// text[2] // compile error – cannot index `str` with `usize`let ch = text.chars().nth(2); // Some('l') – a Unicode scalar valueBoth languages make the O(n) cost explicit. Swift does it through its String.Index type; Rust does it by requiring you to use an iterator.
You can index into the byte representation of a Rust string using a range, but this creates a &str slice and will panic at runtime if the range does not fall on a character boundary:
// Rustlet text = "hello";let slice: &str = &text[0..3]; // "hel" – safe because ASCIIprintln!("{slice}");
let emoji = "\u{1F600}hello"; // starts with a 4-byte emoji// let bad = &emoji[0..1]; // panics: byte index 1 is not a char boundaryString slicing
Section titled “String slicing”Rust string slices use byte ranges, not character ranges:
// Rustlet greeting = "Hello, world!";let hello: &str = &greeting[0..5]; // "Hello"let world: &str = &greeting[7..12]; // "world"This is efficient – it is O(1) pointer arithmetic – but dangerous with multi-byte characters. Swift’s Substring type serves a similar role but uses String.Index values that are always valid:
// Swiftlet greeting = "Hello, world!"let start = greeting.index(greeting.startIndex, offsetBy: 7)let end = greeting.index(start, offsetBy: 5)let world = greeting[start..<end] // "world" (a Substring)Common operations
Section titled “Common operations”Concatenation
Section titled “Concatenation”// Swiftlet full = "Hello" + ", " + "world!"var greeting = "Hello"greeting += ", world!"greeting.append("!")// Rustlet full = format!("{}, {}!", "Hello", "world");let mut greeting = String::from("Hello");greeting.push_str(", world!");greeting.push('!'); // push a single charRust’s + operator works on strings, but it has an asymmetric signature – the left operand is consumed (moved) and the right must be a &str:
// Rustlet hello = String::from("Hello");let full = hello + ", world!"; // hello is moved; full owns the result// println!("{hello}"); // compile error: hello was movedBecause of this, format! is usually preferred for combining strings – it is clearer and does not consume any of its arguments.
Formatting with format!
Section titled “Formatting with format!”The format! macro is Rust’s equivalent of Swift’s string interpolation:
// Swiftlet name = "Alice"let age = 30let message = "Name: \(name), Age: \(age)"// Rustlet name = "Alice";let age = 30;let message = format!("Name: {name}, Age: {age}");format! returns a new String. The inline variable syntax ({name}) works for local variables; you can also use positional ({0}) or named arguments with formatting options:
// Rustlet pi = std::f64::consts::PI;let formatted = format!("{pi:.4}"); // "3.1416"let padded = format!("{:>10}", "right"); // " right"let hex = format!("{:#x}", 255); // "0xff"Searching and replacing
Section titled “Searching and replacing”// Swift"hello world".contains("world") // true"hello world".replacingOccurrences(of: "world", with: "Rust") // "hello Rust""hello world".hasPrefix("hello") // true"hello world".hasSuffix("world") // true// Rust"hello world".contains("world") // true"hello world".replace("world", "Rust") // "hello Rust" (returns String)"hello world".starts_with("hello") // true"hello world".ends_with("world") // trueSplitting and joining
Section titled “Splitting and joining”// Swiftlet parts = "a,b,c".split(separator: ",") // [Substring]let joined = parts.joined(separator: "-") // "a-b-c"// Rustlet parts: Vec<&str> = "a,b,c".split(',').collect();let joined = parts.join("-"); // "a-b-c"Rust’s split returns an iterator, so you call .collect() to gather the results into a Vec<&str>. The slices borrow from the original string – no allocation happens until you collect.
Trimming whitespace
Section titled “Trimming whitespace”// Swift" hello ".trimmingCharacters(in: .whitespaces) // "hello"// Rust" hello ".trim() // "hello" (returns &str)Rust also provides trim_start() and trim_end() for one-sided trimming. These methods return &str slices – they do not allocate.
Case conversion
Section titled “Case conversion”// Swift"hello".uppercased() // "HELLO""HELLO".lowercased() // "hello"// Rust"hello".to_uppercase() // "HELLO" (returns String)"HELLO".to_lowercase() // "hello" (returns String)String literals and raw strings
Section titled “String literals and raw strings”Regular string literals work the same way in both languages:
// Rustlet simple = "Hello, world!";let escaped = "She said \"hello\"";let newline = "line one\nline two";let unicode = "\u{1F600}"; // emojiRust raw strings use r#"..."# to avoid escaping:
// Rustlet raw = r#"She said "hello" and it was fine"#;let regex = r#"\d{3}-\d{4}"#;You can add more # symbols if your content contains "#:
// Rustlet nested = r##"Contains a "# sequence"##;Swift uses #"..."# for a similar purpose (extended delimiters):
// Swiftlet raw = #"She said "hello" and it was fine"#let regex = #"\d{3}-\d{4}"#Multiline strings
Section titled “Multiline strings”// Swiftlet multi = """ Line one Line two """// Rustlet multi = "\Line oneLine two";Rust does not have a dedicated multiline string literal like Swift’s triple-quoted syntax. Regular string literals can span multiple lines. A backslash at the end of a line suppresses the newline and any leading whitespace on the next line, which is useful for formatting long strings in code.
Byte strings
Section titled “Byte strings”Rust has byte string literals (b"...") that produce &[u8] rather than &str. These are useful when working with binary protocols or ASCII-only data:
// Rustlet bytes: &[u8] = b"hello"; // [104, 101, 108, 108, 111]let byte: u8 = b'A'; // single byte literal: 65Swift does not have a direct equivalent. You would use Array("hello".utf8) or a [UInt8] literal to achieve something similar.
Key differences and gotchas
Section titled “Key differences and gotchas”- Two string types: Rust’s
String/&strdistinction has no Swift equivalent. Learn to think of&stras the default for function parameters andStringas the default when you need ownership. len()is byte length:"cafe\u{0301}".len()returns 6 (bytes), not 4 or 5. Use.chars().count()for scalar count, but remember that there is no built-in grapheme cluster count.- Indexing panics:
&s[0..n]panics if the range does not land on a UTF-8 character boundary. Always validate or usechar_indices()to find safe boundaries. +moves the left operand:let c = a + &b;consumesa. Useformat!when you want to combine strings without consuming any of them.- No grapheme clusters: Rust’s
charis a Unicode scalar value (4 bytes), not a grapheme cluster. For user-perceived characters, use theunicode-segmentationcrate. - String literals are
&str: in Rust,"hello"is a&str, not aString. To get an ownedString, writeString::from("hello")or"hello".to_string(). - Deref coercion: a
&Stringautomatically coerces to&str, so you can pass a&Stringanywhere a&stris expected. This is why accepting&strin function signatures is idiomatic – it works with both types.