sazak@labs:~
oturum

etiketler

CompositionPolymorphismencapsulationsoftware designInheritanceOOP

Software · Jul 13, 2025 · 12 dk okuma

Object-Oriented Programming'i Gerçekten Anlamak: Class, Object ve Tasarım Kararları

OOP, gerçek dünyayı class'lara dönüştürmekten çok, değişen yazılımda sorumlulukları ve kuralları doğru yerlere yerleştirme meselesidir. Encapsulation, polymorphism ve composition kavramlarını çalışan bir sipariş sistemi üzerinden inceleyelim.

Object-Oriented Programming'i Gerçekten Anlamak: Class, Object ve Tasarım Kararları

Object-Oriented Programming (OOP), yazılımı gerçek dünyadaki nesnelerin dijital kopyalarına dönüştürme yöntemi değildir. OOP'nin asıl gücü, veriyi ve o veri üzerinde geçerli olan davranışları belirli sınırlar içinde bir araya getirmesinden gelir. İyi tasarlanmış bir object, yalnızca bilgi taşımaz; hangi değişikliklerin geçerli olduğunu bilir, kendi kurallarını korur ve diğer object'lerle açıkça tanımlanmış ilişkiler kurar. Class, inheritance veya interface kullanmak tek başına bu tasarımı oluşturmaz.

Bu ayrım önemlidir çünkü OOP öğrenen biri kısa sürede çalışan class'lar yazabilir, fakat sistem büyüdüğünde değiştirilmesi zor bir yapı oluşturabilir. Bir ödeme yöntemi eklendiğinde beş dosyayı değiştirmek, sipariş durumunu herhangi bir yerden değiştirebilmek veya inheritance zincirinin beklenmedik davranışlar üretmesi aynı temel soruna işaret eder: sorumluluklar yanlış dağıtılmıştır. OOP'yi anlamak, syntax öğrenmekten çok hangi object'in hangi karardan sorumlu olması gerektiğini öğrenmektir.

OOP'nin çıkış noktası gerçek dünyayı kopyalamak değildi

OOP'nin tarihsel kökleri, özellikle Simula'daki simulation çalışmaları ve daha sonra Smalltalk ile geliştirilen message passing yaklaşımıyla ilişkilidir. Simula, birbirinden bağımsız durumları ve davranışları bulunan varlıkların modellenmesini kolaylaştırdı. Smalltalk ise object'lerin birbirleriyle mesajlaşarak çalıştığı bir hesaplama modelini öne çıkardı. Bu yaklaşımların ortak noktası, programın bütün davranışlarını tek bir merkezi işlem dizisi olarak düzenlemek zorunda olmamaktı.

Procedural programming'de veriler ve bu verileri işleyen fonksiyonlar çoğu zaman ayrı düzenlenir. Bu yaklaşım kendi başına sorunlu değildir; pek çok işlem için oldukça uygundur. Fakat aynı verinin geçerlilik kuralları birçok fonksiyon tarafından korunmak zorundaysa değişiklik maliyeti artabilir. Bir siparişin toplam tutarını değiştiren her fonksiyonun aynı kontrolü doğru uyguladığından emin olmak gerekir.

OOP burada farklı bir sınır önerir. Siparişin kendi durumunu değiştirme yetkisi, siparişin kurallarını uygulayan object üzerinden kullanılabilir. Böylece geçerli durumdan geçersiz duruma geçişler tek bir yerde kontrol edilebilir.

Bu yaklaşımın temel birimi object'tir. Object, kendi identity'si, state'i ve behavior'ı olan çalışma zamanı varlığıdır. Identity, iki object'in aynı değerlere sahip olsalar bile ayrı varlıklar olabilmesini ifade eder. State, object'in o andaki verileridir; behavior ise gerçekleştirebildiği işlemlerdir.

Class ise çoğu OOP dilinde object oluşturmak için kullanılan tanımdır. Hangi alanların bulunacağını, hangi metotların çalışacağını ve hangi kuralların uygulanacağını belirler.

Örneğin Order bir class olabilir. orderA ve orderB, bu class'tan oluşturulmuş iki farklı object'tir. İkisinin toplam tutarı aynı olsa bile aynı sipariş değildirler.

Burada sık yapılan bir hata, class'ı yalnızca veri alanlarından oluşan bir şablon olarak görmektir. Eğer Order yalnızca items, status ve total alanlarını taşıyor, bütün kararları başka bir OrderManager veriyorsa class oluşturulmuş olabilir ama OOP'nin davranışı ilgili verinin yanında tutma avantajından sınırlı ölçüde yararlanılmıştır.

Bununla birlikte her class'ın karmaşık behavior içermesi gerekmez. Bir API response'unu temsil eden basit bir data object tamamen makul olabilir. Sorun, böyle bir yapıyı kullanmak değil, önemli domain kurallarının kime ait olduğunu belirsiz bırakmaktır.

Encapsulation ve abstraction aynı şey değildir

Encapsulation, bir object'in iç durumuna erişimi belirli işlemler üzerinden kontrol etmesidir. Amaç her alanı private yapıp otomatik olarak getter ve setter üretmek değildir. Amaç, object'in geçersiz bir duruma sokulmasını engelleyen sınır oluşturmaktır.

Bir BankAccount düşünelim. balance alanı dışarıdan serbestçe değiştirilebiliyorsa herhangi bir kod account.balance = -500 yazabilir. Oysa hesap türünün negatif bakiyeye izin vermediğini varsayalım. Bu durumda withdraw(amount) metodu, miktarın pozitif olduğunu ve kullanılabilir bakiyeyi aşmadığını kontrol edebilir.

balance alanını private yapmak fakat setBalance(value) metodunu herkesin kullanımına açmak aynı korumayı sağlamaz. Dışarıdaki kod hâlâ hesabın hangi koşullarda değişebileceğine karar vermektedir. Encapsulation'ın işe yaradığı yer, veri erişiminin gizlenmesinden çok geçerli state transition'ların kontrol edilmesidir.

Bir object'in her zaman sağlaması gereken koşullara invariant denir. Negatif bakiyeye izin vermeyen bir hesap için balance >= 0 bir invariant olabilir. Metotlar, başarılı biçimde tamamlandıklarında bu koşulu korumalıdır.

Abstraction ise farklı bir soruya cevap verir: Bir object'i kullanmak için hangi ayrıntıları bilmek gerekir?

Bir ödeme sistemindeki PaymentGateway, dışarıya charge(request) işlemini sunabilir. Bu işlemi kullanan kodun HTTP request oluşturmayı, authentication header hazırlamayı veya sağlayıcının hata kodlarını yorumlamayı bilmesi gerekmemelidir.

Abstraction, kullanım açısından gerekli davranışı görünür bırakırken implementation ayrıntılarını geri plana iter. Encapsulation ise iç state ve değişiklik kuralları üzerindeki kontrolü korur.

Bu iki kavram sıklıkla birlikte kullanılır ama birbirinin yerine geçmez. Bir interface güçlü bir abstraction sağlayabilir; buna rağmen onu uygulayan object kendi durumunu kötü koruyor olabilir. Tersine, bütün alanları private olan bir class, dışarıya onlarca implementation ayrıntısı sunuyorsa zayıf bir abstraction oluşturabilir.

İyi bir API, kullanıcısını doğru işlemlere yönlendirir. order.markAsPaid() ifadesi, order.setStatus("PAID") ifadesinden daha anlamlı olabilir çünkü ödeme durumuna geçişin yalnızca bir string ataması değil, kuralları bulunan bir işlem olduğunu açık eder.

Ancak markAsPaid() metodunun gerçekten ödeme alındığını kanıtladığı varsayılmamalıdır. Bu bilgi dış ödeme sisteminden gelir. Order object'i kendi durum geçişini kontrol ederken dış dünyadaki işlemin doğrulanması başka bir sorumluluktur.

Inheritance ve polymorphism'in asıl mekanizması

OOP çoğu zaman dört temel kavramla anlatılır: encapsulation, abstraction, inheritance ve polymorphism. Bu anlatım başlangıç için yararlı olsa da tasarımın temel sorusunu kaçırabilir. Bu kavramlar eşit ağırlıkta kullanılması gereken dört özellik değildir; farklı problemleri çözmek için kullanılan mekanizmalardır.

Inheritance, bir class'ın başka bir class tarafından tanımlanan özellikleri ve davranışları devralmasını sağlar. Vehicle temel class, Car ise ondan türeyen class olabilir. Böylece ortak davranışlar yeniden kullanılabilir.

Fakat inheritance'ın önemli sonucu kod tekrarını azaltmak değildir. Daha güçlü sonucu, subtype relationship kurmasıdır. Car, Vehicle beklenen yerde kullanılabiliyorsa aralarında davranışsal bir uyumluluk bulunmalıdır.

Bu nokta Liskov Substitution Principle ile ilişkilidir. Bir subtype, temel type'ın kullanıcılarının güvendiği davranış sözleşmesini bozmamalıdır. Yalnızca aynı metot isimlerine sahip olmak yeterli değildir.

Klasik Rectangle ve Square örneğini düşünelim. Bir Rectangle class'ında genişlik ve yükseklik birbirinden bağımsız değiştirilebiliyorsa Square class'ının ondan türemesi problem yaratabilir. Çünkü square, genişliği değiştirildiğinde yüksekliğin de değişmesini gerektirir. Temel class'ın kullanıcıları iki boyutun bağımsız olduğunu varsayıyorsa subtype bu beklentiyi bozar.

Sorun matematiksel olarak square'ın rectangle olmaması değildir. Sorun, programdaki Rectangle abstraction'ının hangi davranışları vaat ettiğidir.

Polymorphism ise aynı abstraction üzerinden farklı implementation'ların kullanılabilmesidir. Örneğin PaymentGateway interface'ini CardGateway ve BankTransferGateway uygulayabilir. Sipariş işlemini yöneten kod, uygun gateway'e charge() çağrısı yapar.

Buradaki mekanizma dynamic dispatch'tir. Runtime sırasında kullanılan object'in gerçek type'ına göre uygun implementation çalıştırılır. Böylece üst seviyedeki kodun her ödeme yöntemi için ayrı bir type kontrolü yapması gerekmez.

Bu, polymorphism'in yalnızca inheritance ile mümkün olduğu anlamına gelmez. Interface implementation, duck typing ve farklı dil özellikleri de polymorphic behavior sağlayabilir. Önemli olan ortak kullanım sözleşmesidir.

Yaygın açıklamaların eksik bıraktığı mekanizma burada ortaya çıkar: polymorphism, koşulları sihirli biçimde yok etmez; değişken davranışın seçimini uygun sınıra taşır.

Bir yerde hâlâ hangi ödeme yönteminin kullanılacağına karar verilmesi gerekir. Bu seçim configuration, dependency injection veya runtime verisi üzerinden yapılabilir. Fakat seçimden sonra sipariş akışının her adımda ödeme türünü yeniden sorgulaması gerekmez.

Bu yaklaşım, yeni bir implementation eklenirken mevcut iş akışının daha az değiştirilmesini sağlayabilir. Bunun bedeli ise daha fazla abstraction ve dolaylı kontrol akışıdır. İki basit durum için kurulan karmaşık bir interface hiyerarşisi, doğrudan bir koşuldan daha iyi olmak zorunda değildir.

Composition, association ve aggregation farklı ilişkiler kurar

Inheritance bir is-a ilişkisini temsil eder. Composition ise çoğunlukla bir object'in davranışını başka object'lerle birlikte kurmasını sağlar. OrderService içinde bir PaymentGateway dependency'si bulunması buna örnektir.

