Skip to content

An overview about important design patterns for software development

Notifications You must be signed in to change notification settings

worldiety/design-patterns

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 

Repository files navigation

Einführung ins Programmieren

Einführung

Dieses Werk richtet sich an Lerner, die in die Softwareentwicklung einsteigen möchten. In Analogie zum schulischen Sprachunterricht, besteht auch das Erlernen von Programmieren aus verschiedenen Kompetenzbereichen. Maßgeblich lassen sich die Kompetenzen wie folgt aufteilen.

Grammatik

In der Schuldidaktik gibt es immer wieder Unterrichtsphasen, die die Form und Funktion einer natürlichen Sprache lehren. Vergleichbar dazu ist die jeweilige Sprachspezifikation einer konkreten Programmiersprache. Diese unterscheiden sich in Umfang und Bedeutung zwischen den Sprachen erheblich, genauso wie die Grammatiken zwischen English, Französisch oder Deutsch. Vergleichbar sind zudem auch Grammatiken von Programmiersprachen, die sich Ursprünge und Einflüsse teilen und gemeinsame Charakteristiken aufweisen können. So wurde die Sprache Go beispielsweise u.a. durch Pascal und C beeinflusst.

Muster

Neben der Grammatik wird in der Schuldidaktik auch das Schreiben verschiedener Textformen, von Briefen, Berichten bis hin zu Aufsätzen jahrelang geübt. Der Sinn dieses Einübens erschließt sich dem Schüler üblicherweise nicht, befähigt es ihn doch im Alltag jetzt und später sich in den Texten anderer unmittelbar zurecht zu finden. Ebendieses Lesen wird jeden üblicherweise viel häufiger treffen, als das produzieren von Texten. Dies lässt sich ebenfalls auf das Programmieren übertragen, auch hier verbringt ein Entwickler üblicherweise viel mehr Zeit damit anderer Leute Quelltext zu lesen und zu verstehen. Lilienthal unterstellt, dass ein Entwickler ca. 30.000 Zeilen Quellcode überblicken kann (vgl. [lilienthal] S. 11) und dabei die Konsequenzen einer Änderungen vorhersehen kann. Diese Einschätzung möchte ich teilen. Heute übliche Softwaresysteme enthalten schnell mehrere 100 Millionen Zeilen Quellcode, die ein einzelner Mensch in seiner verbliebenen Lebenszeit nicht einmal mehr lesen könnte. Lilienthal verweist hier auf die drei strukturbildenden Prozesse der kognitiven Psychologie, die mir sehr passend erscheinen (vgl. [lilienthal] S. 72). Die Anwendung von Chunking, Aufbau von Schemata sowie der Bildung von Hierarchien erscheint geradezu notwendig, um ein erfolgreicher Programmierer zu sein.

"Programmieren kann jeder"

Genauso wie jeder in seiner natürlichen Sprache schreiben kann, kann gewissermaßen also auch jeder programmieren. Dennoch befähigt es nicht jeden, einen wissenschaftlichen Aufsatz zu schreiben, der einer Überprüfung standhalten würde. Genauso verhält es sich mit dem Programmieren, bei dem weit mehr nötig ist, als die Fähigkeit eine Tastatur zu bedienen:

  • Verständnis der Fachlichkeit

  • Beherrschung der Sprache, der zugehörigen Werkzeuge und üblicher Bibliotheken Dritter

  • Anwendung allgemein gültiger Muster, vom kleinen Entwurfsmustern bis hin zu Architekturstilen

Bis auf bestimmte Ausnahmen handelt es sich bei Software zudem üblicherweise um ein evolutionäres Produkt, dass aus zweierlei Sicht fortwährend altert. Klassischerweise verändern sich Programmiersprachen, ähnlich wie ihre natürlich-sprachlichen Zwillinge, gemäß den Erfordnissen des aktuellen Zeitgeistes mit der Konsequenz, dass sich syntaktische und semantische Eigenschaften der Sprache oder abhängigiger Module Dritter in inkompatibler Weise ändern. Viel häufiger noch ändern sich jedoch die Anwendungsfälle der Nutzer:innen, die bei Änderungen ganze Architekturen ad absurdum führen können und selbst in unaufälligen Ergänzungen aber zumindest eine fortwährende Architekturerosion herbeiführen.

Entwurfsmuster

In diesem Kapitel werden die wichtigsten Entwurfsmuster und Varianten vorgestellt.

Creational Patterns

Alle Creational Patterns dienen der Erzeugung von Struct- oder Object-Instanzen. Je nach Problemzerlegung, kann ein bestimmtes Muster Vor- und Nachteile haben.

Factory Method

Beachte, dass Gamma et al. (siehe [gof] S.107) eine sehr spezifische und eingeschränkte Sicht auf eine Factory-Funktion haben. Diese ist insbesondere dadurch belastet, dass die Autoren dies immer vollständig einem Objekt zuordnen und zusätzlich den Rückgabetyp virtuell halten.

Warning
Die Definition einer Factory Methode von Gamma et al. unterscheidet sich von der praktischen Verwendung in anderen Programmiersprachen, die viel häufiger in einem statischen Kontext anzutreffen sind und in der Definition von Gamma et al. nur künstlich vom Builder-Pattern zu unterscheiden ist. Siehe auch Factory-Function für eine bessere Definition.

Factory-Function

Für mich sind Factory-Funktionen und Konstruktoren fast das gleiche. Ein Konstruktor hat hingegen die Einschränkung, dass er in gängigen Programmiersprachen in jedem Fall die Erzeugung und Allozierung einer neuen Instanz zur Folge hat, was bei einer Factory-Funktion nicht der Fall sein muss (Stichwort Singleton). Ferner kann eine Factory-Funktion die Aufgabe von Information hiding übernehmen und die konkrete Instanz geheimhalten und so auch häufig den Einsatz von anderen GoF Creational Patterns in der Praxis überflüssig machen.

Beispiel Factory-Function in Go. Da die Sprache keine Konstruktoren kennt, können wir sonst keine obligatorischen Argumente ausdrücken.
package main

type Person struct{
    Name, Firstname string
}

func NewPerson(name,firstname string)*Person{
    if name == "" || firstname == ""{
        panic("illegal state")
    }

    return &Person{Name:name,Firstname:firstname}
}

func main(){
    person1 := &Person{} // Attribute entsprechen jeweils dem zero-value
    person2 := NewPerson("Torben","Schinke") // Validierung möglich

}
Tip
In Go ist die Verwendung von Factory-Funktionen mit dem Prefix New<Typename> best-practice und du solltest es grundsätzlich immer benutzen. Verwende in anderen Sprachen die übliche Konstruktor-Deklaration stattdessen.

Builder

Das Builder-Pattern von Gamma et al. (siehe [gof] S.102) erscheint zum einen unnötig komplex und übersieht zum anderen meines Erachtens die eigentlichen Mehrwerte und die abgrenzenden Vorteile von den Definitionen der Abstract Factory und der Factory Method. Daher möchte ich auf das MazeGame-Beispiel hier nicht besonders eingehen, stattdessen mein Verständnis des Builder-Patterns darlegen.

Ein Builder trennt die Erzeugung einer Instanz von der Instanz selbst, insbesondere um die folgenden Aspekte zu verbessern:

  • Ausdruck von optionalen Parametern

  • Validierung komplexer Parameterisierungen

  • Bereitstellen einer typsicheren Builder-DSL

  • es kann - muss aber nicht - ein Interface-Typ zurückgegeben werden, um zwischen verschiedenen Implementierungen wechseln zu können

Tip
Ein klassisches Beispiel für das Builder-Pattern ist der string.Builder. Verwende das Pattern, wenn dein Konstruktor zu komplex wird.

Abstract Factory

Die Idee hierbei ist, dass die Erzeugung von Komponenten vollständig durch eine Factory-Klasse übernommen wird und weder die konkreten Konstruktoren noch Typen bekannt sind. Gamma et al. (siehe [gof] S.87) führen hierfür das Beispiel einer WidgetFactory für verschiedene Look-and-feels an. Das hier vorgestellte Beispiel zeigt sehr schön, wie die zugehörige Degeneration und Verklausulierung des entsprechenden Quellcodes aussehen würde. Ein entsprechender UI-Code müsste fortwährend eine WidgetFactory übergeben bekommen, um seinen Widget-Tree zu erzeugen. Hier haben sich stattdessen zwei alternative Muster in der Praxis bewährt:

  • Context-Injection: Ein Beispiel hierfür ist die Verwendung eines Context-Objektes, dass mit den Style-Informationen verknüft ist, sehr populär im klassischen Android-Widget System, siehe z.B. TextView.

  • Value-Modelle oder DSL (domain specific languages): Beispiele hierfür sind die deklarativen Ansätze von SwiftUI oder Jetpack Compose. Alle konkreten Rendering-Klassen sind hierbei vollständig entkoppelt, wodurch die Wartbarkeit erheblich steigt und das Rendering-System dahinter ganz andere Optimierung ermöglicht.

Warning
Anti-Pattern. In der Praxis ist mir das Muster bisher nur negativ aufgefallen. Andere Lösungen waren immer besser, verwende das Pattern besser nicht - egal in welcher Sprache.

Singleton

