Gdzie można zdefiniować klasę?

Dzisiejszy wpis sponsoruje Karol Paciorek.

Już się tłumaczę – nie znam tego jegomościa osobiście, ale czasem słucham jego podcastów. Dziś przy zmywaniu naczyń słuchałem wywiadu z Wojciechem Chmielarzem. Pod wpływem pewnej wypowiedzi pisarza poczułem nieodpartą chęć, żeby coś napisać. Choć początkowo chciałem spędzić wieczór na czytaniu książki (jakby to kogoś interesowało, to pochłaniam teraz Ci szaleni Polacy Piotra Langenfelda).

Po pozmywaniu sterty brudnych naczyń usiadłem do komputera i popatrzyłem na listę tematów, o których chciałbym w najbliższym czasie napisać. Jeden z nich (żeby potrzymać Cię dłużej w napięciu, nazwę go A) sprawił, że poczułem przyspieszone bicie serca. Już otworzyłem edytor tekstu, żeby zacząć pisanie. Przez chwilę zastanawiałem się, od czego by tu zacząć. Stwierdziłem, że logicznie byłoby napisać o innym temacie (nazwijmy go B). Wtedy dojrzysz w temacie A głębię. Może nie będzie to Eureka godna odkrycia siły wyporności, ale myślę że przynajmniej zduszony szept Ahaaaaa ze skromnym uśmiechem pod nosem. Chociaż wszystko będzie lepsze niż:

via GIPHY

Zmieniłem swój zamiar i już zacząłem myśleć, od czego zacząć pisanie artykułu B. I naszło mnie, że dobrze byłoby napisać najpierw na temat C. I właśnie artykuł C teraz czytasz. Może ktoś w komentarzu (niby jeszcze nikt nie napisał żadnego komentarza, ale wciąż mam nadzieję) spróbuje podyskutować o moim toku rozumowania – zapraszam serdecznie! Podpowiem, że temat A miał być swego rodzaju kontynuacją tego artykułu.

Klasa publiczna

Nawet jeżeli masz niewielkie doświadczenie w programowaniu w Javie, to wiesz, że podstawowym miejscem zdefiniowania klasy o nazwie Main jest plik o nazwie Main.java. Więcej – wtedy klasa Main może zostać zdefiniowana jako klasa publiczna. To, że klasa jest publiczna (słowo kluczowe public) oznacza to, że będzie ona widoczna w innych pakietach naszego projektu. Oczywiście nazwa klasy publicznej musi pasować do nazwy pliku z kodem źródłowym. Kompilacja pliku Main.java o poniższej zawartości:

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello");
    }
}

się powiedzie:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/public-class-01
$ javac Main.java

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/public-class-01
$ ls
Main.class  Main.java

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/public-class-01
$ java Main
Hello

Jeżeli będziemy mieli plik o takiej samej zawartości, ale innej nazwie:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/public-class-01
$ cp Main.java Fail.java

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/public-class-01
$ cat Fail.java
public class Main {
        public static void main(String[] args) {
                System.out.println("Hello");
        }
}

To kompilacja się nie powiedzie:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/public-class-01
$ javac Fail.java
Fail.java:1: error: class Main is public, should be declared in a file named Main.java
public class Main {
       ^
1 error

Klasa niepubliczna

W pliku Main.java możesz zdefiniować dowolnie wiele klas. Nie ma wtedy żadnego wymogu odnośnie ich nazw.

class A {
    public void foo() {
        System.out.println("FOO");
    }
}

class B {
    public void bar() {
        System.out.println("BAR");
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello");
    }
}

Zwróć uwagę, że kompilacja utworzy osobny plik class dla każdej klasy:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/nonpublic-class-02
$ javac Main.java

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/nonpublic-class-02
$ ls
A.class  B.class  Main.class  Main.java

Siłą rzeczy, te dodatkowe klasy nie mogą być publiczne. Bo gdybyśmy mieli plik Main.java o następującej zawartości:

public class A {
    public void foo() {
        System.out.println("FOO");
    }
}

public class B {
    public void bar() {
        System.out.println("BAR");
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello");
    }
}

to jego kompilacja się nie powiedzie:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/nonpublic-class-03
$ javac Main.java
Main.java:1: error: class A is public, should be declared in a file named A.java
public class A {
       ^
Main.java:7: error: class B is public, should be declared in a file named B.java
public class B {
       ^
2 errors

Co oznacza, że klasa jest publiczna? Tak jak napisałem przed chwilą – oznacza, że klasa jest widoczna dla innych pakietów. Najłatwiej będzie mi to pokazać na przykładzie. Rozważmy bardzo prosty projekt Javowy, który składa się z dwóch pakietów:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/nonpublic-class-04
$ tree
.
├── a
│   ├── Main01.java
│   └── Test.java
└── b
    └── Main02.java

2 directories, 3 files

Zawartość pliku a/Test.java wygląda następująco:

package a;

class Foo {
    public void foo() {
        System.out.println("FOO");
    }
}

public class Test {
    public void f(double x) {
        System.out.println(x*x);
    }
}

a plik a/Main01.java:

package a;

public class Main01 {
    public static void main(String[] args) {
        Foo f = new Foo();
        f.foo();
        
        Test t = new Test();
        t.f(4.0);
    }
}

W związku z tym, że klasy Main01, Test i Foo znajdują się w tym samym pakiecie, nie musimy nic importować. Program się skompiluje i uruchomi:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/nonpublic-class-04
$ javac a/*.java

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/nonpublic-class-04
$ java a.Main01
FOO
16.0

W pakiecie b mamy plik Main02.java o następującej zawartości:

package b;

import a.Foo;
import a.Test;

public class Main02 {
    public static void main(String[] args) {
        Foo f = new Foo();
        f.foo();
        
        Test t = new Test();
        f.f(4.0);
    }
}

Teraz importy są potrzebne. Jednak ta klasa się nie skompiluje:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/nonpublic-class-04
$ javac b/Main02.java
b\Main02.java:3: error: Foo is not public in a; cannot be accessed from outside package
import a.Foo;
        ^
b\Main02.java:8: error: Foo is not public in a; cannot be accessed from outside package
                Foo f = new Foo();
                ^
b\Main02.java:8: error: Foo is not public in a; cannot be accessed from outside package
                Foo f = new Foo();
                            ^
3 errors

Sam kompilator potwierdza moje wcześniejsze słowa. Powstaje pytanie, po co tworzyć klasę, która będzie widoczna tylko w ramach określonego pakietu? Odpowiedź krótka i treściwa to enkapsulacja (po polsku czasami nazywana hermetyzacją). Tak naprawdę o enkapsulacji można napisać osobny artykuł, ale przynajmniej jedno zdanie wyjaśnienia. Gdy projektujemy jakieś oprogramowanie – najwyraźniej to widać, gdy piszemy bibliotekę, z której będą korzystali inni programiści – możemy tworzyć tyle klas, ile nam się żywnie podoba, ale powinniśmy zadbać o to, aby korzystanie z naszego kodu było wygodne i bezpieczne – czyli żeby użytkownik końcowy nie mógł czegoś niechcący zepsuć. Dlatego powinniśmy bardzo uważnie decydować, do których klas będą mieli dostęp inni programiści.

Klasa wewnętrzna

To nie jest może takie oczywiste. Z całą pewnością nie jest też jakoś specjalnie popularne. Ale klasy można definiować wewnątrz innych klas. Po angielsku takie klasy określa się mianem inner classes. Tutaj mamy kilka różnych przypadków. I przynajmniej pokrótce przyjrzymy się poszczególnym rodzajom.

Członkowska klasa wewnętrzna

To tłumaczenie może być trochę ułomne – z całą pewnością jest zbyt dosłowne, bo w oryginale spotyka się określenie member inner class. Jest to klasa zdefiniowana na poziomie pól i metod w klasie. Rozważmy bardzo prosty przykład:

class Outer {
    private int field = 10;
    
    void method() {
        Inner i = new Inner();
        i.foo();
    }
    
    class Inner {
        private int field = 5;
        
        void foo() {
            System.out.println(field);
        }
    }
}

Mamy zdefiniowaną członkowską klasę wewnętrzną Inner w klasie Outer. Jest kilka reguł (albo praw i obowiązków) związanych z klasami wewnętrznymi:

  • Możemy ją zdefiniować jako publiczną (public), chronioną (protected), prywatną (private) lub bez żadnego modyfikatora dostępu
  • Może rozszerzać dowolną klasę jak również implementować dowolne interfejsy
  • Może być abstrakcyjna (abstract)
  • Może być finalna (final)
  • Nie może zawierać ani statycznych (static) pól ani metod
  • Ma dostęp do wszystkich składowych klasy zewnętrznej (nawet tych prywatnych). I tak naprawdę w tym celu używa się tych klas. Enkapsulujemy jakąś logikę w klasie wewnętrznej, ale jednocześnie metody tej klasy wewnętrznej mają dostęp do wszystkich składowych klasy zewnętrznej.

Tego mechanizmu używa się głównie w celu uproszczenia kodu (uwaga, jeżeli nadużywasz tej funkcjonalności to kod będzie ciężki do czytania). Z tym mechanizmem języka Java są związane dwie ciekawostki:

  1. Sposób tworzenia obiektu klasy Inner. Do stworzenia tego obiektu potrzebna jest instancja klasy Outer. W metodzie method mamy taki obiekt (wskazywany przez referencję this) i tworzenie obiektu klasy Inner nie różni się niczym od tworzenia obiektu dowolnej innej klasy, ale ciekawa składnia się pojawia, gdy chcemy stworzyć obiekt gdzieś poza klasą Outer:
    public class Main {
        public static void main(String[] args) {
            Outer o = new Outer();
            Outer.Inner i = o.new Inner();
            i.foo();
        }
    }

    Jest to chyba najmniej oczywista składnia, z jaką się spotkałem programując w Javie.

  2. Wynik kompilacji. Gdy mamy plik Main.java, który zawiera klasę Main oraz klasę Outer, to w wyniku kompilacji tego kodu źródłowego dostaniemy trzy pliki z rozszerzeniem class:
    pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/member-inner-class-05
    $ javac Main.java
    
    pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/member-inner-class-05
    $ ls
     Main.class   Main.java  'Outer$Inner.class'   Outer.class

Co będzie wynikiem uruchomienia klasy Main? Sprawdźmy:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/member-inner-class-05
$ java Main
5

Widzimy, że w metodzie foo klasy Inner odwołujemy się do pola field z klasy Inner. Ale zgodnie z tym, co napisałem przed chwilą oznacza to, że mamy dostęp do pola field klasy Outer. Jak się odwołać do tego pola w klasie zewnętrznej? Przygotuj się kolejny raz na nietypową składanię:

class Outer {
    private int field = 10;
    
    void method() {
        Inner i = new Inner();
        i.foo();
    }
    
    class Inner {
        private int field = 5;
        
        void foo() {
            System.out.println(Outer.this.field);
        }
    }
}

Uruchomienie klasy Main (bez wprowadzenia jakichkolwiek zmian) da:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/member-inner-class-06
$ java Main
10

Lokalna klasa wewnętrzna

To tłumaczenie jest dużo lepsze – w oryginale mamy local inner class. Jest to klasa zdefiniowana wewnątrz metody. Popatrz na przykład:

class Outer {
    public void method() {
        int variable = 10;
        
        class Inner {
            public void method() {
                System.out.println(variable);
            }
        }
        
        Inner i = new Inner();
        i.method();
    }
}

public class Main {
    
    public static void main(String[] args) {
        Outer o = new Outer();
        o.method();
    }
}

Warto sprawdzić, co jest wynikiem kompilacji takiego pliku Main.java:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/local-inner-class-07
$ ls
 Main.class   Main.java  'Outer$1Inner.class'   Outer.class

Widzimy, że kompilator wygenerował osobny plik class dla klasy lokalnej. Takie klasy też mają kilka obostrzeń:

  • Nie możemy zdefiniować modyfikatora dostępu (z resztą tak samo jak w przypadku zmiennych lokalnych) dla klasy lokalnej
  • Klasa lokalna nie może być zdefiniowana jako statyczna (static)
  • Klasa lokalna nie może mieć składowych statycznych (static)
  • Klasa lokalna ma dostęp do wszystkich elementów składowych klasy zewnętrznej
  • Klasa lokalna ma dostęp do zmiennych lokalnych zdefiniowanych w tej samej funkcji, o ile zmienna ta jest efektywnie finalna (effectively final) czyli jej wartość nie jest zmieniana.

Ostatnie obostrzenie oznacza, że jeżeli do powyższego przykładu dodamy choć jedną instrukcję, która zmieni wartość zmiennej variable:

class Outer {
    public void method() {
        int variable = 10;
        
        class Inner {
            public void method() {
                System.out.println(variable);
            }
        }
        variable++;
        Inner i = new Inner();
        i.method();
    }
}

public class Main {
    
    public static void main(String[] args) {
        Outer o = new Outer();
        o.method();
    }
}

To cały program się nie skompiluje:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/local-inner-class-08
$ javac Main.java
Main.java:7: error: local variables referenced from an inner class must be final or effectively final
                                System.out.println(variable);
                                                   ^
1 error

Anonimowa klasa lokalna

Znów dosłowne tłumaczenie – anonymous inner class – jest całkiem zgrabne. Jest to pewien szczególny przypadek klasy lokalnej. Gdy klasa lokalna implementuje interfejs (lub rozszerza klasę abstrakcyjną) i jeżeli chcemy stworzyć tylko jeden obiekt takiej klasy (nie potrzebujemy tej klasy nazywać), tak jak w poniższym przykładzie:

abstract class Foo {
    public abstract void bar();
}

class Outer {
    public void method() {
        int variable = 10;
        
        class Inner extends Foo {
            @Override
            public void bar() {
                System.out.println(variable);
            }
        }
        
        Inner i = new Inner();
        i.bar();
    }
}

public class Main {
    
    public static void main(String[] args) {
        Outer o = new Outer();
        o.method();
    }
}

To możemy użyć klasy anonimowej:

abstract class Foo {
    public abstract void bar();
}

class Outer {
    public void method() {
        int variable = 10;
                
        Foo i = new Foo() {
            @Override
            public void bar() {
                System.out.println(variable);
            }
        };
        
        i.bar();
    }
}

public class Main {
    
    public static void main(String[] args) {
        Outer o = new Outer();
        o.method();
    }
}

Warto zwrócić uwagę na wynik kompilacji:

pawel@DESKTOP-0BB9FUV MINGW64 ~/code/defining-classes/anonymous-inner-class-10
$ ls
 Foo.class   Main.class   Main.java  'Outer$1.class'   Outer.class

Widzimy, że klasa anonimowa, ma swój plik class, z samym numerkiem – przecież nie nadaliśmy żadnej nazwy tej klasie. Taka składnia pozwala tworzyć zwięzły kod – krótszy niż w przypadku zwykłych klas lokalnych. Taka składnia ma też swoje ograniczenia:

  • Nie możemy stworzyć żadnego konstruktora
  • Nie możemy dodać kolejnych składowych publicznych (a właściwie możemy -kompilator nie zaprotestuje – ale nie będziemy mieli do nich dostępu spoza klasy anonimowej

O wyjątkowych zaletach klas anonimowych opowiem przy okazji artykułu o interfejsach funkcyjnych (a ten już wkrótce).

Klasy zagnieżdżone

W literaturze anglojęzycznej klasy wewnętrzne, które deklarujemy jako statyczne określa się mianem nested classes, czyli w dosłownym tłumaczeniu na polski – klasy zagnieżdżone. Zdaję sobie sprawę, że taka nomenklatura nie jest oczywista… I w tych wszystkich nazwach można się pogubić. Ale nie przejmuj się. Najważniejsze, żebyś wiedział o takich możliwościach Javy.

Zobacz prosty przykład:

class Outer {
    private int field01 = 1;
    private static int field02 = 2;
    
    public void foo() {
        Inner.field02 = 200;
    }
    
    static class Inner {
        private int field01 = 10;
        private static int field02 = 20;

        public void method() {
            System.out.println(field01);
            System.out.println(field02);
            System.out.println(Outer.field02);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Outer.Inner i = new Outer.Inner();
        i.method();
    }
}
  • Można powiedzieć, że umieszczamy klasę Inner w przestrzeni nazw klasy Outer – to oznacza, że do nazwy klasy Inner musimy się odnosić z użyciem nazwy klasy Outer. Zupełnie jak w metodzie main.
  • Definicję klasy możemy poprzedzić dowolnym modyfikatorem dostępu (public, protected, private)
  • Nie potrzebujemy obiektu klasy Outer do stworzenia obiektu klasy Inner.
  • Z wnętrza klasy Inner możemy się odnosić tylko do statycznych składowych klasy Outer.

Podsumowanie

Dziękuję, że doczytałeś do końca tego artykułu. Dowiedziałeś się, gdzie można definiować klasy. Z przykrością muszę stwierdzić, że w prawdziwym życiu nagminnie używam klas anonimowych i bardzo rzadko klas zagnieżdżonych. Dwa pozastałe sposoby definiowania klas wewnętrznych potraktuj jako ciekawostkę. Jeżeli użyłeś klas wewnętrznych lub lokalnych w rzeczywistym programie, daj znać w komentarzu.

Jak zwykle, cały kod z artykułu możesz znaleźć na githubie.

 

Aspirujący twórca internetowy, który zna się na programowaniu i chce się dzielić wiedzą

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

Scroll to top