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)

  • 156