Matt Carroll
Matt CarrollFounder @ SuperDeclarative!

Jak rozumieć i korzystać z obiektów w Javie

Sprawdź, kiedy obiekty Javy zachowują się jak struktury danych, a kiedy jak obiekty OOP, oraz dowiedz się, jak wykorzystać to na swoją korzyść.
22.12.20207 min
Jak rozumieć i korzystać z obiektów w Javie

W Javie obiekty rozumiemy dwojako: jako koncepcję oraz jako klasę. Ten pierwszy przypadek jest ważniejszy. Koncepcję obiektów znamy dzięki programowaniu obiektowemu. Tamtejsze obiekty zawierają w sobie stan i wystawiają w sposób publiczny pewne zachowanie. 

Koncepcja obiektów nie ogranicza się do jednego języka. Właściwie to występują one w językach opartych na klasach (np. Java, C#, Ruby, Python) oraz w językach prototypowych np. JavaScript. Java wprowadziła jednak własną klasę o nazwie Object. Obiekty Javy są zatem instancjami takiej klasy (włączając w tym wszystkie podklasy). Są one jednak tworami języka, a nie czymś wynikającym z koncepcji. 

Czy obiekty w Javie są zatem obiektami OOP? To zależy od okoliczności. W tym artykule przyjrzymy się użyciu struktur danych Object w zestawieniu z obiektami programowania obiektowego.  

Struktury Danych

Wyobraźmy sobie strukturę danych, która reprezentuje jakąś osobę: mamy imię, nazwisko oraz numer telefonu. Jak taka struktura wyglądałaby w językach proceduralnych?

Struktura w C:

struct Person {
  char firstName[20];
  char lastName[20];
  char phoneNumber[10];
};


Struktura w Pascalu: 

type
Person = record
firstName : string;
lastName : string;
phoneNumber : string;
end


A w Javie taka sama struktura wyglądałaby następująco:

public class Person {
  public String firstName;
  public String lastName;
  public String phoneNumber;
}


Technicznie rzecz biorąc, to ta struktura danych różni się w Javie od tych, które widzimy w C i Pascalu. A to dlatego, że struktura w Javie to klasa, a w C i Pascalu mamy struct i rekord. 

Zastanówmy się jednak, czy klasa Person różni się funkcjonalnie od struktury C, czy rekordu Pascala? Otóż nie. Funkcjonalnie jest to dokładnie to samo. Wszystkie 3 struktury danych zapewniają 3 pola tekstowe, które można odczytać lub zapisać.

Pomimo że Person będzie obiektem Javy, to zachowuje się jak struktura danych. Obiekt Person istnieje po to, aby organizować niektóre dane w jedną całość, którą można potem przekazywać i zarządzać jako całość - tak jak struktura C i rekord Pascala.

A co gdyby obiekt Person wyglądał następująco:

public class Person {
  private String mFirstName;
  private String mLastName;
  private String mPhoneNumber;
  public String getFirstName() {
    return mFirstName;
  }
  public void setFirstName(String firstName) {
    mFirstName = firstName;
  }
  public String getLastName() {
    return mLastName;
  }
  public void setLastName(String lastName) {
    mLastName = lastName;
  }
  public String getPhoneNumber() {
    return mPhoneNumber;
  }
  public void setPhoneNumber(String phoneNumber) {
    mPhoneNumber = phoneNumber;
  }
}


Czy obiekt Javy jest prawdziwym obiektem? Ma on w końcu prywatne dane i metody publiczne. To musi być obiekt OOP, prawda? Nawet z publicznymi getterami i setterami, Person to nadal struktura danych. Czy zmienił się cel i zachowanie tego obiektu?

Nie. Obiekt Person nadal byłby używany w ten sam sposób, jak jego poprzednik z właściwościami publicznymi. Wersja getter/setter obiektu Person nadal funkcjonuje jak struktura danych. A co jeśli zaczniemy dodawać jakieś zachowanie do Person:

public class Person {
  //... same getters/setters as before, except one:
  public void setPhoneNumber(String phoneNumber) throws FormatException {
    validatePhoneNumber(phoneNumber);
    mPhoneNumber = phoneNumber;
  }
  private void validatePhoneNumber(String phoneNumber) throws FormatException {
    // Do validation here to ensure we have a legit phone number.
    // Throw an exception if its invalid.
  }
}


Czy teraz w końcu mamy prawdziwy obiekt OOP? Najnowsza wersja Person zawiera zachowanie walidacyjne, a nawet używa prywatnej metody do implementacji walidacji. Niestety, nie jest to nawet w pełni obiekt OOP. To już coś bardziej na wzór Frankensteina. Walidacja numeru telefonu tak naprawdę nie jest zachowaniem, a usługą, czyli tym, czym powinny być obiekty OOP.

Mamy te wszystkie metody, które służą do zapisu i odczytu. Mimo to nie służą one tak naprawdę do opakowania stanu, nie ma tu też jakiegoś szczególnego zachowania. Person nadal jest przede wszystkim strukturą danych, która wystawia cały swój stan, nic nie robi i jest używana jako konkretny typ w całej bazie kodu.

A więc bez względu na te wszystkie kosmetyczne zmiany, Person jest strukturą danych, a nie obiektem OOP.

Obiekty OOP

Jak wygląda obiekt OOP? Jak usługa. Jest to konstrukcja, która coś robi - zachowuje się i działa. Struktura danych Person zawiera imię, nazwisko i numer telefonu. Natomiast obiekt Person wykonuje rzeczy. Oto kilka przykładów obiektów OOP:

public interface PhoneNumberValidator {
  boolean validate(String phoneNumber);
}


PhoneNumberValidator sprawdza, czy podany ciąg znaków reprezentuje poprawnie sformatowany numer telefonu. W walidatorze nie ma wskazania stanu wewnętrznego. Być może ma on stan, a może nie. Co natomiast wiemy, to to, że oferuje usługę weryfikacji numeru telefonu.

public interface PersonDataStore {
  Person getPeople(PeopleQuery query);
  void addPerson(Person person);
  void removePerson(Person person);
}


PersonDataStore zapewnia mechanizm przechowywania i wykonywania zapytań dotyczących struktur danych Person. W tym przykładzie zarówno Person, jak i PeopleQuery są strukturami danych - organizują informacje. Jednak PersonDataStore jest obiektem, który świadczy usługi. A mianowicie, PersonDataStore może przyjąć strukturę danych PeopleQuery i znaleźć wszystkie struktury danych Person, które pasują do kryteriów zapytania.

Czy PersonDataStore pozostaje w pamięci? Czy utrzymuje się na dysku? Czy indeksuje dane? Jako klienci PersonDataStore nic o tym nie wiemy i szczerze - nie obchodzi to nas. Dbamy tylko o akcje, które PersonDataStore wykonuje, ponieważ jest on obiektem OOP.

Niejasne obiekty i struktury danych

Spójrzmy ponownie na strukturę danych Person. W ostatnim przykładzie dodaliśmy zachowanie sprawdzania poprawności numeru telefonu. Prawdopodobne jest, że będziemy potrzebować podobnych walidacji dla imienia i nazwiska. Co więcej, kiedy dodamy więcej pól do tej struktury danych, to mogą one też wymagać walidacji.

Problem z walidacją w strukturze danych Person polega na tym, że stworzyliśmy kandydata, który będzie stale naruszał zasadę otwarte-zamknięte, zasadę jednej odpowiedzialności i zasadę segregacji interfejsów:

Zasada otwarte-zamknięte:

Każde nowe pole wymaga otwarcia klasy Person i dodania kodu do sprawdzenia.

Zasada jednej odpowiedzialności:

Klasa Person jest teraz odpowiedzialna za strukturyzację danych oraz walidację imion, nazwisk i numerów telefonów. To całkiem sporo, a rzeczy do zrobienia mogą się zmieniać niezależnie od siebie.

Zasada segregacji interfejsów:

Wyobraźmy sobie, że interesuje nas tylko numer telefonu. Pracując z instancją Person, polegasz nie tylko na metodzie validatePhoneNumber(), ale także na metodach validateFirstName() i validateLastName().

W ten sposób jesteśmy zależni od metod, których nie potrzebujemy - to dlatego naruszyliśmy tutaj zasadę segregacji interfejsów.

Dane a zachowanie

Na przykładzie Person nauczyliśmy się, że dane i zachowanie nie są ze sobą związane. Dane są pogrupowane według zagadnień I/O, takich jak formaty wejściowe webowego API oraz schematy bazy danych. Zachowanie jest jednak pogrupowane według przypadków użycia, które reprezentują to, co klient chce zrobić z aplikacją.

Kiedy zaczynamy dodawać zachowanie do struktur danych, to naruszamy OOP, tak jak widać to w przykładzie Person. Możesz, zamiast tego zdefiniować zachowania na podstawie przypadków użycia, struktury danych w oparciu o wymagania I/O, a następnie obiekty, które mają je połączyć. Przykład: Activity w Androidzie może zdefiniować taką metodę:

// Called when the user finishes entering a phone number.
private void onPhoneNumberSet() {
  String phoneNumber = mPhoneNumberTextView.getText().toString();
  boolean isValid = mPhoneNumberValidator.validate(phoneNumber);
  if (isValid) {
    mPerson.setPhoneNumber(phoneNumber);
  } else {
    // Notify user of problem.
  }
}


W powyższym przykładzie Person pozostaje swoją własną strukturą danych, która nie wie nic o zachowaniu walidacyjnym. Potem mamy obiekt PhoneNumberValidator, który wie, jak zweryfikować ciąg znaków phone number, ale nie wie nic o strukturze danych Person.

I na koniec, Activity nadzoruje walidację numeru telefonu, a następnie ustawia dane numeru telefonu w strukturze danych Person. A teraz przesyłamy dane struktury Person do źródła danych (internetowej lub lokalnej bazy danych):

private void doSubmit() {
  mPersonDataStore.addPerson(mPerson);
}


Nie wiemy, czy PersonDataStore komunikuje się z lokalną bazą danych, serwerem webowym czy pamięcią lokalną. Wiemy tylko, że naszym obowiązkiem w przypadku użycia jest zbieranie danych Person i ich przechowywanie. Użyliśmy obiektów OOP do walidacji danych wejściowych, a teraz używamy obiektu OOP do przechowywania danych.

Podczas tego procesu dane zostały zebrane w strukturze danych Person.

O co tyle krzyku?

Dlaczego ktoś miałby przejmować się takim rozróżnieniem między strukturami danych a obiektami?

Struktury danych to stan.

Powtórzę dla lepszego efektu.

Struktury danych to stan.

To dlatego przekazywanie struktur danych oznacza dzielenie się stanem, a stan współdzielony jest niestety źródłem wszelkiego zła. Powodem, dla którego wymyślono obiekty OOP, było zapewnienie paradygmatu, w którym współdzielony stan można zminimalizować i kontrolować (dlatego powinniśmy mądrze podejść do pakowania).

Pomyśl o strukturach danych jak o formacie wymiany między obiektami OOP w kodzie. Spójrzmy na następujący pseudokod:


Używamy objectA do wykonania jakiejś pracy. Następnie wyodrębniamy jego stan, uzyskując z tego strukturę danych. Inicjalizujemy objectB, wysyłając strukturę danych, którą otrzymujemy od A, aby objectB teraz coś robił. W tym przykładzie istnieje domniemana niemutowalność myDataStructure, tak że zmiana myDataStructure nie miałaby żadnego ukrytego wewnętrznego wpływu na objectA lub objectB.

Struktura danych zapewnia nam mechanizm poruszania się po zorganizowanym stanie. Nie oferuje ona jednak żadnego własnego zachowania. Oto, w jaki sposób powinniśmy dążyć do wykorzystania struktur danych w naszym kodzie.

Ważne jest też, aby ekstrakcja stanu była operacją o stosunkowo niskiej częstotliwości. W większości przypadków należy dążyć do tego, aby obiekty akceptowały i zwracały inne obiekty, a nie struktury danych. Należy dążyć do stosowania struktur danych tylko podczas przechodzenia między różnymi domenami w aplikacji.

Na przykład, gdy otrzymujesz dane wejściowe w domenie I/O, możesz użyć struktury danych, aby wstrzyknąć te dane do domeny biznesowej. Podobnie jest, gdy chcesz przedstawić coś użytkownikowi wizualnie - możesz wtedy użyć struktury danych, aby wyodrębnić informacje z domeny biznesowej i wysłać je do domeny wizualnej.

Struktury danych są zatem formatem wymiany między domenami aplikacji.

Podczas tworzenia aplikacji należy wiedzieć, że obiekty Javy nie zawsze są obiektami OOP. Należy zatem pamiętać o rozpoznaniu i oddzieleniu tych różniących się między sobą konstrukcji.


Oryginał tekstu w języku angielskim przeczytasz tutaj.

<p>Loading...</p>