Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

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.

stable13 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 collectionDictionary<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 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…