Callback-APIs mit Closures erweitern

Closures sind ein Konzept, dass sich gerade Anfängern nicht unbedingt leicht erschließt. Callback-APIs, die einfach nicht zu den Callbacks, die man verwenden will, passen, bieten aber eine exzellente Gelegenheit für eine sinnvolle und relativ einfach zu verstehende Anwendung für Closures.

Closures in JavaScript

Ich werde hier JavaScript verwenden, um das Problem und seine Lösung zu illustrieren. Perl-Hacker finden eine Version für Perl am Ende dieses Blog-Posts, sollten aber dennoch auch den JavaScript-Teil lesen.

Das Problem

Ausgangspunkt ist das folgende kleine Skript:

'use strict';

function logMessage(prefix, msg) {
    console.log(new Date() + ': [' + prefix + ']: ' + msg);
}       

function runService(logger) {
    logger('starting service');

    // Do something.

    logger('stopping service');
}       

var services = ['cruncher', 'crasher'];
for (var i = 0; i < services.length; ++i) {
            runService(logMessage, services[i]);
}

Die Funktion logMessage() in Zeile 3 ist eine typische Logging-Funktion, die eine Log-Nachricht mit einem Zeitstempel und einem Log-Präfix ausgibt.

Darauf folgt in Zeile 7 ein Pseudo-Microservice runService(). Die Funktion erwartet eine Log-Funktion als Callback, und verrichtet dann ihre Arbeit, getreu dem althergebrachten Unix-Credo Do one thing and do it well (mach nur eine Sache, aber dafür richtig). Im vorliegenden Fall besteht diese Arbeit darin, nichts zu tun, und der Microservice erledigt diesen Job außerordentlich gut. Okay, er protokolliert zumindest Beginn und Ende der Tätigkeit mithilfe der übergebenen Logging-Funktion.

In Zeile 15 werden dann schließlich zwei solche Services, namens cruncher und crasher gestartet.

Die Ausgabe des Skriptes, entweder in der Browserkonsole oder auf der Kommandozeile, sieht in etwa so aus:

Mon May 22 2017 19:14:07 GMT+0300 (EEST): [starting service]: undefined
Mon May 22 2017 19:14:07 GMT+0300 (EEST): [stopping service]: undefined
Mon May 22 2017 19:14:07 GMT+0300 (EEST): [starting service]: undefined
Mon May 22 2017 19:14:07 GMT+0300 (EEST): [stopping service]: undefined

Hm, nein, nicht wirklich das, was wir wollten. Die Log-Meldung wird als Präfix verwendet, und dort, wo die Log-Meldung erscheinen soll, steht nur undefined.

Der Fehler ist einfach zu finden. Schauen wir uns Zeile 15 an:

runService(logMessage, services[i])

runService() erwartet ein einziges Argument, nämlich die Log-Funktion, aber wir übergeben zwei Argumente, in der naiven Hoffnung, das Präfix in den Funktionsaufruf injizieren zu können. Das wollen wir jetzt fixen:

runService(logMessage(services[i]))

Hallo? Geht's noch? Jetzt wird nicht mehr die Funktion logMessage() als Callback-Argument übergeben, sondern ihr Rückgabewert, der im vorliegenden Fall nicht nur keine Funktion sondern auch undefiniert ist. Dass kann nicht wirklich die Lösung sein!

Ein genauerer Blick auf den Microservice in Zeile 8 offenbart das Problem.

function runService(logger) {
    logger('starting service');

    // Do something.

    logger('stopping service');
}

Die Log-Funktion wird immer mit exakt einem Argument aufgerufen, und das ist der Kern des Problems. Welche Optionen gibt es?

Man könnte den Logger so umschreiben, dass er ebenfalls nur noch ein einziges Argument erwartet, und das Präfix von außerhalb erhält, beispielsweise aus einer Variablen außerhalb des eigenen Gültigkeitsbereiches, seines Scopes:

var prefix;

function logMessage(msg) {
    console.log(new Date + ': [' + prefix + ']: ' + msg);
}
// ...
for (var i = 0; i < services.length; ++i) {
    prefix = services[i];
    runService(logMessage);
}

Das ist allerdings nicht nur ein übler Hack, sondern fliegt einem auch um die Ohren, sobald mehrere Services asynchron aufgerufen werden.

Die Lösung besteht tatsächlich in der Verwendung von Closures. Damit können logMessage() und runService() in dem Zustand belassen werden, indem sie waren, und stattdessen wird als Logger ein Callback übergeben, der sich an den eigenen Kontext zur Zeit seiner Erzeugung gleichsam erinnert:

for (var i = 0; i < services.length; ++i) {
    var prefix = services[i];
    function innerLogger(msg) {
        logMessage(prefix, msg);
    }
    
    runService(innerLogger);
}

Eine Closure ist ein Funktions-Objekt, eine Referenz auf eine Funktion, die den Gültigkeitsbereich der von ihr verwendeten Variablen sozusagen huckepack in ihren Aufruf mitnimmt.

Die Ausgabe der neuen Version sieht so aus:

Mon May 22 2017 19:23:04 GMT+0300 (EEST): [cruncher]: starting service
Mon May 22 2017 19:23:04 GMT+0300 (EEST): [cruncher]: stopping service
Mon May 22 2017 19:23:04 GMT+0300 (EEST): [crasher]: starting service
Mon May 22 2017 19:23:04 GMT+0300 (EEST): [crasher]: stopping service

Voilà! Funktioniert! Und noch einmal Voilà! Endlich haben wir ein reales Problem mit Hilfe von Closures einer eleganten Lösung zuführen können.

Die Closure ist nunmehr der Kitt, der Glue-Code, zwischen dem aufrufenden API und dem Callback selber. Sie übersetzt gleichsam die vom API erwartete Funktionssignatur in die Signatur des tatsächlich verwendeten Callbacks. Auf die gleiche Art und Weise lassen sich Argumente nicht nur zufügen, sondern auch entfernen oder in ihrer Reihenfolge ändern.

Übergabe von Objekten an nicht objektorientierte APIs

Ein häufiges Problem bei der Verwendung von Legacy-APIs besteht darin, dass eine reguläre Funktion als Callback erwartet wird, aber stattdessen der Aufruf einer Instanzmethode eines Objektes gewünscht ist.

Im folgenden JavaScript-Beispiel ist der Logger nunmehr ein JavaScript-Objekt:

function Logger(prefix) {
    this.prefix = prefix;
}

Logger.prototype.logMessage = function(msg) {
    console.log(new Date + ': [' + this.prefix + ']: ' + msg);
};

Die Methode logMessage() erhält das Log-Präfix jetzt aus der Instanzvariablen prefix, so dass dieses Präfix nicht mehr bei jedem Aufruf übergeben werden muss.

Das ist ein echter Fortschritt, aber leider ist der Microservice noch genauso beschränkt wie zuvor. Wie lässt sich der neue, objektorientierte Ansatz für das Logging unterschieben? Genau! Ebenfalls wieder mit einer Closure als Adapter (englisch Wrapper) um den eigentlichen Methodenaufruf:

var services = ['cruncher', 'crasher'];
for (var i = 0; i < services.length; ++i) {
    var prefix = services[i],
        logger = new Logger(prefix);
    
    function innerLogger(msg) {
        logger.logMessage(msg);
    }
 
    runService(innerLogger);
}

Funktioniert tadellos!

Aber werfen wir einen genaueren Blick auf die Loggerklasse:

Logger.prototype.logMessage = function(msg) {
    console.log(new Date + ': [' + this.prefix + ']: ' + msg);
};

Die Verwendung der Instanzvariablen this.prefix und des Schlüsselworts this suggeriert eine kompaktere und idiomatischere Lösung des Problems:

var services = ['cruncher', 'crasher'];
for (var i = 0; i < services.length; ++i) {
    var prefix = services[i],
        logger = new Logger(prefix);
    
    runService(logger.logMessage.bind(logger));
}

Was ist denn das??? Erst einmal ausprobieren! Es funktioniert nämlich!

Bekanntermaßen ist jede JavaScript-Funktion ein Objekt, und für alle Funktionsobjekte ist eine Methode bind() definiert, die eben jenes Funktionsobjekt zurückgibt, allerdings in einem Kontext, in dem das Schlüsselwort this an den Wert gebunden ist, der als erstes Argument an bind() übergeben wurde. Was passiert, wenn bind() weitere Argumente übergeben wurden, lässt sich durch Ausprobieren einfach selbst herausfinden.

Man sollte ebenfalls im Sinn behalten, dass this nicht zwangsläufig für ein Objekt steht, sondern jede beliebige JavaScript-Variable repräsentieren kann. Das ursprüngliche Problem hätte sich mit Hilfe von bind() daher auch folgendermaßen lösen lassen::

function logMessage(msg) {
    console.log(new Date + ': [' + this.prefix + ']: ' + msg);
};
//...
runService(logMessage.bind("cruncher"))

Jetzt steht this für eine einfache Zeichenkette, und dennoch funktioniert alles weiterhin wie gehabt.

Keinen Bock auf Perl? Kein Problem! Der natürliche nächste Schritt, nachdem man bind() verstanden hat, besteht in der Untersuchung der eng verwandten Methoden call() und apply().

Perl

Die direkte Übersetzung der ursprünglichen JavaScript-Lösung nach Perl sähe so aus:

use strict;

sub log_message {
    my ($prefix, $msg) = @_;

    my $now = localtime;
    print STDERR "[$now][$prefix]: $msg\n";
}

sub run_service {
    my ($logger) = @_;

    $logger->("starting service");

    # Do something.

    $logger->("stopping service");
}

foreach my $service ("cruncher", "crasher") {
    my $prefix = $service;

    sub inner_logger {
        my ($msg) = @_;

        log_message $prefix, $msg;
    }

    run_service \&inner_logger;
}

Funktioniert nur leider nicht, wie erwartet:

[Tue May 23 08:08:46 2017][cruncher]: starting service
[Tue May 23 08:08:46 2017][cruncher]: stopping service
[Tue May 23 08:08:46 2017][cruncher]: starting service
[Tue May 23 08:08:46 2017][cruncher]: stopping service

Das Präfix ist immer cruncher. Offensichtlich behält die Variable $prefix den Wert, den sie zum Zeitpunkt der Definition der neuen, inneren Funktion hatte. Die Standardlösung hierfür besteht in der Verwendung einer anonymen Subroutine:

#! /usr/bin/env perl

use strict;

sub log_message {
    my ($prefix, $msg) = @_;

    my $now = localtime;
    print STDERR "[$now][$prefix]: $msg\n";
}

sub run_service {
    my ($logger) = @_;

    $logger->("starting service");

    # Do something.

    $logger->("stopping service");
}

foreach my $service ("cruncher", "crasher") {
    my $prefix = $service;

    my $logger = sub {
        my ($msg) = @_;

        log_message $prefix, $msg;
    };

    run_service $logger;
}

Jetzt funktioniert alles wie erwartet. Allgemein sind Closures in Perl immer anonyme Subroutine, und so lassen sich auch die lästigen Warnungen variable $xyz will not stay shared (oder subroutine xyz will not stay shared) unterdrücken.

Es gibt jedoch zwei Fallstricke bei der Verwendung von Closures in Perl: Die Variablendefinition in Zeile 23 sieht redundant aus, und in JavaScript ist sie tatächlich auch redundant. In Perl ist sie zwingend. Die Schleifenvariable $service ist innerhalb der Closure undefiniert. Sie muss zwingend einer anderen Variablen, hier $prefix, zugewiesen werden. Dies stellt ein etwas überraschendes Verhalten dar.

Ein anderer populärer Fehler besteht darin, das Semikolon in Zeile 25 zu vergessen, was in einem Kompilierungsfehler resultiert:

Bareword found where operator expected at microservice.pl line 31, near "run_service"
    (Missing semicolon on previous line?)
syntax error at microservice.pl line 31, near "run_service "
Global symbol "$logger" requires explicit package name (did you forget to declare "my $logger"?) at microservice.pl line 31.
Execution of microservice.pl aborted due to compilation errors.

Es wird nämlich etwas (eine Closure) einer Variablen ($logger) zugewiesen, und diese Zuweisung ist ein normaler Ausdruck, der durch ein Semikolon abgetrennt werden muss. Das wird gerne vergessen, weil man nicht gewohnt ist, ein Semikolon hinter die Definition einer Funktion zu setzen.

Objektmethoden als Callbacks

Ganz wie in JavaScript lässt sich dem Callback-API auch ein Methodenaufruf unterjubeln, zum Beispiel so:

#! /usr/bin/env perl

package Logger;

use strict;

sub new {
    my ($class, $prefix) = @_;

    bless \$prefix, $class;
}

sub logMessage {
    my ($self, $msg) = @_;

    my $now = localtime;
    print STDERR "[$now][$$self]: $msg\n";
}

package main;

use strict;

sub run_service {
    my ($logger) = @_;

    $logger->("starting service");

    # Do something.

    $logger->("stopping service");
}

foreach my $service ("cruncher", "crasher") {
    my $prefix = $service;
    
    my $logger = Logger->new($prefix);
    
    my $callback = sub {
        my ($msg) = @_;

        $logger->logMessage($msg);
    };

    run_service $callback;
}

Das Old-School-Callback-API arbeitet jetzt mit einem objektorientierten Perl-Programm zusammen.

Und wie sieht es mit der JavaScript-Lösung mit bind(), die weiter oben beschrieben wurde, in Perl aus? Geht das? Aber sicher! Es wäre doch nicht Perl, wenn sich keine idiomatische und völlig transparente äquivalente Funktionalität in Perl abbilden ließe, und das auch noch mit lediglich wenigen Zeilen Code. Wie es genau geht, lässt sich in einem zukünftigem Blog-Post nachlesen!


blog comments powered by Disqus