Die Golumne

Kreative Lösungen in Go: Das Zusammenspiel von Interfaces, Methoden und Typen

Frank Müller

© Reneé French (CC BY 3.0 DE) ©SuS Media

In der Welt der Programmiersprachen ist Googles Spross Go ein noch recht junger Vertreter. Doch in den acht Jahren seit ihrer Vorstellung erreichte die Sprache immer mehr Entwickler. War ihr Einsatz anfangs noch auf die Cloud und Systemprogramme fokussiert, so findet man Go nun zunehmend auch in anderen Anwendungsbereichen wieder. In den bekannten Statistiken von Tiobe, GitHub, Stack Overflow, RedMonk, IEEE oder dem PYPL-Index rückt Go kontinuierlich Platz um Platz vorwärts. Ist Google damit ein guter, wenn nicht gar „der“ C-Nachfolger gelungen? Eine kurze Vorstellung der Sprache bildet den Auftakt zu unserer neuen Kolumne.

Auf den ersten Blick macht Go vielleicht einen imperativen Eindruck – die Sprache kann ihre Wurzeln eben nicht verleugnen. Okay, dazu kommt dann noch eine Art Objektorientierung (da ja Methoden möglich sind), aber richtig vollwertig, wie man es erwarten würde, ist sie auch nicht. Nicht zuletzt sind die Basistypen auch nur einfache Typen ohne Methoden. Und auf dieser Basis soll eine moderne und flexible Software möglich sein? Na klar!

Die Flexibilität der Sprache ergibt sich aus dem Zusammenspiel von Funktionstypen, Methoden und Interfaces. Sie erlauben elegante Konstrukte für gut lesbare Programme, ein wichtiger Aspekt für wartbare Softwaresysteme in großen Teams. Eine kleine und praktische Beispielbibliothek soll dies nun demonstrieren. Sie soll bei der nahezu alltäglichen Aufgabe helfen, eine Reihe von Strings zu verarbeiten. Natürlich kann dies mit der Standardbibliothek und den Mitteln der Sprache jedes Mal direkt implementiert werden. Doch wir Entwickler sind ja faul und wollen möglichst alles automatisieren, oder? Daher lieber einmal etwas Kopfarbeit in den StringProcessor stecken, dann kann man später von ihm profitieren.

Die Basis

Ein kurzes Nachdenken führt schon einmal zu den ersten beiden Interfaces. Mich interessiert anfangs noch nicht, wo meine Strings herkommen oder wo sie später hingeschrieben werden sollen. Dennoch muss ich für den Prozessor einen Leser und einen Schreiber haben. Mit Schnittstellen bietet mir Googles Sprache die Möglichkeit, meine Erwartungshaltung an entsprechende Typen zu definieren, ohne diese konkret implementieren zu müssen. Dies überlasse ich hingegen den späteren Nutzerinnen. Sie können aus Dateien oder aus dem Netz lesen, ebenso können sie in Datenbanken oder Queues schreiben. Wesentlich hier ist, dass sich der StringProcessor auf die gewünschte Funktionalität verlassen kann:

package strproc

import "errors"

var EOF = errors.New("eof")

type LineReader interface {
  ReadLine() (string, error)
}

type LineWriter interface {
  WriteLine(line string) error
}

Doch diese beiden Dinge sind nicht genug. Ich möchte das Verhalten des Prozessors mit verschiedenen Optionen beeinflussen können. Hierzu gehört das optionale Überspringen einer ersten Anzahl von Strings oder die Begrenzung der Anzahl der verarbeiteten Strings. Weitere Optionen sind das Puffern der Ausgabe im Gegensatz zum unmittelbaren Schreiben in den LineWriter oder der Umgang mit Fehlern. Für diese Optionen möchte ich im StringProcessor Standardwerte nutzen, welche die Nutzerin bei Bedarf setzt. Mit den hier genannten vier Optionen wäre dies noch als Parameter möglich, mein Constructor hätte jetzt sechs davon:

type ErrorHandler func(err error) error

type StringProcessor struct {
  ...
}

func New(
  r LineReader,
  w LineWriter,
  skip, max int,
  buf bool,
  eh ErrorHandler) *StringProcessor { ... }

Schön ist das aber nicht. Welche Werte muss ich zum Beispiel übergeben, wenn ich eigentlich nichts ändern möchte? Den ausufernden Kommentar für godoc kann man sich einfach ausmalen. Natürlich können für die einfachen Typen Konstanten mit den Defaults definiert werden:

const (
  DefaultSkip = 0
  DefaultMax  = 0
)

Doch mit zusammengesetzten Typen wird es schwieriger. Dinge wie beispielsweise []string lassen sich nicht als Konstanten definieren. Hier sind globale Variablen in der Form var DefaultFoos = []string{„a“, „b“, „c“} nötig. Dies ist unsicher, denn der Wert kann jederzeit verändert werden. Daneben wird diese Variante mit jedem weiteren Parameter unhandlich. Komplexere Typen mit einer umfassenden Parametrisierung lassen sich so nur umständlich realisieren.

Als Alternative wäre ein struct mit allen Optionen möglich. Hier müsste ich dann nur die Werte setzen, die ich verändern möchte, alle weiteren werden durch Go mit ihrem jeweiligen Standardwert initialisiert. Meinen Constructor verändere ich dafür so, dass er dieses struct als Parameter erhält (Listing 1).

 type Options struct {
  Skip         int
  Max          int
  ShallBuffer  bool
  ErrorHandler ErrorHandler
}

// Later usage in other package.

myOptions := strproc.Options{
  Skip:         1,
  ErrorHandler: func(err error) error {
    ...
  },
}
myProc := strproc.New(myReader, myWriter, myOptions)

Im Constructor selbst werden initial die Standardwerte der Konfiguration gesetzt. Anschließend ist struct mit den Optionen auszuwerten (Listing 2).

func New(
  r LineReader,
  w LineWriter,
  options Options) *StringProcessor {
  sp := &StringProcessor{
    lineReader:   r,
    lineWriter:   w,
    skip:         0,
    max:          0,
    shallBuffer:  true,
    errorHandler: func(err error) error {
      return err
    },
  }
  // Check and set individual options.
  if options.Skip != 0 {
    sp.skip = options.Skip
  }
  if options.Max != 0 {
    sp.max = options.Max
  }
  ...
  return sp
}

So weit, so gut, wenn auch mit jeder weiteren Option wieder mehr Aufwand durch die Kontrolle des Werts hinzukommt. Nun hat diese Variante allerdings auch noch einen Schönheitsfehler. Mein Prozessor will als Standard die Ausgabe puffern, shallBuffer ist also true. Wird es in den Optionen nicht gesetzt, dann ist darin der Wert false, da Go boolesche Variablen so initialisiert. Bedeutet dies nun, dass die Nutzerin kein Puffern wünscht oder sie das Setzen einfach nur vergessen hat? Keine Ahnung. Daher zwingt dieser Ansatz die Entwicklerin zumindest bei einigen Optionen, sie immer zu setzen. Diese Tatsache muss dann allerdings auch gut dokumentiert werden, was den gewünschten Vorteil dieser Art der Konfiguration so leider zunichtemacht.

Hier hat sich inzwischen ein deutlich eleganterer Weg entwickelt, durch den der Code später besser lesbar und leichter nachzuvollziehen ist. Er macht sich Funktionstypen zunutze, die struct mit den Optionen manipulieren sollen:

type Option func(os *Options)

Dazu kommen individuelle Funktionen für jede Option, die für sie passende Funktionen vom Typ Option erzeugen (Listing 3). Der Coding-Aufwand mag so anfangs etwas höher sein, doch die individuelle Abgrenzung der Optionen und die spätere Nutzung werden verbessert.

// Skip sets the number of lines to 
// skip initially.
func Skip(n int) Option {
  return func(os *Options) {
    if n > 0 {
      os.Skip = n
    }
  }
}

// ShallBuffer turns the buffering on.
func ShallBuffer() Option {
  return func(os *Options) {
    os.ShallBuffer = true
  }
}

// ShallNotBuffer turns the buffering off.
func ShallNotBuffer() Option {
  return func(os *Options) {
    os.ShallBuffer = false
  }
}

Der Constructor kann diese Funktionen vom Typ Option schließlich nutzen, wie in Listing 4 dargestellt.

func New(
  r LineReader,
  w LineWriter,
  options ...Option} *StringProcessor {
  // Init with standard options.
  sp := &StringProcessor{
    lineReader: r,
    lineWriter: w,
    options:    &Options{
      Skip:         0,
      Max:          0,
      ShallBuffer:  true,
      ErrorHandler: func(err error) error {
        return err
      },
    },
  }
  // Set individual options.
  for _, option := range options {
    option(sp.options)
  }
  return sp
}

Die Übergabe der Optionen als variadischer Parameter erlauben nun sehr vielfältige Verwendungsmöglichkeiten. Werden beim Aufruf des Constructors keine Optionen übergeben, so kommen die Standardwerte zum Einsatz, die beliebig kombiniert werden können. Die Reihenfolge spielt dabei keine Rolle. Einzig ungewohnt ist, dass eine Option mehrfach gesetzt werden kann. Hier ist dann nur die letzte gültig (Listing 5).

// Minimal processor.
myProc := strproc.New(
  myReader,
  myWriter)
  
// Processor with skip and max.
myProc := strproc.New(
  myReader,
  myWriter,
  strproc.Skip(25),
  strproc.Max(100))

// Processor with no buffering.
myProc := strproc.New(
  myReader,
  myWriter,
  strproc.ShallNotBuffer())

// Processor with buffering and error
// handling logs and drops error.
myProc := strproc.New(
  myReader,
  myWriter,
  strproc.ShallBuffer(),
  strproc.ErrorHandler(func(err error) error {
    log.Printf("string processing error: %v", err)
    return nil
  }))

Hier zeigt das Setzen der Fehlerbehandlung noch einen weiteren Einsatz eines Funktionstyps, wie er häufig zu finden ist. Oft gibt es kleinere Aspekte, die flexibel gestaltet werden sollen, in der Implementierung normalerweise jedoch nicht über eine einfache Funktion hinausgehen. Die Fehlerbehandlung zeigt es hier beispielhaft. In der Standardvariante wird der Fehler nur weitergereicht, das String Processing wird im Folgenden darauf reagieren und die Verarbeitung abbrechen. Die Ersatzfunktion in Listing 5 gibt sich hingegen mit einem Logging zufrieden und gibt nur nil zurück. Das Processing wird also seine Arbeit fortsetzen. Andere Logging-Varianten, die Annotation der Fehler mit weiteren Informationen oder auch Panics lassen sich problemlos einimpfen.

Gopher ante portas: Go hält Einzug auf JAXenter!

Wenige (relativ) neue Programmiersprachen erfreuen sich derzeit einer so großen Belibtheit wie Go. Googles Alternative für „C“ ist immer häufiger Zentrum interessanter Anwendungen und mittlerweile im Mainstream angekommen. Grund genug, sich ein wenig eingehender mit der Sprache zu befassen. Folgende Artikel, Kolumnen und Infografiken sind Teil unserer aktuellen Go-Aktion auf JAXenter:

Happy Gophing!

An die Arbeit

Nun ist es ja schön, dass wir einen Typ haben, den wir elegant mit unterschiedlichsten Optionen initialisieren können. Doch was wollen wir mit ihm machen? Die Kernidee ist es, die Strings einer Quelle – des Readers – auszulesen, sie in andere Strings zu verarbeiten und sie dann in ein Ziel – den Writer – zu schreiben. Widmen wir uns also der Verarbeitung: Dabei soll nicht nur eine Funktion auf die jeweiligen Strings angewandt werden, sondern gleich eine Kette verschiedener Funktionen. Jede dieser Funktionen nimmt einen String entgegen, verarbeitet ihn und liefert das Ergebnis für die Folgefunktion zurück. Hierfür wird wieder ein Funktionstyp definiert.

type Processor func(in string) (string, error)

Dabei gibt es allerdings noch eine kleine Herausforderung. In dieser Variante kann eine Funktion einen leeren String zurückgeben. Sie kann jedoch nicht signalisieren, dass sie einen String aus der Verarbeitung herausfiltern möchte. Der Weg über einen speziellen Fehlerwert funktioniert, ist jedoch nicht schön. Besser ist ein weiterer Rückgabewert:

type Processor func(in string) (string, bool, error)

Der StringProcessor kann diesen nun in seiner Verarbeitung zur Ablaufkontrolle nutzen (Listing 6).

func (sp *StringProcessor) Do(
  processors ...Processor) error {
  // Possibly skip initial lines.
  if err := sp.init(); err != nil {
    return err
  }
  n := 0
  for {
    // Read the input.
    n++
    in, err := sp.readLine(n)
    if err != nil {
      if err == EOF {
        break
      }
      return err
    }
    // Process the input.
    var skip bool
    for _, process := range processors {
      in, skip, err = process(in)
      if err != nil {
        return err
      }
      if skip {
        continue
      }
    }
    // Write to buffer of directly out.
    if err = sp.writeLine(in); err != nil {
      return nil
    }
  }
  // Flush out possible buffered output.
  return sp.done()
}

Einige kleinere Aufgaben sind hier in private Methoden ausgelagert, um die Komplexität der Hauptmethode etwas zu verringern. So darf StringProcessor.init() die konfigurierte Anzahl der ersten Zeilen überspringen. Weitergelesen wird via StringProcessor.readLine(), das intern neben einem EOF des LineReader auch ein EOF beim Erreichen einer maximalen Anzahl von Zeilen zurückgibt. Anschließend werden die Prozessoren durchgearbeitet. Der Schreibvorgang erfolgt ebenfalls nicht direkt, sondern mit StringProcessor.writeLine(), das bei entsprechender Konfiguration puffert. Bleibt noch der Abschluss mit StringProcessor.done(). Die Methode kann ziemlich arbeitslos sein, kann aber auch gepufferte Daten ausgeben.

Für die Prozessoren sind unterschiedlichste Aufgaben denkbar. Manche können direkt genutzt werden, andere haben eine Funktion mit Parametern als Fabrik (Listing 7). Das Package könnte bereits einige als Standard mitbringen.

// ToUpper transforms the string to upper case.
func ToUpper(in string) (string, bool, error) {
  return strings.ToUpper(in), false, nil
}

// MakeCutter makes a processor cutting the
// input after n.
func MakeCutter(n int) Processor {
  return func(in string) (string, bool, error) {
    if len(in) < n {
      return in, false, nil
    }
    return in[:n], false, nil
  }
}

// MakeContentChecker makes a processor checking
// if the input contains a substring.
func MakeContentChecker(substr string) Processor {
  return func(in string) (string, bool, error) {
    return "", !strings.Contains(in, substr), nil
  }
}

Mit diesen wenigen Prozessoren kann ich bereits ein Logfile auf Fehler reduzieren, das Ergebnis von führenden Zeitstempeln der Form YYYY-MM-DD HH:MM:SS befreien und in Großschreibung wandeln:

sp := strproc.New(logReader, xtractWriter)
err := sp.Do(
  strproc.MakeContentChecker("ERROR"),
  strproc.MakeCutter(20),
  strproc.ToUpper)

Weitere Standardprozessoren wie beispielsweise ToLower(), MakeFiller(), MakeMatcher(), MakeMapper() oder MakeTrimmer() können die Nutzerin des Packages bereits mit einem starken Rüstzeug ausstatten. Das kurze Beispiel zeigt, wie sich diese dann sehr leicht und elegant kombinieren lassen. Und dank der einzigen Abhängigkeit von der Funktionssignatur Processor kann jede Entwicklerin ihre eigenen Verarbeitungsschritte schnell realisieren (Listing 8).

// MakeReplacer makes a replacer splitting the input
// by slashes, looking for replacements in the passed
// database, replaces matches and joins them again.
func MakeReplacer(db MyDatabase) strproc.Processor {
  return func(in string) (string, bool, error) {
    parts := strings.Split(in, "/")
    for i, part := range parts {
      replacement, ok := db.Get(part)
      if ok {
        parts[n] = replacement
      }
    }
    out := strings.Join(parts, "/")
    return out, false, nil
  }
}

Die Verarbeitung von Zeilenmengen wird somit weniger aufwendig, sei es in Log- oder CSV-Dateien oder proprietären Formaten. Fragt sich nun nur mehr, wo die Daten herkommen und wo sie hingehen.

Mitschnitt

Wir haben ja noch zwei Interfaces, auf die sich der StringProcessor verlässt: LineReader und LineWriter. Ihre Implementierung erfordert nichts weiter als das Vorhandensein der gewünschten Methoden. Gut von der Standardbibliothek unterstützt wird beispielsweise ein FileLineReader (Listing 9).

type FileLineReader struct {
  blines [][]byte
  pos    int
}

func NewFileLineReader(
  filename string) (*FileLineReader, error) {
  content, err := ioutil.ReadFile(filename)
  if err != nil {
    return nil, err
  }
  blines := bytes.Split(content, []byte("\n"))
  return &FileLineReader{
    blines: blines,
    pos:    0,
  }, nil
}

func (r *FileLineReader) ReadLine() (string, error) {
  if r.pos == len(r.blines) {
    return "", strproc.EOF
  }
  line := string(r.blines[r.pos])
  r.pos++
  return line, nil
}

Diese Variante liest eine Datei komplett, ein zeilenweises Lesen von einem beliebigen io.Reader ist jedoch ebenso leicht möglich (Listing 10).

type ReaderLineReader struct {
  reader *bufio.Reader
}

func NewReaderLineReader(reader io.Reader) *ReaderLineReader {
  return &ReaderLineReader{
    reader: reader,
  }
}

func (r *ReaderLineReader) ReadLine() (string, error) {
  s, err := r.reader.ReadString('\n')
  if err != nil {
    if err == io.EOF {
      return "", strproc.EOF
    }
      return err
  }
  return s, nil
}

Hier ist dann der übergebene Reader selbst wieder ein Interface, das in Go durch viele Typen implementiert wird. os.File ist eines davon. Ebenso einfach stellt sich die Ausgabe mit LineWriter dar. Wünsche ich zum Beispiel die Ausgabe in eine Queue, lässt sich der entsprechende Typ schnell implementieren. Angenehm fällt hier auch auf, dass die Implementierung von Interfaces keine Nutzung von struct als Gegenstück zu Klassen in der Objektorientierung benötigt. So lassen sich Methoden an bestehende Typen nahezu „anflanschen“. Der so entstehende Typ benötigt jedoch einen eigenen Namen. Im Beispiel sei die Queue ein System, das Byte Slices als rohe Daten verschickt.

type QueueLineWriter MyQueue

func (w *QueueLineWriter) WriteLine(line string) error {
  return w.Push([]byte(line))
}

In der späteren Nutzung wird so beim Aufruf des Constructors keine neue Instanz von LineWriter benötigt. Ein einfacher Type Cast ist hier bereits genug.

sp := strproc.New(
  myLineReader,
  QueueLineWriter(myQueue))

Ein Blick zurück

Ja, Go ist eine Sprache, die C sehr ähnlich ist, und ja, sie adressiert vielfach sehr technische Anwendungen. Doch ihre Flexibilität beim Umgang mit Funktionstypen, mit Methoden und mit Interfaces erlaubt es dennoch, elegante Typen und Packages auf einem höheren Niveau zu entwickeln. Gerade hier kommt es bei der späteren Nutzung auf einen guten Kompromiss aus Flexibilität und Lesbarkeit an. Der StringProcessor in unserem Beispiel mit seinen Optionen sowie einigen gebräuchlichen LineReader-, LineWriter- und Processor-Implementierungen ergäbe einen leistungsfähigen und komfortablen Baukasten, der problemlos in der Nutzung zu erweitern ist.

Verwandte Themen:

Geschrieben von
Frank Müller
Frank Müller
Der Oldenburger Frank Müller ist seit über dreißig Jahren in der IT zu Hause und im Netz vielfach als @themue anzutreffen. Das Interesse an Googles Sprache Go begann 2009 und führte inzwischen zu einem Buch sowie mehreren Artikeln und Vorträgen zum Thema.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: