Dart ile SOLID Prensiplerinin Kullanımı

SOLID prensipleri yazılımcıların sürdürülebilir, genişletilebilir, yeniden kullanılabilir ve Robert C. Martin (nam-ı diğer Bob Amca) tarafından Design Principles and Design Patterns kitabında temel yazılım geliştirme prensipleri olarak tanımlanır.

Her ne kadar SOLID şeklinde kısaltmış olsak da asıl kısaltması S.O.L.I.D. şeklindedir. Çünkü bu kelime, temelde bir bağımlılık yönetimi (dependency management) biçimi olan, nesne yönelimli tasarımın (Object-Oriented Design - OOD) ilk 5 İlkesini oluşturur.

Peki bu prensipleri kullanmazsak ne olur?

Bu prensipler kullanılmadan elbette kod yazabilirsiniz. Ancak gerçek anlamda "iyi kod" yazmak ve tanımı içerisinde bahsettiğimiz özelliklere haiz olmak için kullanmak, gereklilik seviyesinde önemlidir.

Hatta Robert C. Martin (Bob Amca)'in Clean Code kitabındaki Bad Code bölümünde, 80'lerde popüler olmuş bir yazılımı üreten şirketin, kötü kod sebebiyle kapandığı bir hikayeyi anlatır. Olmaz demeyin! Bir yazılım şirketinin kapanmasına dahi sebep olabiliyor..

Biz de bu yazımızda SOLID prensiplerinin Dart dili ile kullanım örneklerini inceleyeceğiz. Zira bu prensipler nesne yönelimli yazılım geliştirme tekniklerinin kullanıldığı tüm diller ile uygulanabilir prensiplerdir.

Hadi başlayalım.. Ama önce şuraya bir yol haritası bırakalım.

SOLID Prensipleri

S: Single Responsibility Principle / Tek Sorumluluk Prensibi (SRP)

Bir sınıfın değişmesi için tek bir nedeni olmalıdır, yani bir sınıfın yalnızca bir işi olmalıdır.

O: Open–Closed Principle / Açık-Kapalı Prensibi (OCP)

Nesneler veya varlıklar, genişletme için açık ancak değişiklik için kapalı olmalıdır.

L: Liskov Substitution Principle / Liskov’un Yerine Geçme Prensibi (LSP)

Bir programdaki nesnelerin, o programın doğruluğunu değiştirmeden alt sınıflarının örnekleriyle değiştirilebilmesini gerektirir.

I: Interface Segregation Principle / Arayüz Ayrıştırma Prensibi (ISP)

Bir istemci asla kullanmadığı bir arabirimi(interface) uygulamaya zorlanmamalı veya istemciler kullanmadıkları yöntemlere bağımlı olmaya zorlanmamalıdır.

D: Dependency Inversion Principle / Bağımlılığın Ters Çevrilmesi Prensibi (DIP)

Varlıklar, somutlaştırmalara değil, soyutlamalara dayanmalıdır. Yüksek seviyeli modülün düşük seviyeli modüle bağlı olmaması gerektiğini, ancak soyutlamalara bağlı olması gerektiğini belirtir.

S: Single Responsibility Principle / Tek Sorumluluk Prensibi (SRP)

SOLID'in "S" sini oluşturan prensiptir. Adından içeriği anlaşılan prensiplerden biridir aslında. "Tek Sorumluluk İlkesi" olarak da bilinen bu prensibe göre, her class veya metod'un tek bir sorumluluğu olmalıdır. Bir diğer deyişle yapması gereken yalnızca bir işi olması gerekir ve böylece bir nedenden dolayı değişebileceğini belirtir.

Robert C. Martin, "Clean Code: A Handbook of Agile Software Craftsmanship" kitabında fonksiyonlara ait clean code prensiplerini açıklarken,

Function should do one thing. They should do it well. They should do it only.

şeklinde belirterek, fonksiyonlarında aslında SRP gibi çalışması gerektiğini belirtmiştir.

Zira bir sınıf ya da metot ne kadar çok sorumluluk alırsa, o kadar fazla değişime uğramak zorunda kalacak, bu da basitliği azaltacak, daha fazla test yazmanıza sebebiyet verecek, kodunuzun bağımlık oranını arttıracak ve dolayısı ile kod yönetimizi zorlaştıracaktır. Tam da bu sebeplerden ötürü sınıflarımız tek sorumluluğun dışına çıkmamalıdır.

Küçük bir okuma

Bu prensibin teorisini anladığımıza göre daha deneysel bir çalışma için örneğimize geçebiliriz.

class Student {
  String studentName;
  int studentNo;
  String studentSchoolName;
  
  Student(this.studentName, this.studentNo, this.studentSchoolName){}
 
  void printStudentMarks(int studentNo) { /*...*/}
  
  bool studentRegister() { /*...*/}
}

Student sınıfımızın içerisinde detaylarına girmediğimiz printStudentMarks ve studentRegister metotlarına ihtiyacımız var, ancak Student sınıfının içerisinde değil. Çünkü bu metotlarda yapılacak olan bir değişiklik, sınıfın tamamını etkileyecek ve tek sorumluluğun dışına çıkaracaktır. Bu sebeple sınıflarınız ya da metotlarınızın yalnızca bir işi olması gerekir.

O: Open–Closed Principle / Açık-Kapalı Prensibi (OCP)

OCP'yi özetleyen "Nesneler veya varlıklar, genişletme için açık ancak değişiklik için kapalı olmalıdır." cümlesi en yalın haliyle şunu belirtir. Mevcut kaynak kodunu değiştirmeden yeni davranışlar, yeni özellikler kodunuza ekleyebilmelisiniz. Eğer bunu gerçekleştiremiyorsanız anlayın ki Open-Closed prensibini çiğniyorsunuz.

Hadi yine bunu sahada daha iyi anlayalım. Aşağıdaki gibi 3 sınıfımız var. Sınıflarımızdan biri Rectangle/Dikdörtgen, biri Circle/Daire, diğeri de AreaCalculator ismi ile bu şekillerin alanlarını hesapladığımız sınıflar.

class Rectangle {
  final double width;
  final double height;

  Rectangle(this.width, this.height);
}

class Circle {
  final double radius;
  double get PI => 3.1415;
  
  Circle(this.radius);
}

class AreaCalculator {
  double calculate(Object shape) {
    if (shape is Rectangle) {
      return shape.width * shape.height;
    } else {
      Circle c = shape as Circle;
      return c.radius * c.radius * c.PI;
    }
  } 
}

main() {
  var areaCalculator = AreaCalculator();
  print(areaCalculator.calculate(Rectangle(2,4)));
  print(areaCalculator.calculate(Circle(2)));
}

Örneğimizi de çalıştırdığımız da aşağıki gibi çıktılarını da üretecektir.

8
12.566

Örneğimizi irdelediğimizde, AreaCalculator sınıfı içerisinde bulundurduğu calculate metodu ile kendisine argüman olarak verilen Object tipindeki bir değer alıyor. Aldığı bu değer ile gelen nesnenin Rectangle veya Circle olma durumuna göre de gerekli alan hesaplama işlemini gerçekleştiriyor.

Görünüşe göre bir şey yok ve kodumuzda çalışıyor ama literatürdeki söylemiyle code smells yani kod kokuyor.

Peki bu kokunun kaynağı ne?

Her bir sınıf alan hesabı için ihtiyaç duyduğu argümanları constructor'ları yardımı ile elde ediyor ve AreaCalculator sınıfı içerisinde tiplerine göre ayrılarak alan hesaplaması yapılıyor.

Bu örneğimize aynı zamanda Triangle/Üçgen alanının da hesaplanmasını eklesek ne yapmamız gerekecekti?

Hadi bu sorunun cevabını bulmak için örneğimizi revize edelim.

class Rectangle {
  final double width;
  final double height;

  Rectangle(this.width, this.height);
}

class Circle {
  final double radius;
  double get PI => 3.1415;
  
  Circle(this.radius);
}

class Triangle {
  final double height;
  final double base;
  
  Triangle(this.base, this.height);
}

class AreaCalculator {
  double calculate(Object shape) {
    if (shape is Rectangle) {
      return shape.width * shape.height;
    } else if (shape is Circle) {
      return shape.radius * shape.radius * shape.PI;
    } else {
      Triangle t = shape as Triangle;
      return (t.base * t.height) / 2;
    }
  } 
}

main() {
  var areaCalculator = AreaCalculator();
  print(areaCalculator.calculate(Rectangle(2,4)));
  print(areaCalculator.calculate(Circle(2)));
  print(areaCalculator.calculate(Triangle(6,3)));
}

Yeni çıktımız da bu şekilde oluştu ve sorunsuz çalıştı.

8
12.566
9

Ancak OCP prensibi bize ne diyordu?

Mevcut kaynak kodunu değiştirmeden yeni davranışlar, yeni özellikler kodunuza ekleyebilmelisiniz.

Biz ne yaptık?

1. Yeni bir Class ekledik. Burada bir sorun yok.

2. AreaCalculator sınıfını güncelledik.

Prensibin dediği gibi örneğimize yeni bir özellik ekledik ancak kaynak kodunu değiştirmememiz gerektiği halde AreaCalculator üzerinde de değişiklik yapmış olduk. Daha da açık bir ifade ile prensibi ihlal etmiş olduk.

Kokunun kaynağını belirlediğimize göre bunu ortadan kaldırmak için çözümümüz ne olmalı?

Şimdi buna odaklanalım..

Her sınıf aslında alan hesaplaması yapılması gereken bir şekli ifade ettiğine ve ortak kullandıkları sınıf aslında alan hesabını yapan bir sınıf olduğuna göre, her sınıf alan hesabını kendisi hesaplasa ve alan hesaplama sınıfı sadece ilgili şekli çağırma görevine sahip olsa ne olur?

Hadi deneyelim!..

abstract class Area {
  double computeArea();
}

class Rectangle implements Area {
  final double width;
  final double height;

  Rectangle(this.width, this.height);

  @override
  double computeArea() {
    return width * height;
  }
}
class Circle implements Area {
  final double radius;
  double get PI => 3.1415;

  Circle(this.radius);

  @override
  double computeArea() {
    return radius * radius * PI;
  }
}
class Triangle implements Area {
  final double height;
  final double base;

  Triangle(this.base, this.height);

  @override
  double computeArea() {
    return (base * height) / 2;
  }
}

class AreaCalculator {
  double calculate(Area shape) {
    return shape.computeArea();
  }
}

main() {
  var areaCalculator = AreaCalculator();
  print(areaCalculator.calculate(Rectangle(2,4)));
  print(areaCalculator.calculate(Circle(2)));
  print(areaCalculator.calculate(Triangle(6,3)));
}

Neler yaptık şimdi tekrar gözden geçirelim.

1. Area isimli bir abstract class yani bir interface oluşturmuş olduk. Böylece tüm alan hesaplamaları bu sınıf içerisinde oluşturduğumuz computeArea metodu ile gerçekleşebilecek. Böylece yeni bir davranış ekledik ancak kaynak kodumuza müdahale etmedik.

2. Bu interface'i Rectangle, Circle ve Triangle sınıflarımıza implement'e ettik. Böylece bu sınıflar, Area classını sahip olduğu computeArea metodunu override ederek kullanabilme şansına sahip olmuş oldu. Bu durumda başta kurguladığımız gibi artık her sınıf alan hesabını kendisi yapabiliyor.

3. Son olarak AreaCalculator sınıfı calculate metodu ile sadece alanı hesaplayacak şekil bilgisini alması yeterli hale geldi. Zira bu bilgi ile direkt olarak her sınıf kendi computeArea metodunu çağırabilir hale gelmiş oldu.

Bu durumda artık AreaCalculator sınıfını hiç değiştirmeden, sadece yeni alan hesabı yapılacak sınıfı eklemek yeterli hale gelecek. Tam da OCP prensibinin bizden istediği gibi!

Ne dersiniz artık koku falan kalmadı değil mi? Hatta misler gibi kokuyor. :)

L: Liskov Substitution Principle / Liskov’un Yerine Geçme Prensibi (LSP)

Bahsedeceğimiz bu prensibin sahibi Amerika Birleşik Devletleri'nde bilgisayar bilimi doktorası sahibi ilk kadınlardan biri olan ve Massachusetts Teknoloji Enstitüsünde profesör olarak çalışmış Barbara Liskov'a, kendi adını taşıyan ikame ilkesiyle 2008 yılında Turing Ödülü'nü kazandırmıştır.

Varın bu prensibin önemini bir daha düşünün..

Bu ilke Barbara Liskov tarafından ilk kez 1994 tarihli "A Behavioral Notion of Subtyping" makalesinde açıklanmıştı.

Let q(x) be a property provable about objects x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

Nasıl? Pek anlaşılabilir değil, değil mi?

Bob Amca (Robert Martin)'da bu maddeyi şöyle özetlemiştir.

Alt türler (Subtypes), temel türleri (base types) için ikame edilebilir olmalıdır.

Böylesi çok daha iyi..

Peki ikame ya da yerine geçme prensibi olarak adlandıracağımız bu prensip, kokuşmuş kodlarımıza (code smells) nasıl bir güzellik katacak anlamaya çalışalım.

LSP'yi yukarıda özetlerken "Bir programdaki nesnelerin, o programın doğruluğunu değiştirmeden alt sınıflarının örnekleriyle değiştirilebilmesini gerektirir." diyerek tanımladık.

Bakın burası çok önemli!

Liskov İkame İlkesi en anlaşılabilir haliyle; işlevselliğin etkilenmeden bir alt sınıfı üst sınıfıyla değiştirebilmeniz gerektiğidir. İşte tam da bu sebepten dolayı, yazılımınızda kalıtımın doğru şekilde uyguladığından emin olmanızı sağlar. Bu yönüyle bir anlamda bir doğrulama prensibidir.

Açıkçası kod olmadan teorisini açıklaması gerçekten zor bir prensip. Bu yüzden yine yanlışlarla başlayıp, nedenleri tespit edip, çözüm üretelim.

Bu ilkenin klasik örneği, bir Square ve bir Rectangle sınıfıdır. Bu yüzden kod örneğimize biz de buradan başlayalım.

Örneğimiz gelsin..

class Rectangle {
  double width;
  double height;
  Rectangle(this.width, this.height);
  
  double calculateArea(width, height) {
    return width * height;
  }
}

class Square extends Rectangle {
  Square(double length): super(length, length);
}

Liskov İkame İlkesinin ihlaline ilişkin klasik bir örnek Dikdörtgen - Kare problemidir. Bundan dolayı elimizde bir Rectangle (Dikdörtgen) sınıfı ve bu sınıfı extend eden (miras alan) Square (Kare) sınıfı olsun.

Bildiğiniz üzere kare, 4 eşit kenara sahip bir şekilken dikdörtgen, 2 aynı uzunlukla kısa ve 2 aynı uzunlukta uzun kenarı olan başka bir şekildir.

Rectangle sınıfı, genişlik ve yükseklik olmak üzere iki veri üyesi içerir. Ayrıca calculateArea metodu ile dikdörtgenin alanını döndürür. Fakat Square sınıfını düşünün. Square sınıfı, Rectangle sınıfını extend eder ve genişlik ile yüksekliğin eşit olduğunu varsayar. Bu durumda örneğimize göre dikdörtgenin alanı hesaplanırken iki farklı uzunluk olarak değil, aynı karede olduğu gibi alan hesabını eşit uzunluktaki iki değere göre yapmış olur.

Zaten problem tam da burada başlıyor..

LSP bize "Bir programdaki nesnelerin, o programın doğruluğunu değiştirmeden alt sınıflarının örnekleriyle değiştirilebilmesini gerektirir." der ve kalıtımın doğru şekilde uyguladığından emin olmanızı sağlar.

Olduk mu?

O halde soralım..

Burada mirasa gerçekten ihtiyaç var mı?

Maalesef bu şekilde bir ikame için mirasa güvenmiyoruz ve daha ziyade interfaceleri kullanarak soyutlamalar ile ikame edilebilen kompozisyonlar üretebiliriz. Nasıl mı? sorusunun cevabını aslında OCP prensibindeki örneğimizle ile vermiş olduk.

Bir kare gerçek dünyada bir dikdörtgen türü ve beklediğimiz gibi bir dikdörtgen'den extend edilmiş olsa da prensibin de belirttiği gibi alt ve üst sınıfın örnekleri değiştirilebilir olmadığını gözlemlemiş olduk. Yani LSP, karenin bir dikdörtgenin alt türü olamayacağını söyler.

Çalışmamız yukarıdaki örneğimizde olduğu gibi değilde dikdörtgenin değişmez olduğunu söyleseydik, bu durumda kare dikdörtgenin bir alt türü olabilirdi ve LSP ihlal edilmemiş olurdu.

O halde daha önce de söylediğimiz gibi Liskov ikame ilkesi için bir programın "doğruluğunu" nasıl sağladığı hakkında bilgi verir ve yazılımınızda kalıtımın doğru şekilde uyguladığından emin olmanızı sağlar diyebiliriz.

I: Interface Segregation Principle / Arayüz Ayrıştırma Prensibi (ISP)

Kısa açıklamasını "Bir istemci asla kullanmadığı bir arabirimi(interface) uygulamaya zorlanmamalı veya istemciler kullanmadıkları yöntemlere bağımlı olmaya zorlanmamalıdır." şeklinde yaptığımnız prensibi biraz daha açıklayalım.

Kus adında bir abstract class'ımız olsun. Bu arayüzü istediğimiz kuş cinsi sınıflarına ekleyeceğimiz için içerisinde otebilir, ucabilir ve yuzebilir adında üç metodu bulunsun.

abstract class Kus {
  void otebilir();
  void ucabilir();
  void yuzebilir();
}

Bu arayüzü Penguen, DeveKusu ve Guvercin olmak üzere üç kuş çeşidinin sınıflarını aşağıdaki gibi implemente edelim.

class Penguen implements Kus {
  @override
  void otebilir() {}

  @override
  void ucabilir() {}
  
  @override
  void yuzebilir() {}
}

class DeveKusu implements Kus {
  @override
  void otebilir() {}

  @override
  void ucabilir() {}
  
  @override
  void yuzebilir() {}
}

class Guvercin implements Kus {
  @override
  void otebilir() {}

  @override
  void ucabilir() {}

  @override
  void yuzebilir() {}
}

Bildiğiniz üzere Penguen, DeveKusu ve Guvercin temelde üçü de kuş olsa da tüm özellikleri aynı değildir. Örneğin; Penguen ötebilir, yüzebilir ama uçamaz. Fakat bu özellik Kus arayüzü ile Penguen sınıfına eklendiği için bunu da içi boş bile olsa metodunu override etmek zorundasınız.

İşte tam da bu noktada bu prensip bize başta da belirttiğimiz gibi Bir istemci asla kullanmadığı bir arabirimi(interface) uygulamaya zorlanmamalı veya istemciler kullanmadıkları yöntemlere bağımlı olmaya zorlanmamalıdır. der.

Bundan dolayı bu özellikleri tek bir interface'e doldurmak yerine, interface'lerin bu örnekteki duruma mahal vermeyecek şekilde ayrıştırılmasını önerir.

Hadi şimdi prensibin söylediği gibi kodumuzu düzenleyelim..

abstract class otebilir {
  void ot();
}

abstract class ucabilir {
  void uc();
}

abstract class yuzebilir {
  void yuz();
}

class Penguen implements otebilir, yuzebilir {
  @override
  void ot() {}

  @override
  void yuz() {}
}

class DeveKusu implements otebilir {
  @override
  void ot() {}

}

class Guvercin implements otebilir, ucabilir {
  @override
  void ot() {}

  @override
  void uc() {}
}

Bu çözüm kesinlikle çok daha iyi. Zira prensibin de söylediği gibi kullanılmayacak gereksiz yöntemler yok.

D: Dependency Inversion Principle / Bağımlılığın Ters Çevrilmesi Prensibi (DIP)

Bağımlılığın Ters Çevrilmesi Prensibi (DIP), Varlıklar, somutlaştırmalara değil, soyutlamalara dayanmalıdır. Yüksek seviyeli modülün düşük seviyeli modüle bağlı olmaması gerektiğini, ancak soyutlamalara bağlı olması gerektiğini belirtir.

Kötü bir örnekle başlayalım..

class Sms {
  void sendSms() {
    print("Sms Send");
  }
}
class Mail {
  void sendMail() {
    print("Mail Send");
  }
}

class MobileNotification {
  void sendMobileNotification() {
    print("Mobile Notification Send");
  }
}

class MessageManager {
  var sms = Sms();
  var mail = Mail();
  var mobileNotification = MobileNotification();
  
  void sendMessage() {
      sms.sendSms();
      mail.sendMail();
      mobileNotification.sendMobileNotification();
  }
}

main() {
  var messageManager = MessageManager();
  messageManager.sendMessage();
}

Örneğimizde Sms, Mail ve MobileNotification olmak üzere 3 farklı mesaj tipini destekleyen sınıflarımız var. Ve MessageManager isimli sınıfımız, bu sınıfları kullanarak görevlerini yerine getirmesini sağlıyor. Ancak örnekte görebileceğiniz gibi üst sınıfın (MessageManager) alt sınıflara bağımlılıkları üst düzeyde ve aralarında prensibin de istediği gibi hiç bir soyutlaştırma işlemi bulunmuyor.

Bu sıkı bağımlılıktan ötürü yeni bir mesaj gönderim tipini yazılımınıza entegre etmek istediğinizde de bu üst sınıf üzerinde değişiklikler yapmanız gerekecek sadece DIP prensibini değil, başka design prensiplerini daha çiğnemiş olacaksınız.

Şimdi prensibe uygun iyi bir örnekle devam edelim..

abstract class Message { 
  String send();
}

class Sms implements Message {
  @override
  String send() {
    return "Sms Send";
  }
}
class Mail implements Message {
  @override
  String send() {
    return "Mail Send";
  }
}
class MobileNotification implements Message {
  @override
  String send() {
    return "Mobile Notification Send";
  }
}

class MessageManager {
  void sendMessage(Message message) {
      print(message.send());
  }
}

main() {
  var messageManager = MessageManager();
  messageManager.sendMessage(Sms());
  messageManager.sendMessage(Mail());
  messageManager.sendMessage(MobileNotification());
  
}

Örneğimizde Sms, Mail ve MobileNotification olmak üzere 3 farklı mesaj tipini destekleyen sınıflarımız var. Bu sınıflar Message adındaki bir arayüzü implemente ederek onun send metodunu kullanıyorlar. Tüm bunlardan bağımsız bir üst sınıf olarak bu mesajları yöneten MessageManager isimli bir sınıfımız daha bulunuyor. Ve sahip olduğu sendMessage metodu ile mesaj gönderimlerini sağlıyor.

Hatırlayın! Benzer bir örmeği OCP prensibinde de yapmıştık.

Ancak dikkat edecek olursanız, yeni bir mesaj tipi eklerseniz ya da başka bir deyişle yazılımınıza yeni alt sınıflar ekleseniz de Message arayüzünü implemente eden tüm classlar ile sorunsuz ve değişiklik yapmadan çalışacaktır. Bu soyutlaştırma sayesinde üst seviye sınıfımızın alt seviye sınıflara olan bağımlılığını tersine çevirmiş oluruz.. Tam da prensibin istediği gibi!

Egemen MEDE - 09.10.2022