Thema dieser Seite

Auf dieser Seite beschreibe ich meine Erfahrungen mit dem objektorientierten Programmieren in JavaScript mit classes, welche mit ECMAScript 2015 bzw. ES6 eingeführt worden ist. Zu diesem Thema habe ich mir eine kleine Link-Sammlung angelegt mit hilfreichen Tutorials:

Allgemeine Tipps zum Programmieren mit JavaScript findet man auf dieser Seite. Um objektorientiert zu programmieren, ohne die class-Syntax zu verwenden, habe ich die Seite Objektorientiertes Programmieren in JavaScript mit functions erstellt.

Beispiel-Anwendung

Die folgende Beispiel-Anwendung ist in objektorientiertem JavaScript mit class-Notation geschrieben. Der Code, der in den folgenden Abschnitten besprochen wird, stammt aus dieser Anwendung.

Die Anwendung selbst zeichnet bunte Objekte, wenn man auf den schwarzen Bereich klickt.

Dein Browser kann dieses Element leider nicht anzeigen!

Diese Anwendung verwendet folgende JavaScript-Dateien:

Klassendefinition

Eine Klasse in JavaScript wird hier wie bei anderen Programmiersprachen mit dem Keyword class als solche gekennzeichnet. Im folgenden Beispiel wird die Klasse DrawingObject mit zwei privaten Member-Variablen und drei öffentlichen Methoden definiert. Die Klasse hat einen Konstruktor mit zwei Parametern, die dann in den Member-Variablen gespeichert werden.

      class DrawingObject {

        #xCenter;
        #yCenter;

        constructor(xCenter, yCenter) {
          this.#xCenter =  parseInt(xCenter);
          this.#yCenter = parseInt(yCenter);
        }

        getXCenter() {
          return this.#xCenter;
        }

        getYCenter() {
          return this.#yCenter;
        }


        //dummy draw method
        draw(canvasDrawer) {
          console.log("The draw method has not been overwritten");
        }
      }
    

Will man nun die Klasse und deren öffentlich sichtbaren Methoden verwenden, so kann man ein Objekt der Klasse erstellen und darüber dann zugreifen. Im folgenden ein Beispiel:

      let drawingObject = new DrawingObject(2,3);
      alert(drawingObject.getXCenter() + ", " + drawingObject.getXCenter());
      drawingObject.draw();
    

Hier passieren folgende Dinge:

Meine Empfehlungen für Klassen:

Durch die Verwendung von Klassen ergeben sich folgende Vorteile:

Private Variablen

Variablen, die nur in der Klasse sichtbar sein sollen, müssen einen Namen erhalten, der mit # beginnt. Diese Variablen können zur Übersichtlichkeit gleich nach der Klassendefinition deklariert werden. Ansonsten werden sie deklariert, wenn im Konstruktor oder in einer Methode die Variable das erste Mal mit this.#<Variable> erwähnt wird.

Leider ist es in Klassen nicht möglich Konstanten mit const zu definieren. Wie ich das lösen würde, habe ich im Abschnitt Private Konstanten beschrieben.

Werden die privaten Member-Variablen im Konstruktor oder in Funktionen dann verwendet, so muss man sie immer mit this.#<Variable> anführen. this kennzeichnet dabei, dass es sich um Variablen der Klasse handelt.

Das folgende Beispiel zeigt, wie man private Member-Variablen deklariert, wie ihnen im Konstruktor Werte zugewiesen werden und wie sie später in einer privaten Methode verwendet werden.

      class SampleApplication {

        #canvasDrawer;
        #drawingObjects;
        #currentDrawingObjectId;

        constructor() {
          this.#canvasDrawer = new CanvasDrawer("SampleApplicationDiv", "SampleApplicationCanvas", 400);
          this.#drawingObjects = [];
          this.#currentDrawingObjectId = 0;
        }
        
        ...
        #addDrawingObject(drawingObject) {
          this.#currentDrawingObjectId++;
          if (this.#drawingObjects.length < this.#MAX_DRAWING_OBJECTS) {
            this.#drawingObjects.push(drawingObject);
          }
          else {
            this.#currentDrawingObjectId = this.#currentDrawingObjectId % this.#MAX_DRAWING_OBJECTS;
            this.#drawingObjects[this.#currentDrawingObjectId] = drawingObject;
          }
        }
        
        ...
      }
      
    

Werden die privaten Variablen außerhalb der Klasse aufgerufen, so kommt es zu einem Syntax-Fehler und die JavaScript-Logik würde überhaupt nicht ausgeführt werden.

      let anotherSampleApplication = new SampleApplication();
      alert(anotherSampleApplication.#currentDrawingObjectId); // --> SyntaxError: reference to undeclared private field or method #currentDrawingObjectId
      alert(anotherSampleApplication.#MAX_DRAWING_OBJECTS);
    

Das hat den Vorteil, dass Variablen, die nur für die interne Logik in Klassen benötigt werden, außerhalb der Klasse nicht verwendbar sind.

Private Konstanten

Wie im vorigen Abschnitt bereits angesprochen, kann man in den Klassen nicht einfach mit const Konstanten definieren. Zu diesem Thema habe ich dann diese Diskussion gefunden und für mich folgende Lösung für die Definition von privaten Konstanten gefunden.

Um eine Konstante zu definieren, schreibt man eine get Methode, die den Namen der privaten Konstante hat und keine Parameter nimmt. Sie gibt lediglich den Wert der Konstanten zurück. Es handelt sich daher um eine Spezialform einer privaten Member Methode, was im Abschnitt Private Functions ("private methods") ausführlicher beschrieben wird.

Im folgenden Code sieht man ein Beispiel:

      get #MAX_DRAWING_OBJECTS() {
        return 20;
      }
    

Diese Methode kann man dann wie eine private Member-Variable verwenden.

    #addDrawingObject(drawingObject) {
    
      this.#currentDrawingObjectId++;
      
      if (this.#drawingObjects.length < this.#MAX_DRAWING_OBJECTS) {
        this.#drawingObjects.push(drawingObject);
      }
      else {
        this.#currentDrawingObjectId = this.#currentDrawingObjectId % this.#MAX_DRAWING_OBJECTS;
        this.#drawingObjects[this.#currentDrawingObjectId] = drawingObject;
      }
    }
    

Würde man this.#MAX_DRAWING_OBJECT nachträglich einen Wert zuweisen, so würde man einen TypeError erhalten und die Ausführung der Logik wird an diesem Punkt abgebrochen.

      this.#MAX_DRAWING_OBJECTS = 3; //TypeError: setting getter-only property #MAX_DRAWING_OBJECTS
    

Öffentlich sichtbare Variablen

Öffentlich sichtbare Member-Variablen deklariert man genau so wie die privaten Member-Variablen, allerdings muss man das führende # weglassen. Das heißt man kann unter der Klassendefinition einfach die Variablen anführen und im Konstruktor bzw. in den Methoden der Klassen mti this.<Variable> darauf verweisen.

In folgendem Code handelt es sich um das Beispiel aus dem Abschnit Private Variablen. Ich habe lediglich die #-Zeichen bei den Member-Variablen entfernt und dadurch wären die Variablen auch außerhalb der Klasse sichtbar und könnten dort verwendet und verändert werden.

      class SampleApplication {

        canvasDrawer;
        drawingObjects;
        currentDrawingObjectId;

        constructor() {
          this.canvasDrawer = new CanvasDrawer("SampleApplicationDiv", "SampleApplicationCanvas", 400);
          this.drawingObjects = [];
          this.currentDrawingObjectId = 0;
        }
        
        ...
        #addDrawingObject(drawingObject) {
          this.currentDrawingObjectId++;
          if (this.drawingObjects.length < this.#MAX_DRAWING_OBJECTS) {
            this.drawingObjects.push(drawingObject);
          }
          else {
            this.currentDrawingObjectId = this.currentDrawingObjectId % this.#MAX_DRAWING_OBJECTS;
            this.drawingObjects[this.currentDrawingObjectId] = drawingObject;
          }
        }
        
        ...
      }
    

Allerdings habe ich mir angewöhnt solche Variablen nur mit get-Methoden auszugeben bzw. nur mit set-Methoden zu verändern. Somit habe ich die Kontrolle, ob die Variable von außen gelesen bzw. verändert werden kann. Und wenn dies der Fall ist, kann ich noch Zusicherungen machen (z.B. auf undefined abprüfen), bevor mit dieser Variable gearbeitet wird.

Für mein Beispiel, das ich im Abschnitt Vererbung beschreibe, muss ich Member-Variablen in der Vater-Klasse definieren, die in den abgeleiteten Klassen lesbar sind. Dafür habe ich private Member-Variablen erstellt und jede Member-Variable hat eine öffentliche get-Methode erhalten. Dies ist hier zu sehen:

      class DrawingObject {

        #xCenter;
        #yCenter;

        constructor(xCenter, yCenter) {
          this.#xCenter =  parseInt(xCenter);
          this.#yCenter = parseInt(yCenter);
        }

        getXCenter() {
          return this.#xCenter;
        }

        getYCenter() {
          return this.#yCenter;
        }
        
        ...
      }
    

Somit ist sichergestellt, dass die Variablen zwar sichtbar sind, aber nicht außerhalb der Klasse verändert werden können.

Öffentlich sichtbare Functions ("public methods")

Innerhalb der Klasse können öffentlich sichtbare Functions definiert werden. (Im Vergleich zu Java wären dies die Methoden, die mit dem Kenner public gekennzeichnet sind.)

Im Gegensatz zur normalen Deklaration der Functions mit

      function <Function-Name>(<Parameter1>, <Parameter2>, ...) {}
    

kann man dass Kennwort function einfach weglassen. Dabei empfiehlt es sich die Function-Namen mit Kleinbuchstaben beginnen zu lassen.

Im vorigen Abschnitt haben wir schon die einfachen get-Methoden für die Ausgabe von privaten Variablen gesehen. Hier nun ein weiteres Beispiel für öffentliche Methoden:

      class CanvasDrawer {

        #height;
        #width;
        #context;

        ...

        ////////////////////////////////
        // PUBLIC METHODS             //
        ////////////////////////////////
        clearCanvas(color = 'rgb(0, 0, 0)') {
          this.setColor(color);
          this.#context.fillRect(0, 0, this.#width, this.#height);
        }

        setColor(color) {
          this.#context.fillStyle = color;
        }
        
        ...
      }

    

Mit clearCanvas und mit setColor werden zwei öffentliche Methoden definiert. Im Code von clearCanvas sieht man sogar, dass die andere öffentliche Methode der Klasse aufgerufen wird. Geschieht so ein Methoden-Aufruf innerhalb der Klasse, so muss man dies mit this.<Function-Name>() tun, wobei der Kenner this wieder auf das Objekt hinweist, dem diese Funktion gehört.

Will man eine öffentliche Methode außerhalb der Klasse aufrufen, so muss man zunächst ein Objekt der Klasse erstellen und dann die Methode des Objekts ausführen. Hier ist zunächst einmal ein Code-Beispiel:

      class SampleApplication {

        #canvasDrawer;
        ...

        constructor() {
          this.#canvasDrawer = new CanvasDrawer("SampleApplicationDiv", "SampleApplicationCanvas", 400);
          ...
        }
        
        ...
        
        #drawBackground() {
          this.#canvasDrawer.clearCanvas();
          this.#canvasDrawer.setColor("rgb(200, 200, 200)");
          this.#canvasDrawer.writeCenteredText("Klick auf mich!");
        }
        
        ...
      }

    

In diesem Beispiel befinden wir uns in der Klasse SampleApplication, welche die Funktionalität der Klasse CanvasDrawer nützt. Dabei wird zunächst eine private Member-Variable namens #canvasDrawer deklariert. Im Konstrukter wird dieser Variable dann eine Instanz der Klasse CanvasDrawer zugewiesen. In der Methode #drawBackground() werden dann mehrere öffentliche Methoden der Variable #canvasDrawer aufgerufen.

Private Functions ("private methods")

Genauso wie bei öffentlichen und privaten Member-Variablen kann man mit einem führenden # bei Funktionsnamen entscheiden, ob es sich um öffentliche oder private Methoden der Klassen handelt.

Ein führendes # kennzeichnet also eine private Methode, die außerhalb der Klasse nicht sichtbar ist. Innerhalb der Klasse kann man die Funktion aber wieder mit dem vorangestellten this. aufrufen.

      class SampleApplication {

        #canvasDrawer;
        #drawingObjects;
        #currentDrawingObjectId;

        constructor() {
          this.#canvasDrawer = new CanvasDrawer("SampleApplicationDiv", "SampleApplicationCanvas", 400);
          this.#drawingObjects = [];
          this.#currentDrawingObjectId = 0;
        }


        ////////////////////////////////////
        // PUBLIC METHODS                 //
        ////////////////////////////////////
        firstDraw() {
          this.#canvasDrawer.registerOnClick(this.addDrawingObject);
          this.#draw();
        }

        ...
        ////////////////////////////////////
        // PRIVATE METHODS                //
        ////////////////////////////////////
        #draw() {
          this.#drawBackground();
          this.#drawObjects();
        }

        #drawBackground() {
          this.#canvasDrawer.clearCanvas();
          this.#canvasDrawer.setColor("rgb(200, 200, 200)");
          this.#canvasDrawer.writeCenteredText("Klick auf mich!");
        }

        #drawObjects() {
          for(let i = 0; i < this.#drawingObjects.length; i++) {
            this.#drawingObjects[i].draw(this.#canvasDrawer);
          }
        }
      }
    
    

In diesem Beispiel sieht man wie zunächst eine öffentliche Methode namens firstDraw definiert wird. Diese ruft dann die private Methode #draw auf, welche wiederum zwei weitere private Methoden aufruft.

Wird die private Methode von außen aufgerufen, so kommt ein Fehler und die JavaScript-Logik wird nicht ausgeführt.

      sampleApplication =  new SampleApplication();
      sampleApplication.firstDraw();
      sampleApplication.#drawBackground(); //Uncaught SyntaxError: reference to undeclared private field or method #drawBackground
    

Die Funktion firstDraw kann ausgeführt werden, weil es eine öffentliche Funktion ist. Der Aufruf der Funktion #drawBackground stellt allerdings einen Syntax-Fehler dar!

Private Funktionen haben für mich folgende Vorteile:

Vererbung

In dieser Beispielanwendung habe ich mehrere Zeichenobjekte realisiert. Das Grundkonzept für ein Zeichenobjekt ist in der Klasse DrawingObject festgelegt.

      class DrawingObject {

        #xCenter;
        #yCenter;

        constructor(xCenter, yCenter) {
          this.#xCenter =  parseInt(xCenter);
          this.#yCenter = parseInt(yCenter);
        }

        getXCenter() {
          return this.#xCenter;
        }

        getYCenter() {
          return this.#yCenter;
        }


        //dummy draw method
        draw(canvasDrawer) {
          console.log("The draw method has not been overwritten");
        }
      }
    

Diese Klasse wird initialisiert mit X- und Y-Koordinaten, die das Zentrum des Zeichenobjekts repräsentieren. Sie werden in privaten Member-Variablen gespeichert. Für diese gibt es öffentliche get-Methoden, damit deren Werte in den vererbenden Klassen, den eigentlichen Zeichenobjekten, gelesen werden können.

Zusätzlich gibt es dann noch eine Pseudo-Implementierung der Methode draw, welche das Zeichnen des Objekts realisieren soll. In dieser Klasse wird nur eine log-Meldung in die Konsole geschrieben, dass diese Methode überschrieben werden müsste.

Die Klasse Square ist nun eine Klasse, welche von DrawingObject vererbt.

      class Square extends DrawingObject {

        draw(canvasDrawer) {
          //use members of parent
          canvasDrawer.drawRectangle(this.getXCenter()-50, this.getYCenter()-50, 100, 100, "rgb(255,0,0)");
        }
      }
    

Die Klasse Square erweitert mit dem Kennwort extends die Klasse DrawingObject. Somit erhält diese neue Klasse automatisch den selben Konstruktor und die selben öffentlichen Methoden wie DrawingObject.

Um jetzt aber eine sinnvolle draw Methode zu schreiben, wird diese in der Klasse Square einfach neu geschrieben und damit die Funktionalität, die von DrawingObject geerbt wurde, überschrieben. Leider kann man in dieser Methode nicht mehr auf die privaten Member-Variablen von DrawingObject zugreifen, weshalb die öffentlichen get-Methoden zum Abfragen der Koordinaten zum Einsatz kommen.

In der Klasse Triangle wurde nun vergessen, die Methode draw zu überschreiben:

      class Triangle extends DrawingObject {

        //the draw method is not implemented!!!
      }
    

Da sie aber auch von DrawingObject vererbt, besitzt diese Klasse ebenfalls den selben Konstruktor und die selben öffentlichen Methoden.

Somit kann man fehlerlos folgenden Code ausführen.

      let drawingObject = new Square(0, 0);
      drawingObject.draw(canvasDrawer);

      drawingObject = new Triangle(0, 0);
      drawingObject.draw(canvasDrawer);
    

Allerdings wird beim ersten Aufruf von draw ein Quadrat gezeichnet. Beim zweiten Aufruf von draw wird eine Meldung in die Konsole geschrieben, dass die Methode implementiert werden muss.

Würde man nun in Triangle die Methode draw richtig implementieren, so könnte man Triangle Objekte genauso wie Square Objekte verwenden und je nachdem, um welches Objekt es sich handelt würde eine andere Figur gezeichnet werden.

Würde man noch komplexere Dinge in Square und Triangle umsetzen, so könnte man auch Dinge, die für beide gleich sind, in die Vater-Klasse DrawingObject auslagern.

Mein Fazit

Ich finde das Konzept der class-Syntax in JavaScript viel besser als die Syntax mit function-Syntax, welche ich hier beschrieben habe.

Leider zögerten manche Browser wie Firefox lange damit, private Member-Variablen und Methoden zu unterstützen. In Firefox wurde dies erst mit Version 90 im Juli 2021 unterstützt. Davor kam es zu Fehlern, wenn man private Member-Variablen bzw. private Methoden mit dem führenden # definiert hat.