Skip to content

ACP: Option::update_or_default #575

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Kivooeo opened this issue Apr 19, 2025 · 5 comments
Open

ACP: Option::update_or_default #575

Kivooeo opened this issue Apr 19, 2025 · 5 comments
Labels
api-change-proposal A proposal to add or alter unstable APIs in the standard libraries T-libs-api

Comments

@Kivooeo
Copy link

Kivooeo commented Apr 19, 2025

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.
@Kivooeo Kivooeo added api-change-proposal A proposal to add or alter unstable APIs in the standard libraries T-libs-api labels Apr 19, 2025
@pitaj
Copy link

pitaj commented Apr 19, 2025

  1. You seem to be unaware of get_or_insert_default:
let mut opt: Option<String> = None;
opt.get_or_insert_default().push_str("Hello");

Calls T::default() only if None. For types like String, this may allocate (e.g., String::new()). Users with expensive defaults can use get_or_insert_with.

nit: String::new() does not allocate.

pub fn update_or_default<F>(&mut self, f: F)
where
    F: FnOnce(T) -> T,
    T: Default,
let mut opt: Option<String> = None;
opt.update_or_default(|mut s| { s.push_str("Hello"); s });
assert_eq!(opt, Some("Hello".to_string()));
  1. The name is misleading, as it makes it sound like the function either inserts default if None or updates the value of Some.

  2. This doesn't look very ergonomic for the cases you describe. It looks like what you really want is one where the function gets a &mut instead:

pub fn or_default_update<F>(&mut self, f: F)
where
    F: FnOnce(&mut T),
    T: Default,
let mut opt: Option<String> = None;
opt.or_default_update(|s| s.push_str("Hello"));
  1. The utility of even that is dubious compared to the get_or_insert_default version above.

You should provide some better examples that actually make use of your version passing it by value.

  1. Finally, don't you want a T or &mut T out of something like this? You just went through all this trouble to make sure it's Some, why would you want to just unwrap it again later?

And if you're going to get a T or &mut T, why not just do the mutation separately?

@Kivooeo
Copy link
Author

Kivooeo commented Apr 19, 2025

@pitaj

Before I want to clarify intention of this method, because it could be not clearly describe in the ACP:
The intention is to apply a function to the T inside an Option<T> if it’s Some(T), or create a T::default() if it’s None and then apply the function, storing the result back in the Option. This combines initialization and transformation in one ergonomic step, especially for cases where the function consumes T to produce a new T

You're right that get_or_insert_default simplifies initializing an Option with T::default() when None. However, it returns a &mut T, which works well for in-place mutations but doesn't cover cases where the transformation involves consuming and replacing the value (e.g., returning a new T from the old one). The proposed update_or_default aims to handle both in-place mutations and value-consuming transformations in a single ergonomic operation, reducing the need for separate initialization and mutation steps.

I very agree that update_or_default needs brainstorming, because it’s honestly more like or_default_transform or replace_or_default (I’ll use update_or_default in examples below for consistency). Your suggestion of or_default_update feels closer, as it emphasizes inserting a default and transforming the value, but I think we can refine it further to capture the “consume-and-replace” intent. I’d love your thoughts on these or other ideas.

Your suggestion for or_default_update taking a closure FnOnce(&mut T) is compelling for in-place mutations, and I agree it’s more ergonomic in many cases. However, there are scenarios where a FnOnce(T) -> T closure is more natural, particularly when the transformation consumes the value or constructs a new one. Let me provide some concrete, real-world-inspired examples to illustrate both cases and why FnOnce(T) -> T can be valuable.

#[derive(Default, Debug, Clone)]
struct Config {
    log_level: u8,
    output_path: String,
}

impl Config {
    fn merge(self, other: PartialConfig) -> Config {
        Config {
            log_level: other.log_level.unwrap_or(self.log_level),
            output_path: other.output_path.unwrap_or(self.output_path),
        }
    }
}

struct PartialConfig {
    log_level: Option<u8>,
    output_path: Option<String>,
}

fn main() {
    let mut config: Option<Config> = None;
    let partial = PartialConfig {
        log_level: Some(2),
        output_path: None,
    };

    // With proposed API:
    config.update_or_default(|c| c.merge(partial));
   
    dbg!(&config); // Result: Some(Config { log_level: 2, output_path: "" })

    // Current approach:
    let c = config.get_or_insert_default();
    *c = c.clone().merge(partial); // Clone is awkward and potentially expensive
    // OR:
    config = Some(config.take().unwrap_or_default().merge(partial)); // Verbose
}

In a CLI application, a Config struct may be incrementally built by merging user-provided options. The Config is stored in an Option<Config>, and we want to merge a new set of options into the existing config or start with a default if none exists

The transition method consumes the GameState, making FnOnce(T) -> T necessary. The proposed API makes this concise and safe.

#[derive(Default)]
struct GameState {
    score: u32,
    level: u8,
}

impl GameState {
    fn transition(self, event: Event) -> GameState {
        match event {
            Event::Score(points) => GameState {
                score: self.score + points,
                level: self.level,
            },
            Event::LevelUp => GameState {
                score: self.score,
                level: self.level + 1,
            },
        }
    }
}

fn main() {
    enum Event {
        Score(u32),
        LevelUp,
    }

    let mut state: Option<GameState> = None;
    let event = Event::Score(100);

    // With proposed API:
    state.or_default_update(|s| s.transition(event));
    // Result: Some(GameState { score: 100, level: 0 })

    // Current approach:
    state = Some(state.take().unwrap_or_default().transition(event)); // Verbose
    // OR:
    let s = state.get_or_insert_default();
    *s = s.clone().transition(event); // Clone is inefficient
}

You raised a great point about whether users want a T or &mut T after the update. In most cases, the Option is mutated in place, and users don’t immediately unwrap it—they continue working with the Option (e.g., in a loop or later logic). The examples above show scenarios where the Option remains Some after the update, and subsequent operations can chain or access it.

Next Steps

Based on your feedback, I propose:

  1. Renaming to update_or_default for clarity.
  2. Keeping FnOnce(T) -> T as the primary signature to support value-consuming transformations, with examples like those above.
  3. Exploring a &mut T-returning variant or a separate method for in-place mutations (e.g., or_default_mutate with FnOnce(&mut T)).
  4. Correcting the String::new() allocation note and clarifying performance implications.

@magistau
Copy link

This is related to rust-lang/rfcs#1736, you might want to see its discussion for why it is not easy to replace FnOnce(&mut T) with FnOnce(T) -> T. In particular, the value gets "poisoned" if the closure panics. With that in mind, your suggestion seems to be equivalent to replace_with(opt.get_or_insert_default(), f).

@quaternic
Copy link

There’s no standard method to combine default insertion and in-place transformation in a single, ergonomic operation.

Since it returns &mut T, get_or_insert_default is made for that.

For by-value updates, another alternative is

opt.get_or_insert_default();
opt = opt.take().map(some_update_func);

@Kivooeo
Copy link
Author

Kivooeo commented Apr 27, 2025

@quaternic

Thank you for your feedback!

You're right that get_or_insert_default nicely covers the case of in-place mutation via &mut T, and for many scenarios, it is indeed sufficient and very ergonomic.

However, the main motivation behind update_or_default is to better support by-value transformations — that is, when you want to consume the existing value and produce a new one.
In such cases, get_or_insert_default followed by clone (to avoid mutating through a shared reference) or take().map(f) (after inserting default) can get verbose, error-prone, or inefficient (especially if clone is expensive).
The goal is to make this pattern simpler and safer by offering a single, ergonomic method.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-change-proposal A proposal to add or alter unstable APIs in the standard libraries T-libs-api
Projects
None yet
Development

No branches or pull requests

4 participants