Objekt der Begierde (Linux-Magazin, April 2022)

Was macht Objektorientierung eigentlich aus, und was bringt sie im Vergleich zu Opa-Sprachen, die einfach nur nacheinander verschiedene Funktionen abrufen? Die meisten Skripts oder Programme starten als einfache Hacks mit ein paar Variablen ins Leben. Erweist sich eine Applikation als nützlich, steigen oft die Ansprüche, ein Feature hier und noch ein Feature da, und die Codebasis wächst an. Dem kritischen Beobachter fällt dann auf, dass sich bestimmte Code-Abschnitte wiederholen und sich besser als Funktionen zusammenfassen und kapseln lassen. Auch die Zahl der Variablen steigt, und wenn manche Funktionsaufrufe fünf oder zehn Parameter enthalten, wächst beim Experten das Verlangen, die Variablen in kleinen Gruppen als Strukturen zusammenzufassen, und sie platzsparend als Kombipakete herumzuschicken.

Funktionen fest an Datenstrukturen zu binden ergibt sich auch ohne explizite Objektorientierung auf ganz natürliche Weise. Der in blankem C ohne OO-Unterstützung geschriebene Code des schon leicht angestaubten Webservers Apache zeigt zum Beispiel, dass dort viele Funktionen eine Datenstruktur als erstes Element erhalten, typischerweise als erweiterter Kontext, bestehend aus angesammelten Daten, auf die die Funktion nicht nur lesend, sondern auch schreibend zugreifen kann. In Abbildung 1 bekommt zum Beispiel die Funktion ap_get_useragent_host als ersten Parameter eine Request-Struktur, in der der Server die bis dato analysierten Daten aus dem eingehenden Request abgelegt hat, und die Utility-Funktion fieselt aus ihnen den UserAgent des Clients heraus. Ähnliches gilt vielleicht für eine Funktion, die an anderer Stelle einen Teil einer Web-Antwort zusammenbaut, immer geht es darum, dass Funktionen auf strukturierten Daten herumorgeln.

Abbildung 1: Selbst prozeduraler C-Code im Apache-Server arbeitet eigentlich auf "Objekten".

Daten zum Mitreisen gesucht

Eine objektorientierte Sprache würde hingegen nun die mitreisende Datenstruktur als "Objekt" bezeichnen, und Funktionen, die lesend oder schreibend darauf zugreifen, als "Methoden" definieren. Ein Request-Objekt req im Server, das die bis dato analysierten Parameter des eingehenden Web-Requests enthielte, böte vielleicht eine Methode req.UserAgent() an, die den UserAgent des anfordernden Clients als String lieferte. Im Ergebnis ist es das gleiche Verfahren mit anderer Syntax.

Das Objekt req enthält also nicht nur eine geballte Ladung an Informationen, sondern versteht sich auch darauf, mittels Methoden die Bestandteile dieser Daten herauszufiltern oder zu manipulieren. Der ausführende Code kann nun das Objekt herumschicken, unter Umständen an eine weit entfernte Stelle irgendwo im Programm, die, obwohl sie nichts von den Innereien des Objekts versteht, dessen Methoden aufrufen kann. Das ist der große Vorteil: Statt immer Datenstrukturen von Pontius zu Pilatus durchzuschleifen, verpackt das Objekt sie unsichtbar hinter seiner Fassade. Eine unscheinbare Variable, die das Objekt enthält, bietet so eine Schnittstelle für Unbedarfte an, die aussieht, als würde der Aufrufer dem Objekt mit dem Methodenaufruf eine "Nachricht" schicken und eine Antwort darauf erwarten, ohne zu wissen, oder sich darum zu scheren, wie die Methode das Ergebnis intern nun im Detail zusammenbaut.

Eierkopf-Jargon

Das führt dazu, dass Objekte verschiedenster Bauart die gleichen Methoden verstehen und auf ihre Weise ausführen, wie die draw()-Methode von 17 verschiedenen Widgets einer UI, die ihr Widget jeweils auf den Bildschirm zaubern. "Polymorphie" heißt das im Jargon der Eierköpfe, verschiedenste Erscheinungen, also Objekte, bieten die gleiche Schnittstelle.

Listing 1: hello.rb

    01 #!/usr/bin/ruby
    02 
    03 german = Object.new
    04 english = Object.new
    05 
    06 def english.hello
    07     puts "Hello!"
    08 end
    09 
    10 def german.hello
    11     puts "Hallo!"
    12 end
    13 
    14 def greet(speaker)
    15     speaker.hello()
    16 end
    17 
    18 greet(english) # "Hello!"
    19 greet(german)  # "Hallo!"

Ruby ist eine Skriptsprache, die durch und durch objektorientiert arbeitet. In Listing 1 nimmt die Funktion greet() ab Zeile 14 im Parameter speaker ein Objekt entgegen und ruft im Bauch dessen Methode hello() auf. Dabei weiß die Funktion gar nicht, ob das Objekt nun Englisch oder Deutsch spricht, und die beiden Aufrufe in den Zeile 18 und 19 mit einem englischen und deutschen Objekt zeigen, dass beides funktioniert, und greet() automatisch die richtige Sprache spricht. Wer sich übrigens wundert, dass eine durch und durch objektorientierte Sprache wie Ruby überhaupt eine Funktion unterstützt, dem sei versichert: Es handelt sich gar nicht um eine Funktion, sondern um eine Methode, die auf dem sogenannten Default-Objekt arbeitet.

Klasse als Bauplan

Kurioserweise definiert Listing 1 die beiden Sprecherobjekte testweise einfach ad-hoc, eingangs in den Zeilen 3 und 4. Welche Daten ein Objekt einkapselt und welche Methoden es darauf anbietet, bestimmt aber normalerweise die Klassendefinition des Objekts. Nach diesem Bauplan baut der Code anschließend ein oder mehrere gleichartige Objekte, aber jede dieser "Instanzen" führt ein Eigenleben. Gehen zehn Anfragen an den Webserver ein und arbeitet dieser sie gleichzeitig ab, jongliert er unter Umständen mit zehn Request- und zehn Response-Objekten.

Listing 2: class.rb

    01 #!/usr/bin/ruby
    02 
    03 class Speaker
    04     def initialize(name)
    05         @name = name
    06     end
    07     def hello
    08         puts "Hello, I'm #{@name}."
    09     end
    10 end
    11 
    12 hans = Speaker.new("Hans")
    13 franz = Speaker.new("Franz")
    14 
    15 hans.hello()  # "Hello, I'm Hans."
    16 franz.hello() # "Hello, I'm Franz."

Listing 2 definiert als Bauplan für Speaker-Objekte die Klasse Speaker. Die nach dieser Schablone später in den Zeilen 12 und 13 generierten Objekte hans und franz nehmen in ihrem Konstruktor new(), ihren zukünftigen Namen jeweils als String entgegen. Gemäß den Ruby-Regeln durchläuft new() die ab Zeile 4 definierte Methode initialize(), die in @name eine Instanzvariable setzt. Der Wert gilt nur für die gerade erzeugte Objekt-Instanz, andere Objekte setzen ihn nach ihren eigenen Vorstellungen. Dementsprechend stellen sich Hans und Franz ([2]) in den Zeilen 15, beidesmal die gleiche Methode hello() aufrufend, unterschiedlich vor.

Erben und erben lassen

Um den Bauplan einer Klasse zu erweitern, meist um sie auf Spezialaufgaben zu trimmen, etwa durch zusätzliche Methoden oder Datenelemente, bieten objektorientierte Sprachen die Möglichkeit, Methoden von anderen, sogenannten Basisklassen, zu erben. In Listing 3 erbt die Klasse Speaker in Zeile 15 von der Klasse German, also spricht der Sprecher Deutsch, denn die Klasse German ab Zeile 3 definiert ihre Methode hello() ab Zeile 4 mit einem deutschen Satz. Würde Speaker in Zeile 9 hingegen von der Klasse English erben, spräche er Englisch, denn Zeile 11 gibt einen englischen Satz aus.

Listing 3: inherit.rb

    01 #!/usr/bin/ruby
    02 
    03 class German
    04     def hello
    05         puts "Hallo, ich bin #{@name}."
    06     end
    07 end
    08 
    09 class English
    10     def hello
    11         puts "Hello, I'm #{@name}."
    12     end
    13 end
    14 
    15 class Speaker < German
    16     def initialize(name)
    17         @name = name
    18     end
    19 end
    20 
    21 hans = Speaker.new("Hans")
    22 franz = Speaker.new("Franz")
    23 
    24 hans.hello()  # "Hallo, ich bin Hans."
    25 franz.hello() # "Hallo, ich bin Franz."

Findet Ruby eine Methode nicht in der Klassendefinition, verfolgt es die Vererbungshierarchie nach oben, bis es sie findet. Das kann auch über mehrere Stufen erfolgen, denn eine Klasse kann von einer Klasse erben, die wiederum von einer Klasse erbt, und so weiter. Was aber nicht geht, ist, dass eine Klasse von zwei Klassen erbt, das wäre "multiple inheritance", und die meisten Programmiersprachen verbieten dies, weil es zu Komplikationen bei der Auflösung von Methodenaufrufen führt.

In der Praxis kommt es aber oft vor, dass eine Klasse die Methoden mehrerer Klassen verwenden möchte. Das geht über sogenannte Mixins, bei denen eine Klasse lediglich die Methoden einer anderen Klasse einbindet, ohne weitere Vererbung wie das Hochsteigen in deren Basisklassen. Ruby bindet mit "module" gekennzeichnete Mixins mit dem Schlüsselwort "include" ein. Listing 4 definiert in Zeile 3 mit module einen Mixin namens "HelloGoodbye", der die Methoden hello() und goodbye() anbietet, die Hallo und auf Wiedersehen sagen, und beide auf die Instanzvariable @name zugreifen, die die nutzende Klasse später setzen wird.

Listing 4: mixin.rb

    01 #!/usr/bin/ruby
    02 
    03 module HelloGoodbye
    04     def hello
    05         puts "Hello, I'm #{@name}."
    06     end
    07     def goodbye
    08         puts "#{@name}, peace out!"
    09     end
    10 end
    11 
    12 class Speaker
    13     def initialize(name)
    14         @name = name
    15     end
    16     include HelloGoodbye
    17 end
    18 
    19 hans = Speaker.new("Hans")
    20 franz = Speaker.new("Franz")
    21 
    22 hans.hello()    # "Hello, I'm Hans."
    23 hans.goodbye()  # "Hans, peace out!"
    24 franz.hello()   # "Hello, I'm Franz."
    25 franz.goodbye() # "Franz, peace out!"

Zeile 16 zieht den Mixin mit include herein, und weiter unten ab Zeile 22 dürfen die Objekte hans und franz die Methoden aufrufen und es kommt tatsächlich der entsprechende Willkommens- oder Abschiedsgruß mit dem gesetzten Namen heraus.

Mixins kann eine Klasse in beliebiger Anzahl hereinziehen, im Gegensatz zur Vererbung, bei der jede Klasse jeweils nur eine direkte Basisklasse referenzieren darf. Programme nutzen sie, um Klassen mit Utility-Funktionen auszustatten, die eigentlich nicht mit der Klasse verwandt sind, sondern eher als Sonderausstattung gelten, wie Logging oder das Abarbeiten von Daten mittels eines Stacks oder einer Warteschlange. Man spricht von "Aggregation" oder "Komposition", nicht mehr von Vererbung.

Interessanterweise bildet Ruby wirklich (fast) alles als Objekte ab. Sogar Strings oder Integer hören auf Methoden, so gibt "abc".upcase() den String ABC zurück, und 2.+(2) ergibt 4, da die Plus-Methode mit dem Argument 2 zum Wert 2 den Wert 2 hinzuaddiert, was nach Adam Riese 4 ergibt. Dass auch in Ruby 2 + 2 funktioniert, hängt einzig und allein an Rubys "syntactic sugar", der es erlaubt, sowohl den Punkt als auch die Klammern in 2.+(2) wegzulassen ([3]).

OO in Go

Was macht nun eine Sprache wie Go, die explizit keine Objektorientierung unterstützt? Da in Gos Spezialdisziplin, der Systemprogrammierung ebenfalls Datenstrukturen anfallen, auf die das Programm mit Funktionen zugreift, haben sich die Go-Väter den Receiver-Mechanismus für Go-Funktionen ausgedacht.

Listing 5: hello.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05 )
    06 
    07 type Hello struct {
    08   name string
    09 }
    10 
    11 func main() {
    12   hans := Hello{name: "Hans"}
    13   franz := Hello{name: "Franz"}
    14 
    15   hans.hello()
    16   franz.hello()
    17 }
    18 
    19 func (h Hello) hello() {
    20   fmt.Printf("Hello, I'm %s.\n", h.name)
    21 }

Dazu definiert Listing 5 eine Struktur Hello, die unter dem Schlüssel name ein Feld mit dem Namen einer Person als String enthält. Das Hauptprogramm initialisiert jeweils eine solche Struktur in den Variablen hans und franz und ruft anschließend deren "Methode" hello() auf. Dies ist allerdings nur syntaktischer Zucker der weiter unten in Zeile 19 definierten Funktion hello(), in deren Deklaration zwischen dem Schlüsselwort func und dem Funktionsnamen in runden Klammern einen sogenannten "Receiver" enthält. (Was übrigens eine extrem dumme Design-Idee ist, weil man so Funktionen im Editor nicht mehr mit "func name" suchen kann, weil im Code der Receiver dazwischen steht).

