Collections, generics and the standard library
Pick the right collection for the access pattern, understand generics and constraints, and use Span and the modern date types.
Choosing a collection
| Type | Lookup by key | Lookup by index | Add at end | Ordered |
|---|---|---|---|---|
List<T> | O(n) | O(1) | O(1) amortised | Insertion order |
Dictionary<K,V> | O(1) | n/a | O(1) | Unordered |
HashSet<T> | O(1) contains | n/a | O(1) | Unordered |
SortedDictionary<K,V> | O(log n) | n/a | O(log n) | Sorted by key |
Queue<T> | n/a | n/a | O(1) enqueue | FIFO |
Stack<T> | n/a | n/a | O(1) push | LIFO |
LinkedList<T> | O(n) | n/a | O(1) at a node | Insertion order |
using System.Collections.Frozen;
// prefer collection expressions and target-typed new
List<string> names = ["ada", "grace", "alan"];
Dictionary<string, int> ages = new()
{
["ada"] = 36,
["grace"] = 45,
};
// TryGetValue avoids a double lookup and a missing-key exception
if (ages.TryGetValue("ada", out int age))
Console.WriteLine(age);
// a Set for membership; Add returns false when the item was already there
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
bool firstTime = seen.Add("ADA");
// GetOrAdd is the ergonomic dictionary pattern
ages["alan"] = ages.GetValueOrDefault("alan") + 1;
// FrozenDictionary trades build time for the fastest possible reads:
// build once at startup, read many times
FrozenDictionary<string, int> frozen = ages.ToFrozenDictionary();- Always pass an explicit comparer for string keys:
StringComparer.Ordinalfor machine identifiers,OrdinalIgnoreCasefor case-insensitive keys. The default is culture-sensitive and can behave differently on two machines. Dictionaryenumeration order is unspecified. If the order matters for output, sort explicitly.List<T>doubles its capacity on growth; pass an initial capacity when you know the count, which removes the repeated allocations and copies.FrozenDictionaryandFrozenSetare built for read-heavy lookups after startup; they are slower to create and should not be mutated.
Generics, constraints and variance
// a constraint tells the compiler what the type parameter can do
public interface IRepository<T> where T : class, IEntity, new()
{
T? Find(int id);
void Add(T entity);
}
public class Repository<T> : IRepository<T> where T : class, IEntity, new()
{
private readonly Dictionary<int, T> _store = new();
public T? Find(int id) => _store.TryGetValue(id, out T? e) ? e : null;
public void Add(T entity) => _store[entity.Id] = entity;
}
// unconstrained generic: any T works, but you can only use object members
public static T[] Repeat<T>(T value, int count)
{
T[] result = new T[count];
Array.Fill(result, value);
return result;
}
// a generic method with a delegate constraint
public static TResult Map<TSource, TResult>(TSource src, Func<TSource, TResult> f) => f(src);| Constraint | Means | Note |
|---|---|---|
where T : class | Reference type | T? is meaningful |
where T : struct | Value type | Excludes nullable value types |
where T : notnull | Non-nullable | Common on dictionary keys |
where T : IEntity | Interface or a derived class | Enables interface members |
where T : new() | Has a public parameterless constructor | Must be last in the list |
where T : unmanaged | Blittable value type | For pointer and interop work |
Generic types are reified: the runtime knows the type argument at run time, so List<int> stores raw integers with no boxing. That is the practical difference from Java's erasure and the reason a generic collection is both faster and safer than the non-generic one.
Span, dates and the modern helpers
using System.Buffers;
// Span<T>: a view over contiguous memory with no allocation
static int CountDigits(ReadOnlySpan<char> text)
{
int n = 0;
foreach (char c in text)
if (char.IsAsciiDigit(c)) n++;
return n;
}
string line = "order 42 shipped";
int digits = CountDigits(line.AsSpan(6, 2)); // a slice, no substring allocated
// slicing and searching without materialising strings
ReadOnlySpan<char> trimmed = line.AsSpan().Trim();
int comma = trimmed.IndexOf(':');
// stackalloc for small scratch buffers instead of a heap array
Span<byte> buffer = stackalloc byte[32];
// ArrayPool when the buffer can be large and reused
byte[] rented = ArrayPool<byte>.Shared.Rent(4096);
try { /* use rented.AsSpan(0, 4096) */ }
finally { ArrayPool<byte>.Shared.Return(rented); }using System.Globalization;
// DateOnly and TimeOnly: no misleading midnight or date parts
DateOnly shipped = new DateOnly(2026, 9, 18);
DateOnly due = shipped.AddDays(14);
Console.WriteLine(due.DayOfWeek == DayOfWeek.Saturday); // true
TimeOnly opens = new TimeOnly(9, 0);
Console.WriteLine(opens.AddHours(8)); // 17:00
PeriodicTimer timer = new(TimeSpan.FromSeconds(5));
await foreach (DateTime now in timer.WaitForNextTickAsync())
{
Console.WriteLine(now.ToString("O", CultureInfo.InvariantCulture));
break; // just demonstrating the shape
}⚠️
Span<T> is a ref struct: it can only live on the stack, so it cannot be a field, cannot be boxed, and cannot cross an await. Use Memory<T> when the buffer must be stored or passed across an asynchronous boundary.FAQ
When should I use a record instead of a class?
Use a record for a value-like type whose meaning is its data: DTOs, messages, configuration. Use a class for an entity with identity, behaviour and mutable state. Records give value equality, a readable
ToString and non-destructive with for free.Does TryGetValue allocate?
No. It returns a
bool and writes through an out parameter, so there is no exception and no allocation on a miss, unlike indexing with a missing key.Related
Classes and interfaces LINQ and async/await
Last refreshed 2026-09-18.