Data Class ve Destructuring Declaration Nedir?
Kotlin’de sık sık kullanılan data classların ne olduğunu inceleceyeğiz. Bu data sınıfların normal sınıflardan farkı nedir? Data classların özellikleri nedir? Gibi soruları cevaplayacağız. Ek olarak destructuring declaration ne demek ve bu kullanışlı yapıyı nasıl kullanırız, örnekleriyle göreceğiz.
Data classlar, Kotlin’de verileri tutmak için oluşturulmuş sınıflardır. Normal sınıflarda da tutabiliriz ne özelliği var diyecek olursanız, madde madde data sınıfların özelliklerini inceleyelim. Detaylara indikçe her şey daha net olacak.
Data class;
- Constructorına az 1 parametre almak zorundadır (Sebebini açıklayacağız).
- Constructorına yazılan parametreler ya “var” ya da “val” ile tanımlanmak zorundadır.
- Default olarak final yapıdadır ve bu yapıyı değiştiremeyiz. Yani sealed, abstract, open, enum veya inner yapılarını data classlar için kullanamayız.
- Miras alınamazlar, fakat bir sınıfı miras alabilirler veya bir interface yapısını implement edebilirler.
- Normal sınıflar için var olan hashCode, toString ve equals metotlarını kendimiz override edip yazardık. Data classlar için bunlar arka planda direkt olarak oluştulurlar ve ek olarak copy ve component metotları da oluşturulur. Data sınıfların normal sınıflardan en büyük farkı budur.
Şimdi bu maddelerin sebeplerine ve bize sağladığı güzel detaylara bakalım.
İlk olarak normal bir sınıf içerisinde veri tanımlamakla data sınıflarında veri tanımlamak arasındaki farkı anlayalım. Country sınıfımız olsun ve bu sınıfın içinde bu country sınıfına ait 3 tane veri tutalım. main fonksiyonu içerisinde bu sınıflardan bir nesne oluşturup yazdıralım.
fun main() {
val country = Country("Turkey", 84000000, "Ankara")
val countryData = CountryData("Turkey", 84000000, "Ankara")
println(country) // normal class
println(countryData) // data class
}
class Country(
val countryName: String,
val countryPopulation: Long,
val countryCapital: String
)
data class CountryData(
val countryName: String,
val countryPopulation: Int,
val countryCapital: String
)
İlk print fonksiyonu ile normal sınıfı yazdırmaya çalıştık fakat @7530d0a gibi bir çıktıyla karşılaştık. Bu ifade, country nesnesinin bellekte nerede tutulduğunu gösteren bir adrestir. Data sınıflarının maddelerinde belirtmiştik, her data class için arka planda toString metotu yazılır diye. İşte toString metotu bize ne dönderiyorsa, bir nesneyi yazdırdığımızda o ifadeyi görürüz. toString metotu default olarak bellekteki adresi verir. Normal sınıflarda da düzenli bir şekilde verileri görmek istiyorsak toString metotunu override edip düzenlemeliyiz.
class Country(
val countryName: String,
val countryPopulation: Long,
val countryCapital: String
) {
override fun toString(): String {
return "$countryName $countryPopulation $countryCapital"
}
}
toString metotunda sınıf içerisinde yer alan verileri return ettirdik. Artık bu şekilde verileri düzgün bir biçimde görüntüleyebiliriz.
Bu özellik, verileri tutmak için data sınıfların neden daha ideal bir seçim olduğunu bizlere anlatıyor. Düşünün ki internetten veriler çekiyoruz ve bu verileri oluşturacağımız sınıfın constructorında binlerce veri var, dataları doğru mu çekiyoruz diye loglamak istiyoruz. Yani anlık olarak bu verilerin çıktısını görmek istiyoruz. Bu halde constructorda bulunan bütün verileri toString metotuna yazmak mı daha kolay, yoksa data class içinde tanımlayıp hiçbir işlem yapmadan direkt print ettirmek mi. :)
Bu arada data classlar için belirli metotların arka planda otomatik olarak oluşturulduğunu söylemiştik, nasıl oluyor onu da görelim. Üstte yazdığımız Kotlin kodunu Java koduna dönüştürelim. Bu kodu Java koduna decompile edelim. Bunun için IntelliJ IDEA shift+shift kısa yolu ile açılan pencerede show kotlin byte code yazalım ve ilgili aracı seçelim. Sağ tarafta açılan pencereden decompile butonuna basalım.
CountryData adlı data sınıfın içine baktığımızda görüyoruz ki gerçekten de üstte belirttiğimiz o metotların hepsi oluşturuluyor. toString içine baktığımızda bize verilen çıktının birebir aynısı olduğunu da görmüş olduk. Peki ya diğer metotlar? Ne için kullanılıyorlar, onlara da bakalım.
fun main() {
val countryData = CountryData("Turkey", 84000000, "Ankara")
val countryData2 = CountryData("England", 67000000, "London")
val countryData3 = CountryData("Turkey", 84000000, "Ankara")
println(countryData.hashCode())
println(countryData2.hashCode())
println(countryData3.hashCode())
println(countryData.equals(countryData2))
}
Her nesne için bir adet hash code üretilir ve bu değeri hashCode metotu ile alırız. Oluşturduğumuz nesnenin farklılaşmasıyla beraber bu hash codelar da farklılaşır. İki nesne birebir aynıysa aynı hash code değeri oluşturulur (Data class için) . Bazen elimizdeki nesnenin başka bir nesneyle aynı olup olmadığını karşılaştırmak isteriz. Bu durumda hashCode metotunu kullanabiliriz. Aynı şekilde equals metotunu da kullanabiliriz. Bu da direkt olarak boolean bir değer döner bize. Nesneleri karşılaştırır ve birebir aynı mı diye bakar. Aralarındaki fark budur, hashCode ile bir integer değer üretilir, equals ile direkt kıyaslama yapılır ve boolean bir değer dönülür.
Diğer metotlara geçmeden önce şunu belirtmeliyim ki biz bu arka planda otomatik olarak oluşturulan metotları kendimiz override edip yazabiliriz. Bu durumda override ettiğimiz fonksiyon çalışır, arka planda oluşturulan metot geçersiz olur. Hemen örnek verelim.
fun main() {
val countryData = CountryData("Turkey", 84000000, "Ankara")
val countryData2 = CountryData("England", 67000000, "London")
val countryData3 = CountryData("Turkey", 84000000, "Ankara")
println(countryData)
println(countryData.hashCode())
println(countryData2.hashCode())
println(countryData3.hashCode())
println(countryData.equals(countryData2))
}
class Country(
val countryName: String,
val countryPopulation: Long,
val countryCapital: String
)
data class CountryData(
val countryName: String,
val countryPopulation: Int,
val countryCapital: String
) {
override fun toString(): String {
return super.toString()
}
override fun hashCode(): Int {
return super.hashCode()
}
override fun equals(other: Any?): Boolean {
return super.equals(other)
}
}
toString metotunu data class içinde override edip süper ile normal bir sınıfın toString metotuymuş gibi davranmasını sağladık ve görüyoruz ki artık verileri değil bu nesnenin tutulduğu adresi görüyoruz. Bir ilginç olan şey ise hashCode metotunun override edildikten sonraki durumu. Normal sınıflar için oluşturulan nesnelerin tamamı birebir aynı şekilde oluşturulsa da hash codelar birbirinden farklı olur. Burada da override edip normal sınıflar için geçerli olan hashCode metotunu kullandığımız için birebir aynı oluşturulan nesneler için de farklı hash code değerlerini gözlemledik. Bunun sebebi ise, normal sınıfların hashCode metotu nesne referanslarına dayalı olarak çalışır fakat data sınıfları için bu durum veriler üzerinden yapılır.
Data sınıfların özelliklerinden bahsederken en az 1 parametreleri olmalı ve bu parametreler val veya var ile tanımlanmak zorundadır demiştik. Bunun sebebi ise şudur, arka planda oluşturulan üstteki metotların içerisinde bu parametreler kullanılır (üstteki fotoğraflarda görebilirsiniz). Bu yüzden dolayı en az bir parametre olmalı ki o metotların içine bir tanımlama yapılabilsin. Peki neden var ya da val kullanıyoruz? Kotlin’de sınıf constructorında tanımlanan parametrelere sınıf içinde erişmek için bu verileri val veya var tipinde tanımlamalıyız.
countryName parametresine erişemiyoruz. Data sınıf için arka planda oluşturulacak metotların bu parametreleri kullanabilmesi için yine aynı mantığı düşünebiliriz.
Copy metotuna geçelim. Adından da anlaşılacağı gibi kopyalama işlemi için kullanılıyor. Diyelim ki bir sipariş uygulamamız var. Bu uygulama içinde ürünün ismi, fiyatı, kaç tane olduğu gibi birçok özellik olsun. Müşteri bu üründen bir adet aldığı zaman bu ürünün stok miktarını güncellememiz ve güncelledikten sonra bu bilgiyi tekrardan kullanıcı arayüzüne yansıtmamız gerekir. Ürüne ait data sınıfındaki diğer tüm özellikler aynı kalacak fakat sadece stok miktarını değiştireceğiz. İşte copy metotu bu durumlarda çok işe yarıyor. Sınıfın tüm parametrelerini yeniden yazmadan sadece copy kullanarak istediğimiz parametrelerde değişiklik yaparak yeni bir nesne oluşturabiliriz.
fun main() {
val countryData = CountryData("Turkey", 84000000, "Ankara")
val countryData2 = countryData.copy(countryPopulation = 90000000)
println(countryData2)
}
data class CountryData(
val countryName: String,
val countryPopulation: Int,
val countryCapital: String
)
CountryData sınıfının içinde binlerce veri olduğunu düşünün. Bir veri değiştiğinde başka bir nesne daha oluşturup ilgili değişikliği yapıp geriye kalan bütün verileri tek tek aynısı gibi yazacaktık. Bizi bu zahmetten kurtaran copy metotuna teşekkür ediyoruz. 😊
Son özelliğimiz olan component metotuna bakalım. Bu yapıyı anlattıktan sonra destructuring declaration konusuna geçiş yapacağız. Arka planda oluşturulan bu component metotlarını bir daha görelim.
Component metotları, constructorda yer alan her bir veri için oluşturulur ve destructuring declaration yapısı için kullanım yapmamıza olanak sağlar. Data sınıf constructorında kaç tane veri tanımlanmışsa o kadar component fonksiyonu oluşturulur. Constructor içinde yer alan parametreler için sırasıyla component1, component2… diye metotları oluşturulur. İlk sıradaki component1 sonra gelen component2 diye devam eder. N tane veri için son component metotu componentN şeklinde oluşturulur.
Dikkatinizi çekerim ki veriler, constructor içinde değil de sınıf bodysi içinde tanımlansaydı o veriye ait bir component fonksiyonu oluşturulmayacaktı. Ek olarak toString gibi diğer özel metotlarda da kullanımı yapılmayacaktı. Bu da demek oluyor ki data sınıfına ait bir nesneyi yazdırdığımızda sadece data sınıfının constructorı içindeki veriler yazdırılır.
Peki bu component metotları niye oluşturuluyor diye soracak olursanız, zaten söylemiştik. Destructuring declaration yapısını kullanmamız için oluşturuluyor. Ek olarak her bir veriyi component metotu ile tekil şekilde de alabiliriz. Son olarak bu konuya göz atalım ve yazıyı bitirelim.
Destructuring Declaration Nedir?
Kotlin dilinde bir yapıyı (data sınıf, dizi vs.) bileşenlerine kolayca ayırma yöntemidir. Bu özellik, yapıdaki elemanları ayrı değişkenlere atamak için kullanılır. Destructuring declaration ile tek seferde birden çok değişken tanımlayabiliriz. Kısacası, bir yapıyı parçalamaktır. Örneklerine bakınca daha da iyi anlaşılacak.
fun main() {
val countryData = CountryData("Turkey", 84000000, "Ankara")
val(myCountryVariable, myCountryVariable2) = countryData // Destructuring declaration
println(myCountryVariable)
println(myCountryVariable2)
// val myCountryVariable = countryData.component1()
// val myCountryVariable2 = countryData.component2() // bu iki yapı ↓
// val(myCountryVariable, myCountryVariable2) = countryData // bu yapıyla aynıdır.
// Tek tek tanımlama yapıp değerleri alacağımıza destructuring declaration yapısını kullanmamız daha mantıklıdır.
}
data class CountryData(
val countryName: String,
val countryPopulation: Int,
val countryCapital: String
)
“val(myCountryVariable, myCountryVariable2) = countryData” ifadesi, bir destructuring declaration yapısıdır. Bir değişken tanımlarmış gibi var veya val yazıyoruz. Ardından parantez içinde değişkenlerimizi tanımlıyoruz. Buradaki val kelimesini matematikteki dağılma özelliği gibi düşünelim, içindeki bütün yapılar val ile tanımlanmış gibi oluyor. Değişken isimlerini yazdıktan sonra bize component metotlarıyla oluşturulmuş bir yapı gerekiyor. CountryData adlı sınıfımız bir data sınıf olduğu için otomatik olarak component metotlarının oluştuğunu görmüştük. Component metotları nasıl ki constructordaki verilerin sırasıyla eşlenerek oluşturuluyordu, buradaki değişken sırası da aynı şekilde o componentlerin değerini alır, değişken isimlerinin bir önemi yoktur. Yani myCountryVariable adlı değişken component1 metotunun içindeki değere eşitlenmiş olur, myCountryVariable2 adlı değişken de component2 metotunun içindeki değere eşitlenmiş olur. Bu sırayla böyle devam eder. Kaç tane component metotu varsa o kadar değişken tanımlayabiliriz. Bu değişkenler component metotlarıyla eşleşir. Eğer sadece bir tane değişken tanımlarsam component1 metotunun değerini kullanmış oluruz, diğerleriyle ilgilenmeyiz.
Bu parçalama şeklinde kullanımı aslında ilk defa görmüyoruz. Collection yapılarında yer alan map için zaten kullanıyorduk.
fun main() {
val numbersMap = mapOf("one" to 1, "two" to 2, "three" to 3)
for ((key,value) in numbersMap) {
println(key) // one, two, three
println(value) // 1 2 3
}
}
Buradaki key ve value kullanımını nasıl yapıyoruz diye hiç düşündünüz mü? Map yapısında bulunan key ve value için arka planda birer component metotu oluşturulur ve bu sayede destructuring declaration yapısını kullanabiliriz.
Artık destructuring declaration yapısının, kodların daha temiz, daha kısa yazılması için ve verileri parçalayarak almak için kullanıldığını biliyoruz. Yazının sonuna geldik. Umarım sizler için faydalı bir yazı olmuştur :)
Bana Linkedin üzerinden ulaşabilirsiniz.
Son Güncelleme: 01.08.2023