One more unrelated thought: there's two kinds of Clone
ing which could be desired: one which produces an independent storage (e.g. for cloning Box
) and one which produces an interchangable storage managing the same handle(s) (e.g. for cloning Arc
). Unfortunately I think we'll need to have both options (i.e. so both Box
and Arc
can use storages).
Box<T, S>: Clone
is kinda weird, and precisely specifying what it needs from cloning a storage. It's satisfied by an independent clone, but that isn't necessary, just sufficient. It's instead just that cloning the allocator must produce a storage which is capable of allocating a new handle independent of the validity of the first handle.
Drafting what I think is sufficient:
Storage
: if the storage implements Clone
, cloning produces an independent new storage where using either storage does not impact the validity of handles or resolved pointers from the other storage.
SharedStorage: Clone + MultipleStorage + SharedMutabilityStorage
: handles from this storage and any of its clones can be used with any of this set of storages. Handles are not invalidated by dropping any of the set until the final clone is dropped.
I think in my original draft with Send + Sync
handles, the idea was that <Handle as Default>::default
would be the dangling()
-equivalent.
Which is why SharedMutabilityStorage
exists, so that those storages can. Alloc/dealloc is still &self
.
This is only (and necessarily) the case for (potentially) inline storages. Stable plus shared-mutable storages lift the requirement that resolved pointers be considered invalid after moving (or uniquely reborrowing) the storage.
The intent isn't that the actual borrow lifetime must necessarily actually be carried. I fully expect/intend that the lexical lifetime to be transmuted/pointer-cast away most of the time; it's the logical lifetime which (potentially) matters, and "this lifetime would necessarily end" is the most straightforward way to communicate when the pointer validity ends (because the former is a strict subset of the latter, and that it isn't just method usage which is included, but moves and unique reborrows of the storage as well).
Mmm, that's a case I hadn't actually thought about in this context. This looks like two incompatible goals here. Both dynamically-inline storage and outline-metadata handles are desirable, and both are "solved" by the same solution of putting more data inline with the handle. (For the former, a boolean flag for inline/outline; for the latter, the pointer metadata.)
Basically, Box<T, S>
requires either that T: ?Sized + MetaSized
and can provide layout to S::resolve
, or that T: ?Sized + ?MetaSized + DynSized
and that S::resolve
works without a layout. (T: ?DynSized
can't be de/allocated in Rust, since their layout is unknowable to Rust.)
There might be a partial solution: the ability to ask the storage to do Layout::for_value_raw
on a handle and pointer metadata.
unsafe trait Storage {
// ...
unsafe fn layout_for<T: ?Sized + ?MetaSized + DynSized>(&self, handle: Self::Handle, meta: <T as Pointee>::Metadata) -> Layout;
}
It's certainly far from a perfect solution â it would require the value's layout to be the originally allocated layout and requires DynSized
to be a thing, plus a careful documentation of borrow/access implications â but it should be functional. (The storage would also probably prefer to only be required to provide a layout usable for resolving and deallocating the handle, so fixed-size storages could just return that constant.)
Nearly; pinning requires that
for pinned data you have to maintain the invariant that its memory will not get invalidated or repurposed from the moment it gets pinned until when drop
is called. Only once drop
returns or panics, the memory may be reused. [...] Notice that this guarantee does not mean that memory does not leak! It is still completely okay to not ever call drop on a pinned element (e.g., you can still call mem::forget
on a Pin<Box<T>>
). In the example of the [intrusive] doubly-linked list, that element would just stay in the list. However you must not free or reuse the storage without calling drop
.
IIRC, there's an open issue on wg-alloc about the need for a clarification about pinning for Allocator
(and the potential need for a PinningAllocator
refinement trait). Specifically, for Box::<T, A>::pin
to be sound, dropping the (last clone of the) allocator is actually allowed to invalidate any allocated pointers, but forget
ting (or leaking) the allocator must result in the remaining valid forever (since the allocator was never dropped), and importantly, this is a 'static
requirement, even if the allocator itself captures a non-'static
lifetime which expires.
I did write a slightly stronger than necessary requirement in that quick draft, because it's a useful sufficient bound. And actually, the StoreStable
there is unsound to be implemented for a borrowing storage, since it's never mentioned that handles (and thus the resolved pointers) are invalidated when the storage is dropped, let alone when the storage is leaked and itself invalidated by lifetime expiry.
It's at least not completely orthogonal, since the ability to use a zero-sized capacity is a property of the storage (only giving out a single size of object).
While yes a perfectly size-optimal ArrayVec
would use a smaller sized integer for length, capacity isn't a matter of using a smaller integer, it's a matter of just not storing it at all since it's statically known. It's a trivial size optimization to be missing. And since most types tend to be pointer-aligned anyway, using a smaller integer for length isn't even going to be a size improvement. Plus, the std Vec
could also potentially use some sort of bounded integer type in the future if that exists to pick the smallest sufficiently sized integer type for the maximum capacity.
You're stuck back in the position where containers which want to be maximally generic aren't able to just be generic over S: Storage
still, and need to instead be generic over some VecLike
trait instead. If that's still the case, I don't think we can claim the Storage
API fully succeeded at its goals.
On the other hand, the primary goal of ArrayVec
is to eliminate the allocation, and perhaps a small bit of suboptimal space usage can just be acceptable. I've never seen anyone upset that Vec<ZST>
is still three usize
big despite being a single usize of meaningful state. (But back on the initial hand, ArrayVec<ZST>
has infinite capacity and is only the length integer big.)
For clarity purposes, this'd look roughly as
struct FixedVec<T, S: Storage, const CAP: usize> {
ptr: S::Handle,
len: usize,
store: A,
_: PhantomData<T>,
}
impl<T, S: Storage, const CAP: usize> FixedVec<T, S, CAP> {
fn try_new_in(store: S) -> Result<Self, AllocError> {
Self {
ptr: store.allocate(Layout::new::<[T; CAP]>())?,
len: 0,
store,
}
}
fn try_push(&mut self, value: T) -> Result<(), T> {
if self.len == CAP { Err(value) } else unsafe {
self.spare_capacity_mut()[0].write(value);
self.len += 1;
}
}
fn Drop::drop(&mut self) {
self.truncate(0);
self.store.deallocate(self.ptr, Layout::new::<[T; CAP]>());
}
}
... And, writing this, I made a new realization I hadn't yet: because the vector capacity also determines if there's been an allocation yet, eliminating the capacity means you have to also allocate up front in new
, reserve a different marker (e.g. len == MAX
or make ptr: Option
), or deallocate every time you hit zero length, instead of the natural base case of a zero-sized non-allocation needing "re"allocation to fit more elements. That starts to push me towards thinking FixedVec
is a distinct type from Vec
.
Plus because of the const fn new
, ArrayVec
storage is kinda a further special case of not just Storage::Handle = ()
but also that de/allocate
is a no-op.
That would look roughly like
struct FixedStorage<S, const LAYOUT: Layout>(S, PhantomData<T>);
impl<S, const LAYOUT: Layout> FixedStorage<S, LAYOUT> {
fn fits(&self, layout: Layout) -> bool {
layout.size() <= LAYOUT.size() && layout.align() <= LAYOUT.align()
}
}
impl<S: Storage, const LAYOUT: Layout> Storage for FixedStorage {
fn allocate(&self, layout: Layout) -> _ {
if !self.fits(layout) { return Err(AllocError); }
self.0.allocate(layout);
}
fn deallocate(&self, handle: S::Handle, _: Layout) {
self.0.deallocate(handle, LAYOUT);
}
// other items elided...
}
// other forwarding impls elided...
Alright, fine...
unsafe trait Storage {
type Capacity<T>: From<usize> + Into<usize> = usize;
// other items elided...
}
// for resizable storage handles, just Capacity = usize
// for fixed size allocation,
struct ConstCapacity<T, const LAYOUT: Layout>(PhantomData<T>);
impl<T, const LAYOUT: Layout> From<usize> for ConstCapacity<T, LAYOUT> {
fn from(_: usize) -> Self { Self(PhantomData) }
}
impl<T, const LAYOUT: Layout> Into<usize> for ConstCapacity<T, LAYOUT> {
fn into(self) -> usize {
LAYOUT.size().checked_div(size_of::<T>()).unwrap_or(usize::MAX)
}
}
Plus you need wording to allow using any layout that "fits" the handle instead of just the layout used for allocation, to permit a layout calculated from a Capacity
. And probably this should be a nonzero type.