Ders 14 / 16
İhtiyacın Olmayacak
Ücret hesabına bugünden eklenen bir eklenti noktasıyla yapılandırma katmanının karar noktası, dal kapsamı ve çalışmayan yol sayısıyla ölçülmesi; gelen gereksinimin bu yapıya uymadığının başarısız koşumla gösterilmesi ve genelleştirmenin atılması.
İçindekiler
Önceki derste her adım gelmiş bir gereksinime karşılık geldi ve dört kural beş adımda karşılandı. Gelmemiş bir gereksinim için bugünden yazılan yapının ne kadara mal olduğu ölçülmedi. Bu ders ücret hesabına bugünden bir eklenti noktası ile bir yapılandırma katmanı ekler, ikisinin ürettiği karar noktalarını ve hiç çalışmayan yolları sayar, sonra gerçekten gelen gereksinimin bu yapıya uymadığını gösterir.
İlkenin adı ihtiyacın olmayacak (you aren’t gonna need it): bir yetenek, ona duyulan gereksinim ortaya çıkana kadar yazılmaz. İlke bir tahmin yasağıdır — yazılım geliştiricinin gelecekteki gereksinimi doğru tahmin edemeyeceğini değil, tahminin yanlış çıkma maliyetinin ölçülebilir olduğunu söyler.
Başlangıç noktası önceki dersin son adımıdır.
// tarife.mjs — kademe bilgisinin tek kaynagi export const KADEME = [ { ustGram: 1000, kurus: 4990, ad: 'kucuk' }, { ustGram: 5000, kurus: 7490, ad: 'orta' }, { ustGram: 10000, kurus: 9900, ad: 'buyuk' }, { ustGram: 20000, kurus: 12900, ad: 'cok-buyuk' }, { ustGram: Infinity, kurus: 24900, ad: 'agir' }, ]; export function kademe(gram) { return KADEME.find((k) => gram <= k.ustGram); }
// ucret.mjs — onceki dersin son adimindan devralindi import { kademe } from './tarife.mjs'; const BOLGE_KATSAYI = { B1: 1.0, B2: 1.25, B3: 1.6 }; const ASGARI_KURUS = 6990; export function etkinAgirlik(g) { return Math.max(g.gram, Math.ceil((g.en * g.boy * g.yukseklik) / 3)); } export function gonderiUcreti(g) { const bolgeli = Math.round(kademe(etkinAgirlik(g)).kurus * BOLGE_KATSAYI[g.bolge]); return Math.max(bolgeli, ASGARI_KURUS); }
// ucret.test.mjs — onceki dersten devralinan dort test import { test } from 'node:test'; import assert from 'node:assert/strict'; import { gonderiUcreti } from './ucret.mjs'; const kutu = { en: 20, boy: 15, yukseklik: 10 }; test('kademe ucreti agirliktan okunur', () => { assert.equal(gonderiUcreti({ ...kutu, gram: 3000, bolge: 'B1' }), 7490); }); test('bolge katsayisi taban ucreti carpar', () => { assert.equal(gonderiUcreti({ ...kutu, gram: 3000, bolge: 'B3' }), 11984); }); test('asgari ucret bolge carpimindan sonra uygulanir', () => { assert.equal(gonderiUcreti({ ...kutu, gram: 500, bolge: 'B2' }), 6990); }); test('hacim agirligi gercek agirliktan buyukse o kullanilir', () => { assert.equal(gonderiUcreti({ en: 40, boy: 30, yukseklik: 30, gram: 500, bolge: 'B2' }), 16125); });
Ölçü için bir sayaç gerekiyor: bir dosyadaki karar noktalarını, yani programın iki ayrı yoldan birini seçtiği yerleri sayan bir betik.
// dal.mjs — verilen dosyalardaki karar noktalarini sayar import { readFileSync } from 'node:fs'; const ORUNTU = /\bif\b|\bcase\b|\?\?|&&|\|\||\?\.|\?(?![?.])/g; let toplam = 0; for (const yol of process.argv.slice(2)) { const n = (readFileSync(yol, 'utf8').match(ORUNTU) ?? []).length; console.log(`${yol}: ${n} karar noktasi`); toplam += n; } console.log(`toplam: ${toplam}`);
node --test --test-reporter=tap ucret.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))' node dal.mjs ucret.mjs
ok 1 - kademe ucreti agirliktan okunur ok 2 - bolge katsayisi taban ucreti carpar ok 3 - asgari ucret bolge carpimindan sonra uygulanir ok 4 - hacim agirligi gercek agirliktan buyukse o kullanilir # tests 4 # pass 4 # fail 0 ucret.mjs: 0 karar noktasi toplam: 0
Bugünden Yazılan İki Esneklik
Tarife kuralları zamanla değişecek. Bu doğru bir gözlem ve yanlış bir gerekçe: değişimin geleceğini bilmek, hangi biçimde geleceğini bilmek anlamına gelmez. Yine de iki yapı bugünden yazılıyor. Birincisi, hesabın her ayrıntısını dışarıdan seçilebilir kılan bir yapılandırma katmanı.
// yapilandirma.mjs — bugunden yazilan yapilandirma katmani export const VARSAYILAN = { hacimUygula: true, hacimBolen: 3, asgariUygula: true, asgariKurus: 6990, yuvarlama: 'yakin', katsayiKaynagi: 'sabit', }; export function yapilandir(secenek = {}) { return { ...VARSAYILAN, ...secenek }; }
İkincisi, ileride gelecek ücret kurallarının hesaba dışarıdan takılmasını sağlayan bir eklenti noktası (extension point).
// eklenti.mjs — ucret kurallari icin eklenti noktasi const kurallar = []; export function kuralEkle(ad, oncelik, uygula) { kurallar.push({ ad, oncelik, uygula }); kurallar.sort((a, b) => a.oncelik - b.oncelik); } export function kurallariSifirla() { kurallar.length = 0; } export function kurallariUygula(kurus, g, yap) { let sonuc = kurus; for (const k of kurallar) { sonuc = k.uygula(sonuc, g, yap); } return sonuc; }
Ücret hesabı ikisini de kullanacak biçimde yeniden yazılıyor. Davranış aynı kalıyor: varsayılan yapılandırmayla ve kayıtlı kural olmadan sonuçlar değişmez.
// ucret.mjs — bugunden genellestirilmis surum import { kademe } from './tarife.mjs'; import { yapilandir } from './yapilandirma.mjs'; import { kurallariUygula } from './eklenti.mjs'; const SABIT_KATSAYI = { B1: 1.0, B2: 1.25, B3: 1.6 }; function katsayi(g, yap) { if (yap.katsayiKaynagi === 'dis') return yap.katsayiTablosu?.[g.bolge] ?? 1; return SABIT_KATSAYI[g.bolge] ?? 1; } function yuvarla(kurus, yap) { if (yap.yuvarlama === 'yok') return kurus; if (yap.yuvarlama === 'yukari') return Math.ceil(kurus); return Math.round(kurus); } export function etkinAgirlik(g, yap) { if (!yap.hacimUygula) return g.gram; return Math.max(g.gram, Math.ceil((g.en * g.boy * g.yukseklik) / yap.hacimBolen)); } export function gonderiUcreti(g, secenek = {}) { const yap = yapilandir(secenek); const bolgeli = yuvarla(kademe(etkinAgirlik(g, yap)).kurus * katsayi(g, yap), yap); const asgarili = yap.asgariUygula ? Math.max(bolgeli, yap.asgariKurus) : bolgeli; return kurallariUygula(asgarili, g, yap); }
Ödenen Bedelin Ölçülmesi
Testler değişmedi ve yeşil kalıyor. Değişen şey testlerin kapsayabildiği yapının büyüklüğü. Karar noktaları ile birlikte, koşumun gerçekten hangi yolları geçtiğini veren dal kapsamı da ölçülüyor.
node --test --test-reporter=tap ucret.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))' node dal.mjs ucret.mjs yapilandirma.mjs eklenti.mjs node --test --experimental-test-coverage --test-reporter=tap ucret.test.mjs 2>&1 \ | sed -n '/start of coverage report/,/end of coverage report/p'
ok 1 - kademe ucreti agirliktan okunur ok 2 - bolge katsayisi taban ucreti carpar ok 3 - asgari ucret bolge carpimindan sonra uygulanir ok 4 - hacim agirligi gercek agirliktan buyukse o kullanilir # tests 4 # pass 4 # fail 0 ucret.mjs: 8 karar noktasi yapilandirma.mjs: 0 karar noktasi eklenti.mjs: 0 karar noktasi toplam: 8 # start of coverage report # ----------------------------------------------------------------- # file | line % | branch % | funcs % | uncovered lines # ----------------------------------------------------------------- # eklenti.mjs | 63.16 | 66.67 | 33.33 | 5-7 10-11 16-17 # tarife.mjs | 100.00 | 100.00 | 100.00 | # ucret.mjs | 100.00 | 45.45 | 100.00 | # yapilandirma.mjs | 100.00 | 100.00 | 100.00 | # ----------------------------------------------------------------- # all files | 90.41 | 63.16 | 80.00 | # ----------------------------------------------------------------- # end of coverage report
Üç sayı okunmaya değer. Karar noktası sayısı sıfırdan sekize çıktı; bunların hiçbiri bir
alan kuralından gelmiyor, hepsi bir seçeneğin hangi değerde olduğunu sınıyor. Ücret
dosyasının satır kapsamı yüzde 100 ama dal kapsamı yüzde 45,45: her satır çalışıyor,
yolların yarısından fazlası çalışmıyor. Eklenti dosyasında kuralEkle ve kurallariSifirla
hiç çağrılmadığı için işlev kapsamı yüzde 33,33’te kalıyor ve kapsanmayan satırlar tek tek
listeleniyor.
Bu yolların bakımsız kalmasının bir yolu yok: dal kapsamını yüzde 100’e çıkarmak, altı ek karar noktasının her biri için en az bir test daha yazmak demek. Bu testlerin sınayacağı davranışı bugün hiçbir çağrı yeri istemiyor. Yapılandırma katmanı altı alan taşıyor ve çağrı yerlerinin geçtiği seçenek sayısı sıfır; eklenti listesine kayıtlı kural sayısı da sıfır.
Gelen Gereksinim
Sözleşmeli müşteriler için bir kural geliyor: bir fatura döneminde elliden çok gönderisi olan müşterinin o dönemdeki bütün gönderilerine yüzde on iki indirim uygulanacak. Eklenti noktası tam bu tür kurallar için yazılmıştı; kural oraya takılmayı deniyor.
// donem.mjs — donem indirimini eklenti noktasi uzerinden yazma denemesi import { gonderiUcreti } from './ucret.mjs'; import { kuralEkle, kurallariSifirla } from './eklenti.mjs'; const ESIK = 50; const ORAN = 0.88; export function donemUcreti(gonderiler) { kurallariSifirla(); let sayac = 0; kuralEkle('donem-indirimi', 10, (kurus) => { sayac += 1; return sayac > ESIK ? Math.round(kurus * ORAN) : kurus; }); return gonderiler.reduce((t, g) => t + gonderiUcreti(g), 0); }
// donem.test.mjs — gelen gereksinimin tek testi import { test } from 'node:test'; import assert from 'node:assert/strict'; import { donemUcreti } from './donem.mjs'; const donem = Array.from({ length: 60 }, () => ({ en: 20, boy: 15, yukseklik: 10, gram: 3000, bolge: 'B1', })); test('esigi asan donemde butun gonderiler indirimli toplanir', () => { assert.equal(donemUcreti(donem), Math.round(60 * 7490 * 0.88)); });
node --test --test-reporter=tap donem.test.mjs \ | grep -E '^ *(ok|not ok|expected:|actual:|# (tests|pass|fail))'
not ok 1 - esigi asan donemde butun gonderiler indirimli toplanir expected: 395472 actual: 440410 # tests 1 # pass 0 # fail 1
Aradaki 44.938 kuruşluk fark, eklenti noktasının yanlış eksende genelleştirdiğini
gösteriyor. Kural imzası (kurus, gonderi, yapilandirma) biçimindedir: kural tek bir
gönderiyi görür. Gelen gereksinim ise bir gönderinin ücretini kümenin büyüklüğüne göre
belirliyor; elli birinci gönderi işlenirken önceki ellisinin ücreti çoktan toplanmış
oluyor. Eklenti bunu ancak kümeyi iki kez dolaşarak karşılayabilir, o zaman da toplamayı
yapan çağıran taraf işin tamamını üstlenmiş olur; eklenti noktası bir işe yaramaz.
Genelleştirme, gelecekteki değişimin gönderi başına bir kural biçiminde geleceğini varsaymıştı. Gerçekte gelen değişim gönderi başına değil dönem başına. Yapılandırma katmanında da benzeri geçerli: altı alanın hiçbiri indirim eşiğini ya da oranını karşılamıyor.
Genelleştirmeyi Atmak
Atılan yapının büyüklüğü sayılabilir.
printf 'atilan dosyalarin satir sayisi: %s\n' \ "$(cat yapilandirma.mjs eklenti.mjs donem.mjs | wc -l | tr -d ' ')" rm yapilandirma.mjs eklenti.mjs donem.mjs donem.test.mjs
atilan dosyalarin satir sayisi: 48
Yerine gelen gereksinim doğrudan karşılanıyor: dönem ücretini hesaplayan bir fonksiyon, tek eşik ve tek oran.
// ucret.mjs — genellestirme atildi, gelen gereksinim dogrudan karsilandi import { kademe } from './tarife.mjs'; const BOLGE_KATSAYI = { B1: 1.0, B2: 1.25, B3: 1.6 }; const ASGARI_KURUS = 6990; const INDIRIM_ESIGI = 50; const INDIRIM_ORANI = 0.88; export function etkinAgirlik(g) { return Math.max(g.gram, Math.ceil((g.en * g.boy * g.yukseklik) / 3)); } export function gonderiUcreti(g) { const bolgeli = Math.round(kademe(etkinAgirlik(g)).kurus * BOLGE_KATSAYI[g.bolge]); return Math.max(bolgeli, ASGARI_KURUS); } export function donemUcreti(gonderiler) { const toplam = gonderiler.reduce((t, g) => t + gonderiUcreti(g), 0); if (gonderiler.length <= INDIRIM_ESIGI) return toplam; return Math.round(toplam * INDIRIM_ORANI); }
// ucret.test.mjs — dort eski test ve donem indiriminin iki testi import { test } from 'node:test'; import assert from 'node:assert/strict'; import { gonderiUcreti, donemUcreti } from './ucret.mjs'; const kutu = { en: 20, boy: 15, yukseklik: 10 }; const donem = (adet) => Array.from({ length: adet }, () => ({ ...kutu, gram: 3000, bolge: 'B1' })); test('kademe ucreti agirliktan okunur', () => { assert.equal(gonderiUcreti({ ...kutu, gram: 3000, bolge: 'B1' }), 7490); }); test('bolge katsayisi taban ucreti carpar', () => { assert.equal(gonderiUcreti({ ...kutu, gram: 3000, bolge: 'B3' }), 11984); }); test('asgari ucret bolge carpimindan sonra uygulanir', () => { assert.equal(gonderiUcreti({ ...kutu, gram: 500, bolge: 'B2' }), 6990); }); test('hacim agirligi gercek agirliktan buyukse o kullanilir', () => { assert.equal(gonderiUcreti({ en: 40, boy: 30, yukseklik: 30, gram: 500, bolge: 'B2' }), 16125); }); test('esigi asan donemde butun gonderiler indirimli toplanir', () => { assert.equal(donemUcreti(donem(60)), Math.round(60 * 7490 * 0.88)); }); test('esigin altindaki donemde indirim uygulanmaz', () => { assert.equal(donemUcreti(donem(10)), 10 * 7490); });
node --test --test-reporter=tap ucret.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))' node dal.mjs ucret.mjs node --test --experimental-test-coverage --test-reporter=tap ucret.test.mjs 2>&1 \ | sed -n '/start of coverage report/,/end of coverage report/p'
ok 1 - kademe ucreti agirliktan okunur ok 2 - bolge katsayisi taban ucreti carpar ok 3 - asgari ucret bolge carpimindan sonra uygulanir ok 4 - hacim agirligi gercek agirliktan buyukse o kullanilir ok 5 - esigi asan donemde butun gonderiler indirimli toplanir ok 6 - esigin altindaki donemde indirim uygulanmaz # tests 6 # pass 6 # fail 0 ucret.mjs: 1 karar noktasi toplam: 1 # start of coverage report # ----------------------------------------------------------- # file | line % | branch % | funcs % | uncovered lines # ----------------------------------------------------------- # tarife.mjs | 100.00 | 100.00 | 100.00 | # ucret.mjs | 100.00 | 100.00 | 100.00 | # ----------------------------------------------------------- # all files | 100.00 | 100.00 | 100.00 | # ----------------------------------------------------------- # end of coverage report
Tek karar noktası kaldı ve o karar noktası bir alan kuralına karşılık geliyor: dönem eşiğin altında mı, değil mi. Dal kapsamı yüzde 100; kod tabanında koşumun geçmediği yol yok. Ücret dosyası bir modül ithal ediyor, önceki sürüm üçünü ithal ediyordu.
| Ölçüt | Genelleştirilmiş | Doğrudan |
|---|---|---|
| Karar noktası | 8 | 1 |
| Ücret dosyasının dal kapsamı | %45,45 | %100 |
| Bütün dosyaların satır kapsamı | %90,41 | %100 |
| İthal edilen modül | 3 | 1 |
| Gelen gereksinimi karşılıyor mu | hayır | evet |
İlkenin Ne Yasakladığı
Bu ders ile önceki ders farklı şeyler ölçtü. Basit tut ilkesi gelmiş bir gereksinim için en az yapıyı ister; ihtiyacın olmayacak ilkesi gelmemiş bir gereksinim için yapı yazılmasını yasaklar. Bir tanesi bugünkü işin biçimiyle, diğeri yarınki iş hakkındaki tahminle ilgilidir.
İlkenin sınırı, atmanın maliyetindedir. Burada atılan 48 satır kitaplığın içinde kaldı;
hiçbir dış tüketiciyi etkilemedi, git deposundaki tek bir işlemeyle geri alınabilirdi.
Aynı hesap, dışarıya yayımlanmış bir arayüzde ya da geriye dönük dönüştürme gerektiren
kalıcı bir veri biçiminde başka sonuç verir: orada yanlış tahminin bedeli tahmini
yapmamanın bedelinden büyük olabilir. İlkeyi uygulanabilir kılan soru “bu ileride gerekir
mi” değil, “gerektiğinde eklemek bugün eklemekten ne kadar pahalı” sorusudur. Ücret
kitaplığında bu fark sıfıra yakındı: dönem indirimi, hiçbir eklenti noktası olmadan altı
satırda yazıldı.
Özet
- İhtiyacın olmayacak ilkesi gelmemiş bir gereksinim için bugünden yapı yazılmasını yasaklar; ölçülen büyüklük tahminin yanlış çıkma maliyetidir.
- Bugünden yazılan yapılandırma katmanı ile eklenti noktası, ücret dosyasının karar noktası sayısını sıfırdan sekize çıkardı ve dal kapsamını yüzde 45,45’e düşürdü.
- Satır kapsamının yüzde 100 olması yolların çalıştığı anlamına gelmez; çalışmayan yollar dal ve işlev kapsamında görünür.
- Gerçekten gelen gereksinim gönderi başına değil dönem başınaydı; eklenti noktasının imzası bunu ifade edemedi ve tek testli koşum 44.938 kuruşluk farkla kırmızı bitti.
- Genelleştirmenin atılması 48 satır sildi; doğrudan çözüm tek karar noktası, yüzde 100 dal kapsamı ve altı testle aynı gereksinimi karşıladı.
- İlkenin ölçütü “ileride gerekir mi” değil, gerektiğinde eklemenin bugün eklemeye göre ne kadar pahalı olduğudur.
Sonraki Adım
Genelleştirme atıldıktan sonra kalan dosya çalışıyor ama düzenlenmiş değil: bölge
katsayısı tablosu tarifeye ait bilgiyi ücret dosyasında tutuyor, dönem hesabındaki eşik
karşılaştırması adsız duruyor ve g adı bir gönderiyi temsil ettiğini söylemiyor. Sonraki
ders bu üç kusuru üç mekanik teknikle giderir — ayıklama, taşıma ve yeniden adlandırma —
her tekniği ara adımlarıyla uygular ve her adımdan sonra test koşumunu gösterir.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.