Search code examples
c#genericscovariance

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


I'm currently modifying a Blazor library and the souce code of the current state is available on gitlab.

My situation is as follows:

I have a LineChartData object which is supposed to store multiple Datasets for LineCharts.
These Datasets intern have a List of Data. Instead of just working with List<object> I wanted to be able to have List<TData>.
Because there is a Mixed Chart which can accept both LineChartDatasets and BarChartDatasets, there is an interface called IMixableDataset.

I started by making this interface generic so it now looks like this (simplified):

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

I then made my implementing class (LineChartDataset) generic as well and it now looks like this (simplified):

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

Next up was LineChartData. I first made this generic as well and continued with that until I reached the top level (see current state of my master branch). However I later wanted to change this because I wanted to support multiple Datasets with different kind of values. For this reason I reverted the generic stuff in all classes "above" the Datasets and the LineChartData now looks like this (simplified):

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

I decided to go with LineChartDataset<object> because: Since everything is castable to object, (in my mind) XYZ<Whatever> should also be castable to XYZ<object> but as I learned, this is not the case.

The where keyword didn't help either since I don't want to enforce TData to have relations apart from object - it could be int, string or something completely different. The only relation these LineDatasets are supposed to have is that they are LineDatasets, not what type they contain.

I then learned about Covariance and Contravariance (out and in-keyword). I tried out to make TData in IMixableDataset covariant but since List and IList/ICollection are all invariant I was unable to persue.
I also read about IReadOnlyCollection<> which is covariant but I cannot use this because I have to be able to modify the list after creation.

I have also tried using implicit/explicit operators to convert LineChartDataset<whatever> to LineChartDataset<object> but this has a few issues:

  • Since I created a new instance, I would need to store and use the new instance instead of the original one to add items, completely destroying the typesafety I had with the original one.
  • Since there are many more properties in LineChartDataset I would have to clone all of them as well.

If there is a way to convert a more specific one to the other while preserving the instance and not having to write code for every property this might be a solution.

Complete sample which reproduces the error I get and shows the issue:

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

The reason everything only has getters is because of my initial attempt with using out. Even thought this didn't work out, I learned that you usually don't expose Setters for Collection-properties. This is not fix and also not very important for the question but I think worth mentioning.

Second complete example. Here I'm using out and an IReadOnlyCollection. I have removed the descriptions of the class (already visible in the previous example) to make it shorter.

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

So the question:
Is there anyway to have a mutable and covariant collection?
If not, is there a workaround or something I can do to achieve this functionality?

Additional stuff:

  • I'm using the newest version of everything (.net core, VS, blazor, C#). Since the library is .NET Standard I'm still on C# 7.3 there.
  • In the repo under WebCore/Pages/FetchData you can perfectly see what I want to achieve (see comments at the end of the file).

Solution

  • Looking more closely at your example, I see one major problem: you are attempting to involve value types (e.g. int) in type variance. For better or worse, C# type variance applies only to reference types.

    So, no…sorry, but it is quite impossible to do exactly what you're asking. You would have to represent all value-type based collections as object, not as their specific value types.

    Now, as far as reference-type collections go, your example will work fine, with one minor change. Here's a modified version of your second example showing it working, with that one minor change:

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

    In particular, note that I've added an AddRange() method to your LineChartDataset<TData> class. This provides a type-safe way to mutate the collection. Note that the code that wants to mutate the collection must know the correct type, bypassing the variance restrictions.

    The variant interface IMixableDataset<TData> itself cannot, of course, include a way to add things, because this would not be type-safe. You would be able to treat your LineChartDataset<string> as a IMixableDataset<object>, and then if you could add things via that interface, you'd be able to add some other type of object, even a non-reference type like a boxed int value, to your collection that's supposed to only contain string objects.

    But, just as the invariant List<T> can implement the covariant IReadOnlyCollection<T>, your concrete LineChartDataset<TData> class can implement IMixableDataset<TData> while still providing a mechanism for adding items. This works because while the concrete type determines what the object can actually do, the interfaces simply define a contract that users of the reference must abide by, allowing the compiler to ensure type safety where the interface is used, even when used in a variant way. (The invariant concrete type ensures type safety as well, but only because the type has to match exactly, which is of course more restrictive/less flexible.)

    If you don't mind using object in place of any specific value type for the value-type-based collections, then the above would work. It's a bit clumsy, since any time you actually want to get the value type values out, you'd need to retrieve them as object and then cast as necessary to actually use them. But at least the broader variant approach would then succeed, and no special handling would be required for any reference types.


    Aside: that type variance in C# is restricted to reference types is based on the pragmatic requirement that type variance doesn't affect the runtime code. It's just a compile-time type-conversion. This means that you have to be able to just copy references around. To support value types would require adding new boxing and unboxing logic where it otherwise wouldn't exist. It's also not quite as useful, because value types don't have the same rich degree of type inheritance that reference types can have (value types can only ever inherit object, so variant scenarios are much less useful and interesting, in general).