返回 SC 笔记
SC Day 6

Rust 字符串 + 切片 + 生命周期基础

String vs &str 的本质区别、字符串操作方法、切片语法、生命周期基础

2026-04-15
第一阶段:基础构建
ruststringstrsliceslifetimes

日期: 2026-04-15 方向: Rust 阶段: 第一阶段:基础构建 标签: #rust #string #str #slices #lifetimes


今日目标

类型内容
学习String vs &str 的本质区别、字符串操作方法、切片语法、生命周期基础
实操字符串处理函数编写、切片操作练习、解决生命周期编译错误
产出字符串处理工具集 + 生命周期理解笔记

一、Rust 的两种字符串

Rust 的字符串处理是新手的另一个痛点。理解 String&str 的区别是写好 Rust 的关键。

1.1 String vs &str 本质区别

fn main() {
    // &str: 字符串切片(String Slice)
    // - 不可变的 UTF-8 字节序列的引用
    // - 可以指向堆、栈或静态内存
    // - 类似 Solidity 中的 string memory(只读视图)
    let literal: &str = "hello, world";  // 存储在程序二进制中(静态内存)

    // String: 堆分配的可增长字符串
    // - 拥有数据的所有权
    // - 可以修改(如果是 mut)
    // - 类似 Solidity 中的 string storage
    let owned: String = String::from("hello, world");

    // 内存布局对比:
    // &str "hello":
    //   栈: [ptr | len=5]  → 指向某处的 "hello" 字节
    //
    // String "hello":
    //   栈: [ptr | len=5 | capacity=5]  → 堆: [h|e|l|l|o]
}

1.2 相互转换

fn main() {
    // String → &str(解引用,零成本)
    let s: String = String::from("hello");
    let slice: &str = &s;           // 自动解引用
    let slice2: &str = s.as_str();  // 显式方法
    let slice3: &str = &s[..];      // 切片语法

    // &str → String(需要分配堆内存,有成本)
    let literal: &str = "world";
    let owned1: String = literal.to_string();
    let owned2: String = String::from(literal);
    let owned3: String = literal.to_owned();
    // 三种方式等价,推荐用 .to_string() 或 String::from()

    println!("{} {}", s, literal);
}

1.3 为什么需要两种字符串?

// &str 的优势:零成本传递,不分配内存
fn greet(name: &str) {  // 接受 &str 更通用
    println!("Hello, {}!", name);
}

fn main() {
    let owned = String::from("Alice");
    let literal = "Bob";

    greet(&owned);   // ✅ String 自动转为 &str
    greet(literal);  // ✅ &str 直接传入

    // 如果函数签名是 fn greet(name: &String)
    // 那么 literal 无法直接传入,不够通用
}

// 经验法则:
// - 函数参数用 &str(更通用,接受 String 和 &str)
// - 需要拥有字符串时用 String(作为字段、需要修改、需要返回)
// - struct 字段通常用 String(因为需要所有权)

二、字符串操作

2.1 创建字符串

fn main() {
    // 多种创建方式
    let s1 = String::new();                    // 空字符串
    let s2 = String::from("hello");            // 从 &str
    let s3 = "hello".to_string();              // 同上
    let s4 = String::with_capacity(100);       // 预分配容量
    let s5 = format!("{}-{}", "hello", "world"); // 格式化创建
    let s6 = ['h', 'e', 'l', 'l', 'o'].iter().collect::<String>(); // 从字符迭代器

    println!("s1: '{}', s2: '{}', s5: '{}', s6: '{}'", s1, s2, s5, s6);
}

2.2 修改字符串

fn main() {
    let mut s = String::from("hello");

    // 追加字符串
    s.push_str(", world");  // 追加 &str
    s.push('!');             // 追加单个字符

    // 拼接
    let s1 = String::from("hello");
    let s2 = String::from(", world");
    let s3 = s1 + &s2;  // ⚠️ s1 被 Move 了,s2 被借用
    // println!("{}", s1);  // ❌ s1 不再有效
    println!("{}", s3);     // "hello, world"
    println!("{}", s2);     // ✅ s2 仍然有效(只是借用)

    // format! 不会 Move 任何参数(推荐方式)
    let a = String::from("tic");
    let b = String::from("tac");
    let c = String::from("toe");
    let result = format!("{}-{}-{}", a, b, c);
    println!("{}", result);  // "tic-tac-toe"
    println!("{} {} {}", a, b, c);  // ✅ 全部仍然有效

    // 插入
    let mut greeting = String::from("Hello World");
    greeting.insert(5, ',');         // "Hello, World"
    greeting.insert_str(7, "dear "); // "Hello, dear World"
    println!("{}", greeting);

    // 替换
    let replaced = "I like Solidity".replace("Solidity", "Rust");
    println!("{}", replaced);  // "I like Rust"

    // 删除
    let mut trimmed = String::from("  hello  ");
    println!("trimmed: '{}'", trimmed.trim()); // "hello"

    let mut removable = String::from("hello!");
    removable.pop();           // 移除最后一个字符
    println!("{}", removable); // "hello"
    removable.remove(0);       // 移除索引位置的字符
    println!("{}", removable); // "ello"
    removable.truncate(3);     // 截断到前3个字节
    println!("{}", removable); // "ell"
    removable.clear();         // 清空
    println!("empty: '{}'", removable); // ""
}

2.3 查询和检索

fn main() {
    let s = String::from("Hello, World! 你好世界");

    // 长度(字节数,不是字符数!)
    println!("Byte length: {}", s.len());        // 31(UTF-8,中文3字节/字符)
    println!("Char count: {}", s.chars().count()); // 18 个字符

    // 检查
    println!("Contains 'World': {}", s.contains("World"));
    println!("Starts with 'Hello': {}", s.starts_with("Hello"));
    println!("Ends with '世界': {}", s.ends_with("世界"));
    println!("Is empty: {}", s.is_empty());

    // 查找
    println!("Find 'World': {:?}", s.find("World"));  // Some(7)
    println!("Find 'Rust': {:?}", s.find("Rust"));    // None

    // 分割
    let csv = "Alice,Bob,Charlie";
    let names: Vec<&str> = csv.split(',').collect();
    println!("Names: {:?}", names);  // ["Alice", "Bob", "Charlie"]

    // 遍历字符
    for c in "hello".chars() {
        print!("{} ", c);
    }
    println!();

    // 遍历字节
    for b in "hello".bytes() {
        print!("{} ", b);  // 104 101 108 108 111
    }
    println!();
}

2.4 字符串索引的陷阱

fn main() {
    let s = String::from("你好");

    // ❌ 不能用索引直接访问
    // let c = s[0];  // error: String cannot be indexed by `{integer}`

    // 原因:UTF-8 是变长编码
    // "你" = [228, 189, 160]  3个字节
    // "好" = [229, 165, 189]  3个字节
    // s[0] 应该返回什么?字节 228?还是字符 '你'?
    // Rust 要求你明确选择:

    // 方式1:按字节切片(必须在字符边界上!)
    let first_char = &s[0..3];  // "你"(3个字节)
    println!("First char: {}", first_char);
    // let bad = &s[0..1];  // ❌ panic! 不在字符边界上

    // 方式2:按字符迭代
    let first = s.chars().nth(0);  // Some('你')
    println!("First: {:?}", first);

    // 方式3:char_indices 获取字符和字节位置
    for (i, c) in s.char_indices() {
        println!("Byte index {}: '{}'", i, c);
        // Byte index 0: '你'
        // Byte index 3: '好'
    }
}

三、切片 (Slices)

切片是对连续内存序列的引用视图,不拥有数据。

3.1 字符串切片

fn main() {
    let s = String::from("hello world");

    // 字符串切片语法:&s[start..end](字节偏移)
    let hello: &str = &s[0..5];   // "hello"
    let world: &str = &s[6..11];  // "world"
    let hello2: &str = &s[..5];   // 省略开始 = 0
    let world2: &str = &s[6..];   // 省略结束 = len
    let full: &str = &s[..];      // 整个字符串

    println!("{} {} {} {} {}", hello, world, hello2, world2, full);

    // 字符串字面量就是切片
    let literal: &str = "hello";
    // literal 的类型就是 &str,指向程序二进制中的字符串数据
}

// 实用函数:返回第一个单词
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &byte) in bytes.iter().enumerate() {
        if byte == b' ' {
            return &s[..i];
        }
    }

    s  // 没有空格,整个字符串就是一个单词
}

3.2 数组切片

fn main() {
    let arr = [1, 2, 3, 4, 5];

    // 数组切片
    let slice: &[i32] = &arr[1..4];  // [2, 3, 4]
    println!("Slice: {:?}", slice);
    println!("Length: {}", slice.len());
    println!("First: {}", slice[0]);  // 2

    // Vec 也可以切片
    let v = vec![10, 20, 30, 40, 50];
    let v_slice: &[i32] = &v[2..];  // [30, 40, 50]
    println!("Vec slice: {:?}", v_slice);

    // 可变切片
    let mut arr2 = [1, 2, 3, 4, 5];
    let mutable_slice: &mut [i32] = &mut arr2[1..4];
    mutable_slice[0] = 20;
    println!("Modified: {:?}", arr2);  // [1, 20, 3, 4, 5]
}

// 函数参数用切片更通用
fn sum(numbers: &[i32]) -> i32 {
    numbers.iter().sum()
}

fn demo() {
    let arr = [1, 2, 3];
    let vec = vec![4, 5, 6];

    println!("Array sum: {}", sum(&arr));    // ✅
    println!("Vec sum: {}", sum(&vec));      // ✅
    println!("Slice sum: {}", sum(&arr[1..])); // ✅
}

3.3 切片方法

fn main() {
    let data = [5, 2, 8, 1, 9, 3, 7, 4, 6];
    let slice = &data[..];

    println!("Length: {}", slice.len());
    println!("Is empty: {}", slice.is_empty());
    println!("Contains 8: {}", slice.contains(&8));
    println!("First: {:?}", slice.first());   // Some(&5)
    println!("Last: {:?}", slice.last());     // Some(&6)

    // 分割
    let (left, right) = slice.split_at(4);
    println!("Left: {:?}, Right: {:?}", left, right);

    // 窗口
    for window in slice.windows(3) {
        print!("{:?} ", window);
    }
    println!();

    // 块
    for chunk in slice.chunks(3) {
        print!("{:?} ", chunk);
    }
    println!();

    // 排序(需要可变)
    let mut sortable = data.to_vec();
    sortable.sort();
    println!("Sorted: {:?}", sortable);

    // 二分查找
    println!("Search 7: {:?}", sortable.binary_search(&7));
}

四、生命周期基础

生命周期(lifetime)是 Rust 的另一个核心概念,确保引用始终有效。

4.1 为什么需要生命周期

// 问题:这个函数返回 &str,但编译器不知道返回的引用指向谁
// fn longest(x: &str, y: &str) -> &str {  // ❌ 缺少生命周期标注
//     if x.len() > y.len() { x } else { y }
// }

// 编译器需要知道:返回的引用和输入的关系
// 如果返回的是 x,那返回值的生命周期 ≤ x 的生命周期
// 如果返回的是 y,那返回值的生命周期 ≤ y 的生命周期
// 编译器无法自动推断,所以需要开发者标注

4.2 生命周期语法

// 'a 是生命周期参数,读作 "lifetime a"
// 含义:返回的引用和输入引用有相同的生命周期

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("long string is long");
    let result;

    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
        println!("Longest: {}", result);  // ✅ result 在 string2 有效时使用
    }

    // ❌ 如果 result 在这里使用就会编译错误:
    // println!("Longest: {}", result);
    // 因为 result 的生命周期 = min(string1, string2) 的生命周期
    // string2 在上面的 } 处已经被 drop
}

4.3 生命周期省略规则

大多数情况下不需要显式标注生命周期,编译器会自动推断(Lifetime Elision Rules):

// 规则 1:每个引用参数获得自己的生命周期
// fn foo(x: &str) 变成 fn foo<'a>(x: &'a str)
// fn foo(x: &str, y: &str) 变成 fn foo<'a, 'b>(x: &'a str, y: &'b str)

// 规则 2:如果只有一个输入引用参数,输出引用获得相同生命周期
// fn foo(x: &str) -> &str 变成 fn foo<'a>(x: &'a str) -> &'a str
fn first_word(s: &str) -> &str {  // ✅ 不需要标注
    &s[..s.find(' ').unwrap_or(s.len())]
}

// 规则 3:如果有 &self 或 &mut self 参数,输出获得 self 的生命周期
struct Parser {
    input: String,
}

impl Parser {
    fn get_input(&self) -> &str {  // ✅ 不需要标注
        &self.input                // 返回值的生命周期 = &self 的生命周期
    }
}

// 当这三条规则不够推断时,才需要显式标注
// 最常见的场景:两个输入引用,返回其中一个

4.4 结构体中的生命周期

// 结构体包含引用时,必须标注生命周期
struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }

    // 规则 3 自动推断:返回值生命周期 = &self
    fn announce_and_return(&self, announcement: &str) -> &str {
        println!("Attention: {}", announcement);
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence;

    {
        let i = novel.find('.').unwrap_or(novel.len());
        first_sentence = &novel[..i];
    }

    let excerpt = ImportantExcerpt {
        part: first_sentence,
    };

    println!("Excerpt: {}", excerpt.part);
}

4.5 'static 生命周期

// 'static: 引用在整个程序运行期间都有效
let s: &'static str = "I live forever";
// 字符串字面量的生命周期就是 'static,因为它们嵌入在程序二进制中

// const 也是 'static
const PI: f64 = 3.14;

// 小心:不要到处用 'static 来"解决"生命周期问题
// 这通常意味着你的设计有问题

五、代码实战:字符串处理工具

/// 字符串处理工具集 - 模拟区块链地址和交易数据处理

/// 验证以太坊地址格式
fn is_valid_eth_address(addr: &str) -> bool {
    // 以太坊地址:0x 开头 + 40个十六进制字符
    if addr.len() != 42 {
        return false;
    }
    if !addr.starts_with("0x") && !addr.starts_with("0X") {
        return false;
    }
    addr[2..].chars().all(|c| c.is_ascii_hexdigit())
}

/// 缩短地址显示(0x1234...abcd)
fn shorten_address(addr: &str) -> String {
    if addr.len() < 10 {
        return addr.to_string();
    }
    format!("{}...{}", &addr[..6], &addr[addr.len()-4..])
}

/// 解析以逗号分隔的地址列表(返回切片引用,避免分配)
fn parse_address_list<'a>(input: &'a str) -> Vec<&'a str> {
    input.split(',')
         .map(|s| s.trim())
         .filter(|s| !s.is_empty())
         .collect()
}

/// 从交易日志中提取地址
fn extract_addresses<'a>(log: &'a str) -> Vec<&'a str> {
    let mut addresses = Vec::new();
    let mut start = 0;

    while let Some(pos) = log[start..].find("0x") {
        let abs_pos = start + pos;
        if abs_pos + 42 <= log.len() {
            let candidate = &log[abs_pos..abs_pos + 42];
            if is_valid_eth_address(candidate) {
                addresses.push(candidate);
            }
        }
        start = abs_pos + 2;
    }

    addresses
}

/// 查找两个字符串中较长的那个(需要生命周期标注)
fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() >= s2.len() { s1 } else { s2 }
}

/// Token 信息结构体(包含引用,需要生命周期)
struct TokenInfo<'a> {
    name: &'a str,
    symbol: &'a str,
    description: String,  // 拥有的数据不需要生命周期
}

impl<'a> TokenInfo<'a> {
    fn new(name: &'a str, symbol: &'a str) -> Self {
        TokenInfo {
            name,
            symbol,
            description: format!("{} ({})", name, symbol),
        }
    }

    fn display(&self) -> &str {
        &self.description
    }

    fn symbol(&self) -> &str {
        self.symbol
    }
}

fn main() {
    println!("=== Ethereum Address Validation ===");
    let addrs = [
        "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD68",
        "0xINVALID",
        "0x1234",
        "not an address",
    ];
    for addr in &addrs {
        println!("  {}: valid = {}", shorten_address(addr), is_valid_eth_address(addr));
    }

    println!("\n=== Address Shortening ===");
    let full = "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD68";
    println!("  Full: {}", full);
    println!("  Short: {}", shorten_address(full));

    println!("\n=== Parse Address List ===");
    let list = "0xAAA..., 0xBBB..., 0xCCC...";
    let parsed = parse_address_list(list);
    println!("  Input: {}", list);
    println!("  Parsed: {:?}", parsed);
    // parsed 中的元素是 list 的切片引用,零分配!

    println!("\n=== Extract Addresses from Log ===");
    let log = "Transfer from 0x742d35Cc6634C0532925a3b844Bc9e7595f2bD68 to 0x8ba1f109551bD432803012645Ac136ddd64DBA72 amount 1.5 ETH";
    let extracted = extract_addresses(log);
    for addr in &extracted {
        println!("  Found: {}", shorten_address(addr));
    }

    println!("\n=== Lifetime Demo ===");
    let name = "Ethereum";
    let symbol = "ETH";
    let token = TokenInfo::new(name, symbol);
    println!("  Token: {}", token.display());
    println!("  Symbol: {}", token.symbol());

    let longer_str = longer(name, symbol);
    println!("  Longer: {}", longer_str);

    println!("\n=== String vs &str Performance ===");
    // &str: 零成本传递
    let s = String::from("hello world this is a long string for demonstration");
    process_str(&s);       // 传递 &str,只复制指针和长度(16字节)
    // process_string(s);  // 传递 String,Move 所有权
}

fn process_str(s: &str) {
    println!("  Processing {} bytes (zero copy)", s.len());
}

六、关键要点总结

要点说明
&str 是视图,String 是所有者&str 借用数据,String 拥有数据
函数参数推荐 &str更通用,同时接受 String 和 &str
struct 字段用 String除非确定生命周期,否则用 String
字符串不能按索引访问UTF-8 变长编码,必须用 .chars() 或切片
切片是引用的视图零成本创建,不分配内存
生命周期 = 引用的有效范围编译器检查,不是运行时概念
大多数时候不需要标注三条省略规则覆盖常见场景
'static = 永久有效字符串字面量和 const 是 'static

七、常见误区

误区 1:以为 String 长度是字符数

let s = "你好";
println!("{}", s.len());        // 6(字节数!不是 2)
println!("{}", s.chars().count()); // 2(字符数)
// UTF-8 中文每个字符 3 字节

误区 2:在字符串非边界处切片

let s = "你好世界";
// let bad = &s[0..1];  // ❌ panic! byte index 1 is not a char boundary
let good = &s[0..3];    // ✅ "你"(3字节完整字符)

误区 3:到处用 .to_string() 回避借用

// ❌ 不好
fn process(data: &[String]) -> Vec<String> {
    data.iter().map(|s| s.to_string()).collect() // 不必要的复制
}

// ✅ 好
fn process_better<'a>(data: &'a [String]) -> Vec<&'a str> {
    data.iter().map(|s| s.as_str()).collect() // 零成本引用
}

误区 4:混淆 'a 和具体生命周期

// 'a 不是"创建一个生命周期",而是"描述已有引用之间的关系"
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { ... }
// 含义:返回值的有效时间不超过 x 和 y 中较短的那个
// 'a 是 min(x的生命周期, y的生命周期)

八、与 Solidity 的对比

概念SolidityRust
字符串存储string storage(链上永久)String(堆上,所有者 drop 时释放)
字符串引用string memory(临时副本)&str(零成本引用)
字符串比较keccak256(a) == keccak256(b)a == b(直接比较)
字符串拼接string.concat(a, b)format!("{}{}", a, b)
字符串长度bytes(s).lengths.len()(字节)/ s.chars().count()(字符)
生命周期不存在(EVM 管理内存)编译器强制检查引用有效性
gas/性能string 操作很贵&str 传递零成本

九、面试关联

Q: String 和 &str 有什么区别?什么时候用哪个?

A: String 是堆分配的、拥有所有权的、可变的字符串。&str 是字符串数据的不可变引用切片。函数参数推荐用 &str(更通用),struct 字段推荐用 String(需要所有权)。&strString 需要堆分配(.to_string()),String&str 是零成本(&s.as_str())。

Q: 解释 Rust 的生命周期。为什么需要它?

A: 生命周期是编译器跟踪引用有效范围的机制。它确保引用永远不会指向已释放的内存(悬空引用)。大多数时候编译器通过省略规则自动推断,只在有多个输入引用且返回引用时需要显式标注。生命周期标注('a)描述引用之间的关系约束,不改变任何引用的实际存活时间。

Q: 为什么 Rust 字符串不能通过索引访问?

A: 因为 Rust 字符串使用 UTF-8 编码,这是变长编码——ASCII 字符 1 字节,中文 3 字节,emoji 4 字节。s[n] 的语义不明确:返回第 n 个字节(可能切断字符)还是第 n 个字符(需要 O(n) 遍历)?Rust 选择强制开发者明确意图:用 .bytes() 遍历字节,用 .chars() 遍历字符,用字节范围切片 &s[a..b](必须在字符边界上)。


十、参考资源