Christian Schubert: Replikation von Method Chaining außerhalb von Objekten?

Hallo,

fürchte ja mittlerweile, dass es für mein Anliegen tatsächlich keinen optimaleren Weg gibt, aber vielleicht kann ja doch jemandem eine andere Betrachtungsweise beitragen:

Methoden von Objekten lassen sich ja wunderbar nach dem Muster func1().func2().func3() zusammenketten, (solange jedes Glied der Kette this zurückgibt).

...lässt sich das irgendwie auch außerhalb von Objekten replizieren?

(spreche hier nicht von higher order function wie map, filter, reduce, etc, die von Arrays aufgerufen werden können, die wiederum ja auch nichts anderes als Objekte sind).

function addOne(param) {
	return param + 1;
}
function timesTwo(param) {
	return param * 2;
}
function minusSeven(param) {
	return param - 7;
}

// MÜHSAM MIT -ZIG ZWISCHENSCHRITTEN
let stepOne = addOne(1); // 1 + 1 = 2
let stepTwo = timesTwo(stepOne); // 2 * 2 = 4
let finallyDone = minusSeven(stepTwo); // 4 - 7 = -3
console.log(finallyDone); // -3


// IN EINEM SCHRITT ABER SEHR UNÜBERSICHTLICH
let allInOneGo = minusSeven(timesTwo(addOne(1))); // (((1 + 1) * 2) - 7) = -3
console.log(allInOneGo); // -3

// Dummy Code // natürlich so nicht möglich weil Objektnotation
// wäre aber doch viel übersichtlicher!
// ***
// let allInOneGo = addOne(1).timesTwo().minusSeven();
// ***

Dank euch für eure Meinung,

LG Christian

  1. Hallo Christian,

    nee, geht nicht, weil Funktionen ihre Daten über Parameter bekommen.

    Methoden bekommen implizit einen weiteren Parameter, nämlich this, und das wird beim Method-Chaining ausgenutzt.

    Hm. Sorry. Ich hab gelogen. Natürlich geht es. Aber es ist bäh. Die Lösung müllt den Number-Prototypen zu. Möglicherweise ist dein realer Kontext ein anderer und Du kannst die Idee dennoch nutzen:

    Number.prototype.timesTwo = function() { return this * 2; }
    Number.prototype.addOne = function() { return this + 1; }
    Number.prototype.minusSeven = function() { return this - 7; }
    
    console.log((1).addOne().timesTwo().minusSeven());   // gibt -3 aus
    

    Die Magie liegt darin, dass Zahlen in JS zwar keine Objekt sind, sondern einfache Werte, aber in ein Number-Objekt verpackt werden, wenn man Methoden auf ihnen aufrufen will. Das nennt man boxing (und unboxing für den Rückweg), und es ermöglicht die Definition von Methoden für Zahlen.

    Weil 1.addOne() mehrdeutig ist (Dezimalpunkt oder Method-Call Operator), muss die 1 geklammert werden.

    Außer dem Vermüllen von Number.prototype ist ein weiteres Problem an dem Code, dass vier Boxing-Operationen nötig sind, und ebensoviele Unboxing-Operationen, um die Beispielzeile auszuführen. Das ist nicht effizient. Aber - es geht.

    Fun fact: Chrome definiert auf Number.prototype Delegatoren für etliche Methoden von Math. Firefox nicht...

    let x = 4;
    console.log(x.sqrt());   // 4
    console.log(x.max(7));   // 7
    

    Rolf

    --
    sumpsi - posui - obstruxi
    1. Hi,

      Hm. Sorry. Ich hab gelogen. Natürlich geht es.

      Nö, geht nicht.

      Es sollte ja ohne Objekt gehen, und das Number-Objekt ist, wie der Name andeutet, ein Objekt ...

      cu,
      Andreas a/k/a MudGuard

      1. Hallo MudGuard,

        ich weiß, ich habe die Situation manipuliert.

        Aber es geht so immerhin ohne ein explizit dafür erstelltes Objekt.

        Alles andere ist was für die Zukunft, oder für 1UPs pipe-Wrapper (den man auch chain nennen könnte).

        Man könnte 1UPs Vorschlag auch durch eine Funktion ersetzen, die ein Chaining von Funktionen konstruiert und mehrfach aufrufbar macht, ohne die Funktionsliste immer mitschleppen zu müssen.

        function chain(...fs) {
           let funcs = Array.from(fs);   // Falls fs "nur" iterierbar ist
           
           return x0 => funcs.reduce((x,f) => f(x), x0)
        }
        
        let a1m2s7 = chain(x=> x + 1,
                           y => y * 2,
                           z => z - 7);
        // oder
        let a1m2s7 = chain(addOne, timesTwo, subtractSeven);
        
        let y1 = a1m2s7(1),
            y2 = a1m2s7(2);
        

        Spannend wird die Aufgabe, einen Chainer für async-Functionen zu schreiben. Oder einen Mix aus sync und async.

        Rolf

        --
        sumpsi - posui - obstruxi
  2. ...lässt sich das irgendwie auch außerhalb von Objekten replizieren?

    Es wird zur Zeit der sogenannte Pipeline-Operator diskutiert, der soll genau das leisten. Deine Verkettung sähe damit so aus:

    let allInOneGo = 1 |> addOne |> timesTwo |> minusSeven
    

    Für Babel gibt es auch schon ein experimentelles Plugin, damit kannst du den Operator heute schon benutzen.

    Alternativ, kann man sich mit einer eigenen Funktion Abhilfe schaffen:

    function pipe(x, ...fs) {
      return fs.reduce((x,f) => f(x), x)
    }
    

    Die pipe-Funktion ist innerhalb der Functional Programming Community in JavaScript recht weit verbreitet. Anwenden lässt sie sich wie folgt:

    let allInOneGo = pipe(1, addOne, timesTwo, minusSeven)
    
    1. Hallo 1unitedpower,

      klingt spannend, aber eine Sprache mit zu vielen Operatoren neigt dazu, unlesbar zu werden.

      So ging es mir damals bei APL, als ich das versuchte zu lernen, und so geht es mir heute, wenn ich C++ angucke.

      Vor allem die Komplikationen, die bei der Anwendung von |> im Zusammenhang mit diversen Sprachkonstrukten entstehen, machen das Ding ziemlich komplex.

      Hm. Noch ein Nachteil des Fira Code Fonts. Ich habe nur das ligaturierte Zeichen ▷ gesehen, das Fira Code daraus macht, und musste erstmal Code zitieren, um zu sehen, dass es als |> aufgeschrieben wird.

      Rolf

      --
      sumpsi - posui - obstruxi
      1. So ging es mir damals bei APL, als ich das versuchte zu lernen, und so geht es mir heute, wenn ich C++ angucke.

        Ich kenne das Problem auch aus Haskell.

        Vor allem die Komplikationen, die bei der Anwendung von |> im Zusammenhang mit diversen Sprachkonstrukten entstehen, machen das Ding ziemlich komplex.

        Ich glaube die Spezifikation ist ziemlich kompliziert, weil sie sich mit allen möglichen Edge-Cases befassen muss. Aber als Programmierer kommt man mit diesen Edge-Cases ja nicht ständig in Berührung, und falls doch, dann kann man ja immernoch auf eine andere Schreibweise zurückfallen, wenn der Code mit Pipeline-Operator zu unübersichtlich wird.

        Gerade wenn man viel funktionalen Code schreibt, dann kann die mathematische Lesart verwirrend sein, weil man den Code von "rechts nach links" lesen muss (weil der innerste Teil-Ausdruck am weitesten rechts steht). Ich vermute daher dass die Unterstüzung für den Pipeline-Operator hauptsächlich aus der FP-Community kommt. Für viele Entwickler ist die Lesart von links-nach-rechts (innerster Teilasdruck am weitesten links) etwas intuitiver.

        Hm. Noch ein Nachteil des Fira Code Fonts. Ich habe nur das ligaturierte Zeichen ▷ gesehen, das Fira Code daraus macht, und musste erstmal Code zitieren, um zu sehen, dass es als |> aufgeschrieben wird.

        Ich benutze Fira-Code jetzt auch seit ein paar Jahren (ich glaube sogar auch auf den Hinweis von Gunnar hin), und nach einigen Startschwierigkeiten, bin ich sehr glücklich damit.

    2. Die Idee einer pipe Funktion ist genial. Einfach genial.

      Man fragt sich, warum man selbst nicht auf so eine effiziente und dabei zugleich triviale Idee gekommen ist. Aber vieles ist im Nachhinein betrachtet wahrscheinlich trivial, wenn man es erst einmal entdeckt hat.

      1. Hallo Christian,

        effizient? NEIN, das ist es nicht. Ich habe ein paar Messungen gemacht.

        Das ist die Basismessung (Nullpunkt), die braucht bei mir 17ms.

        const t0 = performance.now();
        let total = 0;
        
        for (let i = 0; i<10000000; i++) {
           let result = (i%10);
           tot += result;
        }
        const t1 = performance.now();
        

        Das ist die handgemachte Funktionskette, die braucht 3ms länger. Oder 0.3ns pro Aufruf von ams. Hui.

        function add1(x) { return x+1; }
        function mul2(x) { return x*2; }
        function sub7(x) { return x-7; }
        
        const t0 = performance.now();
        let total = 0;
        
        let ams = x => sub7(mul2(add1(x)));
        
        for (let i = 0; i<10000000; i++) {
           let result = ams(i%10);
           tot += result;
        }
        const t1 = performance.now();
        

        Nun der Vorschlag von 1unitedpower. Ich schreibe die eigentlich Messschleife jetzt nicht mehr ab.

        let ams = reduceChain(add1, mul2, sub7);
        
        function reduceChain(...fs) {
           return x0 => fs.reduce((x,f) => f(x), x0);
        }
        

        Die Laufzeiten der Messschleife liegen nun im Bereich von 360ms. Also 36ns pro Aufruf statt 0,3. Das ist Faktor 100.

        Es ist natürlich immer noch schnell. Und im Normalfall wird man es nicht merken.

        Aber es geht besser. Ich habe einen Chainer geschrieben, der das Verhalten der handgemachten Kette nachbaut:

        function turboChain(...fs) {
              let f = "x0";
              for (let i=0; i<fs.length; i++)
                 f = "fs[" + i + "](" + f + ")";
              return eval("x0 => " + f);
        }
        

        Ich baue eine Aufrufkette als String zusammen und compiliere den mit eval in eine Javascript-Funktion. Laufzeit: 0,8ns pro Aufruf. 💨

        Ist das nun evil eval? Ich finde nicht - der evaluierte Code ist strikt kontrolliert und ich wüsste nicht, wie man sonst in diese Geschwindkeitsklasse kommen kann. Denn der Zeitfresser ist das Zwischenspeichern der Ergebnisse.

        function ams(x0) {
           let result = add1(x0);
           result = mul2(result);
           return sub7(result);
        }
        

        landet nämlich ebenfalls in der 30ns Liga.

        Der Aufwand mit eval lohnt aber nur, wenn man die verkettete Funktion wiederverwendet. Für einen einzelnen Aufruf lohnt das nicht. Und für 1000 auch nicht - wen interessiert bei einer Onlineanwendung 1ms Laufzeitunterschied? In einem Action-Spiel wird es aber wieder spannend.

        Rolf

        --
        sumpsi - posui - obstruxi