December Code

C# collections and generics interview questions, with answers

Collections are where everyday C# performance is won or lost, and generics are what make them type-safe without boxing. Interview questions check that you pick the right structure, read a dictionary without exceptions, know what a HashSet needs from its elements, and understand the constraints and variance rules that decide which generic code compiles. Every answer below comes with code whose output was produced by compiling and running it on .NET 10.

The questions start with lists, arrays, dictionaries and sets, move through the collection interfaces, and end with the rules that govern generic types. Then take the free C# diagnostic — ten questions across every C# topic in the bank — to see which of these you can explain but not yet predict.

The questions, with answers

  1. 1.What is the difference between an array and a List<T> in C#?

    In short: An array has a fixed length set at creation; List<T> wraps an array that it grows automatically, adding Add, Insert and Remove at the cost of a little overhead.

    An array, such as int[], is allocated with a fixed length and never resizes, which makes it the lightest collection and the right choice when the size is known. List<T> keeps an internal array plus a count; Add is amortised O(1) because when the array fills, the list allocates one twice as large and copies the elements across, so Capacity jumps while Count grows by one, as below. Indexing is O(1) for both, while Insert and RemoveAt in the middle are O(n), because later elements shift. If you know roughly how many items a list will hold, passing that capacity to the constructor avoids the intermediate copies. Both implement IList<T>, so code written against the interface accepts either.

    var l = new List<int>();
    for (int i = 0; i < 5; i++)
        l.Add(i);
    Console.WriteLine(l.Count);
    Console.WriteLine(l.Capacity);
    // 5
    // 8
  2. 2.How do you read from a Dictionary safely in C#?

    In short: Use TryGetValue, which returns false instead of throwing when the key is missing, or GetValueOrDefault; the indexer throws KeyNotFoundException for a missing key.

    Dictionary<TKey,TValue> is a hash table: lookups, additions and removals are O(1) on average, and keys need consistent GetHashCode and Equals. Reading d[key] for a missing key throws KeyNotFoundException, so code that is not sure a key exists should use TryGetValue, which does one lookup and hands back the value through an out parameter, as below, rather than ContainsKey followed by the indexer, which does two. GetValueOrDefault returns a fallback instead. Writing d[key] = value adds or overwrites, while Add throws if the key already exists. For counting loops, CollectionsMarshal.GetValueRefOrAddDefault updates a value in place with a single lookup.

    var stock =
        new Dictionary<string, int>
        { ["pen"] = 3 };
    if (stock.TryGetValue("ink",
        out int n))
        Console.WriteLine(n);
    else
        Console.WriteLine("none");
    Console.WriteLine(stock
        .GetValueOrDefault("pen"));
    // none
    // 3
  3. 3.What is a HashSet<T> for, and what does it need from its elements?

    In short: It stores unique elements with average O(1) Add and Contains, and it relies on each element's GetHashCode and Equals, so custom types must override both consistently.

    HashSet<T> answers 'have I seen this?' in constant time on average, and Add returns false when the element is already present, which makes deduplication one line. It also offers set algebra: UnionWith, IntersectWith, ExceptWith and IsSubsetOf. Membership is decided by the element's GetHashCode and Equals, or by an IEqualityComparer passed to the constructor, such as StringComparer.OrdinalIgnoreCase. A class that does not override them uses reference identity, so two objects with equal data are both kept, while a record, whose equality is generated from its values, deduplicates as expected, as below. Changing an element in a way that changes its hash after adding it makes it impossible to find.

    var seen = new HashSet<Pair>();
    seen.Add(new Pair(1, 2));
    bool added =
        seen.Add(new Pair(1, 2));
    Console.WriteLine(
        $"{added} {seen.Count}");
    
    record Pair(int A, int B);
    // False 1
  4. 4.What is the difference between IEnumerable<T>, ICollection<T> and IList<T>?

    In short: IEnumerable<T> can only be iterated, ICollection<T> adds Count, Add, Remove and Contains, and IList<T> adds indexing; a method should accept the smallest one it needs.

    The interfaces form a ladder. IEnumerable<T> offers one thing, GetEnumerator, so it can be looped over with foreach and queried with LINQ, and it may even be an infinite or lazily computed sequence. ICollection<T> adds Count, Add, Remove, Contains and CopyTo, and IList<T> adds an indexer, IndexOf, Insert and RemoveAt, while IReadOnlyCollection<T> and IReadOnlyList<T> offer read-only views. A method should accept the least specific interface it needs, often IEnumerable<T> or IReadOnlyList<T>, so callers can pass arrays, lists or query results, as the three calls below do. It should also avoid enumerating an IEnumerable<T> parameter twice, since that may rerun an expensive query.

    Console.WriteLine(Total(
        new[] { 1, 2, 3 }));
    Console.WriteLine(Total(
        new List<int> { 4, 5 }));
    Console.WriteLine(Total(
        Enumerable.Range(1, 4)));
    
    static int Total(
        IEnumerable<int> xs)
    {
        int t = 0;
        foreach (int x in xs)
            t += x;
        return t;
    }
    // 6
    // 9
    // 10
  5. 5.What are generic constraints in C#?

    In short: where clauses tell the compiler what a type argument must support, such as a base class, an interface, new(), class or struct, so the generic code can use those members.

    Inside a generic method, the compiler allows only operations that every possible T supports, which without constraints is little more than the members of object. A constraint narrows T and unlocks members: where T : IComparable<T> allows CompareTo, as in Clamp below; where T : Animal allows Animal's members; where T : new() allows new T(); and class and struct restrict T to reference or value types, with notnull, unmanaged and enum refining that further. Since C# 11 an interface can declare static abstract members, which is how where T : INumber<T> lets generic math use operators such as +. Constraints are checked where the generic is used, so a call with an unsuitable type fails to compile at the call site.

    Console.WriteLine(
        Clamp(15, 0, 10));
    Console.WriteLine(
        Clamp("m", "a", "k"));
    
    static T Clamp<T>(T v, T lo,
        T hi)
        where T : IComparable<T>
    {
        if (v.CompareTo(lo) < 0)
            return lo;
        return v.CompareTo(hi) > 0
            ? hi : v;
    }
    // 10
    // k
  6. 6.What are covariance and contravariance in C# generics?

    In short: A covariant interface, marked out T, lets an IEnumerable<string> be used as an IEnumerable<object>; a contravariant one, marked in T, lets an Action<object> be used as an Action<string>.

    Generic types are invariant by default: a List<string> is not a List<object>, because a List<object> would accept Add(42) and break the list of strings. Interfaces and delegates can opt in to variance where that cannot happen. IEnumerable<out T> only produces T values, so treating a sequence of strings as a sequence of objects is safe. Action<in T> and IComparer<in T> only consume T, so something that accepts any object can stand in where a string handler is expected, as below. Variance applies only to reference-type arguments, so an IEnumerable<int> is not an IEnumerable<object>, because converting each int would mean boxing it.

    IEnumerable<object> xs =
        new List<string> { "a" };
    Action<object> any = o =>
        Console.WriteLine(o);
    Action<string> onText = any;
    onText("in T works");
    Console.WriteLine(xs.Count());
    // in T works
    // 1
  7. 7.Why can't you add to or remove from a List<T> while looping over it with foreach?

    In short: The list's enumerator checks a version number on every step, and any Add or Remove changes it, so the next step throws InvalidOperationException rather than skip or repeat items.

    List<T> increments an internal version on every modification, and its enumerator records the version when the loop starts. If the list changes during the loop, the next step detects the mismatch and throws InvalidOperationException, Collection was modified, instead of silently skipping or revisiting elements, as the second part below shows. To remove items, use RemoveAll with a predicate, as in the first part, loop backwards with an index, or build a new list with Where and ToList. Dictionary behaves the same way for additions, though since .NET Core 3.0 removing entries during enumeration is allowed. The concurrent collections, such as ConcurrentDictionary, permit modification during enumeration because other threads are expected to change them.

    var nums = new List<int>
        { 1, 2, 3, 4, 5 };
    nums.RemoveAll(n => n > 3);
    Console.WriteLine(
        string.Join(",", nums));
    try
    {
        foreach (var n in nums)
            nums.Add(n);
    }
    catch (Exception e)
    {
        Console.WriteLine(
            e.GetType().Name);
    }
    // 1,2,3
    // InvalidOperationException

How the diagnostic asks it

One question from the C# bank, exactly as a sitting would show it. The bank has 3 on collections & generics and 30 across C#.

Collections & Generics · easyCSHARP-020

What happens when this C# code runs?

var d = new Dictionary<string, int>();
d["a"] = 1;
d["a"] = 2;
Console.WriteLine($"{d.Count} {d["a"]}");
Console.WriteLine(d["b"]);
  1. 1It prints 2 2, then 0
  2. 2It prints 1 2, then throws a KeyNotFoundExceptioncorrect
  3. 3It prints 1 2, then 0
  4. 4It throws an ArgumentException at the second assignment

Assigning through the indexer adds the key if it is missing and overwrites its value if it exists, so d["a"] = 2 replaces 1 and the dictionary still has one entry: 1 2. Reading a missing key through the indexer throws KeyNotFoundException; the dictionary never inserts or returns a default by itself. Printing 0 assumes a default is returned, which is what TryGetValue and GetValueOrDefault are for. 2 2 counts the overwrite as a second entry. An ArgumentException is what d.Add("a", 2) throws for a duplicate key; the indexer overwrites instead.

Measure it

Reading answers tells you what’s true. A diagnostic tells you what you get wrong.

10 C# questions across its topics, easy to hard, about fifteen minutes. You get a readiness figure with the arithmetic shown, the topics you missed named, and a practice set sized for today. Free: 1 diagnostic a month and 15 problems a day. No card.

What the readiness test measures · how the score is computed

By Harshit · updated