Skip to content

ACP: Option::update_or_default #575

Open
@Kivooeo

Description

@Kivooeo

Proposal

Clear Problem Statement

The Rust Option<T> API lacks a concise way to atomically take ownership of the contained value, transform it, and insert a default if it was None. Today, users resort to piecing together multiple methods. For example, Option::get_or_insert_default() (and get_or_insert_with) only returns a mutable reference to a newly-inserted default value, requiring awkward patterns when a by-value replacement is needed. Alternatively, one can call let old = opt.take() (which leaves None) and then set opt = Some(f(old.unwrap_or_default())), but this is verbose and error-prone. Explicit match or if let arms also require duplicating the Some(…) construction. In short, there is no single Option method that cleanly expresses “if Some(x) apply f(x), else set Some(Default::default()).”

This leads to boilerplate and hidden bugs. For instance, forcing users to re-wrap values can cause logic mistakes (forgetting to reassign) or extra moves/clones of T. The absence of a simple update-or-initialize operation forces each crate to reimplement this common pattern in its own way, reducing code clarity.

Motivating Examples

Real-world code frequently needs to update an optional value by taking ownership, or create a default if absent. In every case today the code is either verbose or indirect:

  • Config merging. Suppose we accumulate a configuration in an Option<Config>. On each update we want to merge new settings into the existing Config if present, otherwise start from a default. Current code must do something like:

    // Verbose: take ownership, apply merge, and re-wrap
    config = Some(config.take().map_or_else(Default::default, |mut cfg| {
        cfg.merge(new_settings);
        cfg
    }));

    The map_or_else(Default::default, |cfg| …) pattern is hard to read. With update_or_default, one could write simply:

    config.update_or_default(|mut cfg| { cfg.merge(new_settings); cfg });

    which clearly means “merge into the existing config or default‐initialize it first.”

  • State machine transitions. Consider an Option<State> field in a state machine. Each tick, if there is a current state we want to replace it by the next state value; if there is no state yet, we initialize it. Currently one might write:

    state = Some(match state.take() {
        Some(s) => s.next(),      // transform existing state
        None    => State::default() // start new state
    });

    This match is cumbersome. With update_or_default, it becomes:

    state.update_or_default(|s| s.next());

    making the intent obvious.

  • Incremental data building. Imagine an Option<Vec<T>> accumulating values. On each push, do nothing if None, or append to Vec if Some. Without update_or_default, one commonly writes:

    if let Some(vec) = &mut maybe_vec {
        vec.push(item);
    } else {
        maybe_vec = Some(vec![item]);
    }

    Or equivalently:

    maybe_vec = Some(maybe_vec.take().map_or_else(Vec::new, |mut v| { v.push(item); v }));

    Both forms are lengthy. With the proposed API, it would be:

    maybe_vec.update_or_default(|mut v| { v.push(item); v });

    which is far more concise and expressive.

These examples show that the current idioms for “update or insert default” require either matching and re-wrapping or using take()+map_or_else(), both of which are verbose and can conceal intent. A dedicated method would eliminate that ceremony and reduce errors.

Proposed Solution

I propose adding a method with roughly the signature:

impl<T: Default> Option<T> {
    pub fn update_or_default<F>(&mut self, f: F)
    where F: FnOnce(T) -> T;
}

Semantically, this does: if self is Some(x), replace it with Some(f(x)); if self is None, insert Some(Default::default()). In other words, take ownership of the current value (if any), apply the closure, and store the result; otherwise store the default value. Crucially, the closure f takes its argument by value (FnOnce(T) -> T), allowing arbitrary by-value transformations.

The FnOnce(T) -> T approach is intentional: it lets the closure consume the old T without requiring T: Clone or extra copies. (All closures implement FnOnce, since it represents a one-time call with ownership.) This model is analogous to the replace_with_or_default utility, which “takes ownership of a value at a mutable location, and replaces it with a new value based on the old one”. Like that utility update_or_default would move out the T, apply f, and then put a new T back into the Option.

