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:
- Const members must be initialized at declaration
- Direct assignment to const members is a compile error
- Structures with const members cannot use the assignment operator
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:
- Multiple immutable references (
&T) may coexist - Exactly one mutable reference (
&mut T) may exist - Mutable and immutable references cannot coexist
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:
- Documenting intent in APIs
- Enabling compiler optimizations on known-immutable data
- Zero-overhead requirements where safety is managed externally
Use Rust interior mutability for:
- Caching and memoization through immutable references
- Reference counting with internal state
- Logically immutable types with private mutable instrumentation
- Guaranteed memory safety without compile-time borrow conflicts
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.