Was soll an Funktionen so besonders sein? Sie begleiten uns doch fast seit Anbeginn der Softwareentwicklung und mit der funktionalen Programmierung existiert auch ein entsprechendes Paradigma. Was also macht Go an dieser Stelle dann so besonders? Ganz einfach: nichts. Doch ganz dem Motto der Sprache entsprechend, lässt sich die Arbeit mit Funktionen und ihren Verwandten, den Methoden, ganz pragmatisch an. Und ebenso können sie Bestandteil schöner Lösungen werden.
Fangen wir mit den ganz einfachen Funktionen an. Sie können entweder kein, ein, mehrere oder eine variable Anzahl von Argumenten annehmen. Auf der Rückgabeseite gilt ähnliches, also kein, ein oder mehrere Rückgabewerte.
func SayHello() { … }
func Div(v …float64) (float64, error) { … }
Während die erste Funktion einfach in sich geschlossen ist, ohne Argumente und Rückgabe, nimmt die zweite vonn gar keinem Argument bis zu einer unbegrenzten Anzahl float64 entgegen. Diese sollen in der Funktion dividiert werden. Und da 0 nicht so gut wäre, wird dies geprüft und im entsprechenden Fall ein Fehler ausgegeben. Hier ist zu sehen, wie dieser als letzter zurückgegeben wird. Dies ist eine der Konventionen in Go: Fehler werden allein zurückgegeben oder sind die letzten der Werte.
Diese Funktionen überraschen noch nicht. Schön ist jedoch, dass sie auch nur Typen sind. So lässt sich ein eigener Funktionstyp leicht deklarieren:
type FilterFunc func(string) bool
Dort, wo dieser Typ dann zum Einsatz kommt, kann die Implementierung recht einfach ausgetauscht werden (Listing 1).
Listing 1
func Filter(in []string, isOK FilterFunc) []string {
var out []string
for _, s := range in {
if isOK(s) {
out = append(out, s)
}
}
return out
}
Beim Aufruf kann die jeweilige Filterfunktion übergeben werden. Wenn zum Beispiel alle Strings mit mehr als zwanzig Zeichen aus einer Menge von Strings herausgefiltert werden sollen, geht dies auf folgende, simple Weise:
out := Filter(in, func(s string) bool {
return len(s) <= 20
})
In diesem Fall wird eine anonyme, an Ort und Stelle definierte Funktion übergeben. Doch dies sieht Go glücklicherweise nicht so genau. Wichtig ist nur die Signatur der Funktion; damit können auch zuvor oder in anderen Packages definierte Funktionen übergeben werden.
func MaxTwenty(s string) bool {
return len(s) <= 20
}
. . .
out := Filter(in, MaxTwenty)
Wichtig ist, an dieser Stelle nur den Bezeichner der Funktion zu übergeben. Dies gilt für Funktionen, die Variablen zugewiesen sind, für statische Funktion und auch für Methoden, denn auch diese lassen sich als Argument übergeben. Auf diese Weise weiß der Compiler, dass nur die Referenz auf die Funktion übergeben wird. Ein versehentlicher Aufruf der Funktion, zum Beispiel via Filter(in, MaxTwenty(„foo“)), würde bedeuten, dass der Rückgabewert von MaxTwenty() an Filter() übergeben wird. Dies ist jedoch ein bool und nicht der definierte Funktionstyp.
Doch Funktionen können nicht nur Argumente sein, sie können auch als Rückgabewerte von Funktionen zurückgegeben werden. Das Schöne dieser Funktionen ist, dass sie Zugriff auf die ihr übergeordneten Variablen haben. Dies schließt auch die Argumente des Erzeugers ein. Auf diesem Weg lässt sich die zurückgegebene Funktion leicht parametrisieren.
Listing 2
func MakeFilter(max int) FilterFunc {
return func(s string) bool {
return len(s) <= max
}
}
. . .
out := Filter(in, MakeFilter(80))
So haben wir in Listing 2 nun alles: Eine Funktion, die eine anonyme Funktion zurückgibt und eine Funktion, die diese wiederum als Argument entgegennimmt. Diese schon seit langen Zeiten bekannten Funktionen höherer Ordnung hat Go auf einfache und pragmatische Art und Weise integriert.
Wie oben erwähnt, sind Methoden eigentlich auch nichts anderes als Funktionen. Sie haben jedoch den Vorteil, dass sie Zugriff auf ihre jeweilige Instanz haben, bei Strukturen also deren Felder als Parameter nutzen können. Doch es müssen nicht immer komplexe Strukturen sein. Manchmal genügt auch nur ein Wert, wie im obigen Beispiel die Länge der zu filternden Strings. Hier lässt sich praktisch ein neuer Typ direkt vom Build in int ableiten. Hinzu kommt eine Methode mit der gleichen Signatur der Filterfunktion. Sie nutzt dann den Integerwert als Parameter. Damit ist dann, wie in Listing 3 zu sehen, der Filter flexibel.
Listing 3
type LengthFilter int
func (lf LengthFilter) IsOK(s string) bool {
return len(s) <= lf
}
. . .
lf := LengthFilter(50)
out := Filter(in, lf.IsOK)
Die erwähnte Filter()-Funktion über eine Datenmenge, in unserem Fall ein Slice von Strings, ist bereits ein gängiges Muster für Funktionen mit Funktionen als Argument. In diese Familie gehören auch Map() und Aggregate(). Die erste würde bei String Slices eine func(string) string erwarten. Diese Funktionen könnten dann alle Strings in Groß- oder Kleinbuchstaben umwandeln oder alle via Cut oder Padding auf eine einheitliche Länge bringen (Listing 4).
Listing 4
type MapFunc func(string) string
func Map(in []string, update MapFunc) []string {
var out []string
for _, s := range in {
out = append(out, update(s))
}
return out
}
. . .
out := Map(in, func(s string) string {
return strings.ToUpper(s)
})
Da die Ein- und Ausgabe von Filter() und Map() hier Mengen des gleichen Typs sind, lassen sie sich prima in Reihe schalten, wie in Listing 5 ersichtlich ist.
Listing 5
deDomains := Filter(
Map(emails, ExtractDomain),
MakeDomainFilter("de"),
)
Natürlich können aber auch Map()-Funktionen mit unterschiedlichen Typen entworfen werden, zum Beispiel vom String zum Integer, um aus einem Slice von Strings ein Slice mit deren Längen zu erzeugen. Bei Aggregate() geht es hingegen um die Reduktion einer Menge von Werten zu einem (Listing 6).
Listing 6
type AggregateFunc func(int, string) int
func Aggregate(
initial int
in []string,
aggregate AggregateFunc,
) int {
var out int = initial
for _, s := range in {
out = aggregate(out, s)
}
return out
}
. . .
length := Aggregate(0, in, func(v, s string) int {
return v + len(s)
})
Derartige Funktionen für verschiedene Typen sowie unterschiedlichen Filter-, Mapping- und Aggregationsfunktionen bilden ein praktisches Paket. Allerdings ist es wegen der heutigen statischen Typisierung in Go noch etwas umständlich – es sind einfach zu viele Implementierungen notwendig. Hier sind dynamisch typisierte Sprachen im Vorteil. Mit den Generics in Go wird sich dies jedoch ändern.
Ein weiteres schönes Beispiel ist die Konfiguration strukturierter Typen, bei denen die Parameterfelder in den Strukturen oft privat sein sollen. Gleichzeitig sollen sie über Standardwerte verfügen und nur optional geändert werden. Ich kann natürlich einen Konstruktor mit allen Parametern erzeugen, was aber ein explizites Setzen notwendig macht. Oder ich erzeuge eine Anzahl von unterschiedlichen Konstruktoren, doch dann ist der benötigte später bestimmt nicht vorhanden – Murphy lässt grüßen.
Hier sind Funktionen für die Optionen, Funktionen für das Erzeugen dieser Optionen und ein Konstruktor mit einer variablen Anzahl Optionen nötig. Klingt etwas verwirrend? Die Umsetzung ist es aber weniger. In unserem Beispiel sei der Typ Pinger im Package pinger. Der Typ pingt regelmäßig in einer Go-Routine eine IP-Adresse an und bei einer Anzahl ausgefallener Antworten ruft er einen Callback auf, klarer wird es anhand des Beispiels in Listing 7.
Listing 7
type Callback func(Pinger)
type Pinger struct {
ip net.IP
interval time.Duration
tolerance int
callback Callback
shallLog bool
}
Für die Optionen wird nun ein weiterer Typ definiert: type Option func(*Pinger). Dessen Implementierungen werden dem Konstruktor übergeben. Nach dem Setzen der Standardwerte werden die Optionen auf den erzeugten Pinger angewandt. Erst danach wird die Goroutine gestartet und die neue Instanz zurückgegeben (Listing 8).
Listing 8
func New(options ...Option) *Pinger {
p := &Pinger{
ip: net.IPv4(127, 0, 0, 1),
interval: 5 * time.Second,
tolerance: 3,
callback: func(cbp Pinger) {
fmt.Fprintf(
os.Stderr,
"pinging %v failed\n",
cbp.ip,
)
},
shallLog: true,
}
for _, option := range options {
option(p)
}
go p.backend()
return p
}
Doch woher kommen nun diese Optionen? Für ihre Erzeugung sind andere Funktionen zuständig, individuell benannt nach dem Parameter, den sie setzen. Sie haben Argumente, die sie validieren, bevor sie den entsprechenden Wert setzen. Bei zusammenhängenden Parametern können diese individuellen Optionsfunktionen auch mehrere Argumente haben und Felder setzen. Und bei booleschen Werten können auch zwei Funktionen ohne Argumente aber mit sprechenden Namen den Code lesbarer machen (Listing 9).
Listing 9
func IP(ip net.IP) Option {
return func(p *Pinger) {
if ip != nil {
p.ip = ip
}
}
}
func Interval(i time.Duration) Option {
return func(p *Pinger) {
p.interval = i
}
}
. . .
func DeactivateLogging() Option {
return func(p *Pinger) {
p.shallLog = false
}
}
Nun kann der Pinger in vielfältiger Form erzeugt werden: ohne Optionen, also nur mit Standardwerten, mit einigen Optionen sowie mit allen Optionen. Und je mehr ein Typ konfigurierbar ist, umso vorteilhafter ist dieser Weg.
Listing 10
p1 := pinger.New()
p2 := pinger.New(pinger.IP(net.IPv4(8, 8, 8, 8)))
p3 := pinger.New(
pinger.IP(net.IPv4(8, 8, 8, 8)),
pinger.Interval(time.Minute),
pinger.Tolerance(5),
pinger.Callback(func(p Pinger) {
failures <- p
}),
pinger.DeactivateLogging(),
)
Schön ist, dass sich Funktionen auch über Channels versenden lassen. Dies lässt sich bei nebenläufigen Typen nutzen, die nur eine Aktion gleichzeitig ausführen sollen. Dem Actor Model folgende Sprachen, wie zum Beispiel Erlang/OTP, versenden hierzu Nachrichten in eine Messagebox und verarbeiten diese sequenziell. Dies lässt sich in Go mit der Verarbeitung von Funktionen innerhalb einer Go-Routine erreichen. Sie werden über einen Channel an die verarbeitende Schleife geschickt, so geschehen im Code aus Listing 11.
Listing 11
type MyActor struct {
a int
b bool
c string
tasks chan func()
}
func New() *MyActor {
ma := &MyActor{
tasks: make(chan func(), 1),
}
go ma.backend()
return ma
}
func (ma *MyActor) backend() {
for task := range tasks {
task()
}
}
func (ma *MyActor) do(task func()) {
wait := make(chan struct{})
ma.tasks <- func() {
task()
close(wait)
}
<-wait
}
Nun können die entsprechenden Funktionen für Änderungen oder Abfragen der Felder implementiert werden. Hierbei sorgt die Methode do() für die Synchronisierung der Aufrufe. In ihr kann auch ein Timeout implementiert werden. Alle so versandten Tasks werden sequenziell verarbeitet und konkurrierende Zugriffe auf die Felder des Actor werden vermieden (Listing 12).
Listing 12
func (ma *MyActor) SetA(v int) {
ma.do(func() {
ma.a = v
})
}
func (ma *MyActor) IsPositive() bool {
var positive bool
ma.do(func() {
positive = ma > 0
})
return positive
}
Es gibt noch viele weitere praktische Anwendungsfälle für Funktionen als Argument, als Rückgabewert oder auch als Feld eines Structs. So kann eine State Machine implementiert werden, in der alle Event-verarbeitenden Funktionen die gleiche Signatur haben. Ein Rückgabewert gibt einen solchen Handler zurück, der dann das nächste Event verarbeitet. Das Ende wird erst mit Rückgabe von nil erreicht (Listing 13).
Listing 13
type Event struct { ... }
type EventHandler func(e Event) (EventHandler, error)
type StateMachine struct {
a int
b bool
c string
handle EventHandler
}
func New() *StateMachine {
. . .
}
func (sm *StateMachine) Handle(e Event) error {
if sm.handler == nil {
return errors.New("...")
}
h, err := sm.handle(e)
if err != nil {
sm.handle = nil
return err
}
sm.handle = h
return nil
}
Nun können die entsprechenden Event Handler implementiert werden, wie in Listing 14 dargestellt. Sie werten jeweils das Event aus, können die Felder der State Machine verändern und eine Referenz auf sich selbst, einen anderen Handler oder nil zurückgeben.
Listing 14
func (sm *StateMachine) idle(
e *Event,
) (EventHandler, error) {
...
}
func (sm *StateMachine) active(
e *Event,
) (EventHandler, error) {
...
}
func (sm *StateMachine) outOfService(
e *Event,
) (EventHandler, error) {
...
}
Bleibt noch eine spezielle Variante. Da Funktionen auch nur Typen sind und Typen über Methoden verfügen können, können diese Methoden von ihrem Funktionstyp Gebrauch machen. Ein schönes Beispiel ist das Package net/http. Handler implementieren das Interface http.Handler. Manchmal benötigt man aber nur eine einfache Funktion. In diesem Fall implementiert man den Funktionstyp http.HandlerFunc. Dieser verfügt über die Methode ServeHTTP() des Interface, die Implementierung ruft einfach nur ihren Funktionstyp auf.
Listing 15
type HandlerFunc func(w ResponseWriter, r *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
Diese Varianten geben einen Einblick in die Leistungsfähig von Funktionen und wie sie sich nahtlos in die Welt anderer Funktionen oder strukturierter Typen einbetten. Ob nun als Typen, als Parameter, als Rückgabewert oder als Felder von Structs, ob nun mit Namen oder anonym. Sie sind ein wichtiges Mittel bei der Implementierung leistungsfähiger, flexibler und wartungsfreundlicher Lösungen. Weitere Informationen zu den in diesem Teil der Golumne angesprochenen Themen findet der geneigte Leser unter: