Rust is a great C replacement

12 minute read

Out of curiosity, I wanted to see if there were any resources online that had solid points as to why Rust was fit to be a replacement for C.

However, the first result that came up from Google was the opposite: a blog post written in 2019, titled “Rust is not a good C replacement”.

The post contains a number of points I’ve seen raised by other C programmers, and it’s rather disappointing because they’re very misguided and wrong in quite a few cases.

So, in the interest of fostering a productive discussion, I’ll offer a point-by-point rebuttal to the author’s post, complete with cited sources.

Features

Both Rust and C++ are what I like to call “kitchen sink” programming languages, with the obvious implication. These languages solve problems by adding more language features. A language like C solves problems by writing more C code.

This one is very unfortunate, because it conflates the addition of new features in a language with bloat, and as I’ve seen in C++’s case, adding more than one way to do the same thing.

First off: C is an extremely barebones language that offers practically nothing in the way of a standard library. This is a bad thing, because you then have to reimplement the functionality that you need in pretty much every project that you work on in C.

Or, as I’ve seen professionally, engineers will cheap out and opt to use linked lists everywhere in lieu of implementing data structures. You don’t have HashMap, or Vec, or VecDeque, or… well, you get it. C gives you nothing to work with.

Using a library in C that contains these data structures isn’t a practical option either, due to the lack of a unified build system (a point I will elaborate on further). There is no “just download something from GitHub” for C.

Conversely, for Rust, you get an expansive standard library for free with a ton of data structures that you’d never see in C. Or, if Rust’s standard library doesn’t offer what you want, pull a library from crates.io. Can’t use the standard library because you’re writing a bootloader for a toaster? Opt out with #![no_std]. Notably this does not outright prevent you from using libraries from crates.io.

As for the new Rust features, most of these changes are just small additions and updates to core data structures in Rust. There are far fewer updates to the language itself.

C++ Features

This is also worth adding to the discussion: C++’s features come with hidden bloat and costly abstractions. It’s pretty easy to think that “if C++ couldn’t do it right, why is Rust any different?”

To that, I’d say to read the following two posts (and potentially more by the time you read this) by Jimmy Hartzell, because he goes into fantastic detail why Rust handles these things better than C++:

  1. C++ Move Semantics Considered Harmful
  2. Sayonara, C++, and hello to Rust!

Portability

C is the most portable programming language. Rust actually has a pretty admirable selection of supported targets for a new language (thanks mostly to LLVM), but it pales in comparison to C, which runs on almost everything.

A new CPU architecture or operating system can barely be considered to exist until it has a C compiler. And once it does, it unlocks access to a vast repository of software written in C. Many other programming languages, such as Ruby and Python, are implemented in C and you get those for free too.

This one is a rather confusing point to make, because in most cases this does not matter. When is the last time you’ve had to compile code for an exotic architecture?

In addition, C is easy (for some value of easy) to implement if you want to hand-roll your own compiler. But who actually hand-rolls a compiler nowadays?

Professionals will extend the backend of an existing compiler, most commonly GCC, to support their radical new architecture. They wouldn’t need to touch the frontend of the compiler, meaning that they don’t care what language makes its way into the frontend.

And as the author mentioned: LLVM, Rust’s default backend, also sports codegen ability for quite a few architectures as well. If it doesn’t support your target triple, simply add it with a target JSON script.

And, good news: The Rust compiler team formally accepted a proposal to wire in a GCC backend for code generation, which will gain Rust the portability offered by GCC. To be fair to the author of the blog post, this happened back in July 2021 (2 years after the post was written). And as of writing this post, the implementation does not appear to be complete.

No Specification

C has a spec. No spec means there’s nothing keeping rustc honest. Any behavior it exhibits could change tomorrow. Some weird thing it does could be a feature or a bug. There’s no way to know until your code breaks. That they can’t slow down to pin down exactly what defines Rust is also indicative of an immature language.

Quite honestly, having a formal language specification is not only a huge bottleneck, it is also incorrect to say a specification is the only thing that prevents compilers from breaking your software.

In order to extend C/C++ compilers - you have to submit an RFC, ratify the language specification, and then wait approximately 5-10 years for compiler developers to play catch-up.

In Rust however, you simply submit an RFC, get it approved (after an extensive comment period), and then implement the RFC in the corresponding Rust project. That implementation will then immediately be available to use in the next nightly build of Rust.

The Rust compiler team also tests compiler changes against the entire crates ecosystem to ensure no breakage occurs (in addition to their own compiler tests). Who needs a specification when you can test almost all of the existing Rust code for regressions?

This also leads into another point: It’s nearly impossible to extend C in the way C++ and Rust’s standard libraries can be extended. New functions, macros, types, etc introduce the possibility of breaking existing C code, thanks to the lack of namespacing. Anything new can conflict with existing code and cause it to cease to compile. And because you can’t test compiler implementations across the entire C ecosystem, this breakage is silent.

And finally, despite what I’ve just said, it would seem that C2x has been set forth to extend the language moreso than done before.

One implementation

C has many implementations. C has many competing compilers. They all work together stressing out the spec, fishing out the loosely defined corners, and pinning down exactly what C is. Code that compiles in one and not another is indicative of a bug in one of them, which gives a nice extra layer of testing to each. By having many implementations, we force C to be well defined, and this is good for the language and its long-term stability.

Having many implementations of a compiler for a language is awful. The code compiled via these compilers has to stretch and twist in order to satisfy the many different requirements set forth by each implementation.

Often times the code I write will compile just fine under MSVC, but hit an error in our CI system because GCC was unhappy with it. Or vice versa for our engineers working on GCC. Or maybe engineers accidentally stumble their way into an implementation-specific extension, such as GCC’s case ranges.

Or - have you tried to mark a function as deprecated? In GCC, you have to use __attribute__((deprecated)). In MSVC, it’s __declspec(deprecated).

How about disabling the stack cookie security check on MSVC or GCC for performance-critical code? Yet another compiler-specific hoop I’ve had to jump through.

Even better is the fact that GCC and MSVC optimize my software differently, because the compiler developers don’t implement or share the same optimizations. Code that runs really fast on GCC may run much more slowly on MSVC, or vice versa. And since performance is a requirement, I have to contort my code so it hits the best optimizations for all the compilers it’s supposed to be built by.

We’re so frustrated by these problems that we’ve made plans to swap to Clang, as a unified compiler for all of our platforms.

Having one implementation of Rust is a great thing, because it means no vendor-specific extensions or reinterpretation of the specification.

ABI stability

C has a consistent & stable ABI. The System-V ABI is supported on a wide variety of systems and has been mostly agreed upon by now. Rust, on the other hand, has no stable internal ABI. […] The only code which can interact with the rest of the ecosystem is unidiomatic Rust, written at some kind of checkpoint between Rust and the outside world.

Rust has an unspecified ABI. And it’s a good thing. It allows the compiler developers to make optimizations that C compilers will never be capable of doing - like automatically reordering struct fields to avoid padding bytes, or using a niche optimization for Rust’s enum values to save space. As time goes on, they’re permitted to add or change behavior to make the generated code run faster.

In addition, in Rust you can in-fact use the System-V ABI (specifically, the platform-specific C ABI for your platform). You can simply drop down to the FFI layer and declare a function with extern "C" - exactly the same way you would do it in C++. You can also declare structures with #[repr(C)] to lay them out as they would be laid out in C.

Too low-level? Perhaps check out the abi_stable crate and see if that suits your needs for use of higher-level Rust data structures. (Disclaimer: I haven’t personally used the crate.)

Cargo is mandatory

Cargo is mandatory. On a similar line of thought, Rust’s compiler flags are not stable. Attempts to integrate it with other build systems have been met with hostility from the Rust & Cargo teams. The outside world exists, and us systems programmers spend a lot of our time integrating things. Rust refuses to play along.

This is one of the best things about Rust. A unified build system means a unified ecosystem.

I haven’t once had to screw around with a build system in my use of Rust. No CMake, no Makefiles, nothing. I’ve been able to use numerous libraries in the crates.io ecosystem to very quickly prototype my ideas, as well as very quickly turn those prototypes into full-blown and robust production code.

I’ve even been able to do this at a systems-level programming level, without dropping down to Makefile scripts.

In C, it’s actually cheaper to rewrite functionality than it is to reuse functionality thanks to the fragmented C ecosystem. And that’s owed to the fact that C did not define a unified build system and left it up to the implementors to decide.

In addition, if it’s absolutely necessary to integrate with a C project, you can do so via build scripts and the CC crate.

As a footnote, Cargo is actually not mandatory, as Google has demonstrated with Bazel. However, I very much do not recommend using anything other than Cargo for Rust.

Concurrency is safe and easy

Serial programs have X problems, and parallel programs have XY problems, where Y is the amount of parallelism you introduce. Parallelism in C is a pain in the ass for sure, and this is one reason I find Go much more suitable to those cases. However, nearly all programs needn’t be parallel. A program which uses poll effectively is going to be simpler, reasonably performant, and have orders of magnitude fewer bugs. “Fearless concurrency” allows you to fearlessly employ bad software design 9 times out of 10.

I used to believe this back when I was limited to just C and C++. However, Rust changed my entire perspective on this issue.

First off, there is no such thing as a single-core machine anymore. You need to take advantage of the multiple cores if you want to write fast software. And Rust is an excellent language to take advantage of parallelism.

Rust’s mutability/immutability rules naturally tie into thread safety: if something is shared, you are only allowed to have an immutable reference to it. Period. This is an effective way to mark data as “shared” or “exclusive” for the purpose of avoiding accidental sharing across threads.

I’d recommend that you (the reader) read the blog post “Fearless Concurrency with Rust” for further elaboration, since it’s a much better explanation than I could ever write.

But most importantly: Rust has plenty of crates that make writing parallel code easy. Pick from anything like tokio for parallel asynchronous code, or rayon for simplistic parallelism. C doesn’t have these things, and you have to start from scratch every time thanks to the lack of an ecosystem.

Rewrite it in Rust (RiiR)

Safety. Yes, Rust is more safe. I don’t really care. In light of all of these problems, I’ll take my segfaults and buffer overflows. I especially refuse to “rewrite it in Rust” - because no matter what, rewriting an entire program from scratch is always going to introduce more bugs than maintaining the C program ever would. I don’t care what language you rewrite it in.

This isn’t something I’ve personally screwed with, but there exists a “transpiler” for rewriting C code in Rust automatically. They’ve actually used it to rewrite Quake 3 in Rust.

Now, it isn’t a rewrite in the sense of converting the code to idiomatic Rust - rather it’s a conversion of C code to the equivalent unsafe Rust code (see this demo). However, it’s a very impressive tool that can be used to piecemeal rewrite unsafe C code into safe Rust, with the heavy lifting of converting the code already done.

Apparently they’re successful enough that they’ve launched a business around converting C code to Rust.

Maybe one day I’ll be able to screw with the transpiler professionally.

Conclusion

Hopefully this posting provides a good bit of material to convince yourself or your coworkers to take another look at migrating to Rust from C.

I’ve made the switch a while back for my personal projects, and I feel much more confident that I can throw together safe and robust code in a fraction of the time than I did when I was primarily working on C.

I feel so much more confident, in fact, that I’ve bet on Rust with my career and accepted a job offer wherein I’d be working on a Rust codebase full-time.

Tags:

Categories:

Updated: