

嗨,大家好!我是一名正在学习Rust的大三学生。最近在写一个文本处理工具时,被String和&str搞得晕头转向——什么时候用String?什么时候用&str?为什么类型转换这么复杂?为了彻底搞清楚,我花了两周时间深入研究它们的内部实现。今天想和大家分享我的学习心得!💡
上周我在写一个简单的字符串拼接函数时,遇到了这样的编译错误:
fn concat_strings(s1: &str, s2: &str) -> &str {
let result = s1.to_string() + s2;
&result // 编译错误:返回局部变量的引用!
}编译器报错说我返回了一个悬垂引用(dangling reference)。这让我开始思考:String和&str到底有什么区别?为什么不能互换使用?
经过查阅源码和文档,我发现String的内部结构和Vec几乎一模一样:
pub struct String {
vec: Vec<u8>, // 内部就是一个Vec<u8>
}
pub struct Vec<u8> {
ptr: *mut u8, // 指向堆内存的指针
len: usize, // 实际字节数
cap: usize, // 总容量
}画个示意图更清楚:
栈内存(String结构体) 堆内存(UTF-8字节)
┌─────────────────┐ ┌───┬───┬───┬───┬───┐
│ ptr ────────────────────>│'H'│'e'│'l'│'l'│'o'│
│ len = 5 │ └───┴───┴───┴───┴───┘
│ cap = 8 │ 已用↑ 未用↑
└─────────────────┘24字节(64位系统) 这个结构让我明白了几个关键点:
相比String的"重量级",&str就简单多了:
pub struct &str {
ptr: *const u8, // 指向字符串数据的指针(只读)
len: usize, // 字节数
}&str只有16字节(64位系统),就是一个"胖指针"(fat pointer):
栈内存(&str切片) 可能的数据位置
┌─────────────────┐ ┌────────────┐
│ ptr ────────────────────>│ UTF-8数据 │
│ len = 5 │ └────────────┘
└─────────────────┘ 可能在:
16字节 - 代码段(字符串字面量)
- 堆内存(String的一部分)
- 栈内存(数组的切片)这让我顿悟了:&str不拥有数据,它只是"借用"了某处的字符串数据!
我写了个实验来验证字符串字面量的存储位置:
fn test_string_literal() {
let s1: &str = "Hello, Rust!";
let s2: &str = "Hello, Rust!";
// 比较指针地址
println!("s1 ptr: {:p}", s1.as_ptr());
println!("s2 ptr: {:p}", s2.as_ptr());
// 结果:地址相同!
// s1 ptr: 0x10a0b4000
// s2 ptr: 0x10a0b4000
}发现:相同的字符串字面量共享同一块内存!编译器把它们存在代码段(静态区),多个&str引用同一地址。这种优化叫做"字符串驻留"(string interning)。
再看String的情况:
fn test_string_allocation() {
let s1 = String::from("Hello");
let s2 = String::from("Hello");
println!("s1 ptr: {:p}", s1.as_ptr());
println!("s2 ptr: {:p}", s2.as_ptr());
// 结果:地址不同!
// s1 ptr: 0x7f8e4c400000
// s2 ptr: 0x7f8e4c400020
}发现:每个String都在堆上分配独立内存,即使内容相同也不共享。
理解了内存布局,我开始研究所有权规则:
fn ownership_test() {
// String:拥有数据,可以转移所有权
let s1 = String::from("Hello");
let s2 = s1; // 所有权转移,s1失效
// println!("{}", s1); // 编译错误!
// &str:只是借用,可以随意复制
let s3: &str = "Hello";
let s4 = s3; // 只是复制指针和长度,s3仍有效
println!("{} {}", s3, s4); // 正常工作
}这让我理解了为什么函数参数通常用&str:
// 不好的设计:获取所有权,调用者失去数据
fn process_bad(s: String) {
println!("{}", s);
}
// 好的设计:只借用,不影响调用者
fn process_good(s: &str) {
println!("{}", s);
}
fn main() {
let my_string = String::from("test");
// process_bad(my_string);
// println!("{}", my_string); // 错误:所有权已转移
process_good(&my_string);
println!("{}", my_string); // 正常工作
}设计原则:函数参数优先使用&str,除非需要修改或获取所有权。
String和&str之间的转换是常见操作,但开销大不相同:
use std::time::Instant;
fn benchmark_conversion() {
let iterations = 1000000;
// 测试1:&str -> String(分配内存)
let start = Instant::now();
for _ in 0..iterations {
let s: &str = "Hello, World!";
let _owned = s.to_string(); // 堆分配!
}
let time1 = start.elapsed();
// 测试2:String -> &str(零成本)
let start = Instant::now();
let s = String::from("Hello, World!");
for _ in 0..iterations {
let _borrowed: &str = &s; // 只是创建引用
}
let time2 = start.elapsed();
println!("&str -> String: {:?}", time1);
println!("String -> &str: {:?}", time2);
println!("性能差距: {}倍", time1.as_secs_f64() / time2.as_secs_f64());
}测试结果:
&str -> String: 245.6ms
String -> &str: 1.2ms性能差距: 204倍! 结论:
这个实验让我养成了一个习惯:尽可能延迟String的创建,多用&str传递数据。
在实际项目中,我遇到了一个字符串拼接的性能问题:
// 性能差:每次拼接都重新分配
fn concat_slow(parts: &[&str]) -> String {
let mut result = String::new();
for part in parts {
result = result + part; // 每次都创建新String!
}
result
}
// 性能好:使用push_str原地修改
fn concat_fast(parts: &[&str]) -> String {
let mut result = String::new();
for part in parts {
result.push_str(part); // 原地追加,可能扩容但不重新分配
}
result
}
// 最优:预分配容量
fn concat_optimal(parts: &[&str]) -> String {
let total_len: usize = parts.iter().map(|s| s.len()).sum();
let mut result = String::with_capacity(total_len);
for part in parts {
result.push_str(part);
}
result
}性能测试:
let parts: Vec<&str> = vec!["Hello"; 10000];
// concat_slow: 428ms
// concat_fast: 12ms (35倍提升)
// concat_optimal: 8ms(53倍提升)这个实验让我深刻理解了:String的+操作符会创建新对象,而push_str是原地修改。
String和&str都强制要求UTF-8编码,这带来了一些特殊性质:
fn utf8_exploration() {
let s = String::from("你好🦀");
// 字节数 vs 字符数
println!("字节数: {}", s.len()); // 10字节
println!("字符数: {}", s.chars().count()); // 3字符
// 索引访问的限制
// let c = s[0]; // 编译错误!不能直接索引
// 正确的遍历方式
for c in s.chars() {
println!("字符: {}", c);
}
// 字节级访问
for b in s.bytes() {
println!("字节: 0x{:02x}", b);
}
}关键发现:
这解释了为什么Rust不允许s[i]这种操作——为了保证UTF-8的完整性!
理解了内部实现后,我学会了一些零拷贝技巧:
fn zero_copy_operations() {
let s = String::from("Hello, World!");
// 创建切片:零拷贝
let slice1: &str = &s[0..5]; // "Hello"
let slice2: &str = &s[7..12]; // "World"
println!("原字符串地址: {:p}", s.as_ptr());
println!("slice1地址: {:p}", slice1.as_ptr());
println!("slice2地址: {:p}", slice2.as_ptr());
// 结果:所有切片都指向原String的不同部分!
// 没有额外的内存分配
}这种设计太优雅了!通过&str切片,可以高效地操作字符串的不同部分,而不需要拷贝数据。
为了彻底理解,我尝试实现一个简化版String:
use std::alloc::{alloc, dealloc, Layout};
use std::ptr;
pub struct MyString {
ptr: *mut u8,
len: usize,
cap: usize,
}
impl MyString {
pub fn new() -> Self {
MyString {
ptr: ptr::null_mut(),
len: 0,
cap: 0,
}
}
pub fn from(s: &str) -> Self {
let bytes = s.as_bytes();
let len = bytes.len();
let layout = Layout::array::<u8>(len).unwrap();
let ptr = unsafe { alloc(layout) };
unsafe {
ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, len);
}
MyString { ptr, len, cap: len }
}
pub fn push_str(&mut self, s: &str) {
let bytes = s.as_bytes();
let new_len = self.len + bytes.len();
// 需要扩容
if new_len > self.cap {
self.grow(new_len);
}
unsafe {
ptr::copy_nonoverlapping(
bytes.as_ptr(),
self.ptr.add(self.len),
bytes.len()
);
}
self.len = new_len;
}
fn grow(&mut self, min_cap: usize) {
let new_cap = std::cmp::max(min_cap, self.cap * 2);
let new_layout = Layout::array::<u8>(new_cap).unwrap();
let new_ptr = unsafe { alloc(new_layout) };
if self.cap > 0 {
unsafe {
ptr::copy_nonoverlapping(self.ptr, new_ptr, self.len);
let old_layout = Layout::array::<u8>(self.cap).unwrap();
dealloc(self.ptr, old_layout);
}
}
self.ptr = new_ptr;
self.cap = new_cap;
}
pub fn as_str(&self) -> &str {
unsafe {
let slice = std::slice::from_raw_parts(self.ptr, self.len);
std::str::from_utf8_unchecked(slice)
}
}
}
impl Drop for MyString {
fn drop(&mut self) {
if self.cap > 0 {
unsafe {
let layout = Layout::array::<u8>(self.cap).unwrap();
dealloc(self.ptr, layout);
}
}
}
}这个实现让我深刻理解了: