Klasy parametryzowane typami

Siłą programowania obiektowego jest łatwość opisania za jego pomocą abstrakcji. Za pomocą klas oraz dziedziczenia możemy uzyskiwać kolejne poziomy abstrakcji, od najniższego do stojącego na najwyższym, najbardziej ogólnym poziomie.

Klasa Object

Wyobraźmy sobie, że mamy klasę typu kontenerowego, w której przechowywać będziemy dane pobrane z bazy. W zależności od tabeli, dane będą się oczywiście różnić. Mając podstawową świadomość tego, jak wygląda programowanie w Javie, możemy rozróżnić dwie drogi – pierwsza z nich to oddzielna klasa dla każdego typu danych, oraz dopisywanie odpowiedniej obsługi w momencie, gdy chcemy zagwarantować przepływ nowych encji przez naszą aplikację. Druga droga, to skorzystanie z dziedziczenia po klasie Object i utworzenie kontenera z obiektami własnie tej klasy.

package it.gniado.application.container;

public class Container {

    private Element firstElement;

    public Container(){
        firstElement = null;
    }

    public boolean isEmpty(){
        return firstElement == null;
    }

    public void insert(Object element){
        if(isEmpty()){
            firstElement = new Element(element);
        } else {
            firstElement = new Element(element, firstElement);
        }
    }

    public Object get(){
        if (isEmpty()){
            return null;
        }
        Object element = firstElement.getElement();
        firstElement = firstElement.getPrev();
        return element;
    }

}

package it.gniado.application.container;

public class Element {

    private Object element;
    private Element prev;


    public Element(Object element){
        this.element = element;
    }

    public Element(Object element, Element prev){
        this.element = element;
        this.prev = prev;
    }

    public Object getElement(){
        return element;
    }

    public void setElement(Object element){
        this.element = element;
    }

    public Element getPrev(){
        return prev;
    }

    public void setPrev(Element prev){
        this.prev = prev;
    }
}

 

W powyższym przykładzie mamy dwie klasy – Element i Container. Teoretycznie wszystko wygląda w porządku, ponieważ zarówno ustawiamy jak i pobieramy obiekty klasy Object. Możemy dzięki temu, oraz polimorfizmowi, skorzystać z dowolnej klasy dziedziczącej po Object, ergo – z dowolnej klasy. Weźmy więc pod uwagę klasę User:

package it.gniado.application.model;

public class User {
    
    private String name;

    public String getName(){
        return name;
    }

    public void setName(String name){
        this.name = name;
    }
}

W momencie, gdy chcielibyśmy skorzystać z naszego kontenera, wpisując:

...
Container container = new Container();
User user = new User();

user.setName("qwerty");

container.insert(user);
...

Wszystko byłoby w porządku. Problem pojawiłby się, gdybyśmy próbowali pobrać element z kontenera bez rzutowania typów:

...
User userPopped = container.get();
...
Brak rzutowania typów – mamy niespójne klasy

 

Zamiana tego wiersza na rzutowanie teoretycznie rozwiąże problem, jednak tylko powierzchownie. W ten sposób bowiem rezygnujemy ze sprawdzania typów przez kompilator, oraz bierzemy na siebie odpowiedzialność, że typ obiektu pobranego z kontenera jest właściwy. Właśnie w tym celu wprowadza się do nowoczesnych języków programowania coś, co nazywa się typem uogólnionym (generycznym).

Klasy parametryzowane typami

Rozwiązaniem może być tzw. parametryzacja klasy. Mając taką sytuację jak powyższa, wolimy rozwiązanie ogólne, zamiast dedykowanego dla każdej możliwej klasy elementu kontenera. Z powodów wspomnianych wcześniej nie chcemy gubić informacji o klasie, a nawet zagwarantować samemu sobie, że jeśli wstawimy do kontenera obiekt klasy User – to i obiekt klasy User z tego kontenera wyciągniemy. Zdefiniujmy zatem kontener oraz element jako typ uogólniony.

package it.gniado.application.container;

public class Container<T> {

    private Element<T> firstElement;

    public Container(){
        firstElement = null;
    }

    public boolean isEmpty(){
        return firstElement == null;
    }

    public void insert(T element){
        if(isEmpty()){
            firstElement = new Element(element);
        } else {
            firstElement = new Element(element, firstElement);
        }
    }

    public T get(){
        if (isEmpty()){
            return null;
        }
        T element = firstElement.getElement();
        firstElement = firstElement.getPrev();
        return element;
    }

}
package it.gniado.application.container;

public class Element<T> {

    private T element;
    private Element prev;


    public Element(T element){
        this.element = element;
    }

    public Element(T element, Element prev){
        this.element = element;
        this.prev = prev;
    }

    public T getElement(){
        return element;
    }

    public void setElement(T element){
        this.element = element;
    }

    public Element getPrev(){
        return prev;
    }

    public void setPrev(Element prev){
        this.prev = prev;
    }
}

