More functional: A story in refactoring a 2d vector library
This article is about how I rewrote my library vec-la in a functional programming style. vec-la is a small 2d linear algebra library that models vectors and matrices as plain javascript arrays. This article is not about math and linear algebra though — you don’t have to know that much to be able to get some value out of it.
Crash course: What is a Vector?
In case you don’t know, a vector is basically an ordered collection of numbers. A 2D vector is just an ordered collection of 2 numbers. If you’re familiar with coordinates like (2, 3) then you know what a vector is intuitively.
But a vector can also be seen as an arrow in space; It has a direction (where the arrow points to) and a magnitude (how long the arrow is). That’s important because it means that a vector can represent more than just points in space — it can just as well represent speed in a direction for instance.
In many ways, vectors are like normal numbers. You can add, subtract, and scale them, but you can do other cool stuff like rotate and reflect them. That’s what vec-la is all about — it gives you the tools to manipulate these mathematical objects programmatically.
I primarily use vec-la to build programatic animations and games, although the use cases for vectors and matrices are wide.
Goal
With all of that out of the way, the primary goal of both the original project and the refactor was to move away from the approach that many other libraries take; Class based vector models where operations are methods that mutate the vector.
Avoiding mutation of state is an idea that has become more and more widespread in the last few years. Redux in particular has brought this into the spotlight in the javascript community.
Instead, vec-la uses pure functions that always return a new copy. To illustrate these differences though, let me compare to another (much more popular) library, victor.js.
In the normal case Victor requires creating a specialised object, but it also exposes an API for creating from an array:
Victor.fromArray([15, 22])
Although actually ends up being more tedious than creating vectors with new.
Using add, you can see the idea of mutation coming in. The result of adding vic1 to vic2 is actually stored in vic1. This is annoying if you want to continue using vic1 afterwards. vec-la will always return a new vector, and so you never have the mental burden of worrying about what happens to vec1 and vec2.
Same here for multiply in victor— it mutates the vector your working with. This can often lead to unexpected and difficult to find bugs. To see why take the following code:
Victor also does not allow for some other more advanced functionality that vec-la does, like using matrices. If you don’t know what a matrix is, here is a simple but inexact explanation:
A matrix is a set of numbers arranged into a table. There is a special way to combine these numbers together with a vector or matrix that gives you a new vector or matrix. This operation is called Matrix multiplication. I find it helpful to think of a matrix as being the definition for a kind of transformation function.
Writing matrices by hand is cumbersome and difficult to read, so there is an API using a fluent builder pattern to simplify creation:
A functional level up
You might already say that vec-la is pretty functional.
- It works with functions, not methods
- It never mutates arguments
- Functions that take or return vectors plug easily together — they compose
But it has some drawbacks too:
- The functions are not curried
- They often take arguments in the “wrong” order
- The matrixBuilder introduces a specialised object into the mix which is not a plain array
If you don’t know about currying and argument order, check out my article Making Functional Programming Click. I go deep into detail about these concepts there.
Let’s see how to fix each of these drawbacks.
The functions are not curried
That’s a fairly easy fix, but it actually needs to be combined with fixing the argument order. As I stated in the article I just mentioned:
The rule is typically put the data as the last argument
Most of the time that data is going to be the vector you want to operate on. Let’s use the scale function to see the problem:
This function takes a vector v and a scale factor sc as arguments. Because of that order, you cannot partially apply this function in a useful way; It’s much more likely that you want to scale different vectors by the same scale factor, than it is that you want to scale the same vector by different scaling factors. If you swap the arguments, you can make a more meaningful function.
There are a few more functions like this in the library. transform takes a vector and a matrix and transforms the vector according to the matrix. Let’s imagine that you have a shape defined by an array of vectors. If you want to transform every point, you could do this:
If we switch the arguments we can create a partially applied transform function that will always use our matrix:
That’s a lot more expressive and reusable. If you have another shape or point you can transform it using that same function. Before we were forced to create an arrow function that implicitly has access to the matrix.
As a last example (there are plenty more), there is a convenience function called rotatePointAround, which takes a vector to rotate, a vector to use as the rotation origin (control point), and an angle. If you want to rotate a bunch of vectors around the same point by the same angle, a better order would be angle, rotation origin, vector.
There’s a good possibility that you’re thinking of counter examples and exceptions, because they certainly exist. In those cases, we can use something called a combinator to rearrange the arguments on the fly. A combinator is any function that takes a function as input and produces a function as output.
Let’s imagine that we have a vector, and an array of matrices. We want to produce an array of vectors where the one vector is transformed by all of our different matrices. In this case, we want to flip the arguments of transform. Instead of writing a special custom function, we can write a generic combinator to achieve this:
It’s a less common use case, but having a function like flip2 makes it easy to manage when it does come up. Like curry, you’ll find an implementation of flip in all the major functional libraries like ramda or sanctuary.
The matrixBuilder() problem
We’ve solved the first two problems, and kept the possibility of runtime argument rearrangement open if we need to handle unusual cases. But there is still the problem of the matrix builder.
All of the functions in the new library have been built to support function composition, but a fluent interface simply doesn’t; You have to keep calling methods until the matrix is ready, and then call get() to release the matrix value. (if you don’t know what function composition is, read the article I mentioned before)
Let’s take a look at the original matrixBuilder code.
The vMatrixBuilder function takes a matrix (or nothing, which implies the identity matrix) and returns an object with some methods for rotate, scale etc. From each of those methods, it returns a new matrixBuilder which composes the old matrix and a new one that performs a given operation.
That word compose should be jumping off the page at you right now. It turns out there is a way to take two matrices and perform a complicated sum of the numbers inside, and get a new one that encodes the information of both.
We call that operation matrix composition, and the crazy thing is that this works exactly the same function composition (of course it’s not really crazy — math tends to be a well thought out process of generalisation, so these types overlap happen a lot).
That’s probably the most intimidating piece of code in this whole article. I showed the mCompose function just to show that there is some kind of way to compose matrices, but understanding is not important (and is essentially impossible just from this implementation). If you want to know how all the math for this really works, I recommend 3blue1browns’s series on “The essence of linear algebra”.
The rest just works exactly the same as matrixBuilder — it creates matrices by partially applying mCompose to a specialised matrix. And just like matrixBuilder, you need to kick the whole thing off with a matrix, which is typically in both cases the identity matrix. The identity matrix, unsurprisingly, is just like the identity function in programming — when you apply it to a matrix or vector you just get the same thing you put in.
Now we can express a build up of matrices through pure function composition, and we’re not restricted to the kind of methods that one particular API exposes.
Summary
Reframing the library this way leads to a much higher interoperability with other libraries — especially functional ones like ramda, which I like to work with. To see an example of all of this coming together, check out this programatic animation on codepen.
I will admit that the matrix builder is an easier interface conceptually, but it trades off flexibility and generality to achieve that interface. I think both libraries and approaches have their place.
You can see both the new vec-la-fp, the original vec-la on github. They are both around 150 lines of actual code, so it’s a pretty easy project to get your head around.
If you’ve enjoyed this article, let me know by leaving me a comment here or on twitter.
I’m starting a series called “exploring ramda” where I’ll be writing about incorporating a functional style in javascript using the ramda library, so follow me here on medium if that’s something that interests you.