When and how to use unsafe, raw pointers, FFI, and bypassing the borrow checker safely.
Unsafe Rust bypasses compile-time guarantees when necessary for performance or system-level operations. Use it sparingly and with careful documentation.
// When performance requires unsafe optimizations
fn summing_without_bounds_checking(slice: &[i32]) -> i32 {
let mut sum = 0;
unsafe {
// Bypassing bounds checking when we know it's safe
let ptr = slice.as_ptr();
for i in 0..slice.len() {
sum += *ptr.add(i);
}
}
sum
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let result = summing_without_bounds_checking(&numbers);
println!("Sum: {}", result);
}Inside an unsafe block, you are responsible for upholding Rust's safety invariants. The compiler trusts you, if you get it wrong, you get undefined behaviour.
Raw pointers (*const T and *mut T) allow direct memory access without safety checks. Creating them is safe, but dereferencing requires an unsafe block.
fn main() {
let x = 5;
let raw_ptr = &x as *const i32;
// Can only dereference in unsafe blocks
unsafe {
println!("Value at pointer: {}", *raw_ptr);
}
// Mutable raw pointers
let mut y = 10;
let mut_ptr = &mut y as *mut i32;
unsafe {
*mut_ptr = 20;
println!("Modified value: {}", *mut_ptr);
}
}// Pointer arithmetic - advanced usage
fn main() {
let arr = [1, 2, 3, 4, 5];
let ptr = arr.as_ptr();
unsafe {
println!("First element: {}", *ptr);
println!("Third element: {}", *ptr.add(2));
// Copy from pointer
let mut dest = [0; 5];
std::ptr::copy_nonoverlapping(ptr, dest.as_mut_ptr(), 5);
println!("Copied array: {:?}", dest);
}
}You can create raw pointers in safe code, that is perfectly fine. It is only dereferencing a raw pointer that requires unsafe, because the compiler cannot guarantee the pointer is valid.
Creating and calling unsafe functions requires explicit unsafe blocks. A good pattern is to wrap unsafe internals behind a safe public API.
// Unsafe function that splits a string at an arbitrary position
unsafe fn split_at_unsafe(s: &str, mid: usize) -> (&str, &str) {
let bytes = s.as_bytes();
let ptr = s.as_ptr();
// Assume mid is a valid UTF-8 boundary
(
std::str::from_utf8_unchecked(
std::slice::from_raw_parts(ptr, mid)),
std::str::from_utf8_unchecked(
std::slice::from_raw_parts(ptr.add(mid), bytes.len() - mid)),
)
fn safe_split_wrapper(s: &str, mid: usize) -> Option<(&str, &str)> {
if mid <= s.len() && s.is_char_boundary(mid) {
unsafe { Some(split_at_unsafe(s, mid)) }
} else {
None
}
fn main() {
let text = "Hello, world!";
if let Some((left, right)) = safe_split_wrapper(text, 5) {
println!("Left: {}, Right: {}", left, right);
}
}// Unsafe traits
unsafe trait Dangerous {
unsafe fn do_dangerous_thing(&self);
struct UnsafeStruct;
unsafe impl Dangerous for UnsafeStruct {
unsafe fn do_dangerous_thing(&self) {
println!("Doing something dangerous!");
}
fn main() {
let obj = UnsafeStruct;
unsafe {
obj.do_dangerous_thing();
}
}The best practice is to validate all preconditions in a safe wrapper function, then call the underlying unsafe code only when the invariants are guaranteed. This keeps the unsafe surface area minimal.
Mutable static variables require unsafe access due to potential data races in multithreaded contexts.
static mut COUNTER: i32 = 0;
fn increment_counter() {
unsafe {
COUNTER += 1;
}
fn get_counter() -> i32 {
unsafe {
COUNTER
}
fn main() {
increment_counter();
increment_counter();
println!("Counter: {}", get_counter());
}Mutable statics are inherently unsafe in multithreaded code. In practice, prefer AtomicI32, Mutex<T>, or once_cell::sync::Lazy for thread-safe global state.
The Foreign Function Interface (FFI) allows calling C code from Rust. All FFI calls are unsafe because the Rust compiler cannot verify the correctness of external code.
// Using libc to call C functions
extern "C" {
fn abs(x: i32) -> i32;
fn sqrt(x: f64) -> f64;
fn main() {
unsafe {
println!("abs(-42) = {}", abs(-42));
println!("sqrt(16.0) = {}", sqrt(16.0));
}
}// Wrapping C functions with safe interfaces
extern "C" {
fn strlen(s: *const u8) -> usize;
fn safe_strlen(s: &str) -> usize {
unsafe {
strlen(s.as_ptr())
}
fn main() {
let text = "Hello";
println!("Length: {}", safe_strlen(text));
}extern "C" ABIThe "C" in extern "C" specifies the calling convention. This tells Rust to use the C ABI, which is the standard for cross-language interop on most platforms.
The best practice is to hide unsafe operations behind safe APIs. Validate inputs, manage lifetimes, and implement Drop for cleanup.
// Safe abstraction over unsafe pointer operations
struct SafeBuffer {
ptr: *mut u8,
len: usize,
impl SafeBuffer {
fn new(size: usize) -> Self {
unsafe {
let ptr = libc::malloc(size) as *mut u8;
SafeBuffer { ptr, len: size }
}
}
fn get(&self, index: usize) -> Option<u8> {
if index < self.len {
unsafe { Some(*self.ptr.add(index)) }
} else {
None
}
}
fn set(&mut self, index: usize, value: u8) -> bool {
if index < self.len {
unsafe { *self.ptr.add(index) = value; }
true
} else {
false
}
}
impl Drop for SafeBuffer {
fn drop(&mut self) {
unsafe {
libc::free(self.ptr as *mut libc::c_void);
}
}
fn main() {
let mut buf = SafeBuffer::new(10);
buf.set(0, 42);
if let Some(value) = buf.get(0) {
println!("Value: {}", value);
}
}A well-designed safe abstraction makes it impossible for callers to trigger undefined behaviour through the public API. All bounds checks and validity proofs happen inside the wrapper.
Several patterns in systems programming commonly involve unsafe code, such as type transmutation and #[repr(C)] structs for C interop.
// Pattern 1: Transmute (with extreme caution)
fn bytes_to_u32_unsafe(bytes: &[u8; 4]) -> u32 {
unsafe {
std::mem::transmute::<[u8; 4], u32>(*bytes)
}
// Pattern 2: Accessing struct fields at offsets
#[repr(C)]
struct CStruct {
a: i32,
b: f64,
fn main() {
let s = CStruct { a: 42, b: 3.14 };
// Safe approach: use normal field access
println!("a: {}, b: {}", s.a, s.b);
// Unsafe approach: raw pointer access
unsafe {
let ptr = &s as *const CStruct as *const u8;
println!("Field a offset: {}",
std::mem::offset_of!(CStruct, a));
}
}std::mem::transmute reinterprets the bits of a value as a different type. It can easily cause undefined behaviour if the source and target types have different invariants. Prefer from_ne_bytes / to_ne_bytes for numeric conversions.