To, co widzimy w powyższych klasach to magiczne rozszerzenie klas o <T>. Nazywane to jest parametrem klasy. Sens takiej deklaracji jest w rodzaju: niech będzie publiczna klasa Container, która ma parametr typu T. To, co najważniejsze dzieje się kilka linijek niżej zarówno w pierwszej, jak i drugiej klasie. A mianowicie:

...
public T getElement(){
    return element;
}
...

Powyższy zapis oznacza, że metoda getElement zwróci obiekt typu T, czyli de facto – typu, który jest parametrem metody. Dzięki temu parametryzuąc klasę Element jako, chociażby Element<User> – metoda getElement zwróci obiekt typu User. Podobnie dla innych klas jak String, Integer, Boolean itd. Co istotne – parametrem klasy może być tylko inna klasa. Nie można zatem skorzystać z typów prymitywnych, jak int, long czy boolean. Ale już Integer, Long, Boolean – jak najbardziej.

Interfejsy z parametrem

Rozważmy teraz inne zastosowanie parametrów klas. Wyobraźmy sobie, że mamy projekt, w którym za pobieranie danych z bazy odpowiedzialne są klasy w paczce dao. Chcemy jednak zunifikować te klasy tak, aby w każdej klasie metoda zapisu danych nazywała się save, pobranie wszystkich elementów – getAll, a pojedynczego za pomocą id – getBySingleId. Dodatkowo chcielibyśmy w każdym dao metodę aktualizującą oraz usuwającą element. Można to zrobić dwojako – albo ustalić odpowiednie reguły z zespołem (mało efektywne, niesprawdzalne automatycznie), albo skorzystać z interfejsu (efektywne, sprawdzalne na poziomie kompilatora).

Interfejsy jednakowoż także mają w metodach zadeklarowany typ zwracany, więc jeśli napiszemy interfejs dla jednej encji, to siłą rzeczy, nie wykorzystamy tego samego interfejsu do kolejnych encji. Tu z pomocą przychodzą nam właśnie parametry klas. Ponieważ interfejsy są normalnymi klasami (stuprocentowo abstrakcyjnymi), możemy parametryzację klas wykorzystać także w tym wypadku. W momencie, gdy zadeklarujemy, że metodą getAll pobierzemy wszystkie elementy klasy parametryzowanej, to w zależności od wykorzystania – pobierzemy tę albo inną encję. Zwróćmy uwagę na interfejs SimpleDao:

package it.gniado.primefaces.dao;

import java.util.List;

public interface SimpleDao<T> {

  T save(T entity);
  
  List<T> getAll();
  
  T getBySingleId(Long id);
  
  T update(T entity);
  
  void delete(T entity);

}

Mamy tutaj parametr T (czyli dowolną klasę dziedziczącą po klasie Object, T jest tylko umowne), który przyjmuje interfejs SimpleDao. Powoduje to, że w każdej z metod możemy wykorzystać dowolną klasę ustaloną jako parametr. Dla przykładu, deklaracja nowej klasy jako

...
public class DoctorDao implements SimpleDao<Doctor> {
...

spowoduje, że interfejs implementowany przez tę klasę rozumiany będzie jako

package it.gniado.primefaces.dao;

import java.util.List;

public interface SimpleDao<Doctor> {

  Doctor save(Doctor entity);
  
  List<Doctor> getAll();
  
  Doctor getBySingleId(Long id);
  
  Doctor update(Doctor entity);
  
  void delete(Doctor entity);

}

Dzięki temu wykorzystamy ten sam interfejs w wielu encjach – musimy jedynie podczas deklarowania klasy uzupełnić parametr T w zależności od tego, do jakiej encji będziemy wykorzystywać naszą klasę dao.

Zawężanie typów parametru

W powyższych przykładach korzystaliśmy z parametrów klas biorąc pod uwagę wszystkie klasy (wszak każda klasa dziedziczy po Object). Chcąc np. zawęzić wykorzystanie interfejsu SimpleDao tylko do klas określonych w naszym projekcie jako encje, możemy skorzystać z pewnej sztuczki.

Programiści często implementują klasę abstrakcyjną wspólną dla np. wielu encji. Taka klasa ma np. wspólny atrybut oraz gettery i settery, często do tego typu przedsięwzięć używa się pola id. Najważniejsze jest jednak coś innego. Mając klasy zadeklarowane w ten sposób:

package it.gniado.primefaces.dbo;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class EmailAddress extends EmptyEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String emailAddress;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getEmailAddress() {
        return emailAddress;
    }

    public void setEmailAddress(String emailAddress) {
        this.emailAddress = emailAddress;
    }
}

możemy zmienić konstrukcję interfejsu SimpleDao na następujący:

package it.gniado.primefaces.dao;

import java.util.List;

import it.gniado.primefaces.dbo.EmptyEntity;

public interface SimpleDao<T extends EmptyEntity> {

  T save(T entity);
  
  List<T> getAll();
  
  T getBySingleId(Long id);
  
  T update(T entity);
  
  void delete(T entity);

}

wykorzystując dziedziczenie klasy parametryzowanej po EmptyEntity. Spowoduje to, że tylko takie klasy będą mogły być parametrem interfejsu, ergo – interfejs obsłuży jedynie klasy dziedziczące po EmptyEntity.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *