Objektorientierung mit Go

Kai Spichale

Man kann mit Go objektorientiert programmieren, obwohl zur Definition von Typen und zur Implementierung von Objekten keine Klassen genutzt werden können. Wie das trotzdem funktionieren kann, zeigt der vorliegende Blogpost.

Die Programmiersprache Go wurden bereits in einem anderen Blogpost vorgestellt. Wer einen allgemeinen Einstieg in das Thema sucht, findet dort wichtige Grundlagen.

Typdefinition

Go bietet zur Definition von Typen struct und interface. Zunächst soll struct genauer vorgestellt werden.

Eine Struktur ist zunächst nur eine Sammlung von Variablen. Wie das folgende Beispiel jedoch zeigt, können für eine Struktur auch Funktionen definiert werden. In diesem Fall könnte man auch von Methoden sprechen, weil sie zu einem Objekt gehören. Die Methoden der Objekte (Strukturen) sind übrigens im Gegensatz zu Interfaces nicht virtuell.

type Circle struct {
  radius float64
}

func (c Circle) Area() float64{
  return math.Pi * c.radius * c.radius
}

func main() {
  c := Circle{2}
  fmt.Println(c.Area())
}

In diesem Beispiel erhält die Struktur Circle eine Methode zur Berechnung der Fläche. Dieses kleine Beispiel funktioniert ohne Probleme. In der Methode Area kann der Radius des übergebenen Kreises benutzt werden, um die Fläche zu berechnen. Wenn man jedoch eine Methode Enlarge hinzufügen würde, die den Radius ändert, wird man keinen Erfolg haben, weil die Zustandsänderung auf einer Kopie erfolgt.

func (c Circle) Enlarge() {
  // Kopie wird geändert
  c.radius += 1
}

Beim Aufruf einer Funktion bzw. Methode werden als Argumente stets Kopien übergeben. Denn in allen Sprachen der C-Familie wird Call-by-Value verwendet. Konsequenterweise arbeitet auch Enlarge auf einer Kopie und lässt das ursprüngliche Circle Objekt unverändert. Wenn die aufgerufene Methode das Objekt ändern soll, muss ein Zeiger benutzt werden:

func (c *Circle) Enlarge() {
  c.radius += 1
}

Tipp: Das Entwurfsmuster Command-Query-Separation unterscheidet zwischen Kommandos, die den Zustand des Objektes ändern, und Abfragen, die einen Wert zurückgeben. Eine Funktion ist entweder ein Kommando oder eine Abfrage, aber niemals beides. Entsprechend dieser Idee könnte man nur dann einen Zeiger übergeben, wenn das referenzierte Objekt geändert wird. Bei Übergabe einer Kopie muss man sich auch keine Gedanken über Immutability machen. Ein Nachteil wäre jedoch, dass das Erstellen der Kopien die Performance verschlechtert.

Ein Typ kann alternativ mit interface definiert werden. Aufbauend auf dem zuvor gezeigten Beispiel soll der Typ Shape definiert werden. Eine explizite Typdeklaration, wie man sie beispielsweise in Java mit “implements” kennt, ist nicht notwendig. Ein Objekt vom Typ Circle kann einer Variable vom Typ Shape zugeordnet werden, weil Circle die notwendige Funktion Area bietet.

type Shape interface {
  Area() float64
}

func main() {
  var shape Shape = Circle{2}
  fmt.Println(shape.Area())
}

Hätte das Interface Shape zusätzlich eine Methode Length, die nicht von Circle implementiert wird, wäre die Wertzuweisung nicht möglich. Man kann den Compiler nutzen, um sicherzustellen, dass tatsächlich das Interface implementiert wird:

var _ Shape = Circle{}       // Circle implementiert Shape
var _ Shape = (*Circle)(nil)  // *Circle implementiert Shape

Eine Struktur kann beliebig viele Interfaces implementieren und umgekehrt kann ein Interface von beliebig vielen Strukturen implementiert werden. Interfaces können leicht nachträglich ohne Mehrfachvererbung hinzugefügt werden. Weil es keine expliziten Beziehungen zwischen Strukturen und Interfaces gibt, existiert auch keine Typhierarchie, die man pflegen muss.

Im Paket fmt wird u.a. das Interface Stringer definiert, das automatisch von jeder Struktur implementiert wird, die eine String Methode bietet. Die Implementierung des Interfaces ist ohne Typhierarchie möglich.

func (c Circle) String() string{
  return "radius: " + strconv.FormatFloat(3.1415, 'E', -1, 64)
}

func main() {
  c := Circle{2}
  fmt.Println(c)
}

Typerweiterungen

Es ist nicht möglich, Funktionen zu existierenden Strukturen in anderen Paketen hinzuzufügen. Falls man es dennoch versucht, wird man eine Fehlermeldung vom Compiler erhalten. Stattdessen kann man eine neue lokale Struktur definieren und den existierenden Typ darin einbetten.

type MyAliasType some.ExternalType

Ein verbreitetes Idiom ist das Definieren von Alias-Typen. Ein lokaler Alias-Typ kann mit diversen Funktionen ausgestattet werden.

Nennenswert ist ebenfalls der Gebrauch einer anonymen Variable. Angenommen man hat einen Typ Product und möchte diesen erweitern um den Typ Computer, dann erhält Computer eine anonyme Variable vom Typ *Product, um von diesem zu “erben”. Exemplarisch könnte das so aussehen:

type Product struct {
  id int
}

type Computer struct {
  *Product
}

func (a *Product) Sell() {
  // ...
}

func main() {
  v := &Product{id:"42"}
  c := &Computer{v}

  v.Sell()
  c.Sell()
}

Es wäre auch möglich, die Funktion Sell für Computer zu “überschreiben”. Ebenso könnte Computer weitere anonyme Variablen erhalten. In diesem Fall muss man jedoch auf Namenskonflikte achten.

Polymorphie

Go-Interfaces haben ausschließlich virtuelle Methoden und können von unterschiedlichen Strukturen implementiert werden. Beispielsweise wäre es möglich, das Interface Shape mit den Strukturen Circle und Rectangle zu implementieren. Ob tatsächlich Circle und Rectangle das Interface implementieren, wird zum Kompilierzeitpunkt überprüft. Andere Code-Teile, die Objekte durch das Interface Shape nutzen, müssen nicht wissen, um welche konkreten Strukturen es sich handelt.

type Shape interface {
  Area() float64
}

func (c Circle) Area() float64{
  return math.Pi * c.radius * c.radius
}

func (r Rectangle) Area() float64{
  return r.length * r.width
}

In dynamischen Sprachen wie Python ist das Konzept Duck Typing verbreitet: “If it looks like a duck and quacks like a duck, it’s a duck.” Go bietet zwar kein Duck Typing, wegen der statischen Typüberprüfung, dennoch ist die implizite Implementierung von Interfaces praktisch und sicher. Der für Go gewählte Ansatz ist ein ausgewogener Kompromiss aus Typsicherheit und Convenience.

Keine Konstruktoren

Go hat keine Klassen und demzufolge gibt es auch keine Konstruktoren zur Erzeugung von Instanzen. Man kann aber durchaus Fabrikmethoden implementieren:

package shape

func NewCircle(radius int) *circle {
  c := new(circle)
  c.radius = radius
  return c
}

Man beachte, dass die Struktur circle kleingeschrieben ist und deswegen nicht von anderen Paketen mit new(shape.circle) erzeugt werden kann. Die einzige Möglichkeit, ein Objekt zu instanziieren, ist shape.NewCircle(2).

Weil die Struktur von circle so einfach ist, könnte man die Fabrikmethode auch etwas kompakter formulieren:

func NewCircle(radius int) *circle {
  return &circle{radius}
}

Builder

Zur Objekterzeugung sind Builder mit Fluent API sehr beliebt. Eine Fluent API wird typischerweise durch eine Methodenkette umgesetzt. Auch die Go-Bibliothek Squirrel zur Konstruktion von SQL-Abfragen nutzt eine Fluent API. Ein wesentlicher Vorteil einer solchen API ist, dass der entstandene Client-Code sauber und lesbar ist.

Zum Abschluss dieses Blogposts soll eine solche Fluent API entworfen werden, bei der viele der vorgestellten Sprach-Features benutzt werden. Das Ergebnis soll in der Benutzung so aussehen:

  import "foo/bar/request"

  request := request.New().
                       Uri("https://www.innoq.com/de/blog/golang-objektorientierung/").
                       Method(request.GET).
                       Build()

Mit der Fluent-API des Builders wird ein request-Objekt erzeugt. Dem Builder wird die URI und die HTTP-Methode für den Aufruf übergeben. Alle Methoden des Builders sind großgeschrieben, sodass man sie in anderen Paketen nutzen kann. Der Name des Paketes request und der Name der Methode New sind aufeinander abgestimmt, weil sie im Client-Code hintereinanderstehen.

Der vollständige Code des Beispiels ist hier:

package request

type httpMethod string

// HTTP Methods
const (
    GET    httpMethod = "get"
    PUT               = "put"
    POST              = "post"
    DELETE            = "delete"
)

type request interface {
    Execute() string
}

type requestBuilder interface {
    Method(httpMethod) requestBuilder
    Uri(string) requestBuilder
    Build() request
}

type requestData struct {
    method httpMethod
    uri    string
}

type requestBuilderData struct {
    method httpMethod
    uri    string
}

func New() requestBuilder {
    return &requestBuilderData{}
}

func Request() requestBuilder {
    return &requestBuilderData{}
}

func (builder *requestBuilderData) Method(method httpMethod) requestBuilder {
    builder.method = method
    return builder
}

func (builder *requestBuilderData) Uri(uri string) requestBuilder {
    builder.uri = uri
    return builder
}

func (builder *requestBuilderData) Build() request {
    return &requestData{
        method: builder.method,
        uri:    builder.uri,
    }
}

func (builder *requestData) Execute() string {
    return "result"
}

Fazit

Mit Strukturen und Interfaces können in Go Typen definiert werden, um objektorientiert zu programmieren. Klassen zur Implementierung von Objekten, die ein oder mehrere Typen implementieren, sind nicht notwendig. Einfach- und Mehrfachvererbung erlaubt Go durch das Einbetten eines oder mehrerer anonymer Variablen in eine “erbende” Struktur. Die offizielle Dokumentation von Go spricht jedoch nicht von Vererbung oder Mixins, sondern vom Konzept der anonymen Variablen. Letztendlich bietet Go Polymorphie und vermeidet komplexe Typhierarchien.

Thumb spichale

Kai Spichale beschäftigt sich leidenschaftlich seit mehr als 10 Jahren mit Softwarearchitekturen verteilter Systeme und sauberen Code. Als IT-Berater arbeitet er für innoQ Deutschland GmbH. Sein technologischer Schwerpunkt liegt auf modernen Architekturansätzen, API-Design und NoSQL. Er ist regelmäßiger Autor in verschiedenen Fachmagazinen und Sprecher auf Konferenzen.

More content

Comments

Please accept our cookie agreement to see full comments functionality. Read more