İçeriğe geç
academia.sh

Ders 03 / 19

Liskov Yerine Koyma İlkesi

Alt tip sözleşmesinin ön koşul ve son koşul üzerinden tanımlanması: üst tip üzerinden çalışan bir sözleşme sınamasının kayıttaki bütün tarifelerle koşturulması, ön koşulu güçlendiren ve son koşulu zayıflatan iki tarifenin sınamayı kırması ve sözleşmeyi bozmadan aynı gereksinimin karşılanması.

İçindekiler

Açık–kapalı ilkesi tarife türünü bir kayda taşıdı; hesap modülü hangi tarifeyi aldığını sormadan çalıştı. Bu, kaydın içindeki her nesnenin aynı sözleşmeyi gerçekten karşıladığı varsayımına dayanıyor. Varsayım yazılı değildi: kayıt yalnız taban ve aciklama adlarının var olduğunu görüyordu, bu işlevlerin neyi kabul edip ne döndürdüğünü görmüyordu.

Liskov yerine koyma ilkesi (Liskov substitution principle) o varsayımı bir kurala çevirir: üst tipin beklendiği her yerde alt tiplerinden biri, programın doğruluğunu bozmadan konabilmelidir. TypeScript kursunda tanımlanan alt tip ilişkisi tip denetleyicisinin gördüğü bir yapı uyumudur; bu ilke aynı ilişkiyi davranış düzeyinde ister. Kural iki maddeye iner: alt tip, üst tipin kabul ettiğinden daha azını isteyemez (ön koşulu güçlendiremez) ve üst tipin vaat ettiğinden daha azını veremez (son koşulu zayıflatamaz).

Sözleşmenin Yazılması

Sözleşme üst tipin yanında, yorum olarak değil, sınanabilir bir biçimde durmalıdır. İlk adım üst tipi tanımlamak ve beklentiyi açıkça yazmaktır.

// tarife.mjs — ust tip ve karsilanmasi gereken sozlesme
export class Tarife {
  constructor(ad) {
    this.ad = ad;
  }

  // On kosul: gonderi agirlik, en, boy, yukseklik ve adres alanlarini tasir.
  // Son kosul: pozitif tam sayi (kurus) doner, ayni gonderi icin ayni deger.
  taban(gonderi) {
    throw new Error(`${this.ad}: taban gerceklestirilmedi (${typeof gonderi})`);
  }

  // Son kosul: bos olmayan bir dize doner.
  aciklama(gonderi) {
    throw new Error(`${this.ad}: aciklama gerceklestirilmedi (${typeof gonderi})`);
  }
}
// tarifeler.mjs — sozlesmeyi karsilayan uc alt tip
import { Tarife } from "./tarife.mjs";

export class AgirlikTarifesi extends Tarife {
  constructor() { super("agirlik"); }
  taban(g) { return g.agirlik <= 1 ? 3900 : g.agirlik <= 5 ? 6400 : 11800; }
  aciklama(g) { return `agirlik kademesi (${g.agirlik} kg)`; }
}

export class SabitTarife extends Tarife {
  constructor() { super("sabit"); }
  taban() { return 4500; }
  aciklama() { return "sabit ucret"; }
}

export class HacimTarifesi extends Tarife {
  constructor() { super("hacim"); }
  desi(g) { return Math.ceil((g.en * g.boy * g.yukseklik) / 5000); }
  taban(g) { return 2600 + this.desi(g) * 700; }
  aciklama(g) { return `hacim tarifesi (${this.desi(g)} desi)`; }
}

Kayıt bir önceki dersteki işi görür; tek farkı nesne yerine örnek tutmasıdır.

// kayit.mjs — tarife kaydi: ad ile erisim ve kayitli tarifelerin listesi
const TABLO = new Map();

export function kaydet(t) {
  TABLO.set(t.ad, t);
}

export function tarife(ad) {
  const t = TABLO.get(ad);
  if (t === undefined) throw new RangeError(`bilinmeyen tarife: ${ad}`);
  return t;
}

export const hepsi = () => [...TABLO.values()];
// kurulum.mjs — sozlesmeyi karsilayan uc tarifeyi kayda yazar
import { kaydet } from "./kayit.mjs";
import { AgirlikTarifesi, SabitTarife, HacimTarifesi } from "./tarifeler.mjs";

kaydet(new AgirlikTarifesi());
kaydet(new SabitTarife());
kaydet(new HacimTarifesi());
// ucret.mjs — hesap: tarifenin turunu bilmez, yalniz sozlesmeye guvenir
import { tarife } from "./kayit.mjs";

