Ga naar hoofdinhoud

OOP in Java: Classes, objecten, encapsulation, inheritance en abstractie

Leer objectgeoriënteerd programmeren in Java met praktische voorbeelden. Beheers classes, objecten, inheritance, encapsulation en abstracte classes met behulp van een restaurantsysteem voor het menu.
· 15 min lezen

Verken met AI

Java staat steevast in de top drie van populairste programmeertalen ter wereld. De adoptie in domeinen als enterprise-softwareontwikkeling, Android-apps en grootschalige webapplicaties is ongeëvenaard. Het sterke typesysteem, het uitgebreide ecosysteem en de “write once, run anywhere”-eigenschap maken Java bijzonder aantrekkelijk voor het bouwen van robuuste, schaalbare systemen. In dit artikel verkennen we hoe de objectgeoriënteerde programmeerfuncties van Java ontwikkelaars helpen deze mogelijkheden effectief te benutten, zodat ze onderhoudbare en schaalbare applicaties kunnen bouwen door middel van goede code-organisatie en hergebruik.

Een opmerking over het organiseren en uitvoeren van Java-code

Voordat we code gaan schrijven, doen we eerst wat setup.

Net als bij de syntax heeft Java strikte regels voor de organisatie van code.

Allereerst moet elke public class in een eigen bestand staan, exact genoemd naar de class maar met de extensie .java. Dus als ik een Laptop-class wil schrijven, moet de bestandsnaam Laptop.java—hoofdlettergevoelig. Je kunt niet-public classes in hetzelfde bestand hebben, maar het is beter ze te scheiden. We lopen wat voor de muziek uit—we hebben het over het organiseren van classes nog voordat we ze schrijven—maar het is verstandig om vooraf een globaal idee te hebben waar je dingen neerzet.

Alle Java-projecten moeten een Main.java-bestand hebben met de Main-class. Hier test je je classes door er objecten van aan te maken.

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

  1. Maak een nieuw Java-project aan (File > New > Project)
  2. Klik met de rechtermuisknop op de src-map om het bestand Main.java te maken en plak de volgende inhoud erin:
public class Main {
   public static void main(String[] args) {
       // Create and tests objects here
      
   }
}

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

Om het programma uit te voeren kun je op de groene play-knop naast de main-methode klikken:

Een screenshot van een editorvenster in IntelliJ IDEA voor Java

De output verschijnt in het Run-venster onderaan.

Als je helemaal nieuw bent met Java, bekijk dan onze Introduction to Java-cursus, die de basis van Java-typen en control flow behandelt voordat je verdergaat.

Laten we er dan nu induiken.

Java-classes en objecten

Wat zijn classes nu precies?

Classes zijn programmeerconstructies in Java om concepten uit de echte wereld te representeren. Neem bijvoorbeeld deze MenuItem-class (maak in je IDE een bestand aan om deze class in te schrijven):

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

De class geeft ons een blauwdruk of sjabloon om verschillende menu-items in een restaurant te representeren. Door de twee attributen van de class, name, en price, te variëren, kunnen we talloze menu-objecten maken, zoals een burger of een salade.

Om een class in Java te maken, begin je met een regel die het toegangsniveau van de class beschrijft (private, public of protected) gevolgd door de classnaam. Direct na de accolades beschrijf je de attributen van je class.

Maar hoe maken we objecten die bij deze class horen? In Java kan dat met constructor-methoden:

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 class maken. Hij initialiseert de attributen van het object met de waarden die we meegeven. In het bovenstaande voorbeeld neemt de constructor een name- en price-parameter en kent ze toe aan de velden van het object met het trefwoord ‘this’, dat verwijst naar een toekomstige objectinstantie.

De syntax voor de constructor wijkt af van andere methoden in een class omdat je geen returntype hoeft op te geven. Ook moet de constructor dezelfde naam hebben als de class en zou hij hetzelfde aantal attributen moeten initialiseren als je na de classdefinitie hebt gedeclareerd. Hierboven maakt de constructor twee attributen aan omdat we er twee na de classdefinitie hebben gedeclareerd: name en price.

Nadat je je class en de constructor hebt geschreven, kun je er instanties (objecten) van maken in je main-methode:

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

Output:

Burger, 3.5

Hierboven maken we twee MenuItem-objecten in de variabelen burger en salad. Zoals in Java vereist, moet je het type van de variabele declareren, in dit geval MenuItem. Vervolgens schrijf je het trefwoord new, gevolgd door het aanroepen van de constructormethode, om een instantie van onze class te maken.

Naast de constructor kun je gewone methoden maken die je class gedrag geven. Hieronder voegen we bijvoorbeeld 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;
   }
  
   // Method to calculate price after tax
   public double getPriceAfterTax() {
       double taxRate = 0.08; // 8% tax rate
       return price + (price * taxRate);
   }
}

Nu kunnen we de totaalprijs inclusief belasting berekenen:

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

Encapsulation

Het doel van classes is om een blauwdruk te bieden voor het maken van objecten. Die objecten worden vervolgens gebruikt door andere scripts of programma's. Onze MenuItem-objecten kunnen bijvoorbeeld worden gebruikt door een gebruikersinterface die hun naam, prijs en afbeelding op een scherm toont.

Daarom moeten we onze classes zo ontwerpen dat instanties alleen gebruikt kunnen worden zoals we bedoeld hebben. Op dit moment is onze MenuItem-class erg basic en foutgevoelig. Iemand zou objecten kunnen maken met belachelijke attributen, zoals een appeltaart met een negatieve prijs of een broodje van een miljoen dollar:

// Inside Main.java
MenuItem applePie = new MenuItem("Apple Pie", -5.99);  // Negative price!
MenuItem sandwich = new MenuItem("Sandwich", 1000000); // Unreasonably expensive

System.out.println("Apple pie price: $" + applePie.price);
System.out.println("Sandwich price: $" + sandwich.price);

Dus: het eerste wat je na het schrijven van een class doet, is de attributen beschermen door te beperken hoe ze worden aangemaakt en benaderd. Om te beginnen willen we alleen positieve waarden toestaan voor price en een maximum instellen om te voorkomen dat per ongeluk belachelijk dure items worden getoond.

In Java kunnen we dit doen met 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 bekijken wat er nieuw is in het bovenstaande codeblok:

1. We hebben de attributen private gemaakt door het trefwoord private toe te voegen. Dit betekent dat ze alleen binnen de MenuItem-class toegankelijk zijn. Encapsulation begint met deze cruciale stap.

2. We hebben een nieuwe constante toegevoegd, MAX_PRICE, die:

  • private is (alleen binnen de class toegankelijk)
  • static is (gedeeld door alle instanties)
  • final is (kan na initialisatie niet worden gewijzigd)
  • is ingesteld op $100,0 als redelijk maximum

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

  • Een price-parameter aanneemt
  • Valideert dat de prijs niet negatief is
  • Valideert dat de prijs MAX_PRICE niet overschrijdt
  • IllegalArgumentException gooit met beschrijvende boodschappen als de validatie faalt
  • De prijs alleen instelt als alle validaties slagen

4. We hebben de constructor aangepast om setPrice() te gebruiken in plaats van de prijs direct toe te wijzen. Zo vindt prijsvalidatie plaats tijdens het aanmaken van het object.

We hebben zojuist een van de kernpijlers van goed objectgeoriënteerd ontwerp geïmplementeerd — encapsulation. Dit paradigma dwingt gegevensafscherming en gecontroleerde toegang tot objectattributen af, zodat interne implementatiedetails beschermd zijn tegen externe inmenging en alleen via goed gedefinieerde interfaces kunnen worden aangepast.

Laten we dit punt kracht bijzetten door encapsulation toe te passen op het attribuut name. Stel dat we een koffiezaak hebben die alleen lattes, cappuccino's, espresso's, americanos en mocha's serveert.

Onze menunamen mogen dan alleen uit deze lijst komen. Zo kun je dit afdwingen in code:

// Rest of the class here
...
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 bovenstaande code implementeert naamvalidatie voor menu-items in een koffiezaak. Even in stukjes:

1. Eerst wordt een private static final-array VALID_NAMES gedefinieerd met de enige toegestane dranknamen: latte, cappuccino, espresso, americano en mocha. Deze array is:

  • private: alleen binnen de class toegankelijk
  • static: gedeeld door alle instanties
  • final: na initialisatie niet te wijzigen

2. Er wordt een private String-veld name gedeclareerd om de dranknaam op te slaan

3. De methode setName() implementeert de validatielogica:

  • Neemt een String-parameter name aan
  • Zet die om naar lowercase om de vergelijking case-insensitief te maken
  • Loopt door de array VALID_NAMES
  • Stelt de naam in en returnt als er een match is
  • Gooi een IllegalArgumentException met een beschrijvende boodschap en alle geldige opties als er geen match is

Hier is de volledige class 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 hebben beschermd hoe attributen worden aangemaakt, willen we ook beschermen hoe ze worden benaderd. Dat doen we met getter-methoden:

public class MenuItem {
   // Rest of the code here
   ...
   public String getName() {
       return name;
   }
   public double getPrice() {
       return price;
   }
}

Getter-methoden bieden gecontroleerde toegang tot private attributen van een class. Ze lossen het probleem van directe attribuuttoegang op, wat kan leiden tot ongewenste wijzigingen en het doorbreken van encapsulation.

Zonder getters zouden we bijvoorbeeld attributen direct kunnen benaderen:

MenuItem item = new MenuItem("Latte", 4.99);
String name = item.name; // Direct access to attribute
item.name = "INVALID"; // Can modify directly, bypassing validation

Met getters dwingen we correcte toegang af:

MenuItem item = new MenuItem("Latte", 4.99);
String name = item.getName(); // Controlled access through getter
// item.name = "INVALID"; // Not allowed - must use setName() which validates

Deze vorm van encapsulation:

  1. Beschermt de gegevensintegriteit door ongeldige wijzigingen te voorkomen
  2. Stelt ons in staat de interne implementatie te wijzigen zonder code te breken die de class gebruikt
  3. Biedt één toegangspunt waar indien nodig extra logica kan worden toegevoegd
  4. Maakt de code onderhoudbaarder en minder foutgevoelig

Inheritance

Onze class begint er goed uit te zien, maar er zijn best wat problemen. Voor een groot restaurant met veel soorten gerechten en dranken is de class bijvoorbeeld niet flexibel genoeg.

Als we verschillende soorten etenswaren willen toevoegen, lopen we tegen diverse uitdagingen aan. Sommige gerechten kunnen als afhaal bereid worden, andere moeten direct genuttigd worden. Menu-items kunnen verschillende prijzen en kortingen hebben. Gerechten hebben mogelijk temperatuurtracking of speciale opslag nodig. Dranken kunnen warm of koud zijn met aanpasbare ingrediënten. Items kunnen allergeneninformatie en portieopties nodig hebben. Het huidige systeem kan niet met al deze variërende eisen omgaan.

Inheritance biedt een elegante oplossing voor al deze problemen. We kunnen gespecialiseerde versies van menu-items maken door een basis-MenuItem-class met gemeenschappelijke attributen te definiëren en vervolgens child classes te maken die deze basis overnemen en unieke eigenschappen toevoegen. 

Zo zouden we een Drink-class voor dranken kunnen hebben met temperatuuropties, een Food-class voor items die direct geconsumeerd moeten worden, en een Dessert-class voor items met speciale opslagbehoeften — allemaal met overname van de kernfunctionaliteit van menu-items.

Classes uitbreiden

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 child class te definiëren die erft van een parent, gebruiken we het trefwoord extends na de naam van de child-class, gevolgd door de parent-class. Na de classdefinitie definiëren we eventuele nieuwe attributen van deze child en implementeren we de constructor.

Let echter op dat we de initialisatie van name en price samen met isCold moeten herhalen. Dat is niet ideaal, want de parent-class kan honderden attributen hebben. Bovendien zal de bovenstaande code een fout geven bij compilatie omdat dit niet de juiste manier is om attributen van de parent-class te initialiseren. De juiste manier is met het trefwoord super:

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 trefwoord super wordt gebruikt om de constructor van de parent-class aan te roepen. In dit geval roept super(name, price) de constructor van MenuItem aan om die attributen te initialiseren, waardoor we duplicatie vermijden. We hoeven alleen het nieuwe attribuut isCold te initialiseren dat specifiek is voor de Drink-class.

Het trefwoord is erg flexibel omdat je het overal in de child-class kunt gebruiken om naar de parent-class te verwijzen. Om bijvoorbeeld een parent-methode aan te roepen gebruik je super.methodName(), terwijl super.attributeName voor attributen is.

Method overriding

Stel dat we een nieuwe methode aan onze classes willen toevoegen om de totaalprijs na belasting te berekenen. Omdat verschillende menu-items verschillende btw-tarieven kunnen hebben (bijvoorbeeld bereide maaltijden vs. verpakte dranken), kunnen we method overriding gebruiken om specifieke belastingberekeningen in elke child-class te implementeren, terwijl we in de parent-class een gemeenschappelijke methodenaam behouden.

Zo ziet dat eruit:

public class MenuItem {
   // Rest of the MenuItem class
  
   public double calculateTotalPrice() {
       // Default tax rate of 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() {
       // Food has 15% tax
       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() {
       // Drinks have 8% tax
       return super.getPrice() * 1.08;
   }
}

In dit voorbeeld maakt method overriding het mogelijk dat elke subclass zijn eigen implementatie van calculateTotalPrice() biedt:

De basisclass MenuItem definieert een standaardbelasting van 10%.

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

  • Food-items hebben een hoger tarief van 15%
  • Dranken hebben een lager tarief van 8%

De annotatie @Override wordt gebruikt om expliciet aan te geven dat deze methoden de parent-methode overschrijven. Dit helpt fouten te vangen als de methodsignature niet overeenkomt met die van de parent-class.

Elke subclass kan nog steeds de prijs van de parent-class benaderen via super.getPrice(), wat laat zien hoe overschreven methoden functionaliteit van de parent-class kunnen gebruiken en tegelijk hun eigen gedrag toevoegen.

Kortom, method overriding is een wezenlijk onderdeel van inheritance waarmee subclasses hun eigen implementatie kunnen geven van methoden die in de parent-class zijn gedefinieerd, zodat ze specifieker gedrag kunnen bieden met behoud van dezelfde methodsignature.

Abstracte classes

Onze MenuItem-classhiërarchie werkt, maar er is een probleem: zou iemand een kale MenuItem-instantie mogen maken? In ons restaurant is elk menu-item immers óf Food óf Drink—een "generiek menu-item" bestaat niet.

We kunnen dit voorkomen door MenuItem abstract te maken. Een abstracte class biedt alleen een basisblauwdruk—hij kan alleen als parent-class voor inheritance dienen en niet direct worden geïnstantieerd.

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

public abstract class MenuItem {
   private String name;
   private double price;
  
   public MenuItem(String name, double price) {
       setName(name);
       setPrice(price);
   }
  
   // Existing getters/setters remain the same
  
   // Make this method abstract - every subclass MUST implement it
   public abstract double calculateTotalPrice();
}

Abstracte classes kunnen ook abstracte methoden hebben, zoals calculateTotalPrice() hierboven. Deze abstracte methoden fungeren als contracten die subclasses dwingen hun eigen implementatie te leveren. Met andere woorden: elke abstracte methode in een abstracte class moet door child classes worden geïmplementeerd.

Laten we Food en Drink herschrijven met deze wijzigingen 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% tax
   }
}
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% tax
   }
}

Met deze menu-implementatie hebben we gezien hoe abstractie en inheritance samenwerken om flexibele, onderhoudbare code te creëren die zich gemakkelijk kan aanpassen aan verschillende zakelijke vereisten.

Conclusie

Vandaag hebben we een glimp opgevangen van wat Java kan als objectgeoriënteerde programmeertaal. We hebben de basis van classes, objecten en enkele belangrijke pijlers van OOP behandeld: encapsulation, inheritance en abstractie aan de hand van een restaurantsysteem voor het menu.

Om dit systeem productierijp te maken, moet je nog veel meer leren, zoals interfaces (onderdeel van abstractie), polymorfisme en OOP-ontwerpprincipes. Wil je meer over deze concepten leren, bekijk dan onze Introduction to OOP in Java-cursus.

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

OOP in Java FAQ's

Wat zijn de vereisten om deze Java OOP-tutorial te volgen?

Je moet basiskennis hebben van Java-programmeren, waaronder datatypen, variabelen, control flow (if/else, lussen) en de basis-syntax. Onze Introduction to Java-cursus behandelt deze fundamenten als je een opfrisser nodig hebt.

Waarom een restaurantsysteem voor het menu gebruiken om OOP-concepten uit te leggen?

Een restaurantsysteem voor het menu is een intuïtief voorbeeld dat de toepassingen van OOP-principes in de echte wereld laat zien. Het illustreert op natuurlijke wijze inheritance (verschillende soorten menu-items), encapsulation (het beschermen van prijs- en naamwaarden) en interfaces (loyaliteitsprogramma's, temperatuurregeling). De meeste mensen begrijpen hoe restaurants werken, waardoor de concepten makkelijker te begrijpen zijn.

Wat is het verschil tussen abstracte classes en interfaces in Java?
  • Een class kan slechts één abstracte class extenden maar meerdere interfaces implementeren.
  • Abstracte classes kunnen velden en constructors hebben; interfaces niet.
  • Abstracte classes kunnen zowel abstracte als concrete methoden hebben; interfaces hadden traditioneel alleen abstracte methoden (vóór Java 8).
  • Abstracte classes bieden een basisimplementatie, terwijl interfaces een contract definiëren.
Bexruz (Bex) Tuychiev

Auteur

Bexruz (Bex) Tuychiev

Ik ben een contentmaker op het gebied van data science met meer dan 2 jaar ervaring en een van de grootste achterbannen op Medium. Ik schrijf graag uitgebreide artikelen over AI en ML met een vleugje sarcastische stijl, want je moet toch íets doen om ze wat minder saai te maken. Ik heb meer dan 130 artikelen en een DataCamp-cursus gemaakt, en er is nog een nieuwe in de maak. Mijn content is door meer dan 5 miljoen paar ogen gezien, van wie 20k volgers werden op zowel Medium als LinkedIn. 

Onderwerpen

Gerelateerd