One could imagine an alternate design using a mutable reference, e.g.

fn or_default_update<F>(&mut self, f: F)
where F: FnOnce(&mut T);

This would insert the default T if None, and then run a closure on &mut T in place. Such a method (perhaps called or_default_update or or_default_modify) would be a counterpart to HashMap::entry().and_modify(). However, it only allows in-place mutation of the value and cannot change its type or replace it wholesale. By contrast, the proposed update_or_default(FnOnce(T) -> T) covers cases where we want to replace the value with a computed new one. I note that both styles have merit: in-place modification vs. replacement. The primary goal here is to support the ownership-taking case, which is not directly addressable by any existing API.

Comparison With Alternatives

  • get_or_insert_default / get_or_insert_with. These existing methods can fill in a missing value with T::default() or f(), but they return a &mut T. For example, opt.get_or_insert_default() gives a mutable reference to the contained T. To perform a value-consuming update, the user would still need to do extra work, e.g. copying or moving out of T. In effect, one might write:

    // Workaround: manually take and reassign
    let mut tmp = std::mem::take(opt.get_or_insert_default());
    *opt.get_or_insert_default() = f(tmp);

    which is clumsy. Unlike update_or_default, these methods do not allow the closure to consume the inner value; they only allow in-place mutation via a borrow. Thus they cannot express “take old T, produce new T” in one step.

  • Using Option::take(). A common pattern is to use take() (which sets the option to None) and then rebuild the Option. For example:

    opt = Some(opt.take().map_or_else(Default::default, |v| f(v)));

    or equivalently opt = Some(opt.take().map(f).unwrap_or_default()). While this works, it is verbose and repeats the insertion logic. It also momentarily leaves opt as None, which may matter if panic occurs. Moreover, it may allocate or clone T::default() explicitly. In practice, this idiom is error-prone and hard to read. (The standard docs describe Option::take() as “takes the value out of the option, leaving a None”, but they do not show the combined usage needed to update.)

  • Option::replace. opt.replace(new) will swap in a new value and return the old Option. This is useful when you already have the new value in hand, but it doesn’t directly help when you only have a function to compute the new value. You would still have to do let old = opt.take(); opt.replace(f(old.unwrap_or_default()));. In other words, it doesn’t reduce any work for the user compared to take(). It also requires constructing the new T before calling replace.

  • External crates (replace_with, etc.). Crates like replace_with offer replace_with_or_default(&mut T, f) which applies a closure or default on panic. These show the general pattern can be done: e.g. replace_with_or_default(dest, f) “temporarily takes ownership of a value…and replace it with a new value”, using T::default() on panic. While instructive, relying on a crate is suboptimal when a simple method could be in std. Moreover, the Option-specific case is simpler to implement and use than the generic replace_with approach.

In summary, each existing alternative either lacks convenience (extra boilerplate) or introduces complexity (borrowing vs owning). None directly address the “update-or-default” idiom in a single call. The proposed update_or_default fills this niche succinctly and safely.

Safety Considerations

Because update_or_default would temporarily move out the contained value (if any), we must consider what happens if the closure f panics. In a naive implementation (let old = opt.take(); let new = f(old); *opt = Some(new);), a panic would leave opt in a None state (and the old value dropped). This is similar to replace_with, which notes: on panic it calls T::default() to avoid leaving the original uninitialized. I recommend that update_or_default follow a similar philosophy to avoid “poisoning” the Option: one could catch unwinds internally or otherwise guarantee that on panic the Option ends up containing T::default() instead of being empty. This ensures the Option remains valid (though possibly holding a default-initialized T). It places the same requirement as other panic-safe APIs: T::default() must not panic, or else the process aborts (double-panic). Overall, the panic-safety of update_or_default should be at least as strong as replace_with_or_default. In practice, panics in f are rare, and users still get a consistent default-initialized Option rather than losing the value entirely.

Naming Considerations

We have several candidate names, each with trade-offs:

  • update_or_default (proposed). This name emphasizes that the method updates the existing value or uses a default if None. It parallels get_or_insert_default. One might think it should perhaps be transform_or_default or replace_or_default, but “update” is succinct and clearly implies in-place change semantics.
  • or_default_transform. This alternative highlights that we take the “or default” branch then transform. It is a bit more cumbersome to say, and deviates from usual Option naming patterns (there is no other or_default_* family).
  • replace_or_default. This suggests replacing the old value or defaulting, but could be confused with Option::replace. It also doesn’t clearly convey that a function/closure is involved.
  • or_default_update (for the FnOnce(&mut T) variant). If an alternative method taking FnOnce(&mut T) were added, a name like or_default_update or or_default_modify could be used (mirroring entry().and_modify). This would read like “or default, then update”.

In my view, update_or_default is the most intuitive for the FnOnce(T) -> T variant, clearly signifying “update the value, or default it”. The shorter name keeps the API surface tidy. I also note that using FnOnce(T) in the signature already distinguishes it; if we later add an FnOnce(&mut T) variant, a different name (or_default_update) would avoid confusion. The exact name can be debated, but it should capture the dual behavior (update or default) and the fact that a transformation is applied.

Summary and Benefits

Adding Option::update_or_default would significantly improve ergonomics for a common pattern. It turns a multi-line idiom into a single, intention-revealing call. This makes code clearer and less error-prone: instead of juggling take(), map_or_else, or get_or_insert, the programmer simply supplies a closure. The API parallels the convenience of HashMap’s entry API (e.g. .and_modify(...).or_insert(...)) but for the ubiquitous Option type. In effect, update_or_default gives Option its own built-in “update-or-insert-default” functionality, akin to how HashMap::entry(key).or_default().and_modify() does for maps.

Overall, this proposal adds expressive power without breaking existing code. It leverages Default::default() as the fallback and embraces ownership semantics (FnOnce(T) -> T) to cover mutable and non-Copy types. The result is more concise and readable code for many scenarios: configuration management, state transitions, incremental data construction, and more. I believe the ergonomic benefits justify this addition to the standard library’s API.

References: Rust’s official Option documentation and related examples; replace_with_or_default crate docs; HashMap::Entry docs for comparison.

Links and Related Work

  • Rust:
    • Option::unwrap_or_default: Extracts T with a default, no in-place transformation.
    • Option::get_or_insert_with: Initializes but requires separate mutation.
    • HashMap::entry().or_default().and_modify(f): Similar in-place update pattern.
  • Other Languages:
    • JavaScript: Map.prototype.set(key, map.get(key) ?? defaultValue) for updating/initializing.
  • Discussions:
    • No prior Internals thread, but this ACP is inspired by ergonomic patterns in std.
    • RFC discussion: RFC.

What Happens Now?

This issue contains an API change proposal (or ACP) and is part of the libs-api team [feature lifecycle]. Once this issue is filed, the libs-api team will review open proposals as capability becomes available. Current response times do not have a clear estimate, but may be up to several months.

Possible Responses

The libs team may respond in various different ways. First, the team will consider the problem (this doesn’t require any concrete solution or alternatives to have been proposed):

  • We think this problem seems worth solving, and the standard library might be the right place to solve it.
  • We think that this probably doesn’t belong in the standard library.

Second, if there’s a concrete solution:

  • We think this specific solution looks roughly right, approved, you or someone else should implement this. (Further review will still happen on the subsequent implementation PR.)
  • We’re not sure this is the right solution, and the alternatives or other materials don’t give us enough information to be sure about that. Here are some questions we have that aren’t answered, or rough ideas about alternatives we’d want to see discussed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    T-libs-apiapi-change-proposalA proposal to add or alter unstable APIs in the standard libraries

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions