Elixier: Wie sieht OOP in einer funktionalen Sprache aus?

    In letzter Zeit sind Artikel und Diskussionen zum Thema Abschied von der PLO und die Suche nach der Bedeutung, die Alan Kay ursprünglich in dieses Konzept gesteckt hat, häufiger geworden.

    Kayes paar Sprüche für diejenigen, die es versäumt haben
    Ich habe mir den Begriff „objektorientiert“ ausgedacht und kann Ihnen sagen, dass ich C ++ nicht im Sinn hatte

    OOP bedeutet für mich nur Nachrichtenübermittlung, lokale Aufbewahrung und Schutz sowie das Verstecken von staatlichen Prozessen und extrem spätes Binden aller Dinge.

    Es tut mir leid, dass ich den Begriff „Objekte“ für dieses Thema vor langer Zeit geprägt habe, weil es viele Leute dazu bringt, sich auf die geringere Idee zu konzentrieren. Die große Idee ist "Messaging".

    Der Schlüssel zur Entwicklung großartiger und erweiterbarer Systeme liegt vielmehr in der Gestaltung der Kommunikation der Module als in der Festlegung der internen Eigenschaften und Verhaltensweisen.

    Spätes Binden ermöglicht die Neuformulierung von Ideen, die spät in der Projektentwicklung erlernt wurden, in das Projekt mit exponentiell geringerem Aufwand als herkömmliche frühe Bindungssysteme (C, C ++, Java usw.).

    Ich bin nicht gegen Typen, aber ich kenne keine Typensysteme, die keine völligen Schmerzen bereiten, deshalb mag ich dynamisches Tippen immer noch.

    Im Zusammenhang mit diesen Diskussionen taucht häufig die Idee auf, dass Erlang / Elixir die Kriterien, die Kay für das Konzept der „Objektorientierung“ festgelegt hat, sehr gut erfüllt. Da diese Sprachen jedoch nicht allen bekannt sind, besteht ein Missverständnis darüber, wie funktionale Sprachen objektorientierter sein können als die gängigen Sprachen C ++, Java und C #.

    In diesem Artikel möchte ich anhand eines einfachen Beispiels mit exercism.io zeigen, wie OOP auf Elixir aussieht.

    Aufgabenbeschreibung
    Schreiben Sie ein kleines Programm, in dem die Namen der Schüler gespeichert werden, gruppiert nach der Klassennummer, in der sie studieren.

    Am Ende sollten Sie in der Lage sein:

    • Fügen Sie der Klasse den Schülernamen hinzu
    • Holen Sie sich eine Liste aller Schüler in der Klasse
    • Holen Sie sich eine sortierte Liste aller Schüler in allen Klassen. Die Klassen sollten in aufsteigender Reihenfolge (1, 2, 3 usw.) sortiert sein und die Namen der Schüler sollten alphabetisch sortiert sein.


    Beginnen wir mit den Tests, um zu sehen, wie der Code, der unsere Funktionen aufruft, aussehen wird. Schauen Sie sich die Tests an, die Exercism für Ruby vorbereitet hat , in denen OOP zu dem Schluss kam, dass selbst Operatoren die Methoden eines anderen sind.

    Und wir werden ähnliche Tests für die Elixir-Version dieses Programms schreiben:
    Code.load_file("school.exs")
    ExUnit.start
    defmodule SchoolTest do
      use ExUnit.Case, async: true
      import School, only: [add_student: 3, students_by_grade: 1, students_by_grade: 2]
      test "get students in a non existant grade" do
        school = School.new
        assert [] == school |> students_by_grade(5)
      end
      test "add student" do
        school = School.new
        school |> add_student("Aimee", 2)
        assert ["Aimee"] == school |> students_by_grade(2)
      end
      test "add students to different grades" do
        school = School.new
        school |> add_student("Aimee", 3)
        school |> add_student("Beemee", 7)
        assert ["Aimee"] == school |> students_by_grade(3)
        assert ["Beemee"] == school |> students_by_grade(7)
      end
      test "grade with multiple students" do
        school = School.new
        grade = 6
        students = ~w(Aimee Beemee Ceemee)
        students |> Enum.each(fn(student) -> school |> add_student(student, grade) end)
        assert students == school |> students_by_grade(grade)
      end
      test "grade with multiple students sorts correctly" do
        school = School.new
        grade = 6
        students = ~w(Beemee Aimee Ceemee)
        students |> Enum.each(fn(student) -> school |> add_student(student, grade) end)
        assert Enum.sort(students) == school |> students_by_grade(grade)
      end
      test "empty students by grade" do
        school = School.new
        assert [] == school |> students_by_grade
      end
      test "students_by_grade with one grade" do
        school = School.new
        grade = 6
        students = ~w(Beemee Aimee Ceemee)
        students |> Enum.each(fn(student) -> school |> add_student(student, grade) end)
        assert [[grade: 6, students: Enum.sort(students)]] == school |> students_by_grade
      end
      test "students_by_grade with different grades" do
        school = School.new
        everyone |> Enum.each(fn([grade: grade, students: students]) ->
          students |> Enum.each(fn(student) -> school |> add_student(student, grade) end)
        end)
        assert everyone_sorted == school |> students_by_grade
      end
      defp everyone do
        [
          [ grade: 3, students: ~w(Deemee Eeemee) ],
          [ grade: 1, students: ~w(Effmee Geemee) ],
          [ grade: 2, students: ~w(Aimee Beemee Ceemee) ]
        ]
      end
      defp everyone_sorted do
        [
          [ grade: 1, students: ~w(Effmee Geemee) ],
          [ grade: 2, students: ~w(Aimee Beemee Ceemee) ],
          [ grade: 3, students: ~w(Deemee Eeemee) ]
        ]
      end
    end
    

    Wenn Sie sich nicht mit den Feinheiten des Schreibens von Tests befassen, interessieren uns vor allem die daraus resultierenden "Methoden" für die Arbeit mit der "Klassen" -Schule:

        school = School.new
        school |> add_student("Aimee", 2) # => :ok
        school |> students_by_grade(2) # => ["Aimee"]
        school |> students_by_grade # => [[grade: 2, students: ["Aimee"]]]
    

    Natürlich gibt es weder eine Klasse noch Methoden, aber dazu später mehr. Achten Sie in der Zwischenzeit darauf, wie ähnlich es ist, mit einer Instanz einer Klasse im Rahmen vertrauter OOP-Implementierungen zu arbeiten. Nur anstelle eines Punkts oder Pfeils -> wird der Pipe-Operator |> verwendet.

    Obwohl ich sofort zugebe, dass zum Aufrufen einer Funktion der Pipe-Operator normalerweise nicht verwendet wird und die Funktionen selbst in Modulen und nicht in Klassen platziert sind. Ein idiomatischerer Eintrag für Elixir wäre daher:

        school = School.new
        School.add_student(school, "Aimee", 2) # => :ok
        School.students_by_grade(school, 2) # => ["Aimee"]
        School.students_by_grade(school) # => [[grade: 2, students: ["Aimee"]]]
    

    Das Wesen der Änderung der Syntax ändert sich jedoch nicht! Der Bezeichner des "Objekts" wird einfach als erstes Argument an alle Funktionen übergeben. Es gibt jedoch keine Objekte in funktionalen Sprachen. Mal sehen, was hier wirklich passiert ...

    Tatsache ist, dass alle Programme auf Erlang, die auf Elixir basieren, auf OTP basieren. De facto ist es Teil der Standard-Sprachbibliothek, die genau die Fehlertoleranz und Skalierbarkeit bietet, für die es berühmt ist Erlang OTP enthält ein sehr umfangreiches und mächtiges Arsenal an Modulen und Verhaltensweisen (dies ist eine Art abstrakter Klasse). Aber heute sprechen wir nur über ein, aber sehr häufig verwendetes Verhalten - GenServer . Sie können ein gewöhnliches Modul in eine Art Generator für Akteure verwandeln (einfache Prozesse der virtuellen Erlang-Maschine).

    Jeder Akteurprozess verfügt über einen eigenen isolierten Status, der geändert oder von ihm Informationen empfangen werden kann, indem ausschließlich Nachrichten an ihn gesendet werden. Wenn Nachrichten wettbewerbsfähig ankommen, werden sie in der Reihenfolge der Warteschlange verarbeitet, wodurch selbst die theoretische Möglichkeit, eine Race-Bedingung für einen im Prozess gespeicherten Status zu erhalten, entfällt. Somit verhält sich jeder Prozess einerseits wie ein Server - daher der Name GenServer und andererseits wie ein Objekt - wie von Kay beschrieben.

    Wie ein Objekt verfügt es über einen Status und bietet die Möglichkeit, mit diesem Status zu arbeiten, indem Nachrichten mit den Rückrufen handle_call (mit Antwortrückgabe) und handle_cast (ohne Antwort) verarbeitet werden. Diese letzte Verbindung, über die Alan ständig spricht. Und am häufigsten sind sogenannte Nachrichten für das Versenden von Nachrichten verantwortlich. API-Funktionen, die sich im selben Modul befinden, aber von anderen Prozessen aufgerufen werden.

    Die Prozesse sind insofern vollständig voneinander isoliert, als der Sturz eines Prozesses keinen anderen beeinflusst, es sei denn, Sie legen ausdrücklich fest, wie er sich auswirken soll (die sogenannte Neustart-Strategie).

    Allerdings genug Worte. Mal sehen, wie es im Code aussieht:

    defmodule School do
      use GenServer
      # API
      @doc """
      Start School process.
      """
      def new do
        {:ok, pid} = GenServer.start_link(__MODULE__, %{})
        pid
      end
      @doc """
      Add a student to a particular grade in school.
      """
      def add_student(pid, name, grade) do
        GenServer.cast(pid, {:add, name, grade})
      end
      @doc """
      Return the names of the students in a particular grade.
      """
      def students_by_grade(pid, grade) do
        GenServer.call(pid, {:students_by_grade, grade})
      end
      @doc """
      Return the names of the all students separated by grade.
      """
      def students_by_grade(pid) do
        GenServer.call(pid, :all_students)
      end
      # Callbacks
      def handle_cast({:add, name, grade}, state) do
        state = Map.update(state, grade, [name], &([name|&1]))
        {:noreply, state}
      end
      def handle_call({:students_by_grade, grade}, _from, state) do
        students = Map.get(state, grade, []) |> Enum.sort
        {:reply, students, state}
      end
      def handle_call(:all_students, _from, state) do
        all_students = state
          |> Map.keys
          |> Enum.map(fn(grade) ->
            [grade: grade, students: get_students_by_grade(state, grade)]
          end)
        {:reply, all_students, state}
      end
      # Private functions
      defp get_students_by_grade(state, grade) do
        Map.get(state, grade, []) |> Enum.sort
      end
    end

    In der Regel besteht ein Modul, das das Verhalten von GenServer implementiert, aus drei Teilen:

    • API - Funktionen für die Interaktion mit dem Prozess von außen, sie rufen die Funktionen des GenServer-Moduls auf, um Nachrichten zu senden, den Prozess zu starten / stoppen usw. Sie verbergen auch Implementierungsdetails im aufrufenden Code.
    • Callbacks - Funktionen, die das Verhalten von GenServer implementieren: Nachrichtenverarbeitung usw.
    • Private Funktionen - Hilfsfunktionen, die im Modul verwendet werden

    Wenn der Prozess startet, erhalten wir seine ID - PID, die dann als erstes Argument an die API - Funktionen übergeben werden kann. In der Regel werden Prozesse mit der Funktion start_link gestartet. Mit dieser Vereinbarung können Sie bequem ganze Prozessbäume beschreiben, die mit einem einzigen Befehl gestartet werden. Zur Vereinfachung der Analogien habe ich sie hier jedoch als neu bezeichnet.

    Wenn Sie einen systemweiten Prozess im System haben, für den eine Instanz ausreicht, können Sie ihm einen Namen geben. In diesem Fall können Sie darauf verzichten, die pid an die API-Funktionen zu übergeben Sie können Nachrichten mit Namen an den Prozess senden.

    Auf der obersten Abstraktionsebene besteht fast jede Elixir-Anwendung nur aus ähnlichen Prozessen, die Nachrichten miteinander austauschen.

    PSAuf diese Weise können Sie mit Elixir OOP dort einsetzen, wo es wirklich funktioniert - auf der obersten Ebene des Systemdesigns. Und erschweren Sie gleichzeitig nicht die unteren Ebenen des Systems mit weit hergeholten Abstraktionen und kontraproduktiven Thesen wie „Alles ist ein Objekt“.

    Jetzt auch beliebt: