Java ist konsequent eine der drei beliebtesten Sprachen der Welt. Seine Verbreitung in Bereichen wie der Entwicklung von Unternehmenssoftware, Android-Apps und groß angelegten Webanwendungen ist unübertroffen. Sein starkes Typsystem, umfangreiches Ökosystem und die Fähigkeit „write once, run anywhere“ machen es besonders attraktiv für den Aufbau robuster, skalierbarer Systeme. In diesem Artikel werden wir untersuchen, wie die objektorientierten Programmierfunktionen von Java es Entwicklern ermöglichen, diese Funktionen effektiv zu nutzen, um wartbare und skalierbare Anwendungen durch eine ordnungsgemäße Codeorganisation und Wiederverwendung aufzubauen.
Eine Notiz zur Organisation und Ausführung von Java-Code
Bevor wir mit dem Schreiben von Code beginnen, machen wir einige Vorbereitungen.
Wie bei seiner Syntax hat Java strenge Regeln zur Codeorganisation.
Zunächst muss jede öffentliche Klasse in ihrer eigenen Datei stehen, die genau wie die Klasse benannt ist, aber mit einer.java
Erweiterung. Wenn ich also eineLaptopKlasse schreiben möchte, muss der DateinameLaptop.java
sein—beachten Sie die Groß- und Kleinschreibung. Sie können nicht-öffentliche Klassen in derselben Datei haben, aber es ist am besten, sie zu trennen. Ich weiß, wir sind voraus—sprechen über die Organisation von Klassen, noch bevor wir sie schreiben—aber eine grobe Vorstellung davon zu haben, wo man Dinge platziert, ist eine gute Idee.
Alle Java-Projekte müssen eine Main.java
Datei mit der Main Klasse haben. Hier testen Sie Ihre Klassen, indem Sie Objekte von ihnen erstellen.
Um Java-Code auszuführen, verwenden wir IntelliJ IDEA, eine beliebte Java-IDE. Nach der Installation von IntelliJ:
- Erstellen Sie ein neues Java-Projekt (Datei > Neu > Projekt)
- Klicken Sie mit der rechten Maustaste auf den src-Ordner, um die
Main.java
Datei zu erstellen und fügen Sie den folgenden Inhalt ein:
public class Main { public static void main(String[] args) { // Erstellen und testen von Objekten hier } }
Immer wenn wir über Klassen sprechen, schreiben wir Code in anderen Dateien als der Main.java
Datei. Aber wenn es um das Erstellen und Testen von Objekten geht, wechseln wir zur Main.java
.
Um das Programm auszuführen, können Sie auf die grüne Wiedergabetaste neben der Hauptmethode klicken:
Die Ausgabe wird im Ausführungswerkzeugfenster unten angezeigt.
Wenn Sie ganz neu in Java sind, schauen Sie sich unseren Java-Einführungskursan, der die Grundlagen der Java-Datentypen und Steuerungsflüsse behandelt, bevor es weitergeht.
Ansonsten lassen Sie uns direkt eintauchen.
Java-Klassen und -Objekte
Also, was sind Klassen genau?
Klassen sind Programmierkonstrukte in Java zur Darstellung von realen Konzepten. Betrachten Sie zum Beispiel diese MenuItem Klasse (erstellen Sie eine Datei, um diese Klasse in Ihrem IDE zu schreiben):
public class MenuItem { public String name; public double price; }
Die Klasse gibt uns einen Plan oder eine Vorlage, um verschiedene Menüelemente in einem Restaurant darzustellen. Durch Ändern der zwei Attribute der Klasse, name
, und price
, können wir unzählige Menü Objekte wie einen Burger oder einen Salat erstellen.
Um in Java eine Klasse zu erstellen, beginnen Sie eine Zeile, die den Zugriffslevel der Klasse beschreibt (private, public oder protected), gefolgt vom Klassennamen. Unmittelbar nach den Klammern skizzieren Sie die Attribute Ihrer Klasse.
Aber wie erstellen wir Objekte, die zu dieser Klasse gehören? Java ermöglicht dies durch Konstruktor-Methoden:
public class MenuItem { public String name; public double price; // Konstruktor public MenuItem(String name, double price) { this.name = name; this.price = price; } }
Ein Konstruktor ist eine spezielle Methode, die aufgerufen wird, wenn wir ein neues Objekt aus einer Klasse erstellen. Er initialisiert die Attribute des Objekts mit den von uns bereitgestellten Werten. Im obigen Beispiel nimmt der Konstruktor einen Namen und einen Preisparameter entgegen und weist sie den Feldern des Objekts mithilfe des Schlüsselworts ‚this‘ zu, um auf eine zukünftige Objektinstanz zu verweisen.
Die Syntax für den Konstruktor unterscheidet sich von anderen Klassenmethoden, da sie keinen Rückgabetyp erfordert. Der Konstruktor muss auch den gleichen Namen wie die Klasse haben und die gleiche Anzahl von Attributen haben, die Sie nach der Klassendefinition deklariert haben. Oben erstellt der Konstruktor zwei Attribute, weil wir zwei nach der Klassendefinition deklariert haben: name
und price
.
Nachdem Sie Ihre Klasse und ihren Konstruktor geschrieben haben, können Sie Instanzen (Objekte) davon in Ihrer Hauptmethode erstellen:
public class Main { public static void main(String[] args) { // Objekte hier erstellen MenuItem burger = new MenuItem("Burger", 3.5); MenuItem salad = new MenuItem("Salad", 2.5); System.out.println(burger.name + ", " + burger.price); } }
Ausgabe:
Burger, 3.5
Oben erstellen wir zwei MenuItem
-Objekte in den Variablen burger
und salad
. Wie in Java erforderlich, muss der Typ der Variablen deklariert werden, der MenuItem
ist. Anschließend schreiben wir das new-Schlüsselwort gefolgt von der Aufruf des Konstruktor-Methode, um eine Instanz unserer Klasse zu erstellen.
Abgesehen vom Konstruktor können Sie reguläre Methoden erstellen, die Ihrer Klasse Verhalten verleihen. Zum Beispiel fügen wir unten eine Methode hinzu, um den Gesamtpreis nach Steuern zu berechnen:
public class MenuItem { public String name; public double price; // Konstruktor public MenuItem(String name, double price) { this.name = name; this.price = price; } // Methode zur Berechnung des Preises nach Steuern public double getPriceAfterTax() { double taxRate = 0.08; // 8% Steuerquote return price + (price * taxRate); } }
Jetzt können wir den Gesamtpreis einschließlich Steuern berechnen:
public class Main { public static void main(String[] args) { MenuItem burger = new MenuItem("Burger", 3.5); System.out.println("Price after tax: $" + burger.getPriceAfterTax()); } }
Ausgabe:
Price after tax: $3.78
Kapselung
Der Zweck von Klassen besteht darin, einen Plan zur Erstellung von Objekten bereitzustellen. Diese Objekte werden dann von anderen Skripten oder Programmen verwendet. Zum Beispiel können unsere MenuItem
-Objekte von einer Benutzeroberfläche verwendet werden, die ihren Namen, Preis und Bild auf einem Bildschirm anzeigt.
Aus diesem Grund müssen wir unsere Klassen so gestalten, dass ihre Instanzen nur so verwendet werden können, wie wir es beabsichtigt haben. Im Moment ist unsere MenuItem
Klasse sehr grundlegend und fehleranfällig. Eine Person könnte Objekte mit lächerlichen Attributen erstellen, wie einen negativ bepreisten Apfelkuchen oder ein Million-Dollar-Sandwich:
// In Main.java MenuItem applePie = new MenuItem("Apple Pie", -5.99); // Negativer Preis! MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Unvernünftig teuer System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.price);
Die erste Aufgabe nach dem Schreiben einer Klasse besteht darin, ihre Attribute zu schützen, indem wir einschränken, wie sie erstellt und zugegriffen werden. Zunächst möchten wir nur positive Werte für Preis zulassen und einen Höchstwert festlegen, um zu vermeiden, dass versehentlich absurd teure Artikel angezeigt werden.
Java ermöglicht uns dies durch die Verwendung von Setter-Methoden:
public class MenuItem { private String name; private double price; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { this.name = name; setPrice(price); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }
Schauen wir uns an, was neu im obigen Codeblock ist:
1. Wir haben die Attribute privat gemacht, indem wir das Schlüsselwort private
hinzugefügt haben. Das bedeutet, dass sie nur innerhalb der MenuItem-Klasse zugegriffen werden können. Die Kapselung beginnt mit diesem entscheidenden Schritt.
2. Wir haben eine neue Konstante MAX_PRICE
hinzugefügt, die lautet:
- privat (nur innerhalb der Klasse zugänglich)
- statisch (gemeinsam für alle Instanzen)
- final (kann nach der Initialisierung nicht geändert werden)
- auf $100,0 als angemessenen Maximalpreis gesetzt
3. Wir haben eine setPrice()
Methode hinzugefügt, die:
- einen Preisparameter entgegennimmt
- Überprüft, ob der Preis nicht negativ ist
- Überprüft, ob der Preis MAX_PRICE nicht überschreitet
- Wirft eine IllegalArgumentException mit aussagekräftigen Nachrichten, wenn die Validierung fehlschlägt
- Setzt den Preis nur, wenn alle Validierungen erfolgreich sind
4. Wir haben den Konstruktor geändert, um setPrice()
anstelle einer direkten Zuweisung des Preises zu verwenden. Dadurch wird sichergestellt, dass die Preisvalidierung während der Objekterstellung erfolgt.
Wir haben gerade einen der grundlegenden Pfeiler guten objektorientierten Designs umgesetzt — Kapselung. Dieses Paradigma erzwingt Datenverbergen und kontrollierten Zugriff auf Objektattribute, wodurch sichergestellt wird, dass interne Implementierungsdetails vor externen Eingriffen geschützt sind und nur über gut definierte Schnittstellen geändert werden können.
Lasst uns den Punkt verdeutlichen, indem wir die Kapselung auf das Name-Attribut anwenden. Stellen Sie sich vor, wir haben ein Café, das nur Lattes, Cappuccinos, Espressos, Americanos und Mochas serviert.
Unsere Menüelementnamen können also nur eines der Elemente in dieser Liste sein. So können wir dies im Code durchsetzen:
// Rest der Klasse hier ... private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private String name; public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); }
Der obige Code implementiert die Namensvalidierung für Menüelemente in einem Café. Lassen Sie uns das aufschlüsseln:
1. Zuerst definiert es ein privates statisches finales Array VALID_NAMES
, das die einzigen erlaubten Getränkenamen enthält: Latte, Cappuccino, Espresso, Americano und Mocha. Dieses Array ist:
- privat: nur innerhalb der Klasse zugänglich
- statisch: wird über alle Instanzen hinweg geteilt
- final: kann nach der Initialisierung nicht geändert werden
2. Es deklariert ein privates String-Namenfeld, um den Getränkenamen zu speichern
3. Die Methode setName()
implementiert die Validierungslogik:
- Nimmt einen String-Namenparameter
- Wandelt ihn in Kleinbuchstaben um, um den Vergleich ohne Berücksichtigung der Groß- und Kleinschreibung durchzuführen
- Schleift durch das Array
VALID_NAMES
- Wenn ein Treffer gefunden wird, wird der Name festgelegt und zurückgegeben
- Wenn kein Treffer gefunden wird, wird eine IllegalArgumentException mit einer beschreibenden Nachricht ausgelöst, die alle gültigen Optionen auflistet
Hier ist die gesamte Klasse bis jetzt:
public class MenuItem { private String name; private double price; private static final String[] VALID_NAMES = {"latte", "cappuccino", "espresso", "americano", "mocha"}; private static final double MAX_PRICE = 100.0; public MenuItem(String name, double price) { setName(name); setPrice(price); } public void setName(String name) { String lowercaseName = name.toLowerCase(); for (String validName : VALID_NAMES) { if (validName.equals(lowercaseName)) { this.name = name; return; } } throw new IllegalArgumentException("Invalid drink name. Must be one of: " + String.join(", ", VALID_NAMES)); } public void setPrice(double price) { if (price < 0) { throw new IllegalArgumentException("Price cannot be negative"); } if (price > MAX_PRICE) { throw new IllegalArgumentException("Price cannot exceed $" + MAX_PRICE); } this.price = price; } }
Nachdem wir den Weg, wie Attribute erstellt werden, geschützt haben, möchten wir auch schützen, wie sie zugegriffen werden. Dies geschieht durch die Verwendung von Getter-Methoden:
public class MenuItem { // Rest des Codes hier ... public String getName() { return name; } public double getPrice() { return price; } }
Getter-Methoden bieten kontrollierten Zugriff auf private Attribute einer Klasse. Sie lösen das Problem des direkten Zugriffs auf Attribute, was zu unerwünschten Änderungen führen und die Kapselung brechen kann.
Zum Beispiel könnten wir ohne Getter direkt auf Attribute zugreifen:
MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // Direkter Zugriff auf Attribut item.name = "INVALID"; // Kann direkt geändert werden, um die Validierung zu umgehen
Mit Gettern erzwingen wir korrekten Zugriff:
MenuItem item = new MenuItem("Latte", 4.99); String name = item.getName(); // Kontrollierter Zugriff über Getter // item.name = "UNGÜLTIG"; // Nicht erlaubt - setName() muss verwendet werden, was validiert
Diese Kapselung:
- Schützt die Datenintegrität, indem ungültige Änderungen verhindert werden
- Ermöglicht es uns, die interne Implementierung zu ändern, ohne den Code zu beeinflussen, der die Klasse verwendet
- Bietet einen einzigen Zugriffspunkt, der bei Bedarf zusätzliche Logik enthalten kann
- Macht den Code wartbarer und weniger fehleranfällig
Vererbung
Unsere Klasse sieht zwar gut aus, aber es gibt ziemlich viele Probleme damit. Zum Beispiel ist die Klasse für ein großes Restaurant, das viele Arten von Gerichten und Getränken serviert, nicht flexibel genug.
Wenn wir verschiedene Arten von Speisen hinzufügen möchten, stoßen wir auf mehrere Herausforderungen. Einige Gerichte können zum Mitnehmen zubereitet werden, während andere sofort verzehrt werden müssen. Die Preise und Rabatte von Speisen im Menü können variieren. Gerichte benötigen möglicherweise eine Temperaturverfolgung oder spezielle Lagerung. Getränke können heiß oder kalt mit anpassbaren Zutaten sein. Artikel benötigen möglicherweise Informationen zu Allergenen und Portionsgrößen. Das aktuelle System kommt mit diesen unterschiedlichen Anforderungen nicht zurecht.
Vererbung bietet eine elegante Lösung für all diese Probleme. Sie ermöglicht es uns, spezialisierte Versionen von Menüpunkten zu erstellen, indem wir eine Basisklasse MenuItem
mit gemeinsamen Attributen definieren und dann Kindklassen erstellen, die diese Grundlagen erben und gleichzeitig einzigartige Funktionen hinzufügen.
Zum Beispiel könnten wir eine Klasse Drink
für Getränke mit Temperaturoptionen, eine Klasse Food
für Artikel, die sofort konsumiert werden müssen, und eine Klasse Dessert
für Artikel mit besonderen Lagerungsanforderungen haben, die alle die Kernfunktionalität von Menüpunkten erben.
Erweiterung von Klassen
Lassen Sie uns diese Ideen umsetzen, beginnend mit Drink
:
public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { this.name = name; this.price = price; this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }
Um eine Kindklasse zu definieren, die von einem Elternelement erbt, verwenden wir das extends
Schlüsselwort nach dem Namen der Kindklasse gefolgt von der Elternelementklasse. Nach der Klassendefinition definieren wir alle neuen Attribute, die diese Kindklasse hat, und implementieren ihren Konstruktor.
Aber beachten Sie, wie wir die Initialisierung von name
und price
zusammen mit isCold
wiederholen müssen. Das ist nicht ideal, da die Elternklasse möglicherweise Hunderte von Attributen hat. Außerdem wird der obige Code einen Fehler beim Kompilieren werfen, da dies nicht der richtige Weg ist, Attribute der Elternklasse zu initialisieren. Der richtige Weg wäre die Verwendung des super
Schlüsselworts:
public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } public boolean getIsCold() { return isCold; } public void setIsCold(boolean isCold) { this.isCold = isCold; } }
Das super
Schlüsselwort wird verwendet, um den Konstruktor der Elternklasse aufzurufen. In diesem Fall ruft super(name, price)
den Konstruktor von MenuItem
auf, um diese Attribute zu initialisieren und Code-Duplizierung zu vermeiden. Wir müssen nur das neue isCold
Attribut spezifisch für die Drink
Klasse initialisieren.
Das Schlüsselwort ist sehr flexibel, da Sie es verwenden können, um auf die Elternklasse in jedem Teil der Kindklasse zu verweisen. Zum Beispiel, um eine Elternmethode aufzurufen, verwenden Sie super.methodName()
, während super.attributeName
für Attribute ist.
Methodenüberschreibung
Angenommen, wir möchten unseren Klassen eine neue Methode hinzufügen, um den Gesamtpreis nach Steuern zu berechnen. Da verschiedene Menüelemente unterschiedliche Steuersätze haben können (zum Beispiel zubereitete Speisen im Vergleich zu abgefüllten Getränken), können wir die Methodenüberschreibung verwenden, um spezifische Steuerberechnungen in jeder Kindklasse zu implementieren, während wir einen gemeinsamen Methodennamen in der Elternklasse beibehalten.
So sieht das aus:
public class MenuItem { // Rest der MenuItem-Klasse public double calculateTotalPrice() { // Standardsteuersatz von 10% return price * 1.10; } } public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { // Lebensmittel haben 15% Steuern return super.getPrice() * 1.15; } } public class Drink extends MenuItem { private boolean isCold; public Drink(String name, double price, boolean isCold) { super(name, price); this.isCold = isCold; } @Override public double calculateTotalPrice() { // Getränke haben 8% Steuern return super.getPrice() * 1.08; } }
In diesem Beispiel ermöglicht die Methodenüberschreibung jeder Unterklasse, ihre eigene Implementierung von calculateTotalPrice()
:
Die Basisklasse MenuItem
definiert eine Standardsteuerberechnung von 10%.
Wenn Food
und Drink
Klassen erweitern MenuItem
, überschreiben sie diese Methode, um ihre eigenen Steuersätze zu implementieren:
- Lebensmittel haben einen höheren Steuersatz von 15%
- Getränke haben einen niedrigeren Steuersatz von 8%
Die @Override
Annotation wird verwendet, um explizit anzugeben, dass diese Methoden die Methode der Elternklasse überschreiben. Dadurch werden Fehler erkannt, wenn die Methodensignatur nicht mit der der Elternklasse übereinstimmt.
Jede Unterklasse kann weiterhin auf den Preis der Elternklasse zugreifen, indem sie super.getPrice()
verwendet, was zeigt, wie überschriebene Methoden die Funktionalität der Elternklasse nutzen können, während sie ihr Verhalten hinzufügen.
Kurz gesagt, die Methodenüberschreibung ist ein wesentlicher Bestandteil der Vererbung, der es Unterklassen ermöglicht, ihre Implementierung von in der Elternklasse definierten Methoden bereitzustellen, wodurch spezifischeres Verhalten ermöglicht wird, während die gleiche Methodensignatur beibehalten wird.
Abstrakte Klassen
Unsere MenuItem
Klassenhierarchie funktioniert, aber es gibt ein Problem: Sollte jeder in der Lage sein, ein einfaches MenuItem
Objekt zu erstellen? Schließlich ist in unserem Restaurant jedes Menüelement entweder ein Gericht oder ein Getränk – es gibt kein „generisches Menüelement“.
Wir können dies verhindern, indem wirMenuItem
zu einer abstrakten Klasse machen. Eine abstrakte Klasse bietet nur einen Grundriss – sie kann nur als Elternklasse für Vererbung verwendet werden, nicht direkt instanziiert werden.
Um MenuItem
abstrakt zu machen, fügen wir das abstract
Schlüsselwort nach seinem Zugriffsmodifikator hinzu:
public abstract class MenuItem { private String name; private double price; public MenuItem(String name, double price) { setName(name); setPrice(price); } // Vorhandene Getter/Setter bleiben gleich // Mache diese Methode abstrakt - jede Unterklasse MUSS sie implementieren public abstract double calculateTotalPrice(); }
Abstrakte Klassen können auch abstrakte Methoden haben wie calculateTotalPrice()
oben. Diese abstrakten Methoden dienen als Verträge, die Unterklassen zwingen, ihre Implementierungen bereitzustellen. Mit anderen Worten muss jede abstrakte Methode in einer abstrakten Klasse von Kindklassen implementiert werden.
Also, lassen Sie uns Essen
und Getränk
mit diesen Änderungen umschreiben:
public class Food extends MenuItem { private boolean isVegetarian; public Food(String name, double price, boolean isVegetarian) { super(name, price); this.isVegetarian = isVegetarian; } @Override public double calculateTotalPrice() { return getPrice() * 1.15; // 15% Steuer } } public class Drink extends MenuItem { private boolean hasCaffeine; public Drink(String name, double price, boolean hasCaffeine) { super(name, price); this.hasCaffeine = hasCaffeine; } @Override public double calculateTotalPrice() { return getPrice() * 1.10; // 10% Steuer } }
Durch die Implementierung dieses Menüsystems haben wir gesehen, wie Abstraktion und Vererbung zusammenarbeiten, um flexiblen, wartbaren Code zu erstellen, der sich leicht an unterschiedliche Geschäftsanforderungen anpassen lässt.
Abschluss
Heute haben wir einen Einblick in die Fähigkeiten von Java als objektorientierte Programmiersprache gewonnen. Wir haben die Grundlagen von Klassen, Objekten und einigen wichtigen Säulen der OOP behandelt: Kapselung, Vererbung und Abstraktion anhand eines Restaurantmenüs.
Um dieses System produktionsbereit zu machen, gibt es noch viele Dinge zu lernen, wie Schnittstellen (Teil der Abstraktion), Polymorphismus und OOP-Entwurfsmuster. Um mehr über diese Konzepte zu erfahren, verweisen wir auf unseren Einführung in OOP in Java Kurs.
Wenn Sie Ihr Wissen über Java testen möchten, versuchen Sie, einige der Fragen in unserem Artikel zu Java-Interviewfragen zu beantworten.