OOP in Java: Klassen, Objecten, Encapsulatie, Erfenis en Abstractie

Java is consistent een van de drie meest populaire talen ter wereld. Zijn adoptie in gebieden zoals enterprise softwareontwikkeling, Android mobiele apps en grootschalige webapplicaties is ongeëvenaard. Zijn sterke typesysteem, uitgebreide ecosysteem en de mogelijkheid om “write once, run anywhere” maken het bijzonder aantrekkelijk voor het bouwen van robuuste, schaalbare systemen. In dit artikel zullen we verkennen hoe de objectgeoriënteerde programmeerfuncties van Java ontwikkelaars in staat stellen om deze mogelijkheden effectief te benutten, waardoor ze onderhoudbare en schaalbare toepassingen kunnen bouwen door middel van een goede codeorganisatie en hergebruik.

Een Opmerking over het Organiseren en Uitvoeren van Java Code

Voordat we met het schrijven van code beginnen, laten we wat instellingen doen.

Net als met zijn syntaxis heeft Java strenge regels over codeorganisatie.

Eerst moet elke openbare klasse in zijn eigen bestand staan, met exact dezelfde naam als de klasse maar met een .java extensie. Dus, als ik een Laptop klasse wil schrijven, moet de bestandsnaam Laptop.java zijn—hoofdlettergevoelig. Je kunt niet-openbare klassen in hetzelfde bestand hebben, maar het is beter ze te scheiden. Ik weet dat we vooruitlopen—praten over het organiseren van klassen nog voordat we ze schrijven—maar een ruwe idee hebben van waar je dingen van tevoren kunt plaatsen is een goed idee.

Alle Java-projecten moeten een Main.java bestand hebben met de Main klasse. Dit is waar je je klassen test door objecten ervan aan te maken.

Om Java-code uit te voeren, gebruiken we IntelliJ IDEA, een populaire Java IDE. Na het installeren van IntelliJ:

  1. Maak een nieuw Java-project aan (Bestand > Nieuw > Project)
  2. Klik met de rechtermuisknop op de src-map om het Main.java bestand te maken en plak de volgende inhoud:
