Liskov Yer Değiştirme Prensibi (LSP – Liskov Substitution Principle)

Sıra geldi yazılım tasarım prensipleri (SOLID) makalesinde kısaca değindiğim liskov yer değiştirme prensibini (liskov substitution principle) bir örnekle açıklamaya. Öncesinde bu substitution kelimesini yazmakta baya bir zorlandığımı ve bazı yerlerde kopyala yapıştır ile durumu kurtarmaya çalıştığımı itiraf edeyim. Kendi yazdığım ender yerlerde de yazım yanlışı yapmışsam şimdiden söyleyeyim, kusuruma bakmayın. Yazım açısından bir hayli kafa karıştırıcı kelime, en azından benim için.

Bu prensip ismini vakti zamanında MIT’de (Massachusetts Institute of Technology) profesör olan Amerikalı bir bilim insanı, Barbara Liskov’dan almaktadır. Barbara Liskov, 1987 yılında basılmış olan Data Abstraction and Hierarchy isimli kitabında bu prensiple ilgili tanımı ortaya koymuş ve yazılım dünyasına hiyerarşi ve kalıtım ile ilgili konularda yepyeni bir bakış açısı getirmiştir (Kaynak: wikipedi).

Şimdi Liskov yer değiştirme prensibi (liskov substitution principle) ile ilgili bir örnek yaparak konuyu anlamaya çalışalım. Burada temel olarak yapacağımız şey; bir metodumuz olacak ve bu metod ana sınıf tipinde bir parametre alacak. Bu metoda, ana sınıftan örneklenmiş bir nesne de göndersek, ana sınıftan türetilmiş olan çocuk sınıflardan örneklenmiş bir nesne de göndersek metodun çalışmasında herhangi bir sıkıntı olmamalı. Ayrıca bu metoda da bazı nesneler için istisnai kod yazmamalıyız veya try-catch bloklarını kullanarak hata yakalama işlemlerinde bulunmamalıyız. Sonrasında metodun doğru yazılımının Interface kullanılarak yapılışına ait kodları da vererek bitireceğiz. Tabi bu şekilde sözel anlatım ile konuyu anlayabilmek zor çünkü sözel olarak anlatabilmek de gayet zordu ve ne derecede başarılı olduk bilinmez. Örneğe geçtiğimizde daha anlaşılır bir hale geleceğini düşünüyorum.

Senaryo 1 – Yazılımın Klasik Anlayışa Göre Kodlanması

İlk önce üretim yapan bir işletme olarak aşağıdaki yapıda sınıflarımız olduğunu düşünelim. Ürünlere ait tüm sınıflar (Urun1..N) Urun isimli ana bir sınıftan türetiliyor. Urun isimli ana sınıfımızda da Uret isimli sanal (virtual) bir sınıf tanımlanmış. Türetilen her sınıf bu metodu ezerek (override) kendine ait özel üretim metotlarını işletmektedir.

Aşağıda bir de UrunA isimli bir sınıf daha görülmektedir. O da Urun sınıfından türetilmektedir fakat Uret isimli metodu ezmediğini (override) görüyoruz. Bu ürünü de firmamızda üretilmeyen, başka bir tedarikçiden satın alıp direk olarak kullandığımız veya sattığımız bir ürün olarak düşünebiliriz. Yani bu ürün için üretim yapmıyoruz.

namespace UretimSenaryosu
{
    public class Urun
    {
        public virtual void Uret()
        {
            throw new NotImplementedException();
        }
    }
    
    public class Urun1: Urun
    {
        public override void Uret()
        {
            //Burada ürün 1'e özel üretim metotları çalıştırılıyor.
            Console.WriteLine("Ürün 1'e özel üretim yapıldı.");
        }
    }

    public class Urun2: Urun
    {
        public override void Uret()
        {
            //Burada ürün 2'ye özel üretim metotları çalıştırılıyor.
            Console.WriteLine("Ürün 2'e özel üretim yapıldı.");
        }
    }

    public class UrunA: Urun
    {
    }
}

Tüm sınıflar tanımlandıktan sonra ana projede aşağıdaki gibi bir toplu üretim yapan metodumuz olduğunu düşünelim.

namespace UretimSenaryosu
{
    public class Uretim
    {
        public static void Main(string[] args)
        {
            List<Urun> urunler = UrunListesiGetir();
            foreach (Urun urun in urunler)
            {
                //Normalde burada urun.Uret(); komutu ile işlemi apabiliriz.
                //Fakat ben konunun anlaşılması için üretim işlemlerini yapan bir başka bir sınıfa yönlendirdim.
                UretimYap(urun);
            }
        }

        public static void UretimYap(Urun urun)
        {
            try
            {
                //Burada proje niteliğine göre üretim ile ilgili başka metotlar da çalıştırılabilir.
                urun.Uret();
            }
            catch(Exception hata)
            {
                Console.WriteLine(urun.ToString() + " isimli ürün için üretim yapılamadı.");
            }
        }

        private List<Urun> UrunListesiGetir()
        {
            //Bu metot içerisinde veritabanından ürünlerin bir listesi alındığı düşünülebilir.
            //Ben kolaylık açısından aşağıdaki gibi bir liste döndürerek örnek bir ürün listesi oluşturdum.
            List<Urun> urunler = new List<Urun>();
            urunler.Add(new Urun1());
            urunler.Add(new Urun2());
            urunler.Add(new UrunA());
            return urunler;
        }
    }
}

Proje çalıştırıldıktan sonra konsol ekranında sırası ile aşağıdaki çıktılar görülecektir.

Ürün 1e özel üretim yapıldı.
Ürün 2e özel üretim yapıldı.
UrunA isimli ürün için üretim yapılamadı.

Toplu üretim metodu çalışırken hata veren ürünler için try-catch bloğu kullanarak hata yakalama işlemi yaptık ve uygun bir mesaj verdik. Projenin niteliğine göre başka işlemler veya loglama da yapılabilir. Yada try-catch metodu yerine nesnelerin tiplerine göre bir switch ifadesi kullanılarak uygun işlemler switch blokları içerisinde de yaptırılabilir. Basitlik açısından ben yukarıdaki gibi kullandım.

Bu durumda programın işleyişi açısından belki bir sorun olmadı veya çıkabilecek sorunları bir şekilde yönetebildik fakat mevcut tasarım liskov yer değiştirme prensibine (liskov substitution principle) uygun olmadı. Bu prensip özetle bir ana sınıf, kendisinden türetilen başka bir sınıfla yer değiştirebilir diyordu. Yani Urun1, Urun2 veya UrunA sınıflarından örneklenen nesneler Urun tipinde parametre kabul eden bir metoda gönderildiğinde (bu aşamada üst sınıf ile alt sınıftan örneklenen nesneleri yer değiştirmiş olduk) parametreyi kabul ediyor ve gerekli işlemi yapıyor (Uret metodunu çağırıyor). Bu durumda bizim “UretimYap” metoduna ürünleri gönderdiğimizde hiçbir şekilde “… isimli ürün için üretim yapılamadı.” hatası almamamız gerekiyor.

Senaryo 2 – Yazılım Tasarımının LSP’a Uygun Kodlanması

Peki doğru tasarım nasıl olmalıydı konusuna gelirsek elbette bunun birkaç değişik yolu olabilir. Ben Interface kullanarak yapmayı tercih ettiğimden çözümü de Interface ile yapacağım.

namespace UretimSenaryosu
{
    public Interface IUretimiYapilabilenUrunler
    {
        void Uret();
    }

    public class Urun
    {
    }
    
    public class Urun1: Urun, IUretimiYapilabilenUrunler
    {
        public void Uret()
        {
            //Burada ürün 1'e özel üretim metotları çalıştırılıyor.
            Console.WriteLine("Ürün 1'e özel üretim yapıldı.");
        }
    }

    public class Urun2: Urun, IUretimiYapilabilenUrunler
    {
        public void Uret()
        {
            //Burada ürün 2'ye özel üretim metotları çalıştırılıyor.
            Console.WriteLine("Ürün 2'e özel üretim yapıldı.");
        }
    }

    public class UrunA: Urun
    {
    }
}

Urünlerin tanımlandığı sayfaya “IUretimiYapilabilenUrunler” isimli bir Interface ekledim ve Urunlerin tanımlarında da küçük bir değişiklik yaptım. Ana sınıfımız olan Urun sınıfındaki Uret metodu kaldırıldı ve onun yerine Interface içerisinde Uret isimli bir metot tanımlandı. Alt kısımdaki Urun1 ve Urun2 isimli ürünlere ait sınıflar, bu sınıflardan örneklenen nesneler üretim yapılan ürünler olduğundan ve aynı zamanda da ürün olduğundan hem Urun sınıfından örneklendi hem de yeni arayüzü (interface) uyguladı. UrunA isimli ürüne ait sınıf ise bu arayüzü (interface) uygulamadı ve sadece Urun sınfından örneklendi.

namespace UretimSenaryosu
{
    public class Uretim
    {
        public static void Main(string[] args)
        {
            List<Urun> urunler = UrunListesiGetir();
            foreach (IUretimiYapilabilenUrunler urun in urunler.FindAll(s => s is IUretimiYapilabilenUrunler))
            {
                //Normalde burada urun.Uret(); komutu ile işlemi apabiliriz.
                //Fakat ben konunun anlaşılması için üretim işlemlerini yapan bir başka bir sınıfa yönlendirdim.
                UretimYap(urun);
            }
        }

        public static void UretimYap(IUretimiYapilabilenUrunler urun)
        {
            //Burada proje niteliğine göre üretim ile ilgili başka metotlar da çalıştırılabilir.
            urun.Uret();
        }

        private List<Urun> UrunListesiGetir()
        {
            //Bu metot içerisinde veritabanından ürünlerin bir listesi alındığı düşünülebilir.
            //Ben kolaylık açısından aşağıdaki gibi bir liste döndürerek örnek bir ürün listesi oluşturdum.
            List<Urun> urunler = new List<Urun>();
            urunler.Add(new Urun1());
            urunler.Add(new Urun2());
            urunler.Add(new UrunA());
            return urunler;
        }
    }
}

Programın ana bloğunda yer alan UretimYap isimli metodumuzun parametresini Urun tipinden IUretimiYapilabilenUrunler tipine çevrildi. Main metodunda da foreach içerisinde ürün listesinden sadece IUretimiYapilabilenUrunler arayüzünü uygulayan nesneler seçildi ve UretimYap isimli metoda parametre olarak gönderildi. Bu durumda yazılıma eklenen ürünlerin tek bir merkezden yönetilmesi sağlandı. Bu yapılırken de uygulama, metotlarda ürün tiplerine göre hata yönetimi veya ürün tipine göre yönlendirme (switch) gibi bakımı zorlaştırıcı işlemlerden arındırılmış oldu.

Faydalı olması dileğiyle…