bkataru

Gemini, Sonnet 4.5, and Kimi K2 teach C's const and Rust's interior mutability

Introduction

C's const qualifier and Rust's interior mutability solve different problems. C's const is a compile-time restriction that can be bypassed. Rust's interior mutability is a runtime-checked mechanism that maintains memory safety guarantees while allowing mutation through immutable references.


C Const: Compile-Time Restriction

Basic Syntax and Behavior

In C, const prevents modification through a particular identifier:

#include <stdio.h>
#include <stdlib.h>

typedef struct {
    const int id;
    double value;
} SensorReading;

int main(void) {
    // Const members require initialization at declaration
    SensorReading reading = { .id = 0xDEADBEEF, .value = 28.7 };
    
    printf("ID: %d, Value: %.2f\n", reading.id, reading.value);
    
    // Non-const members can be modified normally
    reading.value = 31.2;
    
    // Compilation error: assignment of read-only member
    // reading.id = 0xCAFEBABE;
    
    // Compilation error: assignment of structs with const members
    // SensorReading reading2;
    // reading2 = reading;
    
    return EXIT_SUCCESS;
}

Key behaviors:

Pointer Constness

const char* demonstrates pointer vs pointee distinctions:

#include <stdio.h>

typedef struct {
    uint32_t id;
    const char* name;  // Mutable pointer to immutable data
} Device;

int main(void) {
    Device dev = { .id = 1, .name = "Sensor-A" };
    
    // Valid: repointing to different constant data
    dev.name = "Sensor-B";
    
    // Error: modifying string literal
    // dev.name[0] = 's';
    
    // Casting away const (undefined behavior)
    char** mutable_ptr = (char**)&dev.name;
    *mutable_ptr = "Modified";
    
    return 0;
}

Const Violation and Undefined Behavior

Const can be circumvented, resulting in undefined behavior:

#include <stdio.h>

void demonstrate_ub(void) {
    const int config = 4096;
    int* alias = (int*)&config;  // Cast removes const
    *alias = 2048;               // Undefined behavior
    
    // May print 4096 or 2048 depending on optimization
    printf("Value: %d\n", config);
}

int main(void) {
    demonstrate_ub();
    return 0;
}

The C standard permits compilers to assume const objects never change, enabling optimizations that may not reflect actual memory contents after a cast.


Rust Interior Mutability: Runtime Enforcement

Borrow Checker Limitations

Rust's compile-time borrow checker enforces:

Interior mutability allows controlled mutation through immutable references.

RefCell: Single-Threaded Interior Mutability

use std::cell::RefCell;

pub struct Cache {
    data: i32,
    is_valid: RefCell<bool>,
}

impl Cache {
    pub fn new(value: i32) -> Self {
        Cache { data: value, is_valid: RefCell::new(true) }
    }
    
    // Takes &self but can mutate is_valid
    pub fn invalidate(&self) {
        *self.is_valid.borrow_mut() = false;
    }
    
    pub fn is_valid(&self) -> bool {
        *self.is_valid.borrow()
    }
}

fn main() {
    let cache = Cache::new(42);
    println!("Valid: {}", cache.is_valid());
    cache.invalidate();
    println!("Valid: {}", cache.is_valid());
}

Mechanism: RefCell tracks borrows with a runtime flag. borrow() allows multiple immutable borrows. borrow_mut() requires exclusive access.

Runtime Panic Behavior

Violations cause deterministic panics rather than undefined behavior:

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(vec![1, 2, 3]);
    
    let _borrow1 = data.borrow();
    let _borrow2 = data.borrow();  // OK
    
    // Panics: already borrowed
    // let _mut_borrow = data.borrow_mut();
}

Technical Comparison

Feature C const Rust Interior Mutability
Enforcement Compile-time type checking Runtime borrow flag
Safety No guarantee; undefined behavior if violated Guaranteed; safe panic on violation
Bypass Explicit cast Requires unsafe block
Runtime Cost None Borrow flag overhead
Optimization Compiler assumes immutability Compiler accounts for mutation
Thread Safety Not applicable Single-threaded (RefCell)

