Choose the structure from the contract
List<T> represents a sequence with positions. It permits duplicates and is useful when order belongs to the result. Dictionary<TKey, TValue> associates a key with a value under an equality rule. HashSet<T> represents membership and uniqueness under its comparer. Choosing a collection merely by name or expected speed can lose important requirements.
An importer may need two structures: a set to recognize previously seen identifiers and a list to retain arrival order. The set decides whether an element is new; the list represents the sequence to present. A structure eliminating duplicates does not mean it is the right specification for a stable public order.
Also define what constitutes a duplicate. For ASCII protocol identifiers, the rule may be OrdinalIgnoreCase; for other data, it may be exact identity. Apply that choice during both insertion and lookup. Restricting the example to one thread avoids confusing collection semantics with concurrency guarantees these ordinary structures do not provide.
Capacity is not contents
new List<int>(100) reserves initial element capacity but begins with Count equal to zero. Add inserts logical elements and increases Count. Capacity may avoid reallocations during that growth; it does not represent one hundred already populated positions available as data.
When sizing a list, use a reasonable estimate and distinguish reserved memory from the quantity actually processed. A dashboard displaying Capacity as record count may report work that never occurred. If element count grows beyond capacity, the implementation can expand storage; the application should not depend on a particular growth factor not required by the contract.
Indexed reading also depends on Count. Available storage does not make indices beyond the present elements valid. During code review, write the expected count after each insertion before reasoning about allocation. Capacity is a resource choice, not evidence of batch completeness.
Presence, replacement and duplicates
TryGetValue returns both a presence indication and an output value. In an integer dictionary, zero may be a stored value or the default from an unsuccessful lookup. Only the Boolean distinguishes those states. Reading the indexer for a missing key throws; it does not automatically return the default.
Write policy should also be explicit. Add rejects an existing key. Indexer assignment inserts or replaces. TryAdd returns false and preserves the previous value when the key exists. Swapping these operations merely to remove an exception can turn a rejection rule into silent data loss.
During import, decide whether a duplicate should stop processing, retain the first entry or replace it with the newest. Then choose the operation expressing that policy and record rejected cases when needed. These examples are sequential: a check followed by a write does not acquire a general cross-thread atomicity guarantee merely because it uses a dictionary.
Identity must remain stable
Dictionaries use equality and hashing to organize lookups. The comparer belongs to the key definition. Constructing a dictionary with StringComparer.OrdinalIgnoreCase treats PORT and port as one key under a culture-independent rule. Migrating from a case-sensitive comparer can expose collisions requiring a policy.
A class key whose hash depends on a mutable property introduces another risk. If that property changes after insertion, placement based on the earlier hash may no longer agree with the current lookup. The dictionary does not automatically observe every application setter to reorganize entries.
Keep the state participating in equality and hashing stable while the key is in use. A string or immutable identity value may fit better than an entire mutable business object. Do not promise that every mutation has exactly the same symptom: the problem is violating the stability rule even if an occasional example still appears to work.
Views, snapshots and enumeration
IReadOnlyList<T> limits operations available through that interface. If its source is a List<T> and another reference changes it, the view continues to observe the same list. The conversion creates no snapshot and does not make contained objects immutable. A stable-history promise requires a decision about copying and ownership.
A List<T> enumerator is not a snapshot either. After obtaining an enumerator and advancing once, adding an element invalidates it. The next MoveNext call throws InvalidOperationException. This can happen on one thread; concurrency is not required for the failure.
If removals must be decided while traversing data, separate observation from modification or use an operation designed for the intended transformation. If a copy is needed, define whether copying the sequence is sufficient or mutable elements must also be separated. Changing the public interface or catching an exception does not, by itself, establish result consistency.
Generics and covariance boundaries
A List<string> cannot be directly assigned to List<object>. If that were permitted, a consumer could add another object type to the same list, violating its original string contract. List<T> is invariant: the relationship between element types does not create that conversion between lists.
IEnumerable<T> permits a covariant conversion for related reference types. A string sequence can be observed as IEnumerable<object> without constructing another sequence of elements. This capability neither turns the interface into a mutable list nor copies data.
The rule does not automatically box an integer sequence. IEnumerable<int> does not directly convert to IEnumerable<object> through covariance. Representing each number as object needs a per-element operation whose cost and execution timing are separate decisions. The next lesson develops sequence operations and deferred execution; here the objective is recognizing which type conversions exist.
Guided example and summary: first occurrence
The importer below receives a finite sequence of ASCII identifiers already validated as non-null and nonempty. There is no concurrency. Output must preserve first spelling and arrival order while ignoring casing for duplicate detection. The set decides membership; the list stores output.
For NodeB, nodea, NODEB and NodeC, output is NodeB, nodea, NodeC. The second NodeB occurrence neither replaces its spelling nor changes its earlier position. The code does not depend on HashSet enumeration order because it never uses the set to produce the final sequence. This function also does not validate the source or guarantee uniqueness across separate executions.
Summary: choose a collection from its contract; distinguish Count from Capacity and presence from default; make replacement and duplication explicit; preserve hash stability; separate views from snapshots; respect variance rules. These choices arise in caches, importers, reports and component boundaries.
using System;
using System.Collections.Generic;
public static class Importing
{
public static List<string> FirstOccurrences(IEnumerable<string> source)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<string>();
foreach (string id in source)
{
if (seen.Add(id)) result.Add(id);
}
return result;
}
}Assume a finite non-null source, nonempty ASCII items and sequential execution. NodeB, nodea, NODEB, NodeC produces NodeB, nodea, NodeC; the returned list is mutable.
Common pitfalls
Using Capacity as count; confusing zero with absence; replacing duplicates without a decision; mutating keys; treating IReadOnlyList as a snapshot; changing List during enumeration.
Related topics: Objects, interfaces and records · References, nullability and text · LINQ, deferred work and materialization
A collection has identity, ordering and modification contracts; make them explicit before choosing an operation.
Reference: Dictionary<TKey,TValue> · C# 14 / .NET 10; no preview features