【AI开发之Rust】第 3 课:字符串与复合类型 —— 数据怎么放

第 3 课:字符串与复合类型 ------ 数据怎么放

预计时间:75-90 分钟 | 难度:⭐⭐ | 前置:第 1-2 课(变量、类型、函数、控制流)


3.1 这节课解决什么问题

写程序 80% 的时间在处理"数据往哪放、怎么取"。Rust 对数据容器的设计分得很清楚,它不像 Python 那样"字符串就是字符串,列表就是列表",而是把两个问题拆开:

复制代码
问题一:这块数据是"借来的视图"还是"自己拥有的完整数据"?
        → 字符串里就是 &str(借来的)vs String(拥有的)
        → 数组数据里就是 &[T](切片视图)vs Vec<T>(拥有的动态数组)

问题二:数据长度是编译期已知(栈上)还是运行期才变(堆上)?
        → 数组 [T; N] 定长在栈上
        → Vec/ String 变长在堆上,栈上只放"指针+长度+容量"

这种"拥有/借用"的区分是 Rust 独有的(其他语言大多模糊处理),它直接为第 4 课所有权铺路。所以本课的真正目标不是背 API,而是让你对每种容器先建立"它拥有什么、借用什么"的直觉。

💡 先给结论方便记忆:&str&[T] 是"只读视图";StringVec<T> 是"可变的、自己持有数据的容器"。 只要在代码里看到类型前带 &,立刻想"这只是借用,不是拥有"。

3.2 字符串:String vs &str

3.2.1 两种类型的根本区别

rust 复制代码
fn main() {
    // &str:字符串切片。字面量写进程序(只读区),"借"出来用
    let greeting: &str = "你好,Rust";

    // String:堆上的、自己拥有的、可增长的字符串
    let mut s: String = String::from("你好");
    s.push_str(",Rust");          // String 可以修改(要有 mut)
    println!("{s}");                // 你好,Rust

    // 二者转换:双向都很便宜(对 &str -> String 是拷贝数据到堆)
    let s2: String = greeting.to_string();   // 或 String::from(greeting)
    let view: &str = &s2;                    // String 借用成 &str(自动 deref 也可)
    let view2: &str = s2.as_str();           // 等价写法
}

内存上看它俩差别(请把这张图记在脑子里,所有字符串问题都源于它):

复制代码
String  s                 &str  view
┌─────────────┐          ┌──────────┐
│ ptr ────────┼───┐      │ ptr ─────┼────┐        ┌─ "你好,Rust..."(堆)
│ len: 9      │   │      │ len: 9   │    └───────▶│  或(只读区)
│ cap: 9      │   ▼      └──────────┘            └─ "你好,Rust"(程序内嵌)
└─────────────┘   ┌──────────────┐
                  │ "你好,Rust"  │  堆上/程序内,数据本体
                  └──────────────┘
String 拥有自己那块内存(有容量 cap,可扩容)
&str   只是"指针+长度",不拥有数据,无法扩容
维度 &str String
是否拥有数据 否(借用/视图) 是(堆上自有)
是否可变长 否(不可 push) 是(push_str 等)
典型来源 字符串字面量、切片 String::from.to_string()、拼接
传参/返回值 想只读时用 想拥有、想修改时用

3.2.2 拼接与 format!(高频)

rust 复制代码
fn main() {
    let a = String::from("Hello");
    let b = String::from("World");

    // 方式一:+ ------ 语义是 a 被"吃掉"(add 会把 a move 进去),右边必须是 &str
    let c = a + &b;          // a 之后就不能再用了(被移动)!b 还能用(只借了 &b)
    println!("{c}");         // HelloWorld

    // 方式二:format! ------ 最常用,任何组合都行,不移动任何变量
    let d = format!("{c}-{b}-{}", 2026);
    println!("{d}");         // HelloWorld-World-2026
}

⚠️ 常见坑 String + String 编译错误:

rust 复制代码
// let e = a + b;   // ❌ error[E0369]: cannot add `String` to `String`

原因:+ 右侧只实现了 &strimpl Add<&str> for String)。编译器会提示"右边的操作数可以借用一下(&b)"。凡是看到"cannot add",先想是不是该传引用。

3.2.3 为什么不能 s[0] 按下标取字符(UTF-8 关键课)

Rust 的 String 内部是 UTF-8 字节序列,下标索引是"按字节"的。但一个字符在 UTF-8 里占 1~4 字节,所以"第几个字符"和"第几个字节"对不上:

rust 复制代码
fn main() {
    let s = String::from("中文abc");

    // len() 返回的是【字节数】!"中"占 3 字节,"文" 3 字节,a/b/c 各 1
    println!("len = {}(字节)", s.len());      // 3+3+3 = 9
    println!("chars count = {}", s.chars().count());  // 字符数 = 6

    // 直接按下标/按字节切片可能 panic(切在字符中间)
    // let ch = s[0];            // ❌ 编译期就拒绝:String 不支持按索引取字符
    // let slice = &s[0..1];     // ❌ 运行期 panic:不是字符边界

    // 正确取字符:用 chars()
    let chars: Vec<char> = s.chars().collect();
    println!("第一个字符: {}", chars[0]);          // 中
    println!("前两个字符: {}", s.chars().take(2).collect::<String>());  // 中文

    // 需要按字节看?s.bytes() / s.as_bytes()
    println!("bytes: {:?}", s.as_bytes());        // [228, 184, 173, ...]
}

一句话记忆:len() = 字节数,chars().count() = 字符数,遍历/取字符用 chars(),要下标式访问先 .chars().collect::<Vec<_>>().chars().nth(i)

💡 后端对接时这点极重要:服务器往往按字节算长度、做截断(比如只允许 20 字节),你按"字符"算就对不上。课程第 14 课做 JSON 校验时还会遇到。

3.2.4 常用字符串 API 速查(每个都给最小示例)

rust 复制代码
fn main() {
    let s = "  Hello, Rust 语言!  ";

    // 查询
    println!("{}", s.trim());                     // 去首尾空白
    println!("{}", s.starts_with(" "));           // true
    println!("{}", s.contains("Rust"));           // true
    println!("{}", "RUST".to_lowercase());        // rust

    // 变换(注意:返回新值,原字符串不变------String 默认不可变只是约定,见 3.2.5)
    let replaced = "a-b-c".replace("-", "_");     // a_b_c
    println!("{replaced}");

    // 按空白切词(写单词统计神器)
    for word in s.split_whitespace() {
        print!("[{word}] ");
    }
    println!();

    // split / splitn / lines
    let parts: Vec<&str> = "name=小灵;age=26".split(';').collect();
    println!("{parts:?}");                        // ["name=小灵", "age=26"]

    // 查找:find 返回 Option<usize>(第 5 课才系统讲 Option,先会读即可)
    if let Some(idx) = s.find("Rust") {
        println!("Rust 首次出现于字节偏移 {idx}");
    }
}

⚠️ 坑:trim() 只去 Unicode 空白 (含空格/制表/换行),不去自定义字符;要去掉某个固定前缀/后缀用 trim_start_matches("xxx") / strip_prefix

3.2.5 一个小实验:String 可变性 vs &str 的"修改"

rust 复制代码
fn main() {
    // 为什么 String 要 mut 才能 push?因为它要改自己堆上那块数据
    let mut owned = String::from("A");
    owned.push('B');                  // ok,owned 可变

    let view: &str = "AB";
    // view.push('C');                // ❌ 借用视图没有 push 方法
    // 想"改 &str 内容"?做不到,只能生成新字符串:
    let new_view: String = view.replace("AB", "abc");
    println!("{owned} {new_view}");
}

这条规则后面的推论很重要:函数若只读字符串,参数写 &str(谁都能传:字面量/String 的借用都行);函数若要存下/修改字符串,参数写 String&mut String 它是 3.1 那张"拥有 vs 借用"表在函数签名上的直接应用。

3.3 复合类型进阶:tuple / array / slice

第 2 课已见过 tuple 与 array 的基础,这里重点放在三者的对比 和真正的明星 slice(切片) 上。

类型 长度 元素 所在内存 典型写法
tuple 固定 可不同类型 (1, "a", true)
[T; N] array 固定,N 编进类型 同类型 [1, 2, 3]
&[T] slice 不固定(视图) 同类型 指向别人 &arr[..]&vec[..]
Vec<T> 可变 同类型 vec![1, 2, 3]

3.3.1 切片 &[T]:一段数组的只读窗口

切片是对已有连续数据的借用视图 ,写出来一定是引用形式 &[T]

rust 复制代码
fn main() {
    let arr = [1, 2, 3, 4, 5];

    let whole: &[i32] = &arr[..];     // 整段切片
    let head: &[i32]  = &arr[..3];    // 前 3 个:[1, 2, 3]
    let tail: &[i32]  = &arr[2..];    // 从下标 2 到末尾:[3, 4, 5]
    let mid: &[i32]   = &arr[1..4];   // [2, 3, 4]
    println!("{whole:?} {head:?} {tail:?} {mid:?}");

    // 也可以切 Vec
    let v = vec![10, 20, 30, 40];
    let part: &[i32] = &v[1..3];      // [20, 30]
    println!("{part:?}");
}

为什么函数要尽量收 &[T] 而不是 &Vec<T> 因为切片能同时接受数组、Vec 的借用,通用性最强:

rust 复制代码
fn sum(slice: &[i32]) -> i32 {
    let mut total = 0;
    for &n in slice { total += n; }
    total
}

fn main() {
    let arr = [1, 2, 3];
    let vec = vec![4, 5, 6];
    println!("{} {}", sum(&arr), sum(&vec));   // 两个都能传(&Vec<T> 自动转为 &[T])
}

💡 这是 Rust 生态约定俗成的函数签名风格:只读传 &[T]/&str,拥有或修改才传 Vec/String 后面实战篇封装的每个数据读取函数都会这么写。

3.3.2 切片越界:数组/切片下标访问的"安全阀"

rust 复制代码
fn main() {
    let arr = [1, 2, 3];

    // arr[5] ------ 越界:运行期 panic(index out of bounds)
    // let x = arr[5];

    // 更稳的姿势:.get(i) 返回 Option,不 panic
    println!("{:?}", arr.get(0));    // Some(1)
    println!("{:?}", arr.get(5));    // None(而不是 panic!)
}

规则:确定不会越界 → 用 arr[i];不确定 → 用 arr.get(i)Option 这在本课程后面处理用户输入下标、HTTP 分页参数时会反复用到。

3.4 Vec<T>:可变长数组(本课最重要容器)

Vec<T> 是"会自动扩容的数组",能存同类型数据任意多个,堆上存储:

rust 复制代码
fn main() {
    // 创建
    let mut v: Vec<i32> = Vec::new();   // 空 Vec(类型要标/推断出来)
    let mut v2 = vec![1, 2, 3];          // vec! 宏(最常用)
    let v3 = vec![0; 5];                 // 5 个 0

    // 增删
    v.push(10); v.push(20); v.push(30);
    v2.pop();                            // 弹掉末尾 3,返回 Option(Some(3))
    v2.insert(1, 99);                    // 在下标 1 插入
    v2.remove(0);                        // 移除下标 0,返回被删元素

    // 查改
    println!("{:?}", v.get(0));          // Some(&10)(引用!见下)
    println!("{}", v[1]);                // 直接下标:20
    println!("len={} cap={}", v.len(), v.capacity());  // 3 / 4(容量≥长度)

    // 遍历(三种取法对应"借用/借用改/拿走")
    for x in &v {            // 只读借用每个元素,x: &i32
        print!("{x} ");
    }
    println!();
    for x in &mut v2 {       // 可变借用,可原地改
        *x *= 2;
    }
    println!("{v2:?}");      // [198, 4, 6]
    for x in v {             // 拿走 v(v 之后不能再用)------ 所有权第 4 课细讲
        print!("{x} ");
    }
    println!();
}

关键点三条:

  1. v[i] 返回元素本身,.get(i) 返回 Option<&T> 引用 ------.get 不 panic、不复制,读取大元素时也高效(后续读结构体时会看到 & 无处不在的原因);
  2. 容量 capacity ≥ 长度 lenpush 到容量不够时 Vec 会"搬家扩容"(重新分配更大的堆块并把数据拷过去),所以"大概知道多大"时可用 Vec::with_capacity(n) 提前留好,避免反复扩容(实战篇处理 SSE 大响应时会用);
  3. 遍历三个 for 版本本质不同(借用/借用改/拿走),这是所有权语义的预演,第 4 课回来看会豁然开朗。

3.4.1 小试:收集、过滤、求和(只用现有知识)

rust 复制代码
fn main() {
    // 收集 1..=100 里的偶数
    let mut evens = Vec::new();
    for n in 1..=100 {
        if n % 2 == 0 {
            evens.push(n);
        }
    }
    println!("偶数个数: {}, 最后一个: {}", evens.len(), evens[evens.len() - 1]);

    // 求平均(把 &[i32] 版本的 sum 复用在这里)
    let avg = sum(&evens) as f64 / evens.len() as f64;
    println!("前若干项均值: {avg:.2}");
}

fn sum(slice: &[i32]) -> i32 {
    slice.iter().sum()   // 预告:迭代器求和;等价写法见 3.3.1 的循环版
}

💡 注意 sum(&evens):这里把 &Vec<i32> 传给了要 &[i32] 的函数------&Vec 自动 deref 成切片,所以 3.3.1 的通用签名在这里直接生效。

3.5 读报错专项:String 与 &str 的三类典型错误

选三种最高频的,学会看后你能自己解决大部分字符串报错:

① 把 String 当 &str 用 / 反之(类型不匹配)

rust 复制代码
fn len_str(s: &str) -> usize { s.chars().count() }

fn main() {
    let owned = String::from("hi");
    // len_str(owned);   // ❌ error[E0308]: mismatched types
    //                     expected `&str`, found `String`
    //             编译器通常还会附一句:help: consider borrowing here: `&owned`
    len_str(&owned);      // ✅ 传借用
}

② 想改 &str(没有所有权)

rust 复制代码
fn main() {
    let s = "hello";
    // s.push('!');       // ❌ error[E0596]: cannot borrow `s` as mutable,
    //                       as it is not declared as mutable
    // 即使加了 mut:&str 也没有 push 方法,该错误会提示把类型改成 String
}

③ String 拼接右边必须是 &str

rust 复制代码
fn main() {
    let a = String::from("x");
    let b = String::from("y");
    // let c = a + b;     // ❌ error[E0369]: cannot add `String` to `String`
    // 修正:a + &b 或 format!("{a}{b}")
}

💡 统一的心法:看到报错里有 expected &str, found String(或反过来)时,先问自己------这个变量到底是"拥有者"还是"借用视图"? 判断准了,加个 & 或换个 .to_string() 就好。

3.6 常见坑清单(本课自检)

  • s.len() 想当然当"字符个数"------它数的是字节;中文字符 1 个占 3 字节;

  • &s[..n] 切在字符中间直接 panic------想取前 n 个字符chars().take(n)

  • Vec/数组越界下标直接 panic------边界不确定就用 .get(i)

  • String + String 报错------右侧改 &b

  • 字符串 replace/trim 等方法返回新值 ,忘了接收就白干:

    rust 复制代码
    let s = String::from("a-b");
    s.replace("-", "");        // ❌ 结果被丢弃,编译器会警告 unused
    let s = s.replace("-", ""); // ✅ 或 let s2 = ...
  • 遍历 Vec 拿值却只需要读时,若写成 for x in v 会把 v 整个移动走,后面再用 v 就报 "use of moved value"(第 4 课正式解释,现在只需记住:只读遍历写 for x in &v)。

3.7 📝 动手练习

参考实现放 code/03-compound-strings/(写作时同步给出),每题独立完成再对照。

  1. 单词统计器 :写 fn count_words(text: &str) -> usize,用 split_whitespace() 实现;用一段含中文+英文的文本测试,并打印字符数(chars().count())与字节数(len())的差异并解释原因。
  2. 字符频率 Top3 (不动用 HashMap,用遍历+Vec):统计一个英文句子中每个字母出现次数,打印出现最多的 3 个(提示:Vec<(char, u32)>,遍历时先查再改,或用元组临时排序思路)。此题做不出可先跳过,第 9 课 HashMap 后是送分题。
  3. 去重保留顺序 :写 fn dedup(items: &[i32]) -> Vec<i32>,返回去除连续重复项后的 Vec。
  4. 字符串清洗 :写 fn clean(text: &str) -> String:去首尾空白 → 把连续多个空格压成一个 → 全转小写。要求只用 2.3/3.2 学的 API(可拆多步链式调用)。
  5. 切片函数化 :把 sum(&[i32]) 改写为 fn avg(slice: &[i32]) -> f64,并分别用数组、Vec 调用,验证 &arr&vec 都能传。
  6. 读报错三连:把 3.5 的三种错误各写一遍,记录错误码(E0308 / E0596 / E0369)与编译器给出的修正提示原文,再修复。
  7. 字符偏移实验let s = "é中文",分别打印 s.len()s.chars().count()、用 chars().enumerate() 打印每个字符的字节偏移(提示:需要手动累计字节数,可用 c.len_utf8() 获取单个字符占几个字节)。

验收门禁 :不查资料能说出------len()chars().count() 的区别;String&str 各在什么时候用;函数"只读时收 &str/&[T]"这条约定的理由;v[i] vs v.get(i) 的选择依据。

✅ 本节小结

  • 核心心智&str/&[T] 是借用视图(不拥有、不能改);String/Vec<T> 是拥有数据的可变容器(堆上,能扩容)------"有没有 &"决定了一切;
  • 字符串 :UTF-8 字节序列,len() 数字节、chars().count() 数字符;不能 s[0];拼接用 + &sformat!
  • slice&arr[..]/&vec[..] 是通用只读窗口,函数收参首选 &[T]
  • Vecvec![] 建、push/pop/insert/remove 增删、[i].get(i) 两种访问、capacity 与 len 的关系、三种 for 遍历对应不同所有权;
  • 报错模式:E0308(类型不匹配)、E0596(不可变借用想改)、E0369(相加类型不对)三类字符串错误的修法。

下一课预告 :第 4 课《所有权系统:Rust 的灵魂》------本课程最重要的一课。为什么前面反复出现"拥有/借用/move/use of moved value"?栈与堆、三条所有权规则、Copy vs Clone、可变与不可变借用规则,一次讲清。这一课想明白,后面 5~21 课都会顺很多。

相关推荐
小智老师PMP1 小时前
2026PMP第八版新纲深度解读|从流程管控到价值交付,核心考点全迭代
人工智能·职场和发展·软件工程·制造·敏捷流程
t-think1 小时前
C++ string 类(上)—— string类初识与常用接口详解
开发语言·c++
_山海1 小时前
Bun入门指南
前端·javascript·后端
不好听6131 小时前
从朴素到智能:RAG 为什么需要 Agent 化——Agentic RAG 系列之一
llm
秦先生在广东1 小时前
Worktrunk: 面向并行 AI Agent 工作流的 Git Worktree 管理 CLI
人工智能
cpp_learners1 小时前
C++ 实现责任链模式(Chain of Responsibility):从一堆 if-else 到可插拔的处理管道
开发语言·c++·责任链模式
韩曙亮1 小时前
【AI 大模型】国内各平台 AI 大模型 价格、性能 对比分析 ② ( 智谱 BigModel | 腾讯云 TokenHub | 千问 AI 平台 )
人工智能·ai·大模型·腾讯云·ai大模型·千问·智谱
掰头战士1 小时前
三道保险丝,最后只能放弃治疗? 一文聊聊我的agent是怎么做死循环检测的
typescript·llm·agent
YHL1 小时前
🧠 Agent 记忆进阶:总结压缩与向量检索 —— 从截断到 Milvus 长期记忆
前端·人工智能