Have you ever wondered how much functional programming and immutable data structures can affect your application performance? Well, today we're going to explore that together with the flocking birds, and the results are going to be surprising.
I created a React app that generates 300 birds, each represented by an SVG. These birds will go through a pipeline that does all kinds of cool things, like making them bounce off walls and updating their colors and positions. Ultimately, all the birds will be rendered to the screen, triggering the next cycle.
To test the impact of different programming approaches, I coded the exact same logic in three different ways. First up, we've got the functional programming way with pipes, curries, and immutable data structures.
export const update = (
birds: Bird[],
color: string,
range: { x: number; y: number }
): Bird[] => {
return birds.map((bird) => {
return pipe(
align(birds), // here we are calling these curry functions
cohesion(birds),
separate(birds),
bounce(range),
changeColor(color),
move
)(bird);
});
};
// this function creates and returns a new fucntion that takes a bird as an argument
const align =
(birds: Bird[]) =>
(bird: Bird): Bird => {
const friends = filterBirdsInRadius(bird, birds, FRIEND_RADIUS);
if (friends.length === 0) {
return bird;
}
const averageX =
friends.reduce((acc, f) => acc + f.next.x, 0) / friends.length;
const averageY =
friends.reduce((acc, f) => acc + f.next.y, 0) / friends.length;
const desired = {
x: averageX - bird.next.x,
y: averageY - bird.next.y,
};
// here eventually we are spreading a new bird object and returning it
return {
...bird,
next: {
x: bird.next.x + ALIGN_STRENGTH * desired.x,
y: bird.next.y + ALIGN_STRENGTH * desired.y,
},
};
};
In this version, all functions create a new bird object. If you're not familiar with currying, it means that when you call a function, it creates a new function in memory and then returns it. Later, you can call the returned function with some arguments to get the final result.
The second version uses only immutable data structures, with no currying or pipes.
export const update = (
birds: Bird[],
color: string,
range: { x: number; y: number }
): Bird[] => {
return birds.map((bird) => {
bird = align(bird, birds); // here the functions are recieving all arguments at once
bird = cohesion(bird, birds);
bird = separate(bird, birds);
bird = bounce(bird, range);
bird = changeColor(bird, color);
bird = move(bird);
return bird;
});
};
And last but not least, we've got the classic 'mutate' approach, which involves changing the bird in place. However, since React doesn't allow state mutation, we need to copy the state at least once and then mutate it later.
export const update = (
birds: Bird[],
color: string,
range: { x: number; y: number }
): Bird[] => {
return birds.map((bird) => {
return bird
.clone()
.align(birds)
.cohesion(birds)
.separate(birds)
.bounce(range)
.changeColor(color)
.move();
});
};
export class Bird {
id: number;
x: number;
y: number;
size: number;
color: string;
next: Vector;
// ...
align(birds: Bird[]): Bird {
const friends = this.filterBirdsInRadius(birds, FRIEND_RADIUS);
if (friends.length === 0) {
return this;
}
const averageX =
friends.reduce((acc, f) => acc + f.next.x, 0) / friends.length;
const averageY =
friends.reduce((acc, f) => acc + f.next.y, 0) / friends.length;
// I know I'm allowcating memory on to heap here, but I don't care.
const desired = {
x: averageX - this.next.x,
y: averageY - this.next.y,
};
this.next.x += ALIGN_STRENGTH * desired.x; // mutate the bird in place
this.next.y += ALIGN_STRENGTH * desired.y;
return this;
}
clone(): Bird {
return new Bird({
x: this.x,
y: this.y,
size: this.size,
color: this.color,
next: {
x: this.next.x,
y: this.next.y,
},
});
}
}
I know the performance will be better if I change the reduce and filters to regular loop and calculate the average manually. But I want to see how much the currying and immutable data approach affects the performance, so don't be too picky.
Anyway, we tracked the timestamps of each frame and let the birds fly for a while to let the garbage collector do its thing. Then we calculated the average time it took to run each frame and put the results on a spreadsheet.
Here's the final result.
Turns out the curried version wasn't much worse than the immutable version, only 1.7% slower on average. But the mutated version was whopping 9.99% faster than the curried version.
I mean, I didn't expect the mutated birds could be this much faster."
But to be honest, code performance isn't usually an thing in front-end development, and normal applications wouldn't render 300 divs thousands of times. BUT, if you're writing a Node server and frequently spreading and copying objects, it might be time to rethink your approach.
Source code: https://github.com/Bailig/flocking-birds/tree/main/react
Live site: https://flocking-birds-zeta.vercel.app/
Top comments (0)