Custom Types with Numbers

A rectangle has a width and a height. We could represent this with two integer variables:

int rectWidth = 10;
int rectHeight = 5;

This works for one rectangle. What about three?

int rect1Width = 10;
int rect1Height = 5;
int rect2Width = 8;
int rect2Height = 3;
int rect3Width = 12;
int rect3Height = 12;

Six variables for three rectangles. Nothing in the code connects rect1Width to rect1Height. The relationship exists only in our heads.

We solved this problem in Chapter 1 with structs. The fields hold integers instead of booleans, but the mechanics are identical.

Structs with Integer Fields

struct Rectangle
{
    public int Width;
    public int Height;
}

Translation: “Define a struct named Rectangle with public integer fields Width and Height.”

One practical detail: struct definitions must be placed either at the bottom of your Program.cs file (after all other code) or in a separate file in the same directory. They cannot appear in the middle of your program’s statements.

Creating objects and accessing fields works the same way as Chapter 1:

Rectangle rect;
rect.Width = 10;
rect.Height = 5;
 
Console.WriteLine(rect.Width);   // displays 10
Console.WriteLine(rect.Height);  // displays 5

The translations transfer directly. “Bind 10 to the Width field of the rect object.” “Evaluate the Width field of the rect object and display the result to the console.”

Here is a second struct we will use throughout this section:

struct Point
{
    public int X;
    public int Y;
}

Translation: “Define a struct named Point with public integer fields X and Y.”


Try it yourself.

Write a struct definition and create an object from this description:

Define a struct named Coordinate with public integer fields Row and Col. Create a Coordinate object named cell. Bind 3 to Row and 7 to Col.

Reveal answer
struct Coordinate
{
    public int Row;
    public int Col;
}
 
Coordinate cell;
cell.Row = 3;
cell.Col = 7;

Self-correct against the model above.


Mixed-Type Structs

In Chapter 1, every struct had boolean fields. That was not a limitation of structs. It was a limitation of what we knew. Structs can group fields of any type.

struct Student
{
    public string Name;
    public int Score;
    public bool Passed;
}

Translation: “Define a struct named Student with a public string field Name, a public integer field Score, and a public boolean field Passed.”

Three fields, three different types. The mechanism is the same: each field has a type, a name, and the public keyword. We create objects, bind to fields, and evaluate fields exactly as before.

Student alice;
alice.Name = "Alice";
alice.Score = 88;
alice.Passed = true;
 
Console.WriteLine(alice.Name);    // Alice
Console.WriteLine(alice.Score);   // 88
Console.WriteLine(alice.Passed);  // True

The struct groups data that belongs together. A student’s name, score, and pass status are related. Without a struct, they would be three unconnected variables.


Try it yourself.

Write a struct definition from this description:

Define a struct named Product with a public string field Name, a public integer field Price, and a public boolean field InStock.

Reveal answer
struct Product
{
    public string Name;
    public int Price;
    public bool InStock;
}

Self-correct against the model above.


Try it yourself.

Create a Student object named bob. Bind “Bob” to the Name field, 72 to the Score field, and true to the Passed field.

Reveal answer
Student bob;
bob.Name = "Bob";
bob.Score = 72;
bob.Passed = true;

Self-correct against the model above.


Value Type Semantics

Structs are value types. We saw this with boolean fields in Chapter 1. The same behavior applies to integer fields.

Rectangle original;
original.Width = 10;
original.Height = 5;
 
Rectangle copy = original;
copy.Width = 99;
after lineoriginal.Widthoriginal.Heightcopy.Widthcopy.Height
1-3105
5105105
6105995

When we bound original to copy, we copied all the field values. The two objects are independent from that point on. Changing the Width field of the copy object does not affect the Width field of the original object.

This is the same behavior booleans showed. It is not specific to booleans or integers. It is how structs work.


Try it yourself.

What does this code display? Trace through it before checking.

Point a;
a.X = 3;
a.Y = 7;
 
Point b = a;
b.X = 100;
a.Y = 0;
 
Console.WriteLine(a.X);
Console.WriteLine(a.Y);
Console.WriteLine(b.X);
Console.WriteLine(b.Y);
Reveal answer

Output:

3
0
100
7

State table:

after linea.Xa.Yb.Xb.Y
1-337
53737
6371007
7301007

After line 5, both objects hold the same values. After lines 6 and 7, each object changed independently. Modifying b.X did not affect a.X. Modifying a.Y did not affect b.Y.

If your answer differed, note what you missed before continuing.


Arithmetic with Fields

Boolean fields supported logical operations. Integer fields support arithmetic.

Rectangle rect;
rect.Width = 10;
rect.Height = 5;
 
int area = rect.Width * rect.Height;
int perimeter = 2 * (rect.Width + rect.Height);
 
Console.WriteLine(area);       // 50
Console.WriteLine(perimeter);  // 30

Translation of line 5: “Evaluate the Width field of the rect object and the Height field of the rect object. Multiply the results and bind the product to an integer variable named area.”

This combines two skills. Field evaluation comes from Chapter 1. Arithmetic comes from earlier in this chapter. The expression rect.Width * rect.Height evaluates each field to get an integer, then multiplies those integers.

Point p;
p.X = 3;
p.Y = 4;
 
int distanceFromOrigin = p.X * p.X + p.Y * p.Y;
Console.WriteLine(distanceFromOrigin);  // 25

The expression evaluates fields, squares them, and adds the results. Each step uses operations we already know.


Try it yourself.

Write code that creates a Rectangle object with Width 8 and Height 3, then computes and displays its area and perimeter.

Reveal answer
Rectangle rect;
rect.Width = 8;
rect.Height = 3;
 
int area = rect.Width * rect.Height;
int perimeter = 2 * (rect.Width + rect.Height);
 
Console.WriteLine(area);       // 24
Console.WriteLine(perimeter);  // 22

Self-correct against the model above.


Functions That Take Objects

We can write functions whose parameters are our custom types.

Func<Rectangle, int> Area = r => r.Width * r.Height;

Translation: “Define a function named Area that takes a Rectangle object r and returns the Width field of r multiplied by the Height field of r.”

         ┌──────────────────────────────┐
  r ────►│  r.Width * r.Height          │────► result
(Rect)   │                              │     (int)
         └──────────────────────────────┘
                     Area

A Rectangle enters on the left. The function reads its fields, computes, and an integer emerges on the right.

To call it:

Rectangle rect;
rect.Width = 10;
rect.Height = 5;
 
int size = Area(rect);

Translation: “Evaluate rect, call Area with that value, and bind the returned value to an integer variable named size.”

Let’s trace the call:

  1. Evaluate rect (get the object)
  2. Call Area with that object
  3. Inside Area, bind the object to r
  4. Evaluate r.Width (get 10)
  5. Evaluate r.Height (get 5)
  6. Multiply: 10 * 5 = 50
  7. Return 50
  8. Back where Area was called, bind 50 to size

Here are more functions on Rectangle:

Func<Rectangle, int> Perimeter = r => 2 * (r.Width + r.Height);

Translation: “Define a function named Perimeter that takes a Rectangle object r and returns 2 multiplied by the sum of the Width and Height fields of r.”

Func<Rectangle, bool> IsSquare = r => r.Width == r.Height;

Translation: “Define a function named IsSquare that takes a Rectangle object r and returns whether the Width field of r equals the Height field of r.”

Notice that IsSquare takes a Rectangle but returns a boolean. The input and output types do not have to match, just as with the Func<int, bool> signatures we saw earlier.


Try it yourself.

Translate this function definition to English:

Func<Point, int> SumCoordinates = p => p.X + p.Y;
Reveal answer

“Define a function named SumCoordinates that takes a Point object p and returns the X field of p plus the Y field of p.”

If your answer differed, note what you missed before continuing.


Try it yourself.

Write a function named IsOrigin that takes a Point object p and returns whether both the X field and Y field of p equal 0.

Reveal answer
Func<Point, bool> IsOrigin = p => p.X == 0 && p.Y == 0;

Self-correct against the model above.


Functions That Return Objects

Creating an object takes multiple lines: declare the variable, bind each field, then use it. When we need to create several objects of the same type, that setup gets repetitive. The standard practice is to write a function that handles the construction for you.

Func<int, int, Rectangle> MakeRectangle = (w, h) =>
{
    Rectangle r;
    r.Width = w;
    r.Height = h;
    return r;
};

Translation: “Define a function named MakeRectangle that takes two integers w and h and returns a Rectangle object with Width bound to w and Height bound to h.”

We name these functions with the prefix Make: MakeRectangle, MakePoint, MakeStudent. The name signals that the function’s job is to construct and return a new object. You will see this pattern in every chapter going forward.

  w ────►┌──────────────────────────────┐
  (10)   │  Rectangle r;               │────► result
  h ────►│  r.Width = w;               │     (Rectangle)
  (5)    │  r.Height = h;              │
         │  return r;                  │
         └──────────────────────────────┘
               MakeRectangle

Two integers enter. A Rectangle object emerges.

To use it:

Rectangle box = MakeRectangle(10, 5);

Translation: “Call MakeRectangle with the values 10 and 5 and bind the returned object to a Rectangle variable named box.”

Now box.Width is 10 and box.Height is 5. One line replaces three.


Try it yourself.

Write a function named MakePoint that takes two integers x and y and returns a Point object with X bound to x and Y bound to y.

Reveal answer
Func<int, int, Point> MakePoint = (x, y) =>
{
    Point p;
    p.X = x;
    p.Y = y;
    return p;
};

Self-correct against the model above.


Try it yourself.

Write a function named MakeUnitSquare that takes nothing and returns a Rectangle object with both Width and Height bound to 1.

Reveal answer
Func<Rectangle> MakeUnitSquare = () =>
{
    Rectangle r;
    r.Width = 1;
    r.Height = 1;
    return r;
};

Self-correct against the model above.


Functions That Display Objects

Some functions perform an action rather than computing a value. We use Action instead of Func for functions that return nothing.

Action<Rectangle> PrintRectangle = r =>
{
    Console.WriteLine("Width: " + r.Width);
    Console.WriteLine("Height: " + r.Height);
};

Translation: “Define a function named PrintRectangle that takes a Rectangle object r and displays its Width and Height fields to the console.”

To use it:

PrintRectangle(rect);

This displays:

Width: 10
Height: 5

No value is returned. We call the function for what it does, not for what it produces.


Try it yourself.

Write a function named PrintStudent that takes a Student object s and displays its Name, Score, and Passed fields to the console.

Reveal answer
Action<Student> PrintStudent = s =>
{
    Console.WriteLine("Name: " + s.Name);
    Console.WriteLine("Score: " + s.Score);
    Console.WriteLine("Passed: " + s.Passed);
};

Self-correct against the model above.


Combining Struct Fields with Control Flow

Struct fields can appear in conditions, switch expressions, and loops.

A function that classifies a student’s grade based on their score:

Func<Student, string> GetGrade = s => s.Score switch
{
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    >= 60 => "D",
    _ => "F"
};

Translation: “Define a function named GetGrade that takes a Student object s and returns ‘A’ where the Score field of s is greater than or equal to 90, ‘B’ where it is greater than or equal to 80, ‘C’ where it is greater than or equal to 70, ‘D’ where it is greater than or equal to 60, and ‘F’ otherwise.”

The switch expression evaluates s.Score, a field access. The patterns and the switch mechanics are the same as before. The field access is new.

A function that moves a Point to the right by a given number of steps:

Func<Point, int, Point> MoveRight = (p, steps) =>
{
    Point result;
    result.X = p.X + steps;
    result.Y = p.Y;
    return result;
};

Translation: “Define a function named MoveRight that takes a Point object p and an integer steps, and returns a new Point with its X field equal to the X field of p plus steps and its Y field equal to the Y field of p.”

This function reads from one object, does arithmetic, and creates a new object with the computed values. It combines struct creation, field evaluation, arithmetic, and returning an object.


Try it yourself.

Write a function named MoveUp that takes a Point object p and an integer steps, and returns a new Point with its X field equal to the X field of p and its Y field equal to the Y field of p plus steps.

Reveal answer
Func<Point, int, Point> MoveUp = (p, steps) =>
{
    Point result;
    result.X = p.X;
    result.Y = p.Y + steps;
    return result;
};

Self-correct against the model above.


Putting It Together: Student Grade Report

We defined a Student struct earlier with Name, Score, and Passed fields. Now we write functions that operate on it and build a complete program that reads input, validates it, creates a student, and displays a full report.

The Functions

Func<string, int, Student> MakeStudent = (name, score) =>
{
    Student s;
    s.Name = name;
    s.Score = score;
    s.Passed = score >= 60;
    return s;
};
 
Func<Student, string> GetGrade = s => s.Score switch
{
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    >= 60 => "D",
    _ => "F"
};
 
Action<Student> PrintReport = s =>
{
    Console.WriteLine("Name: " + s.Name);
    Console.WriteLine("Score: " + s.Score);
    Console.WriteLine("Grade: " + GetGrade(s));
    Console.WriteLine("Passed: " + s.Passed);
};

