Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to make readonly structs XML serializable?

I have an immutable struct with only one field:

struct MyStruct
{
    private readonly double number;

    public MyStruct(double number)
        => this.number = number;
}

And I want this to be able to get serialized/deserialized by:

  • Data contract serializer
  • Binary formatter
  • XML serializer (edit: forgotten in the original question)
  • Json.NET (without adding Json.NET as a dependency)

So the struct becomes this:

[Serializable]
struct MyStruct : ISerializable, IXmlSerializable
{
    private readonly double number;

    public MyStruct(double number)
        => this.number = number;

    private MyStruct(SerializationInfo info, StreamingContext context)
        => this.number = info.GetDouble(nameof(this.number));

    void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context)
        => info.AddValue(nameof(this.number), this.number);

    XmlSchema IXmlSerializable.GetSchema() => null;

    void IXmlSerializable.ReadXml(XmlReader reader)
    {
        // Necessary evil
        reader.Read();
        this = new MyStruct(double.Parse(reader.Value, CultureInfo.InvariantCulture));
    }

    void IXmlSerializable.WriteXml(XmlWriter writer)
        => writer.WriteString(this.number.ToString(CultureInfo.InvariantCulture));
}

Because:

  • [Serializable] is required by the binary formatter.
  • Json.NET honors both [DataContract] and ISerializable.
  • [DataContract] and ISerializable can't be used together.
  • Luckily, IXmlSerializer is supported by the data contract serializer.

C# 7.2 introduces the readonly modifier for structs and MyStruct, being an immutable struct seems like an ideal candidate for this.

The problem is that IXmlSerializable interface requires the ability to mutate MyStruct. That's what we did above, assigning to this in IXmlSerializable.ReadXml implementation.

readonly struct MyStruct : IXmlSerializable
{
    // ...
    void IXmlSerializable.ReadXml(XmlReader reader)
    {
        // No longer works since "this" is now readonly.
        reader.Read();
        this = new MyStruct(double.Parse(reader.Value, CultureInfo.InvariantCulture));
    }
    // ...
}

I tried cheating via reflection but FieldInfo.SetValue boxes the value, and FieldInfo.SetValueDirect requires a TypedReference, which I can't obtain since __makeref is also forbidden when this is read-only.

So what are ways that would allow MyStruct to get serialized by the XML serializer?

I should also mention that I do not care what the output XML looks like, I don't really need the fine grained control provided by the IXmlSerializable interface. I only need to make MyClass consistently serializable using the serializers I listed.

like image 785
Şafak Gür Avatar asked Dec 05 '17 07:12

Şafak Gür


2 Answers

While you can successfully use unsafe, Unsafe.AsRef, or FieldInfo.SetValue to mutate the value in some scenarios, this is technically invalid code and may result in undefined behavior.

From ECMA-335:

[Note: The use of ldflda or ldsflda on an initonly field makes code unverifiable. In unverifiable code, the VES need not check whether initonly fields are mutated outside the constructors. The VES need not report any errors if a method changes the value of a constant. However, such code is not valid. end note]

Likewise from the official API Docs for FieldInfo.SetValue:

This method cannot be used to set values of static, init-only (readonly in C#) fields reliably. In .NET Core 3.0 and later versions, an exception is thrown if you attempt to set a value on a static, init-only field.

The runtime is technically free to make optimizations around initonly fields and currently does in the case of certain static, initonly fields.

You might be interested in the new init only setters feature coming in C# 9 (https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-9#init-only-setters). This provides a valid way to set properties as part of the property initializer syntax and will get the appropriate support/changes to ensure they work successfully and result in valid code.

like image 192
Tanner Gooding Avatar answered Sep 22 '22 02:09

Tanner Gooding


To satisfy your requirements all you need is:

[Serializable]
[DataContract]
public readonly struct MyStruct {
    [DataMember]
    private readonly double number;

    public MyStruct(double number)
        => this.number = number;
}

Test code:

var target = new MyStruct(2);
// with Data Contract serializer
using (var ms = new MemoryStream()) {
    var s = new DataContractSerializer(typeof(MyStruct));
    s.WriteObject(ms, target);
    ms.Position = 0;
    var back = (MyStruct) s.ReadObject(ms);
    Debug.Assert(target.Equals(back));
}

// with Json.NET
var json = JsonConvert.SerializeObject(target);
var jsonBack = JsonConvert.DeserializeObject<MyStruct>(json);
Debug.Assert(target.Equals(jsonBack));

// with binary formatter
using (var ms = new MemoryStream()) {
    var formatter = new BinaryFormatter();
    formatter.Serialize(ms, target);
    ms.Position = 0;
    var back = (MyStruct) formatter.Deserialize(ms);
    Debug.Assert(target.Equals(back));
}

Update. Since you also need to support XmlSerializer, you can use some unsafe code to achieve your requirements:

[Serializable]    
public readonly struct MyStruct : ISerializable, IXmlSerializable
{        
    private readonly double number;
    public MyStruct(double number)
        => this.number = number;

    private MyStruct(SerializationInfo info, StreamingContext context)
        => this.number = info.GetDouble(nameof(this.number));

    XmlSchema IXmlSerializable.GetSchema() {
        return null;
    }

    unsafe void IXmlSerializable.ReadXml(XmlReader reader) {
        if (reader.Read()) {
            var value = double.Parse(reader.Value, CultureInfo.InvariantCulture);
            fixed (MyStruct* t = &this) {
                *t = new MyStruct(value);
            }
        }
    }

    void IXmlSerializable.WriteXml(XmlWriter writer) {
        writer.WriteString(this.number.ToString(CultureInfo.InvariantCulture));
    }

    public void GetObjectData(SerializationInfo info, StreamingContext context) {
        info.AddValue(nameof(number), this.number);
    }
}
like image 38
Evk Avatar answered Sep 26 '22 02:09

Evk