Course outline

C# / Basic: Typed Programs and Object Contracts / Lesson 4

Collections, Strings, and Bounds

Separate sequence length, storage identity, keyed presence, and text units before choosing collection operations.

About 5 min readContent reviewed
On this page

Lesson goals

  • Choose arrays, lists, and dictionaries by contract.
  • Explain aliasing and shallow copies.
  • Distinguish UTF-16 code units from Unicode scalar values.

Choose the access pattern first

An array has a fixed length; List<T> manages a resizable sequence with Count and Capacity; Dictionary<TKey,TValue> provides keyed lookup. Count is the logical element number, while capacity is implementation storage and does not authorize indexing outside Count. Use a half-open index range and handle empty input before assuming element zero exists.

Select a collection from required operations and ownership. A list assignment copies a reference, so two variables may name the same list. An array clone or new List<T>(source) copies element values, not recursively every object reachable from those values. Independent outer storage can still contain shared mutable objects.

Make a collection copy explicit

The first example creates a list alias and a separate element copy. Updating alias[0] modifies the original list. Updating copied[1] changes only the copied list because int elements contain no nested references. The output illustrates identity and element-copy semantics without depending on internal capacity growth.

For a list of mutable Person objects, the new list would still refer to the same Person instances. Decide whether an API returns a borrowed view, a shallow snapshot, or recursively independent data. IReadOnlyList<T> limits operations through that interface but does not freeze the original list or the objects stored inside it.

csharp An alias and an independent integer list
using System;
using System.Collections.Generic;

var original = new List<int> { 1, 2, 3 };
var alias = original;
var copied = new List<int>(original);
alias[0] = 9;
copied[1] = 8;
Console.WriteLine(string.Join(",", original));
Console.WriteLine(string.Join(",", copied));
Expected output
9,2,3
1,8,3

The alias changes the original; the element copy has separate list storage.

Presence is different from a default value

TryGetValue distinguishes an absent key from a present entry whose value equals default. A direct dictionary index lookup throws when a key is absent. Choose an appropriate comparer for keys: protocol tokens often need ordinal comparisons, while user-facing linguistic matching has different requirements. Do not let ambient culture accidentally decide identifier identity.

If output order matters, establish it explicitly with sorting or an ordered structure whose contract matches the requirement. Do not rely on observed dictionary enumeration order as a general serialization guarantee. Enumerating and modifying a collection also requires the specific collection’s documented rules and a concurrency policy.

Sort presentation and inspect lookup success

The second program stores a legitimate zero count and performs an absent lookup separately. Sorting keys makes apple precede pear regardless of insertion details. The Boolean result of TryGetValue tells the caller whether zero came from absence or an actual stored record. No concurrent writer is present in this small example.

A production snapshot must cover the whole relevant state under its synchronization rule. Copying keys while values change is not necessarily a consistent snapshot, even if every individual operation avoids an exception. Keep ordering, presence, and snapshot consistency as three independent decisions.

csharp Stable keyed output with presence
using System;
using System.Collections.Generic;
using System.Linq;

var counts = new Dictionary<string, int>(StringComparer.Ordinal)
{
    ["pear"] = 2, ["apple"] = 0
};
foreach (string key in counts.Keys.OrderBy(key => key, StringComparer.Ordinal))
{
    Console.WriteLine($"{key}={counts[key]}");
}
bool present = counts.TryGetValue("missing", out int value);
Console.WriteLine($"missing={present} value={value}");
Expected output
apple=0
pear=2
missing=False value=0

Sorting establishes order, and the absent lookup returns false with default int zero.

A string length is a UTF-16 unit count

.NET strings are immutable sequences of UTF-16 code units. Length and char indexing count those units, not necessarily Unicode scalar values or user-perceived characters. A supplementary scalar uses a surrogate pair, and a displayed grapheme can contain several scalars. Use Rune enumeration or text-element APIs when the requirement needs those units.

foreach over a mutable collection does not generally grant permission to modify it structurally while enumerating. Many enumerators detect invalidation and throw; exact allowances are collection-specific. Prefer a separate update phase or an intentional snapshot. String immutability also does not make unsynchronized assignment to a shared variable a complete concurrency protocol.

Common errors and fixes

A list copy changes another list’s objects.

Why it happens
Only outer element references were copied.
How to fix it
Clone required nested mutable state or document borrowing.
Prevent it next time
Test alias effects explicitly.

A missing dictionary key crashes a normal path.

Why it happens
Indexer access assumes presence.
How to fix it
Use TryGetValue for expected absence.
Prevent it next time
Test stored zero and missing key separately.

Text truncation splits a Unicode character.

Why it happens
UTF-16 units were confused with the required text unit.
How to fix it
Use scalar or grapheme-aware processing as appropriate.
Prevent it next time
Test surrogate pairs and combining sequences.

Check your understanding

1. Does exposing IReadOnlyList<Person> guarantee that neither the list nor any Person can change?

Show worked solution

No. It restricts mutations available through that interface, but another alias may modify the backing collection and Person objects may remain mutable. To promise a stable independent snapshot, copy under the relevant synchronization policy and choose immutable elements or an explicit deep-copy policy. Document whether enumeration reflects live state or a captured result.

2. A dictionary stores balances including zero. Design the result for an unknown account without inventing a special numeric sentinel.

Show worked solution

Use TryGetValue and return presence alongside the balance, or translate absence to a documented domain result. Keep zero as legitimate data. If the map is shared, protect lookup under the same rule as updates. Test missing, zero, positive, and identifier-comparer cases, and specify whether case differences represent different accounts.

Key takeaways

  • Logical count, storage identity, and key presence are distinct.
  • Read-only access paths do not automatically create immutable snapshots.
  • Text processing must name its Unicode unit explicitly.

Official references