`impl Default` for generically sized arrays? (`[T; N]`)

Maybe we can do a crater run and see how much it impacts? :person_shrugging:

1 Like

We can do without lattice specialization here. We can use marker traits to define the public signature and then exploit that the non-Default case is a noop. But this still needs full specialization since it has to specialize on a public trait, so we still can't have this in core.

#![feature(marker_trait_attr)]
#![feature(specialization)]

#[marker]
trait EmptyOrDefault {}

impl<T: Default, const N: usize> EmptyOrDefault for [T; N] {}
impl<T> EmptyOrDefault for [T; 0] {}


impl<T, const N: usize> local::Default for [T; N] where Self: EmptyOrDefault {
    fn default() -> Self {
        <Self as ArrDefaultSpec<T, N>>::default()
    }
}

trait ArrDefaultSpec<T, const N: usize> {
    fn default() -> [T; N];
}

impl<T, const N: usize> ArrDefaultSpec<T, N> for [T; N] {
    default fn default() -> [T; N] {
        if N == 0 {
            return unsafe { core::mem::zeroed() }
        }
    
        unreachable!("always specialized")
    }
}

impl<T, const N: usize> ArrDefaultSpec<T, N> for [T; N] where T: Default {
    fn default() -> [T; N] {
        core::array::from_fn(|_| Default::default())
    }
}

// just to avoid coherence
mod local {
    pub trait Default {
        fn default() -> Self;
    }
} 
1 Like
1 Like

Looks like there are three actual uses of this in the latest versions of code visible to crater:

It's heartening that in all of these cases, the break was someone directly writing Default::default() when they could have written [] instead – there were no cases where generic code that needed a Default value was picking up the implementation on [T; 0].

I also checked cases where current code is linking against old versions of crates that use the impl (but where the dependency has a more recent version that doesn't use the impl). Most of them were the same situation (someone writing Default::default() where they could have written []), but an old version of foundations attempts to implement a trait that requires Default on [T; 0]. There's also an old version of spacetimedb-sats that uses a call to Default with an annotaiton (in the style of <[T; 0]>::default()), which can't trivially be replaced with [] because there isn't an easy way to specify the type of an empty array.

Also notable is that an old version of bevy-ecs writes Default::default() rather than [] – the code in question is not present in more recent versions, but bevy is used widely enough that many projects have old versions of bevy-ecs in their lock files.

I'm not sure whether this is enough breakage to veto the change, especially given that the edition mechanism probably doesn't help. I guess it might be possible to make calls to Default::default() be interpreted as though they said [] in old editions, in cases where the type is known from the callsite to be an empty array type; this would fix almost all the practical problems from the change (although it would still technically be breaking), but that would be a very weird ad-hoc fix (sort of like specialisation but confined only to callsites where an explicit call were written).

3 Likes

Hmm... this seems interesting. Maybe we can do a

impl<T>  [T; 0] {
    fn default() {
        []
    }
}

as a part-way stopgap measure?

The problem there is that most of the existing uses wrote it as Default::default rather than <[T; 0]>::default, and writing the name of the trait means that an inherent impl won't be used.

1 Like

An if-else impl allowed on traits like this:

impl<T, const N: usize> Default for [T; N] where const {
    if N != 0 {
        T: Default
    }
} {
    fn default() {
        if N != 0 {
            [T::default(); N]
        } else {
            []
        }
    }
}

would be really helpful. I got the idea of a const function that could change type signature dependent on const params. So, essentially we could generate many functions dependent on type signature and we could specify how many of these functions we could generate.

This idea is still very nebulous and I need some help fleshing it out.

The hard part of such proposals is how to do trait resolution with them in generic contexts. For example how would the compiler prove that Default is implemented for [T; N] with T and N generic? Your const block needs knowledge about N to be executed, but N is not known, so this would effectively be stuck. You could argue that it should try all possibilities, and it might work in simple cases, but in general this is effectively equivalent to solving the halting problem.

3 Likes

Clever. I'm not fully aware of the specialization-as-is details, to be fair, even though I think I have a good grasp on specialization-in-the-ideal.

Though to be clear, I brought up lattice specialization for two purposes:

  • it had already been mentioned in passing, and I wanted to clarify what that meant for passers by, and
  • to share the observation that the specialization is sound, despite the specialization on an existing public trait[^1], due to the overlap being source-equivalent to an always-applicable implementation.

[^1] i.e. one that can be implemented potentially dependent on lifetimes

The lattice rule? What is that?

When two specialization overlaps, you must write a new specialization for the intersection of them. In this case, [T; 0] and [T; N] where T: Default overlaps, so you have to write specialization for [T; 0] where T: Default.

This resolve the question of what happens when a type can fit into multiple specializations: there's always a specialization that fits the best.

7 Likes

T-lang was okay with pin! using nightly/internal features specifically because they were okay committing to something like what pin! does[1] being available in perpetuity to accomplish pin!.

I wonder if they'd be willing to do similar for impl Default for [impl Sized; 0] overlapping impl Default for [impl Default; _]. It's a lot more impactful to what we commit to being possible at some level, but it's also the absolute simplest form of "obviously correct" overlap possible — all of the overlapping cases provide the exact same implementation (after MIR inlining).


  1. Specifically, field access to a private field and/or the ability to super let an extended temporary in the macro calling scope. ↩︎

3 Likes

Already said yes: Use const generics for array `Default` impl · Issue #61415 · rust-lang/rust · GitHub

We talked about this question -- using const generics for the Default impl on arrays and supporting the unconditional [T; 0]: Default as a special case (for now) -- in the lang call today. We had appetite for seeing this happen. Please nominate the stabilization PR for us, and we'll FCP that along with libs-api.

… there are seemingly many routes open to being able to generalize this and that he wasn't worried about finding coherent and feasible design options …

5 Likes

I've created a new topic that would perhaps make groundwork on creating a comprehensive framework for lattice specialisation on const generics.

I believe that this would be sound for the reasons mentioned in the topic, and would request comments on the idea.

My idea is that specific instances of a type (for example, 0: u32) will be given a higher priority than generic instances const T: u32. Perhaps later we could incorporate pattern types as a middle ground.

Sidebar: this whole debacle makes me wonder if there should be some defined process by which Rust's current stability guidelines can be violated, either in the form of actually releasing a Rust 2.0 with a very limited subset of breaking changes, or a process for breaking changes to Rust 1.0.

In this case, the actual practical implications of the breaking change are minuscule, and the benefits are IMO huge. I can't emphasize enough how many times I've encountered it in the years since const generics have been released and had to come up with convoluted ways to work around with, including but not limited to writing my own array newtype crate.

2 Likes

There is such a process (albeit not well documented): a crater run to see what breaks, assessing the impact, coordination with the crates affected, FCWs if there are many of them, and a transition period.

@WaffleLapkin and I have initiated the coordination with the crates affected. 4 out of the 8 unarchived root failures responded, 3 positively and 1 negatively (this was due to not signing the CLI).

Did you mean CLA? Otherwise, I don't know what this is saying.

Yep CLA. Typo when I was typing in the dark of night.

2 Likes