Obsługa zdarzeń w Javie

Marek Wierzbicki

JBuilder czy Visual Cafe pozwalają rozpocząć tworzenie programów nawet osobom z niewielkim przygotowaniem informatycznym. Dzięki środowiskom graficznym w prosty sposób można napisać szablon programu, zawierający nawet wiele elementów graficznych i obsługę zdzarzeń przypisaną do nich. Jednak automatyzacja prowadzi zawsze do powstania jednakowych, domyślnych i niekoniecznie najlepszych fragmentów kodu. W artykule tym pokażę, jak wyjść poza standardową obsługę zdarzeń w Javie proponowaną przez środowiska RAD.

Java jest chyba jedynym popularnym językiem, który umożliwia łatwe łączenie ręcznego pisania programów z automatycznym procesem jego tworzenia w środowiskach graficznych typu RAD (Rapid Application Development). Sprzyja temu fakt, że wszystkie informacje potrzebne do stworzenia programu wynikowego zawarte są w pojedynczych plikach źródłowych jednego rodzaju. C++ poza plikami z programem zawiera pliki nagłówkowe. Delphi i Visual Basic korzystają z plików zasobów (często w binarnej postaci). Natomiast w Javie wszystkie informacje zgromadzone są w pojedynczych plikach tekstowych dla każdej klasy. Co więcej, wszystko jest zapisane w plikach jawnie w postaci programu. W Delphi np. widzimy procedury obsługi zdarzeń, jednak nigdzie w kodzie źródłowym nie ma funkcji wstawiającej adresy poszczególnych procedur do kolejki obsługi zdarzeń. Niektórzy, zwłaszcza mniej doświadczeni programiści mogą twierdzić, że jest to zaleta, gdyż nie zaciemnia obrazu programu. Osoby doświadczone wiedzą jednak, że domyślna automatyzacja nie zawsze jest najlepszym rozwiązaniem.

Klasy anonimowe i jawne

JBuilder interpretuje na bieżąco, w czasie tworzenia programu jego kod i zamienia go w oknie podglądu na graficzną prezentację. Jednak ważniejsza jest praca w drugą stronę. Programista może wstawić dowolny, wybrany z palety komponent do okna graficznej edycji, co natychmiast jest odnotowane w kodzie źródłowym. Podobnie jak w innych środowiskach RAD, równie łatwe jest generowanie procedur obsługi zdarzeń. Z listy zdarzeń dostępnych dla danego komponentu wybieramy to, które nas interesuje. Dwukrotnie klikamy na tym zdarzeniu i w kodzie źródłowym pojawia się modyfikacja, która zapewnia powstanie mechanizmu obsługi zdarzenia. Poniżej zamieszczamy fragment listingu prostego apletu (wydruk 1), zawierającego jeden przycisk i procedurę obsługującą przyciskanie go (wygenerowany automatyczne przez JBuildera).

Wydruk 1. Aplet z anonimową klasą myApplet (JBuilder)

import java.awt.*;
import java.awt.event.*;
import java.applet.*;
public class myApplet extends Applet {
Button DemoButton = new Button();
  // Initialize the applet
  public void init() {
    DemoButton.setLabel("Przycisk testowy");
    // wstawianie procedury obsługi zdarzenia do kolejki
    // przycisku DemoButton
    DemoButton.addActionListener(new ActionListener(){
      public void actionPerformed(ActionEvent e) {
        DemoButton_actionPerformed(e);
      }
    });
  this.add(DemoButton, null);
  }
  // procedura obsługi zdarzenia
  void DemoButton_actionPerformed(ActionEvent e) {
    // tutaj obsługa zdarzenia
  }
}

Z punktu widzenia omawianego tu problemu najważniejszych jest kilka pogrubionych wierszy, które odpowiadają za to, że po naciśnięciu przycisku wywołana zostanie metoda DemoButton_actionPerformed.

Aby w pełni zrozumieć wyróżnione wiersze w tym listingu, należy wprowadzić dwa pojęcia (niespotykane w innych językach obiektowych, jak C++ i Object Pascal). Pierwsze to klasa anonimowa - specjalny sposób definiowania klas w chwili ich tworzenia, w celu umożliwienia przekazania tej klasy jako parametru funkcji. Gdyby wspomniana wyżej klasa nie była tworzona jako anonimowa, lecz jako jawna, interesujący nas fragment programu miałby postać jak na wydruku 2.

Wydruk 2. Jawne rozwinięcie klasy myApplet z wydruku 1 (Visual Cafe)

class PressButtonClass extends Object implements ActionListener(){
  public void actionPerformed(ActionEvent e) {
    DemoButton_actionPerformed(e);
  }
};
PressButtonClass pbc=new PressButtonClass();
DemoButton.addActionListener(pbc);

Dopiero po jawnym rozwinięciu można zobaczyć, co naprawdę zrobiliśmy (teraz klasa nie jest już anonimowa). Zdefiniowaliśmy nową klasę, dziedziczącą po klasie bazowej Object, w której dodatkowo zaimplementowaliśmy interfejs ActionListener. Takie rozwinięcie stosuje Visual Cafe 4.1 przy automatycznym wstawianiu obsługi zdarzeń. Ta nowo zdefiniowana klasa PressButtonClass różni się od macierzystej klasy Object posiadaniem dodatkowej metody actionPerformed, której jedyną akcją jest wywołanie metody DemoButton_actionPerformed z odpowiednim parametrem.

Klasę anonimową (tzn. bez jawnego użycia słowa class, bez jawnego zadeklarowania, że dziedziczymy po klasie bazowej Object) stosujemy wyłącznie z lenistwa, gdyż zarówno zapis oryginalny, jak i rozwinięty wygeneruje niemal identyczny kod wynikowy. Jedyną różnicą, jaką jawnie zobaczymy będzie nazwa pliku, w którym zadeklarowana i zaimplementowana została klasa bazowa. W przypadku oryginalnym poza plikiem myApplet.class otrzymamy plik myApplet$1.class. W przypadku rozwinięcia drugi plik będzie miał jawną nazwę klasy myApplet$PressButtonClass.class.

Interfejs ActionListener

W przykładzie wykorzystaliśmy też drugie bardzo ciekawe rozwiązanie (dostępne tylko w Javie) - interfejs ActionListener. Mechanizm ten to duży krok w kierunku całkowitego oddzielenia definicji od implementacji metody. Interfejs umożliwia wyłącznie zadeklarowanie istnienia metod (wraz z opisem ich parametrów) i z założenia nie pozwala na ich implementację. Gdyby Java nie była tak mocno obiektowa, moglibyśmy (zamiast podawać w metodzie addActionListener adres obiektu) podać adres metody DemoButton_actionPerformed jako fragment kodu, który trzeba wykonać. Groziłoby to jednak potencjalnie łatwiejszym popełnieniem błędu w przypadku bardziej skomplikowanych procesów dziedziczenia.

Zamiast więc podstawiać adres procedury, musimy stworzyć nowy obiekt, który zawiera metodę obsługującą dane zdarzenie. Oczywiście w klasie tej musi istnieć wymagana metoda, a klasa musi być właściwego typu. Przed utworzeniem naszego przykładu nie było potrzeby, aby metoda actionPerformed cokolwiek robiła, więc mogła być ona abstrakcyjna (wraz ze swoją klasą). W praktyce zamiast deklarować abstrakcyjne klasy obsługi zdarzeń, deklaruje się je jako interfejsy. W przeciwieństwie do C++, Java umożliwia dziedziczenie wyłącznie po jednej klasie (rozwiązanie zostało wprowadzone w celu zmniejszenia liczby potencjalnych błędów możliwych do popełnienia przez programistę). Nie ma jednak ograniczeń w zakresie implementacji interfejsów. Jedna klasa może implementować wiele interfejsów - to bardzo atrakcyjne rozwiązanie (lepsze niż dziedziczenie po wielu klasach).

Na wydruku 2 metoda actionPerformed w klasie PressButtonClass służy jedynie do wywołania metody DemoButton_actionPerformed. Można uprościć przykład, przenosząc obsługę zdarzenia do wnętrza klasy PressButtonClass (nie zmieni to w żaden sposób dostępności do elementów klasy myApplet, gdyż klasa wewnętrzna ma takie same prawa dostępu do niej, jak metody samej klasy myApplet). Uproszczenie pokazałem na wydruku 3.

Wydruk 3. Uproszczenie apletu przez przemieszczenie obsługi zdarzenia do wnętrza klasy PressButtonClass

public class myApplet extends Applet {
  Button DemoButton = new Button();
  // Initialize the applet
  public void init() {
    DemoButton.setLabel("Przycisk testowy");
    class PressButtonClass extends Object implements ActionListener(){
      public void actionPerformed(ActionEvent e) {
        // tutaj obsługa zdarzenia
      }
    };
    DemoButton.addActionListener(new PressButtonClass());
    this.add(DemoButton, null);
  }
}

Stąd już tylko krok do dalszego uproszczenia przykładowego apletu. Zamiast projektować nową klasę, wystarczy, że rozszerzy się klasę główną myApplet, tak aby implementowała ActionListener (tu widać zaletę stosowania interfejsów w języku, który udostępnia tylko dziedziczenie po jednej klasie) tak jak na wydruku 4.

Wydruk 4. Rozszerzenie klasy myApplet

public class myApplet extends Applet implements ActionListener{
  Button DemoButton = new Button();
  // Initialize the applet
  public void init() {
    DemoButton.setLabel("Przycisk testowy");
    DemoButton.addActionListener(this);
    this.add(DemoButton, null);
  }

  // procedura obsługi zdarzenia
  public void actionPerformed(ActionEvent e) {
    // tutaj bezpośrednia obsługa zdarzenia
  }
}

Należy zwrócić uwagę na trzy wyróżnione fragmenty wydruku. Po pierwsze skorzystaliśmy z możliwości dodania implementacji interfejsu ActionListener do dowolnej klasy (niekoniecznie tworzonej specjalnie na potrzeby obsługi zdarzeń, jak na wydrukach 2 i 3). Świadczy o tym zmieniony nagłówek definicji klasy oraz dodanie do ciała klasy myApplet metody actionPerformed, która musi wystąpić, aby tak zadeklarowana klasa nie była abstrakcyjna. Deklaracja oraz dodanie nowej metody zapewnia, że zamiast wskazania na klasę anonimową (czy tworzoną specjalnie w tym celu) w metodzie addActionListener jako na klasę zobligowaną do obsługi zdarzenia, możemy wskazać na klasę główną, w której jesteśmy (czyli this).

Rozwiązanie, do którego doszliśmy (wydruk 4) ma kilka zalet. Unikamy powstania dodatkowej klasy (anonimowej bądź wewnętrznej), co wiąże się ze zmniejszeniem wielkości kodu wynikowego (nie powstaje plik wynikowy zawierający tę klasę). Kod klasy głównej zmniejsza się o fragment związany z tworzeniem i inicjacją wspomnianej klasy. Ponadto w pamięci przechowujemy o jedną klasę mniej. Mniejsza ilość wykorzystanej pamięci operacyjnej sprawia, że szybsza jest obsługa klasy.

Obsługa zdarzeń poza daną klasą

Wydruk 1 prezentował fragment kodu utworzony automatycznie przez JBuildera 4 Foundation po automatycznym dodaniu procedury obsługi naciśnięcia przycisku ekranowego. Wydruk 2 to kod proponowany przez Visual Cafe 4.1. Wydawałoby się, że skoro są to kody generowane automatycznie, to powinny być możliwie najprostsze do zrealizowania. Przykłady pokazały, że tak jednak nie jest.

Eliminacja klasy anonimowej to tylko wprawka programistyczna, nie zmieniająca w zasadniczy sposób funkcjonalności programu. Poważniejszą zmianę jakościową możemy uzyskać dopiero przy wyprowadzaniu obsługi zdarzeń poza klasę. Załóżmy, że projektujemy pewien komponent, który chcemy udostępnić wyłącznie w postaci kodu wynikowego (pliku z rozszerzeniem class). Do pełnej funkcjonalności tego komponentu wymagane jest jednak, aby jedno ze zdarzeń było wyprowadzone na zewnątrz i udostępnione użytkownikowi tego komponentu. Jednocześnie ze względów optymalizacyjnych chcemy zadeklarować ten komponent jako ostateczny (final), a więc użytkownik nie będzie mógł pokryć go własną klasą (w tym metodą obsługującą zdarzenia).

Należy zatem wymusić na programie, aby obsługą zdarzenia zajęła się klasa, która stanie się właścicielem projektowanego komponentu. Możemy skorzystać z mechanizmu podobnego do zastosowanego w przykładzie eliminacji klasy anonimowej. Jednak zamiast wskazywać na projektowany właśnie komponent, jako obiekt zobligowany do obsługi zdarzeń wskażemy na nieokreśloną klasę, będącą w przyszłości właścicielem tego komponentu (parent). Na wydruku 5 przedstawiony jest fragment nowo projektowanego komponentu (wiersze, na które powinniśmy zwrócić uwagę, zostały wyróżnione).

Wydruk 5. Fragment nowo projektowanego komponentu

public class intPanel extends Panel {
  private ActionListener parent;
  Button DemoButton = new Button();
  public intPanel(ActionListener parent) {
    this.parent=parent;

    DemoButton.setLabel("Przycisk testowy");
    DemoButton.addActionListener(parent);
    this.add(DemoButton, null);
  }
}

Poza zmianą wskazania z bieżącej klasy na właściciela trzeba pamiętać o tym, aby zadeklarować właściciela takiego typu, aby był on poprawnie zaakceptowany w metodzie addActionListener. Poza tym jedyną różnicą jest dodanie fragmentu kodu, który pozwoli odebrać informację o właścicielu naszego obiektu. Używanie opisywanego komponentu wymaga w wywołaniu wskazania na klasę wywołującą (klasa ta musi implementować interfejs ActionListener). Fragment właściwego kodu pokazałem na wydruku 6:

Wydruk 6. Wskazanie na klasę wywołującą

import java.*;
import intPanel;
public class butApplet extends Applet implements ActionListener{
  intPanel ip = new intPanel(this);
  public butApplet() {
    this.add(ip, null);
  }

  public void actionPerformed(java.awt.event.ActionEvent e){
    // tu obsługa zdarzenia generowanego w naszym komponencie typu intPanel
  }
}

Podsumowanie

Przedstawione modyfikacje, które mogą poprawić funkcjonalność programu przez zmianę standardowej obsługi zdarzeń, to tylko kilka z możliwych sposobów samodzielnej obsługi zdarzeń. Przykłady te pokazują jednak, że warto wyjść poza standardy proponowane przez środowiska RAD. Każde z nich używa sztywno jednego rozwiązania, najprostszego do realizacji (według producenta) z punktu widzenia automatycznej analizy kodu źródłowego. Bardzo często jednak kode generowany automatycznie nie jest optymalny w ogólnym tego słowa znaczeniu, nie mówiąc już o przystosowaniu go do naszych bieżacych potrzeb. Jak pisałem warto podchodzić do tej kwestii samodzielnie, zwłaszcza, że jak pokazałem nie jest to zbyt trudne zadanie.