创建和使用协变和可变列表(或潜在的解决方法)

Create and use covariant and mutable list (or potential workaround)

我目前正在修改 Blazor library and the souce code of the current state is available on gitlab

我的情况如下:

我有一个 LineChartData 对象,它应该为折线图存储多个数据集。
这些数据集实习生有一个数据列表。而不是仅仅使用 List<object> 我希望能够拥有 List<TData>.
因为有一个可以接受 LineChartDatasets 和 BarChartDatasets 的混合图表,所以有一个名为 IMixableDataset 的接口。

我首先让这个接口变得通用,所以它现在看起来像这样(简化):

public interface IMixableDataset<TData>
{
    List<TData> Data { get; }
}

然后我使我的实现 class (LineChartDataset) 也通用,现在它看起来像这样(简化):

public class LineChartDataset<TData> : IMixableDataset<TData>
{
    public List<TData> Data { get; }
}

接下来是 LineChartData。我首先也制作了这个通用的,并继续这样做,直到我达到顶层(见我的主分支的当前状态)。但是我后来想改变这一点,因为我想支持具有不同类型值的多个数据集。出于这个原因,我还原了所有 classes "above" 数据集中的通用内容,LineChartData 现在看起来像这样(简化):

public class LineChartData
{
    // HashSet to avoid duplicates
    public HashSet<LineChartDataset<object>> Datasets { get; }
}

我决定使用 LineChartDataset<object> 因为:既然一切都可以转换为对象,(在我看来)XYZ<Whatever> 也应该可以转换为 XYZ<object> 但据我了解,这事实并非如此。

where 关键字也没有帮助,因为我不想强制 TDataobject 之外的关系 - 它可能是 intstring 或完全不同的东西。这些 LineDataset 应该具有的唯一关系是它们是 LineDataset,而不是它们包含什么类型。

然后我了解了协变和逆变(out 和 in-关键字)。我试图使 IMixableDataset 中的 TData 协变,但由于 ListIList/ICollection 都是不变的,我无法说服。
我还读到 IReadOnlyCollection<> 是协变的,但我不能使用它,因为我必须能够在创建后修改列表。

我也尝试过使用 implicit/explicit 运算符将 LineChartDataset<whatever> 转换为 LineChartDataset<object> 但这有几个问题:

如果有一种方法可以将更具体的一个转换为另一个同时保留实例并且不必为每个 属性 编写代码,这可能是一个解决方案。

重现我遇到的错误并显示问题的完整示例:

// Provides access to some Data of a certain Type for multiple Charts
public interface IMixableDataset<TData>
{
    List<TData> Data { get; }
}

// Contains Data of a certain Type (and more) for a Line-Chart
public class LineChartDataset<TData> : IMixableDataset<TData>
{
    public List<TData> Data { get; } = new List<TData>();
}

// Contains Datasets (and more) for a Line-Chart
// This class should not be generic since I don't want to restrict what values the Datasets have. 
// I only want to ensure that each Dataset intern only has one type of data.
public class LineChartData
{
    // HashSet to avoid duplicates and Public because it has to be serialized by JSON.Net
    public HashSet<LineChartDataset<object>> Datasets { get; } = new HashSet<LineChartDataset<object>>();
}

// Contains the ChartData (with all the Datasets) and more
public class LineChartConfig
{
    public LineChartData ChartData { get; } = new LineChartData();
}

public class Demo
{
    public void DesiredUseCase()
    {
        LineChartConfig config = new LineChartConfig();

        LineChartDataset<int> intDataset = new LineChartDataset<int>();
        intDataset.Data.AddRange(new[] { 1, 2, 3, 4, 5 });

        config.ChartData.Datasets.Add(intDataset);
        // the above line yields following compiler error:
        // cannot convert from 'Demo.LineChartDataset<int>' to 'Demo.LineChartDataset<object>'

        // the config will then get serialized to json and used to invoke some javascript
    }

    public void WorkingButBadUseCase()
    {
        LineChartConfig config = new LineChartConfig();

        LineChartDataset<object> intDataset = new LineChartDataset<object>();
        // this allows mixed data which is exactly what I'm trying to prevent
        intDataset.Data.AddRange(new object[] { 1, 2.9, 3, 4, 5, "oops there's a string" });

        config.ChartData.Datasets.Add(intDataset); // <-- No compiler error

        // the config will then get serialized to json and used to invoke some javascript
    }
}

所有东西都只有 getter 的原因是因为我最初尝试使用 out。即使认为这没有成功,我了解到您通常不会公开 Collection-properties 的 Setter。这不是解决问题的问题,也不是很重要,但我认为值得一提。

第二个完整示例。这里我使用 out 和一个 IReadOnlyCollection。我删除了 class 的描述(在前面的示例中已经可见)以使其更短。

public interface IMixableDataset<out TData>
{
    IReadOnlyCollection<TData> Data { get; }
}

public class LineChartDataset<TData> : IMixableDataset<TData>
{
    public IReadOnlyCollection<TData> Data { get; } = new List<TData>();
}

public class LineChartData
{
    public HashSet<IMixableDataset<object>> Datasets { get; } = new HashSet<IMixableDataset<object>>();
}

public class LineChartConfig
{
    public LineChartData ChartData { get; } = new LineChartData();
}

public class Demo
{
    public void DesiredUseCase()
    {
        LineChartConfig config = new LineChartConfig();

        IMixableDataset<int> intDataset = new LineChartDataset<int>();
        // since it's ReadOnly, I of course can't add anything so this yields a compiler error.
        // For my use case, I do need to be able to add items to the list.
        intDataset.Data.AddRange(new[] { 1, 2, 3, 4, 5 }); 

        config.ChartData.Datasets.Add(intDataset);
        // the above line yields following compiler error (which fairly surprised me because I thought I correctly used out):
        // cannot convert from 'Demo.IMixableDataset<int>' to 'Demo.IMixableDataset<object>'
    }
}

所以问题:
有没有可变和协变的集合?
如果没有,是否有解决方法或我可以做些什么来实现此功能?

其他内容:

仔细观察您的示例,我发现了一个主要问题:您试图在类型变体中涉及值类型(例如 int)。无论好坏,C# 类型差异仅适用于 引用类型。

所以,不……抱歉,完全按照您的要求做是不可能的。您必须将所有基于 value-type 的集合表示为 object,而不是它们的特定值类型。

现在,就 reference-type 个集合而言,您的示例可以正常工作,只需稍作更改。这是你的第二个例子的修改版本,显示它可以工作,有一个小的变化:

public interface IMixableDataset<out TData>
{
    IReadOnlyCollection<TData> Data { get; }
}

public class LineChartDataset<TData> : IMixableDataset<TData>
{
    private readonly List<TData> _list = new List<TData>();

    public IReadOnlyCollection<TData> Data => _list;

    public void AddRange(IEnumerable<TData> collection) => _list.AddRange(collection);
}

public class LineChartData
{
    public HashSet<IMixableDataset<object>> Datasets { get; } = new HashSet<IMixableDataset<object>>();
}

public class LineChartConfig
{
    public LineChartData ChartData { get; } = new LineChartData();
}

public class Demo
{
    public void DesiredUseCase()
    {
        LineChartConfig config = new LineChartConfig();

        // Must use reference types to take advantage of type variance in C#
        LineChartDataset<string> intDataset = new LineChartDataset<string>();

        // Using the non-interface method to add the range, you can still mutate the object
        intDataset.AddRange(new[] { "1", "2", "3", "4", "5" });

        // Your original code works fine when reference types are used
        config.ChartData.Datasets.Add(intDataset);
    }
}

请特别注意,我已将 AddRange() 方法添加到您的 LineChartDataset<TData> class。这提供了一种 type-safe 改变集合的方法。请注意,想要改变集合的代码必须知道正确的类型,绕过方差限制。

当然,变体接口 IMixableDataset<TData> 本身不能包含添加内容的方法,因为这不会是 type-safe。您可以将 LineChartDataset<string> 视为 IMixableDataset<object>,然后如果您可以通过该界面添加内容,则可以添加其他类型的对象,甚至是 non-reference 像盒装的 int 值一样键入您的集合,该集合应该只包含 string 个对象。

但是,正如不变量 List<T> 可以实现协变 IReadOnlyCollection<T>,您的具体 LineChartDataset<TData> class 可以实现 IMixableDataset<TData>,同时仍然提供一种机制用于添加项目。这是可行的,因为虽然具体类型决定了对象实际可以做什么,但接口只是定义了引用用户必须遵守的契约,允许编译器确保使用接口的类型安全,即使以变体方式使用时也是如此. (不变的具体类型也确保了类型安全,但这只是因为类型必须 完全匹配 ,这当然更 restrictive/less 灵活。)

如果您不介意使用 object 代替基于 value-type 的集合的任何特定值类型,则上述方法可行。这有点笨拙,因为任何时候您实际上想要获取值类型值,您都需要将它们检索为 object ,然后根据需要强制转换以实际使用它们。但至少更广泛的变体方法会成功,并且不需要对任何引用类型进行特殊处理。


旁白:C# 中的类型差异仅限于引用类型是基于类型差异不影响运行时代码的实用要求。这只是一个 compile-time type-conversion。这意味着您必须能够复制引用。要支持值类型,需要在不存在的地方添加新的装箱和拆箱逻辑。它也不是很有用,因为值类型不具有引用类型所具有的丰富程度的类型继承(值类型只能继承 object,因此变体场景的用处和趣味性要小得多,在一般)。