Skip to main content
Published / updated

Collections and Generics

Before you start

You need: classes and properties (Article 04).

Time: about 60 minutes, plus the practice. The delegates section near the end is what makes LINQ in Article 06 make sense — do not skip it.

Learning objective

Choose the right collection for each School Management System task, and understand what generics give you that object does not.

Topics

  • Arrays
  • List<T>
  • Dictionary<TKey, TValue>
  • HashSet<T>
  • Queue<T> and Stack<T>
  • Choosing between them
  • Generics
  • Delegates and lambdas
  • Interfaces — IEnumerable<T>, IReadOnlyList<T>
  • Equality and hash codes

Arrays

string[] sections = new string[3];
sections[0] = "A";
sections[1] = "B";
sections[2] = "C";

string[] classNames = { "8th", "9th", "10th" };

int[] marks = new int[] { 85, 72, 91, 68, 45 };

Console.WriteLine(marks.Length); // 5
Console.WriteLine(marks[0]); // 85

An array's size is fixed at creation. You cannot add a fourth section to a three-element array — you create a new array and copy.

for (int i = 0; i < marks.Length; i++)
{
Console.WriteLine($"Subject {i + 1}: {marks[i]}");
}

Indexes run from 0 to Length - 1. Using <= runs one past the end:

System.IndexOutOfRangeException: Index was outside the bounds of the array.

Use an array when the size is genuinely fixed — the twelve months, the six StaffDesignation values. For anything that grows, use List<T>.

List<T>

List<Student> students = new List<Student>();

students.Add(new Student { Name = "Ravi Kumar", RollNumber = "NCA-2024-0012" });
students.Add(new Student { Name = "Priya Sharma", RollNumber = "NCA-2024-0013" });
students.Add(new Student { Name = "Arjun Reddy", RollNumber = "NCA-2024-0014" });

Console.WriteLine(students.Count); // 3
Console.WriteLine(students[0].Name); // Ravi Kumar
MemberDoes
AddAppends one item
AddRangeAppends many
Insert(index, item)Places at a position
Remove(item)Removes the first match
RemoveAt(index)Removes by position
Contains(item)Membership test — linear scan
IndexOf(item)Position, or -1
CountHow many — not Length
ClearEmpties it
SortSorts in place

Count for a list, Length for an array and a string. Mixing them up is a compile error, and a very common one.

foreach (Student student in students)
{
Console.WriteLine($"{student.RollNumber}{student.Name}");
}

Searching a list is a linear scan. Contains on 800 students checks up to 800 items. For occasional lookups that is fine; for lookups inside a loop over another 800 items it is 640,000 comparisons, and that is where a Dictionary belongs.

Dictionary<TKey, TValue>

Dictionary<string, Student> studentsByRollNumber = new Dictionary<string, Student>();

studentsByRollNumber.Add("NCA-2024-0012", ravi);
studentsByRollNumber["NCA-2024-0013"] = priya;

Student found = studentsByRollNumber["NCA-2024-0012"];

Lookup by key is fast regardless of size — 800 students or 80,000, roughly the same cost.

// Throws KeyNotFoundException if absent
Student student = studentsByRollNumber["NCA-2024-9999"];

// Safe
Student student;

if (studentsByRollNumber.TryGetValue("NCA-2024-9999", out student))
{
Console.WriteLine(student.Name);
}
else
{
Console.WriteLine("No student with that roll number.");
}

TryGetValue is the correct way to read a dictionary you are not certain about — the same shape as int.TryParse.

Add throws on a duplicate key; the indexer overwrites silently. Choose deliberately: Add when a duplicate means a bug, the indexer when replacing is intended.

foreach (KeyValuePair<string, Student> entry in studentsByRollNumber)
{
Console.WriteLine($"{entry.Key}{entry.Value.Name}");
}
Dictionary<string, List<Student>> studentsByClass = new Dictionary<string, List<Student>>();

foreach (Student student in students)
{
if (!studentsByClass.ContainsKey(student.ClassName))
{
studentsByClass[student.ClassName] = new List<Student>();
}

studentsByClass[student.ClassName].Add(student);
}

Grouping into a dictionary of lists is one of the most useful shapes in application code — students by class, results by exam, payments by academic year.

Dictionary order is not guaranteed. Never rely on the sequence a foreach returns; sort explicitly if order matters.

HashSet<T>

HashSet<string> usedRollNumbers = new HashSet<string>();

bool added = usedRollNumbers.Add("NCA-2024-0012"); // true
bool addedAgain = usedRollNumbers.Add("NCA-2024-0012"); // false — already present

Console.WriteLine(usedRollNumbers.Contains("NCA-2024-0012")); // True
Console.WriteLine(usedRollNumbers.Count); // 1

A HashSet<T> stores unique values and tests membership fast. Add returning false is a duplicate check for free.

HashSet<int> presentToday = new HashSet<int> { 12, 13, 14, 15 };
HashSet<int> classStudents = new HashSet<int> { 12, 13, 14, 15, 16, 17 };

HashSet<int> absentToday = new HashSet<int>(classStudents);
absentToday.ExceptWith(presentToday); // 16, 17

UnionWith, IntersectWith and ExceptWith do set arithmetic directly — useful for attendance, permissions and eligibility.

Queue<T> and Stack<T>

Queue<Student> admissionWaitingList = new Queue<Student>();

admissionWaitingList.Enqueue(sneha);
admissionWaitingList.Enqueue(kiran);

Student next = admissionWaitingList.Dequeue(); // sneha — first in, first out
Student peeked = admissionWaitingList.Peek(); // kiran, without removing
Stack<string> navigationHistory = new Stack<string>();

navigationHistory.Push("Students");
navigationHistory.Push("Ravi Kumar");
navigationHistory.Push("Fee summary");

string back = navigationHistory.Pop(); // Fee summary — last in, first out

Queue for fairness — a waiting list processes in arrival order. Stack for undo or back-navigation.

Dequeue and Pop throw on an empty collection. Check Count first, or use TryDequeue and TryPop.

Choosing

NeedUse
Fixed size, known at creationT[]
Ordered, grows and shrinksList<T>
Look up by a unique keyDictionary<TKey, TValue>
Unique values, fast membershipHashSet<T>
First in, first outQueue<T>
Last in, first outStack<T>

Three questions decide it: Do I look items up by a key? Must values be unique? Does order of processing matter?

For the School system: students in a class → List<Student>; students by roll number → Dictionary<string, Student>; roll numbers already issued → HashSet<string>; the admission waiting list → Queue<Student>.

Generics

Generics let one type or method work with many types while keeping the compiler's type checking.

Before generics, collections held object:

ArrayList students = new ArrayList();
students.Add(ravi);
students.Add("not a student"); // compiles — nothing stops it

Student first = (Student)students[1]; // InvalidCastException at run time
List<Student> students = new List<Student>();
students.Add(ravi);
students.Add("not a student"); // compile error — caught immediately

The failure moves from run time to compile time. That is the entire point, and it is why ArrayList and Hashtable are legacy types you will only meet in the VB.NET and Web Forms tracks.

Writing a generic method:

public static T FindFirst<T>(List<T> items, Func<T, bool> match)
{
foreach (T item in items)
{
if (match(item))
{
return item;
}
}

return default(T);
}
Student found = FindFirst(students, s => s.RollNumber == "NCA-2024-0012");
Teacher teacher = FindFirst(teachers, t => t.EmployeeCode == "NCA-T-004");

One method, both types, full type safety. default(T) is null for reference types and 0 for numbers.

A generic class:

public class Repository<T>
{
private readonly List<T> _items = new List<T>();

public void Add(T item)
{
_items.Add(item);
}

public List<T> GetAll()
{
return new List<T>(_items);
}

public int Count
{
get
{
return _items.Count;
}
}
}

GetAll returns a copy, so a caller cannot add to the internal list from outside. Returning _items directly hands out the private state and undoes the encapsulation.

Constraints restrict what T may be:

public class Repository<T> where T : class, new()
{
}

where T : class means a reference type; new() means it has a parameterless constructor; where T : IEntity means it implements an interface.

Delegates and lambdas

The problem they solve

The office needs to find a student — sometimes by roll number, sometimes by class, sometimes by status. Without delegates you write a method for each:

public static Student FindByRollNumber(List<Student> students, string rollNumber)
{
foreach (Student student in students)
{
if (student.RollNumber == rollNumber)
{
return student;
}
}

return null;
}

public static Student FindByClass(List<Student> students, string className)
{
foreach (Student student in students)
{
if (student.ClassName == className)
{
return student;
}
}

return null;
}

Look at what is identical and what is not. The loop is the same. The return is the same. Only the line inside the if differs.

You already know how to make a method work with different values — that is what parameters do. What you need here is a way to pass in a different test. That is what a delegate is for.

What a delegate is

A delegate is a variable that holds a method.

Every variable so far has held data. int marks = 85; holds a number. Student ravi = new Student(); holds a student. A delegate variable holds a method you can call later.

Just as int is the type of a number, a delegate is the type of a method — it records what parameters the method takes and what it returns.

public delegate bool StudentTest(Student student);

Read that as: StudentTest is the name of a type. Any method taking one Student and returning a bool fits it.

public static bool IsActive(Student student)
{
return student.Status == StudentStatus.Active;
}

public static bool IsInTenthClass(Student student)
{
return student.ClassName == "10th";
}

Both take a Student and return a bool, so both fit StudentTest:

StudentTest test = IsActive;
bool result = test(ravi); // this actually calls IsActive

Notice there are no brackets after IsActive.

You writeIt means
test = IsActive;Store the method. Do not run it yet.
test = IsActive(ravi);Run it now, store the true or false it returned

The first is a delegate. The second is a bool, and assigning it to a StudentTest will not compile. This catches nearly everyone once.

Func, Action and Predicate

You will rarely declare your own delegate type, because .NET already supplies three that cover almost everything.

TypeTakesReturnsUse it for
Func<T, TResult>One or more valuesA valueA calculation or a test
Action<T>One or more valuesNothingDoing something
Predicate<T>One valueboolA test — identical to Func<T, bool>

Read the angle brackets left to right. The last one is always the return type:

WrittenRead as
Func<Student, bool>Takes a Student, returns a bool
Func<FeeAccount, decimal>Takes a FeeAccount, returns a decimal
Func<decimal, decimal, decimal>Takes two decimal values, returns a decimal
Action<Student>Takes a Student, returns nothing
Func<Student, bool> isActive = IsActive;
Action<Student> printStudent = PrintStudent;

So the StudentTest above was never needed — Func<Student, bool> already means the same thing.

Lambdas: a shorter way to write the same thing

Writing a whole named method for a one-line test is heavy. A lambda lets you write the method inline, without naming it.

These two lines do exactly the same thing:

// Using the named method from earlier
Func<Student, bool> isActive = IsActive;

// The same test, written inline as a lambda
Func<Student, bool> isActive = student => student.Status == StudentStatus.Active;

A lambda is not a new concept. It is a way of writing a delegate, the same way "NCA" is a way of writing a string.

Read => as "goes to": take student, go to this result.

student => student.Status == StudentStatus.Active
| |
| +--- what to return
+--- the parameter, named by you

You do not write the parameter's type. The compiler already knows it is a Student, because you said Func<Student, bool>.

Two or more parameters need brackets:

Func<decimal, decimal, decimal> payable = (fees, discount) => fees - discount;
decimal amount = payable(15000m, 2500m); // 12500

A body longer than one line needs braces and an explicit return:

Func<ExamResult, string> describe = result =>
{
if (result.IsAbsent)
{
return "Absent";
}

return $"Scored {result.MarksObtained}";
};

This is the one place => appears in this track. It is a lambda, not an expression-bodied method — those stay off-limits in tutorial code.

Putting it together

The two near-identical Find methods from the start now collapse into one:

public static Student FindFirst(List<Student> students, Func<Student, bool> test)
{
foreach (Student student in students)
{
if (test(student))
{
return student;
}
}

return null;
}
Student byRoll = FindFirst(students, s => s.RollNumber == "NCA-2024-0012");
Student inTenth = FindFirst(students, s => s.ClassName == "10th");
Student anyActive = FindFirst(students, s => s.Status == StudentStatus.Active);

One method, three different searches. The caller supplies the test; the method supplies the loop.

This is exactly what the generic FindFirst<T> earlier in this article was doing. Its Func<T, bool> match parameter is a delegate, and every call passed it a lambda.

It is also the whole mechanism behind LINQ, which Article 06 covers next. Where, Select and OrderBy each take a delegate and apply it to every item. Once delegates make sense, LINQ stops looking like magic.

Events

An event is a delegate that something else calls when a thing happens — a button is clicked, a page loads, a file finishes downloading.

button.Click += SaveStudent; // subscribe: call SaveStudent when clicked
button.Click -= SaveStudent; // unsubscribe: stop calling it

You hand over a method, and something else decides when to run it. You will meet this again as Handles in Track 04 — VB.NET, OnClick in Track 05 — Web Forms, and onClick in Track 12 — React.

An event with nobody subscribed is null, and calling it throws. A class raising its own event checks first:

if (StudentAdded != null)
{
StudentAdded(this, student);
}

The three things beginners get wrong

MistakeWhat happens
Func<Student, bool> f = IsActive(ravi);Calls the method instead of storing it — will not compile
Reading Func<Student, bool> as "returns a Student"The last type parameter is the return type
Expecting Action<T> to give back a valueAction returns nothing. Use Func when you need a result

IEnumerable and read-only interfaces

public static int CountActive(IEnumerable<Student> students)
{
int count = 0;

foreach (Student student in students)
{
if (student.Status == StudentStatus.Active)
{
count = count + 1;
}
}

return count;
}

IEnumerable<T> says only "you can iterate this". So the method accepts an array, a List<Student>, a HashSet<Student> or a LINQ result.

InterfaceGives you
IEnumerable<T>Iteration
IReadOnlyCollection<T>Iteration and Count
IReadOnlyList<T>Iteration, Count and indexing
ICollection<T>Add, remove, clear
IList<T>All of the above, plus indexing

Accept the least powerful interface the method needs, and return the most restrictive one the caller needs.

public class ClassRoster
{
private readonly List<Student> _students = new List<Student>();

public IReadOnlyList<Student> Students
{
get
{
return _students;
}
}

public void Enrol(Student student)
{
if (student == null)
{
throw new ArgumentNullException(nameof(student));
}

_students.Add(student);
}
}

Exposing the List<Student> directly would let any caller add or clear it, bypassing Enrol and every check it performs. IReadOnlyList<Student> allows reading and nothing else.

Equality

HashSet<T> and dictionary keys work by comparing values and hash codes. For reference types the default comparison is reference identity — two objects with identical data are still different.

Student a = new Student { RollNumber = "NCA-2024-0012" };
Student b = new Student { RollNumber = "NCA-2024-0012" };

HashSet<Student> set = new HashSet<Student>();
set.Add(a);
set.Add(b);

Console.WriteLine(set.Count); // 2 — same data, different objects

Override Equals and GetHashCode when business equality differs from identity:

public class Student
{
public int SchoolId { get; set; }
public string RollNumber { get; set; }

public override bool Equals(object obj)
{
Student other = obj as Student;

if (other == null)
{
return false;
}

return SchoolId == other.SchoolId && RollNumber == other.RollNumber;
}

public override int GetHashCode()
{
return HashCode.Combine(SchoolId, RollNumber);
}
}

Always override both together. Equal objects must return equal hash codes, or a HashSet and Dictionary will behave unpredictably — sometimes finding an item, sometimes not.

Note the key includes SchoolId. Two schools may legitimately both issue NCA-2024-0012; uniqueness is the pair, never the roll number alone. That composite rule reappears in the SQL and API tracks as UNIQUE (SchoolId, RollNumber).

record types provide value equality automatically:

public record StudentKey(int SchoolId, string RollNumber);

Errors you will hit

MessageCauseFix
CS1061: 'List<Student>' does not contain a definition for 'Length'Used Length on a listLists have Count; arrays and strings have Length
System.IndexOutOfRangeExceptionIndex outside 0 to Length - 1Loop with <, not <=
System.ArgumentOutOfRangeExceptionSame, on a List<T>Check Count before indexing
System.Collections.Generic.KeyNotFoundExceptionRead a dictionary key that is not thereUse TryGetValue
System.ArgumentException: An item with the same key has already been addedAdd with a duplicate keyUse the indexer, or check first
CS0029: Cannot implicitly convert 'string' to 'Student'Added the wrong type to a typed listThat is generics doing its job
CS1503: cannot convert from 'method group' to 'Func<...>'Wrote IsActive() where IsActive was meantDrop the brackets — you are storing the method, not calling it

The last row is the delegate trap. Func<Student, bool> f = IsActive; stores the method; = IsActive(ravi); calls it.

Common mistakes

  • <= against Length or Count, running one index too far
  • Length on a list or Count on an array
  • Contains inside a loop where a Dictionary belongs
  • dictionary[key] on a key that may not exist
  • Add on a dictionary where a duplicate is expected
  • Relying on dictionary ordering
  • Exposing a private List<T> through a public property
  • Overriding Equals without GetHashCode
  • Business equality on RollNumber without SchoolId
  • Writing filter = IsActive(ravi); when you meant filter = IsActive;
  • Invoking an event with no subscribers, without a null check
  • ArrayList or Hashtable in new code
  • Modifying a collection inside a foreach over it

Practice

  1. Create a fixed string[] of sections and read past the end. Read the exception.
  2. Build a List<Student> of five students and print them with foreach.
  3. Use Contains on that list, then explain what it does internally.
  4. Build a Dictionary<string, Student> keyed by roll number and look one up.
  5. Look up a missing key with the indexer, read the exception, then rewrite with TryGetValue.
  6. Call Add twice with the same key, then do the same with the indexer. Compare.
  7. Group students into Dictionary<string, List<Student>> by class name.
  8. Use a HashSet<string> to reject a duplicate roll number, relying on Add returning false.
  9. Compute today's absentees with ExceptWith over class members and present members.
  10. Model an admission waiting list with Queue<Student> and process two admissions.
  11. Model back-navigation with Stack<string>.
  12. Add a string to an ArrayList of students and cast it. Then repeat with List<Student> and compare where the error appears.
  13. Write the generic FindFirst<T> and call it for both Student and Teacher.
  14. Write a named method IsActive(Student), assign it to a Func<Student, bool>, and call it through the variable. Then assign IsActive(ravi) by mistake and read the compiler error.
  15. Rewrite that same test as a lambda, and confirm FindFirst<T> accepts both forms.
  16. Pass three different lambdas into one FindFirst<T> call site — by roll number, by class, by status.
  17. Declare an Action<Student> that prints a student, and a Func<FeeAccount, decimal> that returns a balance.
  18. Write a two-parameter lambda: Func<decimal, decimal, decimal> subtracting a discount from fees. Note where the brackets go.
  19. Write a lambda with a body of several lines — returning "Absent" or the marks — using braces and an explicit return.
  20. Invoke an event with no subscribers and read the exception. Then add the null check.
  21. Write Repository<T> returning a copy from GetAll. Then return _items directly and add to it from outside.
  22. Expose a roster as IReadOnlyList<Student> and try to add to it.
  23. Put two students with identical roll numbers in a HashSet and count them. Then override Equals and GetHashCode on SchoolId plus RollNumber and count again.

Exercises 21 and 22 are the encapsulation lesson from Article 04, applied to collections — the place it is most often forgotten. Exercises 14 and 16 are the pair that make delegates click: 14 shows what a delegate holds, 16 shows why that is worth having.

You can now

  • Pick the right collection from the shape of the problem
  • Use TryGetValue instead of risking a KeyNotFoundException
  • Group items into a dictionary of lists
  • Write and use a generic method
  • Expose a collection without letting callers modify it
  • Say what a delegate is and how a lambda relates to one
  • Pass behaviour into a method with Func<> or Action<>

Review questions

  1. When is a Dictionary worth the extra complexity over a List?
  2. What problem do generics solve that ArrayList had?
  3. What is a delegate, and what is a lambda's relationship to one?
  4. Why must Equals and GetHashCode always be overridden together?

Next: LINQ