For C programmers, the tricky part of Rust operators is not whether +, -, *, and / look familiar. The tricky part is that many conversions C performs automatically do not happen in Rust.
C often allows code like this:
#include <stdint.h>
#include <stddef.h>
uint8_t version = 1;
size_t len = 128;
uint32_t total = version + len;
if (total) {
/* ... */
}
Rust asks you to spell out the type relationship:
let version: u8 = 1;
let len: usize = 128;
let total: usize = version as usize + len;
if total != 0 {
// ...
}
The first adjustment is that type changes must be visible in the code. Control-flow structure builds on top of that.
Arithmetic requires matching types
C performs integer promotions and usual arithmetic conversions:
uint8_t a = 10;
uint32_t b = 20;
uint32_t c = a + b;
a is promoted and the expression compiles.
Rust does not automatically turn u8 into u32:
let a: u8 = 10;
let b: u32 = 20;
let c = a + b; // compile error
Write the conversion:
let c = a as u32 + b;
This is longer, but it removes hidden rules. In protocols, file formats, and length calculations, implicit conversions can easily mix signedness, fixed widths, and platform-dependent sizes.
Do not casually mix usize with field widths
C often mixes size_t and uint32_t:
uint32_t packet_len = header.length;
size_t offset = 4;
size_t end = offset + packet_len;
Rust rejects that direct mix:
let packet_len: u32 = 128;
let offset: usize = 4;
let end = offset + packet_len; // compile error
Decide what the value means: an in-memory index or an external format field.
For slicing, convert to usize:
let end = offset + packet_len as usize;
For writing back to a protocol field, keep the fixed width:
let packet_len: u32 = payload.len() as u32;
That still needs a range decision. Library code is often better with try_from:
let packet_len = u32::try_from(payload.len()).map_err(|_| EncodeError::TooLarge)?;
as converts. It does not check that the value is in range.
bool does not come from integers
C accepts integers as conditions:
if (flags & 0x01) {
enable();
}
Rust if conditions must be bool:
let flags: u32 = 0x01;
if flags & 0x01 {
// compile error
}
Compare explicitly:
if flags & 0x01 != 0 {
enable();
}
Bitwise operators are still &, |, ^, !, <<, and >>, but a bitwise result is an integer, not a condition.
Common C forms become:
if (len) -> if len != 0
if (ptr) -> if !ptr.is_null() // raw pointer case
if (flags&m) -> if flags & m != 0
Rust wants the test to be explicit.
Logical operators only work on bool
C lets && and || work with integer conditions:
if (len && enabled) {
/* ... */
}
Rust requires both sides to be bool:
let len: usize = 10;
let enabled = true;
if len != 0 && enabled {
// ...
}
Short-circuiting still works:
if !data.is_empty() && data[0] == 0x7e {
// ...
}
data[0] is not evaluated for an empty slice.
Assignment is not a useful chained value
C assignment expressions have values:
int a;
int b;
a = b = 0;
if ((n = read(fd, buf, sizeof buf)) > 0) {
/* ... */
}
In Rust, assignment evaluates to (), so C-style chained assignment and assignment inside conditions are not the usual shape:
let mut a = 1;
let mut b = 2;
a = b = 0; // compile error
Write it separately:
b = 0;
a = b;
Read results are usually bound first:
let n = reader.read(&mut buf)?;
if n > 0 {
// ...
}
This is less compact than C, but intermediate values and error paths are clearer.
Blocks can produce values
In C, { ... } is mainly a statement block and a scope. In Rust, a block is also an expression:
let size = {
let header = 4;
let payload = 128;
header + payload
};
The last line without a semicolon is the block’s value.
This affects many Rust patterns. if can also produce a value:
let limit = if debug { 64 } else { 1024 };
Both branches must have the same type:
let limit = if debug { 64 } else { "large" }; // compile error
For a C programmer, the useful model is: many Rust control structures both control execution and return a value, but their types must line up at compile time.
sizeof maps to size_of
C uses sizeof:
size_t n = sizeof(uint32_t);
Rust uses a standard library function:
use std::mem::size_of;
let n = size_of::<u32>();
size_of::<T>() returns usize. It is useful for checking in-memory type sizes, not for designing an external protocol layout. External formats should still define field widths and byte order explicitly.
Use as carefully
Rust as is convenient:
let a: u32 = 300;
let b: u8 = a as u8;
But this can truncate. 300_u32 as u8 does not become 300; it is converted according to the target type.
When an out-of-range value is possible, prefer try_from:
let b = u8::try_from(a).map_err(|_| Error::OutOfRange)?;
A simple rule:
internal calculation known to fit -> as can be acceptable
external input, file fields, protocol lengths -> prefer try_from
Rust does not perform implicit conversions for you. Once you write an explicit conversion, the responsibility is back in your code.
Minimal migration table
C automatic promotion -> Rust first aligns the types
C size_t + uint32_t -> Rust explicitly converts to usize or fixed width
C if (x) -> Rust if x != 0 / is_null / is_empty
C flags & mask -> Rust flags & mask != 0 for a condition
C a = b = 0 -> Rust separates assignments
C sizeof(T) -> Rust size_of::<T>()
C implicit numeric conversion -> Rust as / try_from
C statement block -> Rust blocks can also be expressions
The main habit change is that C hides many conversions inside expression evaluation, while Rust asks you to write type changes down. For libraries and command-line tools, that makes lengths, field widths, indexes, and error paths easier to review.