Seeking suggestions to reduce code repetition when using const generics

The codebase I'm contributing to uses const generics on the Frame type to improve data locality/performance. However the type is very clumsy to use. I plan to write a lot of functions like percent_grounded which compute stats about the game based on frame data, but I want to find a better way to do this than crazy case statements with nearly identical code. Is there an idiomatic way to reduce repetition like this with/without macros?

fn main() {
    let player_data: [Data; 1] = [Data { grounded: true }];
    let frame: Frame<1> = Frame { player_data };
    let frames = Frames::P1(vec![frame]);
    println!("Percent grounded: {:?}", percent_grounded(&frames));
}

pub struct Data {
    pub grounded: bool
    // ...
}

// Single frame of the game. Const generics allows a 2-player game (most common)
// to take up half the space of a 4-player game (max). This is better for data
// locality/performance
pub struct Frame<const N: usize> {
	pub player_data: [Data; N],
	//pub index: i32,
	// ...
}

// Encapsulates the frame data from a game
pub enum Frames {
	P1(Vec<Frame<1>>),
	P2(Vec<Frame<2>>),
	P3(Vec<Frame<3>>),
	P4(Vec<Frame<4>>),
}

// However this is where the tedium begins. I'd like to avoid having to write
// code like this. Macros could be a decent start.
impl Frames {
    pub fn len(&self) -> usize {
        match self {
			Self::P1(frames) => frames.len(),
			Self::P2(frames) => frames.len(),
			Self::P3(frames) => frames.len(),
			Self::P4(frames) => frames.len(),
		}
    }
    // ...
}

// The Frames enum contains important data, so this sort of repetitiveness
// would permeate everywhere.
pub fn percent_grounded(frames: &Frames) -> Vec<f32> {
    let mut grounded_percentages = Vec::new();
    let total_frames = frames.len();
    match frames {
        Frames::P1(fs) => {
            grounded_percentages.resize(1, 0.0);
            for f in fs {
                if f.player_data[0].grounded {
                    grounded_percentages[0] += 1.0 / total_frames as f32;
                }
            }
        }
        Frames::P2(_fs) => {
            // ... would have to repeat pretty much same code above
        }
        Frames::P3(_fs) => {
            // ...
        }
        Frames::P4(_fs) => {
            // ...
        }
    }
    grounded_percentages
}

(Playground)

Output:

Percent grounded: [1.0]

Errors:

   Compiling playground v0.0.1 (/playground)
    Finished dev [unoptimized + debuginfo] target(s) in 0.69s
     Running `target/debug/playground`

Wouldn't you just keep using and propagating generics in this case? Playground

pub fn percent_grounded(frames: &Frames) -> Vec<f32> {
    match frames {
        Frames::P1(fs) => percent_grounded_impl(fs),
        Frames::P2(fs) => percent_grounded_impl(fs),
        Frames::P3(fs) => percent_grounded_impl(fs),
        Frames::P4(fs) => percent_grounded_impl(fs),
    }
}

fn percent_grounded_impl<const N: usize>(frames: &[Frame<N>]) -> Vec<f32> {
    let mut grounded_percentages = vec![0.0; N];
    let total_frames = frames.len();
    
    for f in frames {
        for (gp, pd) in grounded_percentages.iter_mut().zip(&f.player_data) {
            if pd.grounded {
                *gp += 1.0 / total_frames as f32;
            }
        }
    }

    grounded_percentages
}

You could even replace the returned Vec<f32> with [f32; N].

Ok that makes a lot of sense. Thank you so much!

By the way, this particular enum is a weird way to implement an at most length-N inline vector. It doesn't save any space since the size of an enum is the size of its largest variant (plus the discriminant). So you might as well use ArrayVec<Data, 4> instead, and the whole Frames enum dance could then be eliminated altogether, and so would the generic parameter of Frame itself.

The Frame<N> struct has some other fields I left out to simplify my example. Some of the per-frame data is independent of the number of players.

It doesn't save any space since the size of an enum is the size of its largest variant (plus the discriminant)

Yes, but aren't all the variants of Frames the same size on the stack (the size of one Vec)? It's just the amount of memory on the heap associated with the Vec that would be different.

Changing the Frame<N> struct to a ArrayVec<Data, 4> would basically just get rid of the space-saving gains because ArrayVec would always allocate space enough for all 4 players (even in a 2-player game).

I think there's confusion between the Frame<N> struct and the Frames enum (probably my fault on that front).

size_of::<Frame<2>>() > size_of::<Frame<1>>()

Agreed! But also:

size_of::<Vec<Frame<2>>>() == size_of::<Vec<Frame<1>>>()

Indeed. However, in this case, you could still just put Vec<Data> directly in whatever contains Frames, and size the vector according to the number of players times the number of frames. This would still result in a flat array, and it would not artificially restrict the number of players, nor would it require matching on an enum in every relevant method.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.