Replicating C Patterns in Rust

Immutable ID with Mutable Data

C's per-instance field immutability is achieved through encapsulation:

pub struct Device {
    id: u32,           // Private field
    measurement: f64,
}

impl Device {
    pub fn new(id: u32, measurement: f64) -> Self {
        Device { id, measurement }
    }
    
    // No setter method = immutable from outside
    pub fn id(&self) -> u32 {
        self.id
    }
    
    pub fn measurement(&self) -> f64 {
        self.measurement
    }
    
    pub fn set_measurement(&mut self, value: f64) {
        self.measurement = value;
    }
}

fn main() {
    let mut dev = Device::new(1001, 28.7);
    // dev.id = 1002; // Error: private field
    dev.set_measurement(31.2);
}

Mutable Pointer to Immutable Data (C const char*)

use std::rc::Rc;

pub struct Entity {
    id: u32,
    name: Rc<str>,  // Shared, immutable string
}

impl Entity {
    pub fn new(id: u32, name: &str) -> Self {
        Entity { id, name: Rc::from(name) }
    }
    
    pub fn rename(&mut self, name: &str) {
        self.name = Rc::from(name);
    }
    
    pub fn name(&self) -> &str {
        &self.name
    }
}

fn main() {
    let mut e = Entity::new(1, "Alpha");
    e.rename("Beta");
}

Internal State Mutation Through Immutable References

use std::cell::RefCell;

pub struct Config {
    pub setting: String,
    access_count: RefCell<usize>,
}

impl Config {
    pub fn new(setting: String) -> Self {
        Config { setting, access_count: RefCell::new(0) }
    }
    
    pub fn get(&self) -> String {
        *self.access_count.borrow_mut() += 1;
        self.setting.clone()
    }
    
    pub fn access_count(&self) -> usize {
        *self.access_count.borrow()
    }
}

fn main() {
    let cfg = Config::new("prod".to_string());
    cfg.get();
    cfg.get();
    println!("Accessed: {} times", cfg.access_count());
}

Advanced Example: Shared State

use std::cell::RefCell;
use std::rc::Rc;

pub struct Stats {
    queries: usize,
    hits: usize,
}

impl Stats {
    fn new() -> Self { Stats { queries: 0, hits: 0 } }
    fn record(&mut self, hit: bool) { self.queries += 1; if hit { self.hits += 1; } }
    pub fn hit_rate(&self) -> f64 { self.hits as f64 / self.queries.max(1) as f64 }
}

pub struct Store {
    cache: RefCell<std::collections::HashMap<String, String>>,
    stats: Rc<RefCell<Stats>>,
}

impl Store {
    pub fn new() -> Self {
        Store { cache: RefCell::new(HashMap::new()), stats: Rc::new(RefCell::new(Stats::new())) }
    }
    
    pub fn query(&self, key: &str) -> String {
        if let Some(v) = self.cache.borrow().get(key) {
            self.stats.borrow_mut().record(true);
            return v.clone();
        }
        let result = format!("result: {}", key);
        self.cache.borrow_mut().insert(key.to_string(), result.clone());
        self.stats.borrow_mut().record(false);
        result
    }
    
    pub fn stats(&self) -> Rc<RefCell<Stats>> { Rc::clone(&self.stats) }
}

fn main() {
    let store = Store::new();
    store.query("k1");
    store.query("k1");
    println!("Hit rate: {:.1}%", store.stats().borrow().hit_rate() * 100.0);
}

When to Use Each

Use C const for:

Use Rust interior mutability for:


Conclusion

C's const is a compile-time restriction that can be bypassed, leading to undefined behavior. Rust's interior mutability enforces safety at runtime through mechanisms like RefCell, converting potential memory safety violations into controlled panics.

The key distinction: C relies on programmer discipline with no enforcement; Rust provides explicit, non-bypassable runtime checks that guarantee memory safety. This difference reflects broader design philosophies—C prioritizes zero-cost abstraction, while Rust prioritizes safety with minimal runtime overhead.

#c #rust #slop #systems