Types and Variables

Every value in a running program occupies bytes in memory. C and Rust both force you to think about types, but Rust does it with stricter rules and stronger guarantees. This chapter maps the C type system onto Rust's so you can translate between them without hesitation.

Integer Types in C

C gives you a menu of integer types whose exact sizes are platform-dependent.

/* int_types.c */
#include <stdio.h>
#include <stdint.h>

int main(void)
{
    char           c  = 'A';
    short          s  = 32000;
    int            i  = 2000000000;
    long           l  = 2000000000L;
    long long      ll = 9000000000000000000LL;
    unsigned int   u  = 4000000000U;

    printf("char:      %d  (size: %zu)\n", c,  sizeof(c));
    printf("short:     %d  (size: %zu)\n", s,  sizeof(s));
    printf("int:       %d  (size: %zu)\n", i,  sizeof(i));
    printf("long:      %ld (size: %zu)\n", l,  sizeof(l));
    printf("long long: %lld (size: %zu)\n", ll, sizeof(ll));
    printf("unsigned:  %u  (size: %zu)\n", u,  sizeof(u));

    return 0;
}
$ gcc -Wall -o int_types int_types.c && ./int_types
char:      65  (size: 1)
short:     32000  (size: 2)
int:       2000000000  (size: 4)
long:      2000000000 (size: 8)
long long: 9000000000000000000 (size: 8)
unsigned:  4000000000  (size: 4)

The C standard only guarantees minimum sizes. int is at least 16 bits, long at least 32, long long at least 64. For exact-width integers, use <stdint.h>: int8_t, uint8_t, int16_t, uint16_t, int32_t, uint32_t, int64_t, uint64_t. For object sizes, use size_t (unsigned, pointer-width).

Driver Prep: The Linux kernel uses fixed-width types extensively: u8, u16, u32, u64, s8, s16, s32, s64. These map directly to the <stdint.h> types. Always prefer fixed-width types in systems code.

Integer Types in Rust

Rust makes the bit width part of the type name. No ambiguity.

// int_types.rs
fn main() {
    let a: i8   = -128;
    let b: u8   = 255;
    let c: i16  = -32_768;
    let d: u16  = 65_535;
    let e: i32  = -2_147_483_647;
    let f: u32  = 4_294_967_295;
    let g: i64  = -9_223_372_036_854_775_807;
    let h: u64  = 18_446_744_073_709_551_615;
    let s: usize = 1024;

    println!("i8:    {}", a);
    println!("u8:    {}", b);
    println!("i32:   {}", e);
    println!("u64:   {}", h);
    println!("usize: {} (size: {} bytes)", s, std::mem::size_of::<usize>());
}

Note the underscores in numeric literals (65_535). Rust allows them for readability.

Type size comparison

+------------+----------+------------------+
| C type     | Rust     | Size (bytes)     |
+------------+----------+------------------+
| char       | i8 / u8  | 1                |
| short      | i16      | 2                |
| int        | i32      | 4                |
| long       | i64*     | 8 (on LP64)      |
| long long  | i64      | 8                |
| size_t     | usize    | pointer-width    |
| ptrdiff_t  | isize    | pointer-width    |
+------------+----------+------------------+
  * C long is 4 bytes on Windows (LLP64), 8 on Linux (LP64).
    Rust i64 is always 8 bytes.

Rust Note: Rust has no type whose size varies by platform except usize and isize, which are always pointer-width. Everything else is fixed. This eliminates an entire class of portability bugs.

Try It: In both C and Rust, print sizeof(long) / size_of::<i64>() and confirm the sizes on your machine.

Floating-Point Types

C provides float (32-bit) and double (64-bit). Rust provides f32 and f64.

/* floats.c */
#include <stdio.h>

int main(void)
{
    float  f = 3.14f;
    double d = 3.141592653589793;

    printf("float:  %.7f  (size: %zu)\n", f, sizeof(f));
    printf("double: %.15f (size: %zu)\n", d, sizeof(d));

    return 0;
}
// floats.rs
fn main() {
    let f: f32 = 3.14;
    let d: f64 = 3.141592653589793;

    println!("f32: {:.7}  (size: {})", f, std::mem::size_of::<f32>());
    println!("f64: {:.15} (size: {})", d, std::mem::size_of::<f64>());
}

Both follow IEEE 754. The behavior is identical at the bit level.

Characters

This is where C and Rust diverge sharply.

C: char is one byte

/* chars_c.c */
#include <stdio.h>

int main(void)
{
    char c = 'A';
    printf("char: %c, value: %d, size: %zu\n", c, c, sizeof(c));
    return 0;
}

C's char is a single byte. It holds ASCII values (0-127). Whether char is signed or unsigned is implementation-defined.

Rust: char is four bytes (a Unicode scalar value)

// chars_rust.rs
fn main() {
    let c: char = 'A';
    let heart: char = '\u{2764}';
    let kanji: char = '\u{6F22}';

    println!("char: {}, value: {}, size: {}", c, c as u32, std::mem::size_of::<char>());
    println!("heart: {}, value: U+{:04X}", heart, heart as u32);
    println!("kanji: {}, value: U+{:04X}", kanji, kanji as u32);
}

Rust Note: Rust's char is a Unicode scalar value and always occupies 4 bytes. This is fundamentally different from C's 1-byte char. In Rust, u8 is the equivalent of C's char when you want a raw byte.

C char 'A':
  +----+
  | 41 |    1 byte
  +----+

Rust char 'A':
  +----+----+----+----+
  | 41 | 00 | 00 | 00 |    4 bytes (little-endian, Unicode scalar)
  +----+----+----+----+

Booleans

C: Booleans are integers

/* bools_c.c */
#include <stdio.h>
#include <stdbool.h>

int main(void)
{
    bool a = true;
    bool b = false;
    int  n = 42;

    printf("true:  %d (size: %zu)\n", a, sizeof(a));
    printf("false: %d (size: %zu)\n", b, sizeof(b));

    if (n) {
        printf("%d is truthy in C\n", n);
    }
    return 0;
}

In C, true is 1, false is 0, and any integer can be used where a boolean is expected. Zero is false; everything else is true.

Rust: bool is a distinct type

// bools_rust.rs
fn main() {
    let a: bool = true;
    let b: bool = false;
    let n: i32 = 42;

    println!("true:  {} (size: {})", a, std::mem::size_of::<bool>());
    println!("false: {} (size: {})", b, std::mem::size_of::<bool>());

    // if n { } // ERROR: expected `bool`, found `i32`
    if n != 0 {
        println!("{} is non-zero", n);
    }
}

Caution: C's implicit integer-to-boolean conversion is a source of bugs. if (x = 0) (assignment, not comparison) evaluates to false and silently succeeds. Rust rejects this at compile time.

Constants

C: const and #define

/* constants_c.c */
#include <stdio.h>

#define MAX_BUFFER 1024

static const int MAX_RETRIES = 5;

int main(void)
{
    printf("buffer size: %d\n", MAX_BUFFER);
    printf("max retries: %d\n", MAX_RETRIES);
    return 0;
}

#define performs textual substitution -- no type, no scope, no address. const creates a typed, scoped value.

Rust: const and static

// constants_rust.rs
const MAX_BUFFER: usize = 1024;       // compile-time constant, inlined
static MAX_RETRIES: i32 = 5;          // fixed address in memory

fn main() {
    println!("buffer size: {}", MAX_BUFFER);
    println!("max retries: {}", MAX_RETRIES);
}
KeywordCompile-time?Has address?Mutable?
C #definePreprocessorNoN/A
C constNoYesNo
Rust constYesNo (inlined)No
Rust staticNoYesNo*

(*) static mut exists but is unsafe to access.

Rust Note: Rust's const is evaluated at compile time and inlined at every use site. Rust's static lives at a fixed address for the entire program lifetime.

Variable Declaration and Mutability

C: mutable by default

/* mutability_c.c */
#include <stdio.h>

int main(void)
{
    int x = 10;
    x = 20;           /* fine -- variables are mutable by default */

    const int y = 30;
    /* y = 40; */      /* error: assignment of read-only variable */

    printf("x = %d, y = %d\n", x, y);
    return 0;
}

Rust: immutable by default

// mutability_rust.rs
fn main() {
    let x = 10;
    // x = 20;        // error: cannot assign twice to immutable variable

    let mut y = 30;
    y = 40;            // fine -- declared with `mut`

    println!("x = {}, y = {}", x, y);
}

This is the opposite default. In C, you opt into immutability with const. In Rust, you opt into mutability with mut.

Try It: In Rust, try reassigning an immutable variable. Read the compiler error message. Rust error messages are famously helpful -- get used to reading them.

Type Casting and Coercion

C: implicit and explicit casts

/* casting_c.c */
#include <stdio.h>

int main(void)
{
    int    i = 42;
    double d = i;         /* implicit: int -> double */
    int    j = (int)3.99; /* explicit: double -> int (truncation!) */
    char   c = 300;       /* implicit: int -> char (overflow!) */

    printf("d = %f\n", d);
    printf("j = %d\n", j);
    printf("c = %d\n", c);

    return 0;
}

Caution: C silently narrows values. char c = 300 wraps around without error. The compiler may warn with -Wall, but it compiles.

Rust: explicit only

// casting_rust.rs
fn main() {
    let i: i32 = 42;
    let d: f64 = i as f64;          // explicit: i32 -> f64
    let j: i32 = 3.99_f64 as i32;  // explicit: f64 -> i32 (truncates to 3)
    let c: u8 = 300_u16 as u8;     // explicit: wraps to 44

    println!("d = {}", d);
    println!("j = {}", j);
    println!("c = {}", c);
}

Rust requires as for every numeric conversion. No implicit narrowing or widening.

sizeof in C, size_of in Rust

/* sizeof_demo.c */
#include <stdio.h>

int main(void)
{
    printf("char:      %zu bytes\n", sizeof(char));
    printf("int:       %zu bytes\n", sizeof(int));
    printf("long:      %zu bytes\n", sizeof(long));
    printf("double:    %zu bytes\n", sizeof(double));
    printf("void*:     %zu bytes\n", sizeof(void *));

    return 0;
}
// sizeof_demo.rs
use std::mem::size_of;

fn main() {
    println!("i8:    {} bytes", size_of::<i8>());
    println!("i32:   {} bytes", size_of::<i32>());
    println!("i64:   {} bytes", size_of::<i64>());
    println!("f64:   {} bytes", size_of::<f64>());
    println!("bool:  {} bytes", size_of::<bool>());
    println!("char:  {} bytes", size_of::<char>());
    println!("usize: {} bytes", size_of::<usize>());
}

Complete type size reference (64-bit Linux)

+---------------+-------+---------------+-------+
| C type        | Bytes | Rust type     | Bytes |
+---------------+-------+---------------+-------+
| char          |   1   | i8 / u8       |   1   |
| short         |   2   | i16 / u16     |   2   |
| int           |   4   | i32 / u32     |   4   |
| long          |   8   | i64 / u64     |   8   |
| long long     |   8   | i64 / u64     |   8   |
| (none)        |  16   | i128 / u128   |  16   |
| float         |   4   | f32           |   4   |
| double        |   8   | f64           |   8   |
| _Bool         |   1   | bool          |   1   |
| char          |   1   | char          |   4   |
| void*         |   8   | *const T      |   8   |
| size_t        |   8   | usize         |   8   |
+---------------+-------+---------------+-------+

Integer Overflow

C: undefined behavior for signed, wrapping for unsigned

/* overflow_c.c */
#include <stdio.h>
#include <limits.h>

int main(void)
{
    unsigned int u = UINT_MAX;
    printf("UINT_MAX:     %u\n", u);
    printf("UINT_MAX + 1: %u\n", u + 1);  /* wraps to 0 -- defined behavior */

    int s = INT_MAX;
    printf("INT_MAX:      %d\n", s);
    /* s + 1 is UNDEFINED BEHAVIOR for signed integers */
    printf("INT_MAX + 1:  %d\n", s + 1);

    return 0;
}

Caution: Signed integer overflow in C is undefined behavior. The compiler is allowed to assume it never happens, and it may optimize your code in surprising ways based on that assumption.

Rust: panics in debug, wraps in release

// overflow_rust.rs
fn main() {
    let u: u32 = u32::MAX;

    // Use wrapping_add for explicit wrapping:
    let v = u.wrapping_add(1);
    println!("u32::MAX wrapping_add(1) = {}", v);

    // Use checked_add to detect overflow:
    match u.checked_add(1) {
        Some(val) => println!("result: {}", val),
        None      => println!("overflow detected!"),
    }

    // Use saturating_add to clamp at max:
    let w = u.saturating_add(1);
    println!("u32::MAX saturating_add(1) = {}", w);
}
$ rustc overflow_rust.rs && ./overflow_rust
u32::MAX wrapping_add(1) = 0
overflow detected!
u32::MAX saturating_add(1) = 4294967295

Rust Note: Rust gives you four explicit choices for overflow: wrapping_*, checked_*, saturating_*, and overflowing_*. In debug builds, the standard + operator panics on overflow. In release builds, it wraps. There is no undefined behavior.

Quick Knowledge Check

  1. What is the size of char in C versus char in Rust?
  2. What happens when you add 1 to INT_MAX in C? In Rust (debug mode)?
  3. How do you declare a mutable variable in Rust?

Common Pitfalls

  • Assuming int is always 32 bits. The C standard only guarantees at least 16. Use int32_t when you need exactly 32 bits.
  • Forgetting that C's char signedness is implementation-defined. On ARM, char is unsigned. On x86, it is signed. Use signed char or unsigned char to be explicit.
  • Using %d to print size_t. Use %zu. The wrong format specifier is undefined behavior.
  • Implicit narrowing in C. Assigning a long to an int silently truncates. Rust forces you to write as i32.
  • Forgetting mut in Rust. Variables are immutable by default. The compiler error is clear, but it catches newcomers off guard.