public class Main { public static void main(String[] args) { // Maak en test objecten hier } }

Wanneer we het hebben over klassen, schrijven we code in andere bestanden dan het Main.java bestand. Maar als we het hebben over het maken en testen van objecten, schakelen we over naar Main.java.

Om het programma uit te voeren, kunt u op de groene afspeelknop naast de hoofdmethode klikken:

De uitvoer wordt weergegeven in het venster van het Run-hulpprogramma onderaan.

Als je helemaal nieuw bent in Java, bekijk dan onze Introductie tot Java-cursus, die de grondbeginselen van Java-datatypes en controlestructuren behandelt voordat je verder gaat.

Anders, laten we er meteen induiken.

Java Klassen en Objecten

Dus, wat zijn klassen precies?

Klassen zijn programmeerconstructies in Java voor het representeren van concepten uit de echte wereld. Bijvoorbeeld, overweeg deze MenuItem klasse (maak een bestand aan om deze klasse te schrijven in je IDE):

public class MenuItem { public String name; public double price; }

De klas geeft ons een blauwdruk of een sjabloon om verschillende menu-items in een restaurant te vertegenwoordigen. Door de twee attributen van de klas te veranderen, naam, en prijs, kunnen we talloze menu-objecten zoals een hamburger of een salade maken.

Om een klasse in Java te maken, begint u met een regel die het toegangsniveau van de klasse beschrijft (private, public, of protected) gevolgd door de naam van de klasse. Direct na de accolades schetst u de attributen van uw klasse.

Maar hoe maken we objecten die tot deze klasse behoren? Java maakt dit mogelijk via constructormethoden:

public class MenuItem { public String name; public double price; // Constructor public MenuItem(String name, double price) { this.name = name; this.price = price; } }

Een constructor is een speciale methode die wordt aangeroepen wanneer we een nieuw object van een klasse maken. Het initialiseert de attributen van het object met de waarden die we verstrekken. In het bovenstaande voorbeeld neemt de constructor een naam- en prijsparameter en wijst ze toe aan de velden van het object met behulp van het ’this’-woord om te verwijzen naar een toekomstige objectinstantie.

De syntaxis voor de constructor is anders dan andere methoden van de klasse omdat het geen retourtype vereist. Ook moet de constructor dezelfde naam hebben als de klasse, en het moet hetzelfde aantal attributen hebben dat je na de klassedefinitie hebt verklaard. Hierboven maakt de constructor twee attributen aan omdat we er twee hebben verklaard na de klassedefinitie: name en price.

Na het schrijven van je klasse en de bijbehorende constructor, kun je instanties (objecten) ervan aanmaken in je hoofdmethode:

public class Main { public static void main(String[] args) { // Maak hier objecten aan MenuItem burger = new MenuItem("Burger", 3.5); MenuItem salad = new MenuItem("Salad", 2.5); System.out.println(burger.name + ", " + burger.price); } }

Uitvoer:

Burger, 3.5

Boven creëren we twee MenuItem-objecten in de variabelen burger en salad. Zoals vereist in Java, moet het type van de variabele worden gedeclareerd, namelijk MenuItem. Vervolgens schrijven we om een instantie van onze klasse te maken het new-trefwoord gevolgd door het aanroepen van de constructor methode.

Naast de constructor kunt u reguliere methoden maken die gedrag aan uw klasse geven. Bijvoorbeeld, hieronder voegen we een methode toe om de totaalprijs na belasting te berekenen:

public class MenuItem { public String name; public double price; // Constructor public MenuItem(String name, double price) { this.name = name; this.price = price; } // Methode om prijs na belasting te berekenen public double getPriceAfterTax() { double taxRate = 0.08; // 8% belastingtarief return price + (price * taxRate); } }

Nu kunnen we de totaalprijs berekenen, inclusief belasting:

public class Main { public static void main(String[] args) { MenuItem burger = new MenuItem("Burger", 3.5); System.out.println("Price after tax: $" + burger.getPriceAfterTax()); } }

Output:

Price after tax: $3.78

Encapsulatie

Het doel van klassen is om een blauwdruk te bieden voor het maken van objecten. Deze objecten zullen vervolgens worden gebruikt door andere scripts of programma’s. Bijvoorbeeld, onze MenuItem objecten kunnen worden gebruikt door een gebruikersinterface die hun naam, prijs en afbeelding op een scherm weergeeft.

Om deze reden moeten we onze klassen zo ontwerpen dat hun instanties alleen gebruikt kunnen worden zoals we dat bedoeld hebben. Op dit moment is onze MenuItem klasse zeer basic en foutgevoelig. Iemand kan objecten creëren met belachelijke attributen, zoals een appeltaart met een negatieve prijs of een sandwich van een miljoen dollar:

// Binnen Main.java MenuItem applePie = new MenuItem("Apple Pie", -5.99); // Negatieve prijs! MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Onredelijk duur System.out.println("Apple pie price: $" + applePie.price); System.out.println("Sandwich price: $" + sandwich.price);

Dus, de eerste taak na het schrijven van een klasse is om de attributen te beschermen door te beperken hoe ze worden gemaakt en benaderd. Om te beginnen willen we alleen positieve waarden toestaan voor prijs en een maximale waarde instellen om te voorkomen dat belachelijk dure items per ongeluk worden weergegeven.

Java stelt ons in staat dit te bereiken door gebruik te maken van 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; } }

Laten we eens kijken wat er nieuw is in het bovenstaande codeblok:

1. We hebben de attributen privé gemaakt door het toevoegen van het private-trefwoord. Dit betekent dat ze alleen toegankelijk zijn binnen de MenuItem-klasse. Encapsulatie begint met deze cruciale stap.

2. We hebben een nieuwe constante MAX_PRICE toegevoegd die als volgt is:

  • privé (alleen toegankelijk binnen de klasse)
  • statisch (gedeeld over alle instanties)
  • final (kan niet worden gewijzigd na initialisatie)
  • gesteld op $100.0 als een redelijk maximale prijs

3. We hebben een setPrice() methode toegevoegd die:

  • Een prijsparameter accepteert
  • Controleert of de prijs niet negatief is
  • Controleert of de prijs niet hoger is dan MAX_PRICE
  • Gooit een IllegalArgumentException met beschrijvende berichten als de validatie mislukt
  • Zet de prijs alleen als alle validaties slagen

4. We hebben de constructor aangepast om setPrice() te gebruiken in plaats van de prijs rechtstreeks toe te wijzen. Dit zorgt ervoor dat de prijsvalidatie plaatsvindt tijdens de creatie van het object.

We hebben zojuist een van de kernpijlers van goed objectgeoriënteerd ontwerp geïmplementeerd – encapsulatie. Dit paradigma dwingt gegevensverberging en gecontroleerde toegang tot objectattributen af, waardoor interne implementatiedetails beschermd zijn tegen externe interferentie en alleen kunnen worden gewijzigd via goed gedefinieerde interfaces.

Laten we dit benadrukken door encapsulatie toe te passen op het naam attribuut. Stel je voor dat we een koffiebar hebben die alleen lattes, cappuccino’s, espresso’s, americano’s en mocha’s serveert.

Dus, onze menunaam kan alleen maar een van de items in deze lijst zijn. Zo kunnen we dit in code afdwingen:

// Rest van de 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)); }

De code hierboven implementeert naamvalidatie voor menu-items in een koffiebar. Laten we het uiteenzetten:

1. Eerst definieert het een private statische finale array VALID_NAMES die alleen toegestane dranknamen bevat: latte, cappuccino, espresso, americano en mocha. Dit array is:

  • private: alleen toegankelijk binnen de klasse
  • static: gedeeld over alle instanties
  • final: kan niet worden gewijzigd na initialisatie

2. Het declareert een private String naamveld om de dranknaam op te slaan

3. De setName() methode implementeert de validatielogica:

  • Neemt een String naam parameter
  • Converteert het naar kleine letters om de vergelijking hoofdletterongevoelig te maken
  • Doorloopt de VALID_NAMES array
  • Als er een overeenkomst wordt gevonden, stelt het de naam in en retourneert het
  • Als er geen overeenkomst wordt gevonden, wordt er een IllegalArgumentException gegooid met een beschrijvende boodschap die alle geldige opties opsomt

Hier is de volledige klasse tot nu toe:

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; } }

Nadat we de manier waarop attributen worden aangemaakt hebben beschermd, willen we ook beschermen hoe ze worden benaderd. Dit wordt gedaan door gebruik te maken van getter-methoden:

public class MenuItem { // Rest van de code hier ... public String getName() { return name; } public double getPrice() { return price; } }

Getter-methoden bieden gecontroleerde toegang tot privé-attributen van een klasse. Ze lossen het probleem van directe toegang tot attributen op, wat kan leiden tot ongewenste wijzigingen en de encapsulatie kan breken.

Bijvoorbeeld, zonder getters kunnen we attributen direct benaderen:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.name; // Directe toegang tot attribuut item.name = "INVALID"; // Kan direct worden gewijzigd, validatie omzeilend

Met getters dwingen we juiste toegang af:

MenuItem item = new MenuItem("Latte", 4.99); String name = item.getName(); // Gecontroleerde toegang via getter // item.name = "ONGELDIG"; // Niet toegestaan - setName() moet worden gebruikt die valideert

Deze encapsulatie:

  1. Beschermt gegevensintegriteit door ongeldige wijzigingen te voorkomen
  2. Stelt ons in staat interne implementatie te wijzigen zonder code die de klasse gebruikt te beïnvloeden
  3. Biedt een enkel toegangspunt dat extra logica kan bevatten indien nodig
  4. Maakt de code beter onderhoudbaar en minder vatbaar voor bugs

Overerving

Onze klasse begint er goed uit te zien, maar er zijn behoorlijk wat problemen mee. Bijvoorbeeld, voor een groot restaurant dat veel verschillende soorten gerechten en dranken serveert, is de klasse niet flexibel genoeg.

Als we verschillende soorten voedselitems willen toevoegen, zullen we verschillende uitdagingen tegenkomen. Sommige gerechten kunnen worden voorbereid voor afhaal, terwijl andere onmiddellijke consumptie vereisen. Menu-items kunnen variërende prijzen en kortingen hebben. Gerechten kunnen temperatuurregistratie of speciale opslag vereisen. Dranken kunnen heet of koud zijn met op maat gemaakte ingrediënten. Items kunnen informatie over allergenen en portieopties nodig hebben. Het huidige systeem kan deze variërende vereisten niet aan.

Overerving biedt een elegante oplossing voor al deze problemen. Het stelt ons in staat om gespecialiseerde versies van menu-items te maken door een basisklasse MenuItem te definiëren met gemeenschappelijke attributen en vervolgens kinderklassen te maken die deze basis overerven en unieke functies toevoegen. 

Bijvoorbeeld, we zouden een Drink klasse kunnen hebben voor dranken met temperatuuropties, een Food klasse voor items die onmiddellijke consumptie vereisen, en een Dessert klasse voor items met speciale opslagbehoeften – allemaal erven ze de kernfunctionaliteit van het menu-item.

Het uitbreiden van klassen

Laten we die ideeën implementeren te beginnen met 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; } }

Om een kindklasse te definiëren die erft van een ouderklasse, gebruiken we het extends-trefwoord na de naam van de kindklasse gevolgd door de ouderklasse. Na de klasse definitie definiëren we eventuele nieuwe attributen die dit kind heeft en implementeren we de constructor.

Merk echter op hoe we de initialisatie vanname en price moeten herhalen, samen met isCold. Dat is niet ideaal omdat de ouderklasse wellicht honderden attributen heeft. Bovendien zal de bovenstaande code een fout genereren bij het compileren omdat dit niet de juiste manier is om attributen van de ouderklasse te initialiseren. De juiste manier is door gebruik te maken van het super-keyword:

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; } }

Het super-trefwoord wordt gebruikt om de constructor van de bovenliggende klasse aan te roepen. In dit geval roept super(name, price) de constructor van MenuItem aan om die attributen te initialiseren, waardoor code duplicatie wordt vermeden. We hoeven alleen het nieuwe isCold attribuut specifiek voor de Drink klasse te initialiseren.

Het trefwoord is zeer flexibel omdat je het kunt gebruiken om naar de bovenliggende klasse te verwijzen in elk deel van de onderliggende klasse. Bijvoorbeeld, om een methode van de bovenliggende klasse aan te roepen, gebruik je super.methodName() terwijl super.attributeName bedoeld is voor attributen.

Methoden overschrijven

Stel nu dat we een nieuwe methode aan onze klassen willen toevoegen om de totale prijs na belasting te berekenen. Aangezien verschillende menu-items verschillende belastingtarieven kunnen hebben (bijvoorbeeld, bereide voeding versus verpakte dranken), kunnen we methoden overschrijven gebruiken om specifieke belastingberekeningen in elke onderklasse te implementeren terwijl we een gemeenschappelijke methodenaam in de bovenliggende klasse behouden.

Hier is hoe dit eruit ziet:

public class MenuItem { // Rest van de MenuItem klasse public double calculateTotalPrice() { // Standaard belastingtarief van 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() { // Voedsel heeft 15% belasting 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() { // Dranken hebben 8% belasting return super.getPrice() * 1.08; } }

In dit voorbeeld stelt methode-overerving elke subklasse in staat om zijn eigen implementatie van calculateTotalPrice():

De klasse MenuItem definieert een standaard belastingberekening van 10%.

Wanneer Food en Drink klassen de MenuItem uitbreiden, overschrijven ze deze methode om hun eigen belastingtarieven te implementeren:

  • Voedingsmiddelen hebben een hoger belastingtarief van 15%
  • Drankjes hebben een lager belastingtarief van 8%

De @Override annotatie wordt gebruikt om expliciet aan te geven dat deze methoden de methode van de bovenliggende klasse overschrijven. Dit helpt bij het opsporen van fouten als de methodehandtekening niet overeenkomt met die van de bovenliggende klasse.

Elke subklasse kan nog steeds de prijs van de bovenliggende klasse ophalen met super.getPrice(), wat aantoont hoe overriden methoden de functionaliteit van de bovenliggende klasse kunnen gebruiken terwijl ze hun eigen gedrag toevoegen.

Kortom, method overriding is een integraal onderdeel van overerving dat subklassen in staat stelt hun eigen implementatie van methoden die gedefinieerd zijn in de ouderklasse te bieden, waardoor meer specifiek gedrag mogelijk is terwijl dezelfde methodenaam behouden blijft.

Abstracte Klassen

Onze MenuItem klassenhiërarchie werkt, maar er is een probleem: zou iedereen in staat moeten zijn om een eenvoudig MenuItem object te maken? Immers, in ons restaurant is elk menu-item ofwel een Food of een Drink – er bestaat niet zoiets als alleen een “generiek menu-item.”

We kunnen dit voorkomen door van MenuItem een abstracte klasse te maken. Een abstracte klasse biedt slechts een basisblauwdruk – deze kan alleen worden gebruikt als ouderklasse voor overerving, niet rechtstreeks geïnstantieerd.

Om MenuItem abstract te maken, voegen we het abstract trefwoord toe na de toegangsmodifier:

public abstract class MenuItem { private String name; private double price; public MenuItem(String name, double price) { setName(name); setPrice(price); } // Bestaande getters/setters blijven hetzelfde // Maak deze methode abstract - elke subclass MOET deze implementeren public abstract double calculateTotalPrice(); }

Abstracte klassen kunnen ook abstracte methoden hebben zoals calculateTotalPrice() hierboven. Deze abstracte methoden dienen als contracten die subklassen dwingen om hun implementaties te leveren. Met andere woorden, elke abstracte methode in een abstracte klasse moet geïmplementeerd worden door kinderklassen.

Dus laten we Eten en Drinken herschrijven met deze veranderingen in gedachten:

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% belasting } } 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% belasting } }

Door deze menu-implementatie hebben we gezien hoe abstractie en overerving samenwerken om flexibele, onderhoudbare code te creëren die gemakkelijk kan worden aangepast aan verschillende zakelijke vereisten.

Conclusie

Vandaag hebben we een glimp opgevangen van wat Java kan als een objectgeoriënteerde programmeertaal. We hebben de basisprincipes van klassen, objecten en een paar belangrijke pijlers van OOP behandeld: encapsulatie, overerving en abstractie via een restaurantsysteem.

Om dit systeem productierijp te maken, moet je nog veel meer leren, zoals interfaces (een onderdeel van abstractie), polymorfisme en OOP-ontwerppatronen. Voor meer informatie over deze concepten, raadpleeg onze Introductie tot OOP in Java cursus.

Als je je kennis van Java wilt testen, probeer dan eens enkele vragen te beantwoorden in ons artikel met Java-interviewvragen.

Source:
https://www.datacamp.com/tutorial/oop-in-java