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 null on a miss rather than the type's zero. That is a
deliberate difference from C#, and it is the useful one: a 0 is indistinguishable from a stored 0, so a miss
would be invisible. A null makes you decide.
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.
.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
}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 ask where something sits rather than change anything, .IndexOf(item), .LastIndexOf(item) and
.FindIndex(x => …) answer its position, or -1 when it is not there. IndexOf finds the
first occurrence and LastIndexOf the last, which is the only thing they disagree about. Over a list of
entity rows all three 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);
}An enum element#
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