1. Методи writeReplace і readResolve: теорія
Іноді стандартних засобів серіалізації недостатньо. Уявіть ситуацію: у вас є синглтон (клас, у якого може бути лише один екземпляр в усій програмі), і ви хочете, щоб після десеріалізації він залишився єдиним екземпляром (а не з’явився новий клон). Або ви хочете серіалізувати не сам об’єкт, а його «полегшену» версію (проксі), щоб приховати деталі реалізації або заощадити місце.
У Java для цього існують спеціальні методи: writeReplace і readResolve. Їхнє завдання — замінити серіалізований або десеріалізований об’єкт іншим.
Проста аналогія:
Це якби ви надсилали посилку другові, але замість себе в коробку поклали іграшкового двійника. А коли друг розпаковує посилку, замість іграшки в нього в руках опиняєтеся ви — справжні! (У реальному житті так не працює, але в Java — цілком.)
writeReplace
Метод private Object writeReplace() викликається у об’єкта перед серіалізацією. Він може повернути будь-який об’єкт, який буде фактично серіалізований замість вихідного. Якщо метод не реалізовано — серіалізується сам об’єкт.
Сигнатура:
private Object writeReplace() throws ObjectStreamException
readResolve
Метод private Object readResolve() викликається у об’єкта після десеріалізації. Він дає змогу замінити щойно створений об’єкт іншим (наприклад, повернути синглтон або кешований екземпляр).
Сигнатура:
private Object readResolve() throws ObjectStreamException
Важливо:
Обидва методи мають бути private і повертати Object. Це вимога специфікації серіалізації Java. Якщо зробити їх public — механізм серіалізації просто проігнорує ці методи.
2. Застосування writeReplace і readResolve на практиці
Синглтон і readResolve
Синглтон — це просто клас, у якого може бути лише один екземпляр в усій програмі. Якщо такий об’єкт серіалізувати й потім відновити, то без методу readResolve з’явиться новий екземпляр, і правило «єдиності» порушиться. З readResolve можна повернути саме той самий об’єкт, зберігши ідею синглтона.
import java.io.*;
public class MySingleton implements Serializable {
private static final MySingleton INSTANCE = new MySingleton();
private MySingleton() {}
public static MySingleton getInstance() {
return INSTANCE;
}
// Гарантуємо, що після десеріалізації повернеться саме INSTANCE
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}
Пояснення:
Без readResolve після десеріалізації з’явиться новий об’єкт, не рівний (за ==) оригінальному синглтону. З readResolve — завжди повертається INSTANCE.
Перевіримо на практиці:
MySingleton s1 = MySingleton.getInstance();
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("singleton.bin"));
out.writeObject(s1);
out.close();
ObjectInputStream in = new ObjectInputStream(new FileInputStream("singleton.bin"));
MySingleton s2 = (MySingleton) in.readObject();
in.close();
System.out.println(s1 == s2); // true, якщо є readResolve; false — без нього
writeReplace: серіалізація проксі-об’єкта
Іноді об’єкт занадто «важкий» для серіалізації, або містить чутливі дані, або просто не має потрапляти назовні в повному вигляді. У цьому разі можна серіалізувати «заміник» — проксі-об’єкт.
Приклад:
Припустімо, у нас є клас User із приватним паролем. Ми не хочемо, щоб пароль серіалізувався.
import java.io.*;
public class User implements Serializable {
private String username;
private transient String password; // transient — не серіалізується
public User(String username, String password) {
this.username = username;
this.password = password;
}
// Замість User серіалізуємо лише UserProxy
private Object writeReplace() throws ObjectStreamException {
return new UserProxy(username);
}
// Проксі-клас — лише для серіалізації
private static class UserProxy implements Serializable {
private String username;
public UserProxy(String username) {
this.username = username;
}
private Object readResolve() throws ObjectStreamException {
// У реальному житті пароль не відновити — повертаємо User з порожнім паролем
return new User(username, "");
}
}
}
Пояснення:
- Під час серіалізації User перетворюється на UserProxy (без пароля).
- Під час десеріалізації UserProxy перетворюється назад на User (але пароль уже порожній).
3. Кастомізація серіалізації для незмінних об’єктів
Незмінні (immutable) об’єкти часто використовують приватні final-поля і не мають сеттерів. За стандартної серіалізації Java може обійти це обмеження, але іноді краще явно контролювати процес через writeReplace/readResolve.
Приклад: Value Object
import java.io.*;
public final class Money implements Serializable {
private final int amount;
private final String currency;
public Money(int amount, String currency) {
this.amount = amount;
this.currency = currency;
}
private Object writeReplace() throws ObjectStreamException {
return new MoneyProxy(amount, currency);
}
private static class MoneyProxy implements Serializable {
private final int amount;
private final String currency;
MoneyProxy(int amount, String currency) {
this.amount = amount;
this.currency = currency;
}
private Object readResolve() throws ObjectStreamException {
return new Money(amount, currency);
}
}
}
Пояснення:
- Під час серіалізації Money перетворюється на MoneyProxy (POJO).
- Під час десеріалізації MoneyProxy перетворюється назад на Money.
Взаємодія з writeObject/readObject
Методи writeReplace/readResolve працюють незалежно від writeObject/readObject. Якщо обидва механізми визначені, то спочатку викликається writeReplace, а вже у поверненого об’єкта — writeObject (якщо він реалізує Serializable).
Схема:
flowchart LR
A[Об'єкт] -- writeReplace --> B[Проксі-об'єкт]
B -- writeObject --> C[Потік байтів]
C -- readObject --> D[Проксі-об'єкт]
D -- readResolve --> E[Підсумковий об'єкт]
4. Практика: серіалізація з підміною об’єкта
Додаймо кастомну серіалізацію у ваш навчальний застосунок — наприклад, для класу Person, щоб під час серіалізації записувалося лише ім’я, а вік ігнорувався (припустімо, ми дбаємо про приватність).
Крок 1. Основний клас
import java.io.*;
public class Person implements Serializable {
private String name;
private int age; // не хочемо серіалізувати
public Person(String name, int age) {
this.name = name;
this.age = age;
}
private Object writeReplace() throws ObjectStreamException {
return new PersonProxy(name);
}
private static class PersonProxy implements Serializable {
private final String name;
PersonProxy(String name) {
this.name = name;
}
private Object readResolve() throws ObjectStreamException {
return new Person(name, -1); // -1 — "вік невідомий"
}
}
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + "}";
}
}
Крок 2. Тестуємо
public class TestCustomSerialization {
public static void main(String[] args) throws Exception {
Person original = new Person("Alice", 30);
// Серіалізація
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("person.bin"));
out.writeObject(original);
out.close();
// Десеріалізація
ObjectInputStream in = new ObjectInputStream(new FileInputStream("person.bin"));
Person deserialized = (Person) in.readObject();
in.close();
System.out.println("До серіалізації: " + original);
System.out.println("Після десеріалізації: " + deserialized);
}
}
Результат:
До серіалізації: Person{name='Alice', age=30}
Після десеріалізації: Person{name='Alice', age=-1}
Як бачите, вік не серіалізувався — усе за планом!
5. Особливості та нюанси
Коли використовувати writeReplace/readResolve?
- Коли потрібно серіалізувати лише частину стану об’єкта.
- Для серіалізації/десеріалізації проксі-об’єктів.
- Для підтримки патерна Singleton.
- Для незмінних або складних об’єктів, чия внутрішня структура може змінюватися.
Коли не варто використовувати?
- Якщо можна обійтися полями transient або writeObject/readObject.
- Якщо об’єкт не повинен підмінятися іншим.
Сумісність з успадкуванням
Якщо суперклас визначає writeReplace/readResolve, вони будуть викликані й для підкласів (якщо не перевизначені). Будьте обережні з ієрархіями!
6. Типові помилки під час кастомної серіалізації
Помилка № 1: Неправильна видимість методів. Якщо зробити writeReplace/readResolve не private, механізм серіалізації їх не викличе. Лише private!
Помилка № 2: Невідповідність типів, що повертаються. writeReplace/readResolve мають повертати Object. Навіть якщо фактично повертаєте власний тип, метод оголошуйте з типом повернення Object.
Помилка № 3: Втрата даних. Якщо проксі-об’єкт не містить усіх потрібних даних для відновлення вихідного об’єкта, частину інформації буде втрачено. Завжди перевіряйте, що зможете відновити об’єкт.
Помилка № 4: Порушення інваріантів. readResolve має повертати об’єкт, який відповідає очікуванням програми (наприклад, для синглтона — саме INSTANCE).
Помилка № 5: Необроблені винятки. writeReplace/readResolve можуть кидати ObjectStreamException. Обробляйте або явно оголошуйте його у сигнатурі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