Im vorliegenden Fall handelt es sich um eine Struktur vom Typ Hello, die der Go-Code während des Aufrufs in den Zeilen 15 und 16 aus dem main-Programm mit den vorher initialisierten Strukturen besetzt und wegen der Receiver-Deklaration in Zeile 19 in dem Parameter h an die Funktion hello() übergibt. Fertig ist die Objektorientierung!

So elegant und kompakt wie mit dem Konstruktor new() im Ruby-Code sieht das freilich nicht aus, aber Go-Programmierer behelfen sich mit einem Trick. Listing 6 zeigt, wie die zusätzliche Funktion NewHello() ab Zeile 19 einen Konstruktor implementiert. Wohlgemerkt, NewHello() erhält keinen Receiver, sondern es handelt sich um eine ganz normale Funktion, die eine initialisierte Datenstruktur vom Typ Hello zurückgibt. Folgende Aufrufe mittels obj.hello() rufen dann die ab Zeile 23 definierte Funktion hello() mit dem Receiver-Variable obj auf.

Listing 6: hello-const.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05 )
    06 
    07 type Hello struct {
    08   name string
    09 }
    10 
    11 func main() {
    12   hans := NewHello("Hans")
    13   franz := NewHello("Franz")
    14 
    15   hans.hello()
    16   franz.hello()
    17 }
    18 
    19 func NewHello(name string) Hello {
    20   return Hello{name: name}
    21 }
    22 
    23 func (h Hello) hello() {
    24   fmt.Printf("Hello, I'm %s.\n", h.name)
    25 }

Das Ergebnis ist das gleiche, sowohl Listing 7 als auch 8 geben

    Hello, I'm Hans.
    Hello, I'm Franz.

aus. Wie sieht nun Gos Ansatz zum Vererben von Daten und Funktionen aus, wie das objektorientierte Sprachen durch Vererbung von der Basisklasse zur abgeleiteten Klasser erlauben? Durch das sogenannte "Struct Embedding" darf der Go-Programmierer Teile einer bereits vorher definierten Struktur, einschließlich der sie als Receiver nutzenden Funktionen in einer erweiterten Struktur übernehmen. Listing 7 definiert zum Beispiel in der Struktur HelloGerman ab Zeile 11, dass diese einfach eine Erweiterung der Struktur Hello aus Zeile 7 ist. Letztere verfügt bereits über eine Funktion hello() (ab Zeile 21), die mittels des Receivers vom Typ Hello die dahintersteckende Person dazu bringt, sich auf Englisch vorzustellen. Andererseits definiert die Funktion hallo() (die deutsche Version von hello() ab Zeile 25 einen Receiver vom Typ HelloGerman, und ihr Code veranlasst die Person, sich auf Deutsch vorzustellen.

Listing 7: hello-german.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05 )
    06 
    07 type Hello struct {
    08   name string
    09 }
    10 
    11 type HelloGerman struct {
    12   Hello
    13 }
    14 
    15 func main() {
    16   hans := HelloGerman{Hello: Hello{name: "Hans"}}
    17   hans.hello() // "Hello, I'm Hans."
    18   hans.hallo() // "Hallo, ich bin Hans."
    19 }
    20 
    21 func (h Hello) hello() {
    22   fmt.Printf("Hello, I'm %s.\n", h.name)
    23 }
    24 
    25 func (h HelloGerman) hallo() {
    26   fmt.Printf("Hallo, ich bin %s.\n", h.name)
    27 }

Prompt kann das Hauptprogramm dann hintereinander hans.hello() und hans.hallo() aufrufen und die Ausgabe ist wie erwartet:

    Hello, I'm Hans.
    Hallo, ich bin Hans.

Einen Schönheitsfehler hat die Syntax noch: Bei der Initialisierung der HelloGerman-Struktur in Zeile 16 kann Go nicht einfach auf das Feld name zugreifen, weil name ein Feld von Hello ist und nicht von HelloGerman. Also schreibt der Initialisierer Hello: Hello{name: "Hans"} und weist den Compiler damit an, das Feld name in der eingebetteten Struktur Hello zu initialiseren, nicht in der darumgewickelten Struktur HelloGerman.

Noch etwas gilt es zu beachten: Ein Konstruktor kann einen Pointer auf die initialisierte Struktur zurückgeben oder aber die Struktur selbst. Bei einer Monsterstruktur wie http.Request bietet sich ein Pointer an, bei einer Mini-Struktur eher nicht. Ganz kritisch wird's beim Aufruf des Receivers: Nutzt die Receiver-Definition einen Pointer (func (t* type) fu()), dann kann die Funktion die Felder der Struktur dauerhaft verändern. Nutzt sie die Struktur selbst (func (t type) fu()), dann steigert sich der Programmierer unter Umständen in panikartige Zustände hinein, denn Veränderungen nimmt die Funktion dann an einer Kopie der Struktur vor, und verwirft sie nach Ablauf der Methode kommentarlos.

Polymorphes Go

Gos strenge Typprüfung verbietet es eigenlich, einer Funktion eine Variable beliebigen Typs zu übergeben, auf dass diese auf gut Glück darauf eine Methode aufruft.

Listing 8: reader.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "net/http"
    06   "os"
    07   "strings"
    08 )
    09 
    10 func main() {
    11   f, _ := os.Open("/usr/bin/fg")
    12   defer f.Close()
    13   printNine(f) // File type implements Read()
    14 
    15   s := strings.NewReader("abc def")
    16   printNine(s) // strings.Reader implements Read()
    17 
    18   resp, _ := http.Get("https://google.com/")
    19   defer resp.Body.Close()
    20   printNine(resp.Body) // io.ReadCloser implements Read()
    21 }
    22 
    23 type Reader interface {
    24   Read(p []byte) (n int, err error)
    25 }
    26 
    27 func printNine(r Reader) {
    28   b := make([]byte, 9)
    29   r.Read(b)
    30   fmt.Printf("printNine: [%s]\n", string(b))
    31 }

Listing 7 zeigt aber, wie Funktionen im Namen des Polymorphismus trotz Gos strenger Typprüfung verschiedenste Parameter annehmen können. Die Funktion printNine() ab Zeile 27 nimmt hierzu entweder eine offene Datei, einen String-Reader oder die Antwort auf eine Webabfrage der Google-Homepage entgegen und schickt die ersten neun Bytes daraus auf die Standardausgabe. Allen drei Typen gemein ist, dass sie die Funktion Read() implementieren, die von wo auch immer ankommende Bytes häppchenweise einliest und an den Aufrufer zurückgibt. Zeile 30 in Listing 7 druckt die Ausgabe aus Datei, String und Webantwort testhalber mit fmt.Printf in die Standardausgabe:

    $ ./reader 
    printFive: [#!/bin/sh]
    printFive: [abc def]
    printFive: [<!doctype]

Dabei handelt es sich bei den Datenstrukturen um höchst unterschiedliche "Objekte". Der String-Reader kann zum Beispiel mit Len() feststellen, wie lang der gesamte einzulesende String ist, während der Reader der Webantwort diese Funktion nicht anbietet.

Der Trick an der Sache: Die Funktion printNine() definiert keinen Parameter eines Datentyps, sondern ein Interface als Aufrufparameter. Ab Zeile 23 definiert Listing 7 ein Interface vom Typ Reader, das nur eine Funktion unterstützt, nämlich Read(), das einen []byte-Array-Slice entgegennimmt, diesen mit Daten füllt, und dann die Anzahl der im Slice zurückgegebenen Daten als Integer, sowie einen möglichen Fehlerwert vom Type error zurückgibt. Wie groß der Puffer b ist, weiß Go und damit Read() übrigens, also gibt's keinen Überlauf und die maximale Anzahl eingelesener Bytes liegt von vorneherein fest ohne dass der Programmierer dies festlegen müsste.

Dieses Reader-Interface nutzen viele verschiedenste Go-Funktionen aus der Standardbibliothek: Zeile 11 öffnet eine Datei auf der Festplatte und der zurückkommende Datentyp liefert mit Read() schubweise Zeichen aus der Datei an. Zeile 15 setzt einen String-Reader auf, der Zeichen häppchenweise aus einem ihm übergebenen String liest. Und Zeile 18 setzt schließlich einen HTTP-Request ab, und dessen Antwort in resp speichert im Feld Body einen Reader, der auf Anfrage den Inhalt der eingeholten Webseite Stückchen für Stückchen ausspuckt.

Alle diese Datenquellen -- ob Datei, ob String, ob Datenstrom aus dem Internet -- kann printNine() ab Zeile 27 anzapfen, dank der Interface-Definition. Pure Polymorphie, selbst bei strengster Typprüfung! So zeigt sich Go von seiner praktischen Seite. Volle Objektorientierung ist das freilich nicht, aber selbst komplexe Systeme wie GUIs mit Dutzenden von Widgets lassen sich damit problemlos und sauber programmieren.

Infos

[1]

Listings zu diesem Artikel: XXX

[2]

"Pumpin' Up with Hans & Franz - SNL", https://youtu.be/7Mk1nykjnYA,

[3]

David Black, "The Well Grounded Rubyist", Manning 2019, https://www.manning.com/books/the-well-grounded-rubyist-third-edition

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc