Unity'de Yazılım Mimarisi Oluşturma : Bileşim (Composition) ve Kalıtım (Inheritance)
Yazılım mühendisliğinin her alanında olduğu gibi, Unity ile oyun geliştirirken de en çok dikkat edilmesi gereken konulardan birisinin (belki de en çok dikkat edilmesi gereken konunun) doğru bir mimari ile projeyi yürütmek olduğunu söyleyebiliriz. Yazılım mühendisliği dünyasında, pek çok mimari çözüm üretilmiştir. Her çözümün de artısı ve eksisi bulunmaktadır. Ama bazen, sadece mimariyi seçip işe koyulmak yetmez. Projedeki katmanların içerisindeki düzenin de korunması gerekir. Bu düzenin korunması, projenin test maliyetini düşürmek olduğu gibi aynı zamanda projede yapılması mümkün olabilecek değişimleri de desteklemesidir.
Tam da bu noktada, Nesneye Yönelik Programlama (OOP) destekli programlama dillerinde üretilmiş bazı prensipler vardır. Bu prensipler, projede kullandığımız mimarinin güçlü kalmasına ve daha düşük maliyetle proje yapmamıza destek olur. Pek çok prensip bulunuyor olsa da bunlardan sadece Bileşim (Composition) ve Kalıtım (Inheritance) prensiplerine bu yazımda değiniyor olacağım.
Kalıtım (Inheritance)
Nesneye Yönelik Programlama mantığındaki programlama dillerinde; bir class’ın sahip olduğu tüm özellikleri, tabiri caiz ise, miras alıp aynı özellikleri başka bir class’ın kendi üzerine de alması prensibine kalıtım (Inheritance) denir. Kalıtım prensibinde özellikleri mirasla alınan class’a parent veya base class adı verilirken, bu class’ın özelliklerini miras alan class’a da child class denir.
Bir örnek üzerinden bunu açıklayalım:
Bir RPG oyun yaptığımızı varsayalım. Bu oyunda, 3 farklı ırkımız var. Bunlar Elf, Dwarf ve Orc ırkları olarak tanımlanmış durumda. Bir tane de base class görevi gören Karakter classı oluşturduk. Eğer bütün karakterlerin yapacağı ortak işlemler varsa, Karakter classı içerisinde bu işlemler oluşturuluyor olacak. Böylece bütün ırk scriptleri içerisinde aynı işlemleri tekrar tekrar yazmamıza gerek kalmayacak çünkü bütün ırk classları, Karakter classını miras alıyor. Bu yöntemin avantajlarını şöyle sıralayalım:
Kod tekrarının önüne geçmiş olduk. Aynı kodun tekrar etmesinden ziyade, kodu bir kere tanımlayarak bütün child classların aynı kodu kullanabilmesini sağladık.
Karakter classı içerisinde, base metotlar tanımlayarak temel davranışlar tanımlayabiliriz. Eğer herhangi bir ırk classı, bu temel davranışa ek olarak kendi özelinde bir şeyler yapmak isterse base metodu override edebilir ve kendi davranışını da ekleyebilir. (SOLID prensiplerindeki Open-Closed Principle prensibine sadık kalmak da oldukça büyük bir önem taşıyor. Eğer Karakter classındaki bir metotu override ederken sadece alt classlarda kod düzenlemesi değil de Karakter classına da her zaman müdahale etmeniz gerekiyorsa o zaman implementasyonunuzda problem var demektir. Open-Closed Principle, çok önemli bir prensiptir ve bir alt classın değiştirilmesi durumunda parent classın değişmemesi gerektiğini savunur.)
Eğer istersek yeni alt classlar oluşturarak daha detaylı ırklar oluşturabiliriz. (Fakat bu her zaman iyi bir şey değildir, sebebini az sonra açıklayacağım.)
Bu yaklaşımla, daha fazla ırk oluşturduğumuzu düşünelim. Şöyle bir örneklendirme yapabiliriz:
Bu örnekte, daha fazla elf ırkı oluşturduk. Az önce tanımladığımız elf base classı, diğer elf classlarının parent classı ile tanımlanmış oldular. Elf classından gelen temel davranış ve özellikleri taşıyarak yeni elf ırkları tanımlayabiliyoruz. Böylece yine özel elf ırkları tanımlayarak oyunumuzdaki çeşitliliği arttırmış olduk ve önemli bir kod tasarrufu da elde ettik.
Fakat burada bir sıkıntı ortaya çıkmaya başlıyor. Kalıtım, her ne kadar güçlü bir OOP özelliği olsa da en büyük problemini bu örnekte göstermiş oluyor. Fazla kullanıldığı takdirde elimizde çok fazla class olmaya başlıyor. Bu kadar fazla classın takibini yapmak, proje büyüdükçe çok ciddi bir test maliyeti oluşturuyor olacak. Belki temiz kod yazıyor olacağız ama projenin büyüme ve test maliyetlerinde önemli bir artış olacak. Bir yerden kazanırken, diğer yerden çok önemli bir fedakarlık veriyoruz.
Eğer elf ırklarının da kendi içerisinde ayrım gösterdiği alt ırklar varsa, o zaman bunları devamlı alt class oluşturma ile yaparsak içinden çıkılamayacak bir durum oluşuyor. Belki Dark Elf ırkı, gece görüşü olan veya olmayan olmak üzere ikiye ayrılmak istenebilir. O zaman iki tür için de farklı class oluşturmamız gerekiyor. Belki hem gece görüşü olan hem de görünmez olabilen bir wood elf ırkı tanımlamamız gerekiyor. O zaman bunun için de ayrı bir class mı oluşturmalıyız? Hayır, gerekmiyor. Daha güçlü bir alternatifimiz var.
Bileşim (Composition)
Bileşim (Composition), OOP’nin konseptlerinden birisidir. Bir classın, diğer class ile bir “has-a” yani sahip olma ilişkisi kurarak diğer class’ın özelliklerine sahip olması demektir. Bu yaklaşımın en güçlü yanı, runtimeda proje çalışırken bir classın fonksiyonelliğini düzenleyebilmektir. Bu da oldukça dinamik bir yazılım mimarisi oluşturmada avantaj sağlar.
Örneğimizden devam edecek olursak, ırklarımızın sahip olabileceği özelliklerin oyun içerisinde çok değişken olma ihtimali yüksek olacaktır. Yani bir elf ırkının gece görüşüne sahip olmasının yanı sıra görünmez olma ihtimali de vardır. Aynı şekilde bir ork ırkı da bu özelliklere sahip olabilir veya bazılarına sahip olabilir. Bu nedenle, bu özellikleri ayrı classlar olarak oluşturup ırklara ihtiyaç halinde eklemek de çok doğru bir karar olacaktır.
IOzellik adında bir interface tanımladık. Bu interface, oyun içerisinde karakterlere tanımlanacak tüm özellik classlarının karakterize edilmesini sağlıyor. Her özellik, mutlaka karaktere etki etmeli ve karakteri değiştirebilmeli. Bu nedenle, KarakteriDegistir() metodunu tanımladık ve bütün bu interface’e sahip classlar bu metodu taşımak zorunda kalıcak. GeceGorusuOzelligi ve GorunmezOlmaOzelligi adıyla iki tane özellik tanımladık. Bu özellikler, adı üstünde, bir karakteri görünmez yapabildiği gibi gece görüşü de ekleyebilir. KarakteriDegistir metodu ile de özelliğin eklendiği karakterlere kendi etkilerini ekliyor olacaklar.
Karakter classımıza geri döndük ve bir özellik listesi oluşturduk. Runtime’da, herhangi bir yolla, karakterimize yetenek eklemek istersek OzellikEkle metodunu çağırıyoruz. Bu metodu çağırınca da hem listemizi güncelliyoruz, hem de özellik classındaki KarakteriDegistir metodunu çağırarak da özelliği karakterimize ekliyoruz.
Bu yaklaşımla nasıl bir kazanç elde ettik?
Her spesifik özellikte karakter için class açmak zorunda kalmadık. Minimal seviyede kalıtım kullandık ve karakterlerimizi büyük oranda dinamik karakterlere dönüştürdük. Pek çok karakter türünü minimal efor ile oluşturma imkanımız oldu.
Artık test maliyetimiz daha da düştü. Eğer bir özellik aktifken bug oluşursa, debug yaparken test edeceğimiz classların sayısı azaldı.
Kalıtım ile tanımladığımız classlar, compile time’da tanımlanır ve dinamiklik özelliğine sahip değildirler. Bileşim yöntemini kullandığımızda, karakterlerimizi runtimeda düzenleme açısından herhangi bir engelimiz bulunmaz. Oynanışı minimal eforla daha da zenginleştirmiş olduk.
SOLID prensiplerinde Interface Segregation prensibine sadık kalan bir yapı oluşturduk. Yani kalıtım yöntemi ile oluşturacağımız bir hiyerarşide, eğer alt classlardan gelen bazı özel interface tanımlamalarına sonraki alt classlar ihtiyaç duymuyorsa, sonraki alt classlara gereksiz bir tanımlama yapmamış olduk. Yani bileşim yöntemi ile gelen interface tanımlı özellikler, runtimeda ihtiyaç duyulmazsa kaldırılabilir fakat kalıtım yöntemi ile compile time’da bu tanımlama yapılmış olacağı için runtimeda değişikliği yapamayacaktık.
Kazanan Kim Oldu?
Çoğu zaman, kalıtım ve bileşim yöntemleri birbirleri ile karşılaştırılır. Hatta çoğu zaman bileşimin kullanılması ve kalıtımın kullanılmaması yönünde pek çok tavsiye yayınlanır. Fakat bunun örnek bazlı değerlendirilmesi, her zaman daha doğrudur. Bir prensibe fanatik bir şekilde bağlı kalmamak gerekir. Çünkü iki yöntemin de kendine göre güçlü yanları vardır. Birine bağlı kalmak ve diğerini umursamamak, tabiri caiz ise bir tuzaktır. Çünkü yazılım mimarileri, yazılımın çalışıp çalışmadığını değil değişimi ne kadar desteklediğini belirler.
Peki yazılım mimarisi oluştururken hangi yöntemi kullanmalıyım?
Eğer class hiyerarşiniz büyük oranda katı, yani classların özelliklerinin pek değişken olmayacağı durumlarda kalıtım kullanmak mantıklı bir tercihtir. Ama classlarınız oldukça değişken bir yapıdaysa yani classların özelliklerinin runtimeda değişmesi muhtemel ise bileşim yöntemini kullanmanız daha doğru olur.
Büyük projelerde, bir mimarinin kalıtım yöntemi ile çok fazla class hiyerarşisi barındırması tercih edilmez. Bunun en büyük sebebi kodun karmaşıklığını arttırıp mimarinin genişletilmesi maliyetini de yükseltmesidir ama performans tarafında da büyük bir maliyet ortaya çıkarma riski vardır. Mesela, classlarda karşılaştırma yapılırken kullanılan is, as ve GetType kontrolleri her ne kadar optimize çalışsa da sık kullanıldıkları durumlarda performans maliyetini arttırır. Kalıtım tabanlı bir mimari de ise class karşılaştırmaları sık yapılacağı için büyük projelerde performans sıkıntısı ortaya çıkabilir. Aynı şekilde, alt classlar eğer ki base classlardan farklı değişkenler ve objeler inherit ediyorsa ama bunları kullanmıyorsa gereksiz yere bir bellek israfı oluşmaya başlar. Bileşim yöntemi, classları runtimeda düzenlemeye imkan sağladığı için bu riskler daha minimaldir.
Bunlara rağmen, bileşim yönteminin fazla kullanılması da çok uzun class kodları oluşmasına neden olabilir. Fazla kullanıldığı takdirde de bileşim yönteminin kod okunurluğunu azaltırken yine test maliyetini arttırma ihtimali de vardır. Temiz kod oluşturulmasını zorlaştıran bir yapının oluşması, bileşim yöntemine fazla güvenilmesi ile mümkündür.
Bu iki yöntemden birini seçmek, her zaman için doğru bir tercih olmayabilir. Yazılım mimarilerinin oluşturulması, sadece proje türüyle belirlenmeyeceği gibi iş maliyeti ve projede çalışan yazılım mühendislerinin tercihleri ile de alakalıdır.
Sonuç
Oyun projeleri, genelde dinamik yapılara sahip olduklarından ötürü, bileşim yönteminin daha verimli uygulanabileceği projelerdir ama kalıtım yöntemi de geliştiricilere pek çok avantaj sağlar. Genelde oyun projelerinde izlenen yöntem ise hibrid bir yöntemdir. Her classın dinamik bir yapıya sahip olması gerekmez. Kuralları tam belirlenmiş ve gelecekte ne kadar değişeceği konusunda oldukça düşük bir ihtimal belirlendiyse kalıtım yöntemi ile daha hızlı bir geliştirme yapılabilir. Eğer class yapısının daha dinamik olacağı ön görülüyorsa, riske girilmeden bileşim yöntemi kullanılır ve gelecekte oluşacak maliyetler azaltılır. Bir oyun projesinde, genel olarak yazılım projelerinde de, bazı kısımların çok dinamik olacağı ön görülürken diğer kısımların ise çok az dinamik olacağı veya hiç olmayacağı ön görülebilir ve o zaman iki yöntemden birine bağlı kalmanın dezavantajları ile yüzleşme zorunda kalabilirsiniz. Bu nedenle iki yaklaşımı da doğru yerde ve doğru şekilde destekleyen bir mimari, iki yaklaşımdan birini favori seçen bir mimariden daha güçlü bir mimaridir.
Herkese iyi çalışmalar diliyorum!