Traits and Associated Refinements
#![allow(unused)] extern crate flux_rs; extern crate flux_core; extern crate flux_alloc; use flux_rs::{attrs::*, extern_spec}; use std::ops::Range;
One of Rust's most appealing features is its trait system which lets us decouple descriptions of particular operations that a type should support, from their actual implementations, to enable generic code that works across all implementations of an interface.
Traits are ubiquitous in Rust code. For example,
-
an addition
x + y
is internally represented asx.add(y)
wherex
andy
can be values that implement theAdd
trait, allowing for a uniform way to write+
that works across all compatible types; -
an indexing operation
x[i]
is internally represented asx.index(i)
wherex
can be any value that implements theIndex
trait, andi
a compatible "key", which allows for a standard way to lookup containers at a particular value; -
an iteration
for x in e { ... }
becomeswhile let Some(x) = e.next() { ... }
, where thee
can be any value that implements theIterator
trait, allowing for an elegant and uniform way to iterate over different kinds of ranges and collections.
In this chapter, lets learn how traits pose some interesting puzzles for formal verification, and how Flux resolves these challenges with associated refinements.
First things First
To limber up before we get to traits, lets write a function to return (a reference to) the first element of a slice.
fn get_first_slice<A>(container: &[A]) -> &A { &container[0] }
The method get_first_slice
works just
fine if you call it on non-empty slices,
but will panic at run-time if you try it on
an empty one
fn test_first_slice() { let s0: &[i32] = &[10, 20, 30]; let v0 = get_first_slice(s0); println!("get_first_slice {s0:?} ==> {v0}"); let s1: &[char] = &['a', 'b', 'c']; let v1 = get_first_slice(s1); println!("get_first_slice {s1:?} ==> {v1}"); let s2: &[bool] = &[]; let v2 = get_first_slice(s2); println!("get_first_slice {s2:?} ==> {v2}"); }
Catching Panics at Compile Time
You might recall from this previous chapter that Flux tracks the sizes of arrays and slices.
If you click the check button, you will see that flux
disapproves of get_first_slice
error[E0999]: assertion might fail: possible out of bounds access
|
13 | &container[0];
| ^^^^^^^^^^^^
Specifying Non-Empty Slices
EXERCISE Can you go back and add a flux spec
for get_first_slice
that says that the function
should only be called with non-empty slices? The spec should look something like the below, except
the ...
should be a constraint over size
.
#[spec(fn (container: &[A]{size: ...}) -> &A)]
When you are done, you should see no warnings in get_first_slice
but you will get an error in
test_first_slice
, precisely at the location where we call it with an empty slice, which you
can fix by commenting out or removing the last call...
A Trait to Index
Values
Next, lets write our own little trait to index different kinds of containers1.
pub trait IndexV1<Idx> { type Output:?Sized; fn index(&self, index: Idx) -> &Self::Output; }
The above snippet defines a trait
called IndexV1
that is parameterized by Idx
: the
type used as the actual index. To implement the trait, we must specify
- The
Self
type for the container itself (e.g. a slice, a vector, hash-map, a string, etc.), - The
Idx
type used as the index (e.g. ausize
orString
key, orRange<usize>
, etc), and - The
Output
: an associated type that describes what theindex
method returns, and finally, - The
index
method itself, which takes a reference toself
and anindex
of typeIdx
, and returns a reference to theOutput
.
A Generic, Reusable get_firstV1
We can now write functions that work over any type that implements the Index
trait.
For instance, we can generalize the get_first_slice
method, which only worked on slices,
to the get_firstV1
method will let use borrow the 0
th element of any container
that
implements Index<usize>
.
fn get_firstV1<A, T>(container: &T) -> &A where T: ?Sized + IndexV1<usize, Output = A> { container.index(0) }
Indexing Slices with usize
To use the trait
, we must actually implement it for particular types of interest.
Lets implement a method to index
a slice by a usize
value:
#[trusted] impl <A> IndexV1<usize> for [A] { type Output = A; fn index(&self, index: usize) -> &Self::Output { &self[index] } }
The above code describes an implementation where the
Self
type of the container is a slice[A]
;Idx
type of the index isusize
;Output
returned byindex()
is a (reference to)A
; andindex()
is just a wrapper around the standard library's implementation.
Lets ignore the #[trusted]
for now: it just tells flux to accept this code
without protesting about self[index]
triggering an out-of-bounds error.
Testing get_firstV1
Sweet! Now that we have a concrete implementation for Index
we should be able to test it
fn test_firstV1() { let s0: &[i32] = &[10, 20, 30]; let v0 = get_firstV1(s0); println!("get_firstV1 {s0:?} ==> {v0}"); let s1: &[char] = &['a', 'b', 'c']; let v1 = get_firstV1(s1); println!("get_firstV1 {s1:?} ==> {v1}"); let s2: &[bool] = &[]; let v2 = get_firstV1(s2); println!("get_firstV1 {s2:?} ==> {v2}"); }
Click the run button. Huh?! No warnings??
Of course, the last one will panic.
But why didn't flux warn us about it, like it did with test_first_slice
.
Yikes get_firstV1
is unsafe!
When we directly access a slice as in get_first_slice
,
or test_first_slice
, flux complains about the potential,
in this case, certain, out of bounds access.
But the indirection through get_firstV1
(and index
) has
has laundered the out of bounds access, tricking
flux into unsoundly missing the run-time error!
We're in a bit of a pickle.
The Index
trait giveth the ability to write generic
code like get_firstV1
, but apparently taketh away the
ability to catch panics at compile-time.
Surely, there is a way to use traits without giving up on compile-time verification...
The Challenge: How to Specify Safe Indexing, Generically
Clearly we should not call get_firstV1
with empty slices.
The method get_firstV1
wants to access the 0
-th element
of the container, and will crash at run-time if the 0
th element
does not exist, as is the case with an empty slice.
But the puzzle is this: how do we specify
"the 0
-th element exists" for any
generic container
that implements Index
?
Associated Refinements
Flux's solution to this puzzle is to borrow a page from Rust's own playbook.
Lets revisit the definition of the Index
trait:
pub trait IndexV1<Idx> {
type Output:?Sized;
fn index(&self, index: Idx) -> &Self::Output;
}
In the above, Output
is an associated type for the Index
trait that
specifies what the index
method returns. For instance, in our implementation
of Index<usize>
for slices [A]
, the Output
is A
.
Inspired this idea, Flux extends traits with the ability to specify
associated refinements that can describe the values accepted
and returned by the trait's methods.
Valid Indexes
Thus, we can extend the trait with an associated refinement
that specifies when an index is valid
for a container.
Lets do so by defining the Index
trait as:
#[assoc(fn valid(me: Self, index: Idx) -> bool)] pub trait Index<Idx: ?Sized> { type Output: ?Sized; #[spec(fn(&Self[@me], idx: Idx{ Self::valid(me, idx) }) -> &Self::Output)] fn index(&self, idx: Idx) -> &Self::Output; }
There are two new things in our new version of Index
.
1. Declaration
First, the assoc
attribute declares2 the associated refinement:
a refinement level function named valid
, that
- takes as inputs, the
Self
type of the container and theIdx
type of the index, and - returns a
bool
which indicates if theindex
is valid for the container.
2. Use
Next, the spec
attribute refines the index
method to say that it should only be
passed an idx
that is valid for the me
container, where valid
is the associated
refinement declared above. The notation Self::valid(me, idx)
is a
way to refer to the valid
associated refinement, similar to
how Self::Output
is used to refer to the Output
associated type.
A Safe (and Generic, Reusable) get_first
We can now write functions that work over any type that implements the Index
trait,
but where flux will guarantee that index
is safe to call. For example, lets revisit
the get_first
method that returns the 0th element of a container.
// #[spec(fn (&T{ container: T::valid(container, 0) }) -> &A)] fn get_first<A, T>(container: &T) -> &A where T: ?Sized + Index<usize, Output = A> { container.index(0) }
Aha, now flux complains that the above is unsafe because we don't know that container
is actually valid
for the index 0
. To make it safe, we must add (uncomment!) the
flux specification in the line above. This spec says that get_first
can only be called
with a container
that is valid
for the index 0
.
Indexing Slices with usize
Lets now revisit that implementation of for slices using usize
indexes.
#[assoc(fn valid(size: int, index: int) -> bool { index < size })] impl <A> Index<usize> for [A] { type Output = A; #[spec(fn(&Self[@me], idx:usize{ Self::valid(me, idx) }) -> &Self::Output)] fn index(&self, index: usize) -> &Self::Output { &self[index] } }
As with the trait definition, there are two new things in our implementation of Index
for slices.
1. Implementation
First, we provide a concrete implementation of the associated refinement valid
.
Recall that in flux, slices [A]
are represented by their size at the refinement level.
Hence, the implementation of valid
takes as parameters the size
of the slice and the index
, and returns true
exactly if
the index
is less than the size
.
2. Use
As with the trait method, the actual implementation of the index
method has been refined to say that it should only be passed an
idx
that is valid for me
at the specified idx
.3
Testing get_first
Now, lets revisit our clients for get_first
using the new Index
trait.
fn test_first() { let s0: &[i32] = &[10, 20, 30]; let v0 = get_first(s0); println!("get_first {s0:?} ==> {v0}"); let s1: &[char] = &['a', 'b', 'c']; let v1 = get_first(s1); println!("get_first {s1:?} ==> {v1}"); let s2: &[bool] = &[]; let v2 = get_first(s2); println!("get_first {s2:?} ==> {v2}"); }
Hooray! Now, when you click the check button, flux will complain about the
last call to get_first
because the slice s2
is not valid
for the index 0
!
To do so, flux specialized the specification of get_first
(which required
container
to be valid
for 0
) with the actual definition of valid
for
slices (which requires that 0 < size
) and the actual size
for s2
(which is 0
).
As 0 < 0
is false, flux rejects the code at compile time.
Indexing Strings with Ranges
The whole point of the Index
trait is be able to index
different kinds of
containers. Lets see how to implement Index
for str
using Range<usize>
indexes,
which return sub-slices of the string.
#[assoc( fn valid(me: str, index: Range<int>) -> bool { index.start <= index.end && index.end <= str_len(me) } )] impl Index<Range<usize>> for str { type Output = str; #[spec(fn(&Self[@me], idx:Range<usize>{ Self::valid(me, idx) }) -> &Self::Output)] fn index(&self, idx: Range<usize>) -> &Self::Output { &self[idx.start..idx.end] } }
The implementation above, implements Index<Range<usize>>
for str
by
-
Defining the associated refinement
valid
to say that aRange
is valid for a string if thestart
of the range is less than or equal to theend
, and theend
is less than or equal to the length of the string (which we get using the built-instr_len
function); -
Refining the specification of the
index
method to say that it should only be passed anindex
that is valid for the stringme
; and the givenidx
.
Now when we run flux on clients of this implementation, we can see that the first call is a valid sub-slice, but the second is not and hence, is rejected by flux.
fn test_str() { let cat = "caterpillar"; let sub = cat.index(0..6); // OK let sub = cat.index(0..19); // Error }
Flux produces the error pinpointing the problem:
error[E0999]: refinement type error
|
89 | let sub = cat.index(0..19); // Error
| ^^^^^^^^^^^^^^^^ a precondition cannot be proved
|
note: this is the condition that cannot be proved
|
74 | index.start <= index.end && index.end <= str_len(me)
| ^^^^^^^^^^^^^^^^^^^^^^^^
EXERCISE Can you modify the code above so that the second call to index
is accepted by flux?
Indexing Vectors with usize
EXERCISE Let's implement the Index
trait for Vec
using usize
indexes.
The definition of valid
is too permissive, can you modify it so that flux accepts
the below impl
?
#[assoc(fn valid(me: Vec, index: int) -> bool { true })] impl <A:Copy> Index<usize> for Vec<A> { type Output = A; #[spec(fn(&Self[@me], index:usize{ Self::valid(me, index) }) -> &Self::Output)] fn index(&self, index: usize) -> &Self::Output { &self[index] } }
EXERCISE Let's write a client that uses the index
on Vec
to compute a dot-product for two Vec<f64>
. Can you fix the spec
for dot_vec
so flux accepts it?
#[spec(fn(xs: &Vec<f64>, ys: &Vec<f64>) -> f64)] fn dot_vec(xs: &Vec<f64>, ys: &Vec<f64>) -> f64 { let mut res = 0.0; for i in 0..xs.len() { res += xs.index(i) * ys.index(i); } res }
Indexing Vectors with Ranges
EXERCISE Finally, lets extract sub-slices from vectors using Range<usize>
indexes.
Why does flux reject the below impl
? Can you edit the code so flux accepts it?
#[assoc( fn valid(me: Vec, idx: Range<int>) -> bool { true } )] impl <A> Index<Range<usize>> for Vec<A> { type Output = [A]; #[spec(fn(&Self[@me], idx: Range<usize>{ Self::valid(me, idx) }) -> &Self::Output)] fn index(&self, idx: Range<usize>) -> &Self::Output { &self[idx.start..idx.end] } }
Conclusion
In this chapter, we saw how traits can be extended with associated refinements
which let us declare refinements on the inputs and outputs of trait methods
(e.g. valid
indexes) that are then implemented by each implementation of
the trait (e.g. the index is less than the slice size).
Associated refinements turn out to be an extremely useful mechanism, for example, they let us specify properties of commonly used operations like indexing and iteration, and more advanced properties like the semantics of sql queries 4 and the behavior of memory allocators 5.
-
The "real" ones in the standard library have a few more moving parts that would needlessly complicate our explanation of the interaction between traits and formal verification. ↩
-
valid
function is just a declaration: we do not specify an actual body as those will be filled in by the implementors of the trait. We could specify a default body forvalid
e.g. which always returnstrue
, which can be over-ridden i.e. redefined by implementations, but we must be careful about what we choose as the default. ↩ -
By the way, it seems a little silly to repeat the spec for
index
doesn't it? To be sound, Flux checks that the implementation needs to be a subtype of the trait method. We could for example, accept more inputs and produce fewer outputs. But in this case, it is simply a version of the trait specification, specialized to the particularSelf
andIdx
types of the implementation. ↩ -
See section 6.2 of this POPL 2025 paper for more details. ↩
-
See this SOSP 2025 paper for more details. ↩