engineering
Rust for high-stakes environments
Have you ever dealt with carburetors? A dead-simple concept, wildly demanding for the driver. Starting a car used to be a ritual. Choke. Pump. Crank. Repeat. Everything should be done right. And — it still might not work. Temperature, altitude, humidity, fuel quality – so many variables at play, and no guarantee it starts. Romantic for some. Frustrating and time-consuming for most.
These days, we don’t even think about it. A computer sets the mixture ratio. And it works. The driver just drives. Systems programming had its own carburetor phase: malloc. Same story. Simple concept. Wildly demanding for the developer.
Ask for memory. Use it. Give it back. Knowing exactly when to allocate, how much, and when to free. And it still might not work. So many ways to break everything. A crash that disappears when you add a print statement. Debug and release builds that behave differently. Bugs that only appear in production, on Tuesdays. Manual memory management may be a badge of honor for some. But you won’t find it in a modern safety-critical system without a mountain of tooling around it.
Rust is fuel injection for systems programming. The compiler decides ownership and lifetimes. And it works. Always. Your engineers stop thinking about whether their code segfaults, and start thinking about building the product.
Let me show you what I mean. I’ve prepared two code examples. Follow along and see for yourself. If you can read the code, great. If not, follow the text.
One hundred lines of C++
I’m a Rust developer, making a PoC integration with a system written in C++. The integration was in a rather unexpected place for that project, so I had to write a little C++ shim. 100 lines. Modern toolchain. Nothing exotic. Should be doable…
What I needed was a little facade over C++ APIs for me to call. Here’s what I got…
[1] 1162714 IOT instruction (core dumped) I can't say I'm surprised it crashed – I'm not a C++ developer. Here’s a minimal reproducible example of what was happening:
#include <cstdio>
#include <memory>
namespace xxx {
template <typename T>
struct shared {
T* ptr;
~shared() { delete ptr; }
template <typename U>
shared(U&& other) : ptr(other.get()) {}
};
}
void process(std::shared_ptr<int> a, std::shared_ptr<int> b, xxx::shared<int> c) {
std::printf("a=%d b=%d\n", *a, *b);
}
int main() {
auto a = std::make_shared<int>(1);
auto b = std::make_shared<int>(2);
auto c = std::make_shared<int>(3);
process(a, b, c);
} See that third argument of the “process” function? It's a custom, project-specific smart pointer. But I pass a shared pointer from std. If you didn’t try it yourself yet: it compiles.
Yes, I made a very dumb mistake. I didn’t see they were different types. But shouldn’t a strongly typed language catch this at compile time?
I spent a few hours on this. The code above is an example. In a real codebase, this could be buried in a corner case, not obvious at all. Imagine if it passed code review. Tests are green. It reaches QA. More people are involved. It reaches production. Now customers are involved. How much does this little issue cost now? How much focus does it steal from the team? Time that could go toward building the product.
Rust would show me an error like this:
error[E0308]: mismatched types
--> src/main.rs:21:19
|
21 | process(a, b, c);
| ------- ^ expected `xxx::Shared<i32>`, found `Arc<i32>`
| |
| arguments to this function are incorrect
|
note: function defined here
--> src/main.rs:14:4
|
14 | fn process(a: Arc<i32>, b: Arc<i32>, c: xxx::Shared<i32>) {
| ^^^^^^^ -------------------------- ----------------- You compile the code, see the message, say “oh, right”, fix, move on. One minute, maybe two.
But was this a “memory-safety” bug? The double-free – yes. But that’s the symptom. The root cause is that C++ silently converted std::shared_ptr<int> into xxx::shared<int> – code I didn’t write, didn’t ask for, didn’t even know about.
It's not about memory safety. It’s about language design.
Rust is safe by default
Here is a simple C++ program:
#include <cstdio>
struct User {
int scores[3];
bool is_admin;
};
int main() {
User user = {.scores = {90, 85, 78}, .is_admin = false};
// scores[3] reads is_admin, overwrites it
user.scores[3] = 1;
if (user.is_admin)
std::printf("Welcome, admin!\n");
else
std::printf("Access denied.\n");
} Compile and run it, with default configuration:
$ gcc -o ub_example ub_example.cpp && ./ub_example
Welcome, admin! Yes, it prints “Welcome, admin!”, as simple as that. Silent stack corruption.
Clang does a little better:
$ clang -o ub_example ub_example.cpp && ./ub_example
ub_example.cpp:13:5: warning: array index 3 is past the end of the array (that has type 'int[3]')
[-Warray-bounds]
13 | user.scores[3] = 1;
| ^ ~
ub_example.cpp:4:5: note: array 'scores' declared here
4 | int scores[3];
| ^
1 warning generated.
Welcome, admin! This time we see a warning, and still have the binary. Binary with a potentially critical error.
Here is the way to prevent compilation:
$ clang -Werror -o ub_example ub_example.cpp As you can see, Clang knows this is a three-element array. Clang knows that write is wrong. Nevertheless, you have the binary.
The same example with Rust:
struct User {
scores: [i32; 3],
is_admin: bool,
}
fn main() {
let mut user = User { scores: [90, 85, 78], is_admin: false };
user.scores[3] = 1;
if user.is_admin {
println!("Welcome, admin!");
} else {
println!("Access denied.");
}
} $ rustc ub_example.rs
error: this operation will panic at runtime
--> ub_example.rs:9:5
|
9 | user.scores[3] = 1;
| ^^^^^^^^^^^^^^ index out of bounds: the length is 3 but the index is 3
|
= note: `#[deny(unconditional_panic)]` on by default
error: aborting due to 1 previous error With Rust, it won’t compile. We can allow it, manually, with #![allow(unconditional_panic)] on the top of the file though.
thread 'main' (1025700) panicked at ub_example.rs:11:5:
index out of bounds: the len is 3 but the index is 3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace And still it crashes. The stack is not corrupted.
Let’s change the operation. Let's tell the compiler to go “unsafe” with that array:
// SAFETY: I declare this operation is safe
unsafe {
*user.scores.get_unchecked_mut(3) = 1;
} Compile and run:
$ rustc ub_example.rs && ./ub_example
thread 'main' (1026242) panicked at ub_example.rs:10:22:
unsafe precondition(s) violated: slice::get_unchecked_mut requires that the index is within the slice
This indicates a bug in the program. This Undefined Behavior check is optional, and cannot be relied on for safety.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread caused non-unwinding panic. aborting.
[1] 1026242 IOT instruction (core dumped) ./ub_example Damn, it still resists! Let’s build with all optimizations enabled:
$ rustc -C opt-level=3 ub_example.rs && ./ub_example
Welcome, admin! Phew! Finally!
C++ trades safety for performance by default. An out-of-bounds write compiles, runs, and silently corrupts your stack. No warning from GCC, just a warning from Clang – and still a binary. You have to opt in to safety with -Werror.
With Rust, the same bug is a compile error. We had to #![allow(unconditional_panic)], then unsafe, then -C opt-level=3 just to reproduce it. Three explicit overrides to get what C++ gives you “for free”.
We had to fight C++ to make it safe. We had to fight Rust to make it unsafe.
Let Rust take care of what wastes time
There are industries where the cost of error is skyrocketing. Automotive, aviation, finance, you name it. Safety is not a feature, it’s obligatory. Because of this, developers carry an enormous additional load: rigorous compliance, certification, documentation requirements. All that eats focus. We are humans; the focus is finite.
We can’t reduce compliance. But we can stop wasting engineering time on bugs the compiler could have caught.
Rust is safe by design and sits in the same performance bracket as C++. Its strict type system gives the compiler more information to optimize. Iterators, generics, and ownership patterns compile down to the same machine code you get from hand-optimized C++. Some tests just disappear. The ones checking for null, verifying types, guarding against states that shouldn’t be possible. The compiler already handles that. Engineers stop chasing ghosts. And focus on actually useful tests, business logic, user experience, and performance.
Remove debugging memory corruption from a daily workflow and people get more productive. More focus, more useful capacity. Productive teams can stay smaller. Smaller teams mean less miscommunication, faster decision making.
It compounds. Every bug you don’t write is a bug you don’t report, don’t debug, don’t document, don’t explain to users. Every test you don’t maintain is capacity you get back. The longer the project lives, the bigger the difference.
It’s not free though
There aren’t many Rust engineers in the hiring pool. At all. You will probably be hiring some experts with an option to retrain existing teams. The good news is that you probably have some enthusiasts around already. They’ve been writing Rust on weekends, maybe dreaming of doing it at work. They are waiting for you to ask.
The learning curve is steep; retraining takes time. The first few months can be slow. Even for experienced developers. Even for experienced C++ developers. It’s not the syntax. It's the ownership and borrowing model. It’s very different from “traditional” ways of doing things. The compiler will reject the code that “should work”.
Happily, there are some resources to ease the process: “Rust Book” aka “The Book”, “Rust By Example”, and for those ready for the dark arts, “The Rustonomicon”.
But books are not enough. Without guidance, developers often end up writing C++ in Rust. I’ve seen it happen. That’s not optimal, because you lose part of the value. Endless unsafe blocks with raw pointers because “that’s faster”. Reinventing wheels the ecosystem already solved. Cloning everything to satisfy the borrow checker. Or the opposite – overengineering references and generic bounds to make something “perfect”. You need at least one experienced Rust developer in the team. Someone who reviews code, sets conventions, and actually explains why things are done differently.
Rust 1.0 was released in 2015. The ecosystem is young. Some crates (packages) are stable and production ready. For example tokio and serde. Many other crates are version 0.2, with a single contributor and a last commit 9 months ago. You’ll have to evaluate dependencies carefully. Sometimes you’ll want to vendor them, or implement important ones.
Still, sometimes you ask the compiler to trust you. Writing unsafe. The part of Rust that can cause Undefined Behavior. Or crash the process with a segfault. Construction that makes critics say, “aha, so Rust is not really safe”. It exists, it’s useful, and sometimes you need it. But your team should be very disciplined about using it. Leave SAFETY comments. Test these parts rigorously. Run those tests with miri. Make kani harnesses.
We can go way deeper on problems. Ask any Rust developer what they hate. Async infrastructure is half-baked, requiring too much ceremony. Features that can’t get stabilised and kept in the nightly compiler for years. Procedural macros that are hard to debug. The keyword generics proposal is very controversial. Memory allocation error handling is practically non-existent. The list goes on. There are many problems to solve. But the foundation is solid, and that’s why we are here.
Living alongside C/C++
“Rewriting everything” is not viable. You’ll have to live with a mixed codebase, adopting Rust incrementally. It integrates fairly well. I’ll focus on C/C++ integration, though most of this applies to any language that can call the C ABI.
C integration is as good as it can be. Rust-C communication is well documented, works both ways natively via extern "C" for functions and [repr(C)] for structs. Primitives map directly. Use CStr and CString for strings. You can link C libraries into Rust binary or produce Rust C-style libraries to link into C binary.
Languages are different, so some ceremony is required. It’s common to see things like function pointers for callbacks and void* contexts in C APIs. That’s not a pattern in idiomatic Rust. Rust can’t guarantee something that comes from C. So the whole FFI layer is inherently unsafe – one of the most dangerous places in your Rust codebase.
Raw pointers are everywhere. You have to properly handle the C pointer to Rust reference convention. It looks like *mut obj translates to &mut obj, but let’s not forget about null and the fact that having two &mut to the same object in Rust is Undefined Behavior. Same goes for handling ownership when casting Rust reference to a C pointer. Imagine getting a raw pointer, passing it to C, dropping the original object, and then observing a periodical segfault because C still uses that pointer.
The linker is perfectly happy to connect libraries with incompatible function signatures. But it’s your job to ensure compatibility on the FFI layer. And it needs to be reliable both now and over time. Doing it manually is not an option. Use bindgen to generate Rust bindings for C headers. Use cbindgen to generate C headers from Rust. Regenerate both as part of the build to catch interface drift at compile time. But if the library is a precompiled blob, headers and binary can be out of sync. Add a runtime version check, or ask the blob provider to package headers from the sources together with it.
C++ is more complex, so the integration is more nuanced too. The cxx crate helps a lot, but there are limitations. You can’t extend classes, and generics on either side must be bridged one concrete type at a time. You have to manually write the bridge code. It’s compile-time verified though. There’s also autocxx, which generates bindings automatically and even makes them look safe. But “safe” comes with a caveat. If C++ keeps its own reference to an object that Rust thinks it owns exclusively, the result is Undefined Behavior.
Keep the FFI layer well defined, well tested, and thin. Expose a safe, idiomatic Rust API on top. The bar: someone who doesn’t know the FFI details can still use the code safely.
I mentioned miri and kani previously, but their usefulness is limited in FFI. miri is a Rust interpreter – it can’t see across the FFI boundary. Kani can’t verify C or C++ code, so you’ll end up mocking the FFI. That’s less than perfect because behavior may differ. So add a good amount of unit tests, property tests, fuzzing, and other checks.
Build system integration is an effort too. You can build and link CMake-based projects into Rust binary with cmake crate. That works great for simple cases. For something big, you might need to write quite a few linking hints for rustc. For CMake-driven build systems, consider corrosion. But keep in mind that Rust statically links its stdlib into every binary it produces. Linking multiple Rust static libraries into a larger project can cause duplicate symbol errors. The usual solution is to consolidate Rust code into one final static library artifact. Not a problem with dynamic libraries, where symbols are hidden. But beware, each will still have its own copy of stdlib.
Just enjoy
I find it hard to explain Rust to newbie developers, they don’t really understand what it guards from. Experienced developers? Easy. They know the feeling when you run your changes. Will it crash? Will it silently do the wrong thing? Will it work but some random unrelated test fails?
The examples cover C++, but only because it’s the closest contender.
I literally had an eye twitch with Python. Memory-safe, garbage collected, and you still get TypeError at runtime, or None where you didn't expect it. GC languages solved memory safety. They didn't solve bug safety. Rust did. Not perfectly, but enough that when it runs, you stop holding your breath.
No random exceptions. No surprises. Build your stuff.
