Add scoped trait use through a type alias

Motivation

Extension traits have an ergonomics problem as downstream must have explicit use statements to use their inherent methods. This is especially frustrating for fields in data structures. For instance, I have an extension trait for Vec<u8> and String which adds accounting to their allocations. That is, allocating methods have an alternative with additional argument that accumulates the bytes in-use. (None of this is safety critical).

let mut vec = vec![];
vec.extend(iterator);

// becomes
match vec.acc_extend(&mut account, iterator) {
    Ok(()) => { /* all items consumed */ },
    Err(actual) => { /* not enough capacity in account */ },
}

For several reasons this does not make a good wrapper type. Firstly, not all methods on Vec need access to an account and providing all of them would contribute to complexity. Secondly, the account is suppose to be shared but using Arc/Rc to have handles to the same account in multiple containers imposes additional costs and design complexity. Thirdly, for purposes of API compatibility the Vec is just too much of a vocabulary type. Fourth, maintaining a list of forwarded methods is hard for unstable methods; and the account need only be managed at the user boundary.

A similar issue appears in the evolution of primitives. Expanding the set of methods on NonZero* and f32 with more numeric operations in the standard library requires care. It would be nice to prototype or even keep some of the more elaborate methods (e.g. methods from statistics, deterministic soft-float, approximate&fast trigonometry, fast-inverse square root) in a crate. Here we also want to keep the actual data type f32 so it composes with type constructor, nalgebra, gemm implementations, etc. It's common enough to declare just data types that are manipulated elsewhere. There's some mental overhead in importing both the container as well as potentially multiple extension traits for floating point types in all use sites separately.

Idea

Let a type alias declare trait bounds in addition to their base type which are considered in-scope when accessing a place of the designated type.

// In utils.rs
trait AccountVec {
    type Item;

    fn acc_reserve(&mut self, _: &mut Account, sz: usize) -> Result<(), usize>;
}

// as an alias definition
type AccountedVec<T> = Vec<T> + use<crate::utils::AccountVec>;

// in module user.rs
fn using_this(acc: &mut Account) {
    let mut container: utils::AccountedVec<_> = vec![];
    // _no_ use `crate::utils::AccountVec`
    let _ = container.acc_reserve(acc, 100);
}

Also importantly this should work for fields in structs downstream in different crates:

use accounted::{Account, AccountedVec, AccountedHashMap, AccountedOsString};

enum Header { /* not relevant */ }

pub struct DecodingBuffer {
    raw: AccountedVec<u8>,
    headers: AccountedVec<Header>,
    by_name: AccountedHashMap<HeaderName, usize>,
    filename: AccountedOsString,
    account: Account,
}

This would behave much more closely as the type having the extra methods than using extensions through a separately named trait. I think it is significantly discoverable when the type alias is documented. There will be roughly one trait per type (family), but you only discover that type family in the documentation by looking at the implementations for the trait—which is not indexed or found by the search feature especially if the trait is not named by its base type.

More example

Slices can natively only be indexed by usize. But strongly-typed indexes are a nice tool to ensure that the right numbers are used in the same manner, i.e. a separate type for indices that went through a validation of sorts. SliceIndex is unstable so we do that via an extension trait to slices instead. In the interface boundary however it would be nice to denote that relationship:

mod internal {
  trait IndexWith<U>:  ?Sized { /* stable copy of SliceIndex */ }
  
  impl<T> IndexWith<WrapperA> for [T] { … }
  
  type IndexedItem = [ItemForA] + use<IndexWith>;
}

fn this_will_index(slice: &IndexedItem, name: WrapperA) {
    slice.get_with(name);
}

Alternatives

Consider the alternative to the above example:

/* Imports disjoint but semantically only useful together */
use std::ffi::OsString;
use std::collections::HashMap;

use accounted::{Account, AccountVec as _, AccountOsString as _, AccountHashMap as _};

enum Header { /* not relevant */ }

pub struct DecodingBuffer {
    raw: Vec<u8>,
    headers: Vec<Header>,
    by_name: HashMap<HeaderName, usize>,
    filename: OsString,
    account: Account,
}

Another problem with the above approach is that, for a generic trait, it brings the whole trait into scope. However, the feature is intended to improve visibility and the real use might want to consider a single specific impl only. In the index type example above this is obvious:

// Suppose:
// impl IndexWith<WrapperA> for [T]
// impl IndexWith<WrapperB> for [T]

fn this_will_index(slice: &IndexedItem) {
    let wrapper: WrapperB = todo!();
    // Oops, we meant to have allow a `WrapperA` only.
    slice.get_with(wrapper);
}

Alternatively what if could we bring a specific impl into scope for the type, instead of a whole trait? This would come with additional complexity such as error messages and semantics that are not found anywhere else.

type SliceIndexByA<T> = [T] + IndexWith<WrapperA>;

fn this_will_index(slice: &IndexedItem) {
    let wrapper: WrapperB = todo!();

    slice.get_with(wrapper);
    //      ^^^^ Error: mismatched types; expected WrapperA found WrapperB
    //
    // help: trait `IndexWith` which provides `get_with` is implemented but only the
    // impl `IndexWith<WrapperA>` is in scope; perhaps you want to import it
}

Open questions

I'm not sure if this is compatible with the compiler's implementation of type alias. Also does it affect validity, we must not be allowed to create incoherent bounds with this…

How does this interact with type deduction?

3 Likes

As far as I understood, fully-qualified syntax could avoid use-statement:

struct Foo {}

mod foo {
    use super::Foo;

    pub trait Bar {
        fn bar(&self) {}
    }
    
    impl Bar for Foo {}
}

fn baz(foo: &Foo) {
    <Foo as foo::Bar>::bar(foo); // <-- Here
}

playground

Yes, but fully-qualified syntax is not available at the type ascription but just during path look. In terms of usability it may be even worse than use statements in that you have to maintain the same synchronicity in knowing the field / variable in addition to the trait whose methods you want to use; and in addition repeat it more often if there are multiple calls.

Whereas a proper type ascription should let this be the responsibility of the data structure / parameter / var definition point no more than once. Type alias would let you tie the (recommended) trait semantically to the value it is used on, not just by documentation and domain knowledge.

As another example, if my algorithm works with fs::Metadata on Linux I want to express that semantically. I want to say that wherever you get some chosen value from me you should get to treat it both fs::Metadata and std::os::unix::fs::MetadataExt without the consumer having to repeat my assertion that the value applies to a unix system.

I suppose it might be a feature of only type ascription but then I imagine there would be significant amounts of wrappers to be able to annotate fields. Interaction with type deduction is clearly unsolved, added it to open questions.

A possible alternative is to have a newtype with the trait's method as inherent method and that impl Deref<Target = Vec<T>>.

1 Like

So you think part of the initial reasoning against a wrapper type is not valid? Consider interface where the &Vec or metadata is handed to you or which is intended to be called by a user that should not have that overhead. Note for instance the public interface in Add scoped trait use through a type alias, which uses a sealed private trait, but the argument can be a standard vocabulary [T] slice type.

I don't think replicating all as inherent methods is particularly good code. If you take this approach in the crate defining the newtype then … just skip having the trait. I think this line of reasoning ignores why we would have extension traits in the first place. And you can't take this approach outside the defining crate while automatically supporting new methods and changes.

The use of an unsized type, slice, in one example was deliberate. If two-way compatibility of repr(transparent) wrapper would have stronger language support the argument may be different. However, the idea of adding used traits without changing the actual type also intends to sidestep some inherent issues that slow down progress in this direction (e.g. different type-id, interaction with privacy, verifying applicability to the wrapper-type).