Osy#betaRun it on Osyrin: osyrin.com

Reference / Class

Sequence fields on a class

class <Name> { public <T>[] <Field>; // fixed-size public List<<T>> <Field>; // growable public HashSet<<T>> <Field>; // distinct, unordered public Dictionary<<K>, <V>> <Field>; }

A class can hold many values in one field — `string[]`, `int[]`, `List<Tag>`, `HashSet<string>`, `Dictionary<string, int>`. The element may be a scalar, an enum, or another class; the whole LINQ surface reads it, a `foreach` over a map binds each entry's `Key` and `Value`, and an array is fixed-size exactly as in C#. An entity cannot hold one: a collection member of an entity is a relation.

stable16 examples compiled by CIclasscollectionstypes

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, .Values

T 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 grows

Nothing 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 DocumentTag entity holding one string, 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

Related

class properties

A class member that reads and writes like a field but runs a body on access — a computed value, a validating setter, or…

Classes

A class is an in-memory shape — data plus the behaviour that belongs to it — and it never touches the database. That is…

LINQ over a local list

Query a local `List<T>`, `HashSet<T>` or `T[]` — of your own `class` values OR of plain scalars like `string[]` and…

relations

One entity points at another by declaring it as a member — that is the foreign key. The parent reads its children back…