Ekstremalna obiektowość w praktyce – część 8 – opakowywanie kolekcji w klasy specyficzne dla kontekstu wykorzystania
Część 0
Część 1
Część 2
Część 3
Część 4
Część 5
Część 6
Część 7
Gladius Noctis, a na blogu ósma z zasad Jeff’a Bay’a
Klasa której polem jest kolekcja nie powinna mieć żadnych innych pól (opakowywanie kolekcji w klasy specyficzne dla kontekstu wykorzystania)
Nazwa trochę długa, ale nie ważne. Powróćmy na chwilę do pierwszej części tego cyklu. Na koniec przykładu z tablicami mieliśmy tam następujący kod:
Listing 1. Kod z pierwszej części
package pl.koziolekweb.eowp1;
public class Array3DFiller {
public static void main(String[] args) {
int[][][] array3d = new int[3][3][3];
fillArray(array3d);
printArray(array3d);
}
private static int fill2dArray(int[][][] array3d, int current, int i) {
for (int j = 0; j < array3d[i].length; j++) {
current = fillRow(array3d, current, i, j);
}
return current;
}
private static int fillArray(int[][][] array3d) {
int current = 0;
for (int i = 0; i < array3d.length; i++) {
current = fill2dArray(array3d, current, i);
}
return current;
}
private static int fillRow(int[][][] array3d, int current, int i, int j) {
for (int k = 0; k < array3d[i][j].length; k++, current++) {
array3d[i][j][k] = current;
}
return current;
}
private static void print2dArray(int[][][] array3d, int i) {
for (int j = 0; j < array3d[i].length; j++) {
printRow(array3d, i, j);
System.out.println();
}
}
private static void printArray(int[][][] array3d) {
for (int i = 0; i < array3d.length; i++) {
print2dArray(array3d, i);
System.out.println();
}
}
private static void printRow(int[][][] array3d, int i, int j) {
for (int k = 0; k < array3d[i][j].length; k++) {
System.out.print(String.format("%2s ", array3d[i][j][k]));
}
}
}
Przy tamtych założeniach było ok. Krawetko podał jeszcze zgrabniejszą wersję. Ja jednak zmienię tu wszystko.
Co robimy z kolekcjami?
Przede wszystkim iterujemy. Przeciskamy przez różne formy pętli w celu wykonania operacji na wszystkich elementach kolekcji. W Javie jest to dość upierdliwe. Za każdym razem należy napisać te klika linijek kodu by przepuścić daną kolekcję przez pętlę. W dodatku może okazać się, że coś skrewiliśmy albo nasz kod dostał się w niepowołane ręce, które wykonały kilka niefajnych operacji bezpośrednio na kolekcji.
W Scali jest trochę lepiej mamy metodę foreach, która opakowuje pętlę:
Listing 2. foreach w Scali
val x = 1 to 10
x foreach ($ => println($*$))
I już mamy kwadraty liczb od 1 do 10. W javie trzeba na to dwóch pętli.
Oczywiście jest tu ukryty mały „fakap” ponieważ zarówno metoda to jak i foreach maskują nam odpowiednie pętle. W przypadku to jest to jeszcze wzbogacone o lazy-init, ale to insza inszość.
Dlaczego opakowywać kolekcje?
Kod w Scali fajny jest. Dlatego warto stworzyć sobie podobne możliwości. W Javie nie jest to trudne generalnie poniższy kawałek kodu rzucił mi się kiedyś w oczy, ale skąd pochodzi ta koncepcja nie jestem wstanie powiedzieć.
Listing 3. foreach w Javie
interface Foreach<T> {
interface Action<T> {
void perform(T t);
}
abstract class ForeachImpl<T> implements Foreach<T> {
private static class ArrayDelegate<T> implements Foreach<T> {
private T[] array;
public ArrayDelegate(T[] array) {
this.array = array;
}
@Override
public void foreach(Action<T> action) {
for (T t : array) {
action.perform(t);
}
}
}
private static class Delegate<T> extends ForeachImpl<T> {
public Delegate(Collection<T> collection) {
super(collection);
}
}
public static <D> Foreach<D> delegate(Collection<D> collection) {
return new Delegate<D>(collection);
}
public static <D> Foreach<D> delegate(D[] collection) {
return new ArrayDelegate<D>(collection);
}
private Collection<T> collection;
private ForeachImpl(Collection<T> collection) {
this.collection = collection;
}
@Override
public void foreach(Action<T> action) {
for (T t : collection) {
action.perform(t);
}
}
}
public void foreach(Action<T> action);
}
Jest to trochę karkołomna konstrukcja w tej postaci, ale wiadomo o co chodzi. Odpowiednie zagłębienia służą tu odwaleniu roboty podobnej do tej jaką odwala scalac przy dodawaniu traita do klasy. Dla przypomnienia jeżeli trait nie zawiera implementacji to jest traktowany jak interfejs. Jeżeli zawiera implementacje to tworzone są klasy abstrakcyjne zawierające zaimplementowane metody i w klasie docelowej następuje delegacja zachowania do takiej wewnętrznej klasy. Tyle w skrócie zainteresowanych odsyłam do konsoli i polecenia javap.
Nasza klasy wystarczy, że zaimplementuje interfejs i wydeleguje obsługę metody do klasy Delegate. Już dużo mniej roboty 😀 później przy iterowaniu przez kolekcję wystarczy zaimplementować clou pętli czyli to co pomiędzy { i }. Można to też łatwo przetestować. Ba! Można implementację przerzucić na użytkownika API. Niech powie co robi z pojedynczym elementem kolekcji, a to jak będzie przechodził przez kolekcję to już nie jego sprawa. Tu dochodzimy do takiego modelu, który jest już blisko programowania funkcyjnego. Klient mówi CO i nie martwi się JAK.
Wypełniamy tablicę
Powróćmy do pierwszego kodu. W pierwszym kroku opakujmy tablicę jednowymiarową o zadanej ilości elementów w coś przyjaznego dla środowiska:
Listing 4. Opakowana tablica intów
class ArrayOfInt {
private Integer array[];
public ArrayOfInt(int size) {
array = new Integer[size];
}
public int fill(final int start) {
return fill(start, 1);
}
public int fill(final int start, final int step) {
int next = start;
for (int i = 0; i () {
@Override
public void perform(Integer t) {
System.out.print(String.format("%2s ", t));
}
});
}
}
Ok, na razie nic groźnego. O ile inicjację tablicy i jej wypełnienie musimy przeprowadzić iterując z palucha to już wypisanie robimy za pomocą delegowania.
Teraz czas na tablicę dwu wymiarową. W tym przypadku musimy powiedzieć ile kolumn i ile rzędów chcemy mieć. Jednak czy aby na pewno? Opakowujemy w końcu kolekcję kolekcji… zatem…
Listing 5. Opakowana dwu wymiarowa tablica intów
class Array2dInt {
private class Next {
int next;
}
private ArrayOfInt array[];
public Array2dInt(int rows, int cols) {
array = new ArrayOfInt[rows];
for (int i = 0; i < array.length; i++)
array[i] = new ArrayOfInt(cols);
}
public int fill(final int start) {
return fill(start, 1);
}
public int fill(final int start, final int step) {
final Next next = new Next();
next.next = start;
ForeachImpl.delegate(array).foreach(new Action<ArrayOfInt>() {
@Override
public void perform(ArrayOfInt t) {
next.next = t.fill(next.next, step);
}
});
return next.next;
}
public void print() {
ForeachImpl.delegate(array).foreach(new Action<ArrayOfInt>() {
@Override
public void perform(ArrayOfInt t) {
t.print();
System.out.println("");
}
});
}
}
Whoa… inicjacja w konstruktorze nadal z palucha (tego nie przeskoczymy). Reszta przez delegaty. Tablica trójwymiarowa to już formalność.
Listing 6. Opakowana trójwymiarowa tablica intów
class Array3dInt {
private class Next {
int next;
}
private Array2dInt array[];
public Array3dInt(int rows, int cols, int depth) {
array = new Array2dInt[depth];
for (int i = 0; i < array.length; i++)
array[i] = new Array2dInt(rows, cols);
}
public int fill(final int start) {
return fill(start, 1);
}
public int fill(final int start, final int step) {
final Next next = new Next();
next.next = start;
ForeachImpl.delegate(array).foreach(new Action<Array2dInt>() {
@Override
public void perform(Array2dInt t) {
next.next = t.fill(next.next, step);
}
});
return next.next;
}
public void print() {
ForeachImpl.delegate(array).foreach(new Action<Array2dInt>() {
@Override
public void perform(Array2dInt t) {
t.print();
System.out.println("");
}
});
}
}
Całość można oczywiście ładnie rozdzielić na nieanonimowe klasy wewnętrzne (albo wyrzucić do osobnych klas o ograniczonej widoczności) czy uczynić generycznym. Cały kod jest, wbrew pozorom, prostszy i łatwiejszy w testowaniu.
Podsumowanie
Bardzo istotną rzeczą w takim podejściu do kodowania jest umiejętność rozdzielenia swojej osobowości na klienta i dostawcę. W przeciwnym wypadku możemy nieźle przestraszyć się ilością kodu.
Najistotniejszą cechą jest jednak ograniczenie klientowi pola manewru w pracy z kolekcją. Mając ograniczone API i pozbawiając go możliwości dowolnego manipulowania elementami możemy się zabezpieczyć przed nieprzyjemnymi sytuacjami jak chociażby usunięcie elementu z kolekcji (boli jak używasz JPA), głębokie klonowanie kolekcji albo nieskończonego iterowania.
Dodatkowo operacje biznesowe są jasno określone przez co zmniejsza się prawdopodobieństwo „hakierowania” i tworzenia obejść we właściwym kodzie biznesowym.
Gdzie warto używać
Przede wszystkim warto opakowywać kolekcje, które wychodzą z bazy danych/DAO. W ten sposób można ograniczyć manipulowanie obiektami w ramach kodu biznesowego. Wszelkie operacje związane ze zmianą stanu bazy będą dokonywane poprzez interfejs opakowanej kolekcji. Dzięki czemu można kontrolować kto co może. W takim układzie aplikacja nie widzi DAO jako takiego. Widzi tylko interfejs dostarczający pewne dane na których można wykonać ściśle określone operacje.
Po drugie MVP. W tym przypadku kolekcja staje się pomocnikiem dla prezentera. Można dzięki temu wprowadzić np. dodatkowe filtrowanie danych tuż przed ich wyświetleniem. To pozwala na stworzenie jednego prezentera, który będzie prezentował dane w zależności od potrzeb, ale będzie jednocześnie na tyle elastyczny by móc z niego skorzystać w wielu miejscach. Przykładowo kolekcja A przed wyświetleniem wymaga wycięcia części danych, których nie bardzo można pozbyć się na etapie pobierania z bazy. Jednocześnie kolekcja B nie ma tych danych już domyślnie. Obie mają metodę show przyjmującą jako parametr prezenter. Następnie w środku wywołują tylko wybrane metody prezentera.
Po trzecie filtry. W takim przypadku zamiast pozwolić iterować po kolekcji możemy udostępnić klientowi ileś tam predefiniowanych metod, które filtrują kolekcję w określony sposób. Tu mała uwaga. Szkoda, że w Javie nie ma eleganckich domknięć i trzeba stosować haki w stylu anonimowych klas.