L v SOLIDe
Milan Herda, 05 / 2023
SOLID princípy v JavaScripte
O čom budeme hovoriť
- Čo je SOLID
- Liskov Substitution Principle
- Výhody Liskov Substitution Principle
- Ako rozpoznať porušenia princípu
- Ako refaktorovať, aby sme dodržali princíp
SOLID
- Single Responsibility Principle
- Open-Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
Skratka predstavujúca 5 základných princípov dobrého softvérového návrhu

SOLID
Nie je iba pre OOP
Princípy sú všeobecne použiteľné
Liskov Substitution Principle
Liskovej princíp zameniteľnosti

Odvodená trieda musí byť náhradou základnej triedy
Odvodená trieda musí byť náhradou základnej triedy
Odvodená trieda musí byť náhradou základnej triedy
Odvodená trieda musí byť náhradou svojho interfacu
Objekt musí byť náhradou svojho interfacu




Implementácia musí byť náhradou svojho typového predpisu

Byť dobrou náhradou svojej základnej triedy/interfacu
znamená:
- poskytovať implementáciu pre všetky predpísané metódy
- mať rovnaké typy návratových hodnôt
- nezvoľňovať typ návratovej hodnoty
- nesprísňovať požiadavky na argumenty
- nesprísňovať kontrakt
- neobchádzať kontrakt v kóde

Cieľom LSP je kód, kde môžeme
vymeniť implementáciu rodiča
za implementáciu potomka
a kód bude ďalej fungovať.
Výhody dodržiavania
LSP

- časti kódu sú navzájom bezpečne vymeniteľné
- eliminácia chýb spôsobených nedodržaním kontraktu
Symptómy porušenia princípu
- nie sú poriadne implementované všetky metódy
- potomok má inú návratovú hodnotu ako rodič
- prísnejšie požiadavky na argumenty
- prísnejší kontrakt
- obchádzanie kontraktu v kóde
- nevieme napísať taký kód potomka, ktorý by vyhovel požiadavke rodiča
Veľkú časť porušení dnes vieme odchytiť statickou analýzou
- chýbajúce metódy
- sprísnené parametre metód
- nesprávne návratové hodnoty
Pozrieme sa preto na menej triviálne situácie

Ako refaktorovať
Podľa individuálnej situácie
Často je problém v nesprávne definovanej základnej triede/interface

// entity/user.ts
export interface User {
getId: () => number;
getName: () => string;
getType: () => UserType;
getFavouriteTvShow: () => string;
getAdminRights: () => AdminRight[];
}
const users: User[] = [
{
getId: () => 11,
getName: () => 'Ferko Mrkvička',
getType: () => UserType.User,
getFavouriteTvShow: () => 'Babylon 5',
//getAdminRights: () => ???,
},
// ...
];
const admins: User[] = [
{
getId: () => 101,
getName: () => 'Neo',
getType: () => UserType.Admin,
//getFavouriteTvShow: () => ???,
getAdminRights: () => [
AdminRight.BanUser,
AdminRight.EditUser,
AdminRight.ViewUser
],
},
// ...
];
Doplňte kód pre chýbajúce metódy

Refactoring time!

Stiahnite si zdrojáky
Problém je, že objekty majú nastaveného nesprávneho rodiča
Prečo má interface User metódy getFavouriteShow a getAdminRights, keď ich nevedia implementovať všetci potomkovia?
- rozdelíme interface
- každý typ používateľa bude implementovať iný interface
- upravíme typehinty v kóde
export interface BaseUser {
getId: () => number;
getName: () => string;
getType: () => UserType;
}
export interface User extends BaseUser {
getFavouriteTvShow: () => string;
getType: () => UserType.User;
}
export interface AdminUser extends BaseUser {
getAdminRights: () => AdminRight[];
getType: () => UserType.Admin;
}
src/entity/user.ts
import { UserType, AdminRight, AdminUser } from '@local/entity/user';
export function getAdmins() {
const admins: AdminUser[] = [
{
getId: () => 101,
getName: () => 'Neo',
getType: () => UserType.Admin,
getAdminRights: () => [
AdminRight.BanUser,
AdminRight.EditUser,
AdminRight.ViewUser
],
},
{
getId: () => 102,
getName: () => 'Mr. Anderson',
getType: () => UserType.Admin,
getAdminRights: () => [AdminRight.ViewUser],
},
];
return admins;
}
src/services/users/adminRepository.ts
import { AdminUser } from '@local/entity/user';
function ListOfAdminUsers({ users }: { users: AdminUser[] }) {
// ...
}
function AdminRow({ user }: { user: AdminUser }) {
// ...
}
src/components/ListOfAdminUsers.tsx
Hotovo


Príklad 2
export interface Validator {
getRules: () => any;
validate: (input: any) => boolean;
}
const StrictValidator: Validator = {
getRules() {
return [
ValidationRule.Required,
ValidationRule.NotEmpty,
ValidationRule.IsNumeric
];
},
validate(input: any) {
// ...
},
};
const BenevolentValidator: Validator = {
getRules() {
return null;
},
validate(input: any) {
// ...
},
};

function ListOfValidation({ validators }: { validators: Validator[] }) {
return (
<>
<h2 className="title is-2">Validation Rules</h2>
<table className="table is-striped">
<thead>
<th>Validator</th>
<th>Rules</th>
</thead>
<tbody>
{validators.map((validator) => {
return (
<tr key={`${validator.getName()}`}>
<td>{validator.getName()}</td>
<td>{validator.getRules().join(', ')}</td>
</tr>
);
})}
</tbody>
</table>
</>
);
}

Problém:
LSP je síce formálne dodržané, ale...
- implementácie metódy getRules sú zásadne odlišné
- komponent ListOfValidation tvrdí, že očakáva typ Validator, ale v skutočnosti predpokladá subtyp
- problém máme v rodičovi, ktorý nie je nadefinovaný tak, aby jeho potomkovia boli poriadnymi potomkami

Skúste kód upraviť tak, aby rodičovský typ presnejšie definoval návratový typ pre getRules
export interface Validator {
getRules: () => ValidationRule[];
validate: (input: any) => boolean;
}
const StrictValidator: Validator = {
getRules() {
return [
ValidationRule.Required,
ValidationRule.NotEmpty,
ValidationRule.IsNumeric
];
},
validate(input: any) {
// ...
},
};
const BenevolentValidator: Validator = {
getRules() {
return [];
},
validate(input: any) {
// ...
},
};
Hotovo


Príklad 3
// types.ts
import { UserBase } from '@local/entity/user';
export type AdminRightsFormatter = (user: UserBase) => string;
// index.ts
import { UserType } from '@local/entity/user';
import { AdminRightsFormatter } from './types';
export const rightsFormatter: AdminRightsFormatter = function (user) {
if (user.getType() !== UserType.Admin) {
throw new Error(`User ${user.getId()} is not an admin`);
}
return user.getAdminRights().join(', ');
};
src/services/users/adminRights/*

LSP (a dedičnosť) vraví, že namiesto inštancie rodičovského typu viem použiť inštanciu potomka a kód pobeží ďalej.
export const rightsFormatter: AdminRightsFormatter = function (user) {
if (user.getType() !== UserType.Admin) {
throw new Error(`User ${user.getId()} is not an admin`);
}
return user.getAdminRights().join(', ');
};
Vyhodenie výnimky toto pravidlo porušuje, lebo rodičovský typ o tom nehovorí.
Nie je to ani súčasťou neformálneho kontraktu (napr. v komentári k typu)
Odstráňte vyhadzovanie výnimky
import { UserType } from '@local/entity/user';
import { AdminRightsFormatter } from './types';
export const rightsFormatter: AdminRightsFormatter = function (user) {
if (user.getType() !== UserType.Admin) {
return '';
}
return user.getAdminRights().join(', ');
};


Hotovo
Naozaj?
Je v poriadku, keď metóda dostane argument typu, s ktorým neráta?
Ako vieme, že sme pre tento typ zadefinovali správne správanie?
Čo ak by mohlo byť nesprávnych typov viac?
A prečo nám rodič prikazuje robiť s typom, o ktorom by mohol vedieť, že nie je správny?
Opäť máme problém v rodičovi!

Rodičovský typ by nemal definovať príliš široké požiadavky na potomkov.
Mal by potomkom ponúknuť jasné hranice, ale zároveň implementačnú voľnosť.
Nemal by nám prikazovať pracovať s typom, o ktorom môže predpokladať, že nie je správny.
// types.ts
import { UserBase } from '@local/entity/user';
export type AdminRightsFormatter = (user: UserBase) => string;
src/services/users/adminRights/types.ts
- Použitie UserBase nie je správne
- Rodič by o tom mohol vedieť, pretože jeho meno aj umiestnenie hovorí o admin používateľoch
- Preto vymeníme UserBase za správny typ
// types.ts
import { AdminUser } from '@local/entity/user';
export type AdminRightsFormatter = (user: AdminUser) => string;
// index.ts
import { AdminRightsFormatter } from './types';
export const rightsFormatter: AdminRightsFormatter = function (user) {
return user.getAdminRights().join(', ');
};
src/services/users/adminRights/*

Hotovo


Čo by sme robili v prípade, keby AdminUser neexistoval a mali by sme iba typ User so začiatku školenia?
Vytvorili by sme ho

Príklad 4
export type GetFansFunc = () => User[];
export const getFansOfVariousShows: GetFansFunc = function () {
return [
createFan(101, 'Susan', 'Star Trek'),
createFan(102, 'Monica', 'Star Wars'),
createFan(103, 'Peter', 'StarGate - SG1'),
createFan(104, 'John', 'Battlestar Galactica'),
];
};
function createFan(id: number, name: string, tvShow: string) {
let favouriteTvShow = tvShow;
return {
getId: () => id,
getName: () => name,
getType: () => UserType.User,
getFavouriteTvShow: () => favouriteTvShow,
setFavouriteTvShow: (show: string) => {
favouriteTvShow = show;
},
};
}
src/services/fans/fansRepository.ts
function watchFewEpisodesOfB5(fan: User) {
const tvShow = new TvShow('Babylon 5');
tvShow.addFan(fan);
}
const fans = getFansOfVariousShows();
watchFewEpisodesOfB5(fans[0]);
src/components/ListOfFans.tsx
class TvShow {
name: string;
constructor(name: string) {
this.name = name;
}
getName() {
return this.name;
}
addFan(user: User) {
user.setFavouriteTvShow(this.name);
}
}
export default TvShow;
src/services/tvShows/index.ts
class TvShow {
name: string;
constructor(name: string) {
this.name = name;
}
getName() {
return this.name;
}
addFan(user: User) {
// @ts-ignore
user.setFavouriteTvShow(this.name);
}
}
export default TvShow;
src/services/tvShows/index.ts

Programovať by sme mali len voči deklarovaným rozhraniam
Ak nám rozhranie nevyhovuje, tak ho vymeníme
Typ User pre metódu addFan nie je dostatočný, vymeníme ho
export interface Fan extends User {
setFavouriteTvShow: (show: string) => void;
}
export type GetFansFunc = () => Fan[];
export const getFansOfVariousShows: GetFansFunc = function () {
return [
createFan(101, 'Susan', 'Star Trek'),
createFan(102, 'Monica', 'Star Wars'),
createFan(103, 'Peter', 'StarGate - SG1'),
createFan(104, 'John', 'Battlestar Galactica'),
];
};
function createFan(id: number, name: string, tvShow: string) {
let favouriteTvShow = tvShow;
return {
getId: () => id,
getName: () => name,
getType: () => UserType.User,
getFavouriteTvShow: () => favouriteTvShow,
setFavouriteTvShow: (show: string) => {
favouriteTvShow = show;
},
};
}
src/services/fans/fansRepository.ts
function watchFewEpisodesOfB5(fan: Fan) {
const tvShow = new TvShow('Babylon 5');
tvShow.addFan(fan);
}
const fans = getFansOfVariousShows();
watchFewEpisodesOfB5(fans[0]);
src/components/ListOfFans.tsx
class TvShow {
name: string;
constructor(name: string) {
this.name = name;
}
getName() {
return this.name;
}
addFan(user: Fan) {
user.setFavouriteTvShow(this.name);
}
}
export default TvShow;
src/services/tvShows/index.ts
Hotovo


Opakovanie
Odvodená trieda musí byť náhradou základnej triedy
Odvodená trieda musí byť náhradou svojho interfacu
Objekt musí byť náhradou svojho interfacu



Implementácia musí byť náhradou svojho typového predpisu

Byť dobrou náhradou svojej základnej triedy/interfacu
znamená:
- poskytovať implementáciu pre všetky predpísané metódy
- mať rovnaké typy návratových hodnôt
- nezvoľňovať typ návratovej hodnoty
- nesprísňovať požiadavky na argumenty
- nesprísňovať kontrakt
- neobchádzať kontrakt v kóde
Výhody dodržiavania
LSP
- časti kódu sú navzájom bezpečne vymeniteľné
- eliminácia chýb spôsobených nedodržaním kontraktu

Ďakujem za pozornosť
L v SOLIDe (JavaScript)
By Milan Herda
L v SOLIDe (JavaScript)
- 94