Kvadrat vs Pravougaonik

4 min

Hajde da dizajniramo OO model pravouganika i kvadrata. Jednostavno, zar ne?

Geometrija je jasna: kvadrat je pravougaonik. Relacija “je” (engl. “is”) označava vezu nasleđivanja, specifikaciju tipa. Krenimo od pravougaonika:

public class Rectangular {
  @Setter @Getter int width;
  @Setter @Getter int height;
}

(Koristim Lombok anotacije radi preglednosti).

Dakle, Rectangle je obična POJO mutabilna klasa. Rekli smo da je Square nasleđuje, pošto kvadrat jeste pravougaonik. Međutim, za kvadrat važi da je dužina jednaka širini, pa je jedini način da se nasleđivanje ostvari sledeći:

public class Square extends Rectangular {
  @Override
  public void setWidth(int w) {
    this.width = w;
    this.height = w;
  }
  @Override
  public void setHeight(int h) {
    this.width = h;
    this.height = h;
  }
}

Čini se da je sve kako treba. Posao završen; komit, merge, idemo dalje.

LSP

Ako je Square isto što i Rectangle, onda bi trebalo da se ponaša identično u svakoj situaciju u kojoj se koristi osnovna klasa. Zamislimo sada metodu koja računa površinu:

public int areaOf(Rectangle rect) {
  return rect.getWidth() * rect.getHeight();
}

Za pravougaonik važi da ako povećamo jednu stranicu n puta, za toliko puta se poveća i njegova površina:

var rect = new Rectangle();

rect.setWidth(2);
rect.setHeight(3);
println(areaOf(rect));  // 6

rect.setWidth(4);
println(areaOf(rect));  // 12

Ako u prvoj liniji zamenimo Rectangle sa Sqaure, da li dobijamo isto ponašanje?

var rect = new Square();

rect.setWidth(2);
println(areaOf(rect));  // 4

rect.setWidth(4);
println(areaOf(rect));  // 16 ?!

Ne. Square se NE ponaša kao Rectangle.

Ovim se narušava jedno od najvažnijih pravila OOP-a: Liskov Substitution Principle (LSP). O njemu i patkama možete pročitati na puuuno mesta mnogo bolja objašnjenja nego što to sam umem da objasnim.

Imutabilno!?

Ha, neko će reći, pa problem je imutabilnost. Da nemamo set metode, stvari bi bile drugačije - nikada ne bi menjali vrednosti:

var rect = new Rectangle(2,3);
println(areaOf(rect));  // 6

rect = new Rectangle(4,3);
println(areaOf(rect));  // 12

rect = new Square(2);
println(areaOf(rect));  // 4

rect = new Square(4);
println(areaOf(rect));  // 16

Nema nejasnoće. Konstruktor Square zabranjuje da uopšte dođe do nepredviđene upotrebe. Time što ograničavamo na jedan argument, dužinu stranice, kvadrat je siguran da će uvek biti upotrebljen na pravi način.

Ne. I dalje je reč o različitom ponašanju dva modela. Prethodni primer nije ispravno napisan:

var rect = new Rectangle(2,3);
println(areaOf(rect));  // 6

rect = new Rectangle(
  rect.getWidth()*2,rect.getHeight());
println(areaOf(rect));  // 12

rect = new Square(2);
println(areaOf(rect));  // 4

rect = new Square(???);

I dalje ostajemo uskraćeni time što pravougaonik opisuju 2 vrednosti, a kvadrat samo jedna.

instanceOf?

Ako ovo nije bilo dovoljno ubedljivo, sledi još jedan primer:

var rect = new Rectangle(2, 2);
println(rect instanceOf Square);  // false

Drugim rečima, pravougaonik koji je zapravo kvadrat - u našem programu to nije!

Rešiti ovo zahteva privatne konstruktore i factory za kreiranje tipova. No ovde bi to bila štaka za programiranje, koja ne rešava suštinski problem, već nudi zakrpu.

Dakle?

Ispostavlja se da je postojanje klase Square sasvim upitno: nema opravdanog razloga da postoji! Ne možemo je uključiti u OOP model, a da ga ne narušimo.

Ako bi nam baš bila neophodna informacija o kvadratu (iz razloga koji ne mogu da zamislim), ona bi se dobavljala na drugi način.

Na primer:

public class Rectangle() {
  // static ctor
  public static Rectangle asSquare(int side) {
    return new Rectangle(side, side);
  }

  // ...ostatak klase

  public boolean isSquare() {...}
}

Ili pak:

public class Rectangle() {
  public Optional<Square> asSquare() {...}
}
public interface Square{
  int getSide();
}

Korak dalje bi bio da ne postoji ni klasa Rectangle, već interfejs; a da se geometrijska figura kreira kompozicijom osobina - no to već umnogo zavisi od toga šta zaista konstruišemo.

Naglasio bih:

Nasleđivanje NIJE preslikavanje relacije “JESTE” iz realnog sveta.

Zvuči pomalo kao film “Inception”. Zato ću ponoviti na drugi način:

Nasleđivanje je ponovno definisanje funkcija i varijabli u podskupu (sub-scope).

Inače, ovaj primer je star… pa, bar nekoliko decenija, a i dalje se lome koplja oko njega. Ne bi trebalo. Priznajem, nije lako odmah uočiti narušavanje LSP principa; pogrešno smo naučeni da slepo preslikavamo realni svet u kod. U poslednje vreme razmišljam… maštam, zapravo… o Ujedinjenoj Teoriji Modelovanja - nepogrešivom načinu modelovanja koda, primenjivoj teoriji koja se bavi kohezijom, uvezanošću, bojama, entropijom i vektorima. Pristup koji nepobitno i jednostavno donosi odgovore na ovakva pitanja.

Ok, sam ću pronaći izlaz.

🧧
Nisam definisan svojim stavovima. Stavove usvajamo, menjamo, nadograđujemo, ali oni ne čine nas same. Manje je važno da li se slažemo, koliko da se razumemo.