Sometimes evaluating an argument for a function is expensive! So expensive, that you only want to pay that fiddler to evaluate the value when the function you are calling uses the value.
Let's look at this problem abstractly first:
// The problem
const code = (props) => {
// Arguments are sometimes only conditionally used!
if (isExpensiveUseful(props.cheap)) {
return doSomethingWithExpensiveArg(props.expensive);
}
return expensiveIsntEvenUsed;
};
const expensive = getExpensiveValue();
const cheap = {
/*..*/
};
// Dangit! I had to compute expensive even though the `code`
// may not use it!
code({ cheap, expensive });
Do you see the problem? code
, requires expensive
but it only uses
expensive
if isExpensiveUseful
is truthy! This isn't ideal because we have
to compute expensive
every time, even if code
doesn't use it.
Jump to the section on Self Overwriting Lazy Getters to skip the obligatorily mentioned boring option.
If we have access to the function that only conditionally uses the expensive
argument and we have access to update its other callers we can simply change the
interface to make expensive
a function that returns the expensive
value.
This allows the consumer of expensive
to determine if we should pay the cost
of computing it.
// Solution One - The Thunk, Explicit Deferred Evaluation
const code = (props) => {
if (isExpensiveUseful(props.cheap)) {
// We only pay the cost of `getExpensive`
// (and therefore whatever functions it calls)
// if we actually use it! Nice!
return doSomethingWithExpensiveArg(props.getExpensive());
}
return expensiveIsntEvenUsed;
};
const payTheCostLaterIfYouWantThunk = () => {
return getExpensiveValue();
};
const cheap = {
/*..*/
};
// It's all on you now code, do us right.
code({
cheap,
getExpensive: payTheCostLaterIfYouWantThunk,
});
This updates the function's interface to make the conditional use of expensive
explicit and allows code
to decide when to evaluate it. This is a great option
if you have access to update the code and all the callers. It also requires you
to have confidence that they are using that expensive value only when it is
needed. Unfortunately, their function may be consuming functions that
conditionally want the expensive value and you'd need to be able to update every
value user to where the value is needed to make sure this strategy works.
An obscure and far less boring technique is to provide a getter that overwrites
itself on the first retrieval. This allows us to avoid paying the cost of
evaluating expensive
if it isn't used without updating the function that
conditionally uses it.
const code = (props) => {
if (isExpensiveUseful(props.cheap)) {
// Hey it's being access so it is time
// to pay the fiddler!
return doSomethingWithExpensiveArg(props.expensive);
}
return expensiveIsntEvenUsed;
};
const cheap = {
/*..*/
};
code({
cheap,
// This code will only be run when someone
// accesses `expensive`.
get expensive() {
// Don't forget to overwrite the value so you
// only pay the cost once!
delete this.expensive;
// `this` is the object that contains `cheap` and
// our `expensive` getter upon first retrieval we
// overwrite the getter with its value.
return (this.expensive = getExpensiveValue());
},
});
First, we make expensive
an "implicit thunk" by providing a
getter.
The getter is only run when someone accesses that field. Here comes the
important part. The getter than overwrites itself with the computed value!
this
in the getter refers to the object that contains the getter. We are
overwriting the getter from within the getter with the computed value. This
prevents getExpensiveValue
from being run several times if the value is
accessed several times.
Consider the following API that accepts any DOM element and returns a tree of
the same shape with each element's computed styles
& boundling client rect
if that particular element has any classes attached.
const getVisualDetails = (el) => {
return {
children: Array.from(el.children || [])
.map((child) =>
getVisualDetails(child)
),
styles:
(el.classList || []).length === 0
? null
: window.getComputedStyle(el),
rect: el.getBoundingClientRect(),
};
};
Let's explore some sample usage.
// Here is what usage looks like!
const styles = getVisualDetails(document.body);
// Should return the styles of the first element
// with classes in the document.
console.log(styles.children[0].styles);
// Should return the styles of the second element
// in the first child with classes.
console.log(styles.children[0].children[1].styles);
// Should return the layout of the first element
console.log(styles.children[1].rect);
If we wrap it in a time function we can get a rough pulse on how expensive it is and running it in the console.
const time = () => {
// Time Before Computation
const then = performance.now();
const styles = getVisualDetails(document.body);
styles.children[0].styles;
styles.children[0].children[1].styles;
styles.children[1].rect;
// Subtract time before against time now.
console.log("This computation took: ", performance.now() - then, "ms");
};
time();
My results running this function on github.com:
This computation took: 5.984999999782303 ms
This computation took: 3.9449999985663453 ms
This computation took: 4.269999999451102 ms
Let's explore how we can use our new "Lazy Getter" technique to only compute what is used.
const getVisualDetails = (el) => {
return {
// We don't even walk the whole DOM in one go! Just one layer
// of depth at a time, as needed!
get children() {
delete this.children;
return (this.children = Array.from(el.children || [])
.map((child) =>
getVisualDetails(child)
));
},
// Only compute styles on demand if they're retrieved
get styles() {
delete this.styles;
return (this.styles =
(el.classList || []).length === 0
? null
: window.getComputedStyle(el));
},
// Only compute rect on demand if it is retrieved
get rect() {
delete this.rect;
return (this.rect = el.getBoundingClientRect());
},
};
};
When we first call our lazy implementation:
const styles = getVisualDetails(document.body);
Very little work is done! We just create an object with some locked and loaded
properties that are ready to do the real work if and when needed. Additionally,
we only do the work one layer at a time, so when children
is accessed, that
just returns a list of locked and loaded objects.
The actual heavy lifting of walking the DOM, computing styles, and measuring layout are all done lazily as needed.
Here are my results running the lazy version on github:
This computation took: 0.43000000005122274 ms
This computation took: 0.1250000004802132 ms
This computation took: 0.28000000020256266 ms
As we expected, the lazy version is many times faster. And less memory intensive! That is because we avoid computing or storing anything that isn't used, while the initial implementation computes the entire tree upfront even though only a handful of paths are accessed. Yikes!
An alternative implementation of self overwriting lazy getters is to define a memoized property that caches its result after its first call.
Lodash provices a handy utility called
once
which runs a function once and
caches the value. Subsequent calls return the value initially computed.
Here is an implementation of once
:
const once = (fn) => {
let cache = null;
// If we don't have it cached, compute and cache it.
// Otherwise, return the cache.
return () => (cache === null ? (cache = fn()) : cache);
};
Now we can define a property on our object that calls our memoized getter.
const args = {
cheap,
};
Object.defineProperty(args, "expensive", {
get: once(getExpensiveValue),
});
Here is what our getVisualDetails
implementation looks like with once
at our
disposal.
const once = (fn) => {
let cache = null;
return () => (cache === null ? (cache = fn()) : cache);
};
const getVisualDetails = (el) => {
const api = {};
Object.defineProperty(api, "children", {
get: once(() =>
Array.from(el.children || [])
.map((child) => getVisualDetails(child))
),
});
Object.defineProperty(api, "styles", {
get: once(() =>
(el.classList || []).length === 0
? null
: window.getComputedStyle(el)
),
});
Object.defineProperty(api, "rect", {
get: once(() => el.getBoundingClientRect()),
});
return api;
};
The important thing here, is that regardless of implementation, we want to compute the expensive value once and lazily as needed. If you're wondering how our memoized version performs, its pretty comparable to our self-overwriting implementation.
This computation took: 0.5100000003039895 ms
This computation took: 0.14000000010128133 ms
This computation took: 0.15499999972234946 ms
JavaScript is a wonderfully (and sometimes terribly) flexible language. Its call by sharing and getter semantics allow us to implement lazy evaluation for field access.
An exercise left for the reader is to implement lazy evaluation using ES2015 Proxys.
This technique of lazy evaluation is sometimes referred to as call by need. To explore this concept further I'd recommend an exploration of Haskell where the entire language is lazily evaluated.