Pewnie większość z Was słyszała o zasadach SOLID. Są one bardzo rozpowszechnione i dosyć często stosowane (i dobrze), ale czy słyszeliście o GRASP? General Responsibility Assignment Software Patterns – to kolejna dawka zasad czystego kodu do zapamiętania.
Zasady GRASP w programowaniu
GRASP, czyli dziewięć zasad clean code, których stosowanie pozwoli Ci na wejście na wyższy poziom ze swoim kodem. Nie zdziwię się, jeśli nigdy nie słyszałeś/aś o General Responsibility Assignment Software Patterns (pssst, daj znać w komentarzu). W środowisku programistów wykuło się przekonanie, że znajomość SOLID-a, to już znajomość lwiej części zasad czystego programowania. Otóż nie, a wokół SOLID-a nie kręci się cały świat czystego kodu. Bardzo ubolewam nad faktem, iż GRASP jest tak słabo znany w naszym „programistycznym ekosystemie” i z tego powodu zdecydowałem się poruszyć ten temat w dzisiejszym wpisie.
Information Expert
Pierwszą z reguł GRASP jest information expert. W tej zasadzie chodzi o odpowiednie umiejscowienie logiki, na podstawie danych agregowanych przez klasę. Reguła ta mówi nam, że klasa powinna realizować tylko te zadania, do których posiada wszystkie niezbędne informacje. Inaczej mówiąc, powinniśmy projektować swoje klasy tak, aby zbierały wszystkie dane konieczne do wykonania konkretnej, zamierzonej logiki. Spójrzmy na poniższy przykład, gdzie widzimy klasę Money. W tym przykładzie spełniamy zasadę eksperta informacji, ponieważ Money dostarcza logikę operującą tylko i wyłącznie na danych, które posiada, np. porównywanie waluty czy dodawanie.
export class Money {
private static readonly allowedCurrencies = [Currency.PLN, Currency.USD];
constructor(
public readonly amount: number,
public readonly currency: Currency
) {
if (amount <= 0) {
throw new Error('Money amount needs to be greater than 0.');
}
if (!Money.allowedCurrencies.includes(currency)) {
throw new Error(`Currency ${currency} is not allowed.`);
}
}
add(money: Money): Money {
if (!this.currencyIs(money.currency)) {
throw new Error('Currency mismatch.');
}
return new Money(this.amount + money.amount, this.currency);
}
currencyIs = (currency: Currency): boolean =>
this.currency === currency;
}
Zasadę tę przestalibyśmy spełniać np. kiedy logika odpowiedzialna za dodawanie, byłaby umieszczona w innej klasie, np. w serwisie. I w takiej sytuacji musielibyśmy odpytywać klasę Money o dane potrzebne do zrealizowania tej logiki. W tej sytuacji to Money powinno być uważane za information expert, a nie klasa serwisowa.
class MoneyService {
add(money1: Money, money2: Money): Money {
if (money1.currency !== money2.currency) {
throw new Error('Currency mismatch.');
}
return new Money(money1.amount + money2.amount, money1.currency);
}
}
Creator
Twórca – Creator, to druga zasada z GRASP określająca kto i kiedy powinien być w stanie tworzyć konkretne obiekty. Definiuje ona prostą regułę mówiącą, gdzie można utworzyć obiekt, a reguła ta brzmi: jeśli blisko współpracujesz i zawierasz byt, który próbujesz utworzyć oraz wiesz jak to zrobić (masz potrzebne dane), to możesz to zrobić. Spójrzmy na poniższy przykład, gdzie widzimy klasę Activity, która agreguje współrzędne w obiektach typu TracePoint.
class Activity implements ActivityInterface {
constructor(
private readonly id: ActivityId,
private readonly activityType: ActivityType,
private state = ActivityState.new,
private trace: TracePoint[] = [],
) {}
}
W metodzie addLog() tworzymy obiekty TracePoint i dodajemy je do kolekcji. Możemy tworzyć nowe obiekty punktu w aktywności i spełniamy zasadę creator z GRASP, ponieważ klasa Activity zawiera w sobie zapisane punkty, a także używa ich, np. do wyliczenia dystansu (bliska współpraca). Dodatkowo, aktywność posiada wszelkie informacje o tym, jak utworzyć obiekt typu TracePoint.
class Activity implements ActivityInterface {
constructor(
private readonly id: ActivityId,
private readonly activityType: ActivityType,
private state = ActivityState.new,
private trace: TracePoint[] = [],
) {}
addLog(lat: string, long: string) {
const points = TracePoint.create({
lat,
long,
pointType: PointType.fromActivityType(this.activityType)
});
this.trace.push(point);
}
getDistance = (): Distance => { /* impl */ }
}
Controller
Kolejną zasadą GRASP jest Controller. Mówi ona o tym, abyśmy tworzyli klasy, które będą odpowiedzialne za kontrolowanie całych procesów, bez wnikania w to, jak te procesy realizowane są pod spodem. Przykładem może być poprawnie zaimplementowany Controller dla RestAPI, wzorzec fasada, a także mój ulubiony przykład, serwis aplikacyjny.
W poniższym przykładzie widzimy metodę eatMeal, która pobiera rekord z bazy po identyfikatorze, wykonuje na nim pewną logikę (bez wnikania w jej szczegóły), a następnie zapisuje ten obiekt po wprowadzeniu zmian. Jest to klasyczny przykład kontrolera, który realizuje pewne flow bez ingerencji w nie.
export class MealsService {
async eatMeal(mealId: string): Promise<void> {
const meal = await this.mealRepository.findOneOrFail(mealId);
meal.eat();
await this.mealRepository.save(meal);
}
}
Low coupling
O zasadzie low coupling i samym coupling-u wkrótce napiszę osobny artykuł, jednak jest to reguła GRASP-a, więc już dzisiaj dowiesz się o niej co nieco. O tym, że klasy powinny posiadać niskie powiązania pomiędzy sobą wie chyba każdy. Problem zazwyczaj pojawia się, kiedy przychodzi do praktyki. Jak sprawić, żeby powiązania (coupling) pomiędzy elementami systemu były niskie? Nie ma na to niestety złotego sposobu, bo rodzai coupling-u jest kilka, a sam coupling nie zawsze jest zły. Tym niechcianym rodzajem coupling-u (tym, którego powinno być najmniej) jest ten rozsiany, niekontrolowany, często przypadkowy, który wynika ze źle dobranej architektury, reguł komunikacyjnych pomiędzy komponentami czy nieodpowiedniego podziału zadań.
Jak wspomniałem, temat jest rozległy i poruszę go w osobnym artykule, jednak aby nie zostawić Cię dzisiaj z niczym, to mam dla Ciebie jedną z wielu metod na zmniejszenie powiązań pomiędzy klasami. A jest nią… stosowanie interfejsów.
Spójrz na poniższy przykład. Mamy tutaj klasyk klasyków, czyli serwis używający repozytorium. Zwróć uwagę, że powiązanie na linii serwis – repozytorium jest teraz bardzo duże. Serwis posiada w sobie instancję repozytorium i kontroluje ją poprzez wywoływanie metod.
export class BlogPostsService {
constructor(
private readonly repository: BlogPostRepository,
) {}
remove(id: string) {
const post = this.repository.findOrFail(id);
this.repository.remove(post)
}
Silne powiązanie pomiędzy tymi klasami możemy bardzo łatwo zmniejszyć, poprzez dodanie interfejsu i operowanie na nim, zamiast na typie bazowym. Da nam to swobodę w ewentualnej zmianie implementacji repozytorium i „rozluźni” trochę to powiązanie.
export interface BlogPostRepositoryInterface {
findOrFail(id: string): BlogPost;
remove(post: BlogPost);
}
export class BlogPostsService {
constructor(
private readonly repository: BlogPostRepositoryInterface,
) {}
remove(id: string) {
const post = this.repository.findOrFail(id);
this.repository.remove(post)
}
}
High cohesion
Reguła mówiąca, że nasz kod powinien posiadać wysoką kohezję jest ściśle połączoną z poprzednią zasadą. Przyjęło się, że idealny kod, to taki, który ma niski coupling i wysoką kohezję. No dobrze, wiemy już czym jest coupling, ale co to do diabła jest ta kohezja? Mówiąc w bardzo dużym uproszczeniu, jest to miara określająca, jak bardzo spójne w swoim wspólnym działaniu (realizowaniu logiki) są metody, klasy, bądź inne elementy systemu. Zwyczajowo mierzymy ją dla jakiejś grupy elementów (np. metod i właściwości klasy), aby określić czy cechują się one wysoką kohezją. O tym zagadnieniu również planuję osobny artykuł, bo kohezja (podobnie jest coupling) może też być zła, lecz bez obaw, jak wcześniej, nie zostawię Cię bez przykładu.
Jedną z metod pomiaru kohezji dla klasy jest LCOM (Lack of Cohesion of Methods), którą wyznaczamy następująco: dla każdego pola klasy liczymy ile metod się do niego odnosi (używa go), następnie sumujemy wszystkie wyniki i dzielimy otrzymany rezultat przez ilość metod w klasie, pomnożoną przez ilość pól. Na sam koniec otrzymany wynik odejmujemy od jeden.
Spójrzmy na ten przykład. Mamy tutaj prostą klasę z dwiema metodami oraz dwoma polami. LCOM dla tej klasy wynosi: 1 – [ ( 1+ 2 ) / (2 * 3) ] = 1 – 3/6 = 1 – 1/2 = 1/2 = 0.5. Wartość którą otrzymaliśmy nie jest satysfakcjonująca i na pierwszy rzut oka widać, iż możemy poprawić kohezję tej klasy. Pola address oraz state nie są ze sobą logicznie związane i może nie powinny znajdować się w jednej klasie?
class Delivery {
constructor(
private address: Address,
private state: DeliveryState = DeliveryState.waiting
) {}
changeAddress(address: Address) {
this.address = address;
}
finish() {
if (this.state.isNot(DeliveryState.finished)) {
this.state.finish();
}
}
start() {
if (this.state.is(DeliveryState.waiting)) {
this.state.start();
}
}
}
Polymorphism
Kolejnym punktem GRASP-a jest polimorfizm. Ci z Was, którzy mają solidne podstawy programowania obiektowego raczej ameryki nie odkryją. GRASP wprost zachęca nas do stosowania wielopostaciowości i przenoszenia powtarzalnego kodu, który zależy od typu, do abstrakcji. Clue jest takie, aby nie powielać kodu oraz nie tworzyć if-ologii, dzięki zastosowaniu polimorfizmu.
Posłużę się uproszczonym przykładem, który omawiałem szerzej przy okazji wzorca state, stan. Mamy tutaj klasę Player z metodą move, której logika zależy wprost od stanu (typu) gracza. W tym momencie, jeśli dojdzie nowy stan, np. paralyzed, albo nowa metoda, np. say, to konieczne okaże się dopisywanie if-ów w każdej z tych metod, aby poprawnie kontrolować status.
class Player {
constructor(
private readonly id: PlayerId,
private status: PlayerStatus,
private speed: number,
private position: Position,
) {}
move(direction: MoveDirection) {
if (this.status === PlayerStatus.dead) return;
let movementSpeed = this.speed;
if (this.status === PlayerStatus.paralyzed) {
movementSpeed = this.speed / 10;
}
this.position = this.position.change(
direction,
speed,
);
}
}
Lepiej będzie uzależniać gracza od samego typu. Zastosujemy w tym przypadku wielopostaciowość dla stanu, a poszczególne jego implementacje będziemy zapisywać bezpośrednio w klasie Player. Voilà!
class DeadPlayer implements PlayerState {
constructor(
private readonly player: Player
) {}
// dead player - cannot move and speak
move(direction: MoveDirection) {}
say(speech: string): string {}
}
class Player {
constructor(/** props **/) {}
kill() {
this.state = new DeadPlayer(this);
}
paralyze() {
this.state = new ParalyzedPlayer(this);
}
reBorn() {
this.state = new AlivePlayer(this);
}
move(direction: MoveDirection) {
this.state.move(direction);
}
}
Pure fabrication
Koncepcję pure fabrication zawsze nazywam protezą… Zdarzają się sytuacje, że w swojej domenie mamy np. klasy, które posiadają jednakowy zestaw informacji o innej i chcą utworzyć jej obiekt, która powinna więc być ekspertem? Może nam też nie pasować, że w myśl zasady no. 1, tj. information expert, klasa A jest bytem tworzącym, podczas gdy nam bardziej odpowiada, aby była to klasa B. Z innej strony, nie chcemy ze swoim rozwiązaniem naruszyć zasad o niskim coupling-u oraz wysokiej kohezji.
W takiej sytuacji, cała na biało, wjeżdża zasada pure fabrication, która mówi nam wprost, aby w takich sytuacjach utworzyć kolejną, niezwiązaną z domeną, „sztuczną” klasę, która zajmie się jedynie tym niepasującym nigdzie indziej zadaniem. Będzie to klasa, która nie ma odzwierciedlenia biznesowego w kodzie: fasada, prosta fabryka, repozytorium i tak dalej.
class Order {
// impl...
addToOrder(productId: ProductId, quantity: number) {
const orderLine = OrderLineFactory.create(productId, quantity);
this.lines.add(orderLine);
}
}
Indirection
Kolejnym elementem GRASPA-a, jest zasada mówiąca o konieczności odwracania zależności. Zapewne kojarzy Ci się ona z SOLID-owym D i jest to poprawne skojarzenie. Wszystkie relacje pomiędzy klasami powinny być opakowane w abstrakcje w postaci interfejsów, tak, aby klasy nie zależały bezpośrednio od siebie. Daje nam to swobodę wymiany implementacji w przyszłości, czy też łatwiejsze testy jednostkowe.
Spójrz na poniższy przykład. UsersService spełnia zasadę indirection, dzięki temu, że używa repozytorium za pośrednictwem dedykowanego interfejsu, a nie konkretnego typu.
class UsersService {
constructor(
private readonly repository: UserRepositoryInterface
) {}
// methods
}
Protected variations
Na sam koniec zostawiłem swoją ulubioną zasadę: „protected variations”. Można rzec, że jest ona kwintesencją całego GRASP-a, a także zawarciem wszystkich reguł clean code w jednej zasadzie. Mówi nam ona o tym, że zmiana (bądź dodanie) kodu w jednym miejscu nie powinna powodować zmian w innych miejscach naszego systemu. Bardzo piękna i często utopijna wizja oprogramowania, które jest w pełni otwarte na rozbudowę.
W tym punkcie przykładu niestety nie będzie, bo też co miałbym tutaj pokazać? Jeśli chcesz spełniać tę zasadę, to twórz swój kod tak, aby był otwarty na rozbudowę. Możesz to osiągnąć stosując wzorce projektowe, np. strategię, a także stosując się do wszelkich znanych zasad czystego kodu.
Podsumowanie
- GRASP, to General Responsibility Assignment Software Patterns
- Jest to zbiór dziewięciu zasad clean code.
- Stosowanie ich pozwala nam na tworzenie szerokopojętego clean code’u.
Dodaj komentarz