Global Durum ve Singleton'lar
Uyarı: Aşağıdaki yapılar kötü tasarlanmış kodun işaretidir:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
veyastatic::$var
Bu yapılardan bazıları kodunuzda bulunuyor mu? O zaman onu iyileştirme fırsatınız var. Belki de bunların, çeşitli kütüphanelerin ve framework'lerin örnek çözümlerinde bile gördüğünüz yaygın yapılar olduğunu düşünüyorsunuzdur. Eğer durum buysa, o zaman kodlarının tasarımı iyi değildir.
Şimdi kesinlikle bir tür akademik saflıktan bahsetmiyoruz. Tüm bu yapıların ortak bir yanı var: global durumu kullanıyorlar. Ve bunun kod kalitesi üzerinde yıkıcı bir etkisi var. Sınıflar bağımlılıkları hakkında yalan söylüyor. Kod öngörülemez hale geliyor. Programcıları şaşırtıyor ve verimliliklerini düşürüyor.
Bu bölümde, neden böyle olduğunu ve global durumdan nasıl kaçınılacağını açıklayacağız.
Global Bağlantı
İdeal bir dünyada, bir nesne yalnızca doğrudan
aktarılan nesnelerle iletişim kurabilmelidir. Eğer iki A
ve B
nesnesi oluşturursam ve
aralarında asla bir referans aktarmazsam, o zaman ne A
ne de B
, diğer nesneye erişemez veya durumunu
değiştiremez. Bu, kodun çok istenen bir özelliğidir. Bu, bir piliniz ve bir ampulünüz olmasına benzer; ampulü pille bir
telle bağlamadığınız sürece yanmaz.
Ancak bu, global (statik) değişkenler veya singleton'lar için geçerli değildir. A
nesnesi,
C::changeSomething()
çağırarak herhangi bir referans aktarımı olmadan kablosuz olarak C
nesnesine erişebilir ve onu değiştirebilir. Eğer B
nesnesi de global C
'yi ele geçirirse, o zaman
A
ve B
birbirini C
aracılığıyla etkileyebilir.
Global değişkenlerin kullanımı, sisteme dışarıdan görünmeyen yeni bir kablosuz bağlantı formu katar. Kodun
anlaşılmasını ve kullanılmasını zorlaştıran bir sis perdesi oluşturur. Geliştiricilerin bağımlılıkları gerçekten
anlamaları için, kaynak kodunun her satırını okumaları gerekir. Sadece sınıf arayüzleriyle tanışmak yerine. Üstelik bu
tamamen gereksiz bir bağlantıdır. Global durum, her yerden kolayca erişilebilir olduğu ve örneğin DB::insert()
global (statik) metodu aracılığıyla veritabanına yazmaya izin verdiği için kullanılır. Ama göstereceğimiz gibi, bunun
getirdiği avantaj önemsizdir, aksine neden olduğu komplikasyonlar ölümcüldür.
Davranış açısından global ve statik değişken arasında bir fark yoktur. Eşit derecede zararlıdırlar.
Uzaktan Ürkütücü Etki
“Uzaktan ürkütücü etki” – 1935'te Albert Einstein, kuantum fiziğinde tüylerini diken diken eden bir olguyu bu şekilde ünlü bir şekilde adlandırdı. Bu, kuantum dolaşıklığıdır ve özelliği, bir parçacık hakkındaki bilgiyi ölçtüğünüzde, milyonlarca ışık yılı uzakta olsalar bile diğer parçacığı anında etkilemenizdir. Bu, görünüşte evrenin temel yasasını, yani hiçbir şeyin ışıktan daha hızlı yayılamayacağını ihlal eder.
Yazılım dünyasında, “uzaktan ürkütücü etki” olarak, izole olduğunu düşündüğümüz (çünkü ona hiçbir referans aktarmadık) bir süreci başlattığımızda, ancak sistemin uzak yerlerinde haberimiz olmayan beklenmedik etkileşimlerin ve durum değişikliklerinin meydana geldiği durumu adlandırabiliriz. Bu, yalnızca global durum aracılığıyla meydana gelebilir.
Geniş, olgun bir kod tabanına sahip bir projenin geliştirici ekibine katıldığınızı hayal edin. Yeni yöneticiniz sizden yeni bir özellik uygulamanızı ister ve siz de doğru bir geliştirici olarak test yazarak başlarsınız. Ama projede yeni olduğunuz için, “bu metodu çağırırsam ne olur” türünde bir sürü keşif testi yaparsınız. Ve aşağıdaki testi yazmayı denersiniz:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // kart numaranız
$cc->charge(100);
}
Kodu çalıştırırsınız, belki birkaç kez, ve bir süre sonra cep telefonunuzda bankadan bildirimler fark edersiniz, her çalıştırmada ödeme kartınızdan 100 dolar çekildiğini 🤦♂️
Tanrı aşkına nasıl test gerçek para çekme işlemine neden olabilir? Ödeme kartıyla işlem yapmak kolay değildir. Üçüncü taraf bir web servisiyle iletişim kurmanız, bu web servisinin URL'sini bilmeniz, giriş yapmanız vb. gerekir. Bu bilgilerin hiçbiri testte yer almaz. Daha da kötüsü, bu bilgilerin nerede bulunduğunu bile bilmiyorsunuz ve dolayısıyla her çalıştırmanın tekrar 100 dolar çekilmesine yol açmaması için dış bağımlılıkları nasıl mocklayacağınızı da bilmiyorsunuz. Ve yeni bir geliştirici olarak, yapmaya hazırlandığınız şeyin sizi 100 dolar daha fakir yapacağını nasıl bilmeliydiniz?
Bu uzaktan ürkütücü etki!
Yapmaktan başka çareniz yok, uzun süre bir sürü kaynak kodunu eşelemek, daha yaşlı ve deneyimli meslektaşlara sormak,
projedeki bağlantıların nasıl çalıştığını anlayana kadar. Bu, CreditCard
sınıfının arayüzüne
bakıldığında, başlatılması gereken global durumu tespit etmenin mümkün olmamasından kaynaklanmaktadır. Hatta sınıfın
kaynak koduna bakmak bile hangi başlatma metodunu çağırmanız gerektiğini açığa çıkarmaz. En iyi durumda, erişilen bir
global değişken bulabilir ve ondan nasıl başlatılacağını tahmin etmeye çalışabilirsiniz.
Böyle bir projedeki sınıflar patolojik yalancılardır. Ödeme kartı, sadece örneklenip charge()
metodunun
çağrılmasının yeterliymiş gibi davranır. Ancak gizlice, ödeme ağ geçidini temsil eden başka bir
PaymentGateway
sınıfıyla işbirliği yapar. Onun arayüzü de bağımsız olarak başlatılabileceğini söyler,
ancak gerçekte kimlik bilgilerini bir yapılandırma dosyasından çeker vb. Bu kodu yazan geliştiricilere,
CreditCard
'ın PaymentGateway
'e ihtiyaç duyduğu açıktır. Kodu bu şekilde yazdılar. Ama projede
yeni olan herkes için bu tam bir gizemdir ve öğrenmeyi engeller.
Durum nasıl düzeltilir? Kolayca. API'nin bağımlılıkları bildirmesine izin verin.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Kod içindeki bağlantıların nasıl birdenbire açık hale geldiğine dikkat edin. charge()
metodunun
PaymentGateway
'e ihtiyaç duyduğunu bildirmesiyle, kodun nasıl bağlantılı olduğunu kimseye sormanıza gerek
kalmaz. Bir örnek oluşturmanız gerektiğini bilirsiniz ve bunu denediğinizde, erişim parametrelerini sağlamanız
gerektiğiyle karşılaşırsınız. Onlar olmadan kod çalıştırılamazdı bile.
Ve en önemlisi, şimdi ödeme ağ geçidini mocklayabilirsiniz, böylece testin her çalıştırılmasında size 100 dolar fatura edilmeyecek.
Global durum, nesnelerinizin API'lerinde bildirilmemiş şeylere gizlice erişebilmesine neden olur ve sonuç olarak API'lerinizi patolojik yalancılara dönüştürür.
Belki de daha önce bu şekilde düşünmediniz, ancak ne zaman global durumu kullanırsanız, gizli kablosuz iletişim kanalları oluşturursunuz. Uzaktan ürkütücü eylem, geliştiricileri potansiyel etkileşimleri anlamak için kodun her satırını okumaya zorlar, geliştirici üretkenliğini düşürür ve yeni takım üyelerini şaşırtır. Eğer kodu oluşturan sizseniz, gerçek bağımlılıkları bilirsiniz, ama sizden sonra gelen herkes çaresizdir.
Global durumu kullanan kod yazmayın, bağımlılıkların aktarılmasına öncelik verin. Yani bağımlılık enjeksiyonu.
Global Durumun Kırılganlığı
Global durumu ve singleton'ları kullanan kodda, bu durumun ne zaman ve kim tarafından değiştirildiği asla emin değildir. Bu risk zaten başlatma sırasında ortaya çıkar. Aşağıdaki kodun veritabanı bağlantısı oluşturması ve ödeme ağ geçidini başlatması gerekiyor, ancak sürekli istisna fırlatıyor ve nedenini aramak son derece uzun sürüyor:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
PaymentGateway
nesnesinin kablosuz olarak diğer nesnelere eriştiğini ve bunlardan bazılarının veritabanı
bağlantısı gerektirdiğini öğrenmek için kodu ayrıntılı olarak incelemeniz gerekir. Yani veritabanını
PaymentGateway
'den önce başlatmak gereklidir. Ancak global durumun sis perdesi bunu sizden gizler. Eğer bireysel
sınıfların API'leri aldatmasaydı ve bağımlılıklarını bildirseydi ne kadar zaman kazanırdınız?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);
Benzer bir sorun, veritabanı bağlantısına global erişim kullanıldığında da ortaya çıkar:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
save()
metodu çağrıldığında, veritabanı bağlantısının zaten oluşturulup oluşturulmadığı ve onun
oluşturulmasından kimin sorumlu olduğu emin değildir. Eğer örneğin testler için çalışma zamanında veritabanı
bağlantısını değiştirmek istersek, muhtemelen DB::reconnect(...)
veya DB::reconnectForTest()
gibi
başka metotlar oluşturmamız gerekirdi.
Bir örnek düşünelim:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
$article->save()
çağrıldığında test veritabanının gerçekten kullanıldığına nerede emin olabiliriz?
Ya Foo::doSomething()
metodu global veritabanı bağlantısını değiştirdiyse? Öğrenmek için Foo
sınıfının kaynak kodunu ve muhtemelen birçok başka sınıfın da kodunu incelememiz gerekirdi. Ancak bu yaklaşım yalnızca
kısa vadeli bir cevap getirirdi, çünkü durum gelecekte değişebilir.
Ya veritabanı bağlantısını Article
sınıfının içindeki bir statik 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ştirmedi. Sorun global durumdur ve hangi sınıfta saklandığı hiç fark etmez. Bu durumda, önceki
gibi, $article->save()
metodunu çağırdığımızda hangi veritabanına yazılacağına dair hiçbir ipucumuz
yok. Uygulamanın diğer ucundaki herhangi biri, Article::setDb()
kullanarak veritabanını herhangi bir zamanda
değiştirebilirdi. Elimizin altında.
Global durum uygulamamızı son derece kırılgan yapar.
Ancak bu sorunla başa çıkmanın basit bir yolu var. Sadece API'nin bağımlılıkları bildirmesine izin vermek yeterlidir, bu da doğru işlevselliği sağlar.
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 sayesinde, veritabanı bağlantısındaki gizli ve beklenmedik değişiklikler hakkında endişe ortadan kalkar. Şimdi makalenin nereye kaydedildiğinden eminiz ve başka ilişkisiz bir sınıfın içindeki kod düzenlemeleri artık durumu değiştiremez. Kod artık kırılgan değil, ama kararlı.
Global durumu kullanan kod yazmayın, bağımlılıkların aktarılmasına öncelik verin. Yani bağımlılık enjeksiyonu.
Singleton
Singleton, bilinen Gang of Four yayınından tanıma göre, sınıfı tek bir örneğe sınırlayan ve ona global erişim sunan bir tasarım desenidir. Bu desenin uygulanması genellikle aşağıdaki 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 verilen işlevlerini yerine getiren diğer metotlar
}
Maalesef, singleton uygulamaya global durum getirir. Ve yukarıda gösterdiğimiz gibi, global durum istenmez. Bu yüzden singleton bir anti-desen olarak kabul edilir.
Kodunuzda singleton'ları kullanmayın ve onları başka mekanizmalarla değiştirin. Singleton'lara gerçekten ihtiyacınız
yok. Ancak tüm uygulama için sınıfın tek bir örneğinin varlığını garanti etmeniz gerekiyorsa, bunu DI konteynerine bırakın. Böylece bir uygulama singleton'u,
yani bir servis oluşturun. Bu sayede sınıf kendi benzersizliğini sağlamaya (yani getInstance()
metodu ve statik
değişkene sahip olmayacak) odaklanmayı bırakır ve yalnızca kendi işlevlerini yerine getirir. Böylece Tek Sorumluluk
İlkesi'ni ihlal etmeyi bırakır.
Global Durum ve Testler
Test yazarken, her testin izole bir birim olduğunu ve ona hiçbir dış durumun girmediğini varsayarız. Ve hiçbir durum testlerden çıkmaz. Test tamamlandıktan sonra, testle ilgili tüm durumun çöp toplayıcı (garbage collector) tarafından otomatik olarak kaldırılması gerekir. Bu sayede testler izole edilmiştir. Bu yüzden testleri istenilen sırada çalıştırabiliriz.
Ancak global durumlar/singleton'lar mevcutsa, tüm bu hoş varsayımlar parçalanır. Durum teste girebilir ve ondan çıkabilir. Birdenbire testlerin sırası önemli olabilir.
Singleton'ları test edebilmek için bile, geliştiriciler genellikle özelliklerini gevşetmek zorunda kalır, belki de
örneği başkasıyla değiştirmeye izin vererek. Böyle çözümler en iyi durumda bir hack'tir ve bakımı zor, anlaşılır
olmayan kod oluşturur. Herhangi bir global durumu etkileyen her test veya tearDown()
metodu, bu değişiklikleri
geri almalıdır.
Global durum, birim testi sırasında en büyük baş ağrısıdır!
Durum nasıl düzeltilir? Kolayca. Singleton'ları kullanan kod yazmayın, bağımlılıkların aktarılmasına öncelik verin. Yani bağımlılık enjeksiyonu.
Global Sabitler
Global durum yalnızca singleton'ların ve statik değişkenlerin kullanımıyla sınırlı değildir, aynı zamanda global sabitlerle de ilgili olabilir.
Değeri bize yeni (M_PI
) veya faydalı (PREG_BACKTRACK_LIMIT_ERROR
) bir bilgi getirmeyen sabitler
kesinlikle sorunsuzdur. Aksine, bilgiyi kodun içine kablosuz olarak aktarmanın bir yolu olarak hizmet eden sabitler,
gizli bir bağımlılıktan başka bir şey değildir. Aşağıdaki örnekteki LOG_FILE
gibi.
FILE_APPEND
sabitinin kullanımı tamamen doğrudur.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
Bu durumda, API'nin bir parçası olması için Foo
sınıfının kurucusunda bir parametre bildirmeliyiz:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Şimdi kayıt tutma için dosya yolu bilgisini aktarabilir ve ihtiyaca göre kolayca değiştirebiliriz, bu da kodun test edilmesini ve bakımını kolaylaştırır.
Global Fonksiyonlar ve Statik Metotlar
Statik metotların ve global fonksiyonların kullanımının kendisinin sorunlu olmadığını vurgulamak istiyoruz.
DB::insert()
ve benzeri metotların kullanımının uygunsuzluğunun ne içerdiğini açıkladık, ancak her zaman
sadece bir statik değişkende saklanan global durum meselesiydi. DB::insert()
metodu, içinde veritabanı
bağlantısı saklandığı için statik değişkenin varlığını gerektirir. Bu değişken olmadan metodu uygulamak imkansız
olurdu.
DateTime::createFromFormat()
, Closure::fromCallable
, strlen()
ve birçok diğer gibi
deterministik statik metotların ve fonksiyonların kullanımı, bağımlılık enjeksiyonu ile tamamen uyumludur. Bu fonksiyonlar
her zaman aynı giriş parametrelerinden aynı sonuçları döndürürler ve bu yüzden öngörülebilirler. Hiçbir global durum
kullanmazlar.
Ancak PHP'de deterministik olmayan fonksiyonlar da vardır. Bunlara örneğin htmlspecialchars()
fonksiyonu
dahildir. Üçüncü parametresi $encoding
, eğer belirtilmemişse, varsayılan değer olarak
ini_get('default_charset')
yapılandırma seçeneğinin değerine sahiptir. Bu yüzden bu parametreyi her zaman
belirtmek ve böylece fonksiyonun olası öngörülemez davranışını önlemek tavsiye edilir. Nette bunu tutarlı bir
şekilde yapar.
strtolower()
, strtoupper()
ve benzeri bazı fonksiyonlar, yakın geçmişte deterministik olmayan
şekilde davrandılar ve setlocale()
ayarına bağımlıydılar. Bu, en sık Türkçe dili ile çalışırken birçok
komplikasyona neden oldu. Çünkü o, noktalı ve noktasız küçük ve büyük I
harfini ayırt eder. Yani
strtolower('I')
ı
karakterini ve strtoupper('i')
İ
karakterini
döndürüyordu, bu da uygulamaların bir dizi gizemli hataya neden olmaya başlamasına yol açtı. Ancak bu sorun PHP sürüm
8.2'de kaldırıldı ve fonksiyonlar artık yerel ayara bağımlı değil.
Bu, global durumun tüm dünyada binlerce geliştiriciyi nasıl rahatsız ettiğinin güzel bir örneğidir. Çözüm, onu bağımlılık enjeksiyonu ile değiştirmekti.
Global Durum Ne Zaman Kullanılabilir?
Global durumu kullanmanın mümkün olduğu belirli özel durumlar vardır. Örneğin kod hata ayıklarken, bir değişkenin değerini yazdırmanız veya programın belirli bir kısmının süresini ölçmeniz gerektiğinde. Bu gibi durumlarda, daha sonra koddan kaldırılacak geçici eylemlerle ilgili olanlarda, global olarak erişilebilir bir dumper veya kronometre kullanmak meşru olarak mümkündür. Bu araçlar çünkü kod tasarımının bir parçası değildir.
Başka bir örnek, dahili olarak derlenmiş düzenli ifadeleri bellekteki statik önbelleğe saklayan preg_*
düzenli ifadelerle çalışmak için fonksiyonlardır. Yani aynı düzenli ifadeyi kodun farklı yerlerinde birden çok kez
çağırdığınızda, yalnızca bir kez derlenir. Önbellek performanstan tasarruf sağlar ve aynı zamanda kullanıcı için
tamamen görünmezdir, bu yüzden böyle bir kullanım meşru kabul edilebilir.
Özet
Neden mantıklı olduğunu ele aldık:
- Koddan tüm statik değişkenleri kaldırmak
- Bağımlılıkları bildirmek
- Ve bağımlılık enjeksiyonu kullanmak
Kod tasarımını düşündüğünüzde, her static $foo
'nun bir sorun teşkil ettiğini düşünün. Kodunuzun
DI'ye saygı duyan bir ortam olması için, global durumu tamamen ortadan kaldırmak ve onu bağımlılık enjeksiyonu kullanarak
değiştirmek gereklidir.
Bu süreç sırasında, birden fazla sorumluluğu olduğu için sınıfı bölmek gerektiğini keşfedebilirsiniz. Bundan korkmayın; Tek Sorumluluk İlkesi'ni hedefleyin.
Miško Hevery'ye teşekkür etmek isterim, Flaw: Brittle Global State & Singletons gibi makaleleri bu bölümün temelini oluşturur.