JavaScript’te “Değişmezlik”

Mutable & immutable veri tipleri üzerine…

Bu yazıda JavaScript dilindeki veri tiplerini ve mutability & immutability kavramlarını inceleyeceğiz.

Immutability(değişmezlik) nedir?

Değişmezlik, program akışı boyunca oluşturulan bir verinin asla değişmemesine dayanan bir konsepttir. Veri üzerinde bir değişiklik yapmak istediğinizde orijinal verinin korunduğundan ve herhangi bir değişikliğe uğramadığından, yani tamamen kalıcı olduğundan emin olabilirsiniz. Değişiklikler orijinal verinin bir kopyası oluşturularak yapılır ve eski veri ve ona yapılan referanslar korunur.

İşte bu nedenle değişmez veri yapıları ile uygulama geliştirirken verilerin uygulama içerisinde nasıl ilerlediğine yönelik farklı bir bakış açısı ile düşünmeye başlarız ve bu bize verilerin akışını izleme kolaylığı ya da büyük nesneleri hızlıca kıyaslamak gibi bir takım olanaklar sağlar.


const != immutable

ES6 ile birlikte gelen const tanımı bazı insanların const ile tanımlanan verilerin tamamen değiştirilemez olduğunu düşünmelerine sebep olabiliyor. const sadece tanımlayıcı ile değer arasında değişmezliği sağlıyor yani tanımlanan değişkene tekrar atama yapılamaz.

Görsel: 1

Ancak atanan nesne üzerinde hala değişiklik yapılabilir. Aşağıdaki örnekte nesnenin değişikliğe uğrayabildiğini görebilirsiniz.

Görsel: 2

JavaScript’de Veri Tipleri

JavaScript’de tipleri primitive(değer bazlı tipler) ve non-primitive(referans bazlı tipler) olarak iki kategoride ele alıyoruz.

const x = “o” dediğimizde x adında bir değişkenin string tipinde bir veri tuttuğunu ifade ederiz. Aslında programlamadaki değişken(variable) ile matematikten bildiğimiz değişken aynıdır.

Matematikte, x = 5 dediğimizde x ifadesi aslında bir değişkeni temsil etmektedir. x değişkenin aldığı değer aynı zamanda veri tipini de belirler(tam sayı vb gibi).

Değer bazlı tipte tanımlanan değişkenler adından da anlaşılacağı gibi değerleri tutarlar. Referans bazlı tiplerde ise değeri değil, onun referansını ya da adresini tutarlar.

Konuyu referans bazlı tipler ve pointer üzerinden detaylandırmak gerekirse, pointerlar bellek adresi tutan değişkenlerdir diyebiliriz. Bir fonksiyon çağırıldığında o fonksiyon kendi scope(kapsam) değerleri ile (parametre, argüman, referans) ‘call stack’e girer ve burada kendine ait pointer saklama işlemini gerçekleştirir.

Pointerlar değerin bulunduğu bellek hücresinin adresini tutukları için dolaylı olarak değere erişebilirler.

Bir değişkenin değeri ile ilgili konuşurken değişkenin aslında verinin bellek adresini ifade ederiz. Pointerlar da bu bellek adreslerinin tutulduğu, yani veriye işaret eden değişkenlerdir.

Dolayısıyla referanslar mevcut değişkene dolaylı yoldan erişmeyi sağlarlar ve yorumlayıcı tarafında garbage collectionı daha yönetilebilir hale getirirler. Değere ulaşmak istendiğinde değişkenin adı ile erişilebilir.

Örnek vererek açıklayalım;

Görsel: 3— bellek adresini göstermek için python kullanıldı

x adıyla yarattığım ve tipi number olan değişkenin değerini 10 olarak belirledim. Bu değişkenin referansı bellekte “140641126337568” id’si ile tutulur.

Görsel: 4

🔥Primitive Tipler:

number, string, boolean, null, undefined

Primitive tipler hepimizin yaygın olarak kullandığı, karmaşık olmayan veri tipleridir. Bu tipleri ilkel tipler olarak tanımlarız.