Notice that PrintReport calls GetGrade. Execution moves to GetGrade, computes the grade, returns, and PrintReport continues. We traced this kind of call in the functions section.

The Main Program

Console.WriteLine("Enter student name:");
string name = Console.ReadLine();
 
Console.WriteLine("Enter score (0-100):");
int score = int.Parse(Console.ReadLine());
 
while (score < 0 || score > 100)
{
    Console.WriteLine("Score must be between 0 and 100. Try again:");
    score = int.Parse(Console.ReadLine());
}
 
Student student = MakeStudent(name, score);
PrintReport(student);

The input validation loop keeps asking until the score is in range. This is the loop-until-valid pattern from the control flow section.

Tracing the Program

Let’s trace with input “Alice” and score 88.

  1. Read “Alice”, bind to name
  2. Read “88”, parse to 88, bind to score
  3. Check loop condition: 88 < 0 || 88 > 100 → false || false → false. Skip the loop.
  4. Call MakeStudent with “Alice” and 88:
Inside MakeStudent:
  Create Student object s.
  Bind "Alice" to s.Name.
  Bind 88 to s.Score.
  Evaluate 88 >= 60 → true. Bind true to s.Passed.
  Return the s object.
Back where MakeStudent was called, bind the object to student.
  1. Call PrintReport with the student object:
Inside PrintReport:
  Display "Name: Alice"
  Display "Score: 88"
  Call GetGrade with the student object:
    Inside GetGrade:
      Evaluate s.Score → 88.
      88 matches >= 80. Return "B".
    Back in PrintReport.
  Display "Grade: B"
  Display "Passed: True"

Output:

Enter student name:
Alice
Enter score (0-100):
88
Name: Alice
Score: 88
Grade: B
Passed: True

Try it yourself.

Trace the same program with input “Dan” and the following score inputs: -5, 105, 73.

Write the full trace, including each iteration of the validation loop.

Reveal answer
  1. Read “Dan”, bind to name
  2. Read “-5”, parse to -5, bind to score
  3. Check loop condition: -5 < 0 || -5 > 100 → true || false → true. Enter the loop.
  4. Display “Score must be between 0 and 100. Try again:”
  5. Read “105”, parse to 105, bind to score
  6. Check loop condition: 105 < 0 || 105 > 100 → false || true → true. Enter the loop.
  7. Display “Score must be between 0 and 100. Try again:”
  8. Read “73”, parse to 73, bind to score
  9. Check loop condition: 73 < 0 || 73 > 100 → false || false → false. Exit the loop.
  10. Call MakeStudent(“Dan”, 73): creates a Student with Name “Dan”, Score 73, Passed true (73 >= 60).
  11. Call PrintReport:
    • “Name: Dan”
    • “Score: 73”
    • GetGrade: 73 matches >= 70, returns “C”
    • “Grade: C”
    • “Passed: True”

Output:

Enter student name:
Dan
Enter score (0-100):
-5
Score must be between 0 and 100. Try again:
105
Score must be between 0 and 100. Try again:
73
Name: Dan
Score: 73
Grade: C
Passed: True

If your answer differed, note what you missed before continuing.


Try it yourself.

Write a complete program that works with a Time struct. Define a struct named Time with integer fields Hours and Minutes. Write the following functions:

  • MakeTime: takes two integers (hours, minutes) and returns a Time object
  • ToMinutes: takes a Time object and returns the total number of minutes (hours * 60 + minutes)
  • FromMinutes: takes an integer (total minutes) and returns a Time object (use integer division for hours, modulo for minutes)
  • AddTime: takes two Time objects and returns a new Time that is their sum (convert both to minutes, add, convert back)
  • PrintTime: takes a Time object and displays it in the format “H hours, M minutes”

The main program should create two Time objects (for example, 2 hours 45 minutes and 1 hour 30 minutes), add them, and display all three times.

Expected output:

2 hours, 45 minutes
1 hours, 30 minutes
4 hours, 15 minutes
Reveal answer
struct Time
{
    public int Hours;
    public int Minutes;
}
 
Func<int, int, Time> MakeTime = (h, m) =>
{
    Time t;
    t.Hours = h;
    t.Minutes = m;
    return t;
};
 
Func<Time, int> ToMinutes = t => t.Hours * 60 + t.Minutes;
 
Func<int, Time> FromMinutes = totalMinutes =>
{
    Time t;
    t.Hours = totalMinutes / 60;
    t.Minutes = totalMinutes % 60;
    return t;
};
 
Func<Time, Time, Time> AddTime = (a, b) =>
{
    int totalA = ToMinutes(a);
    int totalB = ToMinutes(b);
    return FromMinutes(totalA + totalB);
};
 
Action<Time> PrintTime = t =>
{
    Console.WriteLine(t.Hours + " hours, " + t.Minutes + " minutes");
};
 
Time first = MakeTime(2, 45);
Time second = MakeTime(1, 30);
Time sum = AddTime(first, second);
 
PrintTime(first);
PrintTime(second);
PrintTime(sum);

Trace of AddTime(first, second):

  1. Call ToMinutes with first (2 hours, 45 minutes): 2 * 60 + 45 = 165. Bind 165 to totalA.
  2. Call ToMinutes with second (1 hour, 30 minutes): 1 * 60 + 30 = 90. Bind 90 to totalB.
  3. Evaluate totalA + totalB: 165 + 90 = 255.
  4. Call FromMinutes with 255: 255 / 60 = 4 (integer division), 255 % 60 = 15. Return a Time with Hours 4 and Minutes 15.
  5. Return the Time object.

The integer division and modulo operations are doing meaningful work here. 255 minutes is 4 hours and 15 minutes.

Check your program against the model. Pay attention to FromMinutes: it uses integer division and modulo to convert a total number of minutes back into hours and minutes.


Review

Before continuing, test yourself on what you’ve learned. Use the protocol from Chapter 0: attempt each exercise from memory, then search this section to check your answers, then note what you missed.

Part 1: Definitions

Recall from Chapter 1:

  1. What is a struct?
  2. What is an object?
  3. What is a field?
  4. What does public mean for a field?

Write the definitions from memory. Check your answers against the custom types section of Chapter 1. No new definitions were introduced in this section. The same concepts apply to structs with integer fields, mixed-type fields, and any other combination.

Part 2: Translations

Translate each piece of code to English.

  1. struct Pair { public int First; public int Second; }
  2. Pair p;
    p.First = 10;
    p.Second = 20;
  3. int total = p.First + p.Second;
  4. Func<Pair, int> Sum = p => p.First + p.Second;
  5. Func<int, int, Pair> MakePair = (a, b) =>
    {
        Pair p;
        p.First = a;
        p.Second = b;
        return p;
    };
  6. Action<Pair> PrintPair = p =>
    {
        Console.WriteLine("First: " + p.First);
        Console.WriteLine("Second: " + p.Second);
    };

Check your translations against the patterns shown in this section.

Part 3: Writing Code

Write C# code for each description.

  1. Define a struct named Dimensions with public integer fields Length and Width.

  2. Create a Dimensions object named room. Bind 12 to Length and 10 to Width.

  3. Define a function named AreaOf that takes a Dimensions object d and returns the Length field of d multiplied by the Width field of d.

  4. Define a function named MakeDimensions that takes two integers l and w and returns a Dimensions object with Length bound to l and Width bound to w.

Check your code against the examples in this section.

Part 4: State Tables

Complete the state table for this code:

struct Pair { public int First; public int Second; }
 
Pair a;
a.First = 5;
a.Second = 10;
 
Pair b = a;
b.First = 99;
a.Second = 0;
after linea.Firsta.Secondb.Firstb.Second
3-5
7
8
9

Trace through each line carefully. Structs are value types.

Part 5: Complete Programs

Write a complete program for this specification:

Define a Rectangle struct with integer fields Width and Height. The program asks the user for a width and height (both must be positive integers; keep asking until valid input is provided). Create a Rectangle object using a Make function. Compute and display the area, perimeter, and whether the rectangle is a square.

Expected output for width 5 and height 5:

Enter width:
5
Enter height:
5
Width: 5
Height: 5
Area: 25
Perimeter: 20
Square: True

Expected output for width 8 and height 3:

Enter width:
8
Enter height:
3
Width: 8
Height: 3
Area: 24
Perimeter: 22
Square: False

Check your program by tracing through it with different inputs.


You now know how to define your own data types with integer fields. You can group related data into structs, write functions that compute with struct fields, and combine structured data with arithmetic, control flow, and functions.

Chapter 2 Complete

You now have the complete toolkit for integers:

  • What integers are, how to store them, how programs represent them in memory
  • How to compute with integers using arithmetic and comparison operators
  • How integers control program behavior with branching, switch expressions, and counting loops
  • How to define your own computations, including multi-statement functions and recursion
  • How to define your own data types that group related numeric information

In Chapter 3, we apply these same five questions to floating-point numbers.