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