Was ist objektorientierte Programmierung (OOP)?

Die objektorientierte Programmierung (OOP) findet überall Anwendung: Zum Schreiben von Betriebssystemen, kommerzielle Software und Open Source kommen objektorientierte Technologien zum Einsatz. Dabei zeigen sich die Vorteile von OOP erst ab einer gewisser Komplexität des Projekts. Immer noch ist der objektorientierte Programmierstil eines der vorherrschendes Programmierparadigmen.

Was ist objektorientierte Programmierung und wozu wird sie benötigt?

Der Begriff „objektorientierte Programmierung“ wurde gegen Ender der 1960er Jahre von Programmier-Legende Alan Kay geprägt. Dieser war Mitentwickler der bahnbrechenden objektorientierten Programmiersprache Smalltalk, die von Simula, der ersten Sprache mit OOP-Features, beeinflusst wurde. Die grundlegenden Ideen von Smalltalk wirken sich bis heute auf die OOP-Features moderner Programmiersprachen aus. Zu den von Smalltalk beeinflussten Sprachen zählen u. a. Ruby, Python, Go und Swift.

Die objektorientierte Programmierung zählt neben der populären funktionalen Programmierung (FP) zu den vorherrschenden Programmierparadigmen. Programmieransätze lassen sich in die zwei großen Strömungen „imperativ“ und „deklarativ“ einordnen. Dabei ist OOP eine Ausprägung des imperativen Programmierstils und spezifisch eine Weiterentwicklung der prozeduralen Programmierung:

  1. Imperative Programmierung: In einzelnen Schritten beschreiben, wie ein Problem zu lösen ist – Beispiel: Algorithmus
    • Strukturierte Programmierung
      • Prozedurale Programmierung
        • Objektorientierte Programmierung
  2. Deklarative Programmierung: Ergebnisse nach gewissen Regeln erzeugen – Beispiel: SQL-Abfrage
    • Funktionale Programmierung
    • Domänenspezifische Programmierung
Hinweis

Die Begriffe „Prozedur“ und „Funktion“ werden oft synonym verwendet. Bei beiden handelt es sich um ausführbare Blöcke von Code, die Argumente entgegennehmen können. Der Unterschied liegt darin, dass Funktionen einen Wert zurückgeben, während dies bei Prozeduren nicht der Fall ist. Nicht alle Sprachen bieten explizite Unterstützung für Prozeduren.

Prinzipiell ist es möglich, jegliches Programmierproblem mit jedem der Paradigmen zu lösen; denn alle Paradigmen sind „Turing-komplett“. Das limitierende Element ist also nicht die Maschine, sondern der Mensch. Einzelne Programmierende bzw. Programmierteams können nur eine begrenzte Menge an Komplexität überblicken. Sie nutzen daher Abstraktionen, um der Komplexität Herr zu werden. Je nach Einsatzgebiet und Problemstellung eignet sich der eine oder andere Programmierstil besonders gut.

Die meisten modernen Sprachen sind sogenannte Multi-Paradigmen-Sprachen, die die Programmierung in mehreren Programmierstilen erlauben. Demgegenüber stehen Sprachen, die nur einen einzigen Programmierstil unterstützen; dies trifft vor allem auf strikt funktionale Sprachen wie Haskell zu:

  Paradigma Merkmale Besonders geeignet für Sprachen
Imperativ OOP Objekte, Klassen, Methoden, Vererbung, Polymorphismus Modellierung, Systemdesign Smalltalk, Java, Ruby, Python, Swift
Imperativ Prozedural Kontrollfluss, Iteration, Prozeduren / Funktionen Sequenzielle Datenverarbeitung C, Pascal, Basic
Deklarativ Funktional Immutability, Pure Functions, Lambda Calculus, Rekursion, Typ-Systeme Parallele Datenverarbeitung, mathematische und wissenschaftliche Anwendungen, Parser und Compiler Lisp, Haskell, Clojure
Deklarativ Domain-specific language (DSL) Ausdrucksstark, großer Sprachumfang Domänenspezifische Anwendungen SQL, CSS
Hinweis

Überraschenderweise handelt es sich selbst bei CSS um eine Turing-komplette Sprache. Das bedeutet, dass sich jegliche in anderen Sprachen geschriebene Berechnungen auch in CSS lösen ließen.

Objektorientierte Programmierung ist Teil der imperativen Programmierung und hervorgegangen aus der prozeduralen Programmierung. Letztere befasst sich im Grunde mit inerten Daten, die durch ausführbaren Code verarbeitet werden:

  1. Daten: Werte, Datenstrukturen, Variablen
  2. Code: Ausdrücke, Kontrollstrukturen, Funktionen

Genau hierin liegt der Unterschied zwischen der objektorientierten und der prozeduralen Programmierung: OOP vereint Daten und Funktionen zu Objekten. Ein Objekt ist quasi eine lebendige Datenstruktur; denn Objekte sind nicht inert, sondern haben ein Verhalten. Objekte sind somit vergleichbar mit Maschinen oder einzelligen Organismen. Während auf Daten bloß operiert wird, interagiert man mit Objekten bzw. interagieren Objekte miteinander.

Veranschaulichen wir uns den Unterschied an einem Beispiel. Eine Integer-Variable in Java oder C++ enthält lediglich einen Wert. Es handelt sich nicht um eine Datenstruktur, sondern einen „Primitive“:

int number = 42;

Operationen auf Primitives erfolgen über Operatoren oder Funktionen, die außerhalb definiert sind. Hier am Beispiel der successor-Funktion, die auf eine Ganzzahl folgende Zahl liefert:

int successor(int number) {
    return number + 1;
}
// returns `43`
successor(42)

Im Gegensatz dazu gilt in Sprachen wie Python und Ruby: „everything is an object“ („alles ist ein Objekt“). Selbst eine einfache Zahl umfasst den eigentlichen Wert sowie eine Menge von Methoden, die Operationen auf dem Wert definieren. Hier am Beispiel der eingebauten succ-Funktion in Ruby:

# returns `43`
42.succ

Zunächst ist dies praktisch, da die Funktionalität für einen Datentyp gebündelt wird. Es ist nicht möglich, eine Methode aufzurufen, die nicht zum Typ passt. Doch Methoden können noch mehr. In Ruby wird selbst die For-Schleife als Methode einer Zahl realisiert. Wir geben exemplarisch die Zahlen von 51 bis 42 aus:

51.downto(42) { |n| print n, ".. " }

Woher stammen nun die Methoden? Objekte werden in den meisten Sprachen über Klassen definiert. Man sagt, dass Objekte aus Klassen instanziiert werden, und nennt Objekte daher auch Instanzen. Eine Klasse ist eine Schablone zum Erzeugen gleichartiger Objekte, die über dieselben Methoden verfügen. Somit fungieren Klassen in reinen OOP-Sprachen als Typen. Deutlich wird das bei der objektorientierten Programmierung in Python; die type-Funktion liefert als Typ eines Werts eine Klasse zurück:

type(42) # <class 'int'=""></class>
type('Walter White') # <class 'str'=""></class>

Wie funktioniert objektorientierte Programmierung?

Fragt man eine Person mit ein paar Semestern Programmiererfahrung, worum es bei OOP geht, ist die Antwort wahrscheinlich „irgendwas mit Klassen“. Tatsächlich sind Klassen jedoch nicht der Kern der Sache. Die Grundideen der objektorientierten Programmierung von Alan Kay sind simpler und lassen sich wie folgt zusammenfassen:

  1. Objekte kapseln ihren internen Zustand.
  2. Objekte empfangen Nachrichten über ihre Methoden.
  3. Die Zuordnung der Methoden erfolgt dynamisch zur Laufzeit.

Diese drei kritischen Punkte schauen wir uns im Folgenden genauer an.

Objekte kapseln ihren internen Zustand

Um zu verstehen, was mit Kapselung (engl. encapsulation) gemeint ist, nutzen wir das Beispiel eines Autos. Ein Auto hat einen bestimmten Zustand, z. B. die Batterieladung, der Füllstand des Tanks, ob der Motor läuft oder nicht. Wenn wir ein solches Auto als Objekt abbilden, sollen sich die internen Eigenschaften ausschließlich über definierte Schnittstellen ändern lassen.

Schauen wir uns ein paar Beispiele an. Wir haben ein Objekt car, das ein Auto repräsentiert. Im Inneren des Objekts wird der Zustand in Variablen gespeichert. Das Objekt verwaltet die Werte der Variablen; so lässt sich beispielsweise sicherstellen, dass zum Starten des Motors Energie verbraucht wird. Wir starten den Motor des Autos, indem wir eine Nachricht start senden:

car.start()

An diesem Punkt entscheidet das Objekt darüber, was als nächstes passiert: Läuft der Motor schon, wird die Nachricht ignoriert oder es wird eine entsprechende Meldung ausgegeben. Ist nicht genug Batterieladung vorhanden oder ist der Tank leer, bleibt der Motor aus. Sind alle Voraussetzungen erfüllt, wird der Motor gestartet und der interne Zustand angepasst. Etwa wird eine boolesche Variable motor_running auf „True“ gesetzt und die Batterieladung um die zum Starten benötigte Ladung verringert. Wir zeigen schematisch, wie der Code im Inneren des Objekts aussehen könnte:

# starting car
motor_running = True
battery_charge -= start_charge

Wichtig ist, dass der interne Zustand von außen nicht direkt veränderbar ist. Ansonsten könnten wir motor_running auch bei leerer Batterie auf „True“ setzen. Das wäre Magie und würde die tatsächlichen Gegebenheiten der Realität nicht widerspiegeln.

Nachrichten senden / Methoden aufrufen

Wie wir gesehen haben, reagieren Objekte auf Nachrichten und ändern als Reaktion ggf. ihren internen Zustand. Wir nennen diese Nachrichten Methoden; technisch gesehen handelt es sich dabei um Funktionen, die an ein Objekt gebunden sind. Die Nachricht besteht aus dem Namen der Methode und ggf. weiteren Argumenten. Das empfangende Objekt wird als Receiver bezeichnet. Wir drücken das generelle Schema des Nachrichten-Empfangs durch Objekte wie folgt aus:

# call a method
receiver.method(args)

Ein weiteres Beispiel: Stellen wir uns vor, wir programmieren ein Smartphone. Verschiedene Objekte repräsentieren Funktionalitäten, z. B. die Telefon-Funktionen die Taschenlampe, einen Anruf, eine Textnachricht etc. Üblicherweise werden die einzelnen Unterbestandteile wiederum als Objekte modelliert. So ist das Adressbuch ein Objekt, genau wie jeder enthaltene Kontakt und auch die Telefonnummer eines Kontakts. So lassen sich Vorgänge aus der Realität leicht modellieren:

# find a person in our address book
person = contacts.find('Walter White')
# let's call that person's work number
call = phone.call(person.phoneNumber('Work'))
...
# after some time, hang up the phone
call.hangUp()

Dynamische Zuordnung der Methoden

Das dritte essenzielle Kriterium in Alan Kays ursprünglicher Definition von OOP ist die dynamische Zuordnung der Methoden zur Laufzeit. Das bedeutet, dass die Entscheidung darüber, welcher Code beim Aufruf einer Methode ausgeführt wird, erst beim Ausführen des Programms stattfindet. Als Konsequenz lässt sich das Verhalten eines Objekts zur Laufzeit modifizieren.

Die dynamische Zuordnung der Methoden hat wichtige Auswirkungen auf die technische Implementation von OOP-Funktionalität in Programmiersprachen. In der Praxis hat man damit meist weniger zu tun. Schauen wir uns dennoch ein Beispiel an. Wir modellieren die Taschenlampe des Smartphones als Objekt flashlight. Dieses reagiert auf die Nachrichten on, off und intensity:

// turn on flashlight
flashlight.on()
// set flashlight intensity to 50%
flashlight.intensity(50)
// turn off flashlight
flashlight.off()

Nehmen wir an, die Taschenlampe geht kaputt und wir entscheiden, bei jeglichem Zugriff eine entsprechende Warnung auszugeben. Ein Ansatz besteht darin, alle Methoden durch eine neue Methode zu ersetzen. In JavaScript beispielsweise geht das ganz einfach. Wir definieren die neue Funktion out_of_order und überschreiben damit die existierenden Methoden:

function out_of_order() {
    console.log('Flashlight out of order. Please service phone.')
    return false;
}
flashlight.on = out_of_order;
flashlight.off = out_of_order;
flashlight.intensity = out_of_order;

Versuchen wir im Anschluss, mit der Taschenlampe zu interagieren, wird immer out_of_order aufgerufen:

// calls `out_of_order()`
flashlight.on()
// calls `out_of_order()`
flashlight.intensity(50)
// calls `out_of_order()`
flashlight.off()

Woher stammen Objekte? Instanziierung und Initialisierung

Bisher haben wir gesehen, wie Objekte Nachrichten empfangen und darauf reagieren. Doch woher stammen die Objekte? Befassen wir uns nun mit dem zentralen Begriff der Instanziierung. Instanziierung ist der Prozess, mit dem ein Objekt ins Leben gerufen wird. In verschiedenen OOP-Sprachen gibt es unterschiedliche Mechanismen der Instanziierung. Meist kommen ein oder mehrere der folgenden Mechanismen zum Einsatz:

  1. Definition per Objekt-Literal
  2. Instanziierung mit Konstruktor-Funktion
  3. Instanziierung aus einer Klasse

JavaScript glänzt in diesem Punkt, da sich Objekte wie Zahlen oder Strings direkt als Literale definieren lassen. Ein simples Beispiel: Wir instanziieren ein leeres Objekt person und weisen im Anschluss die Eigenschaft name sowie eine Methode greet zu. Im Anschluss ist unser Objekt in der Lage, eine andere Person zu begrüßen und dabei den eigenen Namen zu nennen:

// instantiate empty object
let person = {};
// assign object property
person.name = "Jack";
// assign method
person.greet = function(other) {
    return `"Hi ${other}, I'm ${this.name}"`
};
// let's test
person.greet("Jim")

Wir haben ein einzigartiges Objekt instanziiert. Häufig möchten wir jedoch die Instanziierung wiederholen, um eine Reihe gleichartiger Objekte zu erzeugen. Auch dieser Fall lässt sich in JavaScript leicht abdecken. Wir erzeugen eine sogenannte Konstruktor-Funktion, die beim Aufruf ein Objekt zusammenbaut. Unsere Konstruktor-Funktion namens Person nimmt einen Namen und ein Alter entgegen und erzeugt beim Aufruf ein neues Objekt:

function Person(name, age) {
    this.name = name;
    this.age = age;
    
    this.introduce_self = function() {
        return `"I'm ${this.name}, ${this.age} years old."`
    }
}
// instantiate person
person = new Person('Walter White', 42)
// let person introduce themselves
person.introduce_self()

Beachten Sie die Benutzung des this-Schlüsselworts. Dieses findet sich auch in anderen Sprachen wie Java, PHP und C++ und sorgt bei OOP-Neulingen oft für Verwirrung. Kurz gesagt ist this ein Platzhalter für ein instanziiertes Objekt. Beim Aufrufen einer Methode referenziert this den Receiver, zeigt also auf eine spezifische Objekt-Instanz. Andere Sprachen wie Python und Ruby benutzen statt this das Schlüsselwort self, das denselben Zweck erfüllt.

Ferner benötigen wir in JavaScript das new-Schlüsselwort, um die Objekt-Instanz korrekt zu erzeugen. Dieses findet sich insbesondere in Java und C++, die bei der Speicherung von Werten im Speicher zwischen „Stack“ und „Heap“ unterscheiden. In beiden Sprachen dient new zum Allozieren von Speicher auf dem Heap. JavaScript legt wie Python alle Werte auf dem Heap ab, sodass new eigentlich unnötig ist. Python macht vor, dass es auch ohne geht.

Der dritte und weitaus verbreitetste Mechanismus zum Erzeugen von Objekt-Instanzen bedient sich der Klassen. Eine Klasse erfüllt eine ähnliche Rolle wie eine Konstruktor-Funktion in JavaScript: Beide dienen als Blaupause, nach der sich bei Bedarf gleichartige Objekte instanziieren lassen. Gleichzeitig fungiert eine Klasse in Sprachen wie Python und Ruby als Ersatz für die in anderen Sprachen zum Einsatz kommenden Typen. Ein Beispiel für eine Klasse zeigen wir weiter unten.

Was sind die Vor- und Nachteile von OOP?

Die objektorientierte Programmierung steht etwa seit Beginn des 21. Jahrhunderts verstärkt in der Kritik. Moderne, funktionale Sprachen mit Immutability und starken Typsystemen gelten als stabiler, verlässlicher und performanter. Dennoch findet OOP weite Verbreitung und hat distinkte Vorteile. Wichtig ist, für jedes Problem das richtige Werkzeug zu wählen, anstatt nur auf eine Methodik zu setzen.

Vorteil: Kapselung

Ein unmittelbarer Vorteil von OOP ist die Gruppierung von Funktionalität. Statt mehrere Variablen und Funktionen in einer losen Sammlung zu gruppieren, lassen sich diese zu konsistenten Einheiten verbinden. Wir zeigen den Unterschied an einem Beispiel: Wir modellieren einen Bus und nutzen dafür zwei Variablen und eine Funktion. Fahrgäste können den Bus besteigen, bis dieser voll ist:

# list to hold the passengers
bus_passengers = []
# maximum number of passengers
bus_capacity = 12
# add another passenger
def take_bus(passenger)
    if len(bus_passengers) < bus_capacity:
        bus_passengers.append(passenger)
    else:
        raise Exception("Bus is full")

Der Code funktioniert, ist aber problematisch. Die take_bus-Funktion greift auf die Variablen bus_passengers und bus_capacity zu, ohne dass diese als Argumente übergeben werden. Dies führt bei umfangreichem Code zu Problemen, da die Variablen entweder global bereitgestellt oder bei jedem Aufruf übergeben werden müssen. Ferner ist es möglich zu „schummeln“. Wir können dem Bus weiter Passagiere hinzufügen, obwohl dieser eigentlich voll ist:

# bus is full
assert len(bus_passengers) == bus_capacity
# will raise exception, won't add passenger
take_bus(passenger)
# we cheat, adding an additional passenger directly
bus_passengers.append(passenger)
# now bus is over capacity
assert len(bus_passengers) > bus_capacity

Zudem hält uns nichts davon ab, die Kapazität des Busses zu erhöhen. Jedoch verletzt dies Annahmen über die physische Realität, denn ein existierender Bus hat eine begrenzte Kapazität, die sich nachträglich nicht beliebig verändern lässt:

# can't do this in reality
bus_capacity += 1

Das Kapseln des internen Zustands von Objekten schützt vor unsinnigen oder ungewollten Veränderungen. Hier dieselbe Funktionalität in objektorientiertem Code. Wir definieren eine Bus-Klasse und instanziieren einen Bus mit limitierter Kapazität. Das Hinzufügen von Passagieren ist nur durch die entsprechende Methode möglich:

class Bus():
    def __init__(self, capacity):
        self._passengers = []
        self._capacity = capacity
    
    def enter(self, passenger):
        if len(self._passengers) < self._capacity:
            self._passengers.append(passenger)
            print(f"{passenger} has entered the bus")
        else:
            raise Exception("Bus is full")
# instantiate bus with given capacity
bus = Bus(2)
bus.enter("Jack")
bus.enter("Jim")
# will fail, bus is full
bus.enter("John")

Vorteil: Systeme modellieren

Objektorientierte Programmierung eignet sich besonders gut zum Modellieren von System. Dabei ist OOP menschlich intuitiv, denn wir denken ebenfalls in Objekten, die sich in Kategorien einordnen lassen. Bei den Objekten kann es sich sowohl um physische Dinge handeln als auch um abstrakte Konzepte.

Auch die in vielen OOP-Sprachen anzutreffende Vererbung über Klassen-Hierarchien spiegelt menschliche Denkmuster wider. Veranschaulichen wir uns den letzten Punkt an einem Beispiel. Ein Tier ist ein abstraktes Konzept. Tatsächlich auftretende Tiere sind immer konkrete Ausprägungen einer Spezies. Je nach Spezies haben die Tiere unterschiedliche Eigenschaften. Ein Hund kann nicht klettern oder fliegen, ist also auf Bewegungen im zweidimensionalen Raum beschränkt:

# abstract base class
class Animal():
    def move_to(self, coords):
        pass
# derived class
class Dog(Animal):
    def move_to(self, coords):
        match coords:
            # dogs can't fly nor climb
            case (x, y):
                self._walk_to(coords)
# derived class
class Bird(Animal):
    def move_to(self, coords):
        match coords:
            # birds can walk
            case (x, y):
                self._walk_to(coords)
            # birds can fly
            case (x, z, y):
                self._fly_to(coords)

Nachteile der objektorientierten Programmierung

Ein unmittelbarer Nachteil von OOP ist der anfangs schwer zu durchschauende Jargon. Man sieht sich gezwungen, ganz neue Konzepte zu erlernen, deren Sinn und Zweck sich an simplen Beispielen oft nicht erschließt. So unterlaufen leicht Fehler; gerade die Modellierung von Vererbungs-Hierarchien benötigt eine Menge Geschick und Erfahrung.

Einer der häufigsten Kritikpunkte an OOP ist die eigentlich als Vorteil gedachte Kapselung des internen Zustands. Diese führt zu Schwierigkeiten beim Parallelisieren von OOP-Code. Denn wird ein Objekt an mehreren parallelen Funktionen übergeben, könnte der interne Zustand sich zwischen Funktionsaufrufen verändern. Außerdem ist es manchmal notwendig, innerhalb eines Programms auf woanders gekapselte Informationen zuzugreifen.

Die dynamische Natur objektorientierter Programmierung führt in der Regel zu Performance-Einbußen. Denn es lassen sich weniger statische Optimierungen vornehmen. Auch die tendenziell weniger stark ausgeprägten Typsysteme reiner OOP-Sprachen machen manche statischen Checks unmöglich. So werden Fehler erst zur Laufzeit sichtbar. Neuere Entwicklungen wie die JavaScript-Übersprache TypeScript steuern dagegen.

Welche Programmiersprachen unterstützen oder eignen sich für OOP?

Fast alle Multi-Paradigmen-Sprachen eignen sich für objektorientierte Programmierung. Dazu gehören die bekannten Internet-Programmiersprachen PHP, Ruby, Python und JavaScript. Demgegenüber sind OOP-Prinzipien weitgehend unvereinbar mit der SQL zugrundeliegenden relationalen Algebra. Zum Überbrücken des „Impedance Mismatch“ kommen spezielle Übersetzungsschichten zum Einsatz, die als „Object Relational Mapper“ (ORM) bekannt sind.

Auch rein funktionale Sprachen wie Haskell bringen meist keine native Unterstützung für OOP mit. Um OOP in C umzusetzen, muss man einiges an Aufwand betreiben. Interessanterweise existiert mit Rust eine moderne Sprache, die ohne Klassen auskommt. Stattdessen kommen struct und enum als Datenstrukturen zum Einsatz, deren Verhalten per impl-Schlüsselwort definiert wird. Mit sogenannten Traits lassen sich Verhalten gruppieren; auch Vererbung und Polymorphismus werden so abgebildet. Das Design der Sprache spiegelt die OOP-Best-Practice „Composition over Inheritance“ wider.