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>andStack<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
| Member | Does |
|---|---|
Add | Appends one item |
AddRange | Appends 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 |
Count | How many — not Length |
Clear | Empties it |
Sort | Sorts 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
| Need | Use |
|---|---|
| Fixed size, known at creation | T[] |
| Ordered, grows and shrinks | List<T> |
| Look up by a unique key | Dictionary<TKey, TValue> |
| Unique values, fast membership | HashSet<T> |
| First in, first out | Queue<T> |
| Last in, first out | Stack<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 write | It 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.
| Type | Takes | Returns | Use it for |
|---|---|---|---|
Func<T, TResult> | One or more values | A value | A calculation or a test |
Action<T> | One or more values | Nothing | Doing something |
Predicate<T> | One value | bool | A test — identical to Func<T, bool> |
Read the angle brackets left to right. The last one is always the return type:
| Written | Read 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
| Mistake | What 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 value | Action 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.
| Interface | Gives 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
| Message | Cause | Fix |
|---|---|---|
CS1061: 'List<Student>' does not contain a definition for 'Length' | Used Length on a list | Lists have Count; arrays and strings have Length |
System.IndexOutOfRangeException | Index outside 0 to Length - 1 | Loop with <, not <= |
System.ArgumentOutOfRangeException | Same, on a List<T> | Check Count before indexing |
System.Collections.Generic.KeyNotFoundException | Read a dictionary key that is not there | Use TryGetValue |
System.ArgumentException: An item with the same key has already been added | Add with a duplicate key | Use the indexer, or check first |
CS0029: Cannot implicitly convert 'string' to 'Student' | Added the wrong type to a typed list | That is generics doing its job |
CS1503: cannot convert from 'method group' to 'Func<...>' | Wrote IsActive() where IsActive was meant | Drop 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
<=againstLengthorCount, running one index too farLengthon a list orCounton an arrayContainsinside a loop where aDictionarybelongsdictionary[key]on a key that may not existAddon a dictionary where a duplicate is expected- Relying on dictionary ordering
- Exposing a private
List<T>through a public property - Overriding
EqualswithoutGetHashCode - Business equality on
RollNumberwithoutSchoolId - Writing
filter = IsActive(ravi);when you meantfilter = IsActive; - Invoking an event with no subscribers, without a null check
ArrayListorHashtablein new code- Modifying a collection inside a
foreachover it
Practice
- Create a fixed
string[]of sections and read past the end. Read the exception. - Build a
List<Student>of five students and print them withforeach. - Use
Containson that list, then explain what it does internally. - Build a
Dictionary<string, Student>keyed by roll number and look one up. - Look up a missing key with the indexer, read the exception, then rewrite with
TryGetValue. - Call
Addtwice with the same key, then do the same with the indexer. Compare. - Group students into
Dictionary<string, List<Student>>by class name. - Use a
HashSet<string>to reject a duplicate roll number, relying onAddreturningfalse. - Compute today's absentees with
ExceptWithover class members and present members. - Model an admission waiting list with
Queue<Student>and process two admissions. - Model back-navigation with
Stack<string>. - Add a
stringto anArrayListof students and cast it. Then repeat withList<Student>and compare where the error appears. - Write the generic
FindFirst<T>and call it for bothStudentandTeacher. - Write a named method
IsActive(Student), assign it to aFunc<Student, bool>, and call it through the variable. Then assignIsActive(ravi)by mistake and read the compiler error. - Rewrite that same test as a lambda, and confirm
FindFirst<T>accepts both forms. - Pass three different lambdas into one
FindFirst<T>call site — by roll number, by class, by status. - Declare an
Action<Student>that prints a student, and aFunc<FeeAccount, decimal>that returns a balance. - Write a two-parameter lambda:
Func<decimal, decimal, decimal>subtracting a discount from fees. Note where the brackets go. - Write a lambda with a body of several lines — returning
"Absent"or the marks — using braces and an explicitreturn. - Invoke an event with no subscribers and read the exception. Then add the null check.
- Write
Repository<T>returning a copy fromGetAll. Then return_itemsdirectly and add to it from outside. - Expose a roster as
IReadOnlyList<Student>and try to add to it. - Put two students with identical roll numbers in a
HashSetand count them. Then overrideEqualsandGetHashCodeonSchoolIdplusRollNumberand 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
TryGetValueinstead of risking aKeyNotFoundException - 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<>orAction<>
Review questions
- When is a
Dictionaryworth the extra complexity over aList? - What problem do generics solve that
ArrayListhad? - What is a delegate, and what is a lambda's relationship to one?
- Why must
EqualsandGetHashCodealways be overridden together?
Next: LINQ