Ich habe mich in den letzten Wochen viel mit JavaScript-Funktionen beschäftigt, die andere JavaScript-Funktionen umbauen. Ein gutes Beispiel für solche Funktionen ist die_.flip()
-Funktion von Lodash, die die Parameter-Reihenfolge einer Funktion umdreht:
function oldFn (a, b, c) { return a + b + c; } let newFn = _.flip(oldFn); console.log(newFn("a", "b", "c")); // > "cba"
Funktionsumbau-Funktionen finde ich besonders dann nützlich, wenn ich eine Kompatibilitätsschicht bauen möchte, weil z.B. ein bestimmtes Modul plötzlich wegen eines Updates ganz andere Funktionsnamen und Parameter-Reihenfolgen hat. Statt den kompletten eigenen Code auf die neue API anzupassen kann man oft auch einfach die alte API rekonstruieren diese Kompatibilitätsschicht zwischen den eigenen, nicht weiter zu ändernden Code und das problematische Modul klemmen. Häufig kommt man dabei sehr weit, indem man einfach die neuen Funktionen des problematischen Moduls nimmt und sie mit Funktionen wie eben _.flip()
oder _.rearg()
wieder in die alte Form bringt.
Es gibt da nur ein kleines Problem:
function oldFn (a, b, c) { return a + b + c; } let newFn = _.flip(oldFn); console.log(oldFn.length); // > 3 console.log(newFn.length); // > 0
So gut wie immer liefern Funktionsumbau-Funktionen Funktionen, die eine length
von 0 haben. Ein „Funktionsumbau“ bedeutet immer, dass um eine gegebene Eingangs-Funktion ein Wrapper konstruiert wird, der zusätzliches Verhalten implementiert und am Ende des Tages dann doch wieder die Eingangs-Funktion aufruft. So könnte man flip()
zum Beispiel wie folgt bauen:
function flip (inputFn) { return function (...args) { return inputFn.apply(this, args.reverse()); }; }
Die Funktion, die flip()
zurückgeben würde, hätte natürlich eine length
von 0, da sie variadisch ist – sie hat ja auch nichts weiter zu tun, als die an sie übergebenen Parameter in umgekehrter Reihenfolge an inputFn()
durchzureichen. Das Problem dabei ist, dass Funktionen ohne definierte length
manchmal schlecht weiter umbauen lassen, denn viele Umbau-Operationen müssen wissen, wie viele Parameter eine Funktion haben möchte. Currying ist ein solches Beispiel:
function oldFn (a, b, c) { return a + b + c; } let flippedFn = _.flip(oldFn); let curriedFlippedFn = _.curry(flippedFn); console.log(curriedFlippedFn("a")); // > "aundefinedundefined"
Currying ist das Zerlegen einer Funktion mit N Parametern in N Funktionen mit einem Parameter … was natürlich nur klappt, wenn N (d.h. die length
) bekannt ist! In Lodash könnten wir den gewünschten N-Wert als zweiten Parameter _.curry()
hineinstecken, aber das ist doch eigentlich absurd! Kann man kein _.flip()
konstruieren, das Funktionen mit der korrekten length
liefert? Es stellt sich raus: man kann! Es gibt sogar mehrere Lösungen. Und die haben alle ihre ganz eigenen Vor- und Nachteile.
Das Ziel
Ich möchte eine Funktion ary(fn, n)
konstruieren, die eine Funktion mit length = n
zurückgibt. Diese wiederum liefert das Ergebnis des Aufrufs von fn
mit nur den den ersten n
an sie übergebenen Parametern.
Lodash hat eine solche Funktion, die jedoch nur den zweiten Teil des Ziels erreicht. Sie reicht nur die ersten n
Parameter weiter, hat aber immer noch die falsche length
. Für viele Fälle ist das auch völlig ausreichend. Der geneigte JavaScriptler hat vielleicht schon mal dieses Problem angetroffen:
[ "0", "1", "2" ].map(parseInt); // Ergibt [ 0, NaN, NaN ]
Das Ergebnis kommt zustande, indem die map()
-Methode die Funktion parseInt()
mit mehr als einem Parameter aufruft:
// Was eigentlich passiert [ "0", "1", "2" ].map(function (str, idx, array) { return parseInt(str, idx, array); }); // > [ 0, NaN, NaN ]
parseInt()
erhält neben dem zu parsenden String noch den Index des Strings in dem Array sowie das Array selbst. Der Index wird dabei als Basis für das Zahlensystem verwendet und der dritte Parameter einfach ignoriert. Mit der Lodash-Funktion _.ary()
können wir parseInt()
zu einer Funktion umbauen, die nur einen Parameter annimmt und für alle übrigen Werte auf ihre Defaults zurückgreift:
// Baut einen Wrapper um parseInt(), der nur einen Parameter weitergibt var unaryParseInt = _.ary(parseInt, 1); console.log([ "0", "1", "2" ].map(unaryParseInt)); // > [0, 1, 2] // Problem: length === 0 console.log(unaryParseInt.length) // > 0
Dann probieren wir doch mal, unser eigenes ary(fn, n)
mit richtiger length
zu bauen. Ich habe hierfür drei Methoden gefunden: die Hirn-Aus-Methode, die Holzhammer-Methode und die universelle Methode.
Die Hirn-Aus-Methode
Die wohl einfachste denkbare Lösung sieht wie folgt aus:
function ary (fn, n) { if (n === 0) return function () { return fn.call(this); }; if (n === 1) return function (a) { return fn.call(this, a); }; if (n === 2) return function (a, b) { return fn.call(this, a, b); }; if (n === 3) return function (a, b, c) { return fn.call(this, a, b, c); }; if (n === 4) return function (a, b, c, d) { return fn.call(this, a, b, c, d); }; if (n === 5) return function (a, b, c, d, e) { return fn.call(this, a, b, c, d, e); }; throw new Error("NOPE!") }
Die Vorteile dieses Ansatzes sind zahlreich:
- Funktioniert in jedem noch so alten Browser
- Simpler Code, den jeder sofort versteht
- Bei Bedarf sehr einfach zu debuggen oder zu erweitern
- Garantiert schnell
Eine meiner Programmier-Leitlinien lautet „clever ist das Gegenteil von intelligent“ – lieber ein Problem erst mal auf die billige Tour lösen, bevor man sich in Overengineering verliert. Daher finde ich diesen sehr simplen Ansatz erst mal ganz sympathisch. Dass sich mit dieser Variante maximal fünfstellige Funktionen erzeugen lassen, würde ich nicht als Problem betrachten. Erstens sollte man nie mehr als fünf Parameter brauchen und zweitens ließe sich die Funktion bei Bedarf problemlos entsprechend erweitern. Was will man mehr?
Die Holzhammer-Methode
Wenn die Hirn-Aus-Methode mal zu billig daherkommt, gibt es noch eine weitere Möglichkeit … moderne JavaScript-Engines vorausgesetzt. Seit ES6 ist die length
-Eigenschaft von Funktionen veränderbar. Während sie in ES5 noch non-configurable war, ist diese Limitierung in ES6 entfallen. Also lässt sich das Problem ganz einfach mit Object.defineProperty()
lösen:
function ary (fn, n) { const wrapper = function (...args) { return fn.apply(this, args.slice(0, n)); }; Object.defineProperty(wrapper, "length", { value: n, configurable: true }); return wrapper; }
Sofern die Zielplattform dieses subtile ES6-Feature unterstützt, ist damit unser Ziel erreicht. Bei diesem Ansatz könnte man die Gelegenheit auch nutzen, der zurückgegebenen Funktion einen Namen zu verpassen, indem man die name
-Property (ebenfalls in ES6 configurable
) überschreibt. Das sorgt im Fehlerfall für schönere Stack Traces, denn im Moment wäre unsere Wrapper-Funktionen schließlich noch anonym und würde im Stack Trace bestenfalls als „wrapper“ benannt. Aber das muss ja nicht sein:
function ary (fn, n) { const wrapper = function (...args) { return fn.apply(this, args.slice(0, n)); }; Object.defineProperties(wrapper, { length: { value: n, configurable: true }, name: { value: `${n}ary${fn.name}`, configurable: true } }); return wrapper; } function foo (callback) { callback(); } var unaryFoo = ary(foo, 1); unaryFoo(function cb () { throw new Error(); }); /* Uncaught Error at cb (test.js:19) at foo (test.js:14) at 1aryfoo (test.js:3)/*
Der einzige Haken an der Holzhammer-Methode ist, dass sie moderne JS-Engines voraussetzt und Transpiler oder Polyfills nicht helfen können. Für die älteren Browser brauchen wir also noch eine andere Lösung.
Die Function-Constructor-Doppelwrapper-Methode
Mit dem Function-Constructor lässt sich problemlos eine n-stellige Funktion erzeugen:
new Function ([arg1[, arg2[, ...argN]],] functionBody)
Wenn wir über den unappetitlichen Aspekt hinwegsehen, dass wir den Funktionscode als String bereitstellen müssen, gibt es aber noch ein weiteres Problem: haben wir einen n-stelligen Wrapper mit dem Function-Constructor erzeugt, bekommen wir die zu wrappende Funktion nicht mehr ohne weiteres dort hinein! Dem Function-Constructor entsprungene Funktionen sind keine normalen Closures, sondern werden immer im globalen Scope erzeugt. Die innerhalb von ary(fn, n)
erstellte Funktion kann weder n
noch fn
sehen, wobei zumindest letzteres nötig wäre – irgendwann muss der Wrapper schließlich die Original-Funktion aufrufen! Also machen wir das folgende:
- Eine einstellige Funktion wird dem Function-Constructor erzeugt. Der eine Parameter ist die zu wrappende Funktion.
- Die mit dem Function-Constructor erzeugte einstellige Funktion lassen wir eine n-stellige Funktion zurückgeben, die die zu wrappende Funktion aufruft (das ist der Parameter der erzeugenden einstelligen Funktion)
- Das Ergebnis des Aufrufs der mit dem Function-Constructor erzeugten Funktion mit der zu wrappenden Funktion wird aus
ary(fn, n)
zurückgeben
Das liest sich in Prosa sehr viel komplizierter als, es am Ende im Code ist. Eigentlich ist das umständlichste, die Parameter-Liste für die n-stellige Funktion zu erzeugen (ein String "x1, x2, xn"
):
function ary (fn, n) { const argsList = Array(n).fill("x").map((x, i) => x + i).join(","); const name = `_${n}ary${fn.name}`; const createWrapper = new Function("fn", `return function ${name} (${ argsList }) { return fn.call(this, ${ argsList }); }`); return createWrapper(fn); }
Dieser Ansatz funktioniert mit im Prinzip jedem Browser. Für ältere JavaScript-Umgebungen müssten wir auf die Template-Literals verzichten und die Erzeugung der argsList
angepasst werden, aber das wäre kein großes Problem. Der Name der Wrapper-Funktion erhält hier einen Unterstrich als Präfix, da new Function()
im Prinzip ein eval()
darstellt und sich der JS-Parser an einem Funktionsnamen, der mit einer Zahl beginnt, stören würde. Vermutlich ist damit zu rechnen, dass diese Lösung wegen des Einsatzes von new Function()
von allen drei Möglichkeiten nicht die beste Performance bietet.
Zusammenfassung
Im direkten Vergleich gibt es keinen klaren Sieger unter den drei Methoden:
- Hirn-aus-Methode: simpel, schnell, auch in alten Browsern, Maximal-Length limitiert
- Holzhammer-Methode: universell, Funktionsnamen möglich, nur in modernen Browsern
- Function-Constructor-Doppelwrapper-Methode: universell, auch in alten Browsern, Funktionsnamen möglich, komplexer Code, eval (via Function-Constructor)
Alles in Allem bin ich mir auch nicht sicher, welche Methode mir am besten gefällt. Die Hirn-aus-Methode ist mit Sicherheit der in jeder Hinsicht am limitierteste Kandidat, aber spielen diese Limitierungen wirklich eine Rolle? Bis runter zu welchem Browser-Fossil funktioniert die Holzhammer-Methode? Wie langsam sind die von der Function-Constructor-Doppelwrapper-Methode erzeugten Funktionen wirklich und würde das in der Praxis eine spürbare Auswirkung haben? Das Thema verdient weitere Forschung.