Skip to main content Operators, Expressions, and Casts | IoT Worker

Operators, Expressions, and Casts

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.

Continue Reading