Thursday, December 11, 2014

Lo-Dash: Partials and References

The partial() function in Lo-Dash is used to compose higher-order functions, with argument values partially applied. So for example, let's say we have a function that accepts a string argument, like console.log(). We can compose a new function that when called, doesn't need to supply a string argument — it'll use the argument value it was composed with. This is especially powerful in contexts where we don't necessarily have control over how the function is called. As is the case with callback functions. Primitive types, like strings and numbers work well as partial arguments. Things like arrays and objects work fine too, if they're passed to partial() as a literal. If a reference is passed as a partial argument, unexpected things start to happen.

var app = {
    settings: {
        version: 1
    }
};

var version = _.partial(function(settings) {
    return settings.version;
}, app.settings);

The version() function we've just composed using partial() has a reference passed to it — app.settings. The function itself just returns settings.version. Which means, of course, that version() relies on this reference always being there. Let's put this partial function to the test:

console.assert(version() === 1, 'version is 1');
// → true

app.settings.version = 2.0;
console.assert(version() === 2, 'version is 2');
// → true

app.settings = _.extend(app.settings, { version: 3 });
console.assert(version() === 3, 'version is 3');
// → true

app.settings = { version: 4 };
console.assert(version() === 4, 'version is 4');
// → "Assertion failed: version is 4"

The first assertion checks that the version() function returns the initial value of settings.version. Next, we change the app.settings.version value to 2. Since our partial function is referencing the settings object, the next assertion passes, because when it's called, it looks up the new value. Next, we use the extend() function to assign a new object to app.settings — or so it would seem. Notice that the first argument to extend() is the same object that's referenced by value(). This means that we're not overriding this reference, and the assertion passes.

This last statement assigns a new object literal to app.settings. This is enough to kill the reference in the value() partial, as we can see by the failed assertion. It's still referencing the old object. This can lead to very subtle bugs with your partial functions. The assignment statement doesn't look like it would be problematic. Maybe we can construct the referenced property in such a way that it's immune to reference issues:

var app = {};
Object.defineProperty(app, 'settings', {
    set: function(value) {
        this._settings = _.isPlainObject(this._settings) ?
            _.extend(this._settings, value) : value;
    },
    get: function() {
        return _.isPlainObject(this._settings) ?
            this._settings : this._settings = {};
    }
});

var version = _.partial(function(settings) {
    return settings.version;
}, app.settings);

Here we're manually defining the settings property of our app object using Object.defineProperty(). This let's us implement what happens when the property is assigned a value, and when the property value is accessed. The idea being, we hide the real settings object in a hidden _settings property. When the property is set, we check whether _settings is already an object or not. If so, we can just override existing values with new ones. This keeps the _settings reference in tact.

When the property is read, we simply return the _settings reference. We also need to make sure that the reference already exists before returning it. If it doesn't, we assign an empty object to _settings and return that. Let's put this new approach to the test:

app.settings = { version: 1 };
console.assert(version() === 1, 'version is 1');
// → true

app.settings = { version: 2 };
console.assert(version() === 2, 'version is 2');
// → true

That's better — assigning brand new object literals to app.settings doesn't break our version() partial function. Let's now see if we can achieve similar results with references to arrays:

var app = {};
Object.defineProperty(app, 'users', {
    set: function(value) {
        if (_.isArray(this._users)) {
            this._users.length = 0;
            this._users.push.apply(this._users, value);
        } else {
            this._users = value;
        }    
    },
    get: function() {
        return _.isArray(this._users) ?
            this._users : this._users = [];
    }
});

var first = _.partial(_.first, app.users),
    last = _.partial(_.last, app.users);

console.log('Testing array setter/getter');

app.users = [ 'user1', 'user2' ];
console.assert(first() === 'user1', 'first is "user1"');
// → true
console.assert(last() === 'user2', 'last is "user2"');
// → true

app.users = [ 'user3', 'user4' ];
console.assert(first() === 'user3', 'first is "user3"');
// → true
console.assert(last() === 'user4', 'last is "user4"');
// → true

This approach is similar to the one taken with objects. The only real difference is that instead of using extend() to assign new values, we're simply resetting the array length to 0 and pushing the new array onto the existing _users array. The technique is the same — keep the reference in tact for partials that may be using it.

No comments :

Post a Comment