Konumuz için en önemli özellikleri ise JavaScript dili için immutable yani değişmez olmalarıdır.Yani bir kere yarattığımızda aynı değeri bir daha değiştiremeyiz ancak yenisini oluşturabiliriz. Bu özellikle string değerler söz konusu olduğunda şaşırtıcı olabilir.

Konuyu daha iyi anlamak için iki değişkenin birbirine eşitlenmesi durumunda nasıl bir reaksiyon meydana geliyor bakalım;

Görsel: 5

a değişkenini yaratırken ona bir değer atadık. b değişkenini yaratırken a ile eşitledik yani a değişkeninin değerini b’ye atadık.

Görsel: 6

Buradaki farkı a’nın değerini değiştirmek istediğimizde anlıyoruz.

Görsel: 7 — a’nın değeri değiştiğinde belleğin son durumu

b = a eşitliğinde a’nın değeri değiştiğinde b’nin değerinin de değişmesini umuyoruz ancak durum burada biraz farklılaşıyor.

Bu tür tanımlamalarda değişkenlerin bellekte tutulan referansları eşitlenmez, a değişkeninin bellek üzerinde yeni bir kopyası oluşturulur ve bu kopya artık b değişkeni olarak ifade edilir. Dolayısı ile a’nın değeri değiştiğinde b’nin bu durumdan haberi olmaz.

💨Non-Primitive(Referans Bazlı) Tipler:

object(array, date, regex), function

Referans bazlı tipler genelde değiştirilebilir tiplerdir. Yani ilkel tiplerin tam tersine, değişkenlerin bellek üzerinde referans edildikleri adreslerine doğrudan eşitlenirler.

Örnek üzerinden incelersek;

Görsel: 8

a değişkenine eşitlenen nesnenin “name” özelliği değiştirildiğinde b’ye eşitlenen nesnenin “name” özelliğindeki değerinde değiştiğini görebiliyoruz. Yani eşitlik sağlanırken aynı adrese referans edilmiş diğer değişkenin değeri de değişmektedir.


Diyelim ki elimizde bir nesne var ve nesne üzerinde bir değişiklik yapmak istiyoruz. Aynı zamanda orijinal nesnenin korunmasını, herhangi bir değişikliğe uğramasını istemiyoruz. Peki bunu nasıl başarabiliriz? JavaScript’de değişmezliği nasıl sağlarız?🤔

Orijinal nesneyi değiştirmek yerine değişikliklerin uygulandığı yeni bir nesne döndürmek bu aşamada güzel bir çözümdür.

JavaScript’de değişmezliği sağlamanın bir kaç yolu var. Bunlardan bir tanesi yeni gelen object spread operatörü.

Görsel: 9

Ancak spread ile yaptığımız orijinal nesneyi koruma ve özelliklerini kopyalayarak yeni bir nesne yaratma işlemi performans açısından pahalı bir işlem, hele ki doğrudan orijinal nesne üzerinde değişiklik yapmaya kıyasla daha pahalıdır.

Benzer şekilde Object.assign() kullandığımızda da yukarıdaki gibi sığ/yüzeysel kopyalama(shallow copy) yapılmaktadır. Bu sayede orijinal nesneyi koruyarak değişiklikleri yeni nesneye uygulayabiliyoruz. Ancak daha önce belirttiğim gibi performans açısından iyi bir pratik değil.

https://goo.gl/rRrfj8 | Görsel: 10

Shallow Copy: Nesnelerin klonlanırken nesne içerisindeki sadece birinci seviye özelliklerinin kopyalanması işlemini ifade etmektedir. Yani nesne içerisindeki varsa diğer nesneler kopyalanmamaktadır. Referansları kopyalanır ancak yine aynı bellek adresini göstermeye devam ederler.

Deep Copy: Nesnelerin tam kopyasının oluşturulması olarak özetlenebilir. Nesnenin aynı veri yapısına sahip ancak tamamen bellekte yeni bir adresi gösterdiği kopyalama türüdür. Performans açısından sığ/yüzeysel(shallow) kopyalamaya göre daha maliyetlidir.

