Blog

30 stycznia 2015 Wojciech Zawistowski

Pojedynczy i WŁAŚCIWY Poziom Abstrakcji

Pojedynczy i WŁAŚCIWY Poziom Abstrakcji

Bezpośrednią interpretacją Zasady Pojedynczego Poziomu Abstrakcji (SLAP – Single Level of Abstraction Principle) jest unikanie mieszania ze sobą wysoko- i niskopoziomowych detali w tym samym kodzie.

Nawet na tak podstawowym poziomie daje to wiele korzyści. Pomaga sfokusować kod, poprawia jego czytelność i czyni go łatwiejszym do zrozumienia.

Możemy jednak posunąć tą ideę jeszcze o krok dalej.

Po pierwsze, powinniśmy dbać by nasz kod był nie tylko na pojedynczym poziomie abstrakcji, ale też na właściwym poziomie abstrakcji.

Po drugie, odnosi się to nie tylko do kodu, ale także do specyfikacji.

Zacznijmy jednak od najprostszego przykładu:

Pojedynczy Poziom Abstrakcji

Przyjrzyjmy się następującej, prostej specyfikacji:

describe("battle mode switch", function() {
it("activates shields");
it("activates targeting system");
it("activates plasma cannons");
});

Jest ona tak spójna i czytelna, że właściwie nie wymaga wyjaśnień. Wszystkie kroki tej specyfikacji pozostają na dokładnie tym samym poziomie abstrakcji: dotyczą uruchamiania poszczególnych podsystemów statku podczas kosmicznej bitwy. Zaczynają się nawet wszystkie od tego samego słowa.

To samo odzwierciedlone jest w kodzie:

function battleModeSwitch() {
activateShields();
activateTargetingSystem();
activatePlasmaCannons();
};

Powyższa funkcja jest deklaratywna i bardzo prosta do zrozumienia, a jej odpowiedzialność jest oczywista.

Rozważmy teraz co stanie się gdy pomieszamy dwa różne poziomy abstrakcji:

describe("battle mode switch", function() {
it("activates shields");
it("activates targeting system");
it("opens hull's plasma cannon covers");
it("makes generator set max power to plasma cannons");
it("activates plasma cannons' cooling system");
});

Specyfikacja staje się jednym wielkim bałaganem.

Schludna lista, mówiąca które podsystemy należy aktywować, tonie w detalach tego, co trzeba zrobić by aktywować działka plazmowe. Funkcja staje się nieczytelna a jej cel niejasny.

Nawet nazwy specyfikacji stają się nadmiernie skomplikowane. Zamiast krótkich i prostych zdań typu battle mode switch activates shields otrzymujemy złożone zdania z podwójnym podmiotem: battle mode switch makes generator set max power to plasma cannons – czyje zachowanie w tym zdaniu specyfikujemy, przełącznika czy generatora?

To samo dzieje się gdy złamiemy zasadę SLAP w kodzie (choć w specyfikacji jest to bardziej widoczne):

function battleModeSwitch() {
activateShields();
activateTargetingSystem();
hull.openPlasmaCannonCovers();
generator.setMaxPower(plasmaCannons);
plasmaCannons.activateCoolingSystem();
};

Tak jak poprzednio, utraciliśmy czytelność i zaciemniliśmy cel funkcji.

Ale co w sytuacji, gdy wszystkie kroki zarówno w kodzie jak i specyfikacji na pojedynczym poziomie abstrakcji? Czy kod i specyfikacja stają się automatycznie czyste? Czy jest to aż tak proste?

Niestety, nie zawsze. Kod i specyfikacja nadal mogą pozostawać na niewłaściwym poziomie abstrakcji. Przyjrzyjmy się kolejnemu, bardziej subtelnemu przykładowi:

Właściwy Poziom Abstrakcji

Gdy system monitorujący naszego statku wykryje uszkodzenie kadłuba, powinien natychmiast zaalarmować ekipę naprawczą i kapitana. Ekipę naprawczą za pomocą wycia syren i migotania świateł w pokoju mechaników (conajmniej jeden mechanik jest zawsze na dyżurze w pokoju, więc jest to wystarczające). Kapitana za pomocą dzwonka jego interkomu oraz wibracji w jego zegarku (kapitan rzadko przebywa w swoim pokoju, musimy więc złapać go w ruchu).

Jesteśmy świadomi zasady SLAP, piszemy więc następującą specyfikację:

