Rust不允许使用这种代码,因为它不安全:
fn main() {
let mut i = 42;
let ref_to_i_1 = unsafe { &mut *(&mut i as *mut i32) };
let ref_to_i_2 = unsafe { &mut *(&mut i as *mut i32) };
*ref_to_i_1 = 1;
*ref_to_i_2 = 2;
}
如何通过对同一事物的多个可变引用做一些不好的事情(例如分段错误,未定义的行为等)?
我能看到的唯一可能的问题来自数据的生命周期。在这里,如果i
还活着,那么每个可变引用都应该没问题。
我可以看到引入线程时可能会出现什么问题,但是为什么即使我在一个线程中执行所有操作也会阻止它?
答案 0 :(得分:12)
在C ++程序中,甚至在Java程序中,一个非常常见的陷阱是在迭代它时修改一个集合,如下所示:
for (it: collection) {
if (predicate(*it)) {
collection.remove(it);
}
}
对于C ++标准库集合,这会导致未定义的行为。也许迭代将一直有效,直到你到达最后一个条目,但最后一个条目将取消引用悬空指针或读取数组的末尾。也许集合中的整个数组将被重新定位,并且它会立即失败。也许它大部分时间都有效,但如果在错误的时间重新分配则会失败。在大多数Java标准集合中,根据语言规范,它也是未定义的行为,但集合往往抛出ConcurrentModificationException
- 即使代码正确也会导致运行时成本的检查。这两种语言都无法在编译期间检测到错误。
这是由并发引起的数据竞争的常见示例,即使在单线程环境中也是如此。并发并不仅仅意味着并行性:它也可以意味着嵌套计算。在Rust中,在编译期间会检测到这种错误,因为迭代器对集合有一个不可变的借位,因此在迭代器处于活动状态时你不能改变集合。
当您将多个指针(或引用)传递给函数时,更容易理解但不太常见的示例是指针别名。一个具体的例子是将重叠的内存范围传递给memcpy
而不是memmove
。实际上,Rust's memcpy
equivalent也是unsafe
,但那是因为它需要指针而不是引用。链接页面显示了如何使用可变引用永远别名的保证来创建安全的 swap 函数。
参考别名的一个更人为的例子是这样的:
int f(int *x, int *y) { return (*x)++ + (*y)++; }
int i = 3;
f(&i, &i); // result is undefined
你不能像在Rust中那样写一个函数调用,因为你必须对同一个变量进行两次可变借用。
答案 1 :(得分:2)
作者注:以下答案最初是为How do intertwined scopes create a "data race"?而写的
允许编译器优化&mut
指针,前提是它们是互斥的(非别名)。您的代码违反了这一假设。
问题中的示例有点琐碎,无法表现出任何有趣的错误行为,但请考虑将ref_to_i_1
和ref_to_i_2
传递给修改二者的函数,然后对它们进行一些处理: / p>
fn main() {
let mut i = 42;
let ref_to_i_1 = unsafe { &mut *(&mut i as *mut i32) };
let ref_to_i_2 = unsafe { &mut *(&mut i as *mut i32) };
foo(ref_to_i_1, ref_to_i_2);
}
fn foo(r1: &mut i32, r2: &mut i32) {
*r1 = 1;
*r2 = 2;
println!("{}", r1);
println!("{}", r2);
}
编译器可能会(或可能不会)决定对r1
和r2
的访问进行解交织,因为不允许使用别名:
// The following is an illustration of how the compiler might rearrange
// side effects in a function to optimize it. Optimization passes in the
// compiler actually work on (MIR and) LLVM IR, not on raw Rust code.
fn foo(r1: &mut i32, r2: &mut i32) {
*r1 = 1;
println!("{}", r1);
*r2 = 2;
println!("{}", r2);
}
甚至可能意识到println!
总是打印相同的值,并利用这一事实进一步重新排列foo
:
fn foo(r1: &mut i32, r2: &mut i32) {
println!("{}", 1);
println!("{}", 2);
*r1 = 1;
*r2 = 2;
}
编译器可以执行此优化是一件好事! (即使Doug's answer所提到的,即使Rust目前没有这样做。)优化编译器也很棒,因为它们可以使用上述转换来使代码运行更快(例如,通过在CPU中更好地对代码进行流水化处理,或者通过使编译器可以在以后的过程中进行更积极的优化)。在其他条件相同的情况下,每个人都喜欢他们的代码能够快速运行,对吧?
您可能会说:“嗯,这是无效的优化,因为它不会做同样的事情。”但是您会错:&mut
引用的整个 point 是它们没有别名。在不违反规则†的情况下,无法制作r1
和r2
别名,这就是使此优化有效的原因。
您可能还认为这是一个仅在更复杂的代码中出现的问题,因此编译器应允许使用简单的示例。但是请记住,这些转换是漫长的多步骤优化过程的一部分。重要的是要在任何地方都保持&mut
引用的属性,以便编译器可以对代码的一个部分进行较小的优化,而无需了解代码 all 。
还要考虑的一件事:作为程序员,您的工作是为您的问题选择并应用适当的类型;要求编译器偶尔出现&mut
别名规则的异常,基本上是在要求编译器为您完成工作。
如果您要共享可变性并放弃这些优化,那就很简单:不要使用&mut
。在示例中,您可以使用&Cell<i32>
代替&mut i32
,如所提到的注释:
fn main() {
let mut i = std::cell::Cell::new(42);
let ref_to_i_1 = &i;
let ref_to_i_2 = &i;
foo(ref_to_i_1, ref_to_i_2);
}
fn foo(r1: &Cell<i32>, r2: &Cell<i32>) {
r1.set(1);
r2.set(2);
println!("{}", r1.get()); // prints 2, guaranteed
println!("{}", r2.get()); // also prints 2
}
std::cell
中的类型提供内部可变性,这是“禁止进行某些优化,因为&
引用可能会使事物发生变化”的行话。它们并不总是像使用&mut
那样方便,但这是因为使用它们使您可以更加灵活地编写上述代码。
†请注意,单独使用unsafe
不算作“违反规则”。为了使代码具有定义的行为,即使使用&mut
,也不能为unsafe
引用加上别名,。
答案 2 :(得分:1)
如何通过对同一事物的多个可变引用做一些不好的事情(例如分段错误,未定义的行为等)?
我相信虽然你触发了未定义的行为&#39;通过这样做,技术上 Rust编译器还没有使用noalias
标志,所以实际上说话,现在,你可能实际上不可能以这种方式触发未定义的行为,您触发的是“特定于行为的实现行为”,根据LLVM&#39;行为类似于C ++。
我可以看到引入线程时可能会出现什么问题,但是为什么即使我在一个线程中执行所有操作也会阻止它?
阅读this series of blog articles about undefined behavior
在我看来,竞争条件(如迭代器)并不是你所谈论的一个很好的例子;在单线程环境中,如果您需要小心,可以避免出现这种问题。这与创建无效内存的任意指针并写入它没有什么不同;只是不要这样做。你不比使用C更糟糕。
要理解此处的问题,请考虑在发布模式下编译时,编译器在执行优化时可能会或可能不会重新排序语句;这意味着虽然您的代码可能以线性顺序运行:
a; b; c;
无法保证编译器在运行时会按顺序执行它们,如果(根据编译器知道的话),没有逻辑上的原因必须在特定的原子序列中执行语句。我上面链接的博客的第3部分演示了这可能导致未定义的行为。
tl; dr :基本上,编译器可以执行各种优化;当且仅当你的程序没有触发未定义的行为时,才能保证这些程序继续以确定的方式运行。
据我所知,Rust编译器目前并没有使用许多高级优化功能&#39;这可能会导致这种失败,但不能保证它在未来不会发生。这不是一个突破性的变化&#39;引入新的编译器优化。
所以...它实际上可能不太可能只是通过现在的可变别名来触发实际的未定义行为;但该限制允许未来性能优化的可能性。
相关报价:
C FAQ定义了“未定义的行为”,如下所示:
任何事情都可能发生;标准没有要求。程序可能无法编译,或者可能无法正确执行(崩溃或静默生成不正确的结果),或者它偶然可能完全符合程序员的意图。
答案 3 :(得分:1)
我所知道的最简单的例子是尝试将 push
转化为借用的 Vec
:
let mut v = vec!['a'];
let c = &v[0];
v.push('b');
dbg!(c);
这是一个编译器错误:
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:4:5
|
3 | let c = &v[0];
| - immutable borrow occurs here
4 | v.push('b');
| ^^^^^^^^^^^ mutable borrow occurs here
5 | dbg!(c);
| - immutable borrow later used here
这是一个编译器错误很好,否则它将是一个使用后释放。 push
重新分配 Vec
的堆存储并使我们的 c
引用无效。 Rust 实际上并不知道 push
是做什么的; Rust 只知道 push
使用 &mut self
,这里违反了别名规则。
许多其他未定义行为的单线程示例涉及像这样销毁堆上的对象。但是如果我们稍微使用引用和枚举,我们可以在没有堆分配的情况下表达类似的东西:
enum MyEnum<'a> {
Ptr(&'a i32),
Usize(usize),
}
let my_int = 42;
let mut my_enum = MyEnum::Ptr(&my_int);
let my_int_ptr_ptr: &&i32 = match &my_enum {
MyEnum::Ptr(i) => i,
MyEnum::Usize(_) => unreachable!(),
};
my_enum = MyEnum::Usize(0xdeadbeefdeadbeef);
dbg!(**my_int_ptr_ptr);
这里我们获取了一个指向 my_int
的指针,将该指针存储在 my_enum
中,并使 my_int_ptr_ptr
指向 my_enum
。如果我们可以重新分配 my_enum
,我们就可以丢弃 my_int_ptr_ptr
所指向的位。对 my_int_ptr_ptr
的双重取消引用将是一个野指针读取,这可能会出现段错误。幸运的是,这又违反了别名规则,并且无法编译:
error[E0506]: cannot assign to `my_enum` because it is borrowed
--> src/main.rs:12:1
|
8 | let my_int_ptr_ptr: &&i32 = match &my_enum {
| -------- borrow of `my_enum` occurs here
...
12 | my_enum = MyEnum::Usize(0xdeadbeefdeadbeef);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ assignment to borrowed `my_enum` occurs here
13 | dbg!(**my_int_ptr_ptr);
| ---------------- borrow later used here
答案 4 :(得分:1)
术语“别名”通常用于标识更改涉及不同引用的操作顺序会改变这些操作的效果的情况。如果对一个对象的多个引用存储在不同的地方,但在这些引用的生命周期内没有修改该对象,编译器可以有效地提升、延迟或合并使用这些引用的操作,而不会影响程序行为。
例如,如果编译器看到代码读取了 x
引用的对象的内容,则对 y
引用的对象执行某些操作,然后再次读取由 x
引用的对象的内容y
,并且如果编译器知道 x
上的操作无法修改 x
引用的对象,则编译器可能会将 $string="10,11,12,13,14";
的两个读取合并为一个读取。
在所有情况下确定对一个引用的操作是否会影响另一个引用将是一个棘手的问题,如果程序员有无限的自由来使用和存储他们认为合适的引用。然而,Rust 试图处理两种简单的情况:
如果一个对象在引用的生命周期内永远不会被修改,使用引用的机器代码就不必担心在生命周期内哪些操作可能会改变它,因为任何操作都不可能这样做。
如果在引用的生命周期内,对象只会被明显基于该引用的引用修改,使用该引用的机器代码将不必担心使用该引用的任何操作是否会交互操作涉及看似无关的引用,因为没有看似无关的引用会标识同一个对象。
允许可变引用之间存在别名的可能性会使事情变得更加复杂,因为可以与对可变对象的非共享引用或对不可变对象的可共享引用交替执行的许多优化不再如此。一旦一种语言支持需要以精确排序的方式处理涉及看似独立的引用的操作的情况,编译器就很难知道何时需要这种精确的排序。