Rust程序设计第二版-把书读薄之难点使用(二)

26 次浏览
2024年07月27日创建

Rust的问题还是很多,主要是一些关键的使用方法太多了,需要知道什么时候用,为什么用是非常关键的,下面罗列的是基本上贯穿《Rust程序设计 - 第二版》的所有难点理解内容。为了将这些内容融会贯通,需要非常多的例子总结才能理解。下面内容就开始对其中的内容一个一个来梳理。

主要概念

dynderefderef_mutmutDrop

必须知道vec<T>、[T]、str、String之间的区别

Vec<T> 是一个存储在堆中的动态数组,可以在运行时改变大小。

  1. vec动态大小、存在堆上,
  2. 可以增长和缩小。
  3. 支持索引访问和迭代。
  4. 拥有其所包含的数据的所有权。

[T] 是一个切片,表示一段连续的内存区域,存储零个或多个类型为 T 的元素。用于引用一个数组或 Vec 的一部分。

  1. 不可变或可变引用。
  2. 不支持增长或缩小。
  3. 不拥有数据的所有权。

str 是一个字符串切片,表示一段连续的内存区域,存储UTF-8编码的字符串。用于引用字符串的部分或整个字符串。

  1. 不可变引用。
  2. 不支持增长或缩小。
  3. 不拥有数据的所有权。

String 是一个可变的、拥有所有权的字符串类型。用于需要动态构建或修改字符串的场景。

  1. 可以增长和缩小。
  2. 支持索引访问(但不能用索引直接访问字符)。
  3. 拥有其所包含的数据的所有权。

但与普通字节数组不同的是,String强制它所存储的内容是UTF-8编码,而普通的字节数组可以是任何内容.String对字符串具有读取,修改,乃至销毁的权力,而&str只能读取字符串。

String的内部是一个Vec<u8>,进一步展开,String拥有三个字段一个指向一块内存的指针,一个表示字符串实际长度的usize,一个表示内存大小的usize而&str的内部,是一个指向一块内存的指针,一个表示字符串长度的usize值。

我们一般将String与&str组合使用,两者之间可以轻易互相转换

fn main() {
    let s1: String = String::from("s1");
    let s1: &str = s1.as_str();

    let s2: String = String::from("s2");
    let s2: &str = s2.as_str();
}

&str转换成String的开销大了,如前文所述,String对内存具有所有权,而&str不具有,所以&str转换成String时需要将内存上的内容复制一遍,作为新的String

由于&str的设计,使得String能够轻易地被切成多片&str

fn main() {
    let s = String::from("Hello world !");
    for slice in s.split_whitespace() {
        println!("{}", slice);
    }
}

OsString与String相对应,&OsStr与&str相对应。它们都是没有编码限制的字符串,当与系统交互时,可以使用它们。

dyn

dyn关键字在Rust中用于动态派发,即在运行时决定调用哪个具体的方法实现。它通常与特征对象(trait objects)一起使用。特征对象允许你在运行时处理不同类型的对象,只要这些对象实现了某个特征(trait)。

静态派发 vs 动态派发

静态派发:在编译时决定调用哪个具体的方法实现。通常通过泛型和特征约束来实现。静态派发的优点是性能更高,因为编译器可以内联方法调用。例如函数声明。

动态派发:在运行时决定调用哪个具体的方法实现。通过特征对象和虚函数表(vtable)来实现。动态派发的优点是灵活性更高,可以处理不同类型的对象,而不需要在编译时知道具体类型。例如代码执行里dyn。

动态派发

  1. 多态性:当你需要处理不同类型的对象,但这些对象实现了相同的特征时,可以使用特征对象来实现多态性。
  2. 插件系统:当你需要一个能够动态加载和处理不同插件的系统时,特征对象是一个很好的选择。
  3. 简化代码:在某些情况下,使用特征对象可以简化代码,使其更具可读性和可维护性。
trait Shape {
    fn area(&self) -> f64;
    fn draw(&self);
}

struct Circle {
    radius:f64
}

impl Shape for Circle{
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }

    fn draw(&self) {
        println!("Drawing a circle with radius {}", self.radius);
    }
}

struct Rectangle {
    width: f64,
    height: f64,
}
impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
    fn draw(&self) {
        println!("Drawing a rectangle with width {} and height {}", self.width, self.height);
    }
}
#[test]
pub fn test(){
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 5.0 }),
        Box::new(Rectangle { width: 10.0, height: 20.0 }),
    ];

    for shape in shapes {
        shape.draw();
        println!("Area:{}", shape.area());
    }
}

也就是类比java的话,如果是抽象接口,需要手动声明dyn。 Circle 和 Rectangle 的大小在编译时是已知的,但 dyn Shape 的大小是未知的,因为 dyn Shape 可以表示任何实现了 Shape 特征的类型。编译器无法在编译时确定 dyn Shape 实例的大小。因此,必须通过指针来处理这些特征对象,指针可以用&,如此:

