Serializacja obiektów w C++ nie jest sprawą prostą. Zrzucić pojedyńczą strukturę na dysk nie jest trudno (cały czas zakładamy, że przenośność między różnymi architekturami nas nie obchodzi). Jednak zrobić to samo dla całej hierarchi klas -- oto problem.
Zapis na dysk nie jest sprawą skomplikowaną. Wystarczy wirtualna metoda Serialize. Ale co z odczytem? W C++ nie ma czegoś takiego jak wirtualny konstruktor (nawet brzmi to absurdalnie). Jak można w miarę zautomatyzowany sposób osiągnąć wirtualną deserializację? Dodatkowo fajnie by było móc zrobić to dla POD-ów...
Opierając się na kodzie z mojego silnika (choć tam serializacja jest zrobiona bardziej statycznie), wymyśliłem następującą metodę: mamy hierarchię klas które nas interesują (DataA, DataB, DataC). Opakowane są one w obiekty dostępu do danych (ObjectA, ObjectB, ObjectC). Teraz uwaga: wrappery również pozostają ze sobą w relacji dziedziczenia. Można to zobaczyć na grafie:
OK. Przejdźmy do implementacji. Najpierw proponuję zapoznać się z kodem: serialize.cpp (nie jest najpiękniejszy, ale ma dużo "gadających" funkcji i prosty system wykrywania wycieków pamięci).
Wszystkie obiekty Object* dziedziczą po abstrakcyjnej klasie Object. Dodatkowo, każdy obiekt dziedziczy po swoim interfejsie. Dlaczego? Ano dlatego, by zachować hierarchię klas również między wrapperami. Nie chcemy jednak by obiekt dziedziczący (u nas ObjectB) zawierał dane DataB oraz DataA (co stałoby się w przypadku zwykłego dziedziczenia -- każdy obiekt ObjectX zawiera w sobie dane DataX).
Serializacja jest prosta - zwykłe funkcje wirtualne. A co z deserializacją? To jest dużo ciekawsze. Przy deklarowaniu klasy (ObjectA) tworzymy również globalny obiekt klasy CreateDeserialize który rejestruje naszą klasę i jej funkcję do deserializacji. Mamy globalny słownik funkcji służących do deserializacji, w której identyfikatorami są numery linii w których pojawia się deklaracja klasy (wiem - to takie sobie rozwiązanie, ale zakładam, że deklaracje wszystkich "serializowalnych" obiektów są w jednym pliku).
Przy serializacji zapisujemy do strumienia identyfikator typu. Przy deserializacji, odczytujemy identyfikator i dane i uruchamiamy właściwą funkcję do deserializacji (czyli w gruncie rzeczy wirtualny konstruktor ;) ), wybierając ją z słownika na podstawie tegoż identyfikatora.
Highlights: tworzenie i korzystanie z naszych obiektów:
OBJECT_START( ObjectA )
{
int a;
int b;
char fill[100];
}
OBJECT_END ( ObjectA )
OBJECT_START_INHERIT ( ObjectB, ObjectA )
{
double x;
float z;
}
Tak wygląda deklaracja. OBJECT_START i jej podobne to oczywiście paskudne makra. Od biedy myślę, że taką składnię da się jednak przeżyć.
ObjectA A;
A->a = 5;
A->b = 11;
Tak korzystamy z obiektu. Klasy Object mają przeciążony operator ->, więc można korzystać z nich jak ze wskaźnika. Można też pobrać adres obiektu danych używając metody GetData.
ObjectB B; // dziedziczy z ObjectA
// ....
Serialize(B, s);
auto_ptr<Object> o1(Deserialize(s));
ObjectA* oa1p = object_cast<ObjectA>(o1.get());
A tak wygląda deserializacja i właściwe korzystanie z obiektu. Nie możemy niestety zastosować rzutowania polimorficznego (dynamic_cast) w sposób bezpośredni, więc pomagamy sobie takim oto operatorem rzutowania:
template <typename To>
To* object_cast(Object* ob)
{
if (!dynamic_cast<To::Interfacetype*>(ob)) return 0;
return (To*)(ob);
};
Uwaga! Nie rzutujemy obiektu danych (który nie jest polimorficzny, jest zwykłą strukturą), tylko wykorzystujemy fakt dziedziczenia po interfejsach. Czyli tak naprawdę, robiąc object_cast<ObjectA> robimy dynamic_cast<IObjectA>. Operator stanowi pewne ułatwienie.
OK, a co z wydajnością czasowo-pamięciową?
Jeżeli chodzi o pamięć to jedyny narzut to wskaźnik na tablicę funkcji wirtualnych w Object* (uwaga - przy kilkupoziomowym dziedziczeniu obiekt czasem puchnie o 8, zamiast 4 bajty - nie mam pomysłu dlaczego). Oczywiście przy zapisywaniu obiektów dodatkowo musimy zapamiętać identyfikator typu (który lepiej niech się nie zmienia pomiędzy wersjami -- ponownie __LINE__ może być nienajlepszym wyborem; wartoby zastąpić go hashem z nazwy obiektu).
Natomiast generowany kod jest całkiem do przyjęcia. Nie rozumiem jedynie, czemu kompilator z Visual Studio nie radzi sobie z inline'owaniem operatora -> przy niepolimorficznym dostępie do obiektu:
ObjectA A;
A->a = 5;
Tak więc, tak wygląda dosyć prosty (prosty w C++ to pojęcie względne -- o czym mam zamiar poświęcić cykl notek) system deserializacji. Z chęcią przeczytam uwagi na jego temat (być może jest całkowicie bez sensu, kto wie?).
PS. Zabieram się do małych usprawnień technicznych na blogu. A także do częstszego blogowania - można (i należy) się o to upominać. ;)