Mastering OOP: Praktyczny przewodnik po dziedziczeniu, interfejsach i klasach abstrakcyjnych

Opublikowany: 2022-03-10
Szybkie podsumowanie ↬ Prawdopodobnie najgorszym sposobem nauczenia podstaw programowania jest opisanie, czym coś jest, bez wspominania, jak i kiedy tego użyć. W tym artykule Ryan M. Kay omawia trzy podstawowe koncepcje w OOP w najmniej dwuznacznych terminach, dzięki czemu możesz nigdy więcej nie zastanawiać się, kiedy użyć dziedziczenia, interfejsów lub klas abstrakcyjnych. Podane przykłady kodu są w języku Java z pewnymi odniesieniami do systemu Android, ale do śledzenia wymagana jest tylko podstawowa znajomość języka Java.

O ile wiem, rzadko spotyka się treści edukacyjne z zakresu tworzenia oprogramowania, które zapewniają odpowiednią mieszankę informacji teoretycznych i praktycznych. Jeśli miałbym zgadywać dlaczego, zakładam, że dzieje się tak dlatego, że osoby, które koncentrują się na teorii, mają tendencję do włączania się w nauczanie, a osoby, które skupiają się na praktycznych informacjach, zazwyczaj otrzymują wynagrodzenie za rozwiązywanie konkretnych problemów przy użyciu określonych języków i narzędzi.

Jest to oczywiście szerokie uogólnienie, ale jeśli przyjmiemy je krótko ze względu na argumenty, wynika z tego, że wiele osób (bynajmniej nie wszyscy), którzy przyjmują rolę nauczyciela, bywa albo biedni, albo całkowicie niezdolni. wyjaśnienia praktycznej wiedzy dotyczącej konkretnego pojęcia.

W tym artykule zrobię co w mojej mocy, aby omówić trzy podstawowe mechanizmy, które można znaleźć w większości języków programowania obiektowego (OOP): Dziedziczenie , interfejsy (inaczej protokoły ) i klasy abstrakcyjne . Zamiast dawać techniczne i złożone słowne wyjaśnienia na temat tego, czym jest każdy mechanizm, zrobię co w mojej mocy, aby skupić się na tym, co robią i kiedy ich używać.

Zanim jednak zajmę się nimi indywidualnie, chciałbym krótko omówić, co to znaczy podać teoretycznie rozsądne, ale praktycznie bezużyteczne wyjaśnienie. Mam nadzieję, że możesz wykorzystać te informacje, aby przesiać się przez różne zasoby edukacyjne i uniknąć obwiniania się, gdy coś nie ma sensu.

Więcej po skoku! Kontynuuj czytanie poniżej ↓

Różne stopnie wiedzy

Znając nazwy

Znajomość nazwy jest prawdopodobnie najbardziej płytką formą poznania. W rzeczywistości nazwa jest ogólnie użyteczna tylko w takim zakresie, w jakim jest powszechnie używana przez wiele osób w odniesieniu do tej samej rzeczy i/lub pomaga opisać tę rzecz. Niestety, jak przekonał się każdy, kto spędził czas w tej dziedzinie, wiele osób używa różnych nazw dla tej samej rzeczy (np. interfejsów i protokołów ), tych samych nazw dla różnych rzeczy (np. modułów i komponentów ) lub nazw, które są ezoteryczne dla absurdalny (np. Albo Monada ). Ostatecznie nazwy są tylko wskaźnikami (lub odniesieniami) do modeli umysłowych i mogą mieć różny stopień użyteczności.

Aby uczynić tę dziedzinę jeszcze trudniejszą do zbadania, zaryzykowałbym przypuszczenie, że dla większości osób pisanie kodu jest (lub przynajmniej było) bardzo wyjątkowym doświadczeniem. Jeszcze bardziej skomplikowane jest zrozumienie, w jaki sposób ten kod jest ostatecznie kompilowany do języka maszynowego i reprezentowany w rzeczywistości fizycznej jako seria zmieniających się w czasie impulsów elektrycznych. Nawet jeśli można przypomnieć sobie nazwy procesów, koncepcji i mechanizmów stosowanych w programie, nie ma gwarancji, że modele mentalne, które tworzymy dla takich rzeczy, są zgodne z modelami innej osoby; nie mówiąc już o tym, czy są obiektywnie dokładne.

Właśnie z tych powodów, obok tego, że nie mam naturalnie dobrej pamięci do żargonu, uważam imiona za najmniej ważny aspekt wiedzy. Nie oznacza to, że nazwy są bezużyteczne, ale w przeszłości nauczyłem się wielu wzorców projektowych i stosowałem je w swoich projektach tylko po to, by poznać powszechnie używane nazwy miesiące, a nawet lata później.

Znajomość definicji i analogii werbalnych

Definicje werbalne są naturalnym punktem wyjścia do opisu nowej koncepcji. Jednak, podobnie jak nazwy, mogą mieć różny stopień użyteczności i znaczenia; w dużej mierze zależy to od ostatecznych celów ucznia. Najczęstszym problemem, jaki widzę w definicjach werbalnych, jest przyjęta wiedza zazwyczaj w formie żargonu.

Załóżmy na przykład, że mam wyjaśnić, że wątek jest bardzo podobny do procesu , z wyjątkiem tego, że wątki zajmują tę samą przestrzeń adresową danego procesu . Komuś, kto jest już zaznajomiony z procesami i przestrzeniami adresowymi , zasadniczo stwierdziłem, że wątki mogą być powiązane z ich rozumieniem procesu (tj. mają wiele takich samych cech), ale można je rozróżnić na podstawie odrębnej cechy.

Dla kogoś, kto nie posiada tej wiedzy, w najlepszym razie nie miałem żadnego sensu, aw najgorszym sprawiłem, że uczeń poczuł się w jakiś sposób nieadekwatny, ponieważ nie wiedział tego, co zakładałem , że powinien wiedzieć. Szczerze mówiąc, jest to dopuszczalne, jeśli twoi uczniowie naprawdę powinni posiadać taką wiedzę (na przykład nauczanie studentów studiów magisterskich lub doświadczonych programistów), ale uważam, że jest to monumentalna porażka w jakimkolwiek materiale wprowadzającym.

Często bardzo trudno jest podać dobrą werbalną definicję pojęcia, które nie przypomina niczego, co uczący się widział wcześniej. W tym przypadku bardzo ważne jest, aby nauczyciel wybrał analogię, która prawdopodobnie jest znana przeciętnemu człowiekowi, a także istotna, ponieważ przekazuje wiele z tych samych cech pojęcia.

Na przykład niezwykle ważne jest, aby programista zrozumiał, co to znaczy, gdy jednostki oprogramowania (różne części programu) są ściśle lub luźno powiązane . Młodszy stolarz budując szopę ogrodową może pomyśleć, że szybciej i łatwiej ją złożyć za pomocą gwoździ, a nie wkrętów. Dzieje się tak aż do momentu popełnienia błędu lub zmiany projektu szopy ogrodowej, która wymaga przebudowy części szopy.

W tym momencie decyzja o użyciu gwoździ do ścisłego połączenia części szopy ogrodowej utrudniła i prawdopodobnie spowolniła cały proces budowy, a wyrywanie gwoździ młotkiem grozi uszkodzeniem konstrukcji. Z drugiej strony montaż śrub może zająć trochę więcej czasu, ale można je łatwo usunąć i stwarzać niewielkie ryzyko uszkodzenia pobliskich części szopy. To właśnie rozumiem przez luźno sprzężone . Oczywiście są przypadki, w których naprawdę wystarczy gwóźdź, ale decyzja powinna być podyktowana krytycznym myśleniem i doświadczeniem.

Jak omówię szczegółowo w dalszej części, istnieją różne mechanizmy łączenia ze sobą części programu, które zapewniają różne stopnie sprzężenia ; tak jak gwoździe i śruby . Chociaż moja analogia mogła pomóc ci zrozumieć, co oznacza ten krytycznie ważny termin, nie dałem ci żadnego pomysłu, jak zastosować go poza kontekstem budowania szopy ogrodowej. To prowadzi mnie do najważniejszego rodzaju poznania i klucza do głębokiego zrozumienia niejasnych i trudnych pojęć w każdej dziedzinie badań; chociaż w tym artykule pozostaniemy przy pisaniu kodu.

Wiedząc w kodzie

Moim zdaniem, stricte przy tworzeniu oprogramowania, najważniejszą formą poznania koncepcji jest umiejętność wykorzystania jej w kodzie działającej aplikacji. Tę formę poznania można osiągnąć po prostu pisząc dużo kodu i rozwiązując wiele różnych problemów; nie trzeba podawać nazw żargonowych i definicji słownych.

Z własnego doświadczenia przypominam sobie rozwiązywanie problemu komunikacji ze zdalną bazą danych i lokalną bazą danych za pomocą jednego interfejsu (będziesz wiedział, co to znaczy, niedługo, jeśli jeszcze tego nie zrobiłeś); zamiast klienta (niezależnie od klasy, która komunikuje się z interfejsem ), który musi jawnie wywołać zdalną i lokalną (lub nawet testową bazę danych). W rzeczywistości klient nie miał pojęcia, co kryje się za interfejsem, więc nie musiałem go zmieniać niezależnie od tego, czy działał w aplikacji produkcyjnej, czy w środowisku testowym. Mniej więcej rok po rozwiązaniu tego problemu natknąłem się na termin „Wzór elewacji”, a niedługo po termin „Wzorzec repozytorium”, które są nazwami, których ludzie używają do rozwiązania opisanego wcześniej.

Miejmy nadzieję, że cała ta preambuła ma na celu naświetlenie niektórych błędów, które są najczęściej popełniane w wyjaśnianiu takich tematów, jak dziedziczenie , interfejsy i klasy abstrakcyjne . Z tych trzech dziedziczenie jest prawdopodobnie najłatwiejsze w użyciu i zrozumieniu. Z mojego doświadczenia, zarówno jako studenta programowania, jak i nauczyciela, pozostałe dwa są prawie zawsze problemem dla uczniów, chyba że zwraca się szczególną uwagę na unikanie błędów omówionych wcześniej. Od tego momentu dołożę wszelkich starań, aby te tematy były tak proste, jak powinny, ale nie prostsze.

Uwaga o przykładach

Będąc najbardziej biegły w tworzeniu aplikacji mobilnych na Androida, posłużę się przykładami zaczerpniętymi z tej platformy, aby móc uczyć Cię tworzenia aplikacji GUI jednocześnie z wprowadzeniem funkcji językowych Java. Nie będę jednak wdawał się w szczegóły, aby przykłady były niezrozumiałe dla kogoś, kto pobieżnie rozumie Java EE, Swing czy JavaFX. Moim ostatecznym celem przy omawianiu tych tematów jest pomoc w zrozumieniu, co one oznaczają w kontekście rozwiązywania problemu w niemal każdym zastosowaniu.

Chciałbym Cię również ostrzec, drogi Czytelniku, że czasami może się wydawać, że niepotrzebnie podchodzę filozoficznie i pedantycznie do konkretnych słów i ich definicji. Powodem tego jest to, że naprawdę istnieje głęboka filozoficzna podstawa wymagana do zrozumienia różnicy między czymś konkretnym (rzeczywistym) a czymś, co jest abstrakcyjne (mniej szczegółowe niż rzeczywista rzecz). To zrozumienie ma zastosowanie do wielu rzeczy spoza dziedziny informatyki, ale szczególnie ważne jest, aby każdy programista zrozumiał naturę abstrakcji . W każdym razie, jeśli moje słowa cię zawiodą, miejmy nadzieję, że przykłady w kodzie nie.

Dziedziczenie i implementacja

Jeśli chodzi o budowanie aplikacji z graficznym interfejsem użytkownika (GUI), dziedziczenie jest prawdopodobnie najważniejszym mechanizmem umożliwiającym szybkie zbudowanie aplikacji.

Chociaż istnieje mniej zrozumiała korzyść z używania dziedziczenia , która zostanie omówiona później, podstawową korzyścią jest współdzielenie implementacji między klasami . Słowo „wdrożenie”, przynajmniej na potrzeby tego artykułu, ma odmienne znaczenie. Aby podać ogólną definicję tego słowa w języku angielskim, mógłbym powiedzieć, że wdrożenie czegoś to urzeczywistnienie tego .

Aby podać techniczną definicję specyficzną dla rozwoju oprogramowania, mógłbym powiedzieć, że implementacja kawałka oprogramowania to napisanie konkretnych linii kodu, które spełniają wymagania tego kawałka oprogramowania. Załóżmy na przykład, że piszę metodę sumującą : private double sum(double first, double second){

 private double sum(double first, double second){ //TODO: implement }

Powyższy fragment, mimo że zrobiłem to tak daleko, jak napisanie typu zwracanego ( double ) i deklaracji metody, która określa argumenty ( first, second ) oraz nazwę, która może być użyta do wywołania tej metody ( sum ), ma nie została wdrożona . Aby go zaimplementować , musimy uzupełnić treść metody w następujący sposób:

 private double sum(double first, double second){ return first + second; }

Oczywiście pierwszy przykład nie skompilowałby się, ale za chwilę zobaczymy, że interfejsy to sposób, w jaki możemy pisać tego rodzaju niezaimplementowane funkcje bez błędów.

Dziedziczenie w Javie

Przypuszczalnie, jeśli czytasz ten artykuł, przynajmniej raz użyłeś słowa kluczowego extends Java. Mechanizmy tego słowa kluczowego są proste i najczęściej opisywane na przykładach dotyczących różnych rodzajów zwierząt lub kształtów geometrycznych; Dog and Cat rozszerzają Animal i tak dalej. Zakładam, że nie muszę ci wyjaśniać szczątkowej teorii typów, więc przejdźmy od razu do głównej korzyści dziedziczenia w Javie za pomocą słowa kluczowego extends .

Zbudowanie aplikacji „Hello World” opartej na konsoli w Javie jest bardzo proste. Zakładając, że posiadasz kompilator Java ( javac ) i środowisko wykonawcze ( jre ), możesz napisać klasę zawierającą główną funkcję, jak na przykład:

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

Budowanie aplikacji GUI w Javie na prawie każdej z jej głównych platform (Android, Enterprise/Web, Desktop), z niewielką pomocą IDE w celu wygenerowania szkieletu/boilerplate kodu nowej aplikacji, jest również stosunkowo łatwe dzięki extends słowo kluczowe.

Załóżmy, że mamy układ XML o nazwie activity_main.xml (zwykle tworzymy interfejs użytkownika deklaratywnie w systemie Android za pomocą plików układu) zawierający TextView (jak etykieta tekstowa) o nazwie tvDisplay :

 <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android: android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" /> </FrameLayout>

Załóżmy również, że chcielibyśmy, aby tvDisplay powiedział „Hello World!” Aby to zrobić, wystarczy napisać klasę, która używa słowa kluczowego extends do dziedziczenia z klasy Activity :

 import android.app.Activity; import android.os.Bundle; import android.widget.TextView; public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ((TextView)findViewById(R.id.tvDisplay)).setText("Hello World"); }

Efekt dziedziczenia implementacji klasy Activity najlepiej docenić rzucając okiem na jej kod źródłowy. Szczerze wątpię, aby Android stał się dominującą platformą mobilną, gdyby trzeba było zaimplementować choćby niewielką część z ponad 8000 linii potrzebnych do interakcji z systemem, tylko po to, aby wygenerować proste okno z tekstem. Dziedziczenie to to, co pozwala nam nie musieć przebudowywać od zera struktury Androida lub dowolnej platformy, z którą akurat pracujesz.

Dziedziczenie można wykorzystać do abstrakcji

Dziedziczenie jest stosunkowo proste do zrozumienia, o ile można go użyć do współdzielenia implementacji między klasami. Istnieje jednak inny ważny sposób wykorzystania dziedziczenia , który jest koncepcyjnie powiązany z interfejsami i klasami abstrakcyjnymi, o których będziemy mówić wkrótce.

Jeśli łaska, przypuśćmy przez chwilę, że abstrakcja, użyta w najogólniejszym sensie, jest mniej szczegółową reprezentacją rzeczy . Zamiast określać to za pomocą długiej definicji filozoficznej, postaram się wskazać, jak abstrakcje działają w życiu codziennym, a wkrótce potem omówię je wyraźnie w kontekście tworzenia oprogramowania.

Załóżmy, że podróżujesz do Australii i zdajesz sobie sprawę, że w regionie, który odwiedzasz, występuje szczególnie duże zagęszczenie węży tajpanów śródlądowych (podobno są one dość trujące). Postanawiasz zajrzeć do Wikipedii, aby dowiedzieć się więcej na ich temat, przeglądając zdjęcia i inne informacje. Robiąc to, jesteś teraz w pełni świadomy szczególnego rodzaju węża, którego nigdy wcześniej nie widziałeś.

Abstrakcje, idee, modele lub jakkolwiek chcesz je nazwać, są mniej szczegółowymi reprezentacjami rzeczy. Ważne jest, aby były mniej szczegółowe niż prawdziwe, ponieważ prawdziwy wąż może cię ugryźć; obrazy na stronach Wikipedii zazwyczaj tego nie robią. Abstrakcje są również ważne, ponieważ zarówno komputery, jak i ludzkie mózgi mają ograniczoną zdolność do przechowywania, komunikowania i przetwarzania informacji. Posiadanie wystarczającej ilości szczegółów, aby wykorzystać te informacje w praktyczny sposób, bez zajmowania zbyt dużej ilości miejsca w pamięci, umożliwia komputerom i ludzkim mózgom rozwiązywanie problemów.

Aby powiązać to z powrotem z dziedziczeniem , wszystkie trzy główne tematy, które tutaj omawiam, można wykorzystać jako abstrakcje lub mechanizmy abstrakcji . Załóżmy, że w pliku układu naszej aplikacji „Hello World” postanawiamy dodać ImageView , Button i ImageButton :

 <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <Button android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageButton android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageView android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout>

Załóżmy również, że nasza aktywność zaimplementowała View.OnClickListener do obsługi kliknięć:

 public class MainActivity extends Activity implements View.OnClickListener { private Button b; private ImageButton ib; private ImageView iv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //... b = findViewById(R.id.imvDisplay).setOnClickListener(this); ib = findViewById(R.id.btnDisplay).setOnClickListener(this); iv = findViewById(R.id.imbDisplay).setOnClickListener(this); } @Override public void onClick(View view) { final int id = view.getId(); //handle click based on id... } }

Kluczową zasadą jest tutaj to, że Button , ImageButton i ImageView dziedziczą z klasy View . W rezultacie ta funkcja onClick może odbierać zdarzenia kliknięć z różnych (choć powiązanych hierarchicznie) elementów interfejsu użytkownika, odwołując się do nich jako ich mniej szczegółowej klasy nadrzędnej. Jest to o wiele wygodniejsze niż konieczność napisania odrębnej metody obsługi każdego rodzaju widżetu na platformie Android (nie wspominając o widżetach niestandardowych).

Interfejsy i abstrakcja

Być może poprzedni przykład kodu nie był dla Ciebie inspirujący, nawet jeśli rozumiesz, dlaczego go wybrałem. Możliwość współdzielenia implementacji w hierarchii klas jest niezwykle przydatna i uważam, że jest to główna użyteczność dziedziczenia . Jeśli chodzi o umożliwienie nam traktowania zbioru klas, które mają wspólną klasę rodzica jako równego typu (tj. jako klasę rodzica ), ta cecha dziedziczenia ma ograniczone zastosowanie.

Ograniczając się, mówię o wymogu, aby klasy potomne znajdowały się w tej samej hierarchii klas, aby można było się do nich odwoływać lub nazywać klasą nadrzędną. Innymi słowy, dziedziczenie jest bardzo restrykcyjnym mechanizmem abstrakcji . W rzeczywistości, jeśli przypuszczam, że abstrakcja jest spektrum, które porusza się między różnymi poziomami szczegółowości (lub informacji), mógłbym powiedzieć, że dziedziczenie jest najmniej abstrakcyjnym mechanizmem abstrakcji w Javie.

Zanim przejdę do omawiania interfejsów , chciałbym wspomnieć, że w Javie 8 dodano do interfejsów dwie funkcje zwane metodami domyślnymi i metodami statycznymi . W końcu je omówię, ale na razie chciałbym, żebyśmy udawać, że nie istnieją. Ma to na celu ułatwienie mi wyjaśnienia podstawowego celu korzystania z interfejsu , który początkowo był i prawdopodobnie nadal jest najbardziej abstrakcyjnym mechanizmem abstrakcji w Javie .

Mniej szczegółów oznacza więcej wolności

W sekcji o dziedziczeniu podałem definicję słowa implementacja , które miało kontrastować z innym terminem, do którego teraz przejdziemy. Żeby było jasne, nie dbam o same słowa ani o to, czy zgadzasz się z ich użyciem; tylko, że rozumiesz, na co konceptualnie wskazują.

Podczas gdy dziedziczenie jest przede wszystkim narzędziem do współdzielenia implementacji przez zestaw klas, możemy powiedzieć, że interfejsy są przede wszystkim mechanizmem współdzielenia zachowania przez zestaw klas. Zachowanie użyte w tym sensie jest tak naprawdę tylko nietechnicznym słowem na określenie metod abstrakcyjnych . Metoda abstrakcyjna to metoda , która w rzeczywistości nie może zawierać treści metody :

 public interface OnClickListener { void onClick(View v); }

Naturalną reakcją dla mnie i wielu osób, które prowadziłem, po pierwszym spojrzeniu na interfejs , było zastanowienie się, jaka może być użyteczność udostępniania tylko typu zwracanego , nazwy metody i listy parametrów . Z pozoru wygląda to na świetny sposób na stworzenie dodatkowej pracy dla siebie lub kogoś innego, kto mógłby pisać klasę implements interfejs . Odpowiedź jest taka, że ​​interfejsy są idealne w sytuacjach, w których chcesz, aby zestaw klas zachowywał się w ten sam sposób (tj. posiadają te same publiczne abstrakcyjne metody ), ale oczekujesz, że zaimplementują to zachowanie na różne sposoby.

Weźmy prosty, ale trafny przykład, platforma Android posiada dwie klasy, które służą przede wszystkim do tworzenia i zarządzania częścią interfejsu użytkownika: Activity i Fragment . Wynika z tego, że te klasy będą bardzo często wymagały nasłuchiwania zdarzeń, które pojawiają się, gdy widżet zostanie kliknięty (lub w inny sposób wchodzący w interakcję z użytkownikiem). Ze względu na argument, poświęćmy chwilę, aby zrozumieć, dlaczego dziedziczenie prawie nigdy nie rozwiąże takiego problemu:

 public class OnClickManager { public void onClick(View view){ //Wait a minute... Activities and Fragments almost never //handle click events exactly the same way... } }

Nie tylko spowodowanie, że nasze działania i fragmenty dziedziczą po OnClickManager , uniemożliwiłyby obsługę zdarzeń w inny sposób, ale kickerem jest to, że nie moglibyśmy nawet tego zrobić, gdybyśmy chcieli. Zarówno Activity , jak i Fragment już rozszerzają klasę nadrzędną , a Java nie zezwala na wiele klas nadrzędnych . Naszym problemem jest to, że chcemy, aby zestaw klas zachowywał się w ten sam sposób, ale musimy mieć elastyczność w zakresie implementacji tego zachowania przez klasę. To prowadzi nas z powrotem do wcześniejszego przykładu View.OnClickListener :

 public interface OnClickListener { void onClick(View v); }

To jest rzeczywisty kod źródłowy (który jest zagnieżdżony w klasie View ), a te kilka wierszy pozwala nam zapewnić spójne zachowanie w różnych widżetach ( Views ) i kontrolerach interfejsu użytkownika ( Activity, Fragments itp . ).

Abstrakcja promuje luźne sprzężenie

Mam nadzieję, że odpowiedziałem na ogólne pytanie, dlaczego interfejsy istnieją w Javie; wśród wielu innych języków. Z jednej perspektywy są one tylko sposobem współdzielenia kodu między klasami, ale są celowo mniej szczegółowe, aby umożliwić różne implementacje . Ale tak jak dziedziczenie może być używane zarówno jako mechanizm udostępniania kodu, jak i abstrakcji (choć z ograniczeniami hierarchii klas), wynika z tego, że interfejsy zapewniają bardziej elastyczny mechanizm abstrakcji .

We wcześniejszej części tego artykułu wprowadziłem temat luźnego/ciasnego połączenia przez analogię do różnicy między użyciem gwoździ i śrub do zbudowania pewnego rodzaju konstrukcji. Podsumowując, podstawową ideą jest to, że będziesz chciał użyć śrub w sytuacjach, w których prawdopodobnie nastąpi zmiana istniejącej konstrukcji (co może być wynikiem naprawiania błędów, zmian projektowych itp.). Gwoździe są dobre w użyciu, gdy wystarczy połączyć ze sobą części konstrukcji i nie martwisz się szczególnie o ich rozebranie w najbliższej przyszłości.

Gwoździe i śruby mają być analogiczne do odniesień konkretnych i abstrakcyjnych (obowiązuje również termin zależności ) między klasami. Aby nie było zamieszania, poniższa próbka pokaże, co mam na myśli:

 class Client { private Validator validator; private INetworkAdapter networkAdapter; void sendNetworkRequest(String input){ if (validator.validateInput(input)) { try { networkAdapter.sendRequest(input); } catch (IOException e){ //handle exception } } } } class Validator { //...validation logic boolean validateInput(String input){ boolean isValid = true; //...change isValid to false based on validation logic return isValid; } } interface INetworkAdapter { //... void sendRequest(String input) throws IOException; }

Tutaj mamy klasę o nazwie Client , która posiada dwa rodzaje referencji . Zauważ, że zakładając, że Client nie ma nic wspólnego z tworzeniem swoich referencji (tak naprawdę nie powinien), jest on oddzielony od szczegółów implementacji jakiejkolwiek konkretnej karty sieciowej.

Istnieje kilka ważnych implikacji tego luźnego połączenia . Na początek mogę zbudować Client w całkowitej izolacji od dowolnej implementacji INetworkAdapter . Wyobraź sobie przez chwilę, że pracujesz w zespole dwóch programistów; jeden do zbudowania przodu, drugi do zbudowania tyłu. Tak długo, jak obaj programiści są świadomi interfejsów, które łączą ze sobą ich klasy, mogą kontynuować pracę praktycznie niezależnie od siebie.

Po drugie, co jeśli powiem wam, że obaj programiści mogli zweryfikować, czy ich poszczególne implementacje działają prawidłowo, również niezależnie od postępów innych? Jest to bardzo proste w przypadku interfejsów; po prostu zbuduj Test Double , który implements odpowiedni interfejs :

 class FakeNetworkAdapter implements INetworkAdapter { public boolean throwError = false; @Override public void sendRequest(String input) throws IOException { if (throwError) throw new IOException("Test Exception"); } }

Zasadniczo można zaobserwować, że praca z abstrakcyjnymi odniesieniami otwiera drzwi do zwiększonej modułowości, testowalności i kilku bardzo potężnych wzorców projektowych, takich jak Wzorzec Fasady , Wzorzec Obserwatora i nie tylko. Mogą również pozwolić programistom znaleźć szczęśliwą równowagę między projektowaniem różnych części systemu w oparciu o zachowanie ( Program To An Interface ), bez zagłębiania się w szczegóły implementacji .

Ostatni punkt dotyczący abstrakcji

Abstrakcje nie istnieją w taki sam sposób, jak konkret . Jest to odzwierciedlone w języku Java Programming przez fakt, że klasy abstrakcyjne i interfejsy nie mogą być tworzone.

Na przykład to na pewno nie skompiluje się:

 public class Main extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) { //ERROR x2: Foo f = new Foo(); Bar b = new Bar() } private abstract class Foo{} private interface Bar{} }

W rzeczywistości pomysł, aby oczekiwać, że niezaimplementowany interfejs lub klasa abstrakcyjna będą działać w czasie wykonywania, ma tyle samo sensu, co oczekiwanie, że mundur UPS będzie pływał wokół dostarczania paczek. Za abstrakcją musi kryć się coś konkretnego, aby było użyteczne; nawet jeśli klasa wywołująca nie musi wiedzieć, co faktycznie kryje się za odwołaniami abstrakcyjnymi .

Klasy abstrakcyjne: łączenie wszystkiego w całość

Jeśli dotarłeś tak daleko, to z radością mogę powiedzieć, że nie mam więcej filozoficznych stycznych ani zwrotów żargonowych do przetłumaczenia. Mówiąc najprościej, klasy abstrakcyjne są mechanizmem udostępniania implementacji i zachowania w zestawie klas. Teraz przyznam od razu, że nie często używam klas abstrakcyjnych . Mimo to mam nadzieję, że pod koniec tego rozdziału będziecie dokładnie wiedzieć, kiedy zostaną wezwani.

Studium przypadku dziennika treningów

Mniej więcej rok po tworzeniu aplikacji na Androida w Javie przebudowywałem swoją pierwszą aplikację na Androida od podstaw. Pierwsza wersja była rodzajem przerażającej masy kodu, której można oczekiwać od programisty-samouka z niewielkimi wskazówkami. Kiedy chciałem dodać nową funkcjonalność, stało się jasne, że ściśle powiązana konstrukcja, którą zbudowałem wyłącznie za pomocą gwoździ , jest tak niemożliwa do utrzymania, że ​​muszę ją całkowicie przebudować.

Aplikacja była dziennikiem treningów, który został zaprojektowany, aby umożliwić łatwe rejestrowanie treningów i możliwość wyprowadzania danych z poprzedniego treningu w postaci pliku tekstowego lub graficznego. Nie wchodząc w szczegóły, ustrukturyzowałem modele danych aplikacji w taki sposób, aby istniał obiekt Workout , który składał się z kolekcji obiektów Exercise (między innymi polami nieistotnymi w tej dyskusji).

Kiedy wdrażałem funkcję wyprowadzania danych treningowych na jakiś rodzaj nośnika wizualnego, zdałem sobie sprawę, że mam do czynienia z problemem: różne rodzaje ćwiczeń wymagają różnych rodzajów wyników tekstowych.

Aby dać ci przybliżony pomysł, chciałem zmienić wyniki w zależności od rodzaju ćwiczenia, tak jak:

  • Sztanga: 10 powtórzeń @ 100 LBS
  • Hantle: 10 powtórzeń @ 50 funtów x2
  • Masa ciała: 10 powtórzeń @ Masa ciała
  • Masa ciała +: 10 powtórzeń @ Masa ciała + 45 LBS
  • Czas: 60 SEC @ 100 LBS

Zanim przejdę dalej, zauważ, że istniały inne typy (ćwiczenie może być skomplikowane) i że kod, który pokażę, został skrócony i zmieniony, aby ładnie pasował do artykułu.

Zgodnie z moją wcześniejszą definicją, celem napisania klasy abstrakcyjnej jest zaimplementowanie wszystkiego (nawet stanu , takiego jak zmienne i stałe ), co jest wspólne dla wszystkich klas potomnych w klasie abstrakcyjnej . Następnie dla wszystkiego, co zmienia się we wspomnianych klasach potomnych , utwórz metodę abstrakcyjną :

 abstract class Exercise { private final String type; protected final String name; protected final int[] repetitionsOrTime; protected final double[] weight; protected static final String POUNDS = "LBS"; protected static final String SECONDS = "SEC "; protected static final String REPETITIONS = "REPS "; public Exercise(String type, String name, int[] repetitionsOrTime, double[] weight) { this.type = type; this.name = name; this.repetitionsOrTime = repetitionsOrTime; this.weight = weight; } public String getFormattedOutput(){ StringBuilder sb = new StringBuilder(); sb.append(name); sb.append("\n"); getSetData(sb); sb.append("\n"); return sb.toString(); } /** * Append data appropriately based on Exercise type * @param sb - StringBuilder to Append data to */ protected abstract void getSetData(StringBuilder sb); //...Getters }

Mogę mówić o rzeczach oczywistych, ale jeśli masz jakiekolwiek pytania dotyczące tego, co powinno lub nie powinno być zaimplementowane w klasie abstrakcyjnej , kluczem jest przyjrzenie się dowolnej części implementacji , która została powtórzona we wszystkich klasach potomnych.

Teraz, gdy ustaliliśmy, co jest wspólne dla wszystkich ćwiczeń, możemy zacząć tworzyć klasy potomne ze specjalizacjami dla każdego rodzaju wyjścia String:

Ćwiczenie ze sztangą:

 class BarbellExercise extends Exercise { public BarbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append("\n"); } } }

Ćwiczenie z hantlami:

 class DumbbellExercise extends Exercise { private static final String TIMES_TWO = "x2"; public DumbbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append(TIMES_TWO); sb.append("\n"); } } }

Ćwiczenie na masę ciała:

 class BodyweightExercise extends Exercise { private static final String BODYWEIGHT = "Bodyweight"; public BodyweightExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(BODYWEIGHT); sb.append("\n"); } } }

Jestem pewien, że niektórzy wnikliwi czytelnicy znajdą rzeczy, które można by wyabstrahować w bardziej efektywny sposób, ale celem tego przykładu (uproszczonego z oryginalnego źródła) jest zademonstrowanie ogólnego podejścia. Oczywiście żaden artykuł o programowaniu nie byłby kompletny bez czegoś, co można wykonać. Istnieje kilka internetowych kompilatorów Java, których możesz użyć do uruchomienia tego kodu, jeśli chcesz go przetestować (chyba że masz już IDE):

 public class Main { public static void main(String[] args) { //Note: I actually used another nested class called a "Set" instead of an Array //to represent each Set of an Exercise. int[] reps = {10, 10, 8}; double[] weight = {70.0, 70.0, 70.0}; Exercise e1 = new BarbellExercise( "Barbell", "Barbell Bench Press", reps, weight ); Exercise e2 = new DumbbellExercise( "Dumbbell", "Dumbbell Bench Press", reps, weight ); Exercise e3 = new BodyweightExercise( "Bodyweight", "Push Up", reps, weight ); System.out.println( e1.getFormattedOutput() + e2.getFormattedOutput() + e3.getFormattedOutput() ); } }

Executing this toy application yields the following output: Barbell Bench Press

 10 REPS @ 70.0LBS 10 REPS @ 70.0LBS 8 REPS @ 70.0LBS Dumbbell Bench Press 10 REPS @ 70.0LBSx2 10 REPS @ 70.0LBSx2 8 REPS @ 70.0LBSx2 Push Up 10 REPS @ Bodyweight 10 REPS @ Bodyweight 8 REPS @ Bodyweight

Further Considerations

Earlier, I mentioned that there are two features of Java interfaces (as of Java 8) which are decidedly geared towards sharing implementation , as opposed to behavior . These features are known as Default Methods and Static Methods .

I have decided not to go into detail on these features for the reason that they are most typically used in mature and/or large code bases where a given interface has many inheritors. Despite the fact that this is meant to be an introductory article, and I still encourage you to take a look at these features eventually, even though I am confident that you will not need to worry about them just yet.

I would also like to mention that there are other ways to share implementation across a set of classes (or even static methods ) in a Java application that does not require inheritance or abstraction at all. For example, suppose you have some implementation which you expect to use in a variety of different classes, but does not necessarily make sense to share via inheritance . A common pattern in Java is to write what is known as a Utility class, which is a simple class containing the requisite implementation in a static method :

 public class TimeConverterUtil { /** * Accepts an hour (0-23) and minute (0-59), then attempts to format them into an appropriate * format such as 12, 30 -> 12:30 pm */ public static String convertTime (int hour, int minute){ String unformattedTime = Integer.toString(hour) + ":" + Integer.toString(minute); DateFormat f1 = new SimpleDateFormat("HH:mm"); Date d = null; try { d = f1.parse(unformattedTime); } catch (ParseException e) { e.printStackTrace(); } DateFormat f2 = new SimpleDateFormat("h:mm a"); return f2.format(d).toLowerCase(); } }

Using this static method in an external class (or another static method ) looks like this:

 public class Main { public static void main(String[] args){ //... String time = TimeConverterUtil.convertTime(12, 30); //... } }

Cheat Sheet

We have covered a lot of ground in this article, so I would like to spend a moment summarizing the three main mechanisms based on what problems they solve. Since you should possess a sufficient understanding of the terms and ideas I have either introduced or redefined for the purposes of this article, I will keep the summaries brief.

I Want A Set Of Child Classes To Share Implementation

Classic inheritance , which requires a child class to inherit from a parent class , is a very simple mechanism for sharing implementation across a set of classes. An easy way to decide if some implementation should be pulled into a parent class , is to see whether it is repeated in a number of different classes line for line. The acronym DRY ( Don't Repeat Yourself ) is a good mnemonic device to watch out for this situation.

While coupling child classes together with a common parent class can present some limitations, a side benefit is that they can all be referenced as the parent class , which provides a limited degree of abstraction .

I Want A Set Of Classes To Share Behavior

Sometimes, you want a set of classes to be capable of possessing certain abstract methods (referred to as behavior ), but you do not expect the implementation of that behavior to be repeated across inheritors.

By definition, Java interfaces may not contain any implementation (except for Default and Static Methods ), but any class which implements an interface , must supply an implementation for all abstract methods, otherwise, the code will not compile. This provides a healthy measure of flexibility and restriction on what is actually shared and does not require the inheritors to be of the same class hierarchy .

I Want A Set Of Child Classes To Share Behavior And Implementation

Although I do not find myself using abstract classes all over the place, they are perfect for situations when you require a mechanism for sharing both behavior and implementation across a set of classes. Anything which will be repeated across inheritors may be implemented directly in the abstract class , and anything which requires flexibility may be specified as an abstract method .