Repository navigation
Fallible allocation (via Associated Error type) #10
Description
Following up from https://rust-lang.zulipchat.com/#narrow/stream/197181-t-libs.2Fwg-allocators/topic/Collections.20beyond.20box.2C.20allocation.20fallibility . I've done a preliminary rebase of rust-lang/rust#52420 at https://github.com/QuiltOS/rust/tree/allocator-error (old versions are https://github.com/QuiltOS/rust/tree/allocator-error-old, https://github.com/QuiltOS/rust/tree/allocator-error-old-1, etc.)
If we add an associated Err type to Alloc, we can set it to ! to indicate allocation is infallible (panics, aborts, etc). Allocators should be written with a non-empty Alloc::Err, but a AbortAdapter<A> is provided which calls handle_alloc_error so for<A: Alloc> <AbortAdapter<A> as Alloc>::Err = !.
This has many benefits.
-
We get
try_and traditional versions of every collections method that supports fallibility it for no extra cost! This is done by threading the abstract error type. We don't need to duplicate the original method, but can simply do:struct Collection<T, A: Alloc = AbortAdaptor<Global>> { ... } impl<T, A> Collection<T, A> { fn try_foo(&self) -> Result<T, A::Err> { ... } } impl<T> Collection<T> { // A::Err = ! fn foo(&self) -> T { let Ok(x) = self.try_foo(); x } }
The legacy version would just call the non-
try_version and panic-free unwrap theResult<T, !>. Yes we have twice as many methods, but don't have any duplicated logic. -
Any container (or individual method on the container) that can not handle fallible allocation can require
Err = !so as not to hold back the alloc-parameterization and fallible-allocation-recovery of the other containers. I.e. this proposal reduces the risk of refactoring the collection in that there's no all-or-nothing bifurcation of outcomes. -
Polymorphism all the way up. Just as collections are fallibility-polymorphic with the
try_methods(perhaps they should be renamed to indicate the are not always fallible), other crates using collections can also useA::Errto also be fallibility-polymorphic. This avoids an ecosystem split between those that want and don't want to manually deal with allocation failure.
Note that this seems to impose a downside of each collection having to commit to a fallibility up front. But since AbortAdapter can be #[repr(transparent)], we can freely cast/borrow between the two.
Original unclear version for posterity:
If we add an associated Err type to Alloc, we can set it to ! to indicate allocation is infallible (panics, aborts, etc). This has two purposes:
-
We instantly get
try_versions of every method that supports it for free by threading a non-empty error type. The legacy version would just call the non-try_version and unwrap theResult<T, !>. Yes we have twice as many methods, but don't have any duplicated logic. -
Any container (or individual method on the container) that can not handle fallible allocation can require
Err = !so as not to hold back the alloc-parameterization and fallible-allocation-recovery of the other containers.
Activity
[-]Fallible allocation[/-][+]Fallible allocation (via Associated Error type)[/+]glandium commentedon May 3, 2019
The current methods don't return a
Result, how do you propose to reconcile this?Ericson2314 commentedon May 3, 2019
@glandium See the first point: make a bunch of
try_*versions that do. I don't consider this much of a cost, because no plan to support fallible allocation without race conditions and with backwards compat will avoid new methods.glandium commentedon May 3, 2019
The first point kind of was not clear about what it means.
TimDiekmann commentedon May 3, 2019
Where the
try_methods would be defined? In an extension trait? Or change the wholeAlloctrait to add the methods?scottjmaddox commentedon May 4, 2019
I'm not sure how best to go about doing it, but I strongly support Rust adding some support for fallible allocation.
Has there already been discussion about different approaches? Would something like a
FallibleAlloctrait make sense? AndAlloccould be blanket implemented forFallibleAlloc<Error=!>?TimDiekmann commentedon May 4, 2019
I know there are some discussions floating around on github and internals.rlo on fallible allocation, but I cannot provide a link atm., those threads are pretty long and spread all over the platforms.
To me this makes sense, however I'd name them
Alloc<Error = ??>andInfallibleAlloc. Then you provide anAllocimplementation andInfallibleAllocis provided forAlloc<Error = !>.scottjmaddox commentedon May 4, 2019
Yeah, that would probably be preferable. Has that ship already sailed, though? I think I saw discussions about
Allocbeing stabilized. Maybe not yet, though.TimDiekmann commentedon May 4, 2019
The
alloccrate has been stabilized. TheAlloctrait is gated behindallocator_apiand is likely to be changed this way or that.SimonSapin commentedon May 4, 2019
The
Alloctrait (currently unstable) is already fallible: most methods return aResult.The
try_*methods mentioned above refer not to methods of theAlloctrait, but to methods of e.g. collections that internally use heap allocation. For exampleVec::try_pushthat returns anErrinstead of aborting the process when allocation fails. We have an accepted RFC for this (tracking issue: rust-lang/rust#48043) that proposestry_reserveandtry_reserve_exacton variable-size collections. This is implemented, but still unstable mostly because of uncertainty around what the error type should be. This RFC discusses options for extending this pattern to other APIs such astry_push, recognizing that what’s there is a the bare minimum (e.g. you can dovec.try_reserve(1)?; vec.push(x)) but is not ergonomic, but does not propose a concrete solution.Separately, for implementing APIs like
Vec::pushthat are infallible (they don’t return aResult), code like this is a very common pattern:To simplify such code, there was discussion in at least https://internals.rust-lang.org/t/pre-rfc-changing-the-alloc-trait/7487 to have APIs like the
Alloctrait (possibly as another trait that can be implemented for the same types, or as a wrapper type) for infallible allocation. Theallocmethod would returnNonNull<u8>without aResult, and that API would callhandle_alloc_erroritself on allocation failure.While adding an
Errassociated type in theAlloctrait is something we can do, it’s not clear to my why that would be interesting. In particular, for the two topics discussed above:Fallible APIs for collections might want a different error type than the
Alloctrait:When handling an allocation error, some useful information to have is the layout (or at least the size) of the allocation request that failed. (This is why
std::alloc::handle_alloc_errortakes aLayoutparameter.) InVec::try_pushorHashMap::try_insert, that information should be part of the error type so that the called doesn’t have to guess at the collection’s allocation strategy. InAlloc::alloc(&mut self, layout: Layout) -> Result<NonNull<u8>, AllocErr>however, the caller already knows whatLayoutvalue it just passed, and having a zero-size error type is nice as it keeps the entireResultpointer-sized.For the ergonomics of implementing infallible collections APIs,
Result<T, !>is barely an improvement over some otherResult. You’d still need.unwrap()or some future API ofResult<T, !>in order to get at theTvalue. So an associated error type does not remove the ergonomic need for anInfallibleAllocAPI.TimDiekmann commentedon May 5, 2019
Allocators may provide additional information, why an allocation failed. For example a stack allocator could return, that the stack is full, and only
Nbytes are free to be used.Result<T, !>::unwrap()is a noop whileResult<T, AllocErr>::unwrap()requires a branch.SimonSapin commentedon May 6, 2019
This
unwrapis a noop in optimized machine code, but it still needs to be present in source code so it’s less convenient than an API that returns a bareT(or doesn’t return).gnzlbg commentedon May 6, 2019
@Ericson2314 Alternatively, instead of duplicating all methods again as
try_, an associatedAllocHandle::Errtype allows implementors to provide different handles with different error types. For example, for a given allocator likeJemalloc, we can provide two handles, aJemallocFallibleHandle, that properly reports errors, and aJemallocInfallibleHandlethat hasErr = !.This allows users to pick whatever they need when they need it, without having to hope that collections and such properly use
try_.10 remaining items
Ericson2314 commentedon May 6, 2019
@SimonSapin No that's a bad plan because then you loose fallibility polymorphism and we're left with the ecosystem split.
Ericson2314 commentedon May 6, 2019
[FWIW I'm not even sure how important the mixing use-case is. If I care about some error, why would I panic on it some of the time and handler it other times? I think a better idiom for robust software is to never use
AbortAdapterand manuallyunwrapjust theResults which cannot be handled in a better way. This makes the tech debt clear rather than shoving it under the rug. I don't hold this opinion so strongly that I wouldn't implement the casting methods, but I thought I should share it in hopes that we don't sacrifice something I consider incredibly beneficial like fallibility polymorphism for something I consider barely worth it even without that cost.]Ericson2314 commentedon May 6, 2019
I mean the general rust error handling story is:
unwrapis badIf you can't handle the error now, Use
?to escalate the issue to your caller.Even though a bunch of
?and one top levelunwrap, andunwrapeverywhere all have the same behavior, the former is still better for being fewer syntactic uses ofunwrap.I don't see why allocation failures should be at all different.
AbortAdapateris good vis-à-vis 3, but?is still the best. Frankly, I hope most code is already using?so adding a variant to your error type for allocation errors isn't that big of a burden. The cases where one usestry_today become more instances of:"Oh, I could just escalate this problem upstream but let me try again a different way."
"Let me turn this lower-level error into a higher-level domain-specific error so my caller has an easier time handling this separately."
As already happens with
Result-based error handling.SimonSapin commentedon May 6, 2019
What is fallibility polymorphism? Why is it important or useful?
Ericson2314 commentedon May 6, 2019
@SimonSapin Thanks for asking, I thought I hit this in point 3 in the new OP, but now I'm remembering more nuance from things I wrote down years ago but not yet in this thread.
"fallibility polymorphism" is what I'm calling "allocation failure error type polymorphism". It's the ability to write code, be it collections or "application code" or whatever else with a polymorphic error type, so the code can be used by code that never wants to thinks about allocation failure, code that wants to never panic, and everything in between.
If Rust were going 1.0 in 2021, I'd say the collections should always return
Result<_, AllocError>on all their methods. There should be nohandle_alloc_error, no need for an associated error type for this (though it may still be good for allocator-specific diagnostics), and users not wanting to handle allocation failure should use?, which should be ergonomic enough.But Given that the non-
Result-returning methods do exist, I don't propose that. There are two choices:No associated error type. Implement the legacy method with
try_method(..).unwrap_or_else(|| handle_alloc_error()).My plan. Use associated error type and
AbortAdapter. Legacy methods requireA: Alloc<Err = !>.So, contrary to what I wrote in the OP. collections code reuse is not a unique benefit to my plan. (My apologizes, I'll amend.) But there's still the ecosystem split problem with plan 1.
Let's assume that we have some libraries, all of which use collections /
alloc:barthat doesn't care about allocation failurebazthat wants to audit all panicsfoothat wishes too support a range of uses includingbarandbazabove. Howeverfoodoesn't want to bother defining its every method twice because that's annoying and it's not constrained by backwards compatibility, so it just definesResult<_, Err<..>>returning ones whereE<..>includes allocation failure and may or may not have type parameters.(
barandbazare basically my two extremes from above, except skipping the farthest extreme of "never panic" since that's easier to audit.)Here are the problems:
The small problem.
bardoesn't want to use?on the methods fromfoobecause it nowhere-else cares about allocation failure: Maybe it could be cajoled into doing so except it is already happy using the non-Result-returning methods from collections and doesn't want to change.unwrapworks but looks bad, and maybe youunwrapto much by accident.Solution:
Result<_, !>can have a special totalunwrap(like the extension methods in thevoidpackage) that is always safe to use.barcan use that instead to ignore allocation errors while being confident it isn't ignoring anything else.The big problem:
bazwants to usefoo(andcollections) but is worried about itself or some dependency using the fallible methods by mistake. If the legacy methods are always callable, it's too easy to use them by mistake.Solution: With the
AbortAllocatorplan, the legacy functions can requireA = AbortAllocator<A1>. This means the methods cannot be so easily called, and it's easier forbazaudit for the single pair of casting functions. (In itself andfoo.)Extra Credit: If the methods require
AbortAllocator<Global>then iffooexports an alloccator-polymorphic interfacebazcan be confident thatfoodoesn't use the legacy methods because it is impossible to cast fromAtoAbortAllocator<Global>due to parametricity. It doesn't even need to auditfoo's code for this (though it still should for unsafe tricks in general, including those that could get around this), but just it's interface to unsure the cast methods aren't use.SimonSapin commentedon May 6, 2019
I think this proposal is a lot more controversial than you seem to assume.
It’s really not. Most code is in functions that do not return a
Result. In functions that do, the error type might not implementFrom<AllocErr>.scott-maddox commentedon May 6, 2019
Unless I'm misunderstanding @Ericson2314, I think a more clear way to phrase the original suggestion might be something like "library code that is ambivalent about allocation failure should use ?, which should be ergonomic enough." That library code can impl
From<AllocErr>for its error type. The user code that doesn't care about allocation failure should probably justunwrap.SimonSapin commentedon May 6, 2019
Forcing everyone to sprinkle
unwrapeverywhere when they want to useVecis not “ergonomic enough”. (Same with an hypotheticalResult::<T, !>::unwrap_neverthat doesn’t panic, or whatever its name. It still needs to be called.)Remember that I’m responding to this, which seems to be the premise for everything else here:
Ericson2314 commentedon May 7, 2019
@SimonSapin Rust is not pre-1.0, and so I'm not proposing getting rid of those methods. It's not the premise for everything else; in fact it's the opposite. After defining "fallibility polymorphism", I realized it's not the only way to avoid splitting the ecosystem; a single
AllocErris fine *if all allocating methods Result<T, AllocErr>`. I wanted to come clean and say it's not the only way.The example situation below the line is suppose to show very explicitly why no error polymorphism / fallibility polymorphism in the presence of those methods is no good. If you like, skip the top part and read that.
@scott-maddox Yeah that is a lot closer to what I meant. The whole question of splitting or not splitting the ecosystem hinges on what libraries can do to support both users cases.
liballoccollections must support both sorts of methods due to backwards comparability, but it's a lot to expect other libraries to also do so. That's a lot more of a nuisance all the other things mentioned so far.Even user code using
unwrapis no good, though (that is problem 1 in my example below the line).It's way to easy to below up other errors besides allocation failure. But with error isomorphism, libraries like
barwould have methods would returnResult<_, LibraryErrorType<A::Err>>, and user code not caring would useAbortAllocatorso that they end up with aResult<_, LibraryErrorType<!>>. Now there's effectively no allocation. failure left.OK first of all that's still way better than
unwrapbecause there's no risk on panicking on more than allocation failure. Ergonomics is about maintenance not just banging out the first draft.That said, the situation is still far better than even one
unwrap_neverper every allocatingResultmethod. Picking up where I left off responding to @scott-maddox, one only needs to useunwrap_unsafeonce in aimpl From<LibraryErrorType<!>> for UserErrorType. which is otherwise the exact same from impl they would have written anyways (impl From<LibraryErrorType> for UserErrorType) had the library panicked on allocation failure too.gnzlbg commentedon May 7, 2019
I just want to note that the main reason we have a
std::prelude::v1is to be able to do breaking changes to the std lib API if we need to. That is, we could add aprelude::v2with incompatible collection types (and a compatibility shim), that, e.g., makes the allocation methods of the collections returnResult.This would be obviously very painful, e.g.,
probably as painful as upgrading a dependency graph from
futures0.1 tofutures0.3. And as @SimonSapin argues, making all allocation methods returnResultimpacts ergonomics, so AFAICT it isn't a clear win.But if we can't resolve #2 to make type parameters unstable, we might actually have to move the collections to a
prelude::v2anyways (and hopefully just haveprelude::v1exposetype Vec<T> = v2::Vec<T, alloc::Global>;but maybe that doesn't work either and we will end up with incompatible types).SimonSapin commentedon May 7, 2019
The prelude module only contains
pub usereexports.Vecis defined in thestd::vecmodule.I think that
std::new_vec::Vecthat behaves differently fromstd::vec::Vecwould be confusing. If we go with incompatible base types I think they should have a different “base” name likestd::vec::NewVec, so that they’re distinguishable even after being imported into a given scope. Swapping the meaning of a given name in the prelude would be especially bad IMO.This is at best very misleading phrasing.
gnzlbg commentedon May 7, 2019
Sorry, breaking changes to the prelude that users get by default would probably be more accurate.
Ericson2314 commentedon May 7, 2019
@gnzlbg Your zulip thread seems positive so far so I hope #2 works out. Even if we would somehow remove those methods it wouldn't be a breaking change to
Vecitself.Before this thread meanders somewhere else, does my example above in #10 (comment) about ecosystem splitting make sense?
Ericson2314 commentedon May 10, 2019
Closing for #23 because this thread is indeed messy.