let x :&dyn Shape = &Circle { radius: 5.0 };
let shapes: Vec<&dyn Shape> = vec![
    &Circle { radius: 5.0 },
    &Rectangle { width: 10.0, height: 20.0 },
];

上面的代码都是能work的,但为什么要使用Box?

&dyn Shape和Box<dyn Shape>的区别,为什么要用Box

让我们详细讨论一下使用引用(&dyn Shape)和使用 Box<dyn Shape> 的区别,以及为什么在某些情况下 Box 更合适。

这种方法使用了引用来存储特征对象。主要的特点和限制如下:

生命周期问题

  • 引用必须保证被引用的对象在整个使用过程中是有效的。这意味着 Circle 和 Rectangle 实例的生命周期必须至少与 shapes 向量的生命周期一样长。

例如,如果 Circle 和 Rectangle 是在函数内部创建的局部变量,那么它们在函数结束时会被销毁,从而导致悬挂引用。

fn create_shapes() -> Vec<&dyn Shape> {
    let circle = Circle { radius: 5.0 };
    let rectangle = Rectangle { width: 10.0, height: 20.0 };

    // 这里我们尝试返回对局部变量的引用
    vec![&circle, &rectangle]
}

fn main() {
    let shapes = create_shapes();

    for shape in shapes {
        println!("Area: {}", shape.area());
    }
}

上面的代码就会发生问题:

error[E0515]: cannot return reference to local variable `circle`
  --> src/main.rs:24:12
   |
24 |     vec![&circle, &rectangle]
   |            ^^^^^^ returns a reference to data owned by the current function

error[E0515]: cannot return reference to local variable `rectangle`
  --> src/main.rs:24:21
   |
24 |     vec![&circle, &rectangle]
   |                     ^^^^^^^^ returns a reference to data owned by the current function

所有权问题

  • 引用不获取对象的所有权,因此你不能通过引用来管理对象的生命周期。这意味着你需要手动管理被引用对象的生命周期,确保它们在使用过程中不会被销毁。

不可变性

引用默认是不可变的(&dyn Shape),如果你需要修改被引用的对象,你需要使用可变引用(&mut dyn Shape),这会带来额外的复杂性。

使用 Box<dyn Shape> 的主要特点和优势如下:

所有权管理

  • Box 获取了对象的所有权,并负责在适当的时候释放内存。这意味着你不需要手动管理对象的生命周期,Rust 会在 Box 离开作用域时自动释放内存。

例如,你可以在函数内部创建对象并将它们存储在 Box 中,而不需要担心对象的生命周期问题。

堆分配:

  • Box 将对象存储在堆上,而不是栈上。这使得你可以存储大小未知的特征对象,并在运行时进行动态分派。

灵活性

使用 Box 可以更灵活地管理对象的生命周期和内存。例如,你可以将 Box<dyn Shape> 从一个函数返回,而引用则不能轻易做到这一点。

为了上面的问题,我们可以使用 Box<dyn Shape> 来存储形状对象的所有权,而不是引用。这样,形状对象会在堆上分配内存,并且 Box 会负责管理它们的生命周期。

fn create_shapes() -> Vec<Box<dyn Shape>> {
    let circle = Box::new(Circle { radius: 5.0 });
    let rectangle = Box::new(Rectangle { width: 10.0, height: 20.0 });

    // 返回包含 Box<dyn Shape> 的向量
    vec![circle, rectangle]
}

fn main() {
    let shapes = create_shapes();

    for shape in shapes {
        println!("Area: {}", shape.area());
    }
}

在这个修正后的代码中,create_shapes 函数返回一个包含 Box<dyn Shape> 的向量。由于 Box 获取了对象的所有权,并且对象在堆上分配内存,这样就避免了悬挂引用的问题。Rust 的所有权系统会确保这些对象在合适的时间被释放,避免内存泄漏和未定义行为。

静态派发

fn print_shape_info<T: Shape>(shape: &T) {
    shape.draw();
    println!("Area: {}", shape.area());
}

print_shape_info<T: Shape>(shape: &T) 是一个泛型函数,接受任何实现了 Shape 特征的类型 T。编译器在编译时知道具体的类型,因此可以进行内联优化和其他优化。

dyn作为函数参数

使用 &dyn Trait 作为函数参数

fn print_shape_info(shape: &dyn Shape) {
    shape.draw();
    println!("Area: {}", shape.area());
}

let circle = Circle { radius: 5.0 };
let rectangle = Rectangle { width: 10.0, height: 20.0 };

print_shape_info(&circle);
print_shape_info(&rectangle);

注意细节:这里没有使用Box,是因为这里的方法调用,不涉及所有权。

使用 Box<dyn Trait> 作为函数参数

fn print_shape_info(shape: Box<dyn Shape>) {
    shape.draw();
    println!("Area: {}", shape.area());
}

fn main() {
    let circle = Box::new(Circle { radius: 5.0 });
    let rectangle = Box::new(Rectangle { width: 10.0, height: 20.0 });

    print_shape_info(circle);
    print_shape_info(rectangle);
}

使用 Rc<dyn Trait> 或 Arc<dyn Trait> 作为函数参数

use std::rc::Rc;
use std::sync::Arc;

fn print_shape_info(shape: Rc<dyn Shape>) {
    shape.draw();
    println!("Area: {}", shape.area());
}

fn main() {
    let circle = Rc::new(Circle { radius: 5.0 });
    let rectangle = Rc::new(Rectangle { width: 10.0, height: 20.0 });

    print_shape_info(circle.clone());
    print_shape_info(rectangle.clone());
}

或者使用 Arc 进行线程安全的共享:

fn print_shape_info(shape: Arc<dyn Shape>) {
    shape.draw();
    println!("Area: {}", shape.area());
}

fn main() {
    let circle = Arc::new(Circle { radius: 5.0 });
    let rectangle = Arc::new(Rectangle { width: 10.0, height: 20.0 });

    print_shape_info(circle.clone());
    print_shape_info(rectangle.clone());
}

deref、deref_mut

deref解引用运算符重载

通过实现 std::ops::Deref 特型和 std::ops::DerefMut 特型,可以指定像 * 和 . 这样的解引用运算符在你的类型上的行为。像 Box<T> 和 Rc<T> 这样的指针类型就实现了这些特型,因此它们可以像 Rust 的内置指针类型那样用。

use std::ops::Deref;

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y); // 通过实现 Deref,使得 MyBox<T> 可以像引用一样被解引用
}

上面代码直接对结构体解引用了。

如果上下文对引用目标进行了赋值或借用了可变引用,那么 Rust 就会使用 DerefMut(解可变引用)特型,否则,只要通过 Deref 进行只读 访问就够了。

deref自动解引用

如果你学习The Book时不仔细,你可能会疑惑,有时候明明参数是&str,但是传进&String依然不会报错,这得益于 Deref trait,我们为类型A实现了对String的Deref,那么在我们使用A的时候,编译器自动尝试将&A转换为&String

use std::ops::Deref;

struct A {
    s: String,
}

impl Deref for A {
    type Target = String;
    fn deref(&self) -> &Self::Target {
        &self.s
    }
}

fn say(s: &String) {
    println!("Hello {s} !");
}

fn main() {
    let s = A {
        s: String::from("world"),
    };
    say(&s);
}
// Output
// Hello world !

智能指针类型(如 Box, Rc, 和 Arc)通过实现 Deref 特性,使得它们能够与普通引用类型进行互操作。例如,你可以将 Box<T> 传递给期望 &T 的函数。

use std::rc::Rc;

fn main() {
    let s = Rc::new(String::from("Hello"));
    let s_ref: &str = &s; // 自动解引用 Rc<String> -> &String -> &str

    println!("{}", s_ref);
}

Deref 强制转换是 Rust 中的一种便捷特性,它允许在特定情况下自动将 &T 类型转换为 &U 类型,只要 T 实现了 Deref<Target = U>。这在编写更简洁的代码时非常有用。

use std::rc::Rc;

fn greet(name: &str) {
    println!("Hello, {}!", name);
}

fn main() {
    let name = Rc::new(String::from("Alice"));
    greet(&name); // 自动解引用 Rc<String> -> &String -> &str
}

DerefMut

impl<T> DerefMut for MyBox<T> {
    fn deref_mut(&mut self) -> &mut T {
        &mut self.0
    }
}

可以解引用修改数据

fn main() {
    let mut x = MyBox::new(5);
    *x += 1; // 通过 DerefMut,使得 MyBox<T> 可以像可变引用一样被解引用并修改内部数据
    println!("x: {}", *x);
}

Drop

一个值的拥有者消失时,Rust 会 (drop)该值。丢弃一个值 就必须释放该值拥有的任何其他值、堆存储和系统资源。丢弃可能发 生在多种情况下:当变量超出作用域时;在表达式语句的末尾;当截 断一个向量时,会从其末尾移除元素;等等。

如果一个类型实现了 Drop,就不能再实现 Copy 特型了。如果类型 是 Copy 类型,就表示简单的逐字节复制足以生成该值的独立副本。 但是,对同一份数据多次调用同一个 drop 方法显然是错误的。

impl<T> Drop for MyBox<T> {
    fn drop(&mut self) {
        println!("Dropping MyBox with data: {:?}", self.0);
    }
}
fn main() {
    let x = MyBox::new(5);
    let y = MyBox::new(String::from("Hello"));
    // 当 x 和 y 离开作用域时,drop 方法会被自动调用
}

Rust 保证在值离开作用域时会自动调用 drop 方法,无需手动调用。事实上,手动调用 drop 方法是被禁止的,因为 Rust 设计上希望确保资源的安全释放。