Comparing C and Rust network protocol exercises
Almost by accident, it turned out that I implemented a pretty simple, but non trivial task in both C and Rust and blogged about them.
Now that I’m done with both of them, I thought it would be interesting to talk about the differences in the experiences.
The Rust version clocks at exactly 400 lines of code and uses 12 external crates.
The C version has 911 lines of C code and another 140 lines in headers and depends on libuv and openssl.
Both took about two weeks of evenings of me playing around. If I was working full time on that, I could probably do that in a couple of days (but probably more, to be honest).
The C version was very straightforward. The C language is pretty much not there, and on the one hand, it didn’t get in my way at all. On the other hand, you are left pretty much on your own. I had to write my own error handling code to be sure that I got good errors, for example. I had to copy some string processing routines that aren’t available in the standard library, and I had to always be sure that I’m releasing resources properly. Adding dependencies is something that you do carefully, because it is so painful.
The Rust version, on the other hand, uses the default error handling that Rust has (and much improved since the last time I tried it). I’m pretty sure that I’m getting worse error messages than the C version I used, but that is good enough to get by, so that is fine. I had to do no resource handling. All of that is already handled for me, and that was something that I didn’t even consider until I started doing this comparison.
When writing the C version, I spent a lot of time thinking about the structure of the code, debugging through it (to understand what is going on, since I also learned how OpenSSL work) and seeing if things worked. Writing the code and compiling it were both things that I spent very little time on.
In comparison, the Rust version (although benefiting from the fact that I did it second, so I already knew what I needed to do) made me spend a lot more time on just writing code and getting it to compile. In both cases, I decided that I wanted this to be a production worthy code, which meant handling all errors, producing good errors, etc. In C, that was simply a tax that needed to be dealt with. With Rust, that was a lot of extra work.
The syntax and language really make it obvious that you want to do that, but in most of the Rust code that I reviewed, there are a lot of unwrap() calls, because trying to handle all errors is too much of a burden. When you aren’t doing that, your code size balloons, but the complexity of the code didn’t, which was a great thing to see.
What was really annoying is that in C, if I got a compiler error, I knew exactly what the problem was, and errors were very localized. In Rust, a compiler error could stymie me for hours, just trying to figure out what I need to do to move forward. Note that the situation is much better than it used to be, because I eventually managed to get there, but it took a lot of time and effort, and I don’t think that I was trying to explore any dark corners of the language.
What really sucked is that Rust, by its nature, does a lot of type inferencing for you. This is great, but this type inferencing goes both backward and forward. So if you have a function and you create a variable using: HashMap::new(), the actual type of the variable depends on the parameters that you pass to the first usage of this instance. That sounds great, and for the first few times, it looked amazing. The problem is that when you have errors, they compound. A mistake in one location means that Rust has no information about other parts of your code, so it generates errors about that. It was pretty common to make a change, run cargo check and see three of four screen’s worth of errors pass by, and then go into a “let’s fix the next compiler error” for a while.
The type inferencing bit also come into play when you write the code, because you don’t have the types in front of you (and because Rust love composing types) it can be really hard to understand what a particular method will return.
C’s lack of async/await meant that when I wanted to do async operations, I had to decompose that to event loop mode. In Rust, I ended up using tokio, but I think that was a mistake. I should have used the event loop model there as well. It isn’t as nice, in terms of the code readability, but the fact that Rust doesn’t have proper async/await meant that I had a lot more additional complexity to deal with, and that nearly caused me to give up on the whole thing.
I do want to mention that for C, I had run Valgrind a few times to get memory leaks and invalid memory accesses (it found a few, even when I was extra careful). In Rust, the compiler was very strict and several times complained about stuff that if allowed, would have caused problems. I did liked that, but most of the time, it felt like fighting the compiler.
Speaking of which, the compilation times for Rust felt really high. Even with 400 lines of code, it can take a couple of seconds to compile (with cargo check, mind, not full build). I do wonder what it will do with a project of significant size.
I gotta say, though, compiling the C code meant that I would have to test the code. Compiling the Rust code meant that I could run things and they usually worked. That was nice, but at the same time, getting the thing to compile at all was a major chore many times. Even with the C code not working properly, the feedback loop was much shorter with C than with Rust. And some part of that was that I already had a working implementation for most of what I needed, so I had a lot less need to explore when I wrote the Rust code.
I don’t have any hard conclusions from the experience, I like the simplicity of C, and if I had something like Go’s defer to ensure resource disposal, that would probably be enough (I’m aware of libdefer and friends). I find the Rust code elegant (except the async stuff) and the standard library is great. The fact that the crates system is there means that I have very rich access to additional libraries and that this is easy to do. However, Rust is full of ceremony that sometimes seems really annoying. You have to use cargo.toml and extern crate for example.
There is a lot more to be done to make the compiler happy. And while it does catch you sometimes doing something your shouldn’t, I found that it usually felt like busy work more than anything else. In some ways, it feels like Rust is trying to do too much. I would have like to see something less ambitious. Just focusing on one or two concepts, instead of trying to be high and low level language, type inference set to the make, borrow checker and memory safety, etc. It feels like this is a very high bar to cross, and I haven’t seen that the benefits are clearly on the plus side here.
Comments
I got bit by type inference in F# the same way. All of the docs sell you on under-specifying everything and letting the compiler infer all of the details. But over time I switched to almost always specifying types on function parameters to guide the compiler about my actual intent. It fight with the compiler much less and my code is more readable generally.
What edition of Rust are you using? 2018? If not 2018, I'd be curious to know if you would have faced some of the same ergonomic challenges. For example,
extern crate
is no longer needed.Jordan, I'm pretty sure that I'm using 2018, yes. But I had to do a bunch of stuff around crates, macros, etc. It's possible that this was because the docs for those said I should, and I didn't really have to, though.
Maybe now compare this with C++ and Boost IO. The latest C++ version has await support as well in beta.
Sounds like Rust and C were mixed up in this paragraph. Compiler errors in C are very vague, provided that you even get a compiler error. Rust not only provides a detailed explanation of the error, and highlights the code where the error occurred, but also often provides a code snippet for how the error can be solved, and an error code that can be provided to the compiler to provide a thorough explanation of what causes the error.
Very doubtful that this was merely 400 lines of code. If you've imported crates, you're also compiling those crates as well. It may be 400 lines that you've written yourself, but if you import a crate that has 10,000 lines of code, you may end up actually compiling 10,400 lines of code on the first compile. Subsequent compiles don't need to recompile those crates.
Did no one read the official Rust book on the Rust website? Use of
unwrap()
means they didn't bother to read the book, and thus did not set up their functions to return results with the?
operator. That allows you to neatly handle all errors at a single point, higher up the stack.A bit of searching on error handling will quickly show results about crates like
err-derive
, which makes it trivial to to create quality error types from enums.What is the point of a short feedback loop if the code doesn't work? You get to play with garbage really fast? It was around here that I was almost convinced this is satire.
rokob, Assume that you are doing exploration, you aren't sure what you are going to end up doing, so you want to try to write something, and explore the usage of the API and its behavior. That allowed me to get things _done_. In Rust, I had to spend a lot of time just getting the compiler happy, test something, then rip it out because it wasn't correct.
Michael Murphy, You might want to read this post: https://ayende.com/blog/185857-A/using-tls-in-rust-the-complexity-of-async-macros-and-madness
It talks about an utterly infuriating compiler error in Rust that took a lot of time to fix. Yes, there is a lot of attention of Rust compiler errors, and they have significantly improved in the past year or so. But fighting the compiler is something that you do in Rust.
And maybe it is me, but I find most C compiler errors fairly simple to understand. Now, you get get pretty deep in the hole with abusing macros, but at least it is clear where the error actually is in this case.
Here is another case: https://ayende.com/blog/185793-A/using-tls-in-rust-going-to-async-i-o-with-tokio
You might say that I'm using new libraries, and I would be fine by that. If I could _get my code to compile_. If I'm unable to compiler and run my code, and just understanding the compiler errors takes so much time, that is a major issue.
Note that the errors in both posts are very clear. In fact, I would give them A for effort. They just didn't help with the complexity involved.
I wrote 400 LOC, I used a bunch of crates, because the functionality is not builtin to Rust. So far, great. The problem is that running
cargo check
(after building, of course) take a long time.In C, on the other hand, leaving aside that the number of LOC / sec to compile is much higher, all those dependencies would be compiled once and that would be it, never touched again. Empirically, that isn't what I'm seeing when doing this. So as far as I'm concerned, my 400 LOC project takes multiple seconds to compile.
My other project is 600 KLOC in C#, it takes (in release mode). 2nd time for the build is 3 seconds. In Rust, cargo run (debug) costs me 0.9 seconds. That is with no modifications to the file between the runs.
Editing the
main.rs
file to add a line break means thatcargo check
takes 1.2 seconds andcargo build
takes 4.1 seconds. Doing the same on my 600KLOC C# project? In release build, 10 seconds.Those things _matter_.
As for the critique on error handling and ? operator. I linked to my previous posts with the actual work done. I'm aware of both the ? and crates to ease the error handling. See the linked posts. Even with those, you have to do a bunch of work to handle errors that are purely because Rust mechanics.
The only thing I strongly disagree with is
I have the exact opposite feelings.
The rest is either true or a matter of personal preferences.
Rust actually enforces this. It won't compile unless you explicitly type function signatures.
Comment preview