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](../_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](../_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.