Genel Durum ve Singleton'lar
Uyarı: Aşağıdaki yapılar, kötü tasarlanmış kodun belirtileridir:
Foo::getInstance()DB::insert(...)Article::setDb($db)ClassName::$varya dastatic::$var
Bu yapılardan herhangi biri kodunuzda geçiyor mu? Öyleyse iyileştirme fırsatınız var. Bunların yaygın yapılar olduğunu, belki çeşitli kütüphane ve framework'lerin örnek çözümlerinde gördüğünüzü düşünebilirsiniz. Öyleyse o kodların tasarımı kusurludur.
Burada akademik bir arılıktan söz etmiyoruz. Bu yapıların hepsi ortak bir özellik taşır: genel durumu (global state) kullanırlar. Genel durum ise kod kalitesine zarar verir. Sınıflar bağımlılıkları konusunda yanıltıcı hâle gelir. Kod öngörülemez olur. Geliştiricilerin kafasını karıştırır ve verimliliklerini düşürür.
Bu bölümde bunun neden böyle olduğunu ve genel durumdan nasıl kaçınılacağını açıklayacağız.
Genel Bağlantılar
İdeal bir dünyada bir nesne, yalnızca kendisine
doğrudan aktarılmış nesnelerle iletişim kurmalıdır. A ve B adında iki nesne oluşturur ve
aralarında hiçbir referans aktarmazsam, ne A ne de B diğerinin durumuna erişebilir ya da onu
değiştirebilir. Bu, kodun son derece istenen bir özelliğidir. Elinizde bir pil ve bir ampul olmasına benzer; ampulü telle
pile bağlamadıkça yanmaz.
Ancak bu, genel (statik) değişkenler ya da singleton'lar için geçerli değildir. A nesnesi, hiçbir referans
aktarımı olmadan C::changeSomething() çağırarak C nesnesine kablosuz erişebilir ve onu
değiştirebilir. B nesnesi de genel C nesnesine bağlanırsa, A ile B
birbirini C üzerinden etkileyebilir.
Genel değişken kullanmak, dışarıdan görünmeyen yeni bir kablosuz bağlantı biçimi getirir. Kodu anlamayı ve
kullanmayı zorlaştıran bir sis perdesi yaratır. Bağımlılıkları gerçekten kavramak için geliştiricilerin, yalnızca
sınıf arayüzlerine güvenmek yerine kaynak kodun her satırını okuması gerekir. Üstelik bu bağlantı tümüyle
gereksizdir. Genel durum, her yerden kolayca erişilebilir olduğu ve örneğin genel (statik) bir DB::insert()
metoduyla veritabanına yazmayı sağladığı için kullanılır. Ancak göstereceğimiz gibi, bu algılanan kolaylık,
getirdiği ağır sorunların yanında çok küçüktür.
Davranış açısından genel değişkenle statik değişken arasında fark yoktur. İkisi de aynı ölçüde zararlıdır.
Uzaktan Ürkütücü Etki
“Uzaktan ürkütücü etki” (“spooky action at a distance”) – Albert Einstein'ın kuantum fiziğinde kendisini ürperten bir olguya verdiği ünlü ad budur. Kuantum dolanıklığını anlatır: bir parçacığın bir özelliğinin ölçülmesi, aralarındaki uzaklık milyonlarca ışık yılı bile olsa, dolanık diğer parçacığı anında etkiler; bu da görünüşte evrenin hiçbir şeyin ışıktan hızlı gidemeyeceği temel yasasını çiğner.
Yazılım dünyasında “uzaktan ürkütücü etki”, yalıtılmış olduğunu sandığımız (çünkü açıkça hiçbir bağımlılık aktarılmamıştır) bir işlemi çalıştırdığımızda, sistemin uzak köşelerinde haberimiz olmadan beklenmedik etkileşimlerin ve durum değişikliklerinin olmasını anlatır. Bu ancak genel durumla gerçekleşebilir.
Büyük ve olgun bir kod tabanı olan bir projede geliştirme ekibine katıldığınızı düşünün. Yeni yöneticiniz sizden yeni bir özellik gerçekleştirmenizi istiyor ve iyi bir geliştirici olarak işe bir test yazarak başlıyorsunuz. Ama projede yeni olduğunuz için “bu metodu çağırsam ne olur” türünden bolca keşif testi yapıyorsunuz. Ve şu testi yazmayı deniyorsunuz:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // sizin kart numaranız
$cc->charge(100);
}
Kodu çalıştırıyorsunuz, belki birkaç kez, ve bir süre sonra telefonunuzda banka bildirimlerini fark ediyorsunuz: her çalıştırmada kredi kartınızdan 100 dolar çekilmiş! 🤦♂️
Test nasıl olur da gerçek bir çekim yapar? Kredi kartıyla işlem yapmak basit değildir. Üçüncü taraf bir web servisiyle iletişim kurmanız, URL'sini bilmeniz, kimlik doğrulaması yapmanız vb. gerekir. Bu bilgilerin hiçbiri testte yok. Daha kötüsü, bu bilgilerin nerede durduğunu da bilmiyorsunuz; bu yüzden her test çalıştırmasındaki 100 dolarlık çekimi önlemek için dış bağımlılıkları taklit etmek olanaksız. Peki yeni bir geliştirici olarak, yapmak üzere olduğunuz şeyin sizi 100 dolar yoksullaştıracağını nereden bilecektiniz?
İşte bu, uzaktan ürkütücü etkidir!
Projenin iç bağlantılarını anlamak için upuzun kaynak kodu taramak ve kıdemli meslektaşlarınıza danışmak zorunda
kalırsınız. Bu zorluk, CreditCard sınıfının arayüzünün gereken genel durum başlatmasını açığa
vurmamasından doğar. Sınıfın kaynak kodunu incelemek bile hangi başlatma metodunun çağrılacağını göstermeyebilir. En
iyi durumda, erişilen genel değişkeni bulup onu nasıl başlatacağınızı çıkarsamaya çalışırsınız.
Böyle bir projedeki sınıflar patolojik yalancılardır. CreditCard sınıfı, öylece örneklenip
charge() metodunun çağrılabileceği havasını verir. Oysa gizlice, ödeme geçidini temsil eden başka bir
sınıfla, PaymentGateway ile etkileşir. PaymentGateway arayüzü bile bağımsız başlatma
çağrıştırabilir, ama gerçekte kimlik bilgilerini bir yapılandırma dosyasından çekiyor olabilir vb. Özgün
geliştiriciler CreditCard sınıfının PaymentGateway gerektirdiğini bilir. Kodu böyle
yazmışlardır. Ama yeni gelenler için bu tam bir gizemdir ve öğrenmelerini, katkı vermelerini engeller.
Durum nasıl düzeltilir? Kolay. Bırakın API bağımlılıkları bildirsin.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Koddaki karşılıklı bağımlılıkların nasıl anında görünür olduğuna dikkat edin. charge() metodu bir
PaymentGateway gerektirdiğini bildirdiğinden, artık bu bağımlılığı tahmin etmeniz ya da sormanız gerekmez.
Bir örnek oluşturmanız gerektiğini bilirsiniz ve bunu yaparken gereken erişim parametrelerini keşfedersiniz. Onlar olmadan
kod hiç çalışmazdı.
Ve en önemlisi, artık ödeme geçidini taklit edebilirsiniz; böylece her test çalıştırmasında 100 dolar ödemezsiniz.
Genel durum, nesnelerin API'lerinde bildirilmeyen bağımlılıklara gizlice erişmesine izin verir ve API'lerinizi fiilen patolojik yalancılara çevirir.
Bunu daha önce böyle düşünmemiş olabilirsiniz, ama genel durum kullandığınız her seferde gizli kablosuz iletişim kanalları yaratırsınız. Bu uzaktan ürkütücü etki, geliştiricileri olası etkileşimleri anlamak için her satırı okumaya zorlar; üretkenliği düşürür ve yeni ekip üyelerinin kafasını karıştırır. Kodu siz yazdıysanız gerçek bağımlılıkları bilirsiniz, ama sizden sonra gelen herkes karanlıktadır.
Genel duruma dayanan kod yazmaktan kaçının; bağımlılıkları açıkça aktarmayı yeğleyin. Bağımlılık enjeksiyonunu benimseyin.
Genel Durumun Kırılganlığı
Genel durum ve singleton kullanan kodda, durumun ne zaman ve kim tarafından değiştirildiğinden asla emin olamazsınız. Bu risk başlatma sırasında bile kendini gösterir. Aşağıdaki kod bir veritabanı bağlantısı kurup bir ödeme geçidini başlatmayı amaçlar, ama sürekli istisna fırlatır ve nedenini bulmak son derece yorucudur:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
PaymentGateway nesnesinin başka nesnelere kablosuz eriştiğini, bazılarının da veritabanı bağlantısı
gerektirdiğini bulmak için kodu titizlikle izlemeniz gerekir. Dolayısıyla veritabanı PaymentGateway nesnesinden
önce başlatılmalıdır. Ama genel durumun sis perdesi bunu sizden gizler. Bu sınıfların API'leri dürüst olup
bağımlılıklarını bildirseydi ne kadar zaman kazanılırdı?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, /* ... */);
Bir veritabanı bağlantısına genel erişim kullanıldığında da benzer bir sorun doğar:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
save() metodu çağrıldığında, bir veritabanı bağlantısının kurulup kurulmadığı ya da kurmaktan kimin
sorumlu olduğu belirsizdir. Veritabanı bağlantısını dinamik olarak değiştirmemiz gerekirse (örneğin test için),
DB::reconnect(...) ya da DB::reconnectForTest() gibi metotlar eklemeye yönelebiliriz.
Bir örnek düşünün:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
$article->save() çağrıldığında test veritabanının gerçekten kullanıldığından nasıl emin
olabiliriz? Ya Foo::doSomething() metodu genel veritabanı bağlantısını değiştirdiyse? Bunu belirlemek için
Foo sınıfının ve olasılıkla daha pek çok sınıfın kaynak kodunu incelememiz gerekirdi. Bu araştırma da
yalnızca geçici bir yanıt verirdi; çünkü durum sonradan değişebilir.
Ya veritabanı bağlantısını Article sınıfının içindeki statik bir değişkene taşırsak?
class Article
{
private static DB $db;
public static function setDb(DB $db): void
{
self::$db = $db;
}
public function save(): void
{
self::$db->insert(/* ... */);
}
}
Bu hiçbir şeyi değiştirmez. Sorun, hangi sınıfın içinde gizlendiğinden bağımsız olarak genel durumun kendisidir. Bu
senaryoda da, tıpkı öncekinde olduğu gibi, $article->save() çağrıldığında verinin hangi veritabanına
yazılacağından emin olamayız. Uygulamanın herhangi bir yerinde herkes, Article::setDb() ile veritabanını
istediği an değiştirmiş olabilir. Bizim haberimiz olmadan.
Genel durum, uygulamamızı son derece kırılgan kılar.
Ancak bu sorunla başa çıkmanın basit bir yolu var. Düzgün çalışmayı güvence altına almak için API'nin bağımlılıkları bildirmesini sağlayın.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Bu yaklaşım, veritabanı bağlantısında gizli ya da beklenmedik değişiklik kaygısını ortadan kaldırır. Makalenin nereye kaydedildiğinden artık eminiz ve ilgisiz sınıflardaki değişiklikler bunu artık etkileyemez. Kod artık kırılgan değil, kararlıdır.
Genel duruma dayanan kod yazmaktan kaçının; bağımlılıkları açıkça aktarmayı yeğleyin. Bağımlılık enjeksiyonunu benimseyin.
Singleton
Singleton, ünlü Gang of Four kitabındaki tanıma göre bir sınıfı tek bir örnekle sınırlayan ve ona genel erişim sunan bir tasarım desenidir. Bu desenin gerçekleştirimi genellikle şu koda benzer:
class Singleton
{
private static self $instance;
public static function getInstance(): self
{
self::$instance ??= new self;
return self::$instance;
}
// ve sınıfın işlevlerini yerine getiren diğer metotlar
}
Ne yazık ki singleton, uygulamaya genel durum katar. Ve yukarıda gösterdiğimiz gibi genel durum istenmeyen bir şeydir. Bu yüzden singleton bir anti-desen sayılır.
Kodunuzda singleton kullanmayın, onları başka düzeneklerle değiştirin. Singleton'lara gerçekten ihtiyacınız yok. Yine
de uygulama boyunca bir sınıftan yalnızca bir örnek bulunmasını sağlamanız gerekiyorsa, bu sorumluluğu DI container'a devredin. Bu, yaygın olarak servis diye
anılan, uygulama kapsamında bir singleton oluşturur. Sınıfın kendisi böylece kendi biricikliğini yönetmekten kurtulur
(yani getInstance() metodu ya da statik örnek özelliği olmaz) ve yalnızca kendi sorumluluklarına odaklanabilir.
Böylece tek sorumluluk ilkesini çiğnemeyi bırakır.
Genel Durum ve Testler
Test yazarken ideal olarak her testin yalıtılmış bir birim olduğunu, ona dışarıdan durum girmediğini ve ondan durum çıkmadığını varsayarız. Bir test bittikten sonra onunla ilgili her durum çöp toplayıcı tarafından otomatik olarak temizlenmelidir. Testleri yalıtılmış kılan budur. Böylece testleri istediğimiz sırayla çalıştırabiliriz.
Ancak genel durumlar ya da singleton'lar varsa, bu yararlı varsayımlar çöker. Durum testlere sızabilir ve testlerden dışarı çıkabilir. Aniden testlerin sırası önem kazanabilir.
Singleton içeren kodu test edebilmek için bile geliştiriciler çoğu zaman bütünlükten ödün vermek zorunda kalır;
örneğin singleton örneğinin değiştirilebilmesine izin vererek. Böyle çözümler en iyi durumda birer hiledir ve bakımı
ile anlaşılması zor koda yol açar. Genel durumu değiştiren her test (ya da onun tearDown() metodu), bu
değişiklikleri titizlikle geri almalıdır.
Genel durum, birim testlerdeki en büyük baş ağrısıdır!
Bu nasıl düzeltilir? Basit. Singleton kullanan kod yazmaktan kaçının; bağımlılıkları açıkça aktarmayı yeğleyin. Bağımlılık enjeksiyonunu benimseyin.
Genel Sabitler
Genel durum, singleton ve statik değişken kullanımıyla sınırlı değildir; genel sabitler için de geçerli olabilir.
Değerleri evrensel gerçekleri temsil eden (M_PI) ya da kendi içinde tam bilgi veren
(PREG_BACKTRACK_LIMIT_ERROR) sabitler genellikle kabul edilebilir. Buna karşılık, koda kablosuz bilgi
enjekte etmenin bir yolu olarak kullanılan sabitler aslında gizli bağımlılıklardır. Aşağıdaki örnekteki
LOG_FILE gibi. FILE_APPEND sabitini kullanmak ise tümüyle doğrudur.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
Bunun yerine günlük dosyasının yolunu Foo sınıfının yapıcısında bir parametre olarak bildirmeli ve
böylece API'sinin açık bir parçası yapmalıyız:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Artık günlük dosyasının yolunu açıkça aktarıyoruz. Onu gerektiğinde kolayca değiştirebiliriz; bu da testi ve kod bakımını kolaylaştırır.
Genel Fonksiyonlar ve Statik Metotlar
Statik metot ve genel fonksiyon kullanmanın özünde sorunlu olmadığını vurgulamak isteriz. DB::insert() gibi
metotlardaki sorunları açıkladık, ama asıl sorun her zaman alttaki genel durumdu; tipik olarak statik bir değişkende
saklanan durum. DB::insert() metodu, veritabanı bağlantısını tutmak için statik bir değişkene dayanır. Bu
değişken olmasa metodu gerçekleştirmek olanaksız olurdu.
Closure::fromCallable(), strlen() gibi belirlenimci statik metotları ve fonksiyonları kullanmak,
bağımlılık enjeksiyonuyla tümüyle bağdaşır. Bu fonksiyonlar öngörülebilirdir; çünkü aynı girdi parametreleri için
her zaman aynı sonucu döndürürler. Hiçbir genel durum kullanmazlar.
Ancak PHP'de belirlenimci olmayan fonksiyonlar da vardır. Örneğin htmlspecialchars() fonksiyonu. Üçüncü
parametresi $encoding, atlanırsa varsayılan olarak default_charset yapılandırma seçeneğinin
değerini (ini_get('default_charset')) alır. Bu yüzden olası öngörülemez davranışı önlemek için bu
parametreyi her zaman belirtmek önerilir. Nette bunu tutarlı biçimde yapar.
strtolower() ve strtoupper() gibi bazı fonksiyonlar yakın geçmişte, yerel ayara
(setlocale()) bağlı olarak belirlenimci olmayan davranış gösteriyordu. Bu, çoğu zaman Türkçeyle
çalışırken pek çok soruna yol açtı. Çünkü Türkçe, hem küçük hem büyük harfte noktalı ve noktasız “I”
harfini ayırır. Sonuç olarak strtolower('I') ı (noktasız küçük i), strtoupper('i')
ise İ (noktalı büyük I) döndürüyordu; bu da sayısız gizemli uygulama hatasına yol açtı. Ancak bu sorun
PHP 8.2 sürümünde düzeltildi ve fonksiyonlar artık yerel ayara bağlı değil.
Bu, genel durumun (yerel ayarın) dünya çapında binlerce geliştiriciyi nasıl uğraştırdığının iyi bir örneğidir. Nihai çözüm, fonksiyonları yerel ayardan bağımsız kılmak, yani gizli bağımlılığı kaldırmak oldu.
Genel Durum Ne Zaman Kullanılabilir?
Genel durum kullanmanın kabul edilebilir olduğu belirli, sınırlı durumlar vardır. Örneğin hata ayıklama sırasında bir değişkenin değerini dökmeniz ya da belirli bir kod parçasının yürütme süresini ölçmeniz gerektiğinde. Sonradan koddan kaldırılacak geçici işlemleri kapsayan bu durumlarda, her yerden erişilebilir bir dumper ya da zamanlayıcı kullanmak meşru olabilir. Bu araçlar uygulamanın temel tasarımının parçası değildir.
Bir başka örnek de, derlenmiş düzenli ifadeleri içeride statik bellekte önbelleğe alan PHP'nin düzenli ifade
fonksiyonlarıdır (preg_*). Kodunuzda aynı düzenli ifadeyle bu fonksiyonları defalarca çağırdığınızda,
ifade yalnızca bir kez derlenir. Bu önbellekleme başarımı artırır ve kullanıcı için tümüyle görünmezdir; bu yüzden
iç statik durumun bu kullanımı genellikle kabul edilebilir.
Özet
Şunları yapmanın neden anlamlı olduğunu ele aldık:
- Kodunuzdaki tüm değiştirilebilir statik özellikleri (genel durumu) ortadan kaldırmak
- Bağımlılıkları açıkça bildirmek
- Ve bağımlılık enjeksiyonundan yararlanmak
Kodunuzu tasarlarken, her değiştirilebilir static $foo değişkeninin olası bir sorun kaynağı olduğunu
unutmayın. DI dostu bir ortam yaratmak için genel durumu tümüyle ortadan kaldırıp yerine bağımlılık enjeksiyonunu koymak
can alıcı önemdedir.
Bu süreçte, birden çok sorumluluğu olan sınıfları bölme ihtiyacını keşfedebilirsiniz. Bunu yapmaktan çekinmeyin; tek sorumluluk ilkesini hedefleyin.
Bu bölümün temelini oluşturan Flaw: Brittle Global State & Singletons gibi yazıları için Miško Hevery'ye teşekkür ederim.