describe('Hull Monitoring System', function() {
describe('damage alert', function() {
it('wails mechanics room siren');
it('flashes mechanics room lights');
it('vibrates captain watch');
it('buzzes captain intercom');
});
});

Wszystkie cztery kroki wydają się spójne i są na tym samym poziomie abstrakcji. Czy powyższa specyfikacja jest w takim razie optymalna?

Jak można się domyśleć, nie jest. Może sam dostrzegłeś już, gdzie leży problem problem. Jeśli nie, przyjrzyjmy się implementacji, gdzie problem ten bardziej rzuca się w oczy:

function HullMonitoringSystem() {
this.damageAlert = function() {
mechnicsRoom.wailSiren();
mechnicsRoom.flashLights();

captain.vibrateWatch();
captain.buzzIntercom();
};
};

Zauważ, że mimo iż wszystkie kroki są na tym samym poziomie abstrakcji, wykazują one tendencję do grupowania się wokół dwóch różnych podmiotów: pokoju mechaników i kapitana.

Grupowanie to wydaje się jak najbardziej naturalne i rozsądne, czemu więc nie wyeksponować go w jawny sposób?

function HullMonitoringSystem() {
this.damageAlert = function() {
notifyMechanics();
notifyCaptain();
};

function notifyMechanics() {
mechnicsRoom.wailSiren();
mechnicsRoom.flashLights();
};

function notifyCaptain() {
captain.vibrateWatch();
captain.buzzIntercom();
};
};

Mimo, że oryginalna implementacja nie była jakoś specjalnie zła, obecna jest o wiele lepsza. Odpowiedzialność funkcji damageAlert staje się dzięki niej jaśniejsza.

Czy powinniśmy w taki sam sposób zmienić również specyfikację?

Zanim odpowiemy na to pytanie, zatrzymajmy się na chwilę by rozważyć dlaczego oryginalna funkcja nie była dobra, pomimo przestrzegania zasady SLAP?

Problem polegał na tym, że mimo iż wszystkie kroki były na tym samym poziomie abstrakcji, były one na niewłaściwym poziomie abstrakcji!

Spójrzmy na wymagania: system monitorujący [...] powinien [...] zaalarmować ekipę naprawczą i kapitana [...] za pomocą [...]. System monitorujący powinien zaalarmować ekipę naprawczą i kapitana. To jest odpowiedzialność systemu, a nie uruchamianie syren czy interkomu. Wycie syren i brzęczenie interkomu to detale implementacji. Część wymagań opisana słowami za pomocą. To opis jak, a nie co.

Problem z oryginalną funkcją polegał na tym, że próbowała ona przeskoczyć jeden poziom abstrakcji, schodząc w dół od razu o dwa poziomy, zamiast o jeden.

To z kolei odpowiada na nasze pytanie, czy powinniśmy poprawić również specyfikację:

Jak najbardziej!

Celem specyfikacji jest opisanie co system powinien robić, a nie jak. Przeskakując jeden poziom abstrakcji, zgubiliśmy główny cel specyfikacji. Naprawmy to więc:

describe('Hull Monitoring System', function() {
describe('damage alert', function() {
it('notifies mechanics');
it('notifies captain');
});
});

O wiele lepiej. Cel i sens alarmu są teraz o wiele jaśniejsze.

Jednakże, w detalach implementacji naszej specyfikacji nadal czai się jeszcze jeden problem.

Pojedynczy Poziom Abstrakcji na poziomie klasy

Jak dotąd skupialiśmy się wyłącznie na opisach specyfikacji. Miało to sens, bo dawało nam jaśniejszy pogląd na jej poziomy abstrakcji. Nie możemy jednak unikać implementacji w nieskończoność. Pora, by się jej przyjrzeć:

describe('Hull Monitoring System', function() {
describe('damage alert', function() {
it('notifies mechanics', function() {
expect(mechanicsRoom.siren).toWail();
expect(mechanicsRoom.ligts).toFlash();
});

it('notifies captain', function() {
expect(captain.watch).toVibrate();
expect(captain.intercom).toBuzz();
});
});
});

Czuć tutaj leciutki „zapaszek”: podwójne oczekiwania (expect) w każdej ze specyfikacji.

Nie jest to automatycznie złe. Mimo że powinniśmy starać się opisywać jedynie pojedynczy przykład w każdej specyfikacji, nie musi się to koniecznie mapować na pojedyncze oczekiwanie. Kilka ściśle powiązanych ze sobą oczekiwań, współtworzących pojedynczy, spójny przykład jest do zaakceptowania.

Zastanówmy się jednak, czy w naszym przypadku oba oczekiwania faktycznie współtworzą specyfikację pojedynczego zagadnienia? Powiedziałbym, że nie. Wycie syren i błyskanie świateł to dwie oddzielne, niezależne akcje. Powinniśmy je rozdzielić.

Możemy spróbować osiągnąć to na poziomie samych opisów:

describe('Hull Monitoring System', function() {
describe('damage alert', function() {
describe('it notifies mechanics', function() {
it('wails mechanics room siren');
it('flashes mechanics room lights');
});

describe('it notifies captain', function() {
it('vibrates captain watch');
it('buzzes captain intercom');
});
});
});

Wydaje się to jednak sztuczne, i nie będzie się dobrze skalować, gdyby złożoność implementacji wzrosła – zbyt wiele poziomów zagnieżdżeń uczyni specyfikację nieczytelną.

Czy istnieje inny sposób jej dekompozycji?

Na szczęście tak. Musimy tylko wprowadzić nową klasę:

describe('Hull Monitoring System', function() {
describe('damage alert', function() {
it('notifies mechanics', function() {
expect(notificationCenter).toNotifyMechanics();
});

it('notifies captain', function() {
expect(notificationCenter).toNotifyCaptain();
});
});
});

describe('Notification Center', function() {
describe('notify mechanics', function() {
it('wails mechanics room siren');
it('flashes mechanics room lights');
});

describe('notify captain', function() {
it('vibrates captain watch');
it('buzzes captain intercom');
});
});

Taka specyfikacja zmusza nas przy okazji do zmiany implementacji:

function HullMonitoringSystem() {
this.damageAlert = function() {
notificationCenter.notifyMechanics();
notificationCenter.notifyCaptain();
};
};

function NotificationCenter() {
this.notifyMechanics = function() {
mechnicsRoom.wailSiren();
mechnicsRoom.flashLights();
};

this.notifyCaptain = function() {
captain.vibrateWatch();
captain.buzzIntercom();
};
};

Jest to, jak się okazuje, dobre posunięcie. Kod jest teraz jeszcze bardziej przejrzysty, ze ścisłym podziałem odpowiedzialności. Ponadto, uzyskaliśmy dzięki temu możliwość mockowania klasy NotificationCenter, a przez to odłożenia jej implementacji na później, co daje nam większą elastyczność.

Ale czemu właściwie się tak stało?

Klasa HullMonitoringSystem obejmowała zbyt wiele poziomów abstrakcji. Zawierała zarówno kroki opisujące implementację funkcji damageAlert, jak również pod-kroki opisujące te kroki.

Wydzieliliśmy te dwa poziomy abstrakcji do dwóch oddzielnych klas: HullMonitoringSystem i NotificationCenter, operujących jedynie na pojedynczym poziomie abstrakcji. To sprawiło, że obie klasy są węziej sfokusowane, z lepszym rozdziałem odpowiedzialności.

Czy powinniśmy zawsze przestrzegać zasady SLAP w tak ścisły sposób? Na poziomie funkcji powiedziałbym, że tak. Na poziomie klas, prawdopodobnie nie zawsze. Ale powinniśmy przynajmniej spojrzeć na każdą z klas i każdą specyfikację przez pryzmat tej zasady – często może nas to naprowadzić na lepszą strukturę naszego kodu.


Jakie są Twoje doświadczenia z regułą SLAP? Kiedy poprawia ona strukturę kodu, a kiedy jej rygorystyczne przestrzeganie prowadzi do przesady? Podziel się swoim zdaniem w komentarzach poniżej!

Zobacz na blogu

17.11.2019
Dariusz Rusin
eSky zaproszony do współpracy z Huawei w celu integracji aplikacji mobilnej z nowym asystentem eSky zaproszony do współpracy  z Huawei w celu integracji aplikacji mobilnej z nowym asystentem

Chiński gigant w odpowiedzi na blokady technologiczne i handlowe ze strony Stanów Zjednoczonych rozpoczął ambitny...

04.11.2019
Tomasz Lis
eSky ML eSky ML

Hurtownia danych Kilka lat temu cała analiza danych w eSky odbywała się z wykorzystaniem silnika...