Course outline

C# / Intermediate: Composition and Asynchronous Work / Lesson 7

Generics and Collection Contracts

Reuse algorithms while preserving static relationships, domain limits, and the ownership of collection elements.

About 4 min readContent reviewed
On this page

Lesson goals

  • Explain type parameters and useful constraints.
  • Choose sequence and set abstractions from behavior.
  • Separate generic type safety from copy and concurrency promises.

A generic body has a checked operation set

Generics express a relationship among types without erasing every value to object. A List<int> admits int elements and returns int values without ordinary object boxing for each element. A generic method is checked against what its constraints permit, so an unconstrained T cannot be assumed to support arithmetic or an arbitrary property.

Choose constraints from required behavior, such as IComparable<T> for an ordering operation. A constraint can make a method call legal without proving that every implementation has a useful domain order. Error behavior, null handling, and consistency remain semantic obligations. More constraints are not automatically better if they exclude valid callers unnecessarily.

Make a small ordering contract explicit

The first program returns the larger operand using the comparison contract. int supplies a consistent ordinary numeric order, and equality deliberately returns the left operand. The method preserves T in its result. It does not silently sort a collection or copy an object graph. For reference-type arguments, returning an operand returns its reference.

Document whether null inputs are permitted for an ordering helper rather than relying on each CompareTo implementation’s conventions. An API serving only a specific domain may be clearer with a concrete type. Generalize when the algorithm and semantics truly remain the same across the intended types.

csharp A constrained generic comparison
using System;

Console.WriteLine($"larger={Larger(4, 9)}");

static T Larger<T>(T left, T right) where T : IComparable<T>
{
    return left.CompareTo(right) >= 0 ? left : right;
}
Expected output
larger=9

The int comparison chooses nine while the return type remains int.

Accept the capability you actually need

IEnumerable<T> promises enumeration, not constant-time Count, repeatability, indexing, or independent storage. IReadOnlyList<T> adds indexed read access but does not guarantee immutable backing state. ICollection<T> and mutable concrete collections expose different operations. Select the narrowest useful capability without repeatedly enumerating an unknown source just to discover its size.

A set’s equality comparer is part of its meaning. HashSet<string> with StringComparer.Ordinal treats identifiers differently from an ignore-case set. Choose a comparer explicitly for protocol keys. Mutable key state can undermine hash-based membership; prefer stable values and test equality boundaries, not just typical entries.

State empty behavior for a generic container

The second example uses Stack<int> with last-in, first-out behavior. TryPop distinguishes the empty state without a sentinel exception in a normal check. It returns a Boolean plus an output value whose meaning depends on success. The order comes from stack semantics, not enumeration or sorting.

A generic container copies or stores its element values according to the operation; reference elements still refer to existing objects. Removing an element from a managed collection does not mean disposing the object automatically. Ownership of IDisposable elements must be documented separately from storage membership.

csharp A generic last-in, first-out container
using System;
using System.Collections.Generic;

var stack = new Stack<int>();
stack.Push(3);
stack.Push(8);
bool found = stack.TryPop(out int value);
Console.WriteLine($"found={found} value={value} remaining={stack.Count}");
Expected output
found=True value=8 remaining=1

The latest pushed value is eight, leaving one stored element.

Type compatibility is not shared-state safety

Generic variance applies only where a declaration allows it. For example, some read-oriented interfaces permit covariance for reference types, while List<T> is invariant. This prevents treating a writable List<Derived> as List<Base> and inserting an incompatible base instance. Avoid solving a type mismatch by falling back to object everywhere.

An interface-compatible view can still expose changing data and require synchronization. A generic API should specify enumeration timing, mutation rules, copy depth, null policy, and exception behavior. Tests should include an empty sequence, one element, a reference-bearing element, and an intentionally failing comparer when that failure is supported.

Common errors and fixes

A generic method assumes an unavailable operator.

Why it happens
Its constraint does not provide that operation.
How to fix it
Constrain the intended behavior or inject an operation.
Prevent it next time
Derive the API from real type requirements.

An IEnumerable is enumerated twice and gives different results.

Why it happens
Repeatability was assumed without a contract.
How to fix it
Enumerate once or materialize deliberately.
Prevent it next time
Test stateful or one-pass sources.

Popping a disposable object is assumed to release it.

Why it happens
Collection membership was confused with resource ownership.
How to fix it
Dispose under the owner’s explicit lifetime policy.
Prevent it next time
Document element ownership on insertion and removal.

Check your understanding

1. Why is List<Dog> not generally assignable to List<Animal> even if Dog derives from Animal?

Show worked solution

A writable List<Animal> would let a caller insert a Cat or another Animal that is not a Dog, violating the original list’s element contract. Invariance prevents that unsafe operation. A suitably covariant read-oriented interface may expose animals without granting insertion, but it still does not promise an immutable or independent snapshot.

2. Design a generic first-element operation for a possibly empty source without assuming indexing.

Show worked solution

Accept IEnumerable<T>, acquire an enumerator with using, call MoveNext once, and return a success indicator plus the current value only on success. Document output meaning on failure and whether enumeration can throw. Do not call Count and then First on an unknown source, because that can perform repeated work or consume different states.

Key takeaways

  • Constraints define permitted operations, not complete domain semantics.
  • Enumeration, indexing, mutability, and ownership are different capabilities.
  • A generic collection does not automatically own or dispose its elements.

Official references