OOP vs. FP. Das Streben nach Erweiterbarkeit Teil 1

In den letzten Jahren habe ich mich für die Scala-Programmiersprache interessiert und viel Kritik an der Mischung aus funktionaler und objektorientierter Programmierung gehört. Auf der anderen Seite ist Functional Programming in letzter Zeit so populär geworden, dass OOP mittlerweile als eine altmodische Methode angesehen wird, die so schnell wie möglich in FP übersetzt werden sollte.

In diesem Blog-Beitrag werde ich versuchen, beide Ansätze im Hinblick auf die Erweiterbarkeit zu vergleichen, die auf dem Ausdrucksproblem basieren, das von dem Informatikprofessor Philip Wadler formuliert wurde und das maßgeblich zur Entwicklung der funktionalen Programmierung beigetragen hat.

Erweiterbarkeit

Wir alle wissen, dass sich unser Code ständig weiterentwickelt. Es gibt immer einige Überarbeitungen, Fehlerbehebungen und (hoffentlich) Ergänzungen von neuen Funktionen. Darüber hinaus resultieren die meisten Fehler und Probleme mit dem Code im Allgemeinen aus der Tatsache, dass wir ständig Änderungen vornehmen.

Viele dieser Änderungen wurden möglicherweise vor langer Zeit von Ihren (gegenwärtigen oder früheren) Teammitgliedern geschrieben. Es gibt keine Möglichkeit, Änderungen an unserem Code zu vermeiden, aber wir können die Anzahl der erforderlichen Änderungen beim Hinzufügen neuer Funktionen optimieren, indem wir unseren Code einfach erweiterbar machen.

Die Erweiterbarkeit der Software ist von entscheidender Bedeutung, damit sich eines der berühmten SOLID-Prinzipien ausschließlich diesem Thema widmet, wie es das Open-Close-Prinzip von Bertrand Mayer formuliert

SOFTWARE-EINHEITEN (KLASSEN, MODULE, FUNKTIONEN usw.)
MUSS ZUR VERLÄNGERUNG GEÖFFNET, ABER GESCHLOSSEN WERDEN
ÄNDERUNG.

Mit einfachen Worten, wir müssen Code so erstellen, dass wir, wenn wir etwas Neues hinzufügen möchten, nicht gezwungen sind, etwas in der Vergangenheit Geschriebenes anzufassen.

Das Ausdrucksproblem

Das Ausdrucksproblem konzentriert sich auf eine der wichtigsten Funktionen von Code: die Erweiterbarkeit. Dies kann als die Fähigkeit beschrieben werden, sich zu entwickeln, ohne:

  • Beeinträchtigung der Klarheit des vorhandenen Codes
  • viel Mühe in das Hinzufügen neuer Funktionen stecken
  • Brechen von Code, der ordnungsgemäß funktioniert

Ich habe die Erweiterbarkeit lange Zeit als eine einzige, unidirektionale Tugend des Codes betrachtet, aber als ich das Ausdrucksproblem kennenlernte, stellte ich fest, dass es tatsächlich zwei verschiedene Möglichkeiten gibt, den Code zu erweitern.

Laut Professor Wadler besteht die erste Richtung darin, unseren Code durch Hinzufügen neuer Formulare zu erweitern. Dies kann einfach als brandneue Implementierung aktueller Schnittstellen beschrieben werden.

Die zweite Richtung ist das Hinzufügen neuer Operationen. Operationen drücken, wie Sie wahrscheinlich vermuten, Funktionen aus, die als neue Methode in der Benutzeroberfläche dargestellt werden.

Die ersten Schritte

Um die Erweiterbarkeit unseres Codes zu testen, müssen wir zuerst etwas erweitern. Unser erstes Beispiel besteht aus einer Operation, die als Bewertung eines arithmetischen Ausdrucks dargestellt wird. Wir stellen es als rekursive Datenstruktur von Klassen dar, die den Ausdruck auf jeder Ebene codieren, und teilen uns mit, was mit den Operanden zu tun ist:

val Ausdruck = Add (Add (Nummer (2), Nummer (3)), Nummer (4))

Hier modellieren wir mit Objekten der Typen Add und Number nach dem arithmetischen Ausdruck:

(2 + 3) + 4

Unsere Aufgabe ist es, unseren Ausdruck zu bewerten oder (mathematischer ausgedrückt) auf einen einzelnen Wert eines Double zu reduzieren.

Der objektorientierte Programmieransatz

Lassen Sie mich mit der OOP-Methode zur Durchführung von Polymorphismen beginnen, dem sogenannten Subtyping-Polymorphismus.

Beginnen wir mit der Tatsache, dass ein Ausdruck mehrere Formen haben kann (wir können sagen, dass er polymorph ist). Um diese Formen miteinander zu verknüpfen, müssen wir eine gemeinsame Abstraktionsschnittstelle erstellen, die in Scala als Merkmal definiert ist.

Merkmal Ausdruck {
  def eval: Double
}

Unser Merkmal definiert eine Operation, die für jede Form eines Ausdrucks ausgeführt werden kann. Wir haben diese Operation als eval bezeichnet. Ihre Aufgabe besteht darin, den gesamten Ausdruck auf den Endterm eines Werts von Double zu reduzieren. Ein paar Absätze weiter oben haben wir uns für zwei Ausdrucksformen entschieden: Number und Add.

case class Number (value: Double) erweitert Expr {
  override def eval = value
}
case class Add (a: Ausdruck, b: Ausdruck) erweitert Ausdruck {
  override def eval = a.eval + b.eval
}

Number gibt nur den umgebrochenen Wert zurück und wir können ihn als Endbedingung unseres rekursiven Schemas behandeln.

Add wertet die linke und rechte Seite aus und summiert dann die berechneten Werte. Das ist schön und einfach: Es ist nur die übliche Rekursion, die jedoch in den Methodenaufrufen und nicht in den Funktionen ausgedrückt wird.

Wir haben alles, was wir brauchen, um unsere Implementierung zu testen. Lassen Sie mich daran erinnern, wie unser Referenzausdruck aussieht, und versuchen, sein Ergebnis in REPL zu überprüfen.

val Ausdruck = Add (Add (Nummer (2), Nummer (3)), Nummer (4))
expression.eval
// res0: Double = 9.0

Groß! Dies scheint ein gültiges Ergebnis zu sein, daher funktioniert unsere Implementierung. Jetzt ist es an der Zeit, dasselbe auf die FP-Art umzusetzen.

Der Ansatz der funktionalen Programmierung

In der funktionalen Programmierung versuchen wir, Daten und Operationen zu trennen. Daher beginnen wir mit einer Definition unseres ADT (Algebraic Data Type), die das Modell unseres arithmetischen Ausdrucks definiert.

Merkmal Ausdruck
case class Number (value: Double) erweitert Expr
case class Add (a: Ausdruck, b: Ausdruck) erweitert Ausdruck

Wenn die abstrakte Definition vorhanden ist, müssen Operationen implementiert werden, die auf das Modell angewendet werden sollen. Die Implementierung ist fast die gleiche wie in OOP, sodass nicht viel erklärt werden muss. Wir haben gerade die Methoden für jedes Formular durch eine Funktion ersetzt, die einem Ausdruck entspricht und bestimmt, was jetzt zu tun ist.

def auswerten (Ausdruck: Ausdruck): Double = Ausdrucksübereinstimmung {
  
  Fall Nummer (a) => a
  case Addiere (a, b) => bewerte (a) + bewerte (b)
}

Wir haben unseren Ausgangspunkt in Bezug auf FP und OOP definiert. Als Nächstes testen wir die Erweiterbarkeit jedes dieser Paradigmen.

Erweiterung durch Hinzufügen einer neuen Operation

Stellen wir uns vor, wir möchten die Möglichkeit hinzufügen, unseren Ausdruck zu drucken. Um den Ausdruck auszuführen, müssen wir keine andere Form der Darstellung des Ausdrucks hinzufügen (wir haben immer noch nur Add und Number), sondern eine völlig neue Methode zur Auswertung des ADTs unseres Ausdrucks. Es kann wie folgt gestaltet werden:

val Ausdruck = Add (Add (Nummer (2), Nummer (3)), Nummer (4))
val printed = print (Ausdruck)
// res1: String = ((2.0 + 3.0) + 4.0)

Die funktionale Programmiermethode zur Erweiterung von Operationen

Dieses Mal werden wir mit dem FP-Weg beginnen. Dies kann einfach durch Hinzufügen der neuen Funktion (Operation) geschehen, die zu Beginn der Auswertung definiert wurde:

def print (expression: Expr): String = expression match {
  case Number (a) => a.toString
  case Add (a, b) => s ”($ {print (a)} + $ {print (b)})”
}

Und das ist es! Wir haben gerade eine neue Funktion hinzugefügt, die Expr-Objekte akzeptiert. In FP erfordert das Erweitern von Operationen nicht, dass der zuvor geschriebene Code bearbeitet wird, sodass der Arbeitsaufwand und das Risiko, Regressionen in den Code einzuführen, sehr gering sind.

Wir können sehen, dass die funktionale Programmierung den Ausdrucksproblemtest auf der Grundlage der Fähigkeit, sich in die Operationsrichtung zu erstrecken, bestanden hat. Wir haben eine komplett neue Operation hinzugefügt, die keine Änderung des vorhandenen Codes erfordert.

Die OOP-Methode zur Erweiterung von Operationen

Wir möchten unserem Ausdruck eine neue Fähigkeit hinzufügen, sich selbst zu drucken. Wir haben das Ausdrucksverhalten bereits durch das Merkmal Ausdr definiert, das angibt, dass jede Form des Ausdrucks eine implementierte Möglichkeit haben sollte, sich auf einen einzelnen Double-Wert zu reduzieren.

Aus diesem Grund müssen wir eine weitere Einschränkung hinzufügen, damit unser Ausdruck gedruckt werden kann:

Merkmal Ausdruck {
  def eval: Double
  def print: String
}

Wie Sie sehen können, erfordert dieses Beispiel viel mehr Änderungen in der vorhandenen Codebasis, da die Art des Subtyping-Polymorphismus erfordert, dass jede abstrakte Methode, die von ihren Eltern geerbt wird, in der Erweiterungsklasse implementiert wird:

case class Number (value: Double) erweitert Expr {
  override def eval = value
  override def print = value.toString
}
case class Add (a: Ausdruck, b: Ausdruck) erweitert Ausdruck {
  override def eval = a.eval + b.eval
  override def print = s ”($ {a.print} + $ {b.print})”
}

Bisher ist FP in Bezug auf die Erweiterbarkeit letztendlich effektiver. Wie Sie vielleicht vermuten, ist die Realität jedoch nicht so rosig, dass wir die zweite Art der Erweiterbarkeit testen müssen - Formen.

Formulare erweitern

Lassen Sie uns nun die Erweiterbarkeit in der zweiten Dimension überprüfen, indem wir unserem Code neue Ausdrucksformen hinzufügen. Wir möchten ein neues Formular hinzufügen, um die Fähigkeit darzustellen, einen Ausdruck zu negieren.

Im vorherigen Abschnitt haben wir gesehen, dass FP sehr gut funktioniert, wenn es durch Hinzufügen einer neuen Operation (print) erweitert werden soll, und die Anforderung des Ausdrucksproblems erfüllt, nämlich Code zu erweitern, ohne die zuvor geschriebene Funktionalität zu ändern. Mal sehen, wie FP das Hinzufügen einer neuen Ausdrucksform handhabt.

Zunächst beginnen wir wie bisher mit der Spezifikation:

