Käitumismustrid

Strateegia (Strategy pattern)

Strateegia muster võimaldab delegeerida "otsustamist" mistahes asjade kohta klassist väljapoole - niiviisi saab taaskasutada juba olemasolevat funktsionaalsust erinevate otstarvete jaoks.

Näide

Note

Näidet saad lähemalt vaadata ja käima panna siin salves. Näide asub strategy moodulis.

Ütleme, et programmeerime mängu või simulatsiooni tarbeks olevusi. Neid olevusi saab füüsika seaduste järgi liigutada. Esitame neid Creature klassiga:

public class Creature {
    private final Vec2 position;
    private final Vec2 velocity = new Vec2(0, 0);

    private Vec2 movementDirection;

    public Creature(Vec2 position, Vec2 direction) {
        this.position = position;
        this.direction = direction;
    }

    public void update() {
        accelerate(direction);
        move();
        drag();
    }

    private void accelerate(Vec2 direction) {
        final double accelerationMagnitude = 0.0125;
        velocity.add(direction.getNormalized().getMultiplied(accelerationMagnitude));
    }

    private void move() {
        position.add(velocity);
    }

    private void drag() {
        final double dragCoefficient = 0.99;
        velocity.multiply(dragCoefficient);
    }

    public void setDirection(Vec2 direction) {
        this.direction = direction.getNormalized();
    }
}

Ehk praegu saame väliselt olevuse suunda setDirection meetodiga muuta, ning olevus seab enda sammud sinnapoole.

Ent mis siis, kui me tahaksime, et olevus otsutaks enda suuna ise, et ellu viia erinevat sorti liikumisloogikaid? Ideaalis tahaksime taaskasutada füüsilise keha loogikat, mida olema juba implementeerinud.

Appi tuleb strateegia muster. Defineerime liidese objektidele, mis saavad olevuse hetkesuunda otsustada:

public interface MovementStrategy {
    Optional<Vec2> getMovementDirection(Creature creature, World world);
}

Strateegia peaks ütlema meile liikumissuuna, seega kirjeldame liidesele meetodi getMovementDirection, mis tagastab Optional<Vec2>. Optional ümbris tähistab seda, et alati ei pruugi olla mõistlikku liikumissuunda, ja sellistel puhkudel ei peaks olevus ühtegi suunda kiirendama.

Strateegial võib otsustamiseks vaja minna infot olevuse kohta, seega on meetodil Creature tüüpi parameeter.

Strateegial võib otsustamiseks minna vaja teada infot maailma kohta (mille oleme lihtsuse huvides seni näitest välja jätnud), milles olevus tegutseb, seega on meetodil World tüüpi parameeter.

Nüüd saame kohandada Creature klassi, et liikumissuund tuleks iga ajahetk strateegiast:

public class Creature {
    private final MovementStrategy movementStrategy;
    private final Vec2 position;
    private final Vec2 velocity = new Vec2(0, 0);

    public Creature(Vec2 position, MovementStrategy movementStrategy) {
        this.movementStrategy = movementStrategy;
        this.position = position;
    }

    public void update(World world) {
        var movementDir = movementStrategy.getMovementDirection(this, world);
        movementDir.ifPresent(this::accelerate);
        move();
        drag();
    }

    private void accelerate(Vec2 direction) {
        final double accelerationMagnitude = 0.0125;
        velocity.add(direction.getNormalized().getMultiplied(accelerationMagnitude));
    }

    private void move() {
        position.add(velocity);
    }

    private void drag() {
        final double dragCoefficient = 0.99;
        velocity.multiply(dragCoefficient);
    }
}

Ja nüüd saame luua mistahes liikumisstrateegiaid, Creature klassist sõltumatult.

Hiirekursorile järgnemine:

public class ChasingStrategy implements MovementStrategy {
    @Override
    public Optional<Vec2> getMovementDirection(Creature creature, World world) {
        var mousePos = world.getMousePosition();
        if (mousePos == null)
            return Optional.empty();
        var creaturePos = creature.getPosition();
        return Optional.of(mousePos.getSubtracted(creaturePos).getNormalized());
    }
}

Hiirekursori eest põgenemine:

public class FleeingStrategy implements MovementStrategy {
    @Override
    public Optional<Vec2> getMovementDirection(Creature creature, World world) {
        var mousePos = world.getMousePosition();
        if (mousePos == null)
            return Optional.empty();
        var creaturePos = creature.getPosition();
        return Optional.of(creaturePos.getSubtracted(mousePos).getNormalized());
    }
}

Kindlate punktide vahel lõpmatult patrullimine, mis demonstreerib ka seda, et strateegial võib olla enda seesmine olek:

public class PatrollingStrategy implements MovementStrategy {
    private final double touchRange = 32;

    private final List<Vec2> patrolPoints;
    private int currentTargetIndex = 0;

    public PatrollingStrategy(List<Vec2> patrolPoints) {
        if (patrolPoints.size() < 2)
            throw new IllegalArgumentException("Not a patrol path unless >= 2 points");
        this.patrolPoints = patrolPoints;
    }

    @Override
    public Optional<Vec2> getMovementDirection(Creature creature, World world) {
        acquireTarget(creature);
        return Optional.of(getCurrentTarget().getSubtracted(creature.getPosition()));
    }

    private void acquireTarget(Creature creature) {
        if (creature.getPosition().distance(getCurrentTarget()) < touchRange)
            setNextPatrolPointAsTarget();
    }

    private Vec2 getCurrentTarget() {
        return patrolPoints.get(currentTargetIndex);
    }

    private void setNextPatrolPointAsTarget() {
        currentTargetIndex = (currentTargetIndex + 1) % patrolPoints.size();
    }
}

Strateegiaid kasutades saame luua erinevalt käituvaid olevusi, samas taaskasutades olevuste füüsikalisi baasomadusi:

world.addCreature(new Creature(new Vec2(700, 700), new ChasingStrategy()));
world.addCreature(new Creature(new Vec2(600, 600), new FleeingStrategy()));
world.addCreature(new Creature(new Vec2(200, 200), new PatrollingStrategy(List.of(
        new Vec2(200, 200),
        new Vec2(500, 200),
        new Vec2(200, 200),
        new Vec2(200, 500)
))));

Lõpptulemus visuaalselt:

../_images/creatures.webp

Olek (State pattern)

Oleku mustriga on hõlbus luua tarkvarakomponente, mis on sarnased olekumasinatele.

Näide

Note

Näidet saad lähemalt vaadata ja käima panna siin salves. Näide asub state moodulis.

Ütleme, et peame programmeerima pangaautomaadi loogikat. Riistvaraline loogika (kaardi lugemine, raha väljastamine), teenused (kasutaja autentimine, ülekannete sooritamine) ja kasutajaliidese loogika on juba meie eest ära tehtud - meie peame vaid kõik õigesti kokku panema.

Probleemi lihtsustatud kuju saab esitada olekudiagrammina:

Note

Võid olekumasina tekstilist kujutust uurida siit

../_images/state-diagram.png

Kui oleme probleemi olekumasinana ära kirjeldanud, muutub probleemi lahendamine lihtsaks:

  • Mullid esitavad meie tarkvarakomponendi võimalikke olekuid

  • Nooled esitavad sõnumeid, mida meie tarkvarakomponent võib saada (kas riistvaralt või kasutajaliidselt)

  • Kõrvalmõjusid (nt. raha väljastamine) saab teostada üleminekute ajal

Alustame implementeerimist. Esiteks peame kirjeldama kõik viisid, kuidas pangaautomaat saab olekut vahetada:

public abstract class AtmState {
    protected final Atm atm;

    public AtmState(Atm atm) {
        this.atm = atm;
    }

    public abstract void insertCard();

    public abstract void enterPin(PinCode code);

    public abstract void selectQuit();

    public abstract void selectBalance();

    public abstract void selectWithdrawals();

    public abstract void withdraw(Withdrawal withdrawal);

    public abstract void onDispensingCashStopped();
}

Iga ülal välja toodud meetod on viis, kuidas pangaautomaat võib enda olekut vahetada (nool olekudiagrammil).

Kuna olekuvahetusel võime tahta pangaautomaati kirjeldava tarkvarakomponendiga suhelda, siis teeme abstraktse klassi, kus igal pärijal on juba viide Atm tüüpi objektile olemas.

Nüüd ainuke vaev on iga olek eraldi defineerida. Siin paar näidet - pane tähele, kuidas igas AtmState klassis meetodeid kutsudes seatakse atm uude olekusse:

public class AwaitingCard extends AtmState {
    public AwaitingCard(Atm atm) {
        super(atm);
    }

    @Override
    public void insertCard() {
        atm.setState(new AwaitingPin(atm));
    }

    @Override
    public void enterPin(PinCode code) {
    }

    @Override
    public void selectQuit() {
    }

    @Override
    public void selectBalance() {
    }

    @Override
    public void selectWithdrawals() {
    }

    @Override
    public void withdraw(Withdrawal amount) {
    }

    @Override
    public void onDispensingCashStopped() {
    }
}
public class AwaitingPin extends AtmState {
    public AwaitingPin(Atm atm) {
        super(atm);
    }

    @Override
    public void insertCard() {
    }

    @Override
    public void enterPin(PinCode code) {
        var cardInfo = atm.getHardware().readCardInfo();
        var authResult = atm.getAuthenticationService().authenticate(cardInfo, code);
        authResult.ifPresentOrElse(
            user -> {
                atm.setCurrentUser(user);
                atm.setState(new InMainMenu(atm));
            },
            () -> {
                atm.getHardware().confiscateCard();  // <- Kõrvalmõju oleku ülemineku ajal, nagu enne mainitud
                atm.setState(new AwaitingCard(atm));
            }
        );
    }

    @Override
    public void selectQuit() {
    }

    @Override
    public void selectBalance() {
    }

    @Override
    public void selectWithdrawals() {
    }

    @Override
    public void withdraw(Withdrawal amount) {
    }

    @Override
    public void onDispensingCashStopped() {
    }
}

Kui oleme kõik üleminekud ära defineerinud, peame vaid Atm klassis kätte saadaud sõnumid parajasti kehtivale AtmState objektile edastama.

Lihtsustatult võib Atm klass välja näha selline:

public class Atm implements HardwareObserver, UiObserver {
    private final HardwareController hardwareController;
    private final AuthenticationService authenticationService;
    private final TransactionService transactionService;

    public Atm(
        HardwareController hardware,
        AuthenticationService authenticationService,
        TransactionService transactionService
    ) {
        this.hardwareController = hardware;
        this.authenticationService = authenticationService;
        this.transactionService = transactionService;
        hardwareController.setMessageListener(this);
        setState(new AwaitingCard(this));
    }

    @Override
    public void onMessageReceived(UiMessage message) {
        if (message instanceof PinEntered msg) {
            state.enterPin(msg.pinCode());
        } else if (message instanceof OptionQuitSelected) {
            state.selectQuit();
        } else if (message instanceof OptionBalanceSelected) {
            state.selectBalance();
        } else if (message instanceof OptionWithdrawalsSelected) {
            state.selectWithdrawals();
        } else if (message instanceof WithdrawalRequested msg) {
            state.withdraw(msg.withdrawal());
        }
    }

    @Override
    public void onMessageReceived(HardwareMessage message) {
        if (message instanceof CardEntered) {
            state.insertCard();
        } else if (message instanceof DispensingCashComplete) {
            state.onDispensingCashStopped();
        }
    }

    public void setState(AtmState state) {
        this.state = state;
    }
}

Ehk iga sõnumi puhul, mis Atm klass saab, kutsume lihtsalt vastavat meetodit AtmState väljal.

Eelised

Vaatame, kuidas näeks välja, kui olekuvahetuste loogika oleks Atm klassis endas:

public class Atm implements HardwareObserver, UiObserver {
    private final HardwareController hardwareController;
    private final AuthenticationService authenticationService;
    private final TransactionService transactionService;

    enum State {
        AWAITING_CARD,
        AWAITING_PIN,
        DISPENSING_CASH,
        DISPLAYING_TRANSACTION_ERROR,
        IN_BALANCE_MENU,
        IN_MAIN_MENU,
        IN_WITHDRAWAL_MENU
    }

    public Atm(
        HardwareController hardware,
        AuthenticationService authenticationService,
        TransactionService transactionService
    ) {
        this.hardwareController = hardware;
        this.authenticationService = authenticationService;
        this.transactionService = transactionService;
        hardwareController.setMessageListener(this);
        this.state = AWAITING_CARD;
    }

    @Override
    public void onMessageReceived(UiMessage message) {
        if (message instanceof PinEntered msg) {
            state = AWAITING_PIN;
        } else if (message instanceof OptionQuitSelected) {
            if (state == IN_WITHDRAWAL_MENU || state == IN_BALANCE_MENU) {
                state = IN_MAIN_MENU;
            } else if (state == IN_MAIN_MENU) {
                hardwareController.ejectCard();
                state = AWAITING_PIN;
            }
        } else if (message instanceof OptionBalanceSelected) {
            if (state == IN_MAIN_MENU)
                state = IN_BALANCE_MENU;
        } else if (message instanceof OptionWithdrawalsSelected) {
            if (state == IN_MAIN_MENU)
                state = IN_WITHDRAWAL_MENU;
        } else if (message instanceof WithdrawalRequested msg) {
            if (!(state == IN_WITHDRAWAL_MENU))
                return;

            var withdrawal = msg.withdrawal();
            var user = getCurrentUser();
            var service = getTransactionService();
            var result = service.tryPerformWithdrawal(withdrawal, user);

            setLastWithdrawal(withdrawal);
            setLastTransactionResult(result);
            if (result.isSuccess()) {
                state = DISPENSING_CASH;
                setLastWithdrawal(withdrawal);
                getHardware().dispenseCash(withdrawal.amount());
            } else {
                state = DISPLAYING_TRANSACTION_ERROR;
            }
        }
    }

    @Override
    public void onMessageReceived(HardwareMessage message) {
        if (message instanceof CardEntered) {
            if (state == AWAITING_CARD) {
                state = AWAITING_PIN;
            }
        } else if (message instanceof DispensingCashComplete) {
            if (state == DISPENSING_CASH) {
                state = IN_WITHDRAWAL_MENU;
            }
        }
    }
}

Arvatavasti oled järgmiste tähelepanekutega nõus:

  • Viimase näitega on raskem saada ülevaadet, millisest olekust mis olekust on võimalik liikuda

  • Iga sõnumi puhul on nüüd vaja kontrollida, kas oleme õiges olekus

  • Probleemile omane keerukus on kontsentreeritud ühte kohta koodis

  • Kui tulevikus peab koodi edasi arendama, siis kuhjub keerukus jälle samasse kohta koodis

  • Kui tulevikus peab koodi muutma, siis peab nüüd olema tähelepanelikum, et mitte vigu sisse tuua

  • Kui tulevikus peab koodi muutma, siis on nüüd raskem leida, kus õige koht muudatuse jaoks on

Loodetavasti oled nõus ka sellega, et eeltoodud tähelepanekud kehtivad vaid oleku mustrit mitte rakendaval lahendusel.

Šabloonmeetod (Template pattern)

Šabloonmeetod võimaldab erinevate objektide ühised meetodid defineerida üks kord ning ühes kohas, samas jättes abstraktseks meetodid, mis sõltuvad objekti omadustest.

Näide

Ütleme, et programmeerime arvutimängu jaoks relvasid. Kõikidel relvadel võiks olla moonakogus ning valik neid tulistada, ent nende käitumine tulistamise hetkel võiks varieeruda.

Loome klassi, mis kirjeldab relvade ühisosa:

public abstract class Weapon {
    private int ammo = 0;

    public Weapon(int initialAmmo) {
        this.ammo = initialAmmo;
    }

    public void fire(Vec2 direction) {
        if (isOutOfAmmo())
            return;

        depleteAmmo();
        onFire(direction);
    }

    private boolean isOutOfAmmo() {
        return ammo == 0;
    }

    private void depleteAmmo() {
        ammo = ammo - 1;
    }

    protected abstract void onFire(Vec2 direction);
}

Pane tähele abstraktset meetodit onFire. See on osa, mis võib relviti varieeruda, ning peab olema alamklassides implementeeritud. Näiteks:

public class Pistol extends Weapon {
    public Pistol(int initialAmmo) {
        super(initialAmmo);
    }

    @Override
    protected void onFire(Vec2 direction) {
        new Bullet().fire(direction);
    }
}
public class RocketLauncher extends Weapon {
    public RocketLauncher(int initialAmmo) {
        super(initialAmmo);
    }

    @Override
    protected void onFire(Vec2 direction) {
        new Rocket().send(direction);
    }
}

Niiviisi saame taaskasutada ühisosa (moonaloogika), ent siiski lubada kindlatel omadustel varieeruda.

Siin näites jäi abstraktseks vaid üks meetod, ent praktikas pole sellel limiiti. Abstraktseks peaksid jääma kõik meetodid, mis võiksid varieeruda.