Safe Abstraction of Unsafe Code
Introduction
Writing safe abstractions over unsafe code is a common pattern in systems programming, where performance and control over low-level details are critical. Both C++ and Rust allow programmers to write such code, but they approach safety, unsafety, and abstraction differently. C++ offers a lot of freedom with implicit trust in the programmer, while Rust provides a more structured approach, making unsafe operations explicit and encapsulating them within safe abstractions.
Rust is designed to be safe by default, it acknowledges that unsafe operations are sometimes necessary for low-level systems programming. Rust requires that such operations be explicitly marked with the unsafe keyword
, isolating unsafe code and making it easier to review and audit.
C++: Managing Safety Manually
In C++, safety often relies on the programmer's discipline and conventions. The language offers mechanisms like RAII (Resource Acquisition Is Initialization) to manage resources safely but leaves it to the programmer to use these mechanisms consistently.
Example: Manual Memory Management
#include <iostream>
class SafeIntArray {
private:
int* array;
size_t size;
public:
SafeIntArray(size_t size): size(size), array(new int[size]) {}
~SafeIntArray() {
delete[] array;
}
int& operator[](size_t index) {
// Bounds check for safety
if (index >= size) throw std::out_of_range("Index out of range");
return array[index];
}
};
int main() {
SafeIntArray arr(10);
arr[0] = 42; // Safe access
std::cout << arr[0] << std::endl;
// arr[10] = 3; // This would throw an exception, preventing undefined behavior
return 0;
}
This C++ class SafeIntArray
is a simple example of providing a safe interface to unsafe raw pointer operations. It manually manages memory with new
and delete
, encapsulating unsafe array access within a class that checks bounds.
Rust: Explicit Unsafe with Safe Abstractions
Rust requires any unsafe operation to be explicitly marked with the unsafe
keyword. This makes it clear which parts of the codebase could potentially lead to undefined behavior, encouraging the encapsulation of unsafe blocks within safe interfaces.
Example: Safe Abstraction Over Unsafe Code
struct SafeIntArray { array: Vec<i32>, } impl SafeIntArray { fn new(size: usize) -> Self { SafeIntArray { array: vec![0; size] } } fn set(&mut self, index: usize, value: i32) { // Safe due to Rust's ownership and borrowing rules if index >= self.array.len() { panic!("Index out of range"); } // Unsafe block encapsulated within a safe function unsafe { *self.array.as_mut_ptr().add(index) = value; } } fn get(&self, index: usize) -> i32 { if index >= self.array.len() { panic!("Index out of range"); } // Unsafe block encapsulated within a safe function unsafe { *self.array.as_ptr().add(index) } } } fn main() { let mut arr = SafeIntArray::new(10); arr.set(0, 42); // Safe API println!("{}", arr.get(0)); // arr.set(10, 3); // This would panic at runtime, preventing undefined behavior }
In this Rust example, SafeIntArray
provides a safe interface to an underlying vector. Rust's vector provides safety guarantees, but for demonstration, we've used unsafe operations to manipulate memory directly, simulating what might be necessary for interfacing with low-level system components or optimizing critical paths.
Conclusion
Both C++ and Rust offer mechanisms to write high-performance, low-level code safely. C++ trusts the programmer to manage safety, while Rust enforces safety at the language level, requiring any escape from these guarantees to be explicit. This explicitness in Rust aids in creating clear boundaries between safe and unsafe code, making it easier to maintain and audit for safety while still allowing for the performance benefits of low-level programming.