28 września 2008

SCJP – Serializacja

Wakacje się skończyły; czas zakasać rękawy i wrócić do pracy w zwyczajowym tempie (tj. więcej niż jeden artykuł na miesiąc). Zregenerowałem nieco siły i z nowym zapasem energii "wznawiam" serię o SCJP, zapoczątkowaną artykułem "Przygotowania do SCJP czas zacząć". Dziś będzie o serializacji.

Mechanizm serializacji służy do zapisywania obiektów do strumienia danych, oraz w drugą stronę, do rekonstruowania obiektów z ciągu bitów odczytanych ze strumienia. W typowej sytuacji, strumienie te "podłączone są" do plików dyskowych, a więc z pewnym uproszczeniem można powiedzieć, że serializacja służy do zapisywania obiektów do plików i odczytywania tychże z powrotem. Zacznijmy od przykładu kodu, który serializuje (zapisuje) obiekt do pliku a następnie go odczytuje.

class Test {
public static void main(String[] args) throws IOException, ClassNotFoundException {
saveObject(new DataObject(123), "db.bin");

System.out.println(readObject("db.bin").value);
}

public static void saveObject(DataObject obj, String fileName) throws IOException {
FileOutputStream fileOutputStream = new FileOutputStream(fileName);

ObjectOutputStream outputStream = new ObjectOutputStream(fileOutputStream);
outputStream.writeObject(obj);

outputStream.close();
}

public static DataObject readObject(String fileName)
throws IOException, ClassNotFoundException {

FileInputStream fileInputStream = new FileInputStream(fileName);

ObjectInputStream inputStream = new ObjectInputStream(fileInputStream);
DataObject obj = (DataObject) inputStream.readObject();

inputStream.close();

return obj;
}
}

class DataObject implements Serializable {
int value;

public DataObject(int value) {
this.value = value;
}
}

Uruchomienie powyższego programu spowoduje wyświetlenie liczby "123", co pozwala nam przypuszczać, że obiekt rzeczywiście został zapisany i następnie poprawnie odczytany. Zastanówmy się teraz, co to dokładnie znaczy "zapisać obiekt". W powyższym przykładzie sprawa jest oczywista, ale co się stanie, gdy nasz obiekt będzie zawierał referencje do innych obiektów? Czy one także zostaną zapisane? Tak, zostaną zapisane! Serializacja zapisuje de facto cały graf obiektów, nie tylko obiekt zapisywany explicite.

Aby obiekt mógł być serializowany, jego klasa musi implementować interfejs java.io.Serializable. Ale uwaga! Nie tylko obiekt, który serializujemy explicite musi być serializowalny. Serializowalne muszą być wszystkie zapisywane obiekty, a więc także te, na które wskazują referencje obiektu, który zapisujemy. Zmodyfikujmy nieco nasz przykład, jak pokazano poniżej.

class Test {
public static void main(String[] args) throws IOException, ClassNotFoundException {
saveObject(new DataObject(123), "db.bin");

System.out.println(readObject("db.bin").valueObject.value);
}

// metody saveObject(…) oraz readObject(…) bez zmian
}

class DataObject implements Serializable {
ValueObject valueObject;

public DataObject(int value) {
this.valueObject = new ValueObject(value);
}
}

class ValueObject {
int value;

public ValueObject(int value) {
this.value = value;
}
}

Kod się skompiluje, ale otrzymamy błąd czasu wykonania. Próba uruchomienia programu zakończy się wyjątkiem java.io.NotSerializableException.

Exception in thread "main" java.io.NotSerializableException: my.pckg.ValueObject
at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1081)
at java.io.ObjectOutputStream.defaultWriteFields(ObjectOutputStream.java:1375)
at java.io.ObjectOutputStream.writeSerialData(ObjectOutputStream.java:1347)
at java.io.ObjectOutputStream.writeOrdinaryObject(ObjectOutputStream.java:1290)
at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1079)
at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:302)
at my.pckg.Test.saveObject(Test.java:21)
at my.pckg.Test.main(Test.java:12)

Przyczyną jest naturalnie brak deklaracji, że klasa ValueObject jest serializowalna – dodanie deklaracji ‘implements Serializable’ rozwiązuje problem.

Serializacja obiektu oznacza zapisanie stanu obiektu, a więc "wartości" zmiennych instancyjnych, przy czym "wartość" zmiennej referencyjnej to referowany obiekt. Ważne jest, aby uświadomić sobie, że stan obiektu to tylko zmienne instancji, w odróżnieniu od zmiennych klasowych, tj. statycznych, których serializacja nie dotyczy. Mamy ponadto możliwość wykluczenia poszczególnych zmiennych z serializacji, za pomocą modyfikatora ‘transient’. Wystarczy zmienną oznaczyć jako ‘transient’ i z punktu widzenia serializacji będzie tak, jakby jej nie było.

Zastanówmy się teraz, jak wygląda deserializacja, a więc jak tworzone są obiekty i jak odtwarzany jest ich zserializowany stan. Zerknijmy na kolejny przykład.

class Test {
public static void main(String[] args) throws IOException, ClassNotFoundException {
DataObject obj = new DataObject();

obj.value = 1;
obj.middleValue = 2;
obj.topValue = 3;

saveObject(obj, "db.bin");
obj = readObject("db.bin");

System.out.print(obj.value + " " + obj.middleValue
+ " " + obj.topValue + " " + obj.tValue);
}

// metody saveObject(…) oraz readObject(…) bez zmian
}

class TopDataObject {
int topValue = 345;
}

class MiddleDataObject extends TopDataObject implements Serializable {
int middleValue = 234;
}

class DataObject extends MiddleDataObject {
int value = 123;

transient int tValue = 999;
}

Uruchomienie tego programu spowoduje wyświetlenie liczb "1 2 345 0”. Klasa ‘MiddleDataObject’ deklaruje wprost, że jest serializowalna. Serializowalna jest także klasa ‘DataObject’, jako że dziedziczy z klasy ‘MiddleDataObject’, która jest serializowalna. Natomiast nie jest serializowalna klasa ‘TopDataObject’. Co się zatem będzie działo z zadeklarowaną w tej klasie, i odziedziczoną przez klasę ‘DataObject’ zmienną ‘topValue’? A cóż specjalnego miałoby się dziać? – Będzie ona zwyczajnie ignorowana przez mechanizm serializacji. Zmienna ‘topValue’ jest ignorowana, bo została zadeklarowana w klasie, która nie jest serializowalna. Zmienna ‘tValue’ jest ignorowana, bo została zadeklarowana jako ‘transient’; zatem w wyniku serializacji zostanie zapisana tylko wartość zmiennych ‘value’ i ‘middleValue’.

Zerknijmy teraz do artykułu "SCJP - Konstruktory i inicjalizacja", aby przypomnieć sobie (o ile nie pamiętamy), jak inicjalizowane są nowo tworzone obiekty. Generalnie rzecz biorąc, uruchamiane są konstruktory, począwszy od konstruktora klasy Object a skończywszy na konstruktorze klasy, której instancję tworzymy (w tym inicjalizowane są zmienne instancyjne, którym przypisaliśmy wartość w ramach deklaracji). I teraz pytanie. Co się dzieje, gdy obiekt tworzony jest w wyniku deserializacji a nie z użyciem operatora ‘new’? Otóż coś zupełnie innego. Odtworzenie stanu zserializowanego obiektu i inicjalizacja obiektu to dwie zupełnie różne rzeczy. Jak zatem wygląda odtworzenie obiektu w wyniku deserializacji? Przede wszystkim trzeba zapamiętać, że nie jest uruchamiany konstruktor i zmienne nie są inicjalizowane na wartości przypisane w trakcie deklaracji. Wartości zmiennych są przecież ustawiane na wartości odczytane ze strumienia a więc uruchamianie konstruktora nie ma sensu. No dobrze. A co z wartościami zmiennych, które przez serializacje są ignorowane, jak ‘topValue’ czy ‘tValue’ z powyższego przykładu? No więc:

- Jeśli pewna nadklasa klasy deserializowanego obiektu nie jest serializowalna, to w celu zainicjowania wartości zmiennych zadeklarowanych w tej klasie zostanie uruchomiony jej konstruktor. Tak więc w powyższym przykładzie, uruchomiony będzie konstruktor klasy ‘TopDataObject’ który ustawi wartość zmiennej ‘topValue’ na ‘345’ (inicjalizacja zmiennych w trakcie deklaracji to de facto część konstruktora). Nie zmienia to oczywiście faktu, że w żadnym wypadku nie zostanie uruchomiony konstruktor klas serializowalnych, a więc ‘MiddleDataObject’ czy ‘DataObject’.

- Zmienne ‘transient’ zadeklarowane w klasach serializowalnych nie są inicjalizowane ani wartościami odczytanymi ze strumienia (bo tych wartości tam nie ma), ani przez konstruktor (bo ten nie jest uruchamiany). Mają one zatem wartość domyślną dla danego typu, czyli np. 0 dla typu całkowitoliczbowego, ‘null’ w przypadku referencji. Więcej na temat wartości domyślnych w artykule "SCJP - Wartości domyślne zmiennych".

Wyobraźmy sobie teraz, że chcemy serializować obiekty naszej klasy, które mają referencję do obiektów klasy, którą zaimplementował ktoś inny, której nie możemy zmodyfikować i która nie jest serializowalna. Oczywiście nic nie stoi na przeszkodzie. Możemy oznaczyć taką referencję jako ‘transient’ i wszystko będzie działać… z tym tylko wyjątkiem, że możemy akurat nie chcieć ignorować tego obiektu. Chcielibyśmy jednak zapisywać stan także tej "cudzej" klasy, której musimy (bądź chcemy) używać. Otóż i to da się zrobić. Wystarczy zaimplementować metody wywołania zwrotnego (ang. callback):

private void writeObject(ObjectOutputStream os) throws IOException {
// kod, który wykona serializację "ręcznie"
}

oraz

private void readObject(ObjectInputStream os)
throws IOException, ClassNotFoundException {

// kod, który wykona deserializację "ręcznie"
}

w klasie, która ma się serializować inaczej niż standardowo. Poniżej przykład. Algorytm postępowania wygląda tak, że zmienną referencyjną, która nie może być serializowana w zwykły sposób oznaczamy jako ‘transient’, a jej stan zapisujemy "ręcznie", po czym wywołujemy metodę ‘defaultWriteObject()’ (może być też w odwrotnej kolejności, ale kolejność musi być ta sama), która wykona standardową serializację całej reszty.

class Test {
public static void main(String[] args) throws IOException, ClassNotFoundException {
saveObject(new DataObject(123), "db.bin");

System.out.println(readObject("db.bin").valueObject.value);
}

// metody saveObject(…) oraz readObject(…) bez zmian
}

class DataObject implements Serializable {
transient ValueObject valueObject;

public DataObject(int value) {
this.valueObject = new ValueObject(value);
}

private void writeObject(ObjectOutputStream os) throws IOException {
os.defaultWriteObject();
os.writeInt(valueObject.value);
}

private void readObject(ObjectInputStream os)
throws IOException, ClassNotFoundException {

os.defaultReadObject();
valueObject = new ValueObject(os.readInt());
}
}

class ValueObject {
int value;

public ValueObject(int value) {
this.value = value;
}
}

3 komentarze:

Jacek pisze...

A Ty dalej twardo tłuczesz te tematy SCJP :D zuch!!
p.s. a patrz kto się pojawił http://przemyslawdudek.blogspot.com/ :P

Przemysław Dudek pisze...

Jeszcze się nie pojawił tylko się zastanawia czy się nie pojawić. Jak już takie cieniasy spamują internet to może i ja ;-)

Anonimowy pisze...

Jeśli pewna nadklasa klasy deserializowanego obiektu nie jest serializowalna, to w celu zainicjowania wartości zmiennych zadeklarowanych w tej klasie zostanie uruchomiony jej konstruktor.Zapomniałeś dodać iż klasa ta musi posiadać konstruktor bezargumentowy w przeciwnym wypadku podczas deserializacji rzuci wyjątkiem czasu wykonania: java.io.InvalidClassException ... no valid constructor