Rust 数组与指针(二)

这一节,我们继续探索数组与指针。

内存回收

在开始之前,你一定要清醒,“栈” 内存是系统自动分配自动回收的,“堆” 内存需要你自己申请自己并回收。我们这里所讲的 “内存回收” 是指 “堆” 内存。Rust 的变量默认会放到 “栈” 上,除非你主动去干涉。

在上节的最后,我们实现了一个叫做 MyArray<T> 的动态增长数组,还给 MyArray<T> 实现了一个叫做 Droptrait。我们给一个类型实现 Drop 后,当值离开作用域时,就会自动地调用 drop 方法。我们也可以将 drop 叫做析构函数。标准库 给出了一个例子:

struct HasDrop;

impl Drop for HasDrop {
    fn drop(&mut self) {
        println!("Dropping!");
    }
}

fn main() {
    let _x = HasDrop;
}

main 函数执行结束后,_x 离开了作用域,就会调用 drop 方法打印出 Dropping!。为了更加直观,我们可以修改一下上面的代码,将 main 修改成:

fn main() {
    println!("before drop");
    {
        let _x = HasDrop;
    } // 调用 HasDrop 的 drop
    println!("after drop");
}

这个时候运行程序,会打印出:

before drop
Dropping!
after drop

我们再次修改:

fn main() {
    println!("before drop");

    let x = HasDrop;

    {
        let _y = x;
    } // 调用 HasDrop 的 drop

    println!("after drop");
}

运行这段代码同样会打印出:

before drop
Dropping!
after drop

我们可以继续修改:

fn main() {
    println!("before drop");

    let x = HasDrop;

    {
        let _y = &x;
    }

    println!("after drop");
} // 调用 HasDrop 的 drop

这时候打印的结果就跟上面不一样了:

before drop
after drop
Dropping!

我们分析一下以上的情况。一开始,变量 x 拥有 HasDrop 的值的所有权,当我们 let _y = x; 的时候,会将 HasDrop 的值的所有权,从 x 转移(move)到 _y_y 离开作用域时,就会调用 HasDropdrop 方法; 当我们 let _y = &x; 的时候,_y 只是得到了 HasDrop 的值的引用(或者叫做借用),并不拥有 HasDrop 的值的所有权。因此 _y 离开作用域时,并不会调用HasDropdrop 方法。

所有权发生转移后,原来的变量不能被再次使用。或者说已经 drop 后的变量不能使用。我们修改代码:

fn main() {
    println!("before drop");

    let x = HasDrop;

    {
        let _y = x;
    } // 调用 HasDrop 的 drop

    println!("after drop");

    let _z = x;
}

这段代码是无法编译的,编译器会告诉你:

error[E0382]: use of moved value: “x”
  --> src/main.rs:20:14
   |
12 |     let x = HasDrop;
   |         - move occurs because “x” has type “HasDrop”, which does not implement the “Copy” trait
...
15 |         let _y = x;
   |                  - value moved here
...
20 |     let _z = x;
   |              ^ value used here after move

我们从编译器给出的信息里可以看到 HasDrop 没有实现一个叫做 Copytrait。那我们接下来给 HasDrop 实现一下 Copy

#[derive(Clone, Copy)]
struct HasDrop;

实现 Copy 的前提是实现 Clone。不过,这段代码是无法编译的:

error[E0184]: the trait “Copy” may not be implemented for this type; the type has a destructor
 --> src/main.rs:1:17
  |
1 | #[derive(Clone, Copy)]
  |                 ^^^^ Copy not allowed on types with destructors

编译器告诉我们无法为 HasDrop 实现 Copy,原因是 HasDrop 有一个析构函数。

不过,我们可一先将 HasDrop 的析构函数去掉:

#[derive(Copy)]
struct HasDrop;

impl Clone for HasDrop {
    fn clone(&self) -> Self {
        println!("{:?}", "clone");

        HasDrop
    }
}

fn main() {
    let x = HasDrop;

    {
        let _y = x;
    }

    let _z = x;
}

这段代码能够编译运行。由于 HasDrop 实现了 Copy,在 let _y = x; 的时候,会将 HasDrop 的值拷贝一份,原先 HasDrop 的值的所有权并没有从 x 转移(move)到 _y,所以后面还可继续使用 x

虽然实现 Copy 的前提是实现 Clone,但是 Copy 并不是去调用 CloneCopy 只是浅拷贝。如果允许拥有析构函数的类型实现 Copy,某些情况下会出现意料之外的结果。比如我们在上一节实现的 MyArray<T>

pub struct MyArray<T: Sized> {
    ptr: *mut T,
    capacity: usize,
    len: usize
}

MyArray<T> 内部有一个指针,Copy 时只会拷贝指针的值,并不会拷贝指针指向的数据。而指针指向的数据,是依靠析构函数去回收的。当 MyArray<T> 离开作用时,会重复回收指针指向的数据,这将造成 double free 的问题。因此编译器不允许我们为拥有析构函数的类型实现 Copy

不过,我们可以模拟一下 Copy

fn main() {
    let mut array: MyArray<i32> = MyArray::with_capacity(3);

    array.push(1);
    array.push(2);
    array.push(3);

    println!("{:?}", &array[..]); // [1, 2, 3]

    let ptr: *mut MyArray<i32> = &mut array as *mut MyArray<i32>;

    let mut array2: MyArray<i32> = unsafe { std::mem::MaybeUninit::zeroed().assume_init() };

    let ptr2: *mut MyArray<i32> = &mut array2 as *mut MyArray<i32>;

    unsafe {
        std::ptr::copy(ptr, ptr2, 1);
    }

    assert!(array.ptr == array2.ptr);

    println!("{:?}", &array[..]); // [1, 2, 3]
    println!("{:?}", &array2[..]); // [1, 2, 3]

    array[1] = 123;

    println!("{:?}", &array[..]); // [1, 123, 3]
    println!("{:?}", &array2[..]); // [1, 123, 3]

    drop(array2);

    println!("{:?}", &array[..]); // [0, 0, 3]
}

上面代码中,我们分别创建了 arrayarray2,并转换为裸指针 ptrptr2ptrptr2 保存着 arrayarray2 的内存地址。然后用 std::ptr::copyptr 指向的数据拷贝到 ptr2std::ptr::copy 是浅拷贝,只会拷贝指针的值,并不会拷贝指针指向的数据。array.ptr == array2.ptr,也就是 arrayarray2ptr 指向了同一块内存区域。我们修改 array 的第二个元素,array2 的值也会同步变化。这个特性有时候对我们来说的很有用的,比如多线程编程时,在多个线程之间共享状态。但是,我们调用 array2 的析构函数之后,这时候 array.ptr 已经是一个悬垂指针。当你继续修改或者析构 array 时,会发生一些不可预料的随机的状况,这是内存不安全的。Rust 提供了智能指针 Arc<T>,在满足这个特性的前提下保证内存安全。

std::mem::forget 可以让编译器“忘记” array 在离开作用域时调用析构函数:

std::mem::forget(array);

std::mem::forget 是危险的,使用不当会导致内存泄漏!

我们将视线转移到上一节中实现的 MyArray<T> 的析构函数中:

impl<T: Sized> Drop for MyArray<T> {
    fn drop(&mut self) {
        let align = mem::align_of::<T>();
        let size = mem::size_of::<T>() * self.capacity;
        let layout = Layout::from_size_align(size, align).unwrap();

        for _ in 0..self.len {
            self.pop();
        }

        unsafe {
            std::alloc::dealloc(self.ptr as *mut u8, layout);
        }
    }
}

在调用 std::alloc::dealloc 回收内存前,先将所有元素 pop() 出去。为什么需要这样做呢?

struct HasDrop {
    a: i32
}

impl Drop for HasDrop {
    fn drop(&mut self) {
        println!("Dropping! {}", self.a);
    }
}

fn main() {
    let mut array: MyArray<HasDrop> = MyArray::with_capacity(3);
    array.push(HasDrop { a: 123 });
    array.push(HasDrop { a: 456 });
    array.push(HasDrop { a: 789 });
}

在这段代码中,我们为 HasDrop 实现了一个析构函数,我们希望,array 在离开作用域时, 会调用内部存储的 HasDrop 的析构函数。

Dropping!
Dropping!
Dropping!

这没问题!但是如果你将 self.pop(); 注释掉后,什么也不会打印。pop() 函数内部调用了 std::ptr::read,该函数会读取 *const HasDrop 的值,并转换为 HasDrop,该 HasDrop 离开作用域后会调用析构函数。不过,我们还可以将 pop() 改为:

// for _ in 0..self.len {
//     self.pop();
// }
unsafe {
    std::ptr::drop_in_place(&mut self[..]);
}

编译器会优化这个函数,达到同样的或者更好的效果。

我们再添加一行代码:

fn main() {
    let mut array: MyArray<HasDrop> = MyArray::with_capacity(3);
    array.push(HasDrop { a: 123 });
    array.push(HasDrop { a: 456 });
    array.push(HasDrop { a: 789 });

    unsafe { array.ptr.read(); }
}

这次执行后会打印:

Dropping! 123
Dropping! 789
Dropping! 456
Dropping! 123

这并不是我们期望的结果。因此使用 std::ptr::read 时要及其小心。我们可以看标准库对 std::ptr::read 的实现,(我们先忽略 readread_unalignedwritewrite_unaligned 的差异)。

pub unsafe fn read<T>(src: *const T) -> T {
    let mut tmp = MaybeUninit::<T>::uninit();
    copy_nonoverlapping(src, tmp.as_mut_ptr(), 1);
    tmp.assume_init()
}

pub unsafe fn read_unaligned<T>(src: *const T) -> T {
    let mut tmp = MaybeUninit::<T>::uninit();
    copy_nonoverlapping(src as *const u8, tmp.as_mut_ptr() as *mut u8, mem::size_of::<T>());
    tmp.assume_init()
}

顺带的,我们再看看 str::ptr::write 的实现:

pub unsafe fn write<T>(dst: *mut T, src: T) {
    intrinsics::move_val_init(&mut *dst, src)
}

pub unsafe fn write_unaligned<T>(dst: *mut T, src: T) {
    copy_nonoverlapping(&src as *const T as *const u8, dst as *mut u8, mem::size_of::<T>());
    mem::forget(src);
}

readwrite 中,是用 std::ptr::copysrc 的值拷贝到 dst 或者将 dst 的值拷贝到 src。为什么 write 中会执行一下 mem::forget(src);

我们看下面的代码:

pub unsafe fn write<T>(dst: *mut T, src: T) {
    std::ptr::copy(&src as *const T as *const u8, dst as *mut u8, std::mem::size_of::<T>());
    // std::mem::forget(src);
}

fn main() {
    let mut array: [HasDrop; 1] = [unsafe { std::mem::MaybeUninit::zeroed().assume_init() }];

    unsafe {
        let d = HasDrop { a: 456 };
        write(&mut array as *mut [HasDrop; 1] as *mut HasDrop, d);
    }
}

我们将 std::mem::forget(src) 注释掉,运行这段代码:

Dropping! 456
Dropping! 456

调用了两次析构函数,这不是我们期望的结果。在 write 中,我们将 src 转换为了 *const u8,同时将 dst 转换为 *mut u8,再从 src 拷贝相应的字节数到 dstsrc 离开作用域后,会调用析构函数,这里要用 std::mem::forget 阻止调用析构函数。

对于 Drop,标准库还提到了两个问题。其一是,Drop 是递归的,标准库给出了一段代码:

struct Inner;
struct Outer(Inner);

impl Drop for Inner {
    fn drop(&mut self) {
        println!("Dropping Inner!");
    }
}

impl Drop for Outer {
    fn drop(&mut self) {
        println!("Dropping Outer!");
    }
}

fn main() {
    let _x = Outer(Inner);
}

Outer 离开作用域时,Outerdrop 会先调用,然后才会调用 Innerdrop。并且,即使 Outer 没有实现 DropInnerdrop 也会调用:

struct Inner;
struct Outer(Inner);

impl Drop for Inner {
    fn drop(&mut self) {
        println!("Dropping Inner!");
    }
}

fn main() {
    let _x = Outer(Inner);
}

其二是,变量以声明的相反顺序 drop

struct PrintOnDrop(&'static str);

impl Drop for PrintOnDrop {
    fn drop(&mut self) {
        println!("{}", self.0);
    }
}

fn main() {
    let _first = PrintOnDrop("Declared first!");
    let _second = PrintOnDrop("Declared second!");
}

你也可以去尝试结构体的字段的 drop 顺序,我们最好不要依赖结构体字段的析构顺序。

移花接木

到目前为止,你已经对数组和指针有了一定的了解,也体会到了“理论上,内存(我们暂且不去讨论物理内存与虚拟内存)相当于一个类型为 u8、长度为 usize 的数组,内存操作相当于操作这个数组”,“指针是一个包含内存地址的变量”。

变量的值,在内存里是以字节的形式存在的,既然如此,我们可以在一定限度内,对不同的类型进行转换,这是危险的!!!,请你一定明白自己在做什么。我们看下面的代码:

fn main() {
    let a: i32 = 123456789;

    let b: [u8; std::mem::size_of::<i32>()] = unsafe {
        *(&a as *const i32 as *const [u8; std::mem::size_of::<i32>()])
    };

    println!("{:?}", b); // [21, 205, 91, 7]
}

我们将一个 i32 类型的数字转换成了一个类型为 u8,长度为4的数组。[21, 205, 91, 7]123456789 这个数字在内存里存在的形式。

我们还可以使用 std::mem::transmute

let b: [u8; std::mem::size_of::<i32>()] = unsafe {
    // *(&a as *const i32 as *const [u8; std::mem::size_of::<i32>()])
    std::mem::transmute(a)
};

使用 transmute 的前提是,两种类型的静态大小(std::mem::size_of)是相等的。比如

fn main() {
    let mut array: MyArray<i32> = MyArray::with_capacity(3);

    array.push(1);
    array.push(2);
    array.push(3);

    let vec: Vec<i32> = unsafe {
        std::mem::transmute(array)
    };

    println!("{:?}", vec); // [1, 2, 3]
}

我们上一节实现的 MyArray<T>Vec<T> 内存布局是一致的,静态大小也相等。因此就可以用 transmute 互相转换。

transmute 是非常有用的,标准库 还给了一些例子,比如:

fn foo() -> i32 {
    0
}
let pointer = foo as *const ();
let function = unsafe {
    std::mem::transmute::<*const (), fn() -> i32>(pointer)
};
assert_eq!(function(), 0);

这个例子是将函数转换为函数指针,再将函数指针转换为函数。函数本身就是指针,比如下面段代码,你可以利用这一点,做一些有趣的事:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn sub(a: i32, b: i32) -> i32 {
    a - b
}

fn print(s: &str) {
    println!("{}", s);
}

fn main() {
    use std::collections::HashMap;
    use std::mem::transmute;

    let mut math: HashMap<String, *const ()> = HashMap::new();

    math.insert("+".to_string(), add as *const ());
    math.insert("-".to_string(), sub as *const ());
    math.insert("p".to_string(), print as *const ());

    let add = unsafe {
        transmute::<*const (), fn(i32, i32) -> i32>(*math.get("+").unwrap())
    };
    println!("1 + 2 = {:?}", add(1, 2));

    let print = unsafe {
        transmute::<*const (), fn(s: &str)>(*math.get("p").unwrap())
    };
    print("hello");
}

不用 transmute,也可以转换函数指针:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn main() {
    let ptr = add as *const ();

    unsafe {
        let add: fn(i32, i32) -> i32 = *(&ptr as *const *const () as *const fn(i32, i32) -> i32);

        println!("1 + 2 = {:?}", add(1, 2));
    }
}

这段代码可能是比较费解的。我们看看主要的汇编代码:

example::main:
  push rax
  lea rax, [rip + example::add] // 计算 add 函数的地址放到 rax 寄存器
  mov qword ptr [rsp], rax
  mov edi, 1                    // 第一个参数
  mov esi, 2                    // 第二个参数
  call qword ptr [rsp]          // 调用函数
  pop rax
  ret

lea rax, [rip + example::add] 是计算函数的地址并放到 rax 寄存器,接着又将 rax 寄存器的值放进了 [rsp] 这块内存。此时,ptr 变量保存着计算出来的函数地址,而它的指针就指向 [rsp] 这块内存。*(&ptr as *const *const () as *const fn(i32, i32) -> i32) 所以这段代码的意思就是,取得 [rsp] 这块内存的指针,再将其解引就得到了函数本身。

这似乎是在兜圈子,我们能将函数轻易的转换成其他类型 add as usize,却不能将其他类型直接转换为函数 usize as add

transmute 是危险的。考虑下面的代码:

#[derive(Debug, Clone)]
struct HasDrop {
    a: i32
}

impl Drop for HasDrop {
    fn drop(&mut self) {
        println!("Dropping! {}", self.a);
    }
}

mod foo {
    use crate::HasDrop;

    #[repr(C)]
    pub struct Foo {
        a: i32,
        b: i32,
        c: HasDrop,
        d: HasDrop,
        e: usize
    }

    impl Foo {
        pub fn new() -> Self {
            Foo {
                a: 123,
                b: 456,
                c: HasDrop { a: 111 },
                d: HasDrop { a: 222 },
                e: 789
            }
        }
    }
}

fn main() {
    let foo = foo::Foo::new();

    #[derive(Debug, Clone)]
    #[repr(C)]
    struct Foo2 {
        a: i32,
        b: i32,
        c: HasDrop,
        _unused: [u8; 12]
    }

    let foo2: Foo2 = unsafe { std::mem::transmute(foo) };

    println!("{:?}", foo2);
}

Foofoo 模块中的一个结构体,它的字段在 main 函数中是不可见的。我们想窥探 Foo 的前三个字段的值,忽略后面两个字段,因此后面用数组将其静态大小补齐。这段代码执行后会打印:

Foo2 { a: 123, b: 456, c: HasDrop { a: 111 }, _unused: [222, 0, 0, 0, 21, 3, 0, 0, 0, 0, 0, 0] }
Dropping! 111

这是不正确的!transmute 会阻止 Foo 调用析构方法。而 Foo 中却没有 d 这个字段,导致 HasDrop { a: 222 } 不能被析构。这将造成内存泄漏!正确的做法是直接指针转换:

let foo2: &Foo2 = unsafe { &*(&foo as *const foo::Foo as *const Foo2) };

此时 Foo2Foo 指向了同一块内存,我们可以给 Foo2 实现 Clone,将 &Foo2 转换为 Foo2

let foo2: &Foo2 = unsafe { &*(&foo as *const foo::Foo as *const Foo2) };
let foo2 = foo2.clone();

这一章节的内容就到这里,我们下节再见!

发表评论

电子邮件地址不会被公开。 必填项已用*标注

− 4 = 5