Should there by an array::zip method?

Recently I needed to take two arrays of the same size and generate another array by mapping their elements in order. My first instinct was to reach for zip method to create an array of tuples and then map that array, like this:

let a = [5, 4, 7, 9];
let b = [3, 2, 6, 1];
let c = a.zip(b).map(|(a, b)| a - b);

But to my surprise, there is no zip method on arrays, and I was forced to use std::array::from_fn for a clunky solution, like this:

...
let c: [_; 4] = std::array::from_fn(|i| a[i] - b[i]);

Is there any reason why a zip method is not implemented for two arrays of the same length?

Most recent relevant change I found: it existed but was removed.

[T; N]::zip is "eager" but most zips are mapped. This causes poor optimization in generated code. This is a fundamental design issue and "zip" is "prime real estate" in terms of function names, so let's free it up again.

3 Likes

The same argument can be made for array::map too. Although, I agree that zip was a niche feature.

I think some form of zip_with might be more plausible. In an eager API, an array of pairs is really not what anyone actually wanted, but if the zip+map were one call -- rather than something in a bad order because of the eagerness -- then it might be ok.

But ideally someone can come up with a clever transformer API of some sort so you can write the transformation as one thing, then apply it to a set of inputs to get what you need, so that like Iterators it composes well and doesn't make unfortunate eager intermediaries. No idea what that would look like, though...

5 Likes

My power's out due to (outer parts of) Hurricane Helene and I felt like it, so Writing a version of array_map yourself isn't too bad, e.g.

use std::{ptr, mem::{MaybeUninit, ManuallyDrop}};

// use C style loop for stable const
pub fn array_zip_const<T, U, const N: usize>(ts: [T; N], us: [U; N]) -> [(T, U); N] {
    let ts = ManuallyDrop::new(ts);
    let us = ManuallyDrop::new(us);
    let mut zip = MaybeUninit::<[(T, U); N]>::uninit();
    for i in 0..N {
        // SAFETY: i < N
        let (t_i, u_i, out) = unsafe {(
            &raw const ts[i],
            &raw const us[i],
            &raw mut (*zip.as_mut_ptr())[i],
        )};
        // SAFETY: ts[i] moved from once, zip[i].0 moved to once
        unsafe { ptr::copy_nonoverlapping(t_i, &raw mut (*out).0, 1) };
        // SAFETY: us[i] moved from once, zip[i].1 moved to once
        unsafe { ptr::copy_nonoverlapping(u_i, &raw mut (*out).1, 1) };
    }
    // SAFETY: zip has been fully initialized
    unsafe { zip.assume_init() }
}

// safer but nonconst due to <[_]>::map
pub fn array_zip<T, U, const N: usize>(ts: [T; N], us: [U; N]) -> [(T, U); N] {
    let mut ts = ts.map(ManuallyDrop::new);
    let mut us = us.map(ManuallyDrop::new);
    let mut zip = [const { MaybeUninit::<(T, U)>::uninit() }; N];
    for i in 0..N {
        // SAFETY: ts[i] taken once, untouched afterwards
        let t = unsafe { ManuallyDrop::take(&mut ts[i]) };
        // SAFETY: us[i] taken once, untouched afterwards
        let u = unsafe { ManuallyDrop::take(&mut us[i]) };
        zip[i].write((t, u));
    }
    // SAFETY: zip has been fully initialized
    unsafe { MaybeUninit::array_assume_init(zip) }
}

…although this is entirely untested since the playground seemed to be down so don't trust it.

(EDIT: fixed silly type errors)

But I concur that this is a stack-copy over-reliance footgun in practice. A theoretical IntoConstSizeIterator would likely be a nicer way to handle "iterator like" methods for arrays.

1 Like

the outage must have just ended, it seems to be working now. Throughout, (at least for me), play.integer32.com seems to have kept working btw, that’s a reasonable alternative to test in cases like this (they even use the same GitHub Gists account, so you can just re-brand a share link back to rust-lang.org and it’ll work).

I do wonder how it compares to just

let mut it = zip(ts, us);
array::from_fn(|_| it.next().unwrap())

I forget how important drain_array_with in core::array::drain - Rust (as opposed to array::IntoIter) was for the zip implementation when it existed...

1 Like

You should "just" need closure combinators:

pub trait Transform<T>: FnMut<(T,)> {
    fn map<F: Transform<Self::U>>(self, f: F) -> impl FnMut(T) -> F::Output {
        move |item| f(self(item))
    }
    fn zip<U, F: Transform<U>>(self, f: F) -> impl FnMut((T, U)) -> (Self::Output, F::Output) {
        move |(item, iuem)| (self(item), f(iuem))
    }
}
impl<T, U, F: FnMut(T) -> U> Transform<T> for F {}

pub use core::convert::identity as start_pipe;

trait Apply<T> {
    type Output<U>;
    fn apply<F: Transform<T>>(self, f: F) -> Self::Output<F::Output>;
}

impl<I: Iterator> Apply<I::Item> for I {
    type Output<U> = impl Iterator<Item=U>;
    fn apply<F: Transform<I::Item>>(self, f: F) -> Self::Output<F::Output> {
        self.map(f)
    }
}
impl<I: Iterator, J: Iterator> Apply<(I::Item, J::Item)> for (I, J) {
    type Output<U> = impl Iterator<Item=U>;
    fn apply<F: Transform<(I::Item, J::Item)>>(self, f: F) -> Self::Output<(I::Output, J::Output)> {
        let (i, j) = self;
        i.zip(j).map(f)
    }
}

// etc for other monadish types

(entirely untested)

1 Like