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". |
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.
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.
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.
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.
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.
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.
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.
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]).
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.
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.
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.
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.
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.
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.
Listings zu diesem Artikel: XXX
"Pumpin' Up with Hans & Franz - SNL", https://youtu.be/7Mk1nykjnYA,
David Black, "The Well Grounded Rubyist", Manning 2019, https://www.manning.com/books/the-well-grounded-rubyist-third-edition
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc