2.4 Κληρονομικότητα
© Γιάννης Κωστάρας
<- | Δ | -> |
Κληρονομικότητα
Άλλο ένα “όπλο” που έχουν οι προγραμματιστές αντικειμενοστραφών προγραμμάτων στο “οπλοστάσιό” τους για τη δημιουργία πιο κατανοητού και ευκολότερα συντηρήσιμου κώδικα είναι η κληρονομικότητα (inheritance). Επιτρέπει την επαναχρησιμοποίηση κώδικα (DRY principle - Don’t Repeat Yourself).
Τα κοινά στοιχεία δυο ή περισσοτέρων παρόμοιων κλάσεων μπορούν να οριστούν σε μια κοινή υπερκλάση (superclass). Μία υποκλάση (subclass) μπορεί να κληρονομήσει όλα τα “επιτρεπτά” γνωρίσματα και μεθόδους από τους “προγόνους” της (δηλ. όσα είναι δηλωμένα ως protected
και public
αλλά και package
αν βρίσκεται στην ίδια βιβλιοθήκη με τους προγόνους της) εκτός από τις μεθόδους κατασκευής. Η σχέση μεταξύ μιας υπερκλάσης και μιας υποκλάσης είναι σχέση τύπου “ΕΊΝΑΙ (IS-A)”.
Μια υποκλάση μπορεί να ορίσει νέα γνωρίσματα και μεθόδους, μπορεί να υπερφορτώσει υφιστάμενες μεθόδους (overloading) ή ακόμα και να υπερσκελίσει (επανακαθορίσει, υπερκαλύψει) υφιστάμενες μεθόδους (overriding). Όλες οι κλάσεις στη Java κληρονομούν από την κλάση Object
.
Σημείωση. Προσοχή! μια υποκλάση δεν μπορεί να υπερκαλύψει (override) τις μεθόδους κατασκευής και τις στατικές μεθόδους της υπερκλάσης.
Ας δούμε ένα παράδειγμα. Ο κύκλος, το ορθογώνιο παραλληλόγραμμο κλπ. είναι όλα σχήματα (κληρονομούν από την υπερκλάση Shape
). Αυτό δηλώνεται με τη λέξη-κλειδί extends
.
public abstract class Shape {
protected final Point[] points;
Shape(int edges) {
this.points = new Point[edges];
}
Shape(Point[] points) {
this.points = points;
}
public int getEdges() {
return this.points.length;
}
protected abstract double area();
protected abstract double perimeter();
}
final class Point {
private final int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return this.x;
}
public int getY() {
return this.y;
}
}
public class Circle extends Shape {
private final int radius;
Circle() {
super(1);
this.radius = 1;
}
Circle(Point[] points, int radius) {
super(points);
this.radius = radius;
}
public int getRadius() {
return radius;
}
@Override
public double area() {
return Math.PI * (radius * radius);
}
@Override
public double perimeter() {
return Math.PI * 2*radius;
}
}
public class Rectangle extends Shape {
private int width, height;
Rectangle(int width, int height) {
super(4);
this.width = width;
this.height = height;
}
Rectangle(Point[] points, int width, int height) {
super(points);
this.width = width;
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
@Override
public double area() {
return width * height;
}
@Override
public double perimeter() {
return 2*width + 2*height;
}
}
Η λέξη-κλειδί super
χρησιμοποιείται:
- για να καλέσει τον constructor της υπερκλάσης καθώς αυτοί δεν κληρονομούνται. Στην περίπτωση αυτή πρέπει να είναι η πρώτη εντολή στον κώδικα της μεθόδου κατασκευής της υποκλάσης.
- για να καλέσει μία μέθοδο της υπερκλάσης
Η κλήση της μεθόδου κατασκευής μιας υποκλάσης καλεί τις μεθόδους κατασκευής όλων των υπερκλάσεων αυτής.
class Person {
public Person() {
System.out.println("(1) Person's no-arg constructor");
}
}
class Student extends Person {
public Student() {
System.out.println("(2) Student's no-arg constructor");
}
}
jshell> new Student()
(1) Person's no-arg constructor
(2) Student's no-arg constructor
Στο πιο πάνω παράδειγμα βλέπουμε ότι παρόλο που ο constructor της κλάσης Student
δεν καλεί την super
, η μέθοδος κατασκευής της υπερκλάσης καλείται ούτως ή άλλως.
Αν δεν ορίσουμε κάποιον constructor σε μια κλάση, τότε δημιουργείται αυτόματα ο no argument constructor, διαφορετικά αν ορίσουμε έναν constructor, δε δημιουργείται no argument constructor. Αυτό έχει ως συνέπεια κώδικας όπως ο παρακάτω να μην μεταγλωττίζεται καθώς ο εξ’ορισμού no argument constructor της Carnivor
δεν βρίσκει να καλέσει no argument constructor στην υπερκλάση.
class Animal {
public Animal(String name) {
//...
}
}
public class Carnivor extends Animal {}
Το @Override
είναι ένα σχόλιο μεταγλώττισης (annotation) το οποίο χρησιμοποιείται από τον μεταγλωττιστή για να επιβεβαιώσει ότι υπερκαλύπτουμε (override) τη σωστή μέθοδο, διαφορετικά ο μεταγλωττιστής εμφανίζει λάθος μεταγλώττισης (method does not override or implement a method
). Η γλώσσα διαθέτει κι άλλα τέτοια σχόλια μεταγλώττισης όπως:
@deprecated
: δηλώνει ότι η μέθοδος/κλάση/γνώρισμα κλπ. δεν εγκρίνεται προς χρήση πλέον (είτε γιατί υπάρχει μια καλύτερη υλοποίηση, είτε γιατί η υλοποίησή της ήταν λάθος, είτε γιατί μπορεί να διαγραφεί ή να είναι ασύμβατη σε μελλοντικές εκδόσεις). Ο μεταγλωττιστής παράγει προειδοποιήσεις (warnings) όταν χρησιμοποιείται. Π.χ.@deprecated(since="9", forRemoval=true)
δηλώνει ότι το στοιχείο αυτό (μέθοδος/κλάση/γνώρισμα κλπ.) έχει γίνει παρωχημένο από την έκδοση 9 της Java και ότι πρόκειται να διαγραφεί στο μέλλον@SuppressWarnings
: προτρέπει τον μεταγλωττιστή να μην εμφανίζει προειδοποιήσεις (warnings) κατά τη μεταγλώττιση, π.χ.@SuppressWarnings("unchecked")
Η Αρχή της Υποκατάστασης (Substitution Principle) μας λέει ότι όταν περιμένουμε ένα αντικείμενο μιας κλάσης, μπορούμε να παρέχουμε και μια οποιανδήποτε υπο-κλάσης αυτής της κλάσης:
Αρχή της Υποκατάστασης (Substitution Principle): σε μια μεταβλητή μιας δοθείσας κλάσης μπορεί να αποθηκευθεί μια τιμή μιας οποιασδήποτε υπο-κλάσης αυτής της κλάσης, και μια μέθοδος με μια παράμετρο μιας δοθείσας κλάσης μπορεί να κληθεί με όρισμα μια οποιαδήποτε υποκλάση αυτής της κλάσης.
Με βάση την αρχή αυτή μπορούμε να γράψουμε:
Shape shape = new Circle();
Αν δε θέλουμε να επιτρέψουμε να μπορούν να δημιουργηθούν υποκλάσεις μιας κλάσης (δηλ. δε θέλουμε να μπορεί να κληρονομηθεί) τότε τη δηλώνουμε ως final
(βλ. π.χ. την κλάση Point
πιο πάνω). Επίσης, αν δε θέλουμε να μπορούν να υπερκαλυφθούν (overriden) οι μέθοδοι μιας κλάσης, τότε τις δηλώνουμε final
, π.χ.:
public final int getEdges() { // δεν μπορεί να υπερκαλυφθεί από τις υποκλάσεις
return this.points.length;
}
Ένα συχνό λάθος είναι όταν καλούμε μια υπερκαλυμμένη (overridable) μέθοδο στο σώμα της μεθόδου κατασκευής (constructor). Ας δούμε ένα παράδειγμα:
class Point {
protected final int x, y;
public Point(int x, int y) { // 3
this.x = x;
this.y = y;
print(); // 4
}
public int getX() {
return this.x;
}
public int getY() {
return this.y;
}
public void print() {
System.out.println("{x=" + this.x + ", y=" + this.y + "}");
}
}
class ColouredPoint extends Point {
private Color color = null;
public ColouredPoint(int x, int y, Color color) { // 1
super(x, y); // 2
this.color = color;
}
public Color getColor() {
return color;
}
public void print() { // 5
System.out.println("{x=" + this.x + ", y=" + this.y + ", color=" + this.color + "}");
}
}
Αν εκτελέσουμε τον παραπάνω κώδικα:
jshell> Point p = new Point(1,1)
{x=1, y=1}
p ==> Point@28c97a5
jshell> Point cp = new ColouredPoint(1,2,Color.BLACK)
{x=1, y=2, color=null}
cp ==> ColouredPoint@61e4705b
Η 2η εντολή καλεί την μέθοδο κατασκευής της ColouredPoint (1), η οποία με τη σειρά της καλεί την μέθοδο κατασκευής της υπερκλάσης (2, 3). Ο δε constructor της Point
καλεί την μέθοδο print()
της υποκλάσης (4) η οποία με τη σειρά της τυπώνει την τιμή της color
η οποία δεν έχει προλάβει ν’ αρχικοποιηθεί ακόμα (5). Το πρόβλημα διορθώνεται είτε αν αποφύγουμε να καλέσουμε την υπερκαλυμμένη μέθοδο print()
στον constructor Point είτε αν δηλώσουμε την print()
ως final
ώστε να μην μπορεί να υπερκαλυφθεί.
Επίσης μεγάλη προσοχή χρειάζεται όταν ορίζουμε μια στατική μέθοδο σε μια υποκλάση με την ίδια δήλωση όπως σε μια υπερκλάση. Όπως είπαμε πιο πάνω, οι στατικές μέθοδοι δεν μπορούν να υπερκαλυφθούν, με αποτέλεσμα στην ουσία να αποκρύπτουμε (hide) την μέθοδο της υπερκλάσης. Π.χ.
class SuperClass {
public static void display() {
System.out.println("I 'm the SuperClass");
}
}
class SubClass extends SuperClass {
public static void display() {
System.out.println("I 'm the SubClass");
}
}
SuperClass sc = new SuperClass()
SuperClass ssc = new SubClass()
jshell> sc.display()
I 'm the SuperClass
jshell> ssc.display()
I 'm the SuperClass
Βέβαια, θα πρέπει πάντα να καλείτε στατικές μεθόδους και γνωρίσματα από την κλάση κι όχι από κάποιο αντικείμενο καθώς ανήκουν στην κλάση κι όχι σε κάποιο αντικείμενο:
jshell> SuperClass.display()
I 'm the SuperClass
jshell> SubClass.display()
I 'm the SubClass
Αφαιρετικές/Ιδεατές Κλάσεις (Abstract class)
Ο κύριος λόγος της κληρονομικότητας είναι η εξαφάνιση διπλότυπου κώδικα (αρχή “Μην επαναλαμβάνεσαι” - “Don’t Repeat Yourself” ή DRY principle). Δεν μπορούν να δημιουργηθούν αντικείμενα από αφαιρετικές κλάσεις. Πρέπει να κληρονομηθούν και να υλοποιηθούν οι αφαιρετικές μεθόδοι τους από τις υποκλάσεις.
Μια κλάση δηλώνεται ως αφαιρετική με την λέξη-κλειδί abstract
. Μια κλάση η οποία περιλαμβάνει έστω και μια «αφαιρετική» μέθοδο, καθίσταται επίσης αφαιρετική και πρέπει να δηλωθεί ως αφαιρετική (abstract). Δε μπορούμε να δημιουργήσουμε αντικείμενα μιας αφαιρετικής κλάσης εκτός κι αν παρέχουμε μια υλοποίηση όλων των αφαιρετικών μεθόδων της. Μια κλάση που κληρονομεί από μια αφαιρετική κλάση θα πρέπει να υλοποιήσει όλες τις αφαιρετικές μεθόδους της υπερκλάσης, διαφορετικά ο μεταφραστής επιβάλλει να οριστεί και η υποκλάση ως αφαιρετική.
Η κλάση Shape
έχει δυο abstract
μεθόδους (area()
και perimeter()
) και πρέπει να δηλωθεί κι αυτή ως abstract
.
Το παρακάτω UML διάγραμμα αναπαριστά την ανωτέρω ιεραρχία:
Εικόνα 2.4.1 Παράδειγμα ιεραρχίας κλάσεων στη UML
Η σχέση μεταξύ των κλάσεων Shape
, Circle
και Rectangle
ονομάζεται γενίκευση (generalization) η οποία είναι μια σχέση τύπου είναι (is a).
Abstract
κλάσεις και μέθοδοι αναπαρίστανται με πλάγια γραφή. Στο παραπάνω διάγραμμα βλέπουμε μια ακόμα σχέση μεταξύ κλάσεων, την συσσωμάτωση (aggregation) η οποία είναι μια σχέση τύπου ανήκει (owns). Η σχέση αυτή δηλώνει ότι η κλάση Shape
περιλαμβάνει μια συλλογή από σημεία (Points
) τα οποία τα αποθηκεύει στην protected
μεταβλητή points
.
Όπως μπορείτε να δείτε στην Εικόνα 2.2.4 του 2ου μαθήματος αυτής τη εβδομάδας (το οποίο επαναλαμβάνεται παρακάτω)
Εικόνα 2.2.4. Διαγράμματα κλάσεων
η UML υποστηρίζει διάφορους τύπους σχέσεων μεταξύ κλάσεων. Είδαμε τη συσσωμάτωση (aggregation) αλλά υπάρχει και η σύνθετη συσσωμάτωση (composite aggregation ή composition) (σχέση τύπου περιέχει (is part of)). Η διαφορά τους είναι ότι στη δεύτερη περίπτωση, αν καταστραφεί το αντικείμενο που περιέχει τη συλλογή των συσσωματωμένων αντικειμένων, τότε καταστρέφονται και τα συσσωματωμένα αντικείμενα, ενώ στην περίπτωση της απλής συσσωμάτωσης, τα συσσωματωμένα αντικείμενα παραμένουν και μετά την καταστροφή του αντικειμένου που τα περιέχει και μπορούν να επαναχρησιμοποιηθούν από άλλα αντικείμενα. Π.χ. αν καταστραφεί ένα αντικείμενο τύπου Rectangle
το οποίο περιέχει τα 4 αντικείμενα τύπου Point
για τις κορυφές του, τότε ανάλογα με το αν η εφαρμογή μας υποστηρίζει το Point
ως σχήμα ή όχι, η σχέση θα μπορεί να είναι aggregation (αν υποστηρίζει) ή composite aggregation (αν δεν υποστηρίζει οπότε τα σημεία θα πρέπει να καταστραφούν μαζί με το αντικείμενο). Η σύνθετη συσσωμάτωση υλοποιείται ως εμφωλιασμένη κλάση (ώστε αντικείμενά της να καταστρέφονται όταν καταστρέφονται και τα αντικείμενα που τις περιέχουν) ενώ η συσσωμάτωση ως στατική εμφωλιασμένη κλάση ώστε να μπορεί να υπάρχει και όταν αντικείμενα της κλάσης που την περιέχουν παύσουν να υπάρχουν.
Π.χ.
public abstract class Shape {
protected final Point[] points;
Shape(int edges) {
this.points = new Point[edges];
}
Shape(Point[] points) {
this.points = points;
}
public int getEdges() {
return this.points.length;
}
protected abstract double area();
protected abstract double perimeter();
// nested class
static final class Point {
private final int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return this.x;
}
public int getY() {
return this.y;
}
}
}
Η σχέση συσχέτιση (association) χρησιμοποιείται όταν μια κλάση σχετίζεται με μια άλλη, δηλ. την έχει ως γνώρισμά της (σχέση has a).
Π.χ.
class Fighter {
private Weapon weapon;
}
class Weapon {
}
Εικόνα 2.4.2 Παράδειγμα association στη UML
Τέλος, αν μέσα σε μια μέθοδο μιας κλάσης χρησιμοποιούμε ένα αντικείμενο μιας άλλης κλάσης αλλά χωρίς να την ορίζουμε ως γνώρισμα της κλάσης, τότε χρησιμοποιούμε τη σχέση εξάρτηση (dependency).
Σφραγισμένες κλάσεις
Στην έκδοση 15 εμφανίστηκαν δυο νέες δεσμευμένες λέξεις, η sealed
και η permits
. Ας δούμε τη χρήση τους μ’ ένα παράδειγμα:
public abstract sealed class Shape
permits Circle, Rectangle {...}
public class Circle extends Shape {...}
public class Rectangle extends Shape {...}
public class Triangle extends Shape {...} // compilation error
Η sealed
περιορίζει ποιες άλλες κλάσεις μπορούν να κληρονομήσουν την κλάση Shape
. Βλέπουμε ότι επιτρέπει μόνο τις Circle, Rectangle
με αποτέλεσμα η κλάση Triangle
να εμφανίζει λάθος μεταγλώττισης. Από δω και πέρα, έχουμε διάφορες επιλογές για τις (επιτρεπόμενες) υποκλάσεις (Circle, Rectangle
). Μπορούμε να τις ορίσουμε ως final
(δεν επιτρέπεται να κληρονομηθούν), ως sealed
(οπότε πρέπει να ορίσουμε με την permits
ποιες άλλες κλάσεις επιτρέπεται να την κληρονομήσουν) ή να τις ορίσουμε όπως στο παραπάνω παράδειγμα οπότε μπορεί να τις κληρονομήσει οποιαδήποτε άλλη κλάση.
Στα μαθήματα της 1ης εβδομάδας μάθαμε για τη νέα σύνταξη της switch
. Οι sealed
κλάσεις επιτρέπουν την απλοποίησή τους, καθώς, όπως βλέπουμε στο παρακάτω παράδειγμα δεν απαιτείται default
:
double area = switch (shape) {
case Circle c -> Math.pow(c.radius(), 2)*Math.PI
case Rectangle r -> r.a() * r.b()
};
Σημειώστε, ότι στην έκδοση 15 αυτό είναι ένα χαρακτηριστικό προεπισκόπισης (preview) της γλώσσας, με αποτέλεσμα για να το ενεργοποιήσετε θα πρέπει να περάσετε την παράμετρο --enable-preview
κατά τη μεταγλώττιση. Θα περάσουν μια ή δυο εκδόσεις ακόμα ώστε να γίνει ένα κανονικό χαρακτηριστικό της γλώσσας.
Κλάση Object
Όλες οι κλάσεις στη Java κληρονομούν από την κλάση Object
(δηλ. κάθε κλάση μπορεί να γραφτεί και ως class MyClass extends Object
). Η κλάση Object
δηλώνει έναν αριθμό από χρήσιμες μεθόδους:
public class Object {
boolean equals(Object obj); // Indicates whether some other object is "equal to" this one.
int hashCode(); // Returns a hash code value for the object.
String toString(); // Returns a string representation of the object.
}
Είναι πολύ σημαντικό να υπερκαλύπτουμε (override) αυτές τις μεθόδους όταν ορίζουμε τις κλάσεις μας.
Η equals()
ελέγχει για ισότητα μεταξύ δυο αντικειμένων και είναι πολύ σημαντική όταν θέλουμε να συγκρίνουμε αντικείμενα. Επιστρέφει true
αν ένα αντικείμενο της κλάσης είναι ίσο μ’ ένα άλλο αντικείμενο της κλάσης ή μιας υποκλάσης της κλάσης.
Η hashCode()
επιστρέφει ένα μοναδικό αριθμό με βάση τις τιμές των γνωρισμάτων του αντικειμένου της κλάσης και χρησιμοποιείται όταν το αντικείμενο εισαχθεί σε μια συλλογή (για τις συλλογές θα μιλήσουμε την επόμενη εβδομάδα).
Τέλος η toString()
επιστρέφει μια αναπαράσταση του αντικειμένου σε φιλική μορφή για τον προγραμματιστή. Μας βοηθάει ώστε να αναγνωρίζουμε εύκολα τι τιμές έχει το αντικείμενό μας. Η κλάση Object
παρέχει μια όχι και τόσο χρήσιμη υλοποίηση της μεθόδου αυτής παρέχοντας το όνομα της κλάσης, το χαρακτήρα @
ακολουθούμενο από τον κωδικό κατακερματισμού (hash) της κλάσης.
Σε γενικές γραμμές, μια καλή υλοποίηση της μεθόδου equals()
πρέπει ν’ ακολουθεί τους εξής κανόνες:
- Έλεγχος αν ένα αντικείμενο είναι ίδιο με τον εαυτό του χρησιμοποιώντας τον τελεστή
==
- Έλεγχος αν το αντικείμενο που περνάμε είναι του ίδιου τύπου, με χρήση της
instanceof
(η οποία καλύπτει και την περίπτωση το αντικείμενο που περνάμε να είναιnull
) - Μετατροπή (cast) του αντικειμένου που περνάμε στον σωστό τύπο
- Έλεγχος για ισότητα όλων των γνωρισμάτων των δυο αντικειμένων
- Πάντα υπερσκελίστε την
hashCode()
όταν υπερσκελίζετε τηνequals()
. Δυο ίσα αντικείμενα (με βάση τηνequals()
) πρέπει να επιστρέφουν την ίδια τιμήhashCode()
Π.χ.
public class Car { // κλάση
// ιδιότητες/γνωρίσματα
private String model;
private int maxSpeed;
private int ccm;
private int speed = 0;
//...
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Car))
return false;
Car car = (Car)o;
return car.model.equals(this.model) && car.maxSpeed == this.maxSpeed && car.ccm == this.ccm;
}
@Override
public int hashCode() {
int result = model.hashCode();
result = 31 * result + Integer.hashCode(maxSpeed);
result = 31 * result + Integer.hashCode(ccm);
return result;
// return Objects.hash(model, maxSpeed, ccm);
}
@Override
public String toString() {
return "Model: " + model + " max speed: " + maxSpeed + " ccm: " + ccm;
}
}
ΟΠΕ όπως το NetBeans παρέχουν δυνατότητες ώστε να παράγετε εύκολα και γρήγορα αυτές τις μεθόδους. Απλά κάντε δεξί κλικ μέσα σε μια κλάση και επιλέξτε Insert code –> Generate –> equals() and hashCode() και αντίστοιχα Insert code –> Generate –> toString(). Θα εμφανιστεί ένα διαλογικό παράθυρο που θα σας ρωτήσει ποια γνωρίσματα θέλετε να χρησιμοποιήσετε. Επιλέξτε τα ίδια γνωρίσματα για τις equals()
και hashCode()
. Όταν πατήσετε ΟΚ
το NetBeans θα δημιουργήσει τις μεθόδους αυτές για εσάς. Επίσης, το σχόλιο μεταγλώττισης @AutoValue
της Google κάνει την ίδια δουλειά.
Για μια πιο λεπτομερή περιγραφή αυτών των μεθόδων και πώς θα πρέπει να τις υλοποιείτε, βλ. πηγή [2] κεφ. 3.
Ο τελεστής ==
συγκρίνει αν δυο αντικείμενα έχουν την ίδια ταυτότητα (object ID) κι όχι αν έχουν την ίδια τιμή. Αν θέλουμε να συγκρίνουμε αν οι τιμές δυο αντικειμένων είναι ίσες τότε θα πρέπει να χρησιμοποιήσουμε την μέθοδο equals()
.
Σημαντική σημείωση Ενώ για τη σύγκριση πρωτογενών τύπων (raw types) χρησιμοποιούμε τον τελεστή ==
, κάτι τέτοιο δε συνιστάται για τη σύγκριση αντικειμένων. Ο τελεστής ==
δηλώνει σύγκριση ταυτότητας των αντικειμένων αντί για ισότητα των τιμών τους. Συγκρίνει δηλ. αν η ταυτότητα του αντικειμένου objA
είναι ίση με την ταυτότητα του αντικειμένου objB
. Για να συγκρίνουμε αν οι τιμές των δυο αντικειμένων είναι ίσες, θα πρέπει να χρησιμοποιήσουμε τη μέθοδο equals()
, δηλ. objA.equals(objB)
. Έτσι, π.χ.
jshell> int num1 = 5
num1 ==> 5
jshell> int num2 = 5
num2 ==> 5
jshell> num1 == num2
$1 ==> true
jshell> Integer num1 = 5
num1 ==> 5
jshell> Integer num2 = 5
num2 ==> 5
jshell> num1 == num2
$2 ==> true
jshell> num1.equals(num2)
$3 ==> true
jshell> num1 = 150
num1 ==> 150
jshell> num2 = 150
num2 ==> 150
jshell> num1 == num2
$4 ==> false
jshell> num1.equals(num2)
$5 ==> true
Η κλάση Integer
θέλει μεγάλη προσοχή καθώς χρησιμοποιεί μια λανθάνουσα μνήμη (cache) για να αποθηκεύει τις τιμές -128 έως 127 όπως βλέπουμε στο παραπάνω δείγμα κώδικα. Επίσης το παραπάνω παράδειγμα μας δείχνει γιατί πρέπει ΠΑΝΤΑ να συγκρίνουμε αντικείμενα με τη μέθοδο equals()
.
Η τεχνική αυτή λέγεται απομνημόνευση (memoization) και ισχύουν αυτά που φαίνονται στον ακόλουθο πίνακα:
Πίνακας 2.4.1 Τιμές απομνημόνευσης (memoization) κατά το (Un)Boxing
Αρχέγονος τύπος | Συσκευασμένος (boxed) τύπος | Τιμές απομνημόνευσης |
boolean |
Boolean |
όλες |
byte |
Byte |
όλες |
char |
Character |
μόνο \u0000 - \u007f ή [0, 127] |
short |
Short |
μόνο [-128, 127] |
int |
Integer |
μόνο [-128, 127] |
long |
Long |
μόνο [-128, 127] |
Η κλάση Byte
αποθηκεύει στη λανθάνουσα μνήμη όλες τις τιμές του πρωτογενούς τύπου byte
(οπότε μπορούμε να συγκρίνουμε δυο τύπους Byte
και με τον τελεστή ==
). Οι τελεστές σύγκρισης (<, >, <=, >=) παρόλ’ αυτά, δουλεύουν σωστά και με τους συσκευασμένους (boxed) τύπους δεδομένων.
Διεπαφές (Interfaces)
Η Java (αντίθετα με άλλες γλώσσες προγραμματισμού όπως η C++) δεν υποστηρίζει πολλαπλή κληρονομικότητα κλάσεων. Αντιθέτως, υποστηρίζει πολλαπλή κληρονομικότητα διεπαφών.
Μία διεπαφή (interface) είναι μία δομή (παρόμοια με την κλάση) η οποία μπορεί να περιλαμβάνει:
- Δηλώσεις μεθόδων (δηλ. αφαιρετικές - abstract) χωρίς τις υλοποιήσεις τους
- Εξ’ορισμού (default) μεθόδους με τις υλοποιήσεις τους (από την έκδοση 8)
- Σταθερές (constant)
Με άλλα λόγια, αποτελεί ένα “συμβόλαιο” που θα πρέπει να υλοποιήσει μια κλάση.
public interface IShape {
double PI = 3.1415;
double area();
double perimeter();
}
Οι μέθοδοι και οι σταθερές μιας διεπαφής είναι εξ’ ορισμού public
. Από την έκδοση 9 υποστηρίζονται και private
μέθοδοι.
Μια κλάση μπορεί να υλοποιεί (implements
) μια ή περισσότερες διεπαφές. Επίσης, μια διεπαφή μπορεί να κληρονομήσει άλλες διεπαφές (extends
) αλλά όχι άλλες κλάσεις.
Μία κλάση πρέπει να υλοποιεί όλες τις μεθόδους μίας διεπαφής εκτός και αν είναι δηλωμένη ως abstract
. Π.χ.
public abstract Shape implements IShape {
protected final Point[] points;
Shape(int edges) {
this.points = new Point[edges];
}
Shape(Point[] points) {
this.points = points;
}
public int getEdges() {
return this.points.length;
}
}
Η παραπάνω κλάση δηλώνεται ως αφαιρετική (abstract) καθώς δεν υλοποιεί τις μεθόδους της διεπαφής IShape
.
Στη UML ένα interface αναπαρίσταται με δυο τρόπους όπως φαίνεται στην ακόλουθη Εικόνα 2.4.3 (βλ. και Εικόνα 2.2.4). Λέμε ότι η κλάση Shape
πραγματοποιεί (realizes) τη διεπαφή IShape
.
Εικόνα 2.4.3 Αναπαράσταση διεπαφής (interface) στη UML
Χαρακτηριστικές διεπαφές της γλώσσας Java:
Serializable
: ονομάζεται και marker interface καθώς δεν περιέχει καμία μέθοδο προς υλοποίηση.Comparable
: καθορίζει μία φυσική σειρά για τα αντικείμενα όπως ισχύει π.χ. για τους αριθμούς.interface Comparable { int compareTo(T o); }
Π.χ.
class Circle extends Shape implements Comparable { public int compareTo(Object o) { if (!(o instanceof Circle)) throw new ClassCastException("must be an instance of Circle"); if (getRadius() > ((Circle) o).getRadius()) return 1; else if (getRadius() < ((Circle) o).getRadius()) return -1; else return 0; } }
Επιστρέφει
0
αν τα δυο αντικείμενα είναι ίσα (έχουν ίσες ακτίνες),1
αν το αντικείμενοthis
είναι μεγαλύτερο απ’ αυτό που περνάμε για σύγκριση, ή-1
στην αντίθετη περίπτωση.Cloneable
: επιτρέπει τη δημιουργία κλώνων της κλάσηςRunnable
: υλοποιείται από κάθε κλάση που προορίζεται να εκτελείται ως νήμα (thread)interface Runnable { public void run(); }
Π.χ.
public class Job implements Runnable { public void run() { System.out.println("Runs in a new thread."); } }
Προσθήκες στις διεπαφές
Η έκδοση 8 πρόσθεσε τις έννοιες των στατικών (static) και εξ’ ορισμού (default) μεθόδων στις διεπαφές.
Με την εισαγωγή των ροών (streams) στην έκδοση 8, υπήρξε το πρόβλημα επεκτασιμότητας των διεπαφών. Όπως μάθαμε, μια κλάση που υλοποιεί μια διεπαφή θα πρέπει να υλοποιήσει όλες τις μεθόδους της διεπαφής. Αυτό δημιουργεί ζητήματα με κώδικα που χρησιμοποιεί παλαιότερες εκδόσεις της γλώσσας. Οι εξ’ ορισμού (default) μέθοδοι επιτρέπουν τη μελλοντική επέκταση της γλώσσας.
Ας δούμε τι εννοούμε με ένα παράδειγμα. Η διεπαφή Collection
που θα δούμε στα μαθήματα της επόμενης εβδομάδας, υπάρχει ήδη από την έκδοση 1.2 της γλώσσας. Στην έκδοση 8 έπρεπε να την αλλάξουν προσθέτοντας νέες μεθόδους (π.χ. stream()
). Κάτι τέτοιο όμως θα “έσπαγε” κώδικα που είχε γραφτεί πριν την έκδοση 8, καθώς ο παλιός κώδικας θα έπρεπε, όταν η εφαρμογή αναβαθμιζόταν ώστε να τρέχει με την έκδοση 8 της Java, να υλοποιήσει τις νέες μεθόδους, αλλοιώς δε θα μπορούσε να μεταγλωττιστεί. Με τη χρήση των εξ’ ορισμού μεθόδων κάτι τέτοιο δεν είναι πλέον απαραίτητο. Οι εξ’ ορισμού μέθοδοι παρέχουν μια εξ’ορισμού υλοποίηση κι έτσι δεν είναι απαραίτητο για τις κλάσεις που υλοποιούν (implements
) αυτές τις διεπαφές να υλοποιήσουν τις εξ’ ορισμού μεθόδους καθώς υπάρχει ήδη μια εξ’ ορισμού υλοποίηση που μπορούν να χρησιμοποιήσουν. Με αυτόν τον τρόπο μπορούμε πλέον να επεκτείνουμε τις διεπαφές χωρίς να “χαλάμε” την προς τα πίσω συμβατότητα.
Οι εξ’ ορισμού μέθοδοι είναι μέθοδοι με μια εξ’ορισμού υλοποίηση ώστε να λύνουν το πρόβλημα της προς τα πίσω συμβατότητας (backwards compatibility) αλλά που μπορούν να επεκταθούν από τις κλάσεις που τις υλοποιούν ώστε να αλλάξουν την εξ’ορισμού υλοποίηση αν το επιθυμούν.
Μια εξ’ ορισμού (default) μέθοδος σε μια διεπαφή ορίζεται με τη λέξη-κλειδί default
:
public interface AInterface {
default String message() {
return "Hallo A";
}
}
public class A implements AInterface {
}
jshell> AInterface a = new A();
a ==> A@3c0ecd4b
jshell> a.message()
$1 ==> "Hallo A"
Μια υπο-διεπαφή μπορεί να υπερκαλύψει (override) αυτή τη μέθοδο.
public interface BInterface extends AInterface {
default String message() {
return "Hallo B";
}
}
public class B implements BInterface {
}
jshell> AInterface b = new B();
b ==> B@64bf3bbf
jshell> b.message()
$2 ==> "Hallo B"
Στο ακόλουθο παράδειγμα βλέπουμε ότι η εξ’ορισμού μέθοδος μπορεί να υπερκαλυφθεί κι από μια κλάση (όχι μόνο από διεπαφή):
public class C extends B {
public String message() {
return "Hallo C";
}
}
jshell> AInterface c = new C();
c ==> C@6e1ec318
jshell> c.message()
$3 ==> "Hallo C"
Έστω η ακόλουθη δήλωση:
public class D extends C implements AInterface {
}
jshell> AInterface d = new D();
d ==> D@31dc339b
jshell> d.message()
Τι πιστεύετε ότι θα τυπώσει η πιο πάνω εντολή;
"Hallo C"
. Με άλλα λόγια, η κλάση νικά.
Επειδή η πολλαπλή κληρονομικότητα ισχύει για τις διεπαφές, ενδέχεται μια διεπαφή να κληρονομήσει από δυο διεπαφές που παρέχουν την ίδια εξ’ορισμού μέθοδο.
public interface X {
default String message() {
return "Hallo X";
}
}
public interface Y {
default String message() {
return "Hallo Y";
}
}
public class XY implements X, Y {
}
Σ’ αυτήν την περίπτωση ο μεταγλωττιστής εμφανίζει λάθος μεταγλώττισης καθώς είναι αμφίβολο ποια εξ’ορισμού μέθοδο να κληρονομήσει. Μπορούμε φυσικά να υπερκαλύψουμε το λάθος υλοποιώντας τη δική μας εξ’ορισμού μέθοδο, π.χ.:
public class XY implements X, Y {
default String message() {
return X.super.message();
}
}
Ισχύουν οι ακόλουθοι 2 κανόνες (σε περίπτωση που έχετε αμφιβολίες):
- Η κλάση υπερισχύει οποιασδήποτε διεπαφής
- Ο υπο-τύπος υπερισχύει του υπερ-τύπου (τύπος: κλάση ή διεπαφή)
Όσων αφορά τις στατικές μεθόδους στις διεπαφές:
jshell> interface Z { static void print() { System.out.println("Hello Z"); }}
| created interface Z
jshell> class ZZ implements Z {}
| created class ZZ
jshell> Z z = new ZZ()
z ==> ZZ@3327bd23
jshell> Z.print()
Hello Z
Παραδείγματα στατικών μεθόδων σε διεπαφές του Java API: Comparator.reverseOrder, Collections.reverseOrder, Collections, sort
κ.ά.
Η Java 9 πρόσθεσε τη δυνατότητα ορισμού ιδιωτικών μεθόδων (private methods) στις διεπαφές. Πριν από την έκδοση 9, όλες οι μέθοδοι στις διεπαφές έπρεπε να είναι public
. Με την εισαγωγή ιδιωτικών μεθόδων στις διεπαφές, μπορείτε να αναδιοργανώσετε (refactor) τον κώδικα default μεθόδων ώστε να εισάγετε private
utility methods. Οι διεπαφές πλέον γίνονται όλο και περισσότερο όμοιες με τις abstract κλάσεις.
Τα δικαιώματα πρόσβασης στις μεθόδους των διεπαφών είναι πλέον όπως περιγράφονται στον ακόλουθο πίνακα:
Πίνακας 2.4.2 Τροποποιητές πρόσβασης στις δηλώσεις μεθόδων στις διεπαφές
Τροποποιητής Πρόσβασης | Υποστηρίζεται | Σχόλια |
public abstract |
Ναι | ≥ JDK 1 |
public static |
Ναι | ≥ JDK 8 |
public default |
Ναι | ≥ JDK 8 |
private static |
Ναι | ≥ JDK 9, δεν μπορεί να επεκταθεί (override) |
private |
Ναι | ≥ JDK 9, δεν μπορεί να επεκταθεί (override) |
private abstract |
Όχι | από τη μία πρέπει να επεκταθεί (abstract ) από την άλλη δεν μπορεί (private ) |
private default |
Όχι | από τη μία θα πρέπει να μπορεί να επεκταθεί (abstract ) από την άλλη δεν μπορεί (private ) |
Ανώνυμες Εσωτερικές (inner) κλάσεις
Στο προηγούμενο μάθημα μιλήσαμε για τις εσωτερικές (inner) και τις εμφωλιασμένες (nested) κλάσεις. Αφήσαμε τις ανώνυμες εσωτερικές κλάσεις (anonymous inner classes) γι’ αυτό το μάθημα. Οι ανώνυμες εσωτερικές κλάσεις είναι μη στατικές εσωτερικές κλάσεις χωρίς όνομα. Υλοποιούν κάποια διεπαφή (interface) ή επεκτείνουν μια κλάση με προκαθορισμένη μέθοδο κατασκευής. Συνδυάζουν τον ορισμό και δημιουργία ενός αντικειμένου μίας εσωτερικής κλάσης σε ένα βήμα:
jshell> public interface MyInterface {
public void aMethod();
}
| created interface MyInterface
jshell> public class Test {
public Test() {
new MyInterface() { // ανώνυμη εσωτερική κλάση
@Override
public void aMethod() {
System.out.println("Anonymous class aMethod()");
}
}.aMethod(); // Anonymous class' aMethod()
}
}
| created class Test
jshell> new Test()
Anonymous class aMethod()
$75 ==> Test@679b62af
Ασκήσεις
1) Μετατρέψτε το ακόλουθο UML διάγραμμα σε κώδικα Java
2) Σε συνέχεια της άσκησης 1. του 1ου μαθήματος αυτής της εβδομάδας, δημιουργήστε μια κλάση Person
με τα εξής γνωρίσματα:
- όνομα (αλφαριθμητικό και δεν μπορεί να ‘ναι κενό)
- επώνυμο (αλφαριθμητικό και δεν μπορεί να ‘ναι κενό)
- ηλικία (ακέραιος μεταξύ 5-18 και πρέπει να ‘χει τιμή)
και μετατρέψτε την κλάση Student
ώστε να κληρονομεί από αυτήν. Δημιουργήστε μια κλάση Teacher
που να κληρονομεί από την Person
και επιπλέον τα γνωρίσματα:
- μαθήματα που διδάσκει (το πολύ μέχρι 3)
Πηγές
- Evans B. (2019), Inside the Language: Sealed Types, Java Magazine.
- Jenkov Nested Classes
<- | Δ | -> |