3 unstable releases
0.2.0 | Apr 15, 2023 |
---|---|
0.1.1 | Apr 15, 2023 |
0.1.0 | Apr 15, 2023 |
#194 in No standard library
25KB
247 lines
Utilities for enhanced slicing and indexing
Introduction
The static_slicing
library provides a set of helpful utilities for compile-time checked slicing and indexing.
Background
Not interested in reading all this? Here's a TL;DR: slicing and indexing can be weird in some fairly common situations, so this library makes those things less weird. Now you can skip to the Installation section.
I initially developed this library for use by the Worcester Polytechnic Institute's team in the 2023 MITRE Embedded Capture the Flag competition.
During development of the firmware that was to be installed on an embedded system, an interesting language pain point was identified: insufficient compile-time inference surrounding array slicing and indexing.
For example, this function fails to compile (try it yourself):
fn test() {
let a = [0u8, 1u8, 2u8, 3u8];
let x = a[4]; // there are only 4 elements in `a`! no good...
}
Thankfully, the compiler knows that accessing index 4 of a 4-element array will never succeed, and raises a unconditional_panic
warning that is turned into an error by default.
However, this nearly-identical function compiles just fine, and crashes at runtime (again, try it yourself):
fn test() {
let a = &[0u8, 1u8, 2u8, 3u8];
let x = a[4]; // there are only 4 elements in `a`! no good...
}
The compiler knows that a
is a reference to a 4-element array of u8
- in other words, &[u8; 4]
- yet is no longer able to indicate that anything is potentially wrong.
The compiler's analysis (or lack thereof) of slicing can also be problematic. This function, which a new user might expect to compile, actually doesn't compile:
fn test() {
let a = &[0u8, 1u8, 2u8, 3u8];
let x: &[u8; 2] = &a[1..3]; // *x should be [a[1], a[2]], which is a 2-element array.
}
The compiler's explanation:
3 | let x: &[u8; 2] = &a[1..3];
| -------- ^^^^^^^^ expected array `[u8; 2]`, found slice `[u8]`
| |
| expected due to this
This doesn't work, either:
fn test() {
let a = &[0u8, 1u8, 2u8, 3u8];
let x: &[u8; 2] = &a[1..3].into();
}
However, this does:
fn test() {
let a = &[0u8, 1u8, 2u8, 3u8];
let x: &[u8; 2] = &a[1..3].try_into().unwrap();
}
I am not a huge fan of throwing .try_into().unwrap()
at the end of everything, but I am a huge fan of solving weird problems like this - that's why this library exists!
Installation
To install the current version of static_slicing
, add the following to the dependencies
section of your Cargo.toml
file:
static-slicing = "0.2.0"
no_std
support can be enabled by disabling default features:
static-slicing = { version = "0.2.0", default-features = false }
Note: This library requires Rust 1.59 or later.
How does it work?
Don't want to read this? Skip to the Examples section.
This library introduces two new index types that leverage the power of const generics:
StaticIndex<INDEX>
for getting/setting individual items, and;StaticRangeIndex<START, LENGTH>
for getting/setting ranges of items.
For fixed-size arrays (i.e., [T; N]
where T
is the element type and N
is the array length), the library provides implementations of Index
and IndexMut
that accept both of the static index types. With const panic support (and a bit of type system hacking), invalid indexing operations can be prevented from compiling altogether!
To see exactly how this is done, look at the implementations of IsValidIndex
and IsValidRangeIndex
.
For other types (slices, Vec
s, etc.), use of SliceWrapper
is necessary. SliceWrapper
is an unfortunate workaround for some issues with Rust's orphan rules that I encountered during development. Regardless, SliceWrapper
is designed to appear as "normal" as possible. It can be passed to functions that accept slice references, and essentially appears to be the data it's wrapping.
Performance
Criterion benchmarks are included for fixed-size array indexing. On my computer, which has a 12-core AMD Ryzen 9 5900X, no significant negative performance difference was observed between the built-in indexing facilities and those provided by this library. That is to say, this won't make your code 200000x slower - in fact, any performance impact should be negligible at worst.
Here are the raw numbers from a single benchmark run:
benchmark type | compile-time checked | runtime checked |
---|---|---|
single index | 428.87 ps | 428.07 ps |
range index | 212.19 ps | 212.69 ps |
(In some other runs, the compile-time checked single index was slightly faster than the runtime-checked single index.)
Examples
Here are some examples of how this library can be used.
Example 1: getting an element and a slice from a fixed-size array
use static_slicing::{StaticIndex, StaticRangeIndex};
fn main() {
let x = [513, 947, 386, 1234];
// get the element at index 3
let y = x[StaticIndex::<3>];
// get 2 elements starting from index 1
let z: &[i32; 2] = &x[StaticRangeIndex::<1, 2>];
// this also works:
let z: &[i32] = &x[StaticRangeIndex::<1, 2>];
// prints: y = 1234
println!("y = {}", y);
// prints: z = [947, 386]
println!("z = {:?}", z);
}
Example 2: mutating using static indexes
use static_slicing::{StaticIndex, StaticRangeIndex};
fn main() {
let mut x = [513, 947, 386, 1234];
assert_eq!(x[StaticIndex::<1>], 947);
x[StaticIndex::<1>] = 1337;
assert_eq!(x[StaticIndex::<1>], 1337);
assert_eq!(x, [513, 1337, 386, 1234]);
x[StaticRangeIndex::<2, 2>] = [7331, 4040];
assert_eq!(x[StaticRangeIndex::<2, 2>], [7331, 4040]);
assert_eq!(x, [513, 1337, 7331, 4040]);
}
Example 3: accidental out-of-bounds prevention
use static_slicing::{StaticIndex, StaticRangeIndex};
fn main() {
// read Background to understand why
// `x` being an array reference is important!
let x = &[513, 947, 386, 1234];
// this block compiles...
{
let y = x[5];
let z: &[i32; 2] = &x[2..5].try_into().unwrap();
}
// ...but not this one!
{
let y = x[StaticIndex::<5>];
let z = x[StaticRangeIndex::<2, 3>];
}
}
Example 4: reading from a SliceWrapper
use static_slicing::{StaticIndex, StaticRangeIndex, SliceWrapper};
fn main() {
let x = SliceWrapper::new(&[513, 947, 386, 1234][..]);
{
let y = x[StaticIndex::<3>];
let z = x[StaticRangeIndex::<2, 2>];
// prints: y = 1234
println!("y = {}", y);
// prints: z = [386, 1234]
println!("z = {:?}", z);
}
{
// both of these would panic at runtime
// since they're performing invalid operations.
let _ = x[StaticIndex::<5>];
let _ = x[StaticRangeIndex::<2, 4>];
}
}
Example 5: writing to a SliceWrapper
use static_slicing::{StaticIndex, StaticRangeIndex, SliceWrapper};
fn main() {
// SliceWrappers are mutable under the following conditions:
// 1. the wrapper itself has been declared as mutable, AND;
// 2. the wrapper owns its wrapped data OR contains
// a mutable reference to the data.
let mut x = SliceWrapper::new(vec![513, 947, 386, 1234]);
assert_eq!(x[StaticIndex::<3>], 1234);
x[StaticIndex::<3>] = 1337;
assert_eq!(x[StaticIndex::<3>], 1337);
assert_eq!(x[StaticRangeIndex::<1, 2>], [947, 386]);
x[StaticRangeIndex::<1, 2>] = [5555, 6666];
assert_eq!(x[StaticRangeIndex::<1, 2>], [5555, 6666]);
}
Limitations
There are a few limitations to be aware of:
rust-analyzer
does not seem to be able to show the errors generated by the library's use of const panics.SliceWrapper
cannot perform compile-time checks under any circumstances, even if given an array reference with known length. This may be blocked until specialization is stabilized.- The very existence of
SliceWrapper
is a limitation (in my mind, at least), but it's unlikely to go away unless radical changes are made to the orphan rules and coherence enforcement. (The fundamental problem is that I can't implementIndex(Mut)
for both[T; N]
and[T]
.)
Otherwise, this should be a pretty easy library to deal with - it certainly made things a lot easier for my teammates and I!