Improving usability of having many nearly-identical methods

I'd like to make an observation that calling of these "duplicate" methods is not bad, syntactically.

The syntax of slice.sort_by(closure) is as simple as it can be, and it's pretty readable. slice.sort_unstable() is clear and way more minimal than some hypothetical sort(SortOptions {unstable: true}). Vec::with_capacity(1) is syntactically simpler than some Vec::new(capacity:1).

Individually, they're fine. If sort_by was the only way of sorting, there would be no need to change anything.

The problems these bunches of methods cause are more subtle, and they're problematic in aggregate, not individually.

  • the extra "parameters" like allocator/fallibility/mutability/reverse/closure are not guaranteed to be orthogonal and may not exist for all combinations. So mentally they're more like dealing with a list of methods as long as the number of permutations, rather than as with a single method that has a few parameters.

  • there is no place to concisely document all of the possible variations in one place to get an idea of all possible ways to sort/split/alloc/etc. in a common task. Each method needs its docs to be readable standalone, so the same fragments of the docs need to be duplicated across all the methods (both for basic functionality as well as repeating variants like a custom allocator versions).

  • the sheer number of methods clutters the documentation, table of contents, doc search results, autocomplete lists. This makes some bunches of methods visually take more space and look more prominent than they are.

  • Sorting by name happens to apply some grouping of similarly-named methods, but not always in the best way. The try_ methods get grouped by their fallibility rather than their core functionality, and end up sorted far away from methods with the same stem but with a suffix.

  • Naming inconsistency due to historical reasons. Methods that always returned Result/Option typically don't have explicit try_/_opt naming (File::open vs Path::try_exists). Some methods have both. Some functionality still always panics. String Pattern works with both constants and closures, which makes it a rare case that doesn't need separate _with/_by methods. So when I need to perform "foo" and want a fallible result and lazy evaluation, I can't know up front if I need to call foo, foo_with, try_foo, or try_foo_with.

4 Likes

We probably can't really reduce the need for multiple methods without breaking backwards compatibility (as all of these variants already exist). There may be some ways to reduce the definition boilerplate (e.g. the suggestion from @Mokuz), but in the end all of these variants still exist. And the problem is likely going to get worse when we try to fix it (at least for existing functions).

I think the first step (regardless of how tooling uses this info) would be to group them:

impl<T> [T] {
    // The primary one shown when the variants are collapsed
    #[group("sort", primary)]
    pub fn sort(&mut self)
    where
        T: Ord,
    {...}

    #[group("sort")]
    pub fn sort_by<F>(&mut self, mut compare: F)
    where
        F: FnMut(&T, &T) -> Ordering,
    {...}
}

error hints could display the list of items in the group, as it is likely that one of the other ones is the one that was meant.

rustdoc could group all functions with the same group name under one function (the primary one) and perhaps even link to functions in other types that have the same group name. Additionally it could perhaps collapse an entire group to a single name/member and e.g. show sort*, though the exact name and which function signature to show isn't easy to specify, since there isn't a single group attribute.

Extension: Allow specifying the name it should be shown under, instead of using the primary function name: #[group("sort", primary, display_name="sort*")], that way rustdoc has an easier time to indicate that there are multiple variants of this function.

6 Likes

Yep, the constructor case specifically gets tricky if we also want to ensure that the construction remains optimizable so it can happen in-place. And expressing all those flavors in the type system leads to complex builder types.

1 Like

See this pre-RFC thread:

2 Likes

The overlap between Borrow, AsRef, Deref, and similar inherent methods is unfortunate and also inelegant, but I don't think it's the same kind of problem as methods multiplying for their extra parameters/features. This isn't to say that it shouldn't be improved, but the fix may need to be quite different, e.g. Clippy and Rust-analyzer can suggest the preferred one, or docs for these traits could have a "which borrowing trait is right for me?" self-help guide.

That definitely makes me think that a #[doc(stem = "foo")] might be interesting, so it could automatically cross-link other things with the stem, or group everything under the stem, or something.

3 Likes

This sort of built-in builder pattern thing would be quite a bit neater if we could refer to the args associated type of a Fn* in manner consistent with other traits.

There is a sort of grouping mechanism that is already in the language: multiple impl blocks. Probably they’re not ideal as-is for this particular application (because you’d then have to write generic bounds for each group even if they're the same), but they’re a kind of group that already shows up in rustdoc, and perhaps with some doc formatting improvements and a marker attribute for “this is a family of similar methods”, they might partially solve this problem.

2 Likes

Those have multiple downsides though:

  • Currently they seem somewhat arbitrary
  • Sometimes there are trait bounds applied on the impl block instead of all individual methods and those trait bounds might not match the grouping. You couldn't put everything from feature X in a module and group the methods with similar functions that always exist (e.g. serde and other serialization/encoding options)
  • If some methods are behind feature flags you would have to gate the individual function, which isn't as ergonomic in the imports
  • You cannot group an method on the type with a trait implementation, even though that is likely useful to have (see as_str, as_ref example above).

Does have the advantage of a single place for documenting the entire group though.

2 Likes