L - Liskov Substitution Principle
Sissejuhatus
Objects of a subclass should be substitutable for objects of their parent class without breaking the correctness of the program.
Alamklassid tuleb kirjeldada nii, et objekti kasutaja jaoks ei ole vahet, kui kasutusse antakse alamklassi tüüpi objekt. Seda määratleb Liskov Substittion Principle (LSP) ehk Liskovi asendusprintsiip, mis on nimetatud Barbara Liskovi järgi, kes formaliseeris selle mõtte 1987. aastal.
Varasemalt õpitust peaks juba selge olema upcasting ehk ülemklassi tüüpi muutujasse alamklassi tüüpi objekti salvestamine. Selle korrektsust tagab mingil määral juba kompilaator, kuid viimasel puudub igasugune ülevaade selle üle, kas tegemist on nii-öelda semantiliselt korrektse IS-A seosega. Seda puudujääki üritab LSP lahendada, sõnastades järgmise mõtte: "iga kood, mis töötab korrektselt ülemtüübiga, peab jätkuvalt korrektselt töötama ka siis, kui talle antakse alamklassi objekt."
Pärilikkus, mis rikub reegleid
Vaatleme ristkülikut kujutavat Rectangle klassi:
class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int area() { return width * height; }
}
Ruutu võime ka lugeda ristkülikuks - mõlemal on neli külge, ruudul kõik küljed võrdse pikkusega.
Tundub loomulik antud juhul pärilikkust kasutada ning laiendada Rectangle klassi:
class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // enforce the square constraint
}
@Override
public void setHeight(int height) {
this.width = height; // enforce the square constraint
this.height = height;
}
}
Loome nüüd meetodi, mis mis kahekordistab suvalise ristküliku laiuse ja kontrollib tulemust:
void doubleWidth(Rectangle r) {
int originalHeight = r.height;
r.setWidth(r.width * 2);
// Expected: area = (2 * width) * height
assert r.area() == r.width * originalHeight : "Area calculation is wrong";
}
Rectangle klassi puhul on implementatsioon korrektne ning assert läheb puhtalt läbi.
Square klassi puhul tekiks viga, kuna setWidth meetod muudab ka kõrgust.
Rectangle rect = new Rectangle();
rect.setWidth(4);
rect.setHeight(3);
doubleWidth(rect); // area = 8 * 3 = 24 — assertion passes
Square sq = new Square();
sq.setWidth(4);
doubleWidth(sq); // setWidth(8) also sets height to 8 — area = 64, not 24 — assertion fails
Väljakutsuja kasutas Rectangle klassi ja eeldas Rectangle-le omapärast käitumist.
Square-i edastamine rikkus selle ootuse ilma, et kompilaatori hoiataks selle eest.
See läheb LSP printsiibiga vastuollu.
Probleemist lähemalt
Polümorfismi peatükis oli eesmärgiks kirjutada koodi, mis töötaks ükskõik millise alamklassi tüüpi objektiga, ilma, et teaksime mis see täpselt oleks:
for (Vehicle v : vehicles) {
v.drive();
}
See töötab ohutult ainult siis, kui iga alamklass järgib ülemklassist tulevat lepingut. Kui alamklass muudab vaadeldavat käitumist ootamatul viisil, ei saa väljakutsuja enam ülemklassi usaldada. Ohutus, mida polümorfism lubab, kaob.
Samas peatükis hoiatati ka tiheda instanceof kasutuse vastu ning et see on märk halvast tüübihierarhiast.
LSP rikkumised on sageli selle põhjuseks - väljakutsujad on sunnitud kontrollima tegelikku tüüpi, sest alamklass ei käitu usaldusväärselt nagu ülemklass.
LSP rakendamine
Probleemi juur peitub selles, et ruut ei ole käitumuslikus mõttes tegelikult ristkülik. Matemaatiliselt on iga ruut ristkülik, kuid muudetavuse seisukohalt käituvad need erinevalt: ristküliku puhul on ainult laiuse määramine korrektne, ruudu puhul ei ole ainult laiuse määramine lubatav (kuna ruudu kõik küljed on ühe suurused).
Lahendus on modelleerida see suhe ilma muudetava päriluseta. Mõlemad kujundid võivad implementeerida ühist liidest:
interface Shape {
int area();
}
class Rectangle implements Shape {
private int width;
private int height;
public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}
public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
@Override
public int area() { return width * height; }
}
class Square implements Shape {
private int side;
public Square(int side) {
this.side = side;
}
public void setSide(int side) { this.side = side; }
@Override
public int area() { return side * side; }
}
Square ei pärine enam Rectangle-ist.
Mõlemad teostavad Shape liidest, mis määratleb ära ainult area() meetodit.
Kood mis sõltub Shape tüübist võib kasutada nüüd ohutulit nii Square kui ka Rectangle tüüpi:
List<Shape> shapes = List.of(new Rectangle(4, 3), new Square(5));
for (Shape s : shapes) {
System.out.println(s.area());
}
// 12
// 25
doubleWidth meetodit enam ei eksisteeri.
See oli seotud Rectangle-i muudetavuse lepinguga, mida Square-il polnud võimalik järgida.
Tüübihierarhia kontrollimine
Pärilussuhet disainides küsi endalt, kas iga ülemklassi meetodit saab kutsuda alamklassi peal ilma, et see üllataks väljakutsujat mingil määral?
Järgnevad on tüüpilised hoiatusmärgid LSP printsiibi rikkumise kohta:
- Alamklass kirjutab meetodi üle ja viskab
UnsupportedOperationExceptionvõi tagastab mõttetu väärtuse. - Alamklass lisab eeltingimusi, mida ülemklassil ei olnud (nt ülemklass aktsepteerib kõiki positiivseid arve, alamklass lükkab tagasi arvud üle 100).
- Testid, mis läbivad ülemklassi puhul, ebaõnnestuvad, kui kasutatakse alamklassi.
LSP rikkumised tulenevad sageli IS-A suhtest, mis kehtib matemaatiliselt või kontseptuaalselt, kuid mitte käitumuslikult. Ruut on geomeetrias ristkülik. Kuid käitumse poolest ruut ei käitu nagu ristkülik. Kui käitumuslikku lepingut ei ole võimalik säilitada, eelista pärilikkuse asemel ühist liidest.