Summary#
A class field can hold many values. Four spellings, all of them C#'s:
class Tag { public string Name; }
class Document {
public string Title;
public string[] Keywords; // fixed-size — its length is part of it
public int[] Scores; // any scalar, not just string
public List<Tag> Tags; // growable
public HashSet<string> Seen; // distinct, unordered
public Dictionary<string, int> Counts;
}Signature#
public T[] Field; // array — fixed-size; indexable, .Count, foreach, LINQ. No .Add
public List<T> Field; // list — everything an array does, plus .Add / .Remove / .RemoveAll
public HashSet<T> Field; // set — distinct membership; .Add / .Contains / .Remove
public Dictionary<K, V> Field; // map — d[k], .ContainsKey, .TryGetValue, .GetValueOrDefault, .Keys, .ValuesT may be a scalar (string, int, long, decimal, double, bool, Guid, DateTime, …), an enum
you declared, another class, or another collection — Dictionary<string, List<int>> and List<List<int>>
are both fine, at any depth. byte[] is the exception and is not a sequence at all — it is the binary scalar type.
⚠ The one thing that cannot nest is a Dictionary KEY: a key has to be comparable, and a collection is not. The compiler says so rather than storing it and failing later.
Description#
Reading one#
Every sequence field answers the same questions, and they are the LINQ verbs you already use — filter, project, sort, aggregate — evaluated in memory over the values the object holds:
int LongKeywords(Document d) {
return d.Keywords.Where(k => k.Length > 5).Count;
}
string FirstAlphabetically(Document d) {
return d.Keywords.OrderBy(k => k).First(k => k != "");
}
bool EveryScorePasses(Document d) {
return d.Scores.All(s => s >= 0);
}
// `Max` has no zero to fall back on, so over NO scores it answers absent — which is why this returns `int?`
// where `Count` above returns `int`. Writing `int` here is a compile error, not a silent 0.
int? Best(Document d) {
return d.Scores.Max(s => s);
}Indexing and foreach read it directly, with no ceremony:
int Total(Document d) {
var sum = 0;
foreach (var s in d.Scores) { sum = sum + s; }
return sum + d.Scores[0];
}A collection member starts EMPTY#
A sequence member of a class is an empty collection from the moment the object is made — you never have to check it before adding to it:
class Valley {
List<Prop> trees = new List<Prop>();
public void Build() {
trees.Add(new Prop { X = 1 }); // works on a brand-new Valley
}
}The initializer above is the natural way to write it and is what most people will. It is not load-bearing: a
List<Prop> trees; with no initializer is empty too. Every other reading in the language already treats an absent
collection as empty — iterating one yields nothing, .Count answers 0, a query over it is empty — so .Add agrees
with them rather than being the one operation that does not.
Filling one#
A collection literal fills any of them at construction:
Document New() {
return new Document {
Title = "notes",
Keywords = ["osy", "reference"],
Scores = [10, 30, 20]
};
}Reading a map#
d[k] reads one entry and throws when the key is absent, exactly as in C#. The two total forms are the ones to
reach for when a miss is ordinary:
// `.TryGetValue` — C#'s own spelling, and the value is in scope in the branch.
int SeenOrZero(Document d, string word) {
if (d.Counts.TryGetValue(word, out var n)) { return n; }
return 0;
}
// `.GetValueOrDefault` — the same question as an expression, so it composes with `??`.
int Seen(Document d, string word) {
return d.Counts.GetValueOrDefault(word, 0);
}With no fallback, .GetValueOrDefault(k) answers what C# answers on a miss — default(V): 0 for a number,
false for a bool, null for a string, a row or a class value. A 0 read that way cannot be told from a stored
0, so when the difference matters, ask ContainsKey or pass a fallback no stored value can be.
Iterating a map#
A foreach over a map binds each entry as a pair, with Key and Value — C#'s own shape:
string Summarise(Document d) {
var s = "";
foreach (var kv in d.Counts) { s = s + kv.Key + "=" + kv.Value + ";"; }
return s;
}The same pair is what the LINQ verbs filter and project over, so a map is a source like any other sequence:
int TotalRepeated(Document d) {
return d.Counts.Where(kv => kv.Value > 1).Sum(kv => kv.Value);
}
// `First` answers the PAIR, so its `Key` is one hop away.
string Rarest(Document d) {
return d.Counts.OrderBy(kv => kv.Value).First(kv => kv.Value > 0).Key;
}.ToList() and .ToArray() both hand you the pairs as a plain sequence.
The pair has C#'s own name, KeyValuePair<K, V> — so it can be written as a type, built, kept in a list, and
written out as C# writes it ("[a, 2]"):
string Pair(string key, int value) => new KeyValuePair<string, int>(key, value).ToString(); // "[a, 2]"
int Weight(List<KeyValuePair<string, int>> weights) {
var total = 0;
foreach (var w in weights) { total += w.Value; }
return total;
}
string Heaviest(Dictionary<string, int> weights) {
KeyValuePair<string, int> top = weights.OrderBy(kv => kv.Value).Last();
return top.Key;
}.Keys and .Values are still there when you only want one half:
int TotalSeen(Document d) {
return d.Counts.Values.Sum();
}
int HowManyKeys(Document d) {
return d.Counts.Keys.Count;
}Both are a snapshot, not C#'s live view: a .Values you already took does not follow a later .Add. Take it
again when you want the current contents.
Changing a map#
.Add(k, v) throws if the key is taken — that is exactly what separates it from d[k] = v, which overwrites.
When you mean "add only if absent", .TryAdd says so and answers whether it went in:
void Record(Document d, string word) {
d.Counts[word] = 1; // upsert — replaces whatever was there
d.Counts.TryAdd(word, 1); // adds only if absent; answers false if not
d.Counts.Remove(word); // answers whether it was there
d.Counts.Clear(); // empties it
}
bool AnyoneAt(Document d, int n) {
return d.Counts.ContainsValue(n); // the mirror of .ContainsKey
}Keys that ignore case#
Give a Dictionary or a HashSet of strings StringComparer.OrdinalIgnoreCase and "Ada" and "ADA" are one key — in
the constructor, alone or after a capacity, or as the last argument of ToDictionary / ToHashSet. The key keeps the
spelling it was first written with, and a ToDictionary that meets two keys the comparer calls one throws, as in C#.
string Tally() {
var seen = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var w in new List<string> { "Ada", "ada", "Bo" }) {
seen[w] = seen.GetValueOrDefault(w) + 1;
}
var tags = new List<string> { "x", "X" }.ToHashSet(StringComparer.OrdinalIgnoreCase);
return string.Join(",", seen.Keys) + ":" + seen["ADA"] + ":" + tags.Count; // "Ada,Bo:2:1"
}The comparer may also come before an initializer's elements — new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "a", "A", "b" } holds two, the first spelling kept; a dictionary initializer that names one key twice under the
comparer is refused, as any initializer's duplicate key is. Name the type there: a target-typed new(comparer) { … } is
refused by name rather than read without its comparer.
Case is folded as .NET's OrdinalIgnoreCase folds it, one character at a time — "straße" and "STRASSE" stay two keys.
Keys that are VALUES in C# compare as values without any comparer: 1.0m and 1.00m, two equal dates, two equal
tuples and two Uris naming one address are each one key, in the browser as on the server.
The CULTURE comparers (StringComparer.InvariantCultureIgnoreCase, CurrentCulture…) are refused by name: their
equality follows linguistic rules this language does not reproduce. A comparer belongs to string keys only.
Changing one#
A List<T> grows and shrinks with .Add(x) and .Remove(x). .RemoveAll(x => …) deletes every match in one go
and answers how many went — it changes the list you called it on, so anything else holding that same list sees the
change too:
int DropShortTags(Document d) {
return d.Tags.RemoveAll(t => t.Name.Length < 3);
}That in-place mutation is the difference from d.Tags.Where(…), which answers a new sequence and leaves the
original alone. Reach for Where when you want a filtered view, and RemoveAll when the list itself should change.
The rest of C#'s vocabulary is there too — .AddRange(other) appends every item of another collection,
.Insert(i, x) places one at a position, and .Clear() empties it (on a List, a HashSet or a Dictionary):
void Reset(Document d, List<Tag> extra) {
d.Tags.AddRange(extra); // append all of them
d.Tags.Insert(0, new Tag { Name = "first" });
d.Tags.Clear();
}To change a list WITHOUT touching the one you were handed, copy it first — new List<T>(other) is C#'s copy
constructor and takes any sequence (a list, an array, a query result). The copy is independent: adding to it leaves
the original as it was. It is the same list other.ToList() builds, so write whichever reads better. A HashSet
copies the same way (new HashSet<T>(other) is other.ToHashSet()), and a Dictionary copies another of the same
key and value types (new Dictionary<K, V>(other)). A lone integer argument is a CAPACITY instead — an allocation
hint, so the collection starts empty, as in C#.
List<Tag> WithDefault(Document d) {
var tags = new List<Tag>(d.Tags); // a copy — d.Tags is untouched
tags.Add(new Tag { Name = "general" });
return tags;
}To ask where something sits rather than change anything, there are four verbs and they are two pairs:
.IndexOf(item) / .LastIndexOf(item) when you HOLD the element, and
.FindIndex(x => …) / .FindLastIndex(x => …) when you can only
DESCRIBE it. Each pair answers the first and the last match; all four answer -1 when there is none. Over a list of
entity rows the value forms compare by row identity, so .IndexOf(row) finds the row without your
comparing .Id.
Because it changes things, a change belongs in an action (or an on-change body), never in a render slot — a
render expression is re-evaluated whenever anything it reads changes, in an order nobody controls, so the compiler
refuses .Add / .Remove / .RemoveAll there and names where they go instead. The reads (.Count, .Contains,
.Keys, .Values, indexing, every LINQ verb) are render-slot material and always were.
A collection inside a collection#
The grouping shape — a map of lists — is an ordinary field:
class Index {
public Dictionary<string, List<int>> ByTag;
public List<List<int>> Rows;
}
int TotalTagged(Index ix) {
var n = 0;
foreach (var kv in ix.ByTag) { n = n + kv.Value.Count; }
return n;
}
// The inner list is a REAL list — reached through the pair and appended to in place.
void Tag(Index ix, string tag, int id) {
if (!ix.ByTag.ContainsKey(tag)) { ix.ByTag[tag] = new List<int>(); }
ix.ByTag[tag].Add(id);
}Can an element be an enum?#
An element may be an enum you declared, in any of the four spellings — including both slots of a map, which is how a per-status tally is written:
enum Status { Open, Closed }
class Board {
public List<Status> Lanes;
public Status[] Order;
public Dictionary<Status, int> Tally; // an enum KEY
public Dictionary<string, Status> ByName; // an enum VALUE
}
int OpenCount(Board b) {
var n = 0;
foreach (var s in b.Lanes) { if (s == Status.Open) { n = n + 1; } }
return n;
}
int OpenTally(Board b) {
return b.Tally.Where(kv => kv.Key == Status.Open).Sum(kv => kv.Value);
}Array or list?#
Use a List<T> when the contents change — it is what you will want most of the time. Use a T[] when the set
of values is settled once and then only read.
The difference is C#'s and the compiler holds you to it: an array's length is part of the type, so it has no .Add:
d.Keywords.Add("x"); // ✗ `Add` needs a growable collection, and `string[]` is fixed-size
d.Tags.Add(tag); // ✓ a List growsNothing else differs. Both are indexable, both count, both foreach, both answer every LINQ verb — so choosing an
array never costs you a way to read it.
Can an entity hold a list?#
This is a class surface, and deliberately. On an entity a collection member means something
else entirely: OrderLine[] Lines is a relation — a set of child ROWS, stored in their own table and reached
through a foreign key. A column cannot hold a list, so string[] Tags; on an entity is refused, and it names the two
real answers:
- Model each value as a row. A
DocumentTagentity holding onestring, reached as[ForeignKey(Document)] DocumentTag[] Tags;— which is queryable, indexable, and the answer whenever you will ever want to search or count by it. - Or keep the list on a
class, when it is a value the row simply carries and nobody queries across.
See relations for the relation form.
See also#
- LINQ over a local list — the verbs that read these fields, and the same verbs over a local sequence
- class properties — a member that runs a body on access
- relations — what a collection member means on an entity, and why it is a different thing