SOLID İlkelerini TypeScript’e Uygulama

typescript solid

Uzun zaman önce tanımlanan SOLID ilkeleri, nesne yönelimli tasarımların okunabilirliğini, uyarlanabilirliğini, genişletilebilirliğini ve sürdürülebilirliğini iyileştirmeyi amaçlar. Nesne yönelimli sınıf tasarımının beş SOLID ilkesi, birçok geliştiricinin her zaman ve her yerde kullanabileceği anlaşılır, test edilmiş yazılımların geliştirilmesini kolaylaştırır. Bu yazıda typescript için geçerli olabilecek prensiplerden bahsedeceğiz.

  • S: Single-responsibility prensibi
  • O: Open-closed prensibi
  • L: Liskov substitution prensibi
  • I: Interface segregation prensibi
  • D: Dependency inversion prensibi

S: Single-responsibility principle (Tek sorumluluk ilkesi)

Tek sorumluluk ilkesine göre, bir sınıf yalnızca bir etkinlikten sorumlu olmalı ve yalnızca bir değişiklik nedeni olmalıdır. Bu kural ayrıca modülleri ve işlevleri de içerir. Aşağıdaki örneği ele alalım:

Tek bir görev fikri, yukarıdaki Student sınıfında kırılmıştır. Sonuç olarak, Student sınıfını farklı sorumluluk durumlarına ayırmalıyız. SOLID’e göre sorumluluk fikri, değişmek için bir sebeptir.

				
					class Student {
  public createStudentAccount(){
    //
  }

  public calculateStudentGrade(){
    //
  }

  public generateStudentData(){
    // 
  }
}
				
			

Değişmek için bir neden belirlemek için programımızın sorumluluklarının neler olduğuna bakmamız gerekir. Student sınıfını üç farklı nedenden dolayı değiştirebiliriz:

  1. createStudentAccount hesaplama mantığı değişir
  2. Öğrenci notlarını hesaplama mantığı değişir
  3. Öğrenci verilerini oluşturma ve raporlama biçimi değişir

Tek sorumluluk ilkesi, yukarıdaki üç maddenin Student sınıfına üç farklı sorumluluk yüklediğini vurgular:

				
					class StudentAccount {
  public calculateStudentAccount(){
    //
  }
}


class StudentGrade {
  public calculateStudentGrade(){
    //
  }
}


class StudentData {
  public generateStudentData(){
    //
  }
}
				
			

Artık sınıfları böldüğümüze göre, her birinin yalnızca bir görevi, bir sorumluluğu ve yapılması gereken tek bir değişikliği var. Şimdi, kodumuzun açıklaması ve anlaşılması daha basit.

O: Open-closed principle (Açık-Kapalı ilkesi)

Açık-kapalı ilkesine göre, yazılım varlıkları gelişmeye açık, değişikliğe kapalı olmalıdır. Bu yaklaşımın arkasındaki temel kavram, mevcut kodda değişiklik gerektirmeden yeni işlevler ekleyebilmemiz gerektiğidir:

				
					class Triangle {
  public base: number;
  public height: number;
  constructor(base: number, height: number) {
    this.base = base;
    this.height = height;
  }
}


class Rectangle {
  public width: number;
  public height: number;
  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }
}
				
			

Bir şekil koleksiyonunun alanını hesaplayan bir fonksiyon geliştirmek istediğimizi düşünelim. Mevcut tasarımımızla, aşağıdakine benzer bir şey görünebilir:

				
					function computeAreasOfShapes(
  shapes: Array<Rectangle | Triangle>
) {
  return shapes.reduce(
    (computedArea, shape) => {
      if (shape instanceof Rectangle) {
        return computedArea + shape.width * shape.height;
      }
      if (shape instanceof Triangle) {
        return computedArea + shape.base * shape.height * 0.5 ;
      }
    },
    0
  );
}
				
			

Bu yöntemin sorunu, her yeni şekil eklediğimizde, computeAreasOfShapes işlevimizi değiştirmek zorunda kalmamız ve böylece açık-kapalı kavramını ihlal etmemizdir. Bunu göstermek için Çember adında başka bir şekil ekleyelim:

				
					class Circle {
  public radius: number;
  constructor(radius: number) {
    this.radius = radius;
  }
}
				
			

Açık-kapalı ilkesine karşı, computeAreasOfShapes işlevinin bir daire örneğine dönüşmesi gerekir:

				
					function computeAreasOfShapes(
  shapes: Array<Rectangle | Triangle | Circle>
) {
  return shapes.reduce(
    (calculatedArea, shape) => {
      if (shape instanceof Rectangle) {
        return computedArea + shape.width * shape.height;
      }
      if (shape instanceof Triangle) {
        return computedArea + shape.base * shape.height * 0.5 ;
      }
      if (shape instanceof Circle) {
        return computedArea + shape.radius * Math.PI;
      }
    },
    0
  );
}
				
			