OrderService, ödeme işlemini kendi içinde yeniden yazmak yerine gateway object'ine devreder. Gateway değiştirildiğinde sipariş akışının temel kodu aynı kalabilir. Buradaki ilişki bir subtype relationship değildir; OrderService, PaymentGateway türünde değildir, onu kullanır.

Composition kavramının iki farklı bağlamda kullanıldığına dikkat etmek gerekir. Design konuşmalarında composition çoğu zaman davranışları dependency'ler aracılığıyla birleştirmek anlamına gelir. UML'de composition ise daha özel bir whole-part ilişkisidir ve parçanın yaşam döngüsü bütüne bağlıdır.

Örneğin yalnızca belirli bir siparişin içinde anlam taşıyan OrderLine, UML açısından composition ile modellenebilir. Sipariş ortadan kalktığında o siparişe ait satırların bağımsız bir domain identity olarak yaşaması beklenmez. Buna karşılık aynı ürün kaydı birçok siparişte kullanılabilir; Product ile Order arasındaki ilişki aynı türden değildir.

Association, object'lerin birbirini tanıması veya aralarında anlamlı bir bağlantı bulunmasıdır. Bir Customer ile Order arasındaki ilişki association olarak düşünülebilir. Bu ilişki, taraflardan birinin diğerinin yaşam döngüsünü sahiplenmesini zorunlu kılmaz.

Aggregation ise daha zayıf bir whole-part ilişkisini ifade eder. Örneğin bir Team, üyelerden oluşabilir ancak Employee object'leri ekipten bağımsız varlıklarını sürdürebilir. UML'de aggregation için özel bir gösterim vardır, fakat pratikte association ile aggregation arasındaki ayrım çoğu sistemde yeterince açık değildir.

Bu nedenle her ilişkiye mutlaka UML düzeyinde bir sahiplik etiketi vermek gerekli değildir. Daha yararlı sorular şunlardır: Object'i kim oluşturuyor, yaşam döngüsünü kim yönetiyor, aynı instance birden fazla yerde paylaşılabilir mi ve değişiklikten hangi taraf etkileniyor?

Composition ile inheritance arasındaki tercih de bu sorulardan bağımsız değildir.

Inheritance, davranışsal olarak gerçekten bir subtype relationship varsa anlamlıdır. Composition ise davranışı değiştirmek, farklı parçaları birleştirmek veya implementation bağımlılıklarını sınırlamak istediğimizde çoğunlukla daha esnektir.

Ancak composition her durumda daha iyi değildir. Ortak ve kararlı bir davranış sözleşmesi bulunan subtype'lar için inheritance anlaşılır bir model olabilir. Gereksiz composition ise çok sayıda küçük object, dolaylı çağrı ve takip edilmesi zor bir dependency graph oluşturabilir.

Bir sipariş dosyasında tasarım kararları görünür olur

Bir e-ticaret uygulamasında Order class'ının başlangıçta yalnızca ürünleri ve toplam tutarı tuttuğunu varsayalım. Ödeme işlemi de aynı class içindeki pay() metodunda gerçekleştiriliyor olsun. İlk implementation yalnızca kart ödemesini destekliyorsa bu tasarım kısa süre çalışabilir.

Daha sonra banka transferi eklenir. Geliştirici pay() içine bir koşul koyar: ödeme yöntemi kartsa kart sağlayıcısını, transferse banka akışını çalıştır. Ardından yeni bir ödeme sağlayıcısı gelir. Bir süre sonra aynı metot ödeme doğrulama, hata yönetimi, sipariş durumu değiştirme ve bildirim gönderme sorumluluklarını taşımaya başlar.

Bu noktada problem class'ın uzunluğu değildir. Problem, birbirinden farklı nedenlerle değişen kodların aynı sınır içinde tutulmasıdır.

Daha kontrollü bir tasarımda Order, siparişin domain kurallarını yönetebilir. addItem(), confirm() ve markAsPaid() gibi işlemler üzerinden geçerli durum geçişlerini korur. Örneğin tamamlanmış bir siparişe yeni ürün eklenmesine izin vermeyebilir.

OrderLine, ürünün sipariş anındaki birim fiyatını ve miktarını saklayabilir. Bu ayrıntı önemlidir çünkü ürün kataloğundaki fiyat sonradan değiştiğinde geçmiş siparişin tutarı değişmemelidir. Sipariş toplamı, kendi satırlarının kayıtlı fiyatları ve miktarları üzerinden hesaplanabilir.

Ödeme işlemini yöneten bir application service, örneğin CheckoutService, siparişi alır ve uygun PaymentGateway implementation'ını kullanır. Başarılı ödeme sonucu doğrulandıktan sonra Order üzerindeki geçerli durum geçişini gerçekleştirir.

Bu yapıda Order ödeme sağlayıcısının HTTP endpoint'ini bilmez. Gateway implementation'ı da siparişin hangi durumlarda değişebileceğine karar vermez. Her parça kendi değişiklik nedenine göre sınırlandırılmıştır.

Ödeme sırasında farklı hata türleri de ayrıştırılmalıdır. Sağlayıcının ödemeyi reddetmesi ile network timeout aynı durum değildir. Timeout yaşandığında ödemenin gerçekleşip gerçekleşmediği bilinmeyebilir. Bu nedenle bilinmeyen sonucu otomatik olarak başarısız kabul edip yeniden ödeme istemek çift tahsilata yol açabilir.

Pratikte ödeme akışı idempotency key kullanabilir. Idempotency, aynı mantıksal isteğin tekrarlanmasının ikinci bir bağımsız etki üretmemesi özelliğidir. Sağlayıcı bunu destekliyorsa aynı ödeme girişimi için aynı key kullanılabilir. Yerel sistem de ödeme girişiminin durumunu kalıcı olarak takip etmelidir.

Bu örnek OOP'nin sınırını da gösterir. Bir Order object'inin encapsulation kuralları, tek bir process içindeki state değişikliklerini kontrol edebilir. Fakat iki farklı request aynı siparişi eşzamanlı güncelliyorsa yalnızca private alanlar yeterli değildir.

Database transaction, optimistic locking veya uygun başka bir concurrency control mekanizması gerekebilir. Örneğin iki işlem aynı siparişin version değerini okuyup güncelleme yapmaya çalışıyorsa yalnızca beklenen version ile eşleşen update'in başarılı olması sağlanabilir.

Aynı şekilde bir class'ın doğru tasarlanması, database ile ödeme sağlayıcısı arasında atomic transaction oluşturmaz. Dış sistemlerle çalışırken state machine, retry policy, idempotency ve gerektiğinde reconciliation süreci ayrı ayrı düşünülmelidir.

Bu ayrıntılar OOP'den uzaklaşmak değildir. Object sınırlarının hangi garantileri verebildiğini ve hangi garantilerin altyapı tarafından sağlanması gerektiğini ayırmaktır.

Inheritance mı composition mı? Önce değişimin yönünü belirleyin

Yeni bir behavior eklerken varsayılan tercihim, somut class'lardan inheritance almak yerine dar bir interface ve composition kullanmaktır. Özellikle ödeme sağlayıcıları, bildirim kanalları, dosya depolama implementation'ları ve dış API adapter'ları gibi bağımsız değişen bileşenlerde bu tercih daha savunulabilirdir.

Bunun nedeni composition'ın teorik olarak daha üstün olması değildir. Somut bir parent class'tan inheritance almak, child class'ı parent'ın implementation kararlarına bağlayabilir. Parent içindeki değişiklik, child'ın davranışını beklenmedik biçimde etkileyebilir. Composition ise bağımlılığı daha dar bir sözleşmeye yönlendirme imkânı verir.

Örneğin CardPaymentService extends PaymentService tasarımında temel class hem retry logic hem logging hem de ödeme kurallarını içeriyorsa yeni bir ödeme yöntemi bu kararların tamamını devralır. Oysa bazı yöntemlerde retry güvenliyken bazılarında aynı davranış yanlış olabilir.

Composition ile PaymentGateway, RetryPolicy ve PaymentAttemptRepository ayrı sorumluluklar taşıyabilir. Application service, bunların hangi sırayla kullanılacağını yönetir. Böylece retry kararını değiştirmek ödeme sağlayıcısının inheritance zincirini değiştirmeyi gerektirmez.

Yine de her farklılık için interface açmak doğru değildir. Yalnızca iki yerde kullanılan ve yakın zamanda değişmesi beklenmeyen küçük bir algoritma, basit bir fonksiyon olarak daha anlaşılır olabilir. OOP, her işlemin object olmasını gerektirmez.

Inheritance tercih edeceğim durum, subtype'ların ortak sözleşmeye gerçekten uyduğu ve temel abstraction'ın kararlı olduğu durumdur. Fakat ortak alanların bulunması tek başına inheritance gerekçesi değildir. Customer ve Employee class'larının ikisinde de name bulunması, birinin diğerinden türemesini gerektirmez.

Tasarım kararında önce değişmesi beklenen davranışı bulun. Sonra bu değişikliğin hangi dosyalara yayılacağını düşünün. Yeni bir ödeme yöntemi eklemek için Order, CheckoutService ve mevcut gateway implementation'larını değiştirmek gerekiyorsa abstraction sınırı muhtemelen yanlış yerdedir.

Bununla birlikte sıfır değişiklik hedefi gerçekçi değildir. Yeni ödeme yöntemi farklı bir business rule getiriyorsa domain modelinin de değişmesi gerekebilir. Polymorphism, gerçek domain farklılıklarını ortadan kaldırmaz; yalnızca aynı sözleşme altında yönetilebilen farklı implementation'ları ayırır.

Gerçek dünyayı object'lere modelleme fikrinin sınırı da burada belirir. Yazılımdaki sınırlar fiziksel nesnelerin sınırlarıyla birebir örtüşmez. Order, gerçek dünyada bir nesne gibi görünse de yazılımda belirli kuralları koruyan bir domain modelidir. PaymentGateway ise fiziksel bir varlığı temsil etmek zorunda değildir; değişken bir teknik davranışı soyutlar.

Bazı problemler object graph yerine data transformation olarak daha açık ifade edilir. Büyük veri işleme işleri, matematiksel hesaplamalar veya stateless dönüşümler için functional ya da procedural yaklaşım daha uygun olabilir. Bu durum OOP'nin başarısızlığı değil, farklı problemlerin farklı düzenleme biçimleri gerektirmesidir.

OOP kullanırken en güçlü kontrol sorusu şudur: Bu object'in koruduğu kural nedir ve bu kural değiştiğinde kaç başka bileşenin değişmesi gerekecek? Cevap yalnızca alan isimlerinden oluşuyorsa class sınırını yeniden değerlendirin.

Önce behavior'ı ve invariant'ları tanımlayın, sonra bunları koruyacak object sınırını oluşturun. Değişken implementation'ları dar interface'lerin arkasına alın, gerçek subtype relationship bulunmadıkça composition'ı tercih edin. Ancak domain basitse, abstraction yalnızca olası bir geleceğe hazırlanıyorsa veya işlem doğal olarak stateless ise bu öneri geçerliliğini kaybeder. O noktada daha az class, daha az indirection ve doğrudan çalışan kod daha iyi bir tasarımdır.

yorumlar

0

Henüz yorum yok. İlk notu sen bırak.

Yorum yazmak için giriş yap.

Gmail ile saniyeler içinde, ya da e-posta ile kaydol.

bağlı 26109:32:57