İçeriğe geç
academia.sh

Ders 12 / 16

Kendini Tekrar Etme

Aynı bilginin iki yerde durmasıyla tesadüfen benzeyen iki kodun ayrılması, ayrışan kademe sınırlarının sayılması ve iki çağrı yerini tek fonksiyonda birleştirmenin ürettiği yanlış soyutlamanın bayrak sayısıyla ölçülmesi.

İçindekiler

Arayüz ve Veri Kararları konusu, aynı işin projede tek biçimde yapılmasıyla kapandı. Buraya kadar verilen kararların ortak yanı yerel olmalarıydı: bir adın niyeti açıklaması, bir imzanın boş değer geçirmemesi, bir fonksiyonun tek soyutlama düzeyinde durması. Her biri tek bir dosyaya, çoğu zaman tek bir satıra bakarak denetlenebilirdi.

Kod tabanı büyüdükçe bu denetimin yetmediği bir maliyet doğar. Aynı bilgi birden çok yerde durur ve bir gün biri güncellenip diğeri unutulur; bugün gerekmeyen bir esneklik bugünden yazılır ve her okuyanın bedelini ödediği bir dallanmaya dönüşür; bir kararı değiştirmek kaç dosyaya dokunmayı gerektirdiğiyle ölçülür hâle gelir. Bu konu üç ilkeyi bu maliyet üzerinden ele alır. İlki, aynı bilginin tek bir yerde durmasını isteyen kendini tekrar etme ilkesidir (don’t repeat yourself).

İlkenin sık rastlanan okunuşu “aynı satırları iki kez yazma” biçimindedir ve yanlıştır. Birbirinden ayrılması gereken iki durum vardır: aynı bilginin iki yerde durması ile şu an tesadüfen birbirine benzeyen iki kod.

Bilgi Yinelemesi

Gönderi ücretlendirme ve yönlendirme kitaplığında ağırlık kademeleri iki modülde geçiyor. Birincisi taban ücreti hesaplıyor.

// ucret.mjs — agirlik kademesine gore taban ucret (kurus)
const KADEME = [
  { ustGram: 1000, kurus: 4990 },
  { ustGram: 5000, kurus: 7490 },
  { ustGram: 20000, kurus: 12900 },
  { ustGram: Infinity, kurus: 24900 },
];

export function agirlikUcreti(gram) {
  return KADEME.find((k) => gram <= k.ustGram).kurus;
}

İkincisi operasyon ekranında gönderinin hangi kademede olduğunu gösteriyor.

// ozet.mjs — operasyon ekranindaki kademe rozeti
const KADEME = [
  { ustGram: 1000, ad: 'kucuk' },
  { ustGram: 5000, ad: 'orta' },
  { ustGram: 20000, ad: 'buyuk' },
  { ustGram: Infinity, ad: 'agir' },
];

export function kademeAdi(gram) {
  return KADEME.find((k) => gram <= k.ustGram).ad;
}

İki tablo aynı görünmüyor: birinde kuruş, diğerinde ad var. Yinelenen şey kod değil, kademe sınırlarının nerede olduğu bilgisidir. Bu bilginin tek olup olmadığı ölçülebilir: iki fonksiyonun çıktısının değiştiği gram değerleri karşılaştırılır.

// sinir.mjs — iki dosyanin kademe sinirlarini karsilastirir
import { agirlikUcreti } from './ucret.mjs';
import { kademeAdi } from './ozet.mjs';

function sinirlar(f) {
  const s = [];
  for (let g = 2; g <= 25000; g++) if (f(g) !== f(g - 1)) s.push(g);
  return s;
}

const ucret = sinirlar(agirlikUcreti);
const rozet = sinirlar(kademeAdi);
const ayrisan = ucret.filter((g) => !rozet.includes(g)).length
  + rozet.filter((g) => !ucret.includes(g)).length;

console.log('ucret sinirlari:', ucret.join(' '));
console.log('rozet sinirlari:', rozet.join(' '));
console.log('ayrisan sinir sayisi:', ayrisan);
ucret sinirlari: 1001 5001 20001
rozet sinirlari: 1001 5001 20001
ayrisan sinir sayisi: 0

Bugün sıfır. Yinelemenin maliyeti bugünde değil, ilk değişikliktedir. Tarifeye 10 kilogramlık bir ara kademe eklendiğini, değişikliği yapan kişinin ücret dosyasını bulup rozet dosyasını görmediğini varsayalım.

// ucret.mjs — 10 kg kademesi eklendi
const KADEME = [
  { ustGram: 1000, kurus: 4990 },
  { ustGram: 5000, kurus: 7490 },
  { ustGram: 10000, kurus: 9900 },
  { ustGram: 20000, kurus: 12900 },
  { ustGram: Infinity, kurus: 24900 },
];

export function agirlikUcreti(gram) {
  return KADEME.find((k) => gram <= k.ustGram).kurus;
}
node sinir.mjs
ucret sinirlari: 1001 5001 10001 20001
rozet sinirlari: 1001 5001 20001
ayrisan sinir sayisi: 1

Ayrışan sınır sayısı bire çıktı. Bunun alan karşılığı şudur: 5.001 ile 10.000 gram arasındaki her gönderi için müşteriye 99,00 TL’lik kademe ücreti yansıtılırken operasyon ekranı gönderiyi “buyuk” rozetiyle gösteriyor. İki modül aynı gönderi hakkında farklı şey söylüyor ve hiçbir test kırılmadı, çünkü her modülün testi kendi tablosuna göre yazılmıştı.

Çözüm, bilgiyi tek bir yere taşımaktı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 — tarifeden okur
import { kademe } from './tarife.mjs';

export function agirlikUcreti(gram) {
  return kademe(gram).kurus;
}
// ozet.mjs — ayni tarifeden okur
import { kademe } from './tarife.mjs';

export function kademeAdi(gram) {
  return kademe(gram).ad;
}
node sinir.mjs
ucret sinirlari: 1001 5001 10001 20001
rozet sinirlari: 1001 5001 10001 20001
ayrisan sinir sayisi: 0

Kazanılan şey satır sayısı değil. Kademe sınırlarını değiştirmek için dokunulan dosya sayısı ikiden bire indi ve ayrışma imkânı ortadan kalktı: iki modül aynı satırı okuduğu için tutarsız olamazlar.

Tesadüfi Benzerlik

İlkenin ikinci yüzü, birbirine benzeyen ama aynı bilgiyi taşımayan kodlardır. Kitaplıkta iki belge satırı üretiliyor: birincisi pakete yapıştırılan etiket, ikincisi muhasebeye giden fatura satırı.

// belge.mjs — surum 0: iki cagri yeri, bugun ayni metin
export function gonderiEtiketi(g) {
  return `${g.no} ${g.bolge} ${(g.kurus / 100).toFixed(2)} TL`;
}

export function faturaSatiri(g) {
  return `${g.no} ${g.bolge} ${(g.kurus / 100).toFixed(2)} TL`;
}

İki gövde karakter karakter aynı. Kendini tekrar etme ilkesinin yüzeysel okunuşu bunları birleştirmeyi söyler. Oysa iki satırın aynı olması bir bilgi değil, bir rastlantı: etiketin biçimini depo operasyonu, fatura satırının biçimini muhasebe belirliyor ve bu iki merci birbirinden habersiz karar veriyor. Kodun Aktöre Göre Düzenlenmesi dersindeki ölçüt burada geçerlidir: iki parçanın değişme nedenleri farklıysa aynı gövdede durmamalıdırlar.

Birleştirmenin ne ürettiğini görmek için birleştirilmiş sürümü yazmak gerekir. İlk iki farklılaşma talebi geldiğinde ortak fonksiyon şu hâli alıyor.

// belge.mjs — surum 1: iki cagri yeri tek fonksiyonda birlestirildi
export function belgeSatiri(g, { kdvEkle = false, rotaGoster = false, isoParaBirimi = false } = {}) {
  const tutar = kdvEkle ? Math.round(g.kurus * 1.2) : g.kurus;
  const alan = rotaGoster ? g.rota : g.bolge;
  const birim = isoParaBirimi ? 'TRY' : 'TL';
  return `${g.no} ${alan} ${(tutar / 100).toFixed(2)} ${birim}`;
}

export const gonderiEtiketi = (g) => belgeSatiri(g, { rotaGoster: true });
export const faturaSatiri = (g) => belgeSatiri(g, { kdvEkle: true, isoParaBirimi: true });

Bu, Arayüz ve Veri Kararları konusunda bölünmesi öğütlenen mantıksal bayrak parametrelerinin üç tanesinin tek imzada toplanmış hâlidir. Bayrak uzayının ne kadarının gerçekten kullanıldığı sayılabilir.

// yollar.mjs — bayrak uzayi ile gercekten kullanilan yollari karsilastirir
import { belgeSatiri } from './belge.mjs';

const bayraklar = ['kdvEkle', 'rotaGoster', 'isoParaBirimi'];
const gonderi = { no: 'G-1042', bolge: 'B2', rota: 'IST-ANK', kurus: 7490 };

const tumu = new Set();
for (let m = 0; m < 2 ** bayraklar.length; m++) {
  const secenek = Object.fromEntries(bayraklar.map((b, i) => [b, Boolean(m & (1 << i))]));
  tumu.add(belgeSatiri(gonderi, secenek));
}
const kullanilan = new Set([
  belgeSatiri(gonderi, { rotaGoster: true }),
  belgeSatiri(gonderi, { kdvEkle: true, isoParaBirimi: true }),
]);

console.log('bayrak sayisi:', bayraklar.length);
console.log('kombinasyon sayisi:', tumu.size);
console.log('cagri yerlerinin kullandigi:', kullanilan.size);
console.log('hic calismayan kombinasyon:', tumu.size - kullanilan.size);
bayrak sayisi: 3
kombinasyon sayisi: 8
cagri yerlerinin kullandigi: 2
hic calismayan kombinasyon: 6

Sekiz kombinasyondan altısı hiçbir zaman çalışmıyor. Bu altı yol yine de okunuyor, yine de bakımı yapılıyor ve fonksiyonu okuyan herkes bunların mümkün olduğunu varsaymak zorunda kalıyor. Yanlış soyutlama budur: gövdesi ortak, sözleşmesi ortak olmayan bir fonksiyon.

Yanlış Soyutlamanın Ölçülen Bedeli

Asıl bedel, bayrak sayısında değil, iki çağrı yerinin birbirini kısıtlamasında görünür. İki çağrı yerinin sözleşmesi testlerle sabitlenmiş durumda.

// belge.test.mjs — iki cagri yerinin sozlesmesi
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { gonderiEtiketi, faturaSatiri } from './belge.mjs';

const gonderi = { no: 'G-1042', bolge: 'B2', rota: 'IST-ANK', kurus: 7490 };

test('etiket rota kodunu ve KDVsiz tutari yazar', () => {
  assert.equal(gonderiEtiketi(gonderi), 'G-1042 IST-ANK 74.90 TL');
});

test('fatura satiri bolgeyi ve KDVli tutari ISO birimiyle yazar', () => {
  assert.equal(faturaSatiri(gonderi), 'G-1042 B2 89.88 TRY');
});
node --test --test-reporter=tap belge.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
ok 1 - etiket rota kodunu ve KDVsiz tutari yazar
ok 2 - fatura satiri bolgeyi ve KDVli tutari ISO birimiyle yazar
# tests 2
# pass 2
# fail 0

Muhasebe yeni bir kural bildiriyor: fatura tutarı tam TL’ye yuvarlanacak. Bu, yalnız fatura satırını ilgilendiren bir karar. Değişiklik ortak fonksiyondaki tutar hesabına yazılıyor.

// belge.mjs — surum 2: tam TL yuvarlama ortak yere yazildi
export function belgeSatiri(g, { kdvEkle = false, rotaGoster = false, isoParaBirimi = false } = {}) {
  const ham = kdvEkle ? Math.round(g.kurus * 1.2) : g.kurus;
  const tutar = Math.round(ham / 100) * 100;
  const alan = rotaGoster ? g.rota : g.bolge;
  const birim = isoParaBirimi ? 'TRY' : 'TL';
  return `${g.no} ${alan} ${(tutar / 100).toFixed(2)} ${birim}`;
}

export const gonderiEtiketi = (g) => belgeSatiri(g, { rotaGoster: true });
export const faturaSatiri = (g) => belgeSatiri(g, { kdvEkle: true, isoParaBirimi: true });

Fatura testinin beklentisi yeni kurala göre güncelleniyor; etiket testi olduğu gibi bırakılıyor, çünkü etiket hakkında hiçbir karar değişmedi.

// belge.test.mjs — fatura beklentisi yeni gereksinime gore guncellendi
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { gonderiEtiketi, faturaSatiri } from './belge.mjs';

const gonderi = { no: 'G-1042', bolge: 'B2', rota: 'IST-ANK', kurus: 7490 };

test('etiket rota kodunu ve KDVsiz tutari yazar', () => {
  assert.equal(gonderiEtiketi(gonderi), 'G-1042 IST-ANK 74.90 TL');
});

test('fatura satiri tam TL yuvarlanmis tutari yazar', () => {
  assert.equal(faturaSatiri(gonderi), 'G-1042 B2 90.00 TRY');
});
node --test --test-reporter=tap belge.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
not ok 1 - etiket rota kodunu ve KDVsiz tutari yazar
ok 2 - fatura satiri tam TL yuvarlanmis tutari yazar
# tests 2
# pass 1
# fail 1

Muhasebeye ait bir karar, depo operasyonuna ait bir çıktıyı bozdu. Etiketteki tutar 74,90 TL yerine 75,00 TL yazılmaya başladı. Kısıtlama sayısal olarak şudur: yuvarlamanın yalnız faturaya uygulanması için dördüncü bir bayrak gerekir, bayrak sayısı üçten dörde, kombinasyon sayısı sekizden on altıya çıkar, gerçekten kullanılan yol sayısı iki kalır. Her farklılaşma talebi kombinasyon uzayını iki katına çıkarır ve kullanılan yol sayısını sabit bırakır.

Ayırmanın Maliyeti

Doğru hamle birleştirmeyi geri almaktır. İki gövde, ait oldukları kararla birlikte yeniden ayrılır.

// belge.mjs — surum 3: benzerlik tesadufiydi, iki fonksiyon ayrildi
export function gonderiEtiketi(g) {
  return `${g.no} ${g.rota} ${(g.kurus / 100).toFixed(2)} TL`;
}

export function faturaSatiri(g) {
  const kdvli = Math.round(g.kurus * 1.2);
  const tamTl = Math.round(kdvli / 100) * 100;
  return `${g.no} ${g.bolge} ${(tamTl / 100).toFixed(2)} TRY`;
}
node --test --test-reporter=tap belge.test.mjs | grep -E '^ *(ok|not ok|# (tests|pass|fail))'
printf 'ucluk isleci sayisi: %s\n' "$(grep -c ' ? ' belge.mjs)"
ok 1 - etiket rota kodunu ve KDVsiz tutari yazar
ok 2 - fatura satiri tam TL yuvarlanmis tutari yazar
# tests 2
# pass 2
# fail 0
ucluk isleci sayisi: 0

Bayrak sayısı üçten sıfıra, dallanma sayısı üçten sıfıra indi. Yinelenen görünen tek şey g.no ve toFixed(2) çağrısı; ikisi de bilgi değil, dilin sözdizimi. İki fonksiyonun bundan sonra ayrı ayrı değişmesi, birinin diğerini bozma ihtimali olmadan mümkün.

İki durumu ayıran ölçüt tek bir sorudur: iki parçadan biri değiştiğinde diğerinin de değişmesi zorunlu mu? Kademe sınırlarında zorunluydu; sınır bir yerde kaydığında diğer yerde de kaymalıydı, kaymadığı için sistem tutarsızlaştı. Belge satırlarında zorunlu değildi; muhasebe kararı depo çıktısını değiştirmemeliydi, değiştirdiği için test kırıldı. Yineleme sorusu satırlara değil, değişim nedenlerine bakılarak yanıtlanır.

Özet

  • Kendini tekrar etme ilkesi satır benzerliğiyle değil bilginin tekliğiyle ilgilidir; aynı bilginin iki yerde durması ile tesadüfen benzeyen iki kod ayrı durumlardır.
  • Yinelenen bilginin ayrışması ölçülebilir: kademe tablosu iki dosyada tutulduğunda tek taraflı bir değişiklik ayrışan sınır sayısını sıfırdan bire çıkardı ve hiçbir test kırılmadı.
  • Bilgiyi tek kaynağa taşımak dokunulan dosya sayısını ikiden bire indirdi ve iki modülün tutarsızlaşma imkânını kaldırdı.
  • Tesadüfen benzeyen iki gövdeyi birleştirmek yanlış soyutlama üretir: üç bayrak, sekiz kombinasyon ve gerçekten kullanılan iki yol.
  • Yanlış soyutlamanın bedeli çağrı yerlerinin birbirini kısıtlamasıdır; muhasebeye ait bir yuvarlama kararı etiket testini kırdı ve ayrımı korumak dördüncü bir bayrak gerektirdi.
  • Ayırma kararı tek soruyla verilir: biri değiştiğinde diğerinin değişmesi zorunlu mu?

Sonraki Adım

Bu derste iki değişiklik yapıldı ve ikisi de küçüktü: bir tablo tek dosyaya taşındı, bir fonksiyon ikiye ayrıldı. Adımların küçük olması rastlantı değil, bir yöntem. Sonraki ders aynı gereksinimi iki farklı biçimde karşılar — tek seferde tasarlanmış bir çözümle ve küçük adımlarla evrilen bir çözümle — ve her adımda dokunulan satır sayısı ile testlerin yeşil kaldığı adım sayısı karşılaştırılır.

İ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