Search code examples
c#runtime.net-4.8base-classdowncast

How to downcast back a non-generic base-class object to a generic derived-class object at runtime (generic type unknown at compile time)


I have two classes: a non-generic base class Node and a generic derived class Node<T>. The nodes build a chained list, each node containing eventually a different data type.

    public class Node{  
      public Node NextNode{get; set;}  
      public static Node<T> WithData<T>(T data) => new Node<T>(data);  
    }  
    public class Node<T>: Node{  
      public T Data{get; set;}  
      public Node(T data) => Data = data;  
    }  

The classes can be used like this:

    var node1 = Node.WithData(73);      // the node contains an int  
    var node2 = Node.WithData("Good luck!");    // the node contains a string  
    node1.NextNode = node2;         // chaining the nodes  
    Notify(node1.Data == 73));  // displays "true" (method Notify() omitted for brevity)  
    Notify(node1.NextNode.GetType());       // displays "Node`1[System.String]"  
    Notify(node1.NextNode.Data == "Good luck!");  
    //compile error: 'Node' does not contain a definition for 'Data'  

The compiler complains, because NextNode is upcasted to Node and is not anymore of type Node<T>, even though GetType() shows the right generic type.

I can solve the problem by downcasting back to Node<T> (type checks omitted for brevity):

   Notify(((Node<string>)node.NextNode).Data);  
   // verbose and the casting type is usually not known at compile time  
    Notify(((dynamic)node.NextNode).Data); // slow, but works fine at runtime
    if(node.NextNode is Node<string> stringNode) Notify(stringNode.Data);  
    else if(node.NextNode is Node<int> intNode) Notify(intNode.Data);  
    // else if... etc.  
    // bad for maintenance, verbose, especially with many data types  

My questions are:

  • Is there a more suitable way to downcast Node to Node<T> (preferably at runtime?

  • Is there a better approach or a better design of the two classes to avoid or automate the downcasting?


Solution

  • Generics are about type safety. They allow you to declare different variants of a type at compile-time. Safety is guaranteed by the compiler at compile-time.

    Therefore, generics do not provide an advantage in a dynamic scenario. In a dynamic scenario like yours, where a Node<T> can have different Ts determined at runtime, data typed as object does the job just as well and is easier to manage.

    What is the advantage of if (node is Node<string> ns) UseString(ns.Data); over if (node.Data is string s) UseString(s);? None!

    Type your data as object. Period!

    public class Node (object data) // Primary constructor
    {
        public Node Next { get; set; }
        public object Data { get; set; } = data;
    }
    

    Note: The primary constructor was introduced in C# 12.0 and can be used in .NET 4.8, if you add a <LangVersion>latest</LangVersion> in a property group of your *.csproj file (it's just syntactic sugar).


    I would like to add a thought on the concept of these nodes in general.

    Linked together, the nodes represent a collection. The collection should be a class on its own and would handle all the node relates things (like adding and removing nodes or iterating the collection). The collection would be generic and all the nodes would have data of the same generic type.

    Dealing with different data types should be delegated to the data itself. It has nothing to do with nodes or linked lists. For this, we would create an independent type hierarchy for the data only. E.g.:

    // Non generic base class as common access point.
    public abstract class Data
    {
        public abstract object Value { get; }
    
        // Can be called no matter what the concrete derived type will be.
        public abstract void DoSomethingWithValue();
    
        public override bool Equals(object obj)
            => obj is Data data &&
                EqualityComparer<object>.Default.Equals(Value, data.Value);
    
        public override int GetHashCode() => HashCode.Combine(Value);
    
        public override string ToString() => Value?.ToString();
    }
    
    public abstract class TypedData<T>(T value) : Data
    {
        public override object Value => TypedValue;
    
        // Introducing a strongly typed value
        public T TypedValue { get; set; } = value;
    }
    
    public class StringData(string s) : TypedData<string>(s)
    {
        public override void DoSomethingWithValue()
        {
            TypedValue += " World";
        }
    }
    
    public class IntData(int i) : TypedData<int>(i)
    {
        public override void DoSomethingWithValue()
        {
            TypedValue += 10;
        }
    }
    

    With these example classes, the node data should be of type Data. This allows us to call node.Data.DoSomethingWithValue(); no matter whether a StringValue or a IntValue was added to a node.

    If we don't know the type of a node's data, we can get the value through the node.Data.Value property as an object.

    If we have a data object of a concrete type, we can get or set a strongly typed value through the TypedValue property:

    var data = new StringData("Hello");
    data.DoSomethingWithValue();
    string s = data.TypedValue;
    data.TypedValue = "another value";
    

    Here is a possible implementation of the collection. Note that the Node class is hidden inside, so that we cannot mess around with node from the outside.

    public class LinkedList<T> : ICollection<T>
    {
        private class Node(T data)
        {
            public Node Next { get; set; }
    
            public T Data { get; set; } = data;
        }
    
        private Node _head;
        private Node _tail;
    
        public T HeadData => _head is null ? default : _head.Data;
        public T TailData => _tail is null ? default : _tail.Data;
    
        public int Count { get; private set; }
    
        public bool IsReadOnly => false;
    
        public void Add(T item)
        {
            var node = new Node(item);
            if (_head is null) {
                _head = node;
                _tail = node;
            } else {
                _tail.Next = node;
                _tail = node;
            }
            Count++;
        }
    
        public void InsertHead(T item)
        {
            var node = new Node(item);
            if (_head is null) {
                _head = node;
                _tail = node;
            } else {
                node.Next = _head;
                _head = node;
            }
            Count++;
        }
    
        public void Clear()
        {
            _head = null;
            _tail = null;
            Count = 0;
        }
    
        public bool Contains(T item)
        {
            var node = _head;
            while (node is not null) {
                if (item is null && node.Data is null || node.Data?.Equals(item) == true) {
                    return true;
                }
                node = node.Next;
            }
            return false;
        }
    
        public void CopyTo(T[] array, int arrayIndex)
        {
            var node = _head;
            while (node != null && arrayIndex < array.Length) {
                array[arrayIndex++] = node.Data;
                node = node.Next;
            }
        }
    
        public IEnumerator<T> GetEnumerator()
        {
            var node = _head;
            while (node != null) {
                yield return node.Data;
                node = node.Next;
            }
        }
    
        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    
        public bool Remove(T item)
        {
            Node prev = null;
            var node = _head;
            while (node is not null) {
                if (item is null && node.Data is null || node.Data?.Equals(item) == true) {
                    if (prev is null) {
                        _head = node.Next;
                    } else {
                        prev.Next = node.Next;
                    }
                    if (_tail == node) {
                        _tail = prev;
                    }
                    Count--;
                    return true;
                }
                prev = node;
                node = node.Next;
            }
            return false;
        }
    }
    

    Usage example:

    var list = new LinkedList<Data>();
    list.Add(new StringData("Hello"));
    list.Add(new IntData(42));
    foreach (Data data in list) {
        object before = data.Value;
        data.DoSomethingWithValue();
        object after = data.Value;
        Console.WriteLine($"{before} => {after}");
    }
    

    If we add these two implicit operators to the Data class:

    public static implicit operator Data(string s) => new StringData(s);
    public static implicit operator Data(int i) => new IntData(i);
    

    We can now simply write:

    var list = new LinkedList<Data>();
    list.Add("Hello");
    list.Add(42);
    

    The value "Hello" will automatically be converted to a StringData and the value 42 to an IntData.

    To allow the inverse conversion, we can add this line to the TypedData<T> class:

    public static implicit operator T(TypedData<T> data) => data.TypedValue;
    

    Then we can write:

    TypedData<string> tdata = new StringData("Hello");
    string s = tdata;
    

    But this does not work with data typed as Data, because then the type is unknown at compile-time.