Sunday, April 23, 2017

Towards Idiomatic Javsascript

I've been programming a long time (i.e., I predate the personal computer) and my usual programming style is somewhat dated.  One of the things I've been slowly doing on this project is learning about and incorporating some of the more modern features of Javascript, including functional programming idioms and ES6 syntax.  Occasionally I mention that I stopped in the middle of this or that effort to refactor my code.  In this posting will look at how I refactored a section of code to use some of these newer language features.

(This is a bit of left-turn from my usual content, so feel free to skip ahead if this isn't your sort of thing.)

The code I'll look at is from drawing the forests.  It's the portion of the code that avoids drawing where there are mountains.  Here's the code as I originally wrote it:

        //
        //  Create a list of all the bounding boxes of the mountains we've drawn, so that
        //  we can avoid drawing forest where they're at.
        //
        let mtnBBs = [];
        for(let i=0;i<world.mountains.length;i++) {
            let rect = world.mountains[i].node().getBBox();
            mtnBBs.push([[rect.x-(rect.width*0.5), rect.y-(rect.height*0.75)],
                         [rect.x+rect.width*1.5, rect.y+rect.height*1.75]]);
        };
        function collideBBs(i) {
            let x = world.loc[i].coords[0]*1000;
            let y = world.loc[i].coords[1]*1000;
            for(let i=0;i<mtnBBs.length;i++) {
                let mtnBB = mtnBBs[i];
                let left = mtnBB[0][0];
                let top = mtnBB[0][1];
                let right = mtnBB[1][0];
                let bottom = mtnBB[1][1];
                if (x >= left && x <= right && y >= top && y <= bottom) {
                    return true;
                };
            };
            return false;
        };
        //
        //  Later used as:  collideBBs(i)
        //

The code is a pretty straightforward procedural approach, relying on for-loops to iterate through arrays.  The first part of the code creates a list of all the bounding boxes of mountains in the world, "mtnBBs".  The second part is a function that checks to see if location "i" in the world is within one of these bounding boxes. 

Let's tackle the first part of this code, that builds the array of mountain bounding boxes.  The pattern of walking through an existing array and building up a new array is very common, so Javascript arrays now have a functionality called "map" that takes a function, applies it to every element of the array, and then returns an array of the results.  So let me use that to build the "mtnBBs" array.

        function toBB(mtn) {
            let rect = mtn.node().getBBox();
            return [[rect.x-(rect.width*0.5), rect.y-(rect.height*0.75)],
                    [rect.x+rect.width*1.5, rect.y+rect.height*1.75]];
        };
        const mtnBBs = world.mountains.map(toBB);

What I've done here is take the interior portion of the for loop and turned it into a function that returns the bounding box, and then replaced the for loop with a call to "map()".  I realized that "mtnBBs" never changes after I create it, so I declared it as a constant rather than a variable; this helps the browser run the code more efficiently.

You might notice looking at the "toBB" function above, that it comprises two steps: the first turns a mtn into an SVG bounding box, and the second turns that into an array of two coordinates.  So it seems like we could do this with two "maps" -- the first to turn mtns into SVG bounding boxes, and the second to turn the SVG bounding boxes into the coordinate arrays.  That looks like this:

        function toBB(mtn) { return mtn.node().getBBox();}
        function toCoords(rect) {
            return [[rect.x-(rect.width*0.5), rect.y-(rect.height*0.75)],
                    [rect.x+rect.width*1.5, rect.y+rect.height*1.75]];
        };
        const mtnBBs = world.mountains.map(toBB).map(toCoords);

Notice how the map calls are chained together in the last line -- first "toBB" is applied to mountains to create an array of SVG bounding boxes, and then "toCoords" is applied to that array to create the array of coordinates.

It's a little awkward to have to write a whole function for the little bit of functionality in something like "toBB" so Javascript introduced a shorthand method for writing these sorts of little functions called the "fat arrow".  The fat arrow lets you write a short simple function in this format:

        x => Math.sqrt(x)

This little example defines a function that takes a value "x" and returns the square root.  I can use the fat arrow format to make this code a little simpler.

        const mtnBBs = world.mountains
              .map(mtn => mtn.node().getBBox())
              .map(rect => [[rect.x-(rect.width*0.5), rect.y-(rect.height*0.75)],
                            [rect.x+rect.width*1.5, rect.y+rect.height*1.75]]);

Not having to define "throwaway" functions separately makes the code a lot cleaner and compact.

Now let's look at the "collideBBs" function:

        function collideBBs(i) {
            let x = world.loc[i].coords[0]*1000;
            let y = world.loc[i].coords[1]*1000;
            for(let i=0;i<mtnBBs.length;i++) {
                let mtnBB = mtnBBs[i];
                let left = mtnBB[0][0];
                let top = mtnBB[0][1];
                let right = mtnBB[1][0];
                let bottom = mtnBB[1][1];
                if (x >= left && x <= right && y >= top && y <= bottom) {
                    return true;
                };
            };
            return false;
        };

The second part of this walks through the "mtnBBs" array, but it exits early -- the first time it finds a location inside a bounding box, so we can't use map() in this case.  However, this use case "find the first element of an array that meets a condition" is also very common, so Javascript provides this functionality in something called "find".  It works about how you'd expect.

        function collideBBs(i) {
            let x = world.loc[i].coords[0]*1000;
            let y = world.loc[i].coords[1]*1000;
            function within (mtnBB) {
                let left = mtnBB[0][0];
                let top = mtnBB[0][1];
                let right = mtnBB[1][0];
                let bottom = mtnBB[1][1];
                return (x >= left && x <= right && y >= top && y <= bottom);
            };
            return mtnBBs.find(within);
        };

Here I've broken out the condition I was testing into a new function "within" and then used find to apply that to the mtnBBs until I find the first one.  Again, I can simplify this by turning "within" into a fat arrow function:

        function collideBBs(i) {
            let x = world.loc[i].coords[0]*1000;
            let y = world.loc[i].coords[1]*1000;
            return mtnBBs.find(m => x >= m[0][0] && x <= m[1][0] && 
                                    y >= m[0][1] && y <= m[1][1]);
        };


Now the only real reason for the collideBBs function is to define the temporary "x" and "y" variables.  I could just substitute the long expressions into the fat arrow function, but it would be more understandable code if I could incorporate the definition of x and y into the fat arrow function.  It turns out you can have multiple statements in a fat arrow function.  The only caveats are that you have to enclose the statements in braces, and you need to explicitly return a result.

 function collideBBs(i) {
     return mtnBBs.find(m => {
               const x = world.loc[i].coords[0]*1000;
               const y = world.loc[i].coords[1]*1000;
               return x >= m[0][0] && x <= m[1][0] && y >= m[0][1] && y <= m[1][1];} );
 };

I usually prefer to use single-line fat arrow functions, but this case seems tolerable.

At this point I no longer need the collideBBs function, I can just substitute the return expression where I was using collideBBs.  If I only need to do this once I can combine everything into one statement:

        world.mountains
            .map(mtn => mtn.node().getBBox())
            .map(rect => [[rect.x-(rect.width*0.5), rect.y-(rect.height*0.75)],
                          [rect.x+rect.width*1.5, rect.y+rect.height*1.75]])
            .find(m => {
                let x = world.loc[i].coords[0]*1000;
                let y = world.loc[i].coords[1]*1000;
                return x >= m[0][0] && x <= m[1][0] && y >= m[0][1] && y <= m[1][1];} )

For me, the readability of deeply-chained functions is poor, but that's probably just my old school background talking.  I see lots of code like this, so clearly many programmer like it fine.

2 comments:

  1. Hey Scott, love your blog. Speaking about code, is any of your code available online? I'm currently running a D&D game and I am absolutely amazed by your progress.

    ReplyDelete
  2. Thanks very much for the kind words! I really appreciate it. As far as the code goes, at this point this is a personal project but when I get finished / bored with it I'll probably release the code.

    ReplyDelete