I want to add support for circular serialization.
My idea is simple: We only need to support for circular references for a few types, not all types.
So why not just write 0 for new object; and id for known objects?
It is as easy as it sounds, but there is one small problem...
_(Deserialization is missing a small feature)_
using System.Collections.Generic;
using MessagePack;
using MessagePack.Formatters;
using MessagePack.Resolvers;
namespace MsgPackTestCyclicRef
{
[MessagePackObject]
public class Person
{
[Key(0)]
public string Name;
[Key(1)]
public Pet Pet;
}
[MessagePackObject]
public class Pet
{
[Key(0)]
public string Name;
[Key(1)]
public Person Owner;
}
class Program
{
static void Main(string[] args)
{
var p = new Person { Name = "Riki" };
var c = new Pet { Name = "Dr. Cat" };
c.Owner = p;
p.Pet = c;
var myResolver = new MyResolver<Person>();
var bytes = MessagePackSerializer.Serialize(p, myResolver);
var reconstructed = MessagePackSerializer.Deserialize<Person>(bytes, myResolver);
}
}
class MyResolver<T> : IFormatterResolver
{
public CircularFormatter<T> CircularFormatter = new CircularFormatter<T>();
public IMessagePackFormatter<TRequested> GetFormatter<TRequested>()
{
// Maybe we can do a circular resolver before falling back to the default
if (typeof(T) == typeof(TRequested))
return (IMessagePackFormatter<TRequested>)CircularFormatter;
return StandardResolver.Instance.GetFormatter<TRequested>();
}
}
class CircularFormatter<T> : IMessagePackFormatter<T>
{
int _serializingId = 1;
int _deserializingId = 1;
Dictionary<int, T> _idToObj = new Dictionary<int, T>();
Dictionary<T, int> _objToId = new Dictionary<T, int>();
IMessagePackFormatter<T> _defaultFormatter = StandardResolver.Instance.GetFormatter<T>();
IMessagePackFormatter<int> _intFormatter = StandardResolver.Instance.GetFormatter<int>();
public int Serialize(ref byte[] bytes, int offset, T value, IFormatterResolver formatterResolver)
{
// How do we serialize? Will we write the object? Or just the ID?
int writtenBytes = 0;
if (!_objToId.TryGetValue(value, out int existingId))
{
// First encounter! Assign an ID to the object, then serialize it normally
_objToId[value] = _serializingId++;
// Write 0, meaning "this is a new object"
writtenBytes += _intFormatter.Serialize(ref bytes, offset, 0, formatterResolver);
// Write object itself
writtenBytes += _defaultFormatter.Serialize(ref bytes, offset + writtenBytes, value, formatterResolver);
}
else
{
// It is a known object, only write the ID
writtenBytes += _intFormatter.Serialize(ref bytes, offset, existingId, formatterResolver);
}
return writtenBytes;
}
public T Deserialize(byte[] bytes, int offset, IFormatterResolver formatterResolver, out int readSize)
{
int totalRead = 0;
int read = 0;
var id = _intFormatter.Deserialize(bytes, offset, formatterResolver, out read);
totalRead += read;
offset += read;
read = 0;
if (id == 0)
{
// We're getting a new object
// Let's deserialize it using the default formatter (DynamicObject, ...)
var obj = _defaultFormatter.Deserialize(bytes, offset, formatterResolver, out read);
totalRead += read;
offset += read;
read = 0;
readSize = totalRead;
// This object might be referenced later on. We save it along with its id
_idToObj[_deserializingId++] = obj;
return obj;
}
else
{
// It is an already known object!
readSize = totalRead;
var obj = _idToObj[id];
return obj;
}
}
// todo: need to reset the formatter after every full serialization
public void Reset()
{
_serializingId = 1;
_deserializingId = 1;
_idToObj.Clear();
_objToId.Clear();
}
}
}
In Deserialize there is this line:
var obj = _defaultFormatter.Deserialize(bytes, offset, formatterResolver, out read);
The problem is, it will recursively read objects, while it has not finished reading the current / root object.
Serialization is depth-first, so it will try to resolve objects before they are fully read, and I don't have access to the object that I am currently reading (no "parent" object).
So that is why it will not find the object in the dictionary, and crash.
Let us create the object manually, give us a reference, and we will manually populate all fields.
That way we can work with objects we're still in the process of reading.
@neuecc
1) Is the approach feasible? If CircularFormatter<T> is used for only few types (manually) it should not impact performance much. Disadvantage: have to manually determine what types to serialize by reference/id. It might be a problem for noob/beginner users, if they don't understand what they are doing. (Could get duplicated objects that are missing reference equality for some objects, even when reference equality is expected).
2) Can we get the object that was created for deserialization somehow, populate fields instead?
My suggestion, instead of:
var obj = _defaultFormatter.Deserialize(bytes, offset, formatterResolver, out read);
give us:
var obj = _defaultFormatter.CreateNewObject();
// ---> Can enter obj into dictionary already, before it has finished reading!
_defaultFormatter.DeserializeInto(ref obj, ...);
This feature feels closely related to OverwriteFormatting https://github.com/neuecc/MessagePack-CSharp/pull/110
3) Maybe later we can write a smart algorithm that will "break" a object-graph at strategic positions. Only using CircularFormatter<T> to break loops in the circular graph when needed.
Or maybe we can add an attribute [KeepReferenceEquality] or [UseCircularFormatter] ...
Automatic way can not ensure that the result is what the user expects (maybe it decides that some object can be serialized the normal way, while still not having loops in the graph).
But doing it for every object seems wasteful, lots of dictionary lookups.
Maybe there is some way to "pre-process" a object-graph before serializing, setting the references/fields/properties to null, and then adding another section into the serialized data that tells us what properties to set where, so we can restore the original graph.
What are your thoughts?
In Deserialize we can remove the dictionary and use a list instead.
We know that the index will be simply increasing, so we can use a list directly.
That should be faster than using a dictionary.
I will try to find some time soon to see if I can make the necessary changes to DynamicObjectFormatter.
Either finishing OverwriteFormatter if that solves it, or changing how serialization is done.
I have started writing my own proof of concept serializer, since the design of MessagePack-CSharp is not really suitable to support object overwriting easily (in the current PR it is implemented as a second interface and method void DeserializeTo(ref T to, ...).
I'm getting similar performance to MessagePack-CSharp, using only Linq.Expressions instead of direct ILGen.
If my ideas regarding object "caching" (simply saving an ID for references that were written previously like explained in my first post) pan out as intended, then I'll either try to implement that into MessagePack-CSharp, or I might release my own serialization library.
My own library will be completely incompatible to the MessagePack binary format though and also has much weaker guarantees regarding binary-versioning. It will not support things like the Typeless* formatters, and will generally break if you change classes/fields. I can most likely fix that later by adding attributes similar to those that MessagePack-CSharp has.
It will also support even the most complex object hierarchies; supporting all cases even the ones were MessagePack-CSharp crashes with InvalidProgram / InvalidOperation / StackOverflow
This issue is a blocker to use this awesome library in our framework. The circular references might occur in a big framework, and we can't control the models for circular references in a large group of developers.
Good news! I finished my serializer.
If there is interest, I think I'll publish it on github. And if neuecc is willing, I'd even try to see if it is possible to backport the ideas in my new serializer to MessagePack-CSharp. (Unfortunately that is unlikely though as the binary format is completely different from the established MessagePack protocol).
Obviously it takes quite a bit of time to properly release something on github (along with nuget packages...). And seeing as the serializer I wrote belongs at least partially into a niche category, I decided to just post what I have and then see if there would even be any substantial interest in the library.
Right now it is only supposed to fulfill the needs I and my team have, so even though it has massive advantages compared to MessagePack-CSharp, it also has massive disadvantages. Just like with any library or tool you have to know exactly what you need from it and what features you can totally live without / sacrifice for other things, like performance.
While developing I made a list of the most important differences and similarities, roughly ordered by importance to us:
Performance. Mostly very similar to MsgPack-CSharp. Sometimes faster, sometimes slower, it much depends on what actual types are present in the given object-graph, and what formatters get used; and even in the "worst case" it is still comparable to MessagePack-CSharp in speed (details further down in the post)
No allocations! :raised_hands: This one is really huge for us since few serializers support it and we have a dire need for it. Avoiding useless allocations also gives a notable performance boost even if you don't care about garbage-collection-pressure.
Deserialize(byte[] buffer, int offset, ref T obj);. You can pass in null for obj, but then the serializer will of course allocate a T for you (unless the given data says the object is _supposed to be_ null of course)null, which is important to avoid GC-pressure)new(), which allocates, but that's obviously not the serializers fault then :PBesides primitives it of course supports arbitrary user objects. Same technique as MessagePack-CSharp uses, dynamic code generation to generate an optimized serializer at runtime for each specific object. MessagePack-CSharp calls it "DynamicObjectFormatter", same here. However instead of ILGen I'm using Linq.Expressions.Expression which does essentially the same thing. This adds a limitation though: a hard-dependency on Expression.Block(), meaning at no support for very old runtimes like .NET 3.5 or earlier. Only .NET 4.0 or higher, or any version of Standard/Core. That is probably the only reason why neuecc went the "hard" way of using ILGenerator, which is available since .NET 1.1. Unless someone makes a convincing argument I have no plans to add support for those older runtimes, since even Unity now supports .NET 4.x.
"CacheFormatting". The magic ingredient that enables:
Type (since that has to write as string), and thus also for MemberInfo/FieldInfo/PropertyInfo/MethodBase, ... An important and major disadvantage to keep in mind (at least for now) is: No "binary-versioning" support of any kind! Opposite to MessagePack-CSharp, it does not use field indices ([Key(...)]), and it does not serialize field-names (as Contractless* formatters do in MessagePack-CSharp).
[PreviouslyNamed("OldFieldName")], [PreviousType(typeof(IList<>))], [PreviousTypeName("TypeNameOfTypeBeforeTheUpdate")][MigrationConverter(typeof(MyOldMemberToNewMemberConverterType))] for fields, or [ObjectMigrationConverter(typeof(...))] for whole objects... or something like that for automatic migration of older serialized data. Fully polymorphic serialization; even crazy large inheritance-chains and edge caes of polymorphism are supported! No need to annotate anything with [Union] like in MessagePack-CSharp, no crashes with InvalidProgramException, InvalidOpeartionException, or StackOverflowException when specifically complicated type hierarchies are encountered.
Type type.Type formatting and CachingFormatters! (See note *2 at the end)List<int> is not eligible, and still has to have type information, because the actual value in that field could still possibly be a user-class that inherits from List<>! But a field of type sealed class MyClass {...} won't need any type information!Side effect of full support for arbitrary inheritance means it supports very efficient serialization of the "System.Type" type itself. Non-generic (normal, int), open-generic (List< >), half-open-generic (Dictionary< , int>), closed-generic types (List<int>), and every possible combination of "semi-open" generics (when nesting generics with more than 1 generic parameter) are supported.
Collections: Right now it only supports ICollection<T> (which is basically just IEnumerable+Clear+Add methods). Everything implementing that interface is automatically supported! That means List<>, Dictionary<>, ... are all automatically supported.
Built-in efficient serialization of KeyValuePair<,> and ICollections containing them (like Dictionary)
Built-in support types that serializers usually rarely support like: FieldInfo, ConstructorInfo, PropertyInfo, MethodInfo, and Type, simply because we have a need for that.
Classic arrays (T[]) are not supported at the moment. Why? Simply because we try to use List<> (or similar) wherever possible because those can be cleared and reused while deserializing, whereas arrays can not be reused! They need to be of the exact right size and System.Array.Resize<> simply allocates a new array, and thus causes GC pressure. If there is interest, and you don't care about allocating arrays while deserializing, then that would be very easy to add (literally <20min).
Right now it only serializes public fields. And there's only one attribute it supports ([IgnoreField]).
No support to ignore fields of types you don't control (if you can't add [IgnoreField]), but adding a way to do that should be easy if there is interest.
No support for the "MessagePack" format binary format or any sort of interoperability with other serializers. Nothing planned in that direction either.
For now, no support for primitives except those few: int byte bool (all as zigzag encoded VarInt), float, string.
uint, char, long, double, DateTime, Guid, decimal, short, ... (*1)
That is because I simply don't have any need for binary versioning. If we'd ever have to do something like that (for example upgrade a savegame from a previous version to a newer patch of the game), then we'd keep the old class hierarchy around in a "deprecated" namespace, and then do a very easy, simple and of course slow way to migrate the data: Use the serializer to load the old data using the old types/classes, then serialize that instance to JObject using Newtonsoft.Json, then do all the needed changes to the JObject, then deserialize that into the new type-structure. Sounds complicated but it is actually very easy. The only downside is that it is obviously really slow. But it is doubtful that this is ever needed for us anyway, and if so, it will be a one-time thing, and maybe take 200ms instead of <2ms. Should there be interest in the serializer and the community wants "versioning support", then simply adding fieldnames+field types and even "ordering ids" (as in [Key(...)] from MessagePack-CSharp), would not be a big problem. But that obviously costs some binary space then...
(*2)
To see how writing types and CachingFormatters work together, lets look at a quick example.
Let's assume you have an interface IMyObj { }, class MyObjA : IMyObj { public int Num; } and class MyObjB : IMyObj { public string Str; }.
Then you want to serialize an object containing a field of type IEnumerable<object> which right now contains a List<object>, and the list contains some crazy example content like this:
[new object(), myObjAInstance, myObjBInstance, 123.45f]
Since, at deserializationtime, it would not be clear from just the field type (IEnumerable<object>) what to instantiate, the first thing that gets written is actual type of the collection. In this case the type is List<object>, so it actually writes two separate types. The open generic System.Collections.Generic.List<> and then its argument: System.Object. After that it just writes the number of elements (here 4), and then each object.
Next the elements themselves. To serialize new object() we'd normally write the type System.Object directly again, but since that was already written once only the ID of that type (here 1, List being 0) has to be emitted, and that's it (Since object itself actually has no public fields).
Serialization continues like this, writing the type for each item, and then uses the specific DynamicFormatter to write out the content (fields) of the object. For myObjAInstance, myObjBInstance that'd just be the concrete type name + whatever value (int or string).
The float at the end has to have its type written as well as it would otherwise not be clear what formatter to use for reading (you only know object at this point). If the container would have been a List<float> no type information would have been written per element, as the collection type itself already tells us the item type!
All reference and type IDs are written as int, which is ZigZag encoded and written into the binary as a VarInt (so small numbers only consume 1 byte, using more bytes the larger the number gets, up to 5).
As shown in the example type information for generic types is not written in the usual "mangled form" as that would take up a ton of space and be very redundant.
Example: Serializing an instance of class C { public object A = (int)5; public List<int> B; }
Lets just look at how the types for A and B are written and ignore C and the actual values.
Normally the final binary data would have to contain the very long "mangled type names", which are not only long, but also full of redundant/useless information, like this:
System.Collections.Generic.List1[[System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089`
Instead of doing that, all types are written as combinations of "open type + generic arguments", enabling massive space-savings while serializing types.
In our example we _could_ write
System.Int32 for the type of A and System.Collections.Generic.List<> + System.Int32 for the type of B, but there's still one more optimization to do, and that is again caching.
We've already written System.Int32, so the type definition of B can make use of that.
It can now simply write System.Collections.Generic.List<0> the 0 being the reference to the first written type (int).
In this example the savings are small, but with more realistic hierarchies and generic type definitions it makes a massive difference!
(*3) How reference serialization works
While serializing, if the object was encountered before, it simply writes a lookup ID. If not, it writes out -1 + the new object. While deserializing it reads the ID, if its's -1 the object is added to the known objects list. Note that deserialization uses a list, not a dictionary, so no performance cost while deserializing. This also works for self-referencing objects, and indirectly self-referencing objects (for example an object that references itself through an object-typed field)
(4*) Keeping the reference cache!
Normally that wouldn't make sense, because every serialized object is probably unique in its type-setup (maybe there's a SpecificTypeA at index 5 of some list at one time, and the other time it is a SpecificTypeB there ...).
But think about networking!
Having a two pairs of serializers at each endpoint (one Serializer/Deserializer-pair for sending, and one for receiving) would allow the networking protocol to be self-optimizing. Instead of having to give each object a pre-defined "NetworkMessageId" or something, the serializer would simply write out the type name in full once, and after that everytime the type is to be written, the CachingFormatters would just emit a single byte (or more if actually needed) for the type. Network-Messages that are sent rarely could be marked as "no type caching", to save ID-space for things that get sent more often (for example you wouldn't want to "allocate" a type-ID for something like LoginPacket, which is only ever sent once anyway). It's even possible to let the CachingFormatters decide what should get cached by keeping a count of how often something was encountered! That way Messages that often get sent by the server to the client, but never sent from client to server, get an ID assigned to them in the receiving direction, but not in the sending direction, extremely efficient!
Hi rikimaru0345,
I'm very interested by your serializer .... do you think create a repository?
thanks
I'll probably create a repo for it over the coming week.
The repository is here:
https://github.com/rikimaru0345/Ceras
The code will be added after I finished most of the guides and examples. (probably tomorrow)
Most helpful comment
Good news! I finished my serializer.
If there is interest, I think I'll publish it on github. And if neuecc is willing, I'd even try to see if it is possible to backport the ideas in my new serializer to MessagePack-CSharp. (Unfortunately that is unlikely though as the binary format is completely different from the established MessagePack protocol).
Obviously it takes quite a bit of time to properly release something on github (along with nuget packages...). And seeing as the serializer I wrote belongs at least partially into a niche category, I decided to just post what I have and then see if there would even be any substantial interest in the library.
Right now it is only supposed to fulfill the needs I and my team have, so even though it has massive advantages compared to MessagePack-CSharp, it also has massive disadvantages. Just like with any library or tool you have to know exactly what you need from it and what features you can totally live without / sacrifice for other things, like performance.
While developing I made a list of the most important differences and similarities, roughly ordered by importance to us:
Performance. Mostly very similar to MsgPack-CSharp. Sometimes faster, sometimes slower, it much depends on what actual types are present in the given object-graph, and what formatters get used; and even in the "worst case" it is still comparable to MessagePack-CSharp in speed (details further down in the post)
No allocations! :raised_hands: This one is really huge for us since few serializers support it and we have a dire need for it. Avoiding useless allocations also gives a notable performance boost even if you don't care about garbage-collection-pressure.
Deserialize(byte[] buffer, int offset, ref T obj);. You can pass innullfor obj, but then the serializer will of course allocate aTfor you (unless the given data says the object is _supposed to be_nullof course)null, which is important to avoid GC-pressure)new(), which allocates, but that's obviously not the serializers fault then :PBesides primitives it of course supports arbitrary user objects. Same technique as MessagePack-CSharp uses, dynamic code generation to generate an optimized serializer at runtime for each specific object. MessagePack-CSharp calls it "DynamicObjectFormatter", same here. However instead of ILGen I'm using
Linq.Expressions.Expressionwhich does essentially the same thing. This adds a limitation though: a hard-dependency on Expression.Block(), meaning at no support for very old runtimes like.NET 3.5or earlier. Only.NET 4.0or higher, or any version of Standard/Core. That is probably the only reason why neuecc went the "hard" way of using ILGenerator, which is available since.NET 1.1. Unless someone makes a convincing argument I have no plans to add support for those older runtimes, since even Unity now supports.NET 4.x."CacheFormatting". The magic ingredient that enables:
Type(since that has to write as string), and thus also for MemberInfo/FieldInfo/PropertyInfo/MethodBase, ...An important and major disadvantage to keep in mind (at least for now) is: No "binary-versioning" support of any kind! Opposite to MessagePack-CSharp, it does not use field indices (
[Key(...)]), and it does not serialize field-names (as Contractless* formatters do in MessagePack-CSharp).[PreviouslyNamed("OldFieldName")],[PreviousType(typeof(IList<>))],[PreviousTypeName("TypeNameOfTypeBeforeTheUpdate")][MigrationConverter(typeof(MyOldMemberToNewMemberConverterType))]for fields, or[ObjectMigrationConverter(typeof(...))]for whole objects... or something like that for automatic migration of older serialized data.Fully polymorphic serialization; even crazy large inheritance-chains and edge caes of polymorphism are supported! No need to annotate anything with
[Union]like in MessagePack-CSharp, no crashes withInvalidProgramException,InvalidOpeartionException, orStackOverflowExceptionwhen specifically complicated type hierarchies are encountered.Typetype.Typeformatting and CachingFormatters! (See note *2 at the end)List<int>is not eligible, and still has to have type information, because the actual value in that field could still possibly be a user-class that inherits fromList<>! But a field of typesealed class MyClass {...}won't need any type information!Side effect of full support for arbitrary inheritance means it supports very efficient serialization of the "System.Type" type itself. Non-generic (normal,
int), open-generic (List< >), half-open-generic (Dictionary< , int>), closed-generic types (List<int>), and every possible combination of "semi-open" generics (when nesting generics with more than 1 generic parameter) are supported.Collections: Right now it only supports
ICollection<T>(which is basically just IEnumerable+Clear+Add methods). Everything implementing that interface is automatically supported! That meansList<>,Dictionary<>, ... are all automatically supported.Built-in efficient serialization of
KeyValuePair<,>and ICollections containing them (like Dictionary)Built-in support types that serializers usually rarely support like:
FieldInfo,ConstructorInfo,PropertyInfo,MethodInfo, andType, simply because we have a need for that.Classic arrays (
T[]) are not supported at the moment. Why? Simply because we try to use List<> (or similar) wherever possible because those can be cleared and reused while deserializing, whereas arrays can not be reused! They need to be of the exact right size andSystem.Array.Resize<>simply allocates a new array, and thus causes GC pressure. If there is interest, and you don't care about allocating arrays while deserializing, then that would be very easy to add (literally <20min).Right now it only serializes public fields. And there's only one attribute it supports (
[IgnoreField]).No support to ignore fields of types you don't control (if you can't add
[IgnoreField]), but adding a way to do that should be easy if there is interest.No support for the "MessagePack" format binary format or any sort of interoperability with other serializers. Nothing planned in that direction either.
For now, no support for primitives except those few:
intbytebool(all as zigzag encoded VarInt),float,string.uint,char,long,double,DateTime,Guid,decimal,short, ...(*1)
That is because I simply don't have any need for binary versioning. If we'd ever have to do something like that (for example upgrade a savegame from a previous version to a newer patch of the game), then we'd keep the old class hierarchy around in a "deprecated" namespace, and then do a very easy, simple and of course slow way to migrate the data: Use the serializer to load the old data using the old types/classes, then serialize that instance to JObject using Newtonsoft.Json, then do all the needed changes to the JObject, then deserialize that into the new type-structure. Sounds complicated but it is actually very easy. The only downside is that it is obviously really slow. But it is doubtful that this is ever needed for us anyway, and if so, it will be a one-time thing, and maybe take 200ms instead of <2ms. Should there be interest in the serializer and the community wants "versioning support", then simply adding fieldnames+field types and even "ordering ids" (as in
[Key(...)]from MessagePack-CSharp), would not be a big problem. But that obviously costs some binary space then...(*2)
To see how writing types and CachingFormatters work together, lets look at a quick example.
Let's assume you have an
interface IMyObj { },class MyObjA : IMyObj { public int Num; }andclass MyObjB : IMyObj { public string Str; }.Then you want to serialize an object containing a field of type
IEnumerable<object>which right now contains aList<object>, and the list contains some crazy example content like this:[
new object(),myObjAInstance,myObjBInstance,123.45f]Since, at deserializationtime, it would not be clear from just the field type (
IEnumerable<object>) what to instantiate, the first thing that gets written is actual type of the collection. In this case the type isList<object>, so it actually writes two separate types. The open genericSystem.Collections.Generic.List<>and then its argument:System.Object. After that it just writes the number of elements (here 4), and then each object.Next the elements themselves. To serialize
new object()we'd normally write the typeSystem.Objectdirectly again, but since that was already written once only the ID of that type (here 1, List being 0) has to be emitted, and that's it (Sinceobjectitself actually has no public fields).Serialization continues like this, writing the type for each item, and then uses the specific DynamicFormatter to write out the content (fields) of the object. For
myObjAInstance,myObjBInstancethat'd just be the concrete type name + whatever value (int or string).The float at the end has to have its type written as well as it would otherwise not be clear what formatter to use for reading (you only know
objectat this point). If the container would have been aList<float>no type information would have been written per element, as the collection type itself already tells us the item type!All reference and type IDs are written as int, which is ZigZag encoded and written into the binary as a VarInt (so small numbers only consume 1 byte, using more bytes the larger the number gets, up to 5).
As shown in the example type information for generic types is not written in the usual "mangled form" as that would take up a ton of space and be very redundant.
Example: Serializing an instance of
class C { public object A = (int)5; public List<int> B; }Lets just look at how the types for A and B are written and ignore C and the actual values.
Normally the final binary data would have to contain the very long "mangled type names", which are not only long, but also full of redundant/useless information, like this:
System.Collections.Generic.List1[[System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089`Instead of doing that, all types are written as combinations of "open type + generic arguments", enabling massive space-savings while serializing types.
In our example we _could_ write
System.Int32for the type of A andSystem.Collections.Generic.List<> + System.Int32for the type of B, but there's still one more optimization to do, and that is again caching.We've already written
System.Int32, so the type definition of B can make use of that.It can now simply write
System.Collections.Generic.List<0>the0being the reference to the first written type (int).In this example the savings are small, but with more realistic hierarchies and generic type definitions it makes a massive difference!
(*3) How reference serialization works
While serializing, if the object was encountered before, it simply writes a lookup ID. If not, it writes out
-1+ the new object. While deserializing it reads the ID, if its's -1 the object is added to the known objects list. Note that deserialization uses a list, not a dictionary, so no performance cost while deserializing. This also works for self-referencing objects, and indirectly self-referencing objects (for example an object that references itself through anobject-typed field)(4*) Keeping the reference cache!
Normally that wouldn't make sense, because every serialized object is probably unique in its type-setup (maybe there's a
SpecificTypeAat index 5 of some list at one time, and the other time it is aSpecificTypeBthere ...).But think about networking!
Having a two pairs of serializers at each endpoint (one Serializer/Deserializer-pair for sending, and one for receiving) would allow the networking protocol to be self-optimizing. Instead of having to give each object a pre-defined "NetworkMessageId" or something, the serializer would simply write out the type name in full once, and after that everytime the type is to be written, the CachingFormatters would just emit a single byte (or more if actually needed) for the type. Network-Messages that are sent rarely could be marked as "no type caching", to save ID-space for things that get sent more often (for example you wouldn't want to "allocate" a type-ID for something like
LoginPacket, which is only ever sent once anyway). It's even possible to let the CachingFormatters decide what should get cached by keeping a count of how often something was encountered! That way Messages that often get sent by the server to the client, but never sent from client to server, get an ID assigned to them in the receiving direction, but not in the sending direction, extremely efficient!