const BOLGE = { "34": 100, "06": 115, "65": 140 };
const katsayi = (adres) => BOLGE[adres.slice(0, 2)] ?? 160;

export const ucret = (g) => Math.round((tarife(g.tarife).taban(g) * katsayi(g.adres)) / 100);
export const aciklama = (g) => tarife(g.tarife).aciklama(g);
// kur.mjs — bilesim koku: kurulumu yukler, ucret satirlarini yazar
import "./kurulum.mjs";
import { ucret, aciklama } from "./ucret.mjs";

const ORNEK = [
  { tarife: "agirlik", agirlik: 0.8, en: 20, boy: 20, yukseklik: 15, adres: "34100" },
  { tarife: "sabit", agirlik: 3.0, en: 30, boy: 40, yukseklik: 50, adres: "06500" },
  { tarife: "hacim", agirlik: 12.0, en: 60, boy: 40, yukseklik: 40, adres: "65200" },
];

for (const g of ORNEK) console.log(`${ucret(g)} kurus  ${aciklama(g)}`);
node kur.mjs
3900 kurus  agirlik kademesi (0.8 kg)
5175 kurus  sabit ucret
23240 kurus  hacim tarifesi (20 desi)

Üst Tip Üzerinden Çalışan Sınama

Sözleşme sınaması alt tiplerin adlarını bilmez. Kayıttaki her tarifeyi sırayla alır ve üst tipin iki maddesini denetler. Ölçüsü tek bir sayıdır: kırılan sınama sayısı.

// sozlesme-sinamasi.mjs — ust tip uzerinden calisir, alt tiplerin adini bilmez
import test from "node:test";
import assert from "node:assert/strict";
import { Tarife } from "./tarife.mjs";
import { hepsi } from "./kayit.mjs";

// Ust tipin kabul ettigi gonderiler. Hicbir alt tip bunlari reddedemez.
const GONDERILER = [
  { agirlik: 0.8, en: 20, boy: 20, yukseklik: 15, adres: "34100" },
  { agirlik: 3.0, en: 30, boy: 40, yukseklik: 50, adres: "06500" },
  { agirlik: 12.0, en: 60, boy: 40, yukseklik: 40, adres: "65200" },
];

const dene = (f) => {
  try { return { deger: f(), hata: null }; } catch (e) { return { deger: null, hata: e.message }; }
};

await import(process.env.KURULUM ?? "./kurulum.mjs");

for (const t of hepsi()) {
  test(`${t.ad}: ust tipin alt tipi`, () => {
    assert.ok(t instanceof Tarife, `${t.ad} ust tipten turemiyor`);
  });
  test(`${t.ad}: on kosul guclendirilmedi`, () => {
    for (const g of GONDERILER) {
      const s = dene(() => t.taban(g));
      assert.ok(s.hata === null, `${t.ad} ${g.adres} gonderisini reddetti: ${s.hata}`);
    }
  });
  test(`${t.ad}: son kosul zayiflatilmadi`, () => {
    for (const g of GONDERILER) {
      const s = dene(() => t.taban(g));
      assert.ok(Number.isInteger(s.deger) && s.deger > 0,
        `${t.ad} ${g.adres} icin taban = ${s.hata ?? s.deger}`);
      assert.ok(dene(() => t.aciklama(g)).deger?.length > 0, `${t.ad} ${g.adres} aciklamasi bos`);
    }
  });
}

Kurulum modülü bir ortam değişkeninden okunuyor; aynı sınama böylece farklı kayıtlarla koşturulabilir. Rapor biçimi zaman ve dosya yolu içerdiği için süre ve konum satırları ayıklanmıştır.

node --test --test-reporter=tap sozlesme-sinamasi.mjs 2>&1 |
  grep -E '^(ok|not ok|# (tests|pass|fail))'
ok 1 - agirlik: ust tipin alt tipi
ok 2 - agirlik: on kosul guclendirilmedi
ok 3 - agirlik: son kosul zayiflatilmadi
ok 4 - sabit: ust tipin alt tipi
ok 5 - sabit: on kosul guclendirilmedi
ok 6 - sabit: son kosul zayiflatilmadi
ok 7 - hacim: ust tipin alt tipi
ok 8 - hacim: on kosul guclendirilmedi
ok 9 - hacim: son kosul zayiflatilmadi
# tests 9
# pass 9
# fail 0

Sözleşmeyi Bozan İki Alt Tip

İki yeni gereksinim geliyor: yurt dışı gönderileri için ayrı bir tarife ve hafif gönderilere ücretsiz kampanya. İkisi de üst tipten türetilerek, akla ilk gelen biçimde yazılmıştır.

// bozan-tarifeler.mjs — sozlesmeyi iki ayri yerden bozan alt tipler
import { Tarife } from "./tarife.mjs";

// On kosulu guclendirir: ust tip her gonderiyi kabul ederken bu alt tip `ulke` ister.
export class YurtdisiTarifesi extends Tarife {
  constructor() { super("yurtdisi"); }
  taban(g) {
    if (g.ulke === undefined) throw new RangeError("yurtdisi: ulke alani zorunlu");
    return 9800 + Math.ceil(g.agirlik) * 1200;
  }
  aciklama(g) { return `yurtdisi (${g.ulke})`; }
}

// Son kosulu zayiflatir: ust tip pozitif tam sayi vaat ederken bu alt tip 0 dondurur.
export class KampanyaTarifesi extends Tarife {
  constructor() { super("kampanya"); }
  taban(g) { return g.agirlik <= 1 ? 0 : 3900; }
  aciklama() { return "kampanya"; }
}
// kurulum-bozuk.mjs — ayni uc tarife, ustune sozlesmeyi bozan iki tarife
import "./kurulum.mjs";
import { kaydet } from "./kayit.mjs";
import { YurtdisiTarifesi, KampanyaTarifesi } from "./bozan-tarifeler.mjs";

kaydet(new YurtdisiTarifesi());
kaydet(new KampanyaTarifesi());

Aynı sınama, değiştirilmeden, ikinci kurulumla koşturulur.

KURULUM=./kurulum-bozuk.mjs node --test --test-reporter=tap sozlesme-sinamasi.mjs 2>&1 |
  grep -E '^(not ok|  error:|# (tests|pass|fail))'
not ok 11 - yurtdisi: on kosul guclendirilmedi
  error: 'yurtdisi 34100 gonderisini reddetti: yurtdisi: ulke alani zorunlu'
not ok 12 - yurtdisi: son kosul zayiflatilmadi
  error: 'yurtdisi 34100 icin taban = yurtdisi: ulke alani zorunlu'
not ok 15 - kampanya: son kosul zayiflatilmadi
  error: 'kampanya 34100 icin taban = 0'
# tests 15
# pass 12
# fail 3

Sınama sayısı dokuzdan on beşe çıktı, üçü kırıldı. Her iki tarife de extends Tarife yazıyor ve iki yöntemi de tanımlıyor; yapı uyumu tamam. Kırılan, davranış sözleşmesidir.

İhlalin Koşumdaki Karşılığı

Kırılan sınama soyut bir uyarı değil. Hesap modülü hiç değişmeden, aynı kayıt üzerinden çalıştırıldığında sonuç görünür.

// kur-bozuk.mjs — hesap modulu degismeden, bozan tarifelerle calisir
import "./kurulum-bozuk.mjs";
import { ucret, aciklama } from "./ucret.mjs";

const ORNEK = [
  { tarife: "kampanya", agirlik: 0.8, en: 20, boy: 20, yukseklik: 15, adres: "34100" },
  { tarife: "yurtdisi", agirlik: 3.0, en: 30, boy: 40, yukseklik: 50, adres: "06500" },
];

for (const g of ORNEK) {
  try {
    console.log(`${g.tarife.padEnd(9)} -> ${ucret(g)} kurus  ${aciklama(g)}`);
  } catch (e) {
    console.log(`${g.tarife.padEnd(9)} -> ${e.constructor.name}: ${e.message}`);
  }
}
node kur-bozuk.mjs
kampanya  -> 0 kurus  kampanya
yurtdisi  -> RangeError: yurtdisi: ulke alani zorunlu

İki sonuç iki ayrı ihlal biçimini gösteriyor. Güçlendirilmiş ön koşul bir hataya dönüştü: hesap modülü kendi gönderdiği veriyle reddedildi. Zayıflatılmış son koşul daha sessiz — sıfır kuruşluk bir ücret üretildi ve hiçbir yerde hata görünmedi. İkinci biçim tehlikelidir, çünkü ancak faturaya bakan biri fark eder.

Sözleşmeyi Bozmadan Aynı Gereksinim

İki gereksinim de karşılanabilir; sözleşmeyi bozan, gereksinimlerin kendisi değil, yazılışlarıdır. Ön koşul, eksik alanı hata yerine tanımlı bir varsayılana çevirerek genişletilir. Son koşul, indirimi asgari ücretle sınırlayarak korunur.

// uyumlu-tarifeler.mjs — ayni iki gereksinim, sozlesme bozulmadan
import { Tarife } from "./tarife.mjs";

// On kosul genisletilmedi: ulke alani yoksa adresten turetilen yurtici degeri kullanilir.
export class YurtdisiTarifesi extends Tarife {
  constructor() { super("yurtdisi"); }
  taban(g) { return 9800 + Math.ceil(g.agirlik) * 1200; }
  aciklama(g) { return `yurtdisi (${g.ulke ?? "yurtici"})`; }
}

// Son kosul korundu: indirim asgari ucretin altina inemez.
export class KampanyaTarifesi extends Tarife {
  constructor() { super("kampanya"); }
  taban(g) { return Math.max(1500, g.agirlik <= 1 ? 0 : 3900); }
  aciklama() { return "kampanya"; }
}
// kurulum-uyumlu.mjs — uc tarife, ustune sozlesmeyi karsilayan iki tarife
import "./kurulum.mjs";
import { kaydet } from "./kayit.mjs";
import { YurtdisiTarifesi, KampanyaTarifesi } from "./uyumlu-tarifeler.mjs";

kaydet(new YurtdisiTarifesi());
kaydet(new KampanyaTarifesi());
KURULUM=./kurulum-uyumlu.mjs node --test --test-reporter=tap sozlesme-sinamasi.mjs 2>&1 |
  grep -E '^(not ok|  error:|# (tests|pass|fail))'
# tests 15
# pass 15
# fail 0

On beş sınamanın tamamı geçiyor. Üçüncü bir seçenek de vardır ve bazen doğrusu odur: bir gereksinim üst tipin kabul ettiğinden gerçekten fazlasını istiyorsa, o gereksinim bir alt tip değildir. Yurt dışı ücretlendirmesi ülke bilgisi olmadan anlamsızsa, Tarife kaydına yazılmamalı, kendi sözleşmesiyle ayrı bir tip olmalıdır. İlkenin söylediği “her şey aynı üst tipten türesin” değil, “türeyen her şey üst tipin sözünü tutsun” biçimindedir.

Sözleşmenin Kapsamı

Ön koşul ve son koşul sözleşmenin tamamı değildir. Üst tipin koruduğu değişmezler de alt tipte geçerli kalmalıdır: taban aynı gönderi için hep aynı değeri döndürüyorsa, alt tip rastgele bir katsayı ekleyemez. Sınamanın üç maddesi bu yüzden aynı gönderi listesini kullanır; bir alt tip ikinci çağrıda farklı sonuç verirse üçüncü madde kırılır.

Sözleşmenin yazılı olmadığı durumda ne olduğu da ölçüldü: kayıt beş tarifeyi de kabul etti, hesap modülü ikisini de çalıştırdı, biri sessizce yanlış ücret üretti. Sözleşme yazılınca aynı kayıt üç kırık sınama verdi. Fark, kodun kendisinde değil, hatanın nerede görüldüğünde.

Özet

  • Liskov yerine koyma ilkesi alt tipin yapı uyumunu değil davranış uyumunu ister: üst tipin beklendiği yerde alt tip programın doğruluğunu bozmadan kullanılabilmelidir.
  • Kural iki maddedir: alt tip ön koşulu güçlendiremez ve son koşulu zayıflatamaz.
  • Sözleşme sınaması üst tip üzerinden yazılır ve alt tiplerin adını bilmez; kayıttaki her tipi aynı ölçütle geçirir.
  • Üç uyumlu tarifeyle 9 sınamanın tamamı geçti; ön koşulu güçlendiren ve son koşulu zayıflatan iki tarife eklendiğinde 15 sınamanın 3’ü kırıldı.
  • Güçlendirilmiş ön koşul koşumda hataya dönüştü, zayıflatılmış son koşul sessizce sıfır kuruşluk ücret üretti; ikinci biçim sözleşme sınaması olmadan görünmez.

Sonraki Adım

Sözleşme sınaması iki yöntem üzerinden yürüdü ve her tarife ikisini de gerçekten kullanıyordu. Tarife arayüzü büyüdüğünde bu kolaylık kalkar. Taşıyıcı seçimi, rota planlama ve teslimat durumu tek bir taşıyıcı arayüzünde toplandığında, yalnız ücret hesabıyla ilgilenen bir istemci de kullanmadığı yöntemlere bağlanır; sahte bir taşıyıcı yazmak için o yöntemlerin hepsini doldurmak gerekir. Sonraki ders istemcilerin gerçekten çağırdığı yöntem sayısı ile arayüzün dayattığı yöntem sayısını ölçer ve şişkin arayüzün sahte bağımlılık düzeneğine maliyetini sayar.

İlerlemeni kaydetmek ve not almak için Giriş yap

Notlarım

Not almak için giriş yapmalısın.

Aramak için yazmaya başlayın.

↑↓ Esc gezin · aç · kapat