Görsel: 11

Object.freeze()

Object.freeze ile bir nesneyi dondurarak tamamen değişmez yapabilirsiniz. Yeni özellikler ekleyemez, güncelleyemez ve silemezsiniz. Nesnenin içerisinde bulunan diğer nesneler yine değişikliğe uğrayabilir yani derinliğe sahip nesnelerde değişmezlik sağlanmaz. Bunu sağlayabilmek için deep freeze örneğine bakabilirsiniz. Bir nesneyi Object.freeze ile tamamen dondurmanın maliyeti de yine oldukça fazla.


Değişmezlik ile ilgili konuşurken en çok vurgulanan konu genellikle performanstır. Yukarıda anlatılan değişmezlik örneklerinin her birinde performansa özellikle değindik. Çünkü her birinin çalışma şekli gereği iyi bir performansa sahip olamıyoruz ve bellek alanından tasarruf sağlayamıyoruz. Bunların nedenlerini ilgili yöntemleri anlatırken açıklamaya çalıştım.

Performans konusu gündeme geldiğinde 100.000, 200.000 elemana sahip nesneler üzerinden benchmark testi yapanlar var. Bana göre bu kıyaslamalar çok gerçekçi değil çünkü gerçek dünyada bu kadar çok elemana sahip nesneler ile karşılaşmaz ya da onlarla tek seferde çalışmazsınız. Bu tür kıyaslamalar biraz pazarlama yöntemi ancak yine de önemli bir noktaya değinildiğini kabul etmek lazım.

Birazdan bahsedeceğim immutable.js için böyle bir pazarlama yapmayacağım. 😌


immutable.js

Immutable.js JavaScript’deki karmaşık tipleri değişmez yapılara dönüştürmek için kullanılan bir kütüphanedir. Performans ve kalıcılık gibi öne çıkan başlıca özelliklere sahiptir.

Immutable.js çok daha düşük maliyetlerle daha hızlı işlem yapmayı sağlıyor. Temel mantalitesi orijinal veri üzerinde işlem yapmadan değişikliklerin uygulandığı yeni bir veri seti döndürmek üzerinedir. Yani verinin hem değişmez(immutable) hem de kalıcı(persistent) olmasını sağlamaktadır.

Görsel: 12 — array push — mutable (native JavaScript)
Görsel: 13 — list push — immutable (immutable.js)
Görsel: 14 — list push — immutable (immutable.js)

Yukarıdaki örneklerde listeye yeni bir eleman eklediğinizde sadece yeni eklediğiniz veriyi döndürdüğünü görebilirsiniz. Bunun nedeni List metodunun aslında değişmez bir array oluşturmasıdır. Yeni bir değişken yaratıp ekleme yapmak isterseniz önceki veriler kopyalanır, değişikliğiniz uygulanır ve yeni veri seti döndürülür. Bu sayede orijinal veri korunmaktadır.

Aşağıdaki örnekte orijinal verinin korunduğunu görebilirsiniz.

Görsel: 15 — list push — immutable (immutable.js)

Immutable.js’in yapı olarak Haskell’den esinlendiğini söyleyebiliriz. Ayrıca Clojure’un sağladığı veri yapısı ile de benzerlik göstermektedir. Fonksiyonel programlamanın sağladığı güçlü konseptlerin JavaScript’e getirilmesi amacıyla tasarlanmıştır.

Kalıcılığın en dikkat çekici kazanımlarından biri bana göre time travel(zaman yolculuğu). Bu sayede uygulama içinde verinin değişimini adım adım takip etmek, bunları geri almak ve hatta zamanı “forklamak” mümkün olabiliyor.

“Trie” veri yapısı

Görsel: 16 — trie

Bir ağacın üzerine eğer metin kodlamak istiyorsak ve insert, update, search gibi işlemler yapmak istiyorsak tercih ettiğimiz ağaç türü ‘trie’dır.

Trie ağacının boyutu, temsil edebileceği tüm olası değerlerin büyüklüğü ile doğrudan ilişkilidir. Yani içereceği şeye göre küçük veya büyük olabilir.

Şekil ve yapı olarak boş bir kök düğüme bağlanan bir dizi çocuk düğümlerden meydana gelir. Buradaki kök düğüm boş bir string’i ifade eder. Trie, aynı zamanda deterministik bir veri yapısı, yani belirli bir değer/metin üzerinde izlenebilecek sadece tek bir yol(patika) var, herhangi bir alternatif olması mümkün değil. Ağaç üzerindeki her düğüm kendisinden sonra gelen bir harfi işaret ederek ilerliyor.

Immutable.js’in bu kadar hızlı olması ve bellek tasarrufu sağlayarak çalışmasının arkasındaki önemli prensiplerden bir tanesi trie veri yapısının yapısal paylaşım(structural sharing)yapılarak kullanılıyor olması. (structure-shared trie)

Bir nesne içinden sadece bir elemanı değiştirmek istediğinizde Immutable.js sadece istediğiniz veriyi değiştirir, değişmeyen veriler ise diğer nesneler arasında paylaşılır yani yeniden kopyalanmaz ve bu sayede yüksek performans elde edilebilir. Bu, daha fazla hız ve daha az bellek kullanımı demek. Yapısal paylaşımın en güzel özelliği veri setinizin boyutu arttıkça daha hızlı çalışması.

Bir örnek ile açıklayalım: elimizde bulunan diziye yeni bir eleman eklediğimizde ağaçta bulunan elemanların nasıl paylaşıldığını şu şekilde gösterebiliriz;

Görsel: 16

Diziye 13 rakamını ekleyelim;

Görsel: 17

Yapısal paylaşım, sayesinde değişikliğe uğramamış verilerin referansı muhafaza edilmektedir. Yapısal paylaşım, temelinde eklemek veya silmek üzere olduğumuz düğümü içeren tüm yolu kopyalamaya dayanmaktadır(path copying).

13 rakamını eklediğimizde ağaçta 8 değerine ait düğümün sonuna ekleneceği için 1, 8 düğümleri kopyalandı ve 13'ü içeren yeni bir düğüm eklendi. İşte veri setiniz büyüdüğünde yaptığınız işlemlerin daha hızlı gerçekleşmesinin bir nedeni de budur.


Veri yapılarına aşina olanlar Trie ve Binary Search Tree(BST) arasında ciddi benzerlikler olduğunu farketmiş olabilirler. Birbirlerine oldukça benzeseler de aralarında temel bir fark var.

Bu iki veri yapısını kıyaslamanın en kolay yolu bir metin içindeki kelimeleri nasıl bulacağımızı düşünmek.

BST ile arama karmaşıklığı O(Log (n)) iken, Trie ile arama karmaşıklığını ise O(n) olarak ifade edebiliriz. Trie oluşturmanın maliyeti ise O(W*N) (buradaki W- kelime sayısı, N- cümledeki en uzun kelimenin uzunluğunu temsil eder).

‘Trie’da aynı zamanda her düğüm en fazla 26 çocuktan oluşabilir (İngiliz alfabesindeki 26 harf kadar) ve kelimeleri ağaca yerleştirdiğimizde her çocuk düğüm alfabenin bir harfi ile ilişkilendirilir. Yani başlangıçta 26 tane düğümümüz var. Her düğüm için alfabedeki sayı kadar işaretçi düğüm bulunuyor.

Ağaç üzerinde örneğin “b” harfi ile başlayan bir kelime yoksa işaretçi düğümler boşta kalabilir. Yani trie üzerinde saklayacağınız metin alfabenin sadece yarısı kadar harf içerebilir. Genelde bu boşta kalma durumunu gereksiz bellek tüketimi olarak düşünebilirsiniz. Bu gibi fazla bellek kullanımının yarattığı sorunları aşmak için daha kısa bir alfabe üzerinde yeniden yorumlamalar yapılabilir. Huffman kodlamasında kodların optimum şekilde yaratılması trie kullanılarak yapılır.

Özetle bir kelime ararken hemen hemen toplam kelime sayısı kadar işlem yapıldığını söyleyebiliriz çünkü her kelimenin her harfi tek tek ağaç üzerindeki bir düğüme yerleştirilmektedir.


Immutable.js bize kendi metodlarını sunarak bu anlattığımız yapıları uygulayabiliyor. Örneğin bir Array yaratmak için List kullanıyoruz ve yarattığımız Array(yani List)artık değişmez bir diziye dönüşüyor. Değişiklik yaptığımızda yapının artık tamamen değişmez ve kalıcı olduğunu biliyoruz. Hem daha hızlı insert, update, search yapıp hem de orijinal veriyi korumayı garantiliyoruz.


⚡️ Zaman Yolculuğu (Time Travel)

Görsel: 18

Kalıcı veri yapılarının en önemli özelliklerinden bir tanesi zaman yolculuğudur. Eğer verinin önceki sürümüne erişebiliyorsanız ancak sadece son sürümü değiştirebiliyorsanız kısmen kalıcı(partially persistent), eğer tüm sürümler hem erişilebilir hem de değiştirilebilir ise tamamen kalıcı(fully persistent) olarak tanımlanmaktadır.

Immutable.js’in sunduğu metodlar tamamen kalıcı kategorisine girmektedir. Bunun bir diğer anlamı ise tahmin edebileceğiniz gibi zamanda yolculuk yapabilme imkanıdır. Yani verinizin önceki sürümlerine erişebilir ve geçmişi değiştirip alternatif bir zaman çizgisi yaratabilirsiniz.

The Flash — Time Travel

Alternatif: Mori

Trie veri yapısını kullanan ve değişmez veri yapıları sağlayan alternatif bir kütüphane görmek isterseniz Mori’ye göz atabilirsiniz.


Değişmez veri yapıları JavaScript için önemli bir konu. Performans her ne kadar önemli bir motivasyon olsa da asıl önemli olan değişebilir verilerin yol açtığı yan etkilerin doğru anlaşılmasıdır. Bunun yanı sıra daha okunabilir, test edilebilir ve sürdürülebilir kodlara sahip olmamıza yardımcı olurlar.

Görsel: 19

Örneğin artık iki nesneyi kıyaslamak için “===” operatörünü rahatlıkla kullanabilirsiniz. Özellikle derinliğe sahip iç içe geçmiş nesnelerin karşılaştırması daha hızlı ve daha basittir.

React ile uygulama geliştiriyorsanız shouldComponentUpdate() metodunu kullanmışsınızdır. String, boolean, number gibi değerlere sahip değişkenleri karşılaştırırken işler çok basittir ancak nesneleri karşılaştırmanız gerektiğinde farklı çözümler üretmeniz gerekir. İç içe geçmiş nesneleri klasik yöntemlerle karşılaştırmaya çalışıyorsanız bu oldukça maliyetli ve yavaş bir işlemdir. Immutable.js ile klasik yöntemlerin yarattığı verimsizliğe maruz kalmadan re-render işlemlerinden daha hızlı kaçınabilirsiniz.

Angular ilk çıktığı zamanlarda bahsettiğimiz klasik yöntemi kullandığı için orta büyüklükteki uygulamalarda bile ciddi yavaşlamalar oluyordu. Google buna çözüm olarak Object.observe() metodunu geliştirip standartlaştırmaya çalışsa da başarılı olamadı.

Özetle bu ve bunun gibi daha birçok problemi aşmak için etkin ve performanslı çözümlere kapı aralamış olacaksınız. Değişmez veri yapıları ile çalışmak uygulama geliştirmeye farklı bir perspektiften bakmanızı sağlayacaktır.



İnceleme için teşekkürler: Burak Yiğit Kaya, Özer Yılmaztekin