String Fixed Array
Tutorial Video
Text description (accessibility)
This video demonstrates the "String Fixed Array" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. Every `String` allocation touches the heap: the allocator must find free memory, update bookkeeping, and the deallocator must run on drop. Key difference from OCaml: 1. **Stack allocation**: Rust's `FixedString<N>` lives entirely on the stack; OCaml's equivalent is always heap
Tutorial
The Problem
Every String allocation touches the heap: the allocator must find free memory, update bookkeeping, and the deallocator must run on drop. In embedded systems (no heap), real-time audio (allocation is forbidden in the audio thread), kernel code, and performance-critical parsers, heap allocation is either impossible or too expensive. A stack-allocated string of fixed maximum size avoids this: the bytes live in the stack frame, Copy semantics are possible, and there is no drop glue. This is the approach of arrayvec, heapless::String, and C's char buf[N].
🎯 Learning Outcomes
const N: usize) to parameterise a struct by capacity at compile time[u8; N] with a separate len: usize fieldfrom_str, push_str, push, as_str, and clearCopy for a fixed-size container — impossible for Stringconst fn new() pattern for compile-time initialisationCode Example
#![allow(clippy::all)]
//! # String Fixed Array — Stack-Allocated Strings
//!
//! Fixed-size strings without heap allocation.
/// Fixed-size string buffer
#[derive(Clone, Copy)]
pub struct FixedString<const N: usize> {
buffer: [u8; N],
len: usize,
}
impl<const N: usize> FixedString<N> {
pub const fn new() -> Self {
Self {
buffer: [0; N],
len: 0,
}
}
pub fn from_str(s: &str) -> Option<Self> {
if s.len() > N {
return None;
}
let mut fs = Self::new();
fs.buffer[..s.len()].copy_from_slice(s.as_bytes());
fs.len = s.len();
Some(fs)
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.buffer[..self.len]).unwrap()
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn capacity(&self) -> usize {
N
}
pub fn push_str(&mut self, s: &str) -> bool {
if self.len + s.len() > N {
return false;
}
self.buffer[self.len..self.len + s.len()].copy_from_slice(s.as_bytes());
self.len += s.len();
true
}
pub fn push(&mut self, c: char) -> bool {
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
self.push_str(s)
}
pub fn clear(&mut self) {
self.len = 0;
}
}
impl<const N: usize> Default for FixedString<N> {
fn default() -> Self {
Self::new()
}
}
impl<const N: usize> std::fmt::Display for FixedString<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl<const N: usize> std::fmt::Debug for FixedString<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FixedString<{}>({:?})", N, self.as_str())
}
}
/// Type aliases for common sizes
pub type String16 = FixedString<16>;
pub type String32 = FixedString<32>;
pub type String64 = FixedString<64>;
pub type String256 = FixedString<256>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create() {
let s = String32::from_str("hello").unwrap();
assert_eq!(s.as_str(), "hello");
assert_eq!(s.len(), 5);
}
#[test]
fn test_too_long() {
let result = String16::from_str("this string is way too long");
assert!(result.is_none());
}
#[test]
fn test_push_str() {
let mut s = String32::new();
assert!(s.push_str("hello"));
assert!(s.push_str(" "));
assert!(s.push_str("world"));
assert_eq!(s.as_str(), "hello world");
}
#[test]
fn test_push_char() {
let mut s = String16::new();
s.push('H');
s.push('i');
assert_eq!(s.as_str(), "Hi");
}
#[test]
fn test_clear() {
let mut s = String32::from_str("hello").unwrap();
s.clear();
assert!(s.is_empty());
}
#[test]
fn test_capacity() {
let s = String64::new();
assert_eq!(s.capacity(), 64);
}
#[test]
fn test_stack_allocated() {
// Verify it fits on stack
let s = String256::from_str("stack allocated").unwrap();
assert_eq!(
std::mem::size_of_val(&s),
256 + std::mem::size_of::<usize>()
);
}
}Key Differences
FixedString<N> lives entirely on the stack; OCaml's equivalent is always heap-allocated.const N: usize is a compile-time parameter — different N values produce distinct types; OCaml would use a runtime n parameter, losing the type-level capacity constraint.Copy derivation**: FixedString<N> is Copy because [u8; N] and usize are Copy; String cannot be Copy because it owns heap memory.const fn new**: Rust's const fn enables compile-time construction (static MY_STR: FixedString<16> = FixedString::new()); OCaml has no equivalent.OCaml Approach
OCaml does not have stack-allocated arrays of statically known size in the same sense. Bytes.create n allocates on the heap. For embedded/no-alloc contexts, OCaml is typically not used; C or Rust are preferred. In standard OCaml, the Bigarray module can use Bigarray.Array1.create Bigarray.int8_unsigned Bigarray.c_layout n for C-layout buffers, but these are still heap-allocated.
(* Closest OCaml equivalent — heap allocated *)
let fixed_string_of n s =
if String.length s > n then None
else Some (Bytes.of_string s)
OCaml's module system can parameterise by capacity using a functor, but there is no const generic equivalent.
Full Source
#![allow(clippy::all)]
//! # String Fixed Array — Stack-Allocated Strings
//!
//! Fixed-size strings without heap allocation.
/// Fixed-size string buffer
#[derive(Clone, Copy)]
pub struct FixedString<const N: usize> {
buffer: [u8; N],
len: usize,
}
impl<const N: usize> FixedString<N> {
pub const fn new() -> Self {
Self {
buffer: [0; N],
len: 0,
}
}
pub fn from_str(s: &str) -> Option<Self> {
if s.len() > N {
return None;
}
let mut fs = Self::new();
fs.buffer[..s.len()].copy_from_slice(s.as_bytes());
fs.len = s.len();
Some(fs)
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.buffer[..self.len]).unwrap()
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn capacity(&self) -> usize {
N
}
pub fn push_str(&mut self, s: &str) -> bool {
if self.len + s.len() > N {
return false;
}
self.buffer[self.len..self.len + s.len()].copy_from_slice(s.as_bytes());
self.len += s.len();
true
}
pub fn push(&mut self, c: char) -> bool {
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
self.push_str(s)
}
pub fn clear(&mut self) {
self.len = 0;
}
}
impl<const N: usize> Default for FixedString<N> {
fn default() -> Self {
Self::new()
}
}
impl<const N: usize> std::fmt::Display for FixedString<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl<const N: usize> std::fmt::Debug for FixedString<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "FixedString<{}>({:?})", N, self.as_str())
}
}
/// Type aliases for common sizes
pub type String16 = FixedString<16>;
pub type String32 = FixedString<32>;
pub type String64 = FixedString<64>;
pub type String256 = FixedString<256>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create() {
let s = String32::from_str("hello").unwrap();
assert_eq!(s.as_str(), "hello");
assert_eq!(s.len(), 5);
}
#[test]
fn test_too_long() {
let result = String16::from_str("this string is way too long");
assert!(result.is_none());
}
#[test]
fn test_push_str() {
let mut s = String32::new();
assert!(s.push_str("hello"));
assert!(s.push_str(" "));
assert!(s.push_str("world"));
assert_eq!(s.as_str(), "hello world");
}
#[test]
fn test_push_char() {
let mut s = String16::new();
s.push('H');
s.push('i');
assert_eq!(s.as_str(), "Hi");
}
#[test]
fn test_clear() {
let mut s = String32::from_str("hello").unwrap();
s.clear();
assert!(s.is_empty());
}
#[test]
fn test_capacity() {
let s = String64::new();
assert_eq!(s.capacity(), 64);
}
#[test]
fn test_stack_allocated() {
// Verify it fits on stack
let s = String256::from_str("stack allocated").unwrap();
assert_eq!(
std::mem::size_of_val(&s),
256 + std::mem::size_of::<usize>()
);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create() {
let s = String32::from_str("hello").unwrap();
assert_eq!(s.as_str(), "hello");
assert_eq!(s.len(), 5);
}
#[test]
fn test_too_long() {
let result = String16::from_str("this string is way too long");
assert!(result.is_none());
}
#[test]
fn test_push_str() {
let mut s = String32::new();
assert!(s.push_str("hello"));
assert!(s.push_str(" "));
assert!(s.push_str("world"));
assert_eq!(s.as_str(), "hello world");
}
#[test]
fn test_push_char() {
let mut s = String16::new();
s.push('H');
s.push('i');
assert_eq!(s.as_str(), "Hi");
}
#[test]
fn test_clear() {
let mut s = String32::from_str("hello").unwrap();
s.clear();
assert!(s.is_empty());
}
#[test]
fn test_capacity() {
let s = String64::new();
assert_eq!(s.capacity(), 64);
}
#[test]
fn test_stack_allocated() {
// Verify it fits on stack
let s = String256::from_str("stack allocated").unwrap();
assert_eq!(
std::mem::size_of_val(&s),
256 + std::mem::size_of::<usize>()
);
}
}
Deep Comparison
String Fixed Array: Comparison
See src/lib.rs for the Rust implementation.
Exercises
Display impl**: Implement fmt::Display for FixedString<N> so it can be used in format! and println!.static EMPTY: FixedString<64> = FixedString::new() and verify it compiles — exploring the limits of const fn.arrayvec**: Replace FixedString<N> with arrayvec::ArrayString<N> (which has Copy, Display, and Deref<Target=str>) and compare the API surface.