Bu sorunu, tüm şekillerimizin alanı döndüren bir yöntemi olmasını zorunlu kılarak çözebiliriz:

				
					interface ShapeAreaInterface {
  getArea(): number;
}
				
			

Şimdi, shapes sınıfının, ShapeArea’nın getArea() yöntemini çağırması için tanımlı arabirimimizi uygulaması gerekecek:

				
					class Triangle implements ShapeAreaInterface {
  public base: number;
  public height: number;
  constructor(base: number, height: number) {
    this.base = base;
    this.height = height;
  }

  public getArea() {
    return this.base * this.height * 0.5
  }
}


class Rectangle implements ShapeAreaInterface {
  public width: number;
  public height: number;
  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }
  public getArea() {
    return this.width * this.height;
  }
}
				
			

Artık tüm şekillerimizin getArea yöntemine sahip olduğundan emin olduğumuza göre, onu daha fazla kullanabiliriz. ComputeAreasOfShapes fonksiyonumuzdan kodumuzu aşağıdaki gibi güncelleyelim:

				
					function computeAreasOfShapes(
  shapes: Shape[]
) {
  return shapes.reduce(
    (computedArea, shape) => {
      return computedArea + shape.getArea();
    },
    0
  );
}
				
			

Artık her yeni şekil eklediğimizde computeAreasOfShapes işlevimizi değiştirmek zorunda değiliz. Bunu Circle shape sınıfıyla test edebilirsiniz. Genişletmek için açık hale getiriyoruz, ancak değişiklik için kapatıyoruz.

L: Liskov Substitution Principle (Liskov Yerine Geçme İlkesi)

Barbara Liskov tarafından ortaya atılan Liskov yerine geçme ilkesi, sistemimizin bir yönünü değiştirmenin diğer unsurları olumsuz etkilememesini sağlamaya yardımcı olur.

Liskov yerine geçme ilkesine göre, alt sınıflar kendi temel sınıfları ile değiştirilebilir olmalıdır. Bu, B sınıfı A sınıfının bir alt sınıfı olduğunu varsayarsak, B sınıfı bir nesneyi, A türünden bir nesne bekleyen herhangi bir yönteme, yöntemin garip sonuçlar üretebileceğinden endişe duymadan sunabilmemiz gerektiğini gösterir.

Daha net hale getirmek için, bu fikri farklı bileşenlere ayıracağız. Dikdörtgen ve kare örneğini kullanalım:

				
					class Rectangle {
    public setWidth(width) {
        this.width = width;
    }
    public setHeight(height) {
        this.height = height;
    }
    public getArea() {
        return this.width * this.height;
    }
}
				
			

Basit bir Rectangle sınıfımız var ve getArea işlevi dikdörtgenin alanını döndürür.

Şimdi, özellikle kareler için başka bir sınıf yapmayı seçeceğiz. Bildiğiniz gibi kare, genişliği ve yüksekliği eşit olan özel bir dikdörtgen çeşididir:

				
					class Square extends Rectangle {

    setWidth(width) {
        this.width = width;
        this.height = width;
    }

    setHeight(height) {
        this.width = height;
        this.height = height;
    }
}
				
			

Yukarıdaki kod, aşağıda gösterildiği gibi hatasız çalışır:

				
					let rectangle = new Rectangle();
rectangle.setWidth(100);
rectangle.setHeight(50);
console.log(rectangle.getArea()); // 5000
				
			

Ancak, bir üst sınıf örneğini onun alt sınıflarından biri ile değiştirdiğimizde sorunlarla karşılaşacağız:

				
					let square = new Square();
square.setWidth(100);
square.setHeight(50);
				
			

setWidth(100) öğesinin hem genişliği hem de yüksekliği 100 olarak ayarlamasının beklendiği göz önüne alındığında, 10.000’e sahip olmalısınız. Ancak bu, setHeight(50) sonucunda Liskov Değiştirme İlkesini çiğneyerek 2500 döndürür.

Bunu düzeltmek için, alt sınıflarınızın nesnelerine erişebilmesini istediğiniz tüm genel yöntemleri içeren tüm şekiller için bir genel sınıf oluşturmalısınız. Ardından, dikdörtgen veya kare gibi her benzersiz yöntem için belirli bir sınıf oluşturacaksınız.

I: Interface segregation principle (Arayüz ayırma ilkesi)

Arayüz ayrımı ilkesi, daha küçük, daha hedefli arayüzleri teşvik eder. Bu konsepte göre, müşteriye özel çoklu arayüzler, tek bir genel amaçlı arayüze tercih edilir. 

Bu basit teoriyi anlamanın ve kullanmanın ne kadar kolay olduğunu görmek için aşağıdaki senaryoyu ele alalım:

				
					interface ShapeInterface {
    calculateArea();
    calculateVolume();
}
				
			

Bir sınıf bu arabirimi uyguladığında, siz onları kullanmasanız bile veya o sınıf için geçerli olmasalar bile tüm yöntemler tanımlanmalıdır:

				
					class Square implements ShapeInterface {
    calculateArea(){
        // some logic
    }
    calculateVolume(){
        // some logic
    }  
}

class Cylinder implements ShapeInterface {
    calculateArea(){
        // some logic
    }
    calculateVolume(){
        // some logic
    }    
}
				
			

Yukarıdaki örnekte, bir karenin veya dikdörtgenin hacmini belirleyemeyeceğinizi göreceksiniz. Sınıf arabirimi uyguladığı için kullanmayacağınız veya ihtiyaç duymayacağınız yöntemler dahil her yöntemi bildirmeniz gerekir.

Bunun yerine, bazen rol arabirimleri olarak bilinen daha kompakt arabirimler yerleştirebiliriz:

				
					interface AreaInterface {
    calculateArea();
}


interface VolumeInterface {
    calculateVolume();
}
				
			

Arayüzler hakkında düşünme şeklimizi değiştirerek arayüzlerin şişmesini önleyebilir ve program bakımını basitleştirebiliriz:

				
					class Square implements AreaInterface {
    calculateArea(){
        // some logic
    }
}

class Cylinder implements AreaInterface, VolumeInterface {
    calculateArea(){
        // some logic
    }
    calculateVolume(){
        // some logic
    }    
}
				
			

D: Dependency inversion principle (Bağımlılığın Ters Çevrilmesi ilkesi)

Bağımlılık tersine çevirme konseptine göre, yüksek seviyeli modüller, düşük seviyeli modüllere bağımlı olmamalıdır. Bunun yerine, her ikisi de soyutlamalara güvenmelidir.

Bob Amca, bu kuralı 2000 tarihli Tasarım İlkeleri ve Tasarım Modelleri adlı makalesinde şöyle özetliyor:

Açık-kapalı ilkesi (OCP), nesne yönelimli (OO) mimarinin hedefini belirtiyorsa, DIP birincil mekanizmayı belirtir.

Basitçe söylemek gerekirse, hem yüksek seviyeli hem de düşük seviyeli modüller, düşük seviyeli modüllere bağlı yüksek seviyeli modüller yerine soyutlamalara bağlı olacaktır. Tasarımdaki her bağımlılık, soyut bir sınıfa veya arayüze yönlendirilmelidir. Hiçbir bağımlılık somut bir sınıfı hedeflememelidir.

Bu prensibi daha fazla keşfetmek için bir örnek oluşturalım. Bir sipariş hizmeti düşünün. Bu örnekte, siparişleri bir veritabanına kaydeden OrderService sınıfını kullanacağız. Düşük seviyeli MySQLDatabase sınıfı, OrderService sınıfı için doğrudan bir bağımlılık görevi görür.

Bu durumda, bağımlılığı tersine çevirme ilkesini ihlal ettik. Gelecekte, kullandığımız veritabanını değiştirirsek, OrderService sınıfını değiştirmemiz gerekir:

				
					class OrderService {
  database: MySQLDatabase;

  public create(order: Order): void {
    this.database.create(order)
  }

  public update(order: Order): void {
    this.database.update
  }
}


class MySQLDatabase {
  public create(order: Order) {
    // create and insert to database
  }

  public update(order: Order) {
    // update database
  }
}
				
			

Bir arayüz tasarlayarak ve OrderService sınıfını buna bağımlı hale getirerek, bağımlılığı tersine çevirerek bu durumu iyileştirebiliriz. Şimdi, düşük seviyeli bir sınıfa güvenmek yerine, yüksek seviyeli sınıf bir soyutlamaya bağlıdır.

Hizmetlerimizi aşağıdaki gibi soyutlamaya yardımcı olan bir arayüz oluşturuyoruz:

				
					interface Database {
  create(order: Order): void;
  update(order: Order): void;
}


class OrderService {
  database: Database;

  public create(order: Order): void {
    this.database.create(order);
  }

  public update(order: Order): void {
    this.database.update(order);
  }
}


class MySQLDatabase implements Database {
  public create(order: Order) {
    // create and insert to database
  }

  public update(order: Order) {
    // update database
  }
}
				
			

Artık OrderService sınıfını değiştirmeden yeni veritabanları servisleri ekleyebilir ve genişletebiliriz.

Eğitimin, Eğlencenin ve Haberin Sitesi TEKNOKODİ

İlgili Yazılar