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

Problem Statement

Rust programmers often need to update an Option<T> by applying a transformation to its value, initializing it with T::default() if None, and storing the result back in the Option. This pattern is common in configuration parsing, state machines, and incremental updates. Currently, this requires verbose boilerplate, such as:

let mut opt: Option<String> = None;
let s = opt.get_or_insert_with(|| String::new());
s.push_str("Hello);

or:

let mut opt: Option<String> = None;
opt = Some(opt.take().unwrap_or_default() + "Hello");

These approaches are cumbersome, requiring multiple steps or temporary variables, which increases cognitive load and error risk (e.g., forgetting to update the reference). There’s no standard method to combine default insertion and in-place transformation in a single, ergonomic operation.

Motivating Examples or Use Cases

This pattern appears in real-world scenarios, such as:

  1. Configuration Parsing:

    struct Config { verbose: bool, log_level: u8 }
    impl Default for Config { fn default() -> Self { Config { verbose: false, log_level: 0 } } }
    
    let mut config: Option<Config> = None;
    let s = config.get_or_insert_with(|| Config::default());
    s.verbose = true;
    s.log_level += 1;
    // Result: Some(Config { verbose: true, log_level: 1 })
  2. Incremental String Building:

    let mut buffer: Option<String> = None;
    let s = buffer.get_or_insert_with(|| String::new());
    s.push_str("hello ");
    // Later:
    s.push_str("world");
    // Result: Some("hello world")

These examples, inspired by configuration handling in libraries like serde and incremental updates in CLI tools, show verbose multi-step operations that could be simplified.

Solution Sketch

Add a new method, Option::update_or_default, to std::option::Option<T>:

/// If `self` is `Some(t)`, replaces it with `Some(f(t))`;
/// otherwise, inserts `T::default()`, applies `f`, and stores the result.
#[unstable(feature = "option_update_or_default", issue = "none")]
pub fn update_or_default<F>(&mut self, f: F)
where
    F: FnOnce(T) -> T,
    T: Default,
{
    let value = self.take().unwrap_or_default();
    *self = Some(f(value));
}

Usage:

let mut opt: Option<String> = None;
opt.update_or_default(|mut s| { s.push_str("Hello"); s });
assert_eq!(opt, Some("Hello".to_string()));

let mut config: Option<Config> = None;
config.update_or_default(|mut c| { c.verbose = true; c.log_level += 1; c });
assert_eq!(config, Some(Config { verbose: true, log_level: 1 }));

The method combines default insertion and transformation, reducing boilerplate and aligning with ergonomic APIs like HashMap::entry().or_default().

Performance Notes

  • 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.
  • The closure f may allocate (e.g., push_str on String).
  • The method’s logic (take, unwrap_or_default, Some) is allocation-free, adding no overhead beyond T::default() or f.

Alternatives

  1. Use Existing APIs:

    • Combine get_or_insert_with and manual mutation:
      let s = opt.get_or_insert_with(|| String::new());
      s.push_str("Hello");
      This is verbose, requires reference management, and increases error risk.
    • Use take and unwrap_or_default:
      opt = Some(opt.take().unwrap_or_default() + 2);
      This is less readable and still multi-step.

    update_or_default is more ergonomic, combining both steps into a single call.

  2. External Crate:

    • A crate like option-ext could add this method, but Option is a core type, and inclusion in std ensures discoverability and consistency with APIs like unwrap_or_default.
  3. Generalized Method:

    • A update_or_insert_with(default, f) with separate default and transform closures is more flexible but less ergonomic for the common T::default() case. This could be a future extension.

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