Ders 13 / 15
Dizileştirme
Dizileştirmenin yapıcıyı hiç çalıştırmadığı ve yapıcının kurduğu değişmezlerin bu yüzden dışarıdan gelen bir bayt dizisiyle atlanabildiği ölçülür. Bir geri okuma kancasının denetimi nasıl geri getirdiği ve alan adıyla sürüm kimliğinin nasıl bir sözleşmeye dönüştüğü gösterilir.
İçindekiler
Önceki ders bir dosyanın var olup olmadığını ve hangi baytları taşıdığını ölçtü — ama bir
nesnenin kendisi bayta çevrildiğinde ne olduğu hiç sorulmadı. Bu ders dizileştirmeye bakar.
Nesneye Dayalı Java kursu (M08/K02, java-siniflar/01, 02) yapıcı zincirini ve sınıf
değişmezlerini kurdu: bir nesne ancak yapıcısından geçerek var olabilir, ve yapıcı değişmezi
zorlayan yerdir. Dizileştirme bu kuralın dışında durur — bir nesneyi bayta çevirip geri
okumak, yapıcıyı hiç çağırmadan bir nesne üretmenin standart kitaplıktaki yoludur.
Kursun sorusu burada yeniden sorulur: yapıcının kurduğu bir değişmez, geri okumada hâlâ geçerli mi? Yanıtı bulmak için tek bir sınıf yeter, çünkü karşılaştırılan iki gerçekleştirim değil, aynı sınıfın iki farklı yoldan — yapıcıdan ve dizileştirmeden — geçerek var olmasıdır.
Üç bölüm sırayla ilerliyor. İlk bölüm yapıcının atlandığını ve bunun bedelini sayıyor. İkincisi bu bedelin nasıl geri kazanılabildiğini gösteriyor. Üçüncüsü ise değişmezlerin ötesine geçip sınıfın kendi şeklinin — alan adlarının ve sürüm kimliğinin — geri okumada nasıl bir sözleşmeye dönüştüğünü ölçüyor.
Bu, K03’ün “çağıranın yükümlülüğü” dediği kaynaklardan biridir, ama alışılmadık bir biçimde: çağıran burada değişmezi kıran taraf değil, değişmezi geri kazanmakla yükümlü taraftır. Kütüphane bir söz vermedi ve tutmadığı bir sözü de yok; boşluğu dolduracak satırı yazmak baştan beri sınıfı tasarlayana aitti.
Yapıcı Hiç Çalışmıyor
- IO17 —
Hesapsınıfı yapıcısında iki değişmez zorluyor: sahip adı boş olamaz, bakiye negatif olamaz. İkisi de tek birifsatırıyla, yapıcının içinde denetleniyor. - IO18 — Elde, yapıcı hiç çağrılmadan üretilmiş bir bayt dizisi var: sahibi boş, bakiyesi
negatif bir
Hesapkaydı. Bu derste o bayt dizisinin nasıl üretildiği önemli değil; önemli olan geri okunduğunda ne olduğu. Risk, yapıcının kurduğu değişmezlerden kaçının geri okumada geçerli kaldığı sayısıyla ölçülüyor.
Serializable’ı uygulayan her sınıf bu ölçümün konusu olabilir; arayüz hiçbir yöntem
istemez, yalnız bir işarettir. Hesap burada seçilmiş özel bir örnek değil, kütüphanenin
herhangi bir dizileştirilebilir sınıfa uyguladığı genel bir davranışın tek bir örneğidir.
Aşağıdaki ölçüm bu davranışı elde bulunan bir bayt dizisiyle gösteriyor; o dizinin bir
dosyadan mı, bir ağ bağlantısından mı, yoksa programın kendi önceki bir çalışmasından mı
geldiği ölçüm açısından fark etmiyor — hangi kaynaktan geldiğine bakmaksızın, geri okuma
aynı yolu izliyor.
// YapiciAtlanir.java — dizilestirme yapiciyi hic calistirmaz, yapicinin kurdugu degismezler korunmaz
import java.io.*;
import java.util.Base64;
class Hesap implements Serializable {
private static final long serialVersionUID = 1L;
private final String sahip;
private final long bakiye;
Hesap(String sahip, long bakiye) {
if (sahip == null || sahip.isBlank()) throw new IllegalArgumentException("bos sahip");
if (bakiye < 0) throw new IllegalArgumentException("negatif bakiye");
this.sahip = sahip;
this.bakiye = bakiye;
}
String sahip() { return sahip; }
long bakiye() { return bakiye; }
}
public class YapiciAtlanir {
// Yapici hic calistirilmadan uretilmis bir bayt dizisi: sahip bos, bakiye negatif.
static final String BOZUK_KAYIT =
"rO0ABXNyAAVIZXNhcAAAAAAAAAABAgACSgAGYmFraXllTAAFc2FoaXB0ABJMamF2YS9sYW5nL1N0cmluZzt4cP/////////OdAAA";
static int gecerliDegismez(Hesap h) {
int sayi = 0;
if (h.sahip() != null && !h.sahip().isBlank()) sayi++;
if (h.bakiye() >= 0) sayi++;
return sayi;
}
public static void main(String[] args) throws Exception {
int yapiciEngeli = 0;
try {
new Hesap("", -50L);
} catch (IllegalArgumentException e) {
yapiciEngeli = 1;
}
System.out.println("yapici iki degismezi de zorluyor mu: " + (yapiciEngeli == 1));
byte[] bayt = Base64.getDecoder().decode(BOZUK_KAYIT);
Hesap geriGelen;
try (ObjectInputStream i = new ObjectInputStream(new ByteArrayInputStream(bayt))) {
geriGelen = (Hesap) i.readObject();
}
System.out.println("geri okuma yapiciyi hic calistirmadan basarili mi: true");
System.out.printf("yapicinin kurdugu iki degismezden gecerli kalan: %d / 2%n",
gecerliDegismez(geriGelen));
}
}
yapici iki degismezi de zorluyor mu: true geri okuma yapiciyi hic calistirmadan basarili mi: true yapicinin kurdugu iki degismezden gecerli kalan: 0 / 2
İlk satır, doğrudan yapıcıdan geçmeye çalışan bozuk bir çağrının reddedildiğini doğruluyor:
denetim orada çalışıyor. İkinci satır aynı sınıfın aynı bozukluğu taşıyan bir kaydı
geri okuyabildiğini gösteriyor — hiçbir istisna düşmüyor, readObject sorunsuzca
tamamlanıyor. Üçüncü satır bunun bedelini sayıyor: yapıcının kurduğu iki değişmezden
sıfırı geri okumada geçerli kalıyor.
Bunun nedeni ObjectInputStream‘in nasıl çalıştığıdır. Varsayılan geri okuma, sınıfın
alanlarına girdi-çıktı akışındaki değerleri doğrudan yazar; bunu yaparken hiçbir yapıcı çağırmaz, hiçbir
if satırından geçmez. final alanlar bile bu yoldan yazılabilir, çünkü dizileştirme dilin
kendi düzeneğidir ve yapıcının önündeki kapıdan girmez, nesneyi doğrudan bellekte kurar. Bu,
K02’de kurulan “bir nesne ancak yapıcısından geçerek var olabilir” kuralının tek
istisnasıdır — ve istisna kuralın bir zayıflığı değil, dizileştirmenin tanımının
kendisidir: geri okumanın işi bir yapıcı çağırmak değil, bir nesneyi aynen yeniden kurmaktır.
Bu yol tamamen kapalı da değildir: Hesap Serializable’ı hiç uygulamasaydı, writeObject
çağrısının kendisi NotSerializableException ile düşerdi ve geri okuma sorusu hiç
gündeme gelmezdi. Arayüzü konuşmak, sınıfı bu ikinci yola açan karardır — ve bu karar
sınıfı tasarlayanın kendisine aittir, dizileştirmenin dayattığı bir şey değildir.
K02’nin kapsülleme dersi, bir alanı private yapmanın onu yalnız sınıfın kendi yöntemleri
aracılığıyla değiştirilebilir kıldığını kurmuştu. Dizileştirme bu duvarı yıkmaz ama
yanından dolaşır: private değiştirici hâlâ yoktur, hiçbir sıradan çağrı bakiye
alanına dışarıdan doğrudan atama yapamaz — ama geri okuma sıradan bir çağrı değil, sanal
makinenin kendi düzeneğidir ve erişim belirtecine hiç takılmaz. Kapsülleme dışarıdan gelen
yöntem çağrılarını kısıtlar; dizileştirme bir yöntem çağrısı değil, dilin kendisine ait
bir yeniden kurma işlemidir.
Geri Okuma Kancası Denetimi Geri Getiriyor
ObjectInputStream’in belgesi bir kaçış yolu tanımlar: bir sınıf, imzası tam olarak
private void readObject(ObjectInputStream) olan bir yöntem tanımlarsa, geri okuma
varsayılan alanı doldurma davranışını bırakıp bu yöntemi çağırır. İmza harfiyen uyulması
gereken bir sözleşmedir — yöntem public olursa, parametre tipi değişirse ya da adı farklı
yazılırsa geri okuma onu hiç görmez ve sessizce varsayılan davranışa döner.
- IO19 —
Hesap’areadObjectadında özel bir yöntem eklenir. Bu yöntem öncedefaultReadObjectile alanları her zamanki gibi doldurur, sonra yapıcıdaki aynı iki denetimi tekrarlar.
// GeriOkumaKancasi.java — readObject kancasi yazildiginda iki degismez de geri kontrol ediliyor
import java.io.*;
import java.util.Base64;
class Hesap implements Serializable {
private static final long serialVersionUID = 1L;
private final String sahip;
private final long bakiye;
Hesap(String sahip, long bakiye) {
if (sahip == null || sahip.isBlank()) throw new IllegalArgumentException("bos sahip");
if (bakiye < 0) throw new IllegalArgumentException("negatif bakiye");
this.sahip = sahip;
this.bakiye = bakiye;
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
if (sahip == null || sahip.isBlank()) throw new InvalidObjectException("bos sahip");
if (bakiye < 0) throw new InvalidObjectException("negatif bakiye: " + bakiye);
}
}
public class GeriOkumaKancasi {
static final String BOZUK_KAYIT =
"rO0ABXNyAAVIZXNhcAAAAAAAAAABAgACSgAGYmFraXllTAAFc2FoaXB0ABJMamF2YS9sYW5nL1N0cmluZzt4cP/////////OdAAA";
public static void main(String[] args) throws Exception {
byte[] bayt = Base64.getDecoder().decode(BOZUK_KAYIT);
String sonuc;
try (ObjectInputStream i = new ObjectInputStream(new ByteArrayInputStream(bayt))) {
i.readObject();
sonuc = "acildi";
} catch (InvalidObjectException e) {
sonuc = "reddedildi: " + e.getMessage();
}
System.out.println("kanca yazildiktan sonra ayni bozuk kayit: " + sonuc);
}
}
kanca yazildiktan sonra ayni bozuk kayit: reddedildi: bos sahip
Bir önceki bölümde okunan aynı bayt dizisi bu kez reddediliyor. Değişen tek şey sınıfın
içine iki satırlık bir yöntem eklenmesi: defaultReadObject çağrısı alanları her zamanki
gibi dolduruyor, ardından gelen iki if yapıcıdakiyle birebir aynı denetimi tekrar
kuruyor. ObjectInputStream, bir sınıfta readObject adında böyle bir yöntem bulursa
varsayılan davranış yerine onu çağırıyor; yöntem bir istisna fırlatırsa geri okuma da
yarım kalıyor ve nesne hiç üretilmiyor.
Bu ölçümün söylediği şey şudur: dizileştirmenin sözü eksik değildi. Söz zaten “bir
nesneyi aynen yeniden kurarım, alanlarını doğrudan doldururum” biçimindeydi ve bu sözde
hiçbir değişmez denetimi yoktu — çünkü o denetim yapıcının işiydi, dizileştirmenin değil.
Denetimin geri gelmesi için gereken şey kütüphaneden bir düzeltme değil, sınıfın kendi
readObject’ini yazmasıydı; bu satırı yazmak yine çağırana, yani sınıfı tasarlayan tarafa
kalıyor.
Kursun üçüncü iddiası burada da doğrulanıyor: bu boşluğun hiçbirini derleyici yakalamıyor.
Kancasız Hesap de, kancalı Hesap de aynı sorunsuzlukla derleniyor; derleyici bir sınıfın
readObject’i olup olmadığına, olan readObject’in yapıcıdaki denetimleri tekrarlayıp
tekrarlamadığına hiç bakmıyor. Serializable bir işaretleyici arayüzdür — hiçbir soyut
yöntem taşımaz — ve bu yüzden derleme zamanında zorlanabilecek hiçbir şey yoktur; bütün
denetim isteğe bağlı bir yöntemin isteğe bağlı gövdesine bırakılmıştır.
Alan Adı ve Sürüm Kimliği Bir Sözleşmedir
- IO20 — Elde, sürüm kimliği
42olan eski birHesapkaydı var; o zamanki sınıfta alanın adıbakiyeidi. Şimdiki sınıfta aynı alanmiktarolarak yeniden adlandırıldı, ama sürüm kimliği hâlâ42. - IO21 — Aynı eski kayıt, bu kez sürüm kimliği
43olan bir sınıf tanımına karşı okunur — yani sürüm kimliği de değişmiş olsaydı ne olacağı ölçülüyor.
// AlanAdiSozlesmesi.java — alan adi ve dizilestirme surum kimligi sozlesmeye donusuyor
import java.io.*;
import java.util.Base64;
class Hesap implements Serializable {
private static final long serialVersionUID = 42L;
private final String sahip;
private final long miktar;
Hesap(String sahip, long miktar) {
if (miktar < 0) throw new IllegalArgumentException("negatif miktar");
this.sahip = sahip;
this.miktar = miktar;
}
long miktar() { return miktar; }
String sahip() { return sahip; }
}
public class AlanAdiSozlesmesi {
// Eski surumde ayni sinif "bakiye" adinda bir alan tasiyordu, "miktar" degil;
// dizilestirme surum kimligi (42L) o zamandan bu yana degistirilmedi.
static final String ESKI_KAYIT =
"rO0ABXNyAAVIZXNhcAAAAAAAAAAqAgACSgAGYmFraXllTAAFc2FoaXB0ABJMamF2YS9sYW5nL1N0cmluZzt4cAAAAAAAAABkdAADYWRh";
public static void main(String[] args) throws Exception {
byte[] bayt = Base64.getDecoder().decode(ESKI_KAYIT);
Hesap h;
try (ObjectInputStream i = new ObjectInputStream(new ByteArrayInputStream(bayt))) {
h = (Hesap) i.readObject();
}
System.out.println("gecti mi (istisna yok): true");
System.out.println("sahip alani (adi degismedi): " + h.sahip());
System.out.println("miktar alani (eskiden bakiye idi): " + h.miktar());
}
}
gecti mi (istisna yok): true sahip alani (adi degismedi): ada miktar alani (eskiden bakiye idi): 0
Geri okuma hiç itiraz etmiyor. sahip alanı adı değişmediği için sorunsuz doluyor: ada.
Ama miktar alanı, akıştaki kaydın bakiye diye taşıdığı değeri hiç görmüyor — long
tipinin varsayılan değeriyle, sıfırla dolduruluyor. ObjectInputStream akıştaki her alanı
adına göre eşliyor; akışta miktar adında bir alan yoksa, sınıftaki miktar hiç
dokunulmamış sayılıyor. Sürüm kimliği aynı kaldığı için hiçbir uyarı da çıkmıyor: kütüphane
açısından bu geçerli bir geri okumadır.
// SurumKimligiUyusmazligi.java — surum kimligi degistiginde ayni bayt dizisi reddedilir
import java.io.*;
import java.util.Base64;
class Hesap implements Serializable {
private static final long serialVersionUID = 43L;
private final String sahip;
private final long miktar;
Hesap(String sahip, long miktar) {
if (miktar < 0) throw new IllegalArgumentException("negatif miktar");
this.sahip = sahip;
this.miktar = miktar;
}
}
public class SurumKimligiUyusmazligi {
static final String ESKI_KAYIT =
"rO0ABXNyAAVIZXNhcAAAAAAAAAAqAgACSgAGYmFraXllTAAFc2FoaXB0ABJMamF2YS9sYW5nL1N0cmluZzt4cAAAAAAAAABkdAADYWRh";
public static void main(String[] args) throws Exception {
byte[] bayt = Base64.getDecoder().decode(ESKI_KAYIT);
String sonuc;
try (ObjectInputStream i = new ObjectInputStream(new ByteArrayInputStream(bayt))) {
i.readObject();
sonuc = "gecti";
} catch (InvalidClassException e) {
sonuc = "reddedildi: " + e.getClass().getSimpleName();
}
System.out.println("surum kimligi 43 iken ayni eski kayit: " + sonuc);
}
}
surum kimligi 43 iken ayni eski kayit: reddedildi: InvalidClassException
Bu kez aynı eski kayıt daha derine bile inmiyor: sürüm kimliği eşleşmediği için geri okuma alanlara hiç bakmadan reddediliyor. İki ölçüm yan yana konduğunda ters bir sonuç çıkıyor. Sürüm kimliğini değiştirmek, uyumsuz bir kaydı gürültülü biçimde durduruyor. Sürüm kimliğini sabit tutmak — sanki sınıf hiç değişmemiş gibi davranmak — yapı gerçekten değiştiğinde sessizce yanlış bir değer üretiyor. Dizileştirme sürüm kimliği bu yüzden bir sürüm numarası değil, “bu sınıf tanımıyla o akış birbirini tanıyor mu” sorusuna verilen tek bitlik bir yanıttır; alan adlarının kendisi de bu yanıtın bir parçasıdır, çünkü bağlama adla yapılır.
Sürüm kimliği hiç yazılmasaydı sonuç değişmezdi, çünkü derleyici o durumda kimliği sınıfın
o anki şekline bakarak kendisi hesaplar; iki farklı şekilden hesaplanan iki kimlik neredeyse
her zaman farklı çıkar ve sonuç yine InvalidClassException olurdu. Sürüm kimliğini elle ve
sabit yazmak bu otomatik korumayı bilinçli olarak kapatmaktır — geriye dönük uyumluluk
isteniyorsa gereklidir, ama isteniyorsa her alan değişikliğinin geri okumaya ne
yapacağını da düşünmeyi gerektirir. Aynı mantık eklenen bir alan için de geçerlidir: akışta
hiç bulunmayan yeni bir alan, tıpkı miktar gibi, sessizce kendi varsayılan değerini alır.
Özet
- Dizileştirme yapıcıyı hiç çalıştırmaz;
ObjectInputStreamalanları doğrudan doldurur vefinalalanlar bile bu yoldan yazılabilir. - Yapıcının kurduğu iki değişmezden hiçbiri geri okumada kendiliğinden korunmaz: elde bulunan bozuk bir kayıt hiçbir istisna vermeden geri okunur.
- Sınıfa özel bir
readObjectyöntemi eklenip yapıcıdaki denetimler tekrarlandığında, aynı bozuk kayıt artık reddedilir; denetim geri gelir çünkü çağıran onu yazmıştır. - Alan adı geri okumada bağlama anahtarıdır: akıştaki bir alan sınıfta bulunmuyorsa yok sayılır, sınıftaki bir alan akışta bulunmuyorsa varsayılan değerle sessizce doldurulur.
- Dizileştirme sürüm kimliği sabit tutulduğunda yapı değişikliği sessizce geçer; sürüm kimliği değiştirildiğinde aynı uyumsuzluk gürültülü biçimde reddedilir.
Serializableişaretleyici bir arayüzdür ve hiçbir soyut yöntem taşımaz; bu yüzden bir sınıfın değişmezlerini geri okumada da koruyup korumadığını derleyici hiçbir zaman denetlemez — denetim tamamen isteğe bağlı birreadObject’in gövdesine bağlıdır.
Sonraki Adım
Bu derste ölçülen her şey belleğe alınmış, sabit bir bayt dizisiydi — zaman hiç karışmadı. Ama bir sistemin taşıdığı en yaygın veri türlerinden biri zamanın kendisidir. Sıradaki ders tarih ve zaman API’sine bakar: bir anlık noktanın, bir yerel tarih-saatin ve bir bölgesel tarih-saatin hangi bilgiyi taşıyıp hangisini taşımadığını, ve bu farkın iki değeri karşılaştırırken nasıl sessizce yanlış bir sonuca yol açabildiğini ölçer.
İlerlemeni kaydetmek ve not almak için Giriş yap
Notlarım
Not almak için giriş yapmalısın.