Ein Singleton stellt sicher, dass es prozessweit nur eine Instanz geben kann, die alle benutzen müssen. Warum dieses Muster als empfehlenswert von Gamma et al. vorgestellt wird, ist für mich nicht nachvollziehbar, da die Nachteile extrem gravierend sind:

  • Ein Singleton stellt nichts anderes dar, als strukturierte globale Variablen.

  • Globale Variablen lassen sich nur schwer testen und überhaupt nicht in parallelisierten Tests.

  • Die Verwendung eines Singletons ist meist der Grundstein für später kaum auflösbare technischer Schulden.

  • Die Kopplung an Singletons ist nur schwer zu sehen, da die Abhängigkeit idR. nicht injiziert wird.

  • Einbindung in fremde Lifecycles ist gefährlich und verursacht häufig Resource-Leaks oder ungültige Zustände. Stelle dir mehrere Fragments vor, die den Android Fragment Lifecycle durchlaufen und versuchen ihren Zustand über ein Singleton abzugleichen.

Heutige Systeme verwenden dieses Pattern kaum mehr:

  • OpenGL bindet seinen Context implizit an Thread-Local Variablen. Vulkan gibt dies zugunsten von Handles auf.

  • Go bietet gar keine ThreadLocals. Stattdessen wird ein Context-Type weitergereicht.

Hier wird das Singleton-Pattern sinnvoll genutzt:

  • In Go und Java werden z.T. Heap-Allocations von geboxten Integers oder Floats vermieden.

  • Die Nachteile des Singletons werden dabei aber auch durch den (immutable) Value-Charakter vermieden.

Warning
Anti-Pattern. Die Verwendung des Singleton-Musters ist eigentlich immer ein Fehler. Wenn du es verwendest, dann nur für immutable (Value-)Types.

Go Patterns

Bestimmte Muster sind sehr typisch für die Sprache Go und eher selten in anderen Sprachen so zu finden.

Accept interfaces, return structs

Ein Interface in Go funktioniert anders als in anderen Sprachen und wird eher als Beschreibung für ein Verhalten aufgefasst. Das ist auch die Begründung, warum man in Go zwischen Datentypen (Struct mit Feldern) und Interfaces (nur Methoden) unterschiedet und Felder nicht Bestandteil von Interfaces sind. In anderen Sprachen dient es häufig eher dazu, einem abstrakten Vertrag jeglicher Art zu entsprechen. In Go sollte ein Modul bzw. ein Stück Code immer seine eigenes Interfaces - also das Verhalten was es erwartet - definieren. Die Vorteile davon sind erheblich:

  • Die Interfaces werden kleiner, da ein Konsument nur das definiert, was er gerade benötigt. In Go bestehen die besten Interfaces aus nur einer Methode.

  • häufig kann eine Kopplung auf einen Shared Kernel mit gemeinsamen Interface-Deklarationen verzichtet werden. Dies verbessert die Wiederverwendbarkeit und reduziert die Kopplung zu nicht beteiligten anderen Interfaces und Datenmodellen.

Wenn man ein Struct zurückliefert, also einen nicht-virtuellen Typ, erleichtert und verbessert dies die Optimierungen, die ein Compiler vornehmen kann. In der Java-Welt werden konkrete Typen häufig als Anti-Pattern wahrgenommen, in der Praxis macht die unreflektierte Implementierung gegen Interfaces allerdings häufig keinen Sinn, da hier nur ohne nachweislichen Nutzen Quelltext produziert wird, der die Lesbarkeit einschränkt (vgl. das scherzhafte aber irgendwie doch wahre Java EE FizzBuzz). Eine gut gelebte Softwarearchitektur hat kein wesentliches Problem mit der Degeneration durch die Verwendung konkreter Typen. Stattdessen profitiert sie von der hohen konkreten Aussagekraft.

Accept interfaces, return structs am Beispiel einer Dependency Injection. So kann jeder Entwickler, der das Greet-Modul verwendet nach Belieben eigene Structs erstellen (z.B. Dog, Car, Company), die alle automatisch das Interface Nameable erfüllen und mit dem Gretter kompatibel sind.
package main

type Person struct{
    Name, Firstname string
}

func (p Person)Name()string{
    return p.name
}

type Cat struct {
    Name string
}

func (p Cat)Name()string{
    return p.name
}

type Nameable interface{
    Name()string
}

type Greeter struct{
    nameable Nameable
}

func (g Greeter)Greet(){
    println("hello "+g.nameable.Name())
}

func NewGreeter(nameable Nameable)Greeter{
    return Greeter{nameable:nameable}
}


func main(){
    person := Person{Name:"Torben"}
    cat := Cat{Name:"Simba"}
    NewGreeter(person).Greet()
    NewGreeter(cat).Greet()
}

Literatur

  • [lilienthal] Carola Lilienthal. Langlebige Softwarearchitekturen - Technische Schulden analysieren, begrenzen und abbauen. dpunkt.Verlag, 3. Auflage 2020.

  • [gof] Erich Gamma, Richard Helm, Ralph Johnson & John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. 1994.

About

An overview about important design patterns for software development

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages