Home > Back-end >  C#: what is the most proper design for serializing containers of different value types?
C#: what is the most proper design for serializing containers of different value types?

Time:02-21

I've recently been getting into using C# more, while most of my background prior to this has been in C , and one of the things I just found myself running into was an awkward problem involving serialization. In particular, I am interfacing with another third-party (not open source and so cannot be modified) program which provides its own set of serialization routines with signatures like

public void serialize(string id, int value);
public void serialize(string id, long value);
public void serialize(string id, float value);
...

you get the idea, there's one overload for each primitive type. The trick now, though, is I want to write a wrapper on top of this that serializes a Dictionary<K, V> where the Key (K) and the Value (V) could be any primitive types.

Now in C , which has compile-time templates, this is pretty easy to do:

template<class K, V>
void serializeMap(const std::map<K, V> &map) {
     for(std::map<K, V>::const_iterator it(map.begin()); it != map.end();   it) {
        // ...
        ThirdParty::serialize(keyId, it->first); // compiler figures which overload
        ThirdParty::serialize(valueId, it->second); // compiler figures which overload
        // ...
     }
}

This works because templates are a code generator that generate separate code for each instantiation, so if you instantiate this for, say, an std::map<int, float>, it will automatically figure out which of the different third-party routines need calling to make the magic happen and if someone passes something this would choke on, the compiler will handily choke when compiling the fcode. However, in C# the analogous feature, generics, are not a code generator, but rather a purely run-time feature.

And when googling around, I read this thread on something similar:

C# generics: cast generic type to value type

which basically was where someone was asking about doing a "type switch" on a generic parameter for something very similar, and they were told in the answers that basically - and very unhelpfully for someone like me, who is left burning for an alternative to be made explicit - that this was "bad design" and that even harder, this was a "really bad code smell". I could see why, but on the other hand, what is the alternative, especially given that in my case you are dealing with third-party code, to this?

public static void SerializeDictionary<K, V>(Dictionary<K, V> dict)
{
   // ...
   foreach(KeyValuePair<K, V> kvp in dict) {
      // ...
      if(typeof(K) == typeof(int)) {
          ThirdParty.Serialize(keyId, (int)kvp.Key);
      } else if(typeof(K) == typeof(long)) {
          ThirdParty.Serialize(keyId, (long)kvp.Key);
      } // ...

      if(typeof(V) == typeof(int)) {
          ThirdParty.Serialize(valueId, (int)kvp.Value);
      } // ...
      // ...
   }
}

Because surely it can't be this:

public static void SerializeDictionary(Dictionary<int, int> dict)
{
   // ... virtually identical code ...
}

public static void SerializeDictionary(Dictionary<int, long> dict)
{
   // ... virtually identical code ...
}

// ...

public static void SerializeDictionary(Dictionary<long, int> dict)
{
   // ... virtually identical code ...
}

public static void SerializeDictionary(Dictionary<long, long> dict)
{
   // ... virtually identical code ...
}

// ... possibly dozens to over a hundred repeated methods ...

as, after all, wouldn't "dozens to hundreds of duplicated methods" be at least as big a "code smell" as the type switch? What, then does the proper design methodology for this case look like? What needs to be called needs to be called with the appropriate types, after all, and DRY is a code smell, too, especially with combinatorial numbers, I'd think.

Or is this an inherent limitation of C# for which there is no nice way around? Note that to me, an "ideal" solution would basically not write more methods than there are serialize calls in the 3rd-party program.

CodePudding user response:

This is a limitation of C# and there are currently no satisfactory work around I am aware of. The easiest "hack" is to use a (dynamic) cast. The downside to this is you loose compile time safety and some runtime performance. These downsides depending on your project may be acceptable.

What really should be a compile time error is now a runtime Exception. (Often that's not a huge issue for me because a myriad of other Exception may occur, now there is just one more and Unit Tests can pick it up)

foreach (var pair in dict)
{
    ThirdParty.Serialize("keyId", (dynamic)pair.Key); // at run time I will lookup the correct overload
    ThirdParty.Serialize("valueId", (dynamic)pair.Value); // at run time I will lookup the correct overload
}

Because surely it can't be this:

public static void SerializeDictionary(Dictionary<int, int> dict) { // ... virtually identical code ... }

Yes manually writing an overload for every conceivable variation is probably your best bet for performance and readability. If its virtually every method is the same then at least a find and replace can update your code. It is a code smell, but an unresolvable one. Sometimes duplicate code exists just try make it manageable. Different codes smells are worse than others high coupling is far more difficult to deal with than controlled duplications.

You could use Source Generators if there are truly an unwieldly number of overload, however, I feel like they will add more complexity that they reduce.

Full Example (using dynamic)

var example1 = new Dictionary<int, float>
{
    { 1, 10.10f },
    { 2, 20.20f },
    { 3, 30.30f }
};

var example2 = new Dictionary<int, long>
{
    { 1, long.MaxValue },
    { 2, long.MinValue },
    { 3, 0 }
};

var example3 = new Dictionary<int, Example>
{
    { 1, new Example{ Id = 1, Value = 101 } },
    { 2, new Example{ Id = 2, Value = 202 } },
    { 3, new Example{ Id = 3, Value = 303 } }
};

var runtimExceptionExample = new Dictionary<int, double>
{
    { 1, 10.10 },
    { 2, 20.20 },
    { 3, 30.30 }
};

SerializeDictionary(example1);
SerializeDictionary(example2);
SerializeDictionary(example3);

// Exception thrown at Runtime:
// Unhandled exception. Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
// The best overloaded method match for 'ThirdParty.serialize(string, int)' has some invalid arguments
SerializeDictionary(runtimExceptionExample); // unfortunately this cannot be flagged during compile time

static void SerializeDictionary<TKey, TValue>(Dictionary<TKey, TValue> dict)
    where TKey : notnull
    where TValue : notnull
{
    foreach (var pair in dict)
    {
        ThirdParty.Serialize("keyId", (dynamic)pair.Key); // at run time I will lookup the correct overload
        ThirdParty.Serialize("valueId", (dynamic)pair.Value); // at run time I will lookup the correct overload
    }
}

public static class ThirdParty
{
    public static void Serialize(string id, int value) => Console.WriteLine($"{id}=(int){value}");

    public static void Serialize(string id, long value) => Console.WriteLine($"{id}=(long){value}");

    public static void Serialize(string id, float value) => Console.WriteLine($"{id}=(float){value}");

    public static void Serialize(string id, Example value) => Console.WriteLine($"{id}=(Example){{{value.Id}, {value.Value}}}");
}

public class Example {
    public int Id { get; set; }
    public int Value { get; set; }
}
  • Related