Genel Durum ve Singleton'lar

Uyarı: Aşağıdaki yapılar, kötü tasarlanmış kodun belirtileridir:

  • Foo::getInstance()
  • DB::insert(...)
  • Article::setDb($db)
  • ClassName::$var ya da static::$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:

  1. Kodunuzdaki tüm değiştirilebilir statik özellikleri (genel durumu) ortadan kaldırmak
  2. Bağımlılıkları açıkça bildirmek
  3. 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.

versiyon: 3.x