pagedvector provides PagedVec<T>, a vector whose logical contents are split
into lazily allocated fixed-size pages. A page is allocated only after one of
its values differs from a configured default value.
The crate has an invariant-safe core, read-only collection ergonomics, and
controlled dynamic-length operations. It is useful when page data dominates
metadata and most logical values have a common default. It is not a drop-in
Vec<T> replacement and does not claim to use constant memory for a very
large logical length.
Every in-bounds logical index has a value:
- an unallocated page logically contains only the configured default;
- an allocated page owns concrete storage for exactly its logical number of slots;
- the final page is partial when
lenis not divisible bypage_size; - a page is deallocated as soon as all of its values return to the default.
The current backend has a dense Vec<Option<Page<T>>> page table. Therefore
page data is lazy, while page-table metadata remains proportional to
ceil(len / page_size). For an extremely large logical length with few writes,
that metadata can still be substantial. A sparse page-map backend is a future
performance investigation, not a property of this release.
PagedVec is non-contiguous. It intentionally does not implement range slices,
Deref<Target = [T]>, AsRef<[T]>, or Borrow<[T]>.
The default feature set enables std. The core library also supports
no_std + alloc when default features are disabled:
[dependencies]
pagedvector = { version = "0.2", default-features = false }The final application supplies the global allocator and, where needed, its
panic handler. PagedVec cannot work in allocator-free core-only programs:
it owns a dense page table and page storage. Disabling std does not make that
metadata bounded; the page table remains proportional to
ceil(len / page_size).
Serialization also works without std when its corresponding feature is
enabled:
[dependencies]
pagedvector = { version = "0.2", default-features = false, features = ["serde"] }
# Or: features = ["bincode"]The crate's feature graph is intentionally small:
std(default) enables standard-library integration, includingstd::error::Errorimplementations and optional dependencystdsupport;serdeenables Serde withallocsupport;bincodeenables Serde plus bincode'sallocsupport.
use pagedvector::PagedVec;
let mut values = PagedVec::new(1_000_000, 0_u32, 1_024);
assert_eq!(values.len(), 1_000_000);
assert_eq!(values.get(42), Some(&0));
assert_eq!(values.allocated_page_count(), 0);
values.set(42, 100)?;
assert_eq!(values[42], 100);
assert_eq!(values.non_default_len(), 1);
values.reset(42)?;
assert_eq!(values.allocated_page_count(), 0);
# Ok::<(), pagedvector::IndexOutOfBounds>(())get follows normal collection conventions: it returns None for an
out-of-bounds index. Index<usize> is also available and panics out of bounds.
For in-bounds indices in unallocated pages, both return the configured default.
Mutation is controlled through set, reset, and update:
set(index, value)andreset(index)returnResult<(), IndexOutOfBounds>;update(index, f)invokesfon a detached copy and commits only when the closure returns normally, so a panicking closure leaves the vector unchanged;- no safe API returns
&mut Tor&mut [T], because such references could bypass the counters required to reclaim pages.
The most relevant inspection methods are page_size, page_count,
allocated_page_count, non_default_len, default_value, page_index,
page_offset, and allocated_page. allocated_page exposes only concrete
physical storage; it does not manufacture a default-filled slice for an
unallocated page.
PagedVec exposes three intentionally different read-only views:
- Logical values exist at every in-bounds index.
iter(andIntoIterator for &PagedVec) visits all of them. - Non-default values are the logical values unequal to
default_value.non_default_iteryields their index and reference. - Allocated pages are concrete physical storage.
allocated_pagesandallocated_page_indicesvisit only those pages. An allocated page can still contain some default-valued slots.
use pagedvector::PagedVec;
let values = PagedVec::from_vec(vec![0, 5, 0, 7, 0], 0, 4)?;
assert_eq!(values.iter().copied().collect::<Vec<_>>(), vec![0, 5, 0, 7, 0]);
assert_eq!(
values
.non_default_iter()
.map(|(index, value)| (index, *value))
.collect::<Vec<_>>(),
vec![(1, 5), (3, 7)],
);
assert_eq!(
values
.allocated_pages()
.map(|(index, page)| (index, page.to_vec()))
.collect::<Vec<_>>(),
vec![(0, vec![0, 5, 0, 7])],
);
assert_eq!(values.to_vec(), vec![0, 5, 0, 7, 0]);
# Ok::<(), pagedvector::PagedVecError>(())is_page_allocated(page_index) distinguishes an invalid page index from a
valid but unallocated page. is_allocated(index) does the same for logical
indices, but Some(true) only says the containing page is physical—it does
not mean the exact value is non-default.
from_vec accepts explicit default and page-size policies, and to_vec /
into_vec materialize the logical sequence. The materialization methods clone
values because unallocated slots share one configured default value.
PagedVec can grow and shrink without compromising its sparse page accounting:
push(value)appends one value, allocating physical storage only if the resulting page contains a non-default value;pop()removes and returns the final value, cloning the configured default when that final slot was unallocated;resize(new_len)shrinks or grows using the configured default, unlikeVec::resize, which accepts an explicit fill value;truncate(new_len)only shrinks and is a no-op whennew_len >= len();clear()drops all logical values and changes the length to zero;reset_all()keeps the length but restores every logical slot to the default and releases every physical page;Extend<T>appends an iterator by applyingpushto each item.
Growing with default-valued slots does not allocate data pages, although it can
grow the dense page table. If an allocated partial final page is extended,
resize clones the existing values and configured default into replacement
storage before committing the new length. This gives resize a strong
guarantee for clone panics: the vector is unchanged. push first grows the
logical extent and then stores the new value, so a later panic while storing
that value can leave a canonical, newly appended default-valued slot.
clear, reset_all, and truncate commit their new canonical metadata before
dropping detached page storage. A panic from T::Drop can therefore propagate,
but does not restore stale pages or counters. Extend<T> is repeated push,
so iteration or element panics can leave the already appended prefix present.
use pagedvector::PagedVec;
let mut values = PagedVec::new(0, 0_i32, 4);
values.resize(1_000_000);
assert_eq!(values.allocated_page_count(), 0);
values.extend([0, 3, 0, 4, 0]);
values.push(5);
assert_eq!(values.to_vec(), vec![0, 3, 0, 4, 0, 5]);
assert_eq!(values.pop(), Some(5));
values.truncate(3);
values.reset_all();
assert_eq!(values.to_vec(), vec![0, 0, 0]);
values.clear();
assert!(values.is_empty());The implementation maintains these canonical rules after every safe mutation:
- page size is non-zero;
- logical indices are valid only below
len; - unallocated pages logically contain only the default;
- every allocated page has at least one non-default value;
- each page counter exactly equals its number of non-default values;
- a page with a zero counter is immediately deallocated;
- page-table length is
ceil(len / page_size); - allocated page storage has exactly its logical length, including a partial final page;
- the global non-default counter equals the sum of page counters.
Debug builds check these invariants after every mutation. The test suite also checks them after every operation in a randomized model test.
PagedVec::new panics when page_size is zero. Use try_new with fallible
input; it returns PagedVecError::ZeroPageSize.
Serialization is opt-in:
[dependencies]
pagedvector = { version = "0.2", features = ["serde"] }The bincode feature enables the optional bincode dependency in addition to
serde. Serialization uses a private, versioned representation of logical
fields and page values, never trusted page or global counters. Deserialization
validates page sizes, page count, and page lengths; it recounts values and
normalizes default-only pages. The representation is not yet a stable wire
format.
Equality compares logical length, the configured default, and every logical value. It ignores page size and physical allocation layout; therefore vectors with matching logical contents can compare equal with different page sizes. Vectors with different configured defaults compare unequal even if all current logical values match.
This release removes get_mut, get_page_slice_mut, IndexMut, and all
page-slice/range-slice APIs. It also replaces the panicking get with
Option<&T>, renames page-count methods, makes set fallible, and makes serde
dependencies optional. See CHANGELOG.md for the complete list.
The roadmap is staged deliberately; none of these items are promises of a specific release date.
- Invariant-safe core — safe access, set/reset, canonical counters, correct final-page sizing, tests, CI. Implemented.
- Read-only ergonomics — iteration, non-default iteration, allocated-page
iteration,
contains, materialization, and logical equality documentation. Implemented. - Dynamic length —
push,pop,resize,truncate,clear,reset_all, andextend. Implemented. - Controlled mutation — richer closure updates, an entry API, mutation guards, and transformations, only where bookkeeping remains reliable.
- Serialization stability — compatibility policy, versioned format tests, and documented support guarantees.
- Performance and backends — benchmarks and comparison of the dense page table with sparse page-map backends.
iter_mut, IndexMut, arbitrary range slices, and slice-deref conversions are
not roadmap goals because they conflict with the accounting or layout model.
CI runs on stable Rust and executes:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo check
cargo check --all-features
cargo check --no-default-features
cargo check --no-default-features --features serde
cargo check --no-default-features --features bincode
cargo check --features serde
cargo check --features bincode
cargo check --examples --all-features
cargo test --all-features
cargo test --no-default-features
cargo test --features serde
cargo test --features bincode
cargo test --doc --all-features
cargo doc --no-deps --all-features
cargo package
The MSRV is Rust 1.85. CI checks the no-default-feature library and test suite
on that toolchain, while stable CI additionally checks no_std + alloc on the
host and thumbv7em-none-eabi target.
Licensed under either of MIT or Apache-2.0, at your option.