For shared references and mutable references the semantics are clear: as long as you have a shared reference to a value, nothing else must have mutable access, and a mutable reference can't be shared.
So this code:
#[no_mangle]
pub extern fn run_ref(a: &i32, b: &mut i32) -> (i32, i32) {
let x = *a;
*b = 1;
let y = *a;
(x, y)
}
compiles (on x86_64) to:
run_ref:
movl (%rdi), %ecx
movl $1, (%rsi)
movq %rcx, %rax
shlq $32, %rax
orq %rcx, %rax
retq
Note that the memory a
points to is only read once, because the
compiler knows the write to b
must not have modified the memory at
a
.
Raw pointer are more complicated. Raw pointer arithmetic and casts are "safe", but dereferencing them is not.
We can convert raw pointers back to shared and mutable references, and then use them; this will certainly imply the usual reference semantics, and the compiler can optimize accordingly.
But what are the semantics if we use raw pointers directly?
#[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)
}
compiles to:
run_ptr_direct:
movl (%rdi), %ecx
movl $1065353216, (%rsi)
movl (%rdi), %eax
shlq $32, %rax
orq %rcx, %rax
retq
Although we write a value of different type, the second read still goes
to memory - it seems to be allowed to call this function with the same
(or overlapping) memory location for both arguments. In other words, a
const
raw pointer does not forbid a coexisting mut
raw pointer; and
its probably fine to have two mut
raw pointers (of possibly different
types) to the same (or overlapping) memory location too.
Note that a normal optimizing C/C++-compiler would eliminate the second read (due to the "strict aliasing" rule: modfying/reading the same memory location through pointers of different ("incompatible") types is UB in most cases):
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};
}
compiles to:
run_ptr:
movl (%rdi), %eax
movl $0x3f800000, (%rsi)
movq %rax, %rdx
salq $32, %rdx
orq %rdx, %rax
ret
Playground with Rust code examples
godbolt Compiler Explorer with C example
So: What are the semantics if we use raw pointers directly: is it ok for referenced data to overlap?
This should have direct implications on whether the compiler is allowed to reorder memory access through raw pointers.
A raw pointer is a pointer whose lifetime isn't controlled by an encapsulating object, such as a smart pointer. A raw pointer can be assigned the address of another non-pointer variable, or it can be assigned a value of nullptr . A pointer that hasn't been assigned a value contains random data.
Rust has a number of different smart pointer types in its standard library, but there are two types that are extra-special. Much of Rust's safety comes from compile-time checks, but raw pointers don't have such guarantees, and are unsafe to use. *const T and *mut T are called 'raw pointers' in Rust.
The dereference operator is also known as the indirection operator. Simply put, the dereferencing operator allows us to get the value stored in the memory address of a pointer. In Rust, we use the Deref trait to customize the behaviour of the dereferencing operator.
You need to use the & operator to get the address of any variable, so you need to write &my_struct as *const _ (where _ can be a literal _ , or the type of the value behind the pointer).
No awkward strict-aliasing here
C++ strict-aliasing is a patch on a wooden leg. C++ does not have any aliasing information, and the absence of aliasing information prevents a number of optimizations (as you noted here), therefore to regain some performance strict-aliasing was patched on...
Unfortunately, strict-aliasing is awkward in a systems language, because reinterpreting raw-memory is the essence of what systems language are designed to do.
And doubly unfortunately it does not enable that many optimizations. For example, copying from one array to another must assume that the arrays may overlap.
restrict
(from C) is a bit more helpful, although it only applies to one level at a time.
Instead, we have scope-based aliasing analysis
The essence of the aliasing analysis in Rust is based on lexical scopes (barring threads).
The beginner level explanation that you probably know is:
&T
, then there is no &mut T
to the same instance,&mut T
, then there is no &T
or &mut T
to the same instance.As suited to a beginner, it is a slightly abbreviated version. For example:
fn main() {
let mut i = 32;
let mut_ref = &mut i;
let x: &i32 = mut_ref;
println!("{}", x);
}
is perfectly fine, even though both a &mut i32
(mut_ref
) and a &i32
(x
) point to the same instance!
If you try to access mut_ref
after forming x
, however, the truth is unveiled:
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
So, it is fine to have both &mut T
and &T
pointing to the same memory location at the same time; however mutating through the &mut T
will be disabled for as long as the &T
exists.
In a sense, the &mut T
is temporarily downgraded to a &T
.
So, what of pointers?
First of all, let's review the reference:
- are not guaranteed to point to valid memory and are not even guaranteed to be non-NULL (unlike both
Box
and&
);- do not have any automatic clean-up, unlike
Box
, and so require manual resource management;- are plain-old-data, that is, they don't move ownership, again unlike
Box
, hence the Rust compiler cannot protect against bugs like use-after-free;- lack any form of lifetimes, unlike
&
, and so the compiler cannot reason about dangling pointers; and- have no guarantees about aliasing or mutability other than mutation not being allowed directly through a
*const T
.
Conspicuously absent is any rule forbidding from casting a *const T
to a *mut T
. That's normal, it's allowed, and therefore the last point is really more of a lint, since it can be so easily worked around.
Nomicon
A discussion of unsafe Rust would not be complete without pointing to the Nomicon.
Essentially, the rules of unsafe Rust are rather simple: uphold whatever guarantee the compiler would have if it was safe Rust.
This is not as helpful as it could be, since those rules are not set in stone yet; sorry.
Then, what are the semantics for dereferencing raw pointers?
As far as I know1:
&T
or &mut T
) then you must ensure that the aliasing rules these references obey are upheld,That is, providing that the caller had mutable access to the location:
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)
}
should be valid, because *a
has type i32
, so there is no overlap of lifetime in references.
However, I would expect:
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)
}
To be undefined behavior, because x
would be live while *b
is used to modify its memory.
Note how subtle the change is. It's easy to break invariants in unsafe
code.
1And I might be wrong right now, or I may become wrong in the future
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With