An array stores multiple values of the same type. A struct groups related values of different types into one unit. So far we have used these tools separately. This section combines them: arrays whose elements are structs, and structs whose fields are arrays.
The Parallel Array Problem
Consider storing data about four students. Each student has a name and a grade. With what we know, one approach is two arrays:
string[] names = new string[4];
int[] grades = new int[4];
names[0] = "Alice"; grades[0] = 92;
names[1] = "Bob"; grades[1] = 78;
names[2] = "Charlie"; grades[2] = 86;
names[3] = "Diana"; grades[3] = 95;The name at index 0 belongs with the grade at index 0. The connection between the two arrays exists only in our heads. The arrays themselves are independent.
Now suppose we want to display grades in sorted order. We call SelectionSort on the grades array:
SelectionSort(grades);
for (int i = 0; i < names.Length; i++)
{
Console.WriteLine(names[i] + ": " + grades[i]);
}Predict the output before reading on.
Reveal answer
Alice: 78
Bob: 86
Charlie: 92
Diana: 95
The grades are sorted, but the names are not. Alice is now paired with 78, which is Bob’s score. SelectionSort rearranged the elements of grades, but names was not touched. The two arrays are out of sync. Every pairing after the sort is wrong.
The problem: related data stored in separate arrays can fall out of sync whenever one array is modified independently. The solution: group the data first, then collect the groups.
Arrays of Structs
Define a struct that holds one student’s data:
struct Student
{
public string Name;
public int Grade;
}Now create an array of Student objects:
Student[] roster = new Student[4];
roster[0].Name = "Alice";
roster[0].Grade = 92;
roster[1].Name = "Bob";
roster[1].Grade = 78;
roster[2].Name = "Charlie";
roster[2].Grade = 86;
roster[3].Name = "Diana";
roster[3].Grade = 95;Each element of the array is a complete record. Name and grade travel together. If we sort the array by grade, Alice’s name moves with her score. The data cannot fall out of sync because each record is a single unit.
Translation for the first line: “Create a Student array of size 4 and store a reference to it in roster.”
Translation for roster[0].Name = "Alice";: “Go to the location of roster, shift by 0, and bind ‘Alice’ to the Name field of the element there.”
Two-Level Access
Reading a field from an array element requires two operations in sequence: shift to the element, then access the field.
Console.WriteLine(roster[2].Name);Translation: “Go to the location of roster, shift by 2, access the Name field of the element there, and display it.”
The dot reaches into the struct at that position. roster[2] is a Student object. .Name accesses the Name field of that object.
Traversal works the same way. The loop variable becomes the index, and each iteration accesses a field of the element at that position:
for (int i = 0; i < roster.Length; i++)
{
Console.WriteLine(roster[i].Name + ": " + roster[i].Grade);
}Output:
Alice: 92
Bob: 78
Charlie: 86
Diana: 95
Each iteration accesses one complete record. All fields of that record are available through the struct.
Value Type Behavior
Structs are value types. Reading an element from the array produces a copy:
Student s = roster[0];
s.Grade = 100;The variable s holds a copy of the struct at index 0. Changing s.Grade modifies the copy. The array element is untouched.
| after line | roster[0].Name | roster[0].Grade | s.Name | s.Grade |
|---|---|---|---|---|
| 1 | ”Alice” | 92 | ”Alice” | 92 |
| 2 | ”Alice” | 92 | ”Alice” | 100 |
To modify the array element, access it directly through the index:
roster[0].Grade = 100; // modifies the actual elementPractice: Arrays of Structs
Problem 1. Translate the following code to English:
struct Book
{
public string Title;
public int Pages;
}
Book[] shelf = new Book[3];
shelf[0].Title = "Dune";
shelf[0].Pages = 412;Reveal answer
“Define a struct named Book with a public string field Title and a public integer field Pages.”
“Create a Book array of size 3 and store a reference to it in shelf.”
“Go to the location of shelf, shift by 0, and bind ‘Dune’ to the Title field of the element there.”
“Go to the location of shelf, shift by 0, and bind 412 to the Pages field of the element there.”
Problem 2. Write code for this description: create a Book array of size 2 and store a reference to it in library. Set the first book’s title to “1984” with 328 pages. Set the second book’s title to “Hamlet” with 289 pages.
Reveal answer
Book[] library = new Book[2];
library[0].Title = "1984";
library[0].Pages = 328;
library[1].Title = "Hamlet";
library[1].Pages = 289;Problem 3. Write a function that takes a Book[] and returns the Book with the most pages.
Reveal answer
Func<Book[], Book> Longest = (books) =>
{
Book best = books[0];
for (int i = 1; i < books.Length; i++)
{
if (books[i].Pages > best.Pages)
{
best = books[i];
}
}
return best;
};This is the max-finding pattern from the previous sections, applied to a struct field. The comparison uses .Pages to decide which book is “greater.”
Problem 4. Predict the output:
Student copy = roster[1];
copy.Grade = 99;
Console.WriteLine(roster[1].Grade);Reveal answer
Output: 78
The variable copy holds a copy of the struct at index 1 (Bob, 78). Changing copy.Grade modifies the copy. The array element at index 1 is unchanged.
Self-correct against the models above.
Structs Containing Arrays
A student has a name and multiple grades, not just one. A browser has a history of pages visited. A playlist has a sequence of songs. When a collection belongs to an entity, it belongs inside the struct as a field.
We need a different version of Student. The earlier definition had a single Grade field. This version replaces it with an array:
struct Student
{
public string Name;
public int[] Grades;
}The Grades field is an array. Arrays are reference types. The struct does not contain the array data itself. It contains a reference to an array object that lives elsewhere in memory.
Creating and populating a Student with multiple grades:
Student alice;
alice.Name = "Alice";
alice.Grades = new int[4];
alice.Grades[0] = 92;
alice.Grades[1] = 88;
alice.Grades[2] = 95;
alice.Grades[3] = 91;Translation for alice.Grades = new int[4];: “Create an integer array of size 4 and store a reference to it in the Grades field of the alice object.”
Translation for alice.Grades[0] = 92;: “Go to the location stored in the Grades field of alice, shift by 0, and store 92 there.”
Memory diagram:
alice (struct, on the stack)
┌──────────────────────────┐
│ Name: "Alice" │
│ Grades: ──────────────────────► ┌────┬────┬────┬────┐
│ │ │ 92 │ 88 │ 95 │ 91 │
└──────────────────────────┘ └────┴────┴────┴────┘
(array object in memory)
The struct sits on the stack. Its Name field holds a string value. Its Grades field holds a reference (an arrow) pointing to an array object stored elsewhere in memory.
The Copying Question
Earlier in the chapter, we saw that copying one array variable to another copies the reference, not the data. Structs are value types, so copying a struct copies its fields. What happens when a struct contains a reference type field?
Student alice;
alice.Name = "Alice";
alice.Grades = new int[4];
alice.Grades[0] = 92;
alice.Grades[1] = 88;
alice.Grades[2] = 95;
alice.Grades[3] = 91;
Student bob = alice;
bob.Name = "Bob";
bob.Grades[0] = 50;Predict: after line 10, what is the value of alice.Name? What is the value of alice.Grades[0]? Write your answers before reading on.
Reveal answer
alice.Name is "Alice". alice.Grades[0] is 50.
The Name result follows the rules for value types: bob gets its own copy of all fields, so changing bob.Name does not affect alice.Name.
The Grades result follows the rules for reference types. The Grades field is a reference to an array object. Copying the struct copies the reference, not the array. After Student bob = alice;, both alice.Grades and bob.Grades point to the same array object. When line 10 writes to bob.Grades[0], it modifies the shared array, and that change is visible through alice.Grades[0].
| after line | alice.Name | alice.Grades[0] | bob.Name | bob.Grades[0] | Same array? |
|---|---|---|---|---|---|
| 8 | ”Alice” | 92 | ”Alice” | 92 | Yes |
| 9 | ”Alice” | 92 | ”Bob” | 92 | Yes |
| 10 | ”Alice” | 50 | ”Bob” | 50 | Yes |
alice (struct) bob (struct)
┌──────────────────┐ ┌──────────────────┐
│ Name: "Alice" │ │ Name: "Bob" │
│ Grades: ────────────---─┐┌──-───── :Grades │
└──────────────────┘ ││ └──────────────────┘
▼▼
┌────┬────┬────┬────┐
│ 50 │ 88 │ 95 │ 91 │
└────┴────┴────┴────┘
Two struct boxes. Separate Name values. Both Grades arrows pointing to the same array block.
This is reference semantics from earlier in the chapter, now appearing inside a value type. The concept is the same. The context is new: the outer container (the struct) copies its fields, but one of those fields is a reference that still points to shared data.
Practice: Structs Containing Arrays
Problem 1. Define a Team struct with a string Name field and an int[] Scores field. Create a team named “Hawks” with scores {88, 92, 76}. Compute the team’s average score using a loop.
Reveal answer
struct Team
{
public string Name;
public int[] Scores;
}
Team hawks;
hawks.Name = "Hawks";
hawks.Scores = new int[3];
hawks.Scores[0] = 88;
hawks.Scores[1] = 92;
hawks.Scores[2] = 76;
int total = 0;
for (int i = 0; i < hawks.Scores.Length; i++)
{
total += hawks.Scores[i];
}
double average = (double)total / hawks.Scores.Length;Problem 2. Predict the output of this code:
Team a;
a.Name = "Hawks";
a.Scores = new int[] {10, 20, 30};
Team b = a;
b.Name = "Eagles";
b.Scores[1] = 99;
Console.WriteLine(a.Name);
Console.WriteLine(a.Scores[1]);Reveal answer
Hawks
99
b is a copy of the struct. Changing b.Name does not affect a.Name because it binds a new value to b’s Name field. But b.Scores and a.Scores share the same array object, so b.Scores[1] = 99 is visible through a.Scores[1].
Problem 3. Draw a memory diagram for the state after Problem 2. Show which parts are value data and which are references.
Reveal answer
a (struct) b (struct)
┌──────────────────┐ ┌──────────────────┐
│ Name: "Hawks" │ │ Name: "Eagles" │
│ Scores: ────────────---─┐┌─────── :Scores │
└──────────────────┘ ││ └──────────────────┘
▼▼
┌────┬────┬────┐
│ 10 │ 99 │ 30 │
└────┴────┴────┘
Name fields hold separate values (value data). Scores fields hold references to the same array object.
Self-correct against the models above.
Higher-Order Functions with Structured Data
In Chapter 1, we wrote a function called IsSecure:
Func<Door, bool> IsSecure = d => !d.IsOpen && d.IsLocked;This function takes a Door and returns a boolean. It is a predicate. We passed predicates like IsPositive and IsEven to Filter in the previous section. Can we do the same with IsSecure?
Here is Filter rewritten for Door arrays, with IsSecure as the predicate:
struct Door
{
public bool IsOpen;
public bool IsLocked;
}
Func<Door, bool> IsSecure = d => !d.IsOpen && d.IsLocked;
Func<Door[], Func<Door, bool>, Door[]> FilterDoors = (arr, predicate) =>
{
int count = 0;
for (int i = 0; i < arr.Length; i++)
{
if (predicate(arr[i]))
{
count++;
}
}
Door[] result = new Door[count];
int fillIndex = 0;
for (int i = 0; i < arr.Length; i++)
{
if (predicate(arr[i]))
{
result[fillIndex] = arr[i];
fillIndex++;
}
}
return result;
};
Door[] allDoors = new Door[3];
allDoors[0].IsOpen = false; allDoors[0].IsLocked = true;
allDoors[1].IsOpen = true; allDoors[1].IsLocked = false;
allDoors[2].IsOpen = false; allDoors[2].IsLocked = true;
Door[] secureDoors = FilterDoors(allDoors, IsSecure);
// secureDoors has 2 elements: allDoors[0] and allDoors[2]Compare FilterDoors to the integer Filter from the previous section. The loop structure is identical: count matches in one pass, create a new array, fill it in a second pass. The only difference is the type. int became Door. Func<int, bool> became Func<Door, bool>. The pattern transferred with no changes to the logic.
The same applies to Fold. In the previous section, Fold accumulated an integer result by combining the accumulator with each integer element. We can fold over a Student array the same way, but there is one difference worth noting: the accumulator type and the element type do not have to match. When summing integers, both were int. When summing grades from a Student array, the accumulator is an int (the running total) and each element is a Student (a struct). The combine function bridges the two: it takes the integer accumulator and a Student, extracts the grade, and produces a new integer.
Here is the full example. We use the single-grade version of Student from earlier in this section:
struct Student
{
public string Name;
public int Grade;
}
Func<int, Student, int> AddGrade = (total, s) => total + s.Grade;
Func<Student[], int, Func<int, Student, int>, int> FoldStudents =
(arr, initial, combine) =>
{
int acc = initial;
for (int i = 0; i < arr.Length; i++)
{
acc = combine(acc, arr[i]);
}
return acc;
};
Student[] roster = new Student[3];
roster[0].Name = "Alice"; roster[0].Grade = 92;
roster[1].Name = "Bob"; roster[1].Grade = 78;
roster[2].Name = "Diana"; roster[2].Grade = 95;
int totalGrades = FoldStudents(roster, 0, AddGrade);
// totalGrades is 265The combine function AddGrade takes an integer accumulator and a Student, and produces a new integer by adding the student’s grade. The loop body is the same as the integer Fold: acc = combine(acc, arr[i]). The pattern works whenever the combine function has the right shape, regardless of whether the accumulator and element types match.
Separating Traversal from Action
In Chapter 1, we wrote PrintDoor:
Action<Door> PrintDoor = d => {
Console.WriteLine("Open: " + d.IsOpen);
Console.WriteLine("Locked: " + d.IsLocked);
};To print every door in an array, we would write a loop:
for (int i = 0; i < allDoors.Length; i++)
{
PrintDoor(allDoors[i]);
}Now suppose we have a Student array and a function that displays one student:
Action<Student> PrintStudent = s => {
Console.WriteLine(s.Name + ": " + s.Grade);
};
for (int i = 0; i < roster.Length; i++)
{
PrintStudent(roster[i]);
}Both loops have the same structure. They traverse an array and call a function on each element. The traversal is identical. The per-element action is the only thing that changes.
A higher-order function separates the traversal from the action. Here it is for Door arrays:
Action<Door[], Action<Door>> ForEachDoor = (arr, action) =>
{
for (int i = 0; i < arr.Length; i++)
{
action(arr[i]);
}
};Type: Action<Door[], Action<Door>>.
Translation using the list-style form: “A function that takes:
- a Door array,
- a function that takes a Door and returns nothing (the action),
and returns nothing.”
Now printing every door requires no loop at the call site:
ForEachDoor(allDoors, PrintDoor);One line. The traversal is inside ForEachDoor. The action is a parameter.
Here is the same pattern for Student arrays:
Action<Student[], Action<Student>> ForEachStudent = (arr, action) =>
{
for (int i = 0; i < arr.Length; i++)
{
action(arr[i]);
}
};
ForEachStudent(roster, PrintStudent);The loop body is identical in both functions. The only difference is the type: Door became Student, Action<Door> became Action<Student>. The traversal pattern transfers across struct types the same way Filter and Fold did.
To do something different with each element, pass a different action. A function that displays only the student’s name:
Action<Student> PrintName = s => Console.WriteLine(s.Name);
ForEachStudent(roster, PrintName);Same traversal, different behavior. The loop is written once. The action is swappable.
Practice. Define a Book struct with string Title and int Pages fields. Write a ForEachBook function. Then write an action named PrintTitle that displays one book’s title, and call ForEachBook with it.
Reveal answer
Action<Book[], Action<Book>> ForEachBook = (arr, action) =>
{
for (int i = 0; i < arr.Length; i++)
{
action(arr[i]);
}
};
Action<Book> PrintTitle = b => Console.WriteLine(b.Title);
ForEachBook(shelf, PrintTitle);The loop body is the same as ForEachDoor and ForEachStudent. The type changed. The structure did not.
Self-correct against the model above.
Finding the Best by Criterion
In the previous sections, Max found the largest integer by hard-coding the > comparison. A higher-order version takes the comparison as a parameter. The caller decides what “best” means.
Func<Student, Student, Student> HigherGrade = (a, b) =>
{
if (a.Grade > b.Grade) return a;
return b;
};
Func<Student, Student, Student> AlphabeticallyFirst = (a, b) =>
{
if (string.Compare(a.Name, b.Name) < 0) return a;
return b;
};
Func<Student[], Func<Student, Student, Student>, Student> Best =
(arr, better) =>
{
Student current = arr[0];
for (int i = 1; i < arr.Length; i++)
{
current = better(current, arr[i]);
}
return current;
};
Student[] roster = new Student[3];
roster[0].Name = "Alice"; roster[0].Grade = 92;
roster[1].Name = "Bob"; roster[1].Grade = 78;
roster[2].Name = "Diana"; roster[2].Grade = 95;
Student topScorer = Best(roster, HigherGrade);
// topScorer.Name is "Diana", topScorer.Grade is 95
Student firstName = Best(roster, AlphabeticallyFirst);
// firstName.Name is "Alice"Best has the same shape as Fold from the previous section. It starts with an initial value (arr[0]), traverses the remaining elements, and updates the accumulator using the better function. The better function takes the current best and the next element, and returns whichever one is “better.” Pass HigherGrade to find the student with the highest grade. Pass AlphabeticallyFirst to find the student whose name comes first. Same traversal, different criterion.
string.Compare is a built-in function that compares two strings alphabetically. It returns a negative number when the first string comes before the second, zero when they are equal, and a positive number when the first comes after the second. The expression string.Compare(a.Name, b.Name) < 0 is true when a.Name comes before b.Name in alphabetical order.
Practice: Higher-Order Functions with Structs
Problem 1. Translate this type to the list-style form:
Func<Student[], Func<Student, bool>, Student[]>
Reveal answer
“A function that takes:
- a Student array,
- a function that takes a Student and returns a boolean (the predicate),
and returns a Student array.”
Problem 2. Write a predicate that takes a Student and returns true if the student’s grade is 90 or above. Then write a call to FilterStudents (assume it exists with the same structure as FilterDoors) that keeps only those students.
Reveal answer
Func<Student, bool> IsHighScorer = s => s.Grade >= 90;
Student[] honors = FilterStudents(roster, IsHighScorer);Problem 3. Write a “better” function that compares two Books by page count and returns the one with more pages. Then write a call that finds the longest book. Assume a function named BestBook exists with the same structure as the Student version of Best.
Reveal answer
Func<Book, Book, Book> MorePages = (a, b) =>
{
if (a.Pages > b.Pages) return a;
return b;
};
Book longest = BestBook(shelf, MorePages);Problem 4. Predict the result of this code. Assume ForEachDoor and PrintDoor are defined as shown above.
Func<Door, bool> IsInsecure = d => d.IsOpen || !d.IsLocked;
Door[] filtered = FilterDoors(allDoors, IsInsecure);
ForEachDoor(filtered, PrintDoor);Use the allDoors array from the FilterDoors example (one open/unlocked door, two closed/locked doors).
Reveal answer
IsInsecure returns true when a door is open or unlocked. From the three doors:
- Door 0: closed, locked.
false || !true=false || false= false. Not insecure. - Door 1: open, unlocked.
true || !false=true || true= true. Insecure. - Door 2: closed, locked.
false || !true=false || false= false. Not insecure.
filtered contains one door (Door 1). ForEachDoor calls PrintDoor on it.
Output:
Open: True
Locked: False
Self-correct against the models above.
Composing Across Nested Data
Here is the problem that ties the section together. We have an array of Students, and each Student has a Grades array field. We want to find the student with the highest average grade.
This requires two levels of computation. The inner level computes one student’s average by folding over their Grades array. The outer level traverses the Student array and tracks which student has the best average.
Start with the data. We use the version of Student with a Grades array field from earlier in this section:
struct Student
{
public string Name;
public int[] Grades;
}
Student[] roster = new Student[3];
roster[0].Name = "Alice";
roster[0].Grades = new int[] {92, 88, 95, 91};
roster[1].Name = "Bob";
roster[1].Grades = new int[] {78, 82, 74};
roster[2].Name = "Diana";
roster[2].Grades = new int[] {95, 90, 88, 97};The inner computation: a function that computes a single student’s average grade.
Func<Student, double> AverageGrade = (s) =>
{
int total = 0;
for (int i = 0; i < s.Grades.Length; i++)
{
total += s.Grades[i];
}
return (double)total / s.Grades.Length;
};This function takes a Student and returns a double. It accesses the Grades field (a reference to an array), traverses that array, sums the elements, and divides by the length.
The outer computation: traverse the roster and find the student whose AverageGrade is highest.
Func<Student, Student, Student> HigherAverage = (a, b) =>
{
if (AverageGrade(a) > AverageGrade(b)) return a;
return b;
};
Func<Student[], Func<Student, Student, Student>, Student> Best =
(arr, better) =>
{
Student current = arr[0];
for (int i = 1; i < arr.Length; i++)
{
current = better(current, arr[i]);
}
return current;
};
Student topStudent = Best(roster, HigherAverage);
Console.WriteLine(topStudent.Name);Output: Diana
Let’s trace through this. Best starts with current bound to roster[0] (Alice).
Iteration 1: compare Alice and Bob.
AverageGrade(Alice): sum of {92, 88, 95, 91} = 366, divided by 4 = 91.5AverageGrade(Bob): sum of {78, 82, 74} = 234, divided by 3 = 78.0- 91.5 > 78.0, so
HigherAveragereturns Alice.currentstays Alice.
Iteration 2: compare Alice and Diana.
AverageGrade(Alice): 91.5 (same as before)AverageGrade(Diana): sum of {95, 90, 88, 97} = 370, divided by 4 = 92.5- 91.5 is not greater than 92.5, so
HigherAveragereturns Diana.currentbecomes Diana.
Best returns Diana.
The inner operation (averaging one student’s grades) is a function. The outer operation (finding the best student) uses that function at each comparison. Two levels of data, two levels of computation, composed through function calls.
Consider what this same program looks like with hand-written nested loops and manual tracking:
int bestIndex = 0;
double bestAvg = 0;
for (int i = 0; i < roster.Length; i++)
{
int total = 0;
for (int j = 0; j < roster[i].Grades.Length; j++)
{
total += roster[i].Grades[j];
}
double avg = (double)total / roster[i].Grades.Length;
if (i == 0 || avg > bestAvg)
{
bestAvg = avg;
bestIndex = i;
}
}
Console.WriteLine(roster[bestIndex].Name);This version works, but the inner loop (computing an average) and the outer strategy (tracking the best) are tangled together in a single block of code. The composed version separates them: AverageGrade handles the inner computation, Best handles the outer traversal, and HigherAverage connects the two. Each piece has one job and a name that describes it.
Review
Before continuing, test yourself on what you have learned. Attempt each exercise from memory, then search this section to check your answers. Note what you missed.
Part 1: Concepts
Answer each question in your own words, then check against the explanations in this section.
- What problem do parallel arrays have that arrays of structs solve?
- Why does copying a struct with an array field lead to shared data?
- What is two-level access? Give an example.
Part 2: Translations
Translate each piece of code to English.
roster[3].Grade = 88;Student s = roster[0];alice.Grades[2] = 95;ForEachDoor(allDoors, PrintDoor);
Check your translations against the patterns shown in this section.
Part 3: Memory Diagrams
Draw memory diagrams for each situation:
- An array of 3 Student structs (Name and Grade fields), after all fields are assigned.
- A Student struct with a Name field and a Grades array field, after the Grades array is created and filled.
- Two Student variables where one is a copy of the other, and both share a Grades array. Show which parts are independent and which are shared.
Part 4: Write Code
- Define a
Productstruct with astring Namefield and adouble Pricefield. Create an array of 3 products and fill in their data. - Write a function that takes a
Product[]and returns the Product with the lowest price. - Write a predicate that takes a Product and returns true if the price is under 10.0. Use it with a Filter function to produce an array of affordable products.
- Write a “better” function that compares two Products and returns the one with the higher price. Use it with Best to find the most expensive product.
Check your code against the examples in this section.
Part 5: Predict and Trace
struct Player
{
public string Name;
public int[] Scores;
}
Player p1;
p1.Name = "Ada";
p1.Scores = new int[] {10, 20, 30};
Player p2 = p1;
p2.Name = "Bea";
p2.Scores[0] = 99;
Console.WriteLine(p1.Name);
Console.WriteLine(p1.Scores[0]);
Console.WriteLine(p2.Name);
Console.WriteLine(p2.Scores[0]);Predict the output. Then trace through the program and draw a memory diagram showing the state after all four lines execute. Verify that your predictions match the trace.
Part 6: Composition
Given an array of Students where each Student has a Name and a Grades array field:
- Write a function that computes a single student’s lowest grade.
- Write a “better” function that compares two students and returns the one with the higher lowest grade.
- Use Best with your “better” function to find the student whose worst performance is the best.
This is the same composition pattern from the worked example: an inner function operates on one student’s data, and an outer function uses it to compare students.
Check your functions by tracing through them with a small data set.
Previous: Section 4 - Functions on Arrays