About terms used: JavaScript is a prototype-based language. We can call its constructor-functions "classes". Why? Because, they are things which 1) Create instances and 2) Describe the common properties of all instances created by the same constructor. A 'method' is a property of an object whose value is a function. For more on what terms 'class' and 'method' can mean in JavaScript, see my previous blog-post "Does JavaScript have classes and methods?" .
Our main purpose is to present a way to enhance JavaScript base-classes in general. But to demosntrate the benefits of our approach we present source-code for four particular enhancements to Array.prototype.map() in particular, which make it easier to use map() in practice, requiring less code to do so..
So two related topics are covered:
1) How to enhance JavaScript base-classes in general
2) How to enhance Array.prototype.map() in particular.
The presentation is in the Design Patterns -format: Problem, Forces, Solution, Implementation, Example, Consequences, Try-it-out. This is a work in progress so any comments, suggestions and corrections are welcome.
1. PROBLEM
JavaScript is an evolving language, which means some of its newer features are not available on older browsers. Newer versions of the ECMA -standard introduce powerful functional-programming features like map() and reduce(). But those do not work on older browsers. And as good as the latest standards are, we might still sometimes like to improve them to our taste.Here's an example. The ECMAScript 5 Array.prototype.map() syntax is lengthy, because JavaScript does not provide a shorthand notation for anonymous functions. Can we create our own, easier to use implementation of map()?
The purpose of map() is to avoid boilerplate code that is required by a for -loop. But having to write a full function definition for even the simplest uses of map() means that using map() may not be much shorter than writing a for-loop after all.
You typically use map() like this:
[1,2,3].map(function (x) {return x * 10});
I would prefer to be able to write it in a shorter way:
[1,2,3].map( 'x * 10' ) ;
And I also can't use map() at all if my code is run on older browsers that don't provide it.
2. FORCES
In a previous blog-post I discussed the function F() whose purpose is to create functions out of String -expressions. That allows us to write the above example more succinctly as:[1,2,3].map (F('x * 10'));
A problem I have with this is that function name 'F' is not very descriptive. The reason for that is that our purpose is to make the code shorter to make map() easier to use. Therefore I made the name of the function 'F' as short as possible. Also there's still two levels of parenthesis combined with the single-quotes make the code look more complicated than I would like to.
An obvious solution to these problems would be to modify map() so that it accepts a String-argument directly, and uses F() internally to turn it into a function. Then we can write:
[1,2,3].map ('x * 10') ;
And thus finally we have arrived at tyhe problem this article is about: How can we modify map() and similar functions to work the way we want them to, allowing us to write less code, and make it work on all browser versions?
One solution is to add our own implementation of map() as a method of Array.prototype, thus providing it for older browsers as well. This is the "Polyfill" -approach suggested for instance at the MDN web-site .
At first I thought using a 'Polyfill' is a great idea - because I can replace the built-in implementations of map() etc. with my own, and make it work on browsers. But I soon discovered a big problem. I added the function map() to Array.prototype as a property. Then on IE-8 the new map() -method had become an enumerable property of every Array in my program. Why? Of course! Every instance of Array inherits its properties from the prototype. Sorry Charlie, no cigar. Trying to loop over any array now included my map() -implementation as one of the elements of any Array.
That meant I would need to add a test to every Array-loop to exclude my map-method. And I would also need do the same for every loop in every library I was using, to be safe. Making such checks inside every for-loop would be tedious and error-prone. It would also make my loops slower.
An obvious problem with the Polyfill -approach is also that it can break code in the libraries you use, if those rely on the standard behavior behavior. You should not modify parts of code that other parts of code may depend on not having been modified.
3. SOLUTION
If we can not safely modify the existing Array.prototype the only option seems to be to create our own version of it. Let's imagine we can do it like this:var Array2 = ClassCloud.enhanced(Array, {map: function () {...});
This will return a constructor which will create a wrapper-object around any Array given as argument to it. Let's further imagine that such a constructor can be called without the 'new' -keyword, to make our code even shorter. Then we can write:
Array2([ 1,2,3] ) . map( ' x * 10 ' );
Array2.map() loops internally over the basic array given as argument when the Array2 -instance was created.
We can make calls to Array2() even shorter of course by assigning it into a short variable:
var A = Array2;
Then we can write most succinctly:
A([1,2,3]).map('x * 10'); // 1.
Contrast the above with our first solution:
[1,2,3].map(F('x * 10')); // 2.
They are still much the same length. So is there much improvement after all? Yes. The difference is that Example 1 works on older browsers too.
And 'A' above is a class, whereas 'F' is just a function. This same 'A' can give us other functions missing from older browsers, reduce() for example. Since we now have an easy way to create enhanced Arrays, it is easy to see that we can apply to improve other system classes as well. We could enhance every Object with any methods we choose and write for example:
O( {x:1, y:2} ) . map ( ... )
So this solution makes it possible to write our own interpretation of what it means to "map" over Objects as well. We have a general way to improve any base-class in JavaScript, and in a way that works on older browsers too. Obvious candidates for enhancement include for instance Math, Boolean, RegExp and Function.
Now that we know what we want, all that remains is to come up with an implementation.
4. IMPLEMENTATION
The Array2() -function used previously must be a constructor that takes a an Array as argument, and returns an instance of Array2, with some enhanced behaviors. It doesn't need to have all methods of a standard Array, because we choose to use it only when we need its enhanced functionality. For instance we might give it have just the method map() so we can use a String-expression in place of a function as the argument of it. But we can also include some pre-existing methods of Array if we think we need them.To create our Array2 we need the function ClassCloud.enhanced() , which can be used like this:
var ap = Array.prototype;
var methodMap =
{ pop: ap.pop
, push: ap.push
, concat: ap.concat
, map: mapCC
};
var Array2 = ClassCloud.enhanced (Array, methodMap);
var a2 = Array2 ([1,2,3]);
var a2b = a2.concat([1,2,3]);
The above reuses the methods pop(), push() and concat() from the built-in Array, and creates a new method of its own 'map()', implemented by the function 'mapCC'.
SIDE-NOTE: If we targeted newer browsers only, we could add EVERY method of the Array.prototype automatically to our new class. But that would not work on older browsers because on them we don't know what are all the methods of the standard Array. We can't enumerate them, partly to the same reason why adding our own methods to Array.prototype is a bad idea.The reason why older browsers can't enumerate methods is explained in this answer on StackOverflow.
Internally the function ClassCloud.enhanced() will create wrapper-methods which call the methods given in the methodMap -argument, in a way that the pseudo-variable 'this' inside them is bound to the data given when Array2 is called. The same thing will be done to the newly added methods similarly.
One added requirement is that when an original method like Array.prototype.concat() returns an instance of Array, the wrapper-method must return an instance of Array2. That will allow us to chain together a set of iterators we have defined for Array2. So we can compose applications of our functions in this way:
var a2b = Array2 ([1,2,3]) . map(funkA) . map(funkB) ... ;
I hear you thinking: You are chaining together function-calls. How about doing the same to the functions themselves? We could then reuse such composed functions anywhere. And yes ... ClassCloud.enhanced() can be used to enhance any built-in JavaScript constructor. Therefore we could use it to enhance the Function.prototype for instance. We leave it as an exercise to implement Function2 which could be used like this:
var funkD = Function2(funkA) . then(funkB) . then(funkC) ;
The implementation of the basic enabling method ClassCloud.enhanced() is given in the code-listing at (hyperlink).
4. EXAMPLE
As a more concrete example of how to benefit from ClassCloud.enhanced() I next describe my enhancements to map(), implemented with the function mapCC(). You can copy its source-code from [hyperlink].mapCC() accepts the same arguments and can be used exactly like the standard ECMAScript 5 map() described at MDN web-site . But it enhances the standard map() in four ways described in the four sections below. For actual examples of using this enhanced funcitnality see the test-code at [HYPERLINK].
4.1 String argument
In addition to a function, mapCC can take a String that evaluates to an expression, which gets converted to a function. That means you need to do less typing, and your code becomes faster to read. This means we can write:
[1,2,3].map( 'e * 10' );
No "return" statement is needed within the string, which makes it short to write. It must contain a JavaScript expression, whose value is returned. The above is a variable that gets as value the elements of the array of successive calls. There are two more variables you can use within the string. 'i' will be the index of the iteration on each round and 'a' contains the array being iterated over. For examples of their use see the unit-tests provided in their own document.
4.2 Useful default for 'this'
According to the MDN documentation 'map' takes an optional 2nd argument 'thisArg' for which it is said "... If a 'thisArg' parameter is provided to map, it will be passed to callback when invoked, for use as its this value. Otherwise, the value undefined will be passed for use as its this value."
However, on my IE-10 it seems if I don't pass in that argument, the value of 'this' will be the window-object. That is one more reason to define your own implementation for map() rather than rely on built-in implementations which may differ from browser to browser.
For our mapCC() if no 'thisArg' is passed in, we automatically create a new object {}that will be the value of 'this' during a call. So our callback function can always use 'this' for instance to pass data from one call to the next. As an example we can simulate another ECMAScript-5 function 'reduce()' with our map():
a2 = Array2([1,2,3]).map
( function (each)
{ var previousResult
= this.previousResult
? this.previousResult
: 0;
var result = each + previousResult;
this.previousResult = result;
return result;
}
)
Having executed the above the following tests will pass:
a = a2.data;
ok (a[0] == 1); // 1 + 0
ok (a[1] == 3); // 2 + 1
ok (a[2] == 6); // 3 + 3
Above 'a2' contains an instance of our Array2 which in our implementation (not visible above) "wraps" the instance of Array it "represents" into its field 'data' for easy "unwrapping". It's a bit like a monad really. Even if it's not really a monad, according to the official definition, this points to the direction of how they could be implemented in JavaScript. An alternative would be to provide a method at() to access the elements of Array2, so we could write:
ok (a2.at(0) == 1); // 1 + 0
ok (a2.at(1) == 3); // 2 + 1
ok (a2.at(2) == 6); // 3 + 3
4.3 Selecting specific elements
If the callback-function returns undefined, that will not be added to the result-array. This means you can decide to skip some elements of the original array. For instance we can select all odd numbers from a list:
a2a = Array2 ([1, 2, 3, 4, 5]);
a2b = a2a.map ('e%2 ? e : undefined');
d = a2b.data ;
ok (d[0] == 1); // 1%2 != 0 so result is not undefined
ok (d[1] == 3); // 3%2 != 0 so result is not undefined
ok (d[2] == 5); // 5%2 != 0 so result is not undefined
ok (d.length == 3);
4.4 Stopping iteration early
Sometime you are interested to if there's at least one element in a collection that fulfills a specific condition. For instance you might want to know whether there are ANY odd numbers in a list.
When using Array2().map() we can stop the iteration early inside the callback-function by setting
this.cut = true;
This means you can stop the iteration as soon as you have enough elements processed.
a2 = Array2([22,98,3,100]).map
( function (each)
{ var result = each + previousResult;
this.previousResult = result;
return result;
}
)
Having executed the above the following tests will pass:
a = a2.data;
ok (a[0] == 1); // 1 + 0
ok (a[1] == 3); // 2 + 1
ok (a[2] == 6); // 3 + 3
This can be used to find the first element that fullfills some criteria. No matter how big the array you are iterating over is, you can stop after 99 elements. Or you can use this to stop iteration as soon as you know there can be no more good results. Our choice of the filed-name 'cut' was inspired by the language Prolog, which uses "cut" for just that purpose: stop searching for more answers.
5. CONSEQUENCES
Above we have seen that it is bad to modify system classes directly, even if that modification only consisted of adding new methods to them. By using the pattern presented we can pick and choose the methods for our alternate system from multiple sources, including our own additions without having to modify system classes.But the pattern is called Alternate System-Class, not "Pick and choose your methods". Why? It could seem that since we can add any methods to our alternate system-class, there would be little connection between our alternate class, and any specific system-class. Yet there is. The enhanced() -method takes the chosen system-class as its first argument because that allows it to "wrap" the results returned by the original method into instances of the alternate system-class.
That allows us to chain multiple operations involving our new methods into a call-chain without requiring us to create new instances explicitly. If that starts to sound bit like "monads", you may be right. Since the provided source-code is so short, you can study it in detail, and insight into the benefits of "monadic" programming.
6. TRY-IT-OUT
To use the JavaScript module ClassCloud described above you need to 3 files on your own system, in the same directory, with the content from the 3 source-code pages related to this article.The three files needed are:
1. The HTML-file ClassCloud_enhanced.htm. Copy the contents of it from the lilnked page ClassCloud_enhanced.htm .
2. The JavaScript -file ClassCloud_enhanced.js. Copy the contents of it from ClassCloud_enhanced.js
3. The JavaScript -file ClassCloud_enhanced_TEST.js. Copy the contents for it from ClassCloud_enhanced_TEST.js.
The two JavaScript -files will get loaded when you open the HTML-file in your browser. The first JavaScript -file contains the implementing source-code. The 2nd JavaScript -file contains tests which serve as examples of how to use this module.
If for some reason the tests don't pass, you should see an alert-box on your browser. That might be because our code does not run on your browser as we intended, does not run in conjunction with your other code, or because you have modified this code or its tests, to accommodate your own modifications and tests for them.
_________________________________________________________
Copyright © Panu Viljamaa 2014. All rights reserved.
No comments:
Post a Comment