C#:序列化不同值类型的容器最合适的设计是什么?

C#: what is the most proper design for serializing containers of different value types?

我最近开始更多地使用 C#,而在此之前我的大部分背景都是 C++,而我刚刚发现自己 运行 遇到的其中一件事是一个尴尬的问题,涉及序列化。特别是,我正在与另一个第三方(非开源因此无法修改)程序连接,该程序提供自己的序列化例程集,其签名如

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

你懂的,每种原始类型都有一个重载。不过,现在的诀窍是我想在此基础上编写一个包装器来序列化一个 Dictionary<K, V>,其中键 (K) 和值 (V) 可以是任何原始类型。

现在在具有编译时模板的 C++ 中,这很容易做到:

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
        // ...
     }
}

这是有效的,因为模板是一个代码生成器,它为每个实例化生成单独的代码,所以如果你为一个 std::map<int, float> 实例化它,它会自动找出需要调用哪些不同的第三方例程来实现奇迹,如果有人传递了一些会阻塞的东西,编译器会在编译 fcode 时轻易阻塞。但是,在 C# 中,类似的功能,泛型,不是代码生成器,而是纯粹的 运行 时间功能。

在谷歌搜索时,我读到了类似的帖子:

C# generics: cast generic type to value type

这基本上是有人问要为非常相似的东西在通用参数上做“类型切换”,他们在答案中被告知基本上 - 对像我这样的人来说非常无益,他们一直在燃烧对于一个明确的替代方案——这是“糟糕的设计”,更难的是,这是“非常糟糕的代码味道”。我明白为什么,但另一方面, 是什么 替代方案,特别是考虑到在我的情况下您正在处理第三方代码?

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);
      } // ...
      // ...
   }
}

因为肯定不可能是这样的:

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 ...

毕竟,“数十到数百个重复的方法”不会至少与类型转换一样大的“代码味道”吗?那么,这个案例的正确设计方法是什么样的呢?需要调用的东西需要用适当的类型调用,毕竟,DRY 也是一种代码味道,我认为尤其是组合数字。

或者这是 C# 的固有限制,没有很好的解决方法?请注意,对我来说,一个“理想”的解决方案基本上不会编写比第 3 方程序中的序列化调用更多的方法。

这是 C# 的局限性,据我所知目前没有令人满意的解决方法。最简单的“技巧”是使用 (dynamic) 强制转换。这样做的缺点是您失去了编译时安全性和一些运行时性能。根据您的项目,这些缺点可能是可以接受的。

真正应该是编译时错误的现在是运行时异常。 (通常这对我来说不是什么大问题,因为可能会出现无数其他异常,现在只有一个,单元测试可以检测到它)

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 ... }

是的,为每个可能的变体手动编写重载可能是提高性能和可读性的最佳选择。如果它几乎每个方法都相同,那么至少查找和替换可以更新您的代码。这是一种代码味道,但无法解决。有时存在重复代码只是尝试使其易于管理。不同代码的味道比其他代码更糟 高耦合比受控重复更难处理。

如果确实有大量过载,您可以使用 Source Generators,但是,我觉得它们会增加更多的复杂性,而不是减少它们。

完整示例(使用动态)

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; }
}