Overview
Generics in C# provide type-safe, reusable code without boxing overhead. Understanding generic constraints and collection internals is essential for writing high-performance, maintainable code.Generic Constraints
Generic constraints restrict which types can be substituted for a type parameter, enabling compile-time type safety and access to constrained members without reflection.Without constraints, a generic type parameter T only exposes the members of object. Constraints let the compiler guarantee capabilities — enabling member access, operator use, and instantiation.
Available Constraints
| Constraint | Description | Enables |
|---|---|---|
where T : class | Reference type | Null checks and reference semantics |
where T : struct | Value type | Prevents null, enables Nullable<T> |
where T : new() | Has parameterless constructor | new T() instantiation |
where T : SomeBase | Inherits from base class | Access to base class members |
where T : IInterface | Implements interface | Call interface methods without cast |
where T : unmanaged | Value type, no managed refs | Unsafe pointer operations, binary serialization |
where T : notnull | Non-nullable type (C# 8+) | Prevents nullable reference types |
Constraint Composition
Constraints compose:where T : class, IComparable\<T\>, new() means a reference type, comparable, with a default constructor.
Key Points
where T : class— reference type; enables null checks and reference semanticswhere T : struct— value type; prevents null, enables Nullable<T>where T : new()— T has public parameterless constructor; enables new T()where T : SomeBase— T is or derives from SomeBase; access base memberswhere T : IInterface— T implements interface; call interface methods without castwhere T : unmanaged— value type with no managed references; enables unsafe pointer ops
Best Practices
Do
- Use
where T : notnull(C# 8+) to enforce non-nullable type parameters - Combine multiple constraints to narrow the contract precisely
- Use interface constraints to call methods on T without boxing or casting
Don't
- Over-constrain generic types — it reduces reusability without adding safety
- Use object as a constraint alternative — use generics to avoid boxing
- Add
new()constraint unless you actually callnew T()— it restricts callers unnecessarily
Generic Variance
- Covariance (out)
- Contravariance (in)
- Invariance
Covariance allows you to use a more derived type than originally specified.Use
out keyword when the type parameter appears only in output positions (return types).Collection Internals
Understanding collection internal implementations drives correct usage: List<T> amortized resizing, Dictionary hash collision handling, and concurrent collection lock strategies.List<T> Implementation
List<T> is a resizable array — O(1) random access, O(1) amortized append (doubles capacity on resize), O(n) insert/remove at index.Dictionary<K,V> Implementation
Dictionary uses open addressing with prime-sized buckets and double-hashing on collision.Collection Performance Characteristics
| Collection | Access | Insert (end) | Insert (middle) | Search | Notes |
|---|---|---|---|---|---|
| List<T> | O(1) | O(1) amortized | O(n) | O(n) | Preallocate capacity |
| Dictionary<K,V> | O(1) | O(1) | N/A | O(1) | Hash collisions matter |
| LinkedList<T> | O(n) | O(1) | O(1) with node | O(n) | Poor cache locality |
| Queue<T> | O(1) front | O(1) | N/A | N/A | Circular buffer |
| Stack<T> | O(1) top | O(1) | N/A | N/A | Dynamic array |
| HashSet<T> | N/A | O(1) | N/A | O(1) | Unique elements |
Key Points
- List<T>: backed by array, doubles when full — preallocate with
List\<T\>(expectedCapacity) - Dictionary<K,V>: hash(key) % buckets, load factor 0.72, rehashes when exceeded
- LinkedList<T>: O(1) insert/remove with node reference, O(n) search — low memory locality
- Queue<T>: circular array buffer; Dequeue() from front, Enqueue() to rear
- Stack<T>: dynamic array; Push() = Append(), Pop() = RemoveLast()
- ConcurrentDictionary: 16 stripes by default — concurrent reads lock-free, writes stripe-locked
Concurrent Collections
Best Practices
Do
- Preallocate collections with expected capacity to avoid resize copies
- Implement GetHashCode() and Equals() on struct keys to avoid boxing
- Use ConcurrentDictionary for multi-reader, multi-writer scenarios — not lock+Dictionary
Don't
- Call
List\<T\>.Insert(0, item)in a hot loop — it is O(n) per insert - Use LinkedList<T> for random access scenarios — cache misses make it slow despite O(1) insert
- Use lock + Dictionary when ConcurrentDictionary or ImmutableDictionary fits the pattern
Specialized Collections
When to Use Each Collection Type
When to Use Each Collection Type
Choose List<T> when:
- You need fast random access by index
- You’re mostly appending to the end
- You know the approximate capacity upfront
Choose Dictionary<K,V> when:
- You need fast key-based lookup
- Keys are unique
- You can provide good GetHashCode() implementation
Choose HashSet<T> when:
- You need unique elements
- You need fast membership testing
- Order doesn’t matter
Choose LinkedList<T> when:
- You have frequent insertions/deletions in the middle
- You hold node references
- Random access is rare (otherwise use List<T>)
Choose ConcurrentDictionary<K,V> when:
- Multiple threads read and write
- Lock contention would be a problem
- You need atomic operations like AddOrUpdate
Choose ImmutableDictionary<K,V> when:
- You need snapshot consistency
- Writes are rare, reads are common
- You’re implementing functional patterns
Custom Generic Types
Generic type parameters with constraints enable rich compile-time guarantees while maintaining type safety and performance.