val Ausdruck = Add (Add (Nummer (2), Neg (Nummer (3))), Nummer (4))

Wir sehen, dass sich die Form unseres Ausdrucks geändert hat. Jetzt stellt es das folgende dar:

(2 + (-3)) + 4

So, jetzt wird unser Ergebnis 3 statt der vorherigen 9 sein. Wir haben alles, um das Codieren in der FP-Weise zu starten:

case class Neg (a: Expr) erweitert Expr
def auswerten (Ausdruck: Ausdruck): Double = Ausdrucksübereinstimmung {
  Fall Nummer (a) => a
  case Addiere (a, b) => bewerte (a) + bewerte (b)
  case Neg (a) => - evaluiere (a)
}
def print (expression: Expr): String = expression match {
  case Number (a) => a.toString
  case Add (a, b) => s ”($ {print (a)} + $ {print (b)})”
  case Neg (a) => s ”- $ {print (a)}”
}

Mir ist klar, dass ich möglicherweise viele FP-Fans enttäuscht habe: Funktionale Programmierung ist schrecklich, wenn es darum geht, neue Formen hinzuzufügen. Wie bei OOP müssen wir beim Hinzufügen neuer Operationen jeden Teil unseres Codes ändern, um eine zusätzliche Möglichkeit zur Darstellung unseres Ausdrucks bereitzustellen.

Lassen Sie uns nun überprüfen, wie sich OOP in dieser Hinsicht verhält. Wir können damit beginnen, eine neue Klasse hinzuzufügen und die zuvor deklarierten Operationen zu implementieren:

case class Neg (a: Expr) erweitert Expr {
  override def eval = - a.eval
  override def print = s ”- $ {a.print}”
}

Und das ist alles. Wir haben ein neues Formular hinzugefügt, ohne einen vorhandenen Code zu berühren. In diesem Fall gewinnt OOP, also scheint es, dass wir ein Unentschieden haben.

Schlussfolgerungen

In diesem Blogbeitrag habe ich eines der wichtigsten Probleme bei der Softwareentwicklung vorgestellt: die Erweiterbarkeit. Wir alle wissen, wie wertvoll es ist, gute, erweiterbare Programme zu schreiben, und wie es sich anfühlt, wenn wir eine neue Funktion hinzufügen und nicht in die gesamte Codebasis eintauchen müssen. Dieses Beispiel für das Ausdrucksproblem ist einfach, aber es berührt beide Aspekte der Erweiterbarkeit und zeigt, wie die beiden wichtigsten Programmierparadigmen (FP und OOP) mit diesem Problem umgehen.

Wir haben gesehen, dass es kein Patentrezept für Erweiterbarkeitsprobleme in Bezug auf FP- und OOP-Entwicklungstechniken gibt. Hoffentlich erlaubt Scala die Verwendung beider Paradigmen, und wir können das Problem der Erweiterung unseres Codes lösen, indem wir bei der Implementierung von Features nur den richtigen Stil auswählen. Es ist jedoch sehr schwierig, die zukünftige Erweiterbarkeit vorherzusagen, wenn Sie mit der Entwicklung beginnen.

Scala selbst ist also keine Lösung für dieses Problem, aber dank des als Typklasse bekannten Musters können wir das Ausdrucksproblem vollständig lösen (indem wir neue Formulare und Operationen einführen, ohne den vorhandenen Code zu berühren). Ich werde es Ihnen im nächsten Blogeintrag vorstellen!

Wenn Sie jedoch wissen möchten, wie Typklassen in Scala implementiert werden können, finden Sie dies in meinem vorherigen Blogbeitrag

PS. Ich habe gerade den zweiten Teil der Geschichte veröffentlicht, in dem ich das Erweiterungsproblem mit dem Typklassenmuster anpacke.

https://medium.com/virtuslab/oop-vs-fp-the-pursuit-of-extensibility-part-2-22a37a33d1a0