Informatik/Programmierparadigmen.md
2022-05-16 21:07:23 +02:00

64 KiB
Raw Blame History

Programmierparadigmen

Was ist ein Paradigma?

  • Paradigma aus dem Altgriechischen Beispiel, Muster; Erzählung mit beispielhaftem Charakter (laut Duden)
  • Programmierparadigmen beschreiben grundsätzliche Arten wie Computer-Programme formuliert werden können
  • Programmiersprachen können einzelne oder viele Konzepte aufgreifen
    • Keine verbreitete Sprache greift alle behandelten Konzepte auf
    • Betrachtung unterschiedlicher Sprachen
  • Ziel der Veranstaltung: Weiten der in Algorithmen und Programmierung eingeführten Sichten hin zu einem Werkzeugkoffer zur Lösung realer Probleme...

Warum unterschiedliche Paradigmen?

Komplexität von Software schlecht beherrschbar

Was bedeutet das?

  • Programmierer schreiben, testen und dokumentieren zwischen 325 und 750 Codezeilen pro Monat
    • maximal 360.000 Zeilen in 40 Jahren!
  • Komplexität muss verborgen werden, z.B. durch
    • Kapselung
    • Spezifische Spachkonstrukte, Domain Specific Languages
    • Ausdrucksstärkere Sprachen
  • Entwicklung neuer Programmierparadigmen hilft Grenzen (ein wenig) zu verschieben
  • Theoretische Rahmenbedingungen (Turing-Mächtigkeit, Satz von Rice) behalten Gültigkeit!

Welche Paradigmen existieren?

  • Aus Vorlesung AuP:
    • Imperative Algorithmen
    • Applikative Algorithmen
    • Deduktive Algorithmen
  • Aber Vielzahl weiterer Formen
    • teilweise ergänzend, unterschiedliche Kategorisierung möglich
    • Bsp: prozedural, deklarativ, objekt-orientiert, datenstromorientiert, parallele & verteilte Programmierung...
  • Teilweise unterschiedliche Bezeichnungen
    • Applikativ bzw. Funktional
    • Deduktiv bzw. Logisch
  • Aktueller Trend: Multiparadigmen-Sprachen
    • Umsetzung unterschiedlichster Paradigmen in einer Sprache
    • Beispiele: Scala, neuere C++-Standards, ...

Objektorientierung und weiterführende Konzepte am Beispiel Java

  • Bekannt:
    • Grundlegendes Verständnis von Java
    • Kapselung durch Klassen und Vererbung
  • Ziele:
    • Verständnis der Probleme bei Vererbung und Typersetzbarkeit in objektorientierten Programmiersprachen
    • Kennenlernen der Grundideen generischer und abstrahierender Konzepte in Objekt-orientierter Programmierung (OOP)
    • Praktische Erfahrungen anhand von Java & C++
  • Ausdrucksstärke erhöhen, Komplexität verbergen

Unit Testing

Motivation

  • Große Software-Systeme entwickeln sich über lange Zeiträume
  • Wie können Änderungen an komplexen Code-Basen beherrscht werden?
  • Veränderung über Zeit + Komplexität der Software
    • Änderungen führen mglw. zu Auswirkungen, die für Einzelne nicht immer überschaubar sind
    • Software muss nach Änderung von Grund auf durchgetestet werden
  • Verbreitetes Vorgehen: zusätzlichen Code schreiben, der eigentlichen Code automatisch “überprüft”
    • Nicht vollständig möglich (z.B. Halteproblem)
    • Eher Heuristik
  • Test-Code wird bei Ereignissen oder periodisch ausgeführt
    • Vor Releases, nach Commit in Repository, während der Entwicklung ...

Eigenschaften von Unit-Tests

  • Software schlecht als Ganzes testbar -> Zergliederung von Software in sinnvolle Einheiten
  • Individuelle Tests dieser Einheiten
  • Dabei: reproduzierbar & vollautomatisierbar
    • Ziel: Wann immer Änderungen in komplexen Programmen vorgenommen werden, möglichst vollständiger Test, da Programmierer nicht mehr alles überblicken
  • Messung der Vollständigkeit der Tests schwierig
  • Üblich: Messung von Überdeckung (Coverage) in Bezug auf Anzahl Funktionen, Code-Zeilen oder Verzweigungen
  • Gute Praxis: Wenn ein Bug beim Testen oder Live-Betrieb auftritt -> Schreiben eines zusätzlichen Tests, um Wiederauftreten zu erkennen

Unit-Testing in Java

  • De facto Standard: JUnit Framework
  • „Best Practice” für einfachen Einsatz:
    • Java Code in ein oder mehrere Klassen im Ordner src speichern
    • Im Ordner tests jeweils eine Klasse anlegen, die Funktionen einer Implementierungsklasse prüft
    • Konvention: Testklasse einer Klasse Name heißt NameTest
    • Eigentliche Tests werden in Methoden implementiert, die als Tests annotiert sind
    • Typischer Ansatz: für bekannte Werte ausführen und Ergebnis mit Grundwahrheit (erwartetes Verhalten) vergleichen, bspw. mit assertEquals-Funktion
  • Viele weitere Features, z.B. Deaktivieren von Tests, Timeouts, GUI Coverage, Mocks

Unit Testing Richtiges Abstraktionsniveau

  • Um die Tests auszuführen, müssen jeweils entsprechende Hauptprogramme generiert werden („Test Suites“)
  • Hauptschwierigkeiten von Unit-Tests:
    • Richtiges Abstraktionsniveau
    • „Herauslösen“ von zu testendem Code aus Umgebung
  • Zwei wesentliche Möglichkeiten:
    1. Individuelles Testen von Klassen:
    • Vernachlässigt Zusammenspiel zwischen Klassen
    • Oft sehr aufwändig, da andere Klassen für Unit-Tests nachgebildet werden müssen (Mocks)
    • Was bei zyklischen Abhängigkeiten?
    1. Gemeinsames Testen von Klassen:
    • Erfordert Eingreifen in gekapselte Funktionalitäten
    • Private & Protected Member-Variablen & Methoden!
    • Eigentlich nicht möglich?!

Reflections

  • Normaler Ablauf: Programm schreiben, compilieren, ausführen
    • Aber was wenn ich ein Programm zur Laufzeit inspizieren oder verändern möchte?
  • Unterschiedliche Gründe
    • Testen (um Fehler zu injizieren!)
    • Fehlersuche („Debugging“)
    • Nachladen von Plugins zur Modularisierung von Programmen
    • Serialisierung/Deserialisierung von Code
    • „Patchen“ zur Laufzeit
    • Erkunden der Ablaufumgebung (z.B. OS-/Shared-Library Version)
  • Benötigt die Fähigkeit, im Programm Codestruktur zu analysieren und ggf. zu verändern:
    • Typisch: Abruf Klassenhierarchie, Auflisten von Methoden und Parametern, Austausch von Klassen und Methoden
    • Teil von Java, Python, ...

API verstreut über verschiedene Packages, z.B. java.lang.Class, java.lang.instrument, java.lang.reflect

Class cls = "test".getClass();
System.out.println("Die Klasse heisst " + cls.getName());
// Die Klasse heisst java.lang.String
// import java.lang.reflect.Method;
Method[] methods = cls.getMethods();
for (Method m : methods)
System.out.println(m.getName());

Annotationen

  • Annotationen erlauben Anmerkungen an Klassen & Methoden
  • Beginnen mit @
  • Einige wenige vordefinierte z.B. @Override
  • Aber auch eigene; u.a. durch Reflections abrufbar
  • Häufig genutzt, wenn zusätzlicher Code geladen wird (Java EE)
  • Oder um Unit-Tests zu markieren...
class MultiTest {
  @org.junit.jupiter.api.Test
  void mul() {
    ...

Reflektionen über Reflections

  • Reflections sind ein sehr mächtiges Werkzeug, aber Einsatz sollte wohldosiert erfolgen
  • Nachteile:
    • Geringe Geschwindigkeit weil Zugriff über Programmcode erfolgt
    • Kapselung kann umgangen werden
      • private, protected und final können entfernt werden
      • Aufruf/Veränderung interner Methoden & Auslesen/Veränderung interner Variablen
      • Synchronisation zwischen externen und internen Komponenten bei Weiterentwicklung?
    • Debugging veränderter Programme?
    • Sicherheit?!
  • Verwandte Techniken:
    • Monkey Patching (JavaScript-Umfeld)
    • Method Swizzling (Swift/Objective-C-Umfeld)

Assertions

  • Kann man interne Zustände testen, ohne invasive Techniken wie Reflections?
  • Einfache Möglichkeit: An sinnvollen Stellen im Programmcode testen, ob Annahmen/Zusicherungen (Assertions) stimmen...
  • Tests, die nie falsch sein sollten
    • Erlauben gezielten Programmabbruch, um Folgefehler zu vermeiden
    • Erlauben gezieltes Beheben von Fehlern
    • Gemeinsames Entwickeln von Annahmen und Code
class Stack {
  public void push(Object o) {
    ...
    if(empty() == true) // es sollte ein Objekt da sein
      System.exit(-1);
  }
  ...
}

Aber: Ausführungsgeschwindigkeit niedriger

  • Zeitverlust stark abhängig von Programm/Programmiersprache
  • Verbreitetes Vorgehen:
    • Aktivieren der Tests in UnitTests und Debug-Versionen
    • Deaktivieren in Releases
    • Benötigt spezielle „if“-Bedingung: assert
  • Aktivierung der Tests über Start mit java -ea
class Stack {
  public void push(Object o) {
    ...
    assert empty() == false
}

Welche braucht man?

  • Woran erkennt man beim Programmieren bzw. (erneutem) Lesen von Code, dass man eine Assertion hinzufügen sollte?
  • Eine einfache Heuristik Die „Eigentlich“-Regel:
    • Wenn einem beim Lesen von Programmcode ein Gedanke der Art „Eigentlich müsste an dieser Stelle XY gelten“ durch den Kopf geht,
    • dann sofort eine entsprechende Assertion formulieren!

Spezielle Assertions: Pre- & Postconditions

  • An welchen Stellen ist es sinnvoll, Annahmen zu prüfen?
  • Einfache Antwort: an so vielen Stellen wie möglich
  • Komplexere Antwort: Design by contract, ursprünglich Eiffel
  • Methoden/Programmabschnitte testen Bedingung vor und nach Ausführung
  • Einige Sprachen bieten spezialisierte Befehle: requires und ensures -> Ziel mancher Sprachen: Formale Aussagen über Korrektheit
class Stack {
  public void push(Object o) {
    assert o != null // precondition
    ...
    assert empty() == false // postcondition
  }
  ...
}

Klasseninvarianten

  • Bei OO-Programmierung sind Vor- und Nachbedingungen nur eingeschränkt sinnvoll
  • Bedingungen oft besser auf Objekt-Ebene -> interner Zustand
  • Invarianten spezifizieren Prüfbedingungen
  • In Java nicht nativ unterstützt:
    • Erweiterungen, wie Java Modeling Language
    • Simulation:
class Stack {
  void isValid() {
    for(Object o : _objs) // Achtung: O(n) Aufwand!
      assert o != null
  }
  public void push(Object o) {
    isValid() // always call invariant
    ...
    isValid() // always call invariant
}

Exeptions

Signifikantes Element vieler Sprachen: Wie wird mit Fehlern umgegangen? Fehler können unterschiedliche Gründe haben Besser für Code-Komplexität: Fehlerprüfungen an zentralerer Stelle

  • Abbrechen und Programm-Stack „abbauen“ bis (zentrale) Fehlerbehandlung greift
  • Dabei Fehler sinnvoll gruppieren
  • Java (und viele mehr): try/catch/throw-Konstrukt
private void readFile(String f) {
  try {
      Path file = Paths.get("/tmp/file");
      if(Files.exists(file) == false)
        throw new IOException("No such dir");
      array = Files.readAllBytes(file);
  } catch(IOException e) {
      // do something about it
  }
}

throw übergibt ein Objekt vom Typ Throwable an Handler, dabei zwei Unterarten:

  • Error: Sollte nicht abgefangen werden z.B. Fehler im Byte-Code, Fehlgeschlagene Assertions
  • Exceptions:
    • Checked Exception: Programm muss Exception fangen oder in Methode vermerken
    • Runtime Exceptions: Müssen nicht (aber sollten) explizit behandelt werden, bspw. ArithmeticException oder IndexOutOfBoundsException

Checked Exceptions

Deklaration einer überprüften Exception:

  void dangerousFunction() throws IOException {
    ...
    if(onFire)
      throw IOException("Already burns");
    ...
}

Die Deklaration mit "throws IOException" lässt beim build mögliche Fehler durch IOExceptions dieser Funktion zu, diese müssen durch die aufrufende Methode abgefangen werden. Aufrufe ohne try-catch-Block schlagen fehl! Sollte man checked oder unchecked Exceptions verwenden?

  • Checked sind potenziell sicherer
  • Unchecked machen Methoden lesbarer
  • Faustregel unchecked, wenn immer auftreten können (zu wenig Speicher, Division durch 0)

Abfangen mehrerer unterschiedlicher Exceptions

try {
  dangerousFunction();
} catch(IOException i) {
  // handle that nasty error
} catch(Exception e) {
  // handle all other exceptions
}

Aufräumen nach einem try-catch-Block: Anweisungen im finally-Block werden immer ausgeführt, d.h. auch bei return in try- oder catch-Block (oder fehlerloser Ausführung)

try {
  dangerousFunction();
} catch(Exception e) {
  // handle exceptions
  return;
} finally {
  // release locks etc..
}

Generizät von Datentypen

(Typ-)Generizität:

  • Anwendung einer Implementierung auf verschiedene Datentypen
  • Parametrisierung eines Software-Elementes (Methode, Datenstruktur, Klasse, ...) durch einen oder mehrere Typen Beispiel:
int min(int a, int b) {
  return a < b ? a : b;
}
float min(float a, float b) {
  return a < b ? a : b;
}
String min(String a, String b) { // lexikographisch
  return a.compareTo(b) < 0 ? a : b;
}

Grenzen von Typsubstitution

Problem: Für jeden Typ? Wie kann sort implementiert werden? Möglicher Ausweg: Klassenhierarchie mit zentraler Basisklasse

void sort(Object[] feld) { ... }            //z.B. java.lang.Object
void sort(java.util.Vector feld) { ... }    //alternativ (nutzt intern Object)

Möglicher Ausweg 2: Nutzung primitiver Datentypen nicht direkt möglich

Object[] feld = new Object[10];         //Object[] ≠ int[]
feld[0] = new Integer(42);
int i = ((Integer) feld[0]).intValue(); //erfordert Wrapper-Klassen wie java.lang.Integer

Weiteres Problem: Typsicherheit
Typ-Substituierbarkeit: Kann ein Objekt einer Oberklasse (eines Typs) durch ein Objekt seiner Unterklasse (Subtyps) ersetzt werden? Beispiel (isSubtyp): short \rightarrow int \rightarrow long Viele Programmiersprachen ersetzen Typen automatisch, d.h. diese wird auch für shorts und ints verwendet

long min(long a, long b) {
  return a < b ? a : b;
}

Kreis-Ellipse-Problem: Modellierung von Vererbungsbeziehungen

  • „Ist ein Kreis eine Ellipse?“ „Oder eine Ellipse ein Kreis?“
  • Annahme: Kreis := Ellipse mit Höhe = Breite
Circle c = new Circle();  
c.skaliereX(2.0);       //skalieren aus Klasse Circle
c.skaliereY(.5);        //is das noch ein Kreis?

evtl. Reihenfolge in der Klassenhierarchie tauschen (nutzung von Radius)? Was bedeutet das für Ellipse? Verwandte Probleme: Rechteck-Quadrat, Set-Bag

Ko- und Kontravarianz

Geg.: Ordnung von Datentypen von spezifisch \rightarrow allgemeiner

  • Gleichzeitige Betrachtung einer Klassenhierarchie, die Datentypen verwendet
  • Kovarianz: Erhaltung der Ordnung der Typen
  • Kontravarianz: Umkehrung der Ordnung
  • Invarianz: keines von beiden
  • Anwendung für
    • Parameter
    • Rückgabetypen
    • Ausnahmetypen
    • Generische Datenstrukturen

Beispiel: Basierend auf Meyers SKIER-Szenario

class Student {
  String name;
  Student mate;
  void setRoomMate(Student s) { ... }
}

Wie überschreibt man in einer Unterklasse Girl oder Boy die Methode „setRoomMate“ in elternfreundlicher Weise? Von Eltern sicher gewollt - Kovarianz:

class Boy extends Student {
  void setRoomMate(Boy b) { ... }
}
class Girl extends Student {
  void setRoomMate(Girl g) { ... }
}

Was passiert mit folgendem Code?

Boy kevin = new Boy("Kevin");
Girl vivian = new Girl("Vivian");
kevin.setRoomMate(vivian);
  • Verwendet setRoomMate der Basisklasse

  • setRoomMate Methoden der abgeleiteten Klassen überladen nur Spezialfälle \rightarrow gültig

  • In C++ und Java keine Einschränkung der Typen zur Compile-Zeit

  • Kovarianz so nur in wenigen Sprachen implementiert (z.B. Eiffel über redefine); Überprüfung auch nicht immer statisch!

  • Auch bekannt als catcall-Problem (cat = changed availablility type) Ausweg: Laufzeitüberprüfung

class Girl extends Student {
  ...
  public void setRoomMate(Student s) { //student wird aufgerufen! nicht boy oder girl, dadurch können die methoden der klasse verwendet werden
    if (s instanceof Girl)
      super.setRoomMate(s);
    else
      throw new ParentException("Oh Oh!");
  }
}

Nachteil: Nur zur Laufzeit überprüfung

Ko- und Kontravarianz für Rückgabewerte

Kovarianz (gängig):

public class KlasseA {
  KlasseA ich() { return this; }
}
public class KlasseB extends KlasseA {
  KlasseB ich() { return this; }
}

Kontravarianz macht wenig Sinn und kommt (gängig) nicht vor

In objektorientierten Programmiersprachen im Allgemeinen

  • Kontravarianz: für Eingabeparameter
  • Kovarianz: für Rückgabewerte und Ausnahmen
  • Invarianz: für Ein- und Ausgabeparameter

Liskovsches Substitutionsprinzip (LSP)

Barbara Liskov, 1988 bzw. 1993, definiert stärkere Form der Subtyp-Relation, berücksichtigt Verhalten:

Wenn es für jedes Objekt o_1 eines Typs S ein Objekt o_2 des Typs T gibt, so dass für alle Programme P, die mit Operationen von T definiert sind, das Verhalten von P unverändert bleibt, wenn o_2 durch o_1 ersetzt wird, dann ist S ein Subtyp von T.' Subtyp darf Funktionalität eines Basistyps nur erweitern, aber nicht einschränken.
Beispiel: Kreis-Ellipse \rightarrow Kreis als Unterklasse schränkt Funktionalität ein und verletzt damit LSP

Generics in Java (Typsicherheit)

Motivation: Parametrisierung von Kollektionen mit Typen

LinkedList<String> liste = new LinkedList<String>();
liste.add("Generics");
String s = liste.get(0);

auch für Iteratoren nutzbar

Iterator<String> iter = liste.iterator();
  while(iter.hasNext()) {
    String s = iter.next();
    ...
}

oder mit erweiterter for-Schleife

for(String s : liste) {
  System.out.println(s);
}

Deklaration: Definition mit Typparameter

class GMethod {
  static <T> T thisOrThat(T first, T second) {
    return Math.random() > 0.5 ? first : second;
  }
}
  • T = Typparameter (oder auch Typvariable) wird wie Typ verwendet, stellt jedoch nur einen Platzhalter dar
  • wird bei Instanziierung (Parametrisierung) durch konkreten Typ „ersetzt“
  • nur Referenzdatentypen (Klassennamen), keine primitiven Datentypen Anwendung:
  • explizite Angabe des Typparameters
    String s = GMethod.<String>thisOrThat("Java", "C++");
    Integer>thisOrThat(new Integer(42), new Integer(23));
    
  • automatische Typinferenz durch Compiler
    String s = GMethod.thisOrThat("Java", "C++");
    Integer i = GMethod.thisOrThat(new Integer(42), new Integer(23));
    

Eingrenzung von Typparametern

Festlegung einer Mindestfunktionalität der einzusetzenden Klasse, z.B. durch Angabe einer Basisklasse

  • Instanziierung von T muss von Comparable abgeleitet werden (hier ein Interface, dass wiederum generisch ist, daher Comparable)
  • Verletzung wird vom Compiler erkannt
static<T extends Comparable<T>> T min(T first, T second) {
  return first.compareTo(second) < 0 ? first : second;
}

Angabe des Typparameters bei der Klassendefinition:

class GArray<T> {
  T[] data;
  int size = 0;
  public GArray(int capacity) { ... }
  public T get(int idx) { return data[idx]; }
  public void add(T obj) { ... }
}

Achtung: new T[n] ist unzulässig! Grund liegt in der Implementierung von Generics: Es gibt zwei Möglichkeiten der internen Umsetzung generischen Codes:

  • Code-Spezialisierung: jede neue Instanziierung generiert neuen Code
    • Array → ArrayString, Array → ArrayInteger
    • Problem: Codegröße
  • Code-Sharing: gemeinsamer Code für alle Instanziierungen
    • Array → Array