解引用裸指针的语义是什么?

16

对于共享引用和可变引用,语义是明确的:只要您拥有对值的共享引用,其他任何东西都不能具有可变访问权限,而可变引用则不能被共享。

因此,这段代码:

#[no_mangle]
pub extern fn run_ref(a: &i32, b: &mut i32) -> (i32, i32) {
    let x = *a;
    *b = 1;
    let y = *a;
    (x, y)
}

编译(在x86_64上)的结果为:

run_ref:
    movl    (%rdi), %ecx
    movl    $1, (%rsi)
    movq    %rcx, %rax
    shlq    $32, %rax
    orq     %rcx, %rax
    retq

请注意,内存 a 指向的位置只被读取一次,因为编译器知道对b的写入不会修改在a处的内存。

原始指针更加复杂。原始指针算术运算和强制类型转换是"安全"的,但对它们进行解引用则是不安全的。

我们可以将原始指针转换回共享和可变引用,然后使用它们;这肯定会涉及到通常的引用语义,并且编译器可以相应地进行优化。

但是,如果我们直接使用原始指针,那么它们的语义是什么呢?

#[no_mangle]
pub unsafe extern fn run_ptr_direct(a: *const i32, b: *mut f32) -> (i32, i32) {
    let x = *a;
    *b = 1.0;
    let y = *a;
    (x, y)
}

编译成:

run_ptr_direct:
    movl    (%rdi), %ecx
    movl    $1065353216, (%rsi)
    movl    (%rdi), %eax
    shlq    $32, %rax
    orq     %rcx, %rax
    retq
尽管我们写入了不同类型的值,但第二次读取仍然会访问内存-似乎可以使用相同(或重叠)的内存位置调用此函数作为两个参数。换句话说,const原始指针并不禁止同时存在mut原始指针;同时拥有两个mut原始指针(可能是不同类型的)指向同一个(或重叠的)内存位置也可能是可以的。 请注意,正常的优化C/C++编译器会消除第二次读取(由于“严格别名”规则:通过不同(“不兼容”的)类型的指针修改/读取相同的内存位置在大多数情况下是未定义行为):
struct tuple { int x; int y; };

extern "C" tuple run_ptr(int const* a, float* b) {
    int const x = *a;
    *b = 1.0;
    int const y = *a;
    return tuple{x, y};
}

编译成:

run_ptr:
    movl    (%rdi), %eax
    movl    $0x3f800000, (%rsi)
    movq    %rax, %rdx
    salq    $32, %rdx
    orq     %rdx, %rax
    ret

使用Rust代码示例的Playground

具有C示例的godbolt编译器浏览器

那么,如果我们直接使用裸指针,它的语义是什么:被引用的数据是否允许重叠?

这应该直接影响编译器是否允许通过裸指针重新排序内存访问。


哦!我喜欢这个问题! - Matthieu M.
1个回答

24

没有尴尬的严格别名

C++的严格别名是一种对木腿的修补。C++没有任何别名信息,缺乏别名信息会阻止许多优化(如您在此处所述),因此为了恢复一些性能,就进行了严格别名的修补...

不幸的是,在系统语言中使用严格别名很尴尬,因为重新解释原始内存是系统语言设计的本质。

更不幸的是,它并没有启用太多的优化。例如,从一个数组复制到另一个数组必须假定这些数组可能重叠。

restrict(来自C语言)有点更有帮助,但它仅适用于一个级别。


相反,我们有基于作用域的别名分析

Rust中别名分析的本质是基于词法作用域(除线程外)。

您可能知道的初学者级别解释是:

  • 如果您有一个&T,则没有&mut T指向同一实例,
  • 如果您有一个&mut T,则没有&T&mut T指向同一实例。

适合初学者的是稍微简略的版本。例如:

fn main() {
    let mut i = 32;
    let mut_ref = &mut i;
    let x: &i32 = mut_ref;

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

即使一个&mut i32mut_ref)和一个&i32x)指向同一实例,也是完全可以的!

然而,在形成x之后尝试访问mut_ref,真相就会揭开:

fn main() {
    let mut i = 32;
    let mut_ref = &mut i;
    let x: &i32 = mut_ref;
    *mut_ref = 2;
    println!("{}", x);
}
error[E0506]: cannot assign to `*mut_ref` because it is borrowed
  |
4 |         let x: &i32 = mut_ref;
  |                       ------- borrow of `*mut_ref` occurs here
5 |         *mut_ref = 2;
  |         ^^^^^^^^^^^^ assignment to borrowed `*mut_ref` occurs here
因此,同时拥有指向同一内存位置的&mut T&T是可以的;但是只要&T存在,通过&mut T进行的变异将被禁用。

在某种意义上,&mut T被“临时”降级为&T


那么指针呢?

首先,让我们回顾一下参考文献

  • 不保证指向有效的内存,甚至不保证非NULL(与Box&不同);
  • 没有任何自动清理,与Box不同,因此需要手动资源管理;
  • 是普通数据,即它们不移动所有权,再次与Box不同,因此Rust编译器无法防止使用后释放等错误;
  • 缺少任何形式的生命周期,与&不同,因此编译器无法推断悬空指针;以及
  • 除了不能直接通过*const T进行变异之外,没有关于别名或可变性的任何保证。

显然缺少任何禁止将*const T转换为*mut T的规则。这很正常,它是允许的,因此最后一点实际上更像是一个“lint”,因为它可以很容易地解决。

Nomicon

讨论不安全的Rust就不能没有Nomicon

基本上,不安全的Rust规则相当简单:遵守编译器在安全的Rust中应该具有的任何保证。

由于这些规则尚未定型,因此这并不是那么有帮助;抱歉。

那么,解引用原始指针的语义是什么?

据我所知1

  • 如果从原始指针(&T&mut T)形成引用,则必须确保遵守这些引用遵守的别名规则,
  • 如果立即读取/写入,则会临时形成一个引用。

也就是说,只要调用者对位置有可变访问权限:

pub unsafe fn run_ptr_direct(a: *const i32, b: *mut f32) -> (i32, i32) {
    let x = *a;
    *b = 1.0;
    let y = *a;
    (x, y)
}

应该是有效的,因为*a的类型是i32,所以引用的生命周期没有重叠。

然而,我期望:

pub unsafe fn run_ptr_modified(a: *const i32, b: *mut f32) -> (i32, i32) {
    let x = &*a;
    *b = 1.0;
    let y = *a;
    (*x, y)
}

*b用于修改其内存时,x仍然存在,因此这将导致未定义的行为。

请注意变化是多么微妙。在unsafe代码中很容易破坏不变量。

1我现在可能是错误的,或者我将来可能会出错


我特别喜欢“取消引用原始指针暂时形成引用”的解释。 - Stefan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接