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)
- 234