Beginner's Perl Program

gradebook.pl

A Student Grade Book — annotated tour of Perl

A single, self-contained program that builds a working class grade book while introducing every major Perl feature in order. Read it top to bottom; each section builds on the last. Run it with perl gradebook.pl and it produces real output plus two files: a text report and a CSV.

17 language features ~350 lines zero dependencies beyond core perl 5.10+
01 Scalars $var strings numbers sprintf

A scalar is Perl's basic unit of data. It holds exactly one thing — a number, a string, or a reference. Every scalar variable starts with $. You don't declare a type; Perl figures it out from context.

Always start every Perl file with use strict; use warnings;. These two pragmas turn on the compiler's safety features and catch most beginner mistakes before the program even runs.

gradebook.plperl
#!/usr/bin/env perl
use strict;
use warnings;
use feature 'say';
use List::Util   qw(sum max min);

my $class_name    = "Introduction to Programming";
my $teacher       = "Ms. Hernandez";
my $passing_score = 60;
my $bonus_points  = 2.5;

# "" interpolates. '' is always literal.
say "Class: $class_name";
say 'Teacher: $teacher';   # prints: $teacher

# . concatenates.  x repeats.
my $separator = "=" x 50;
my $title     = $class_name . " — " . $teacher;

# sprintf formats a string without printing it
my $label = sprintf("%-20s  %s", "Semester:", "Fall 2025");
say $label;

# printf prints a formatted string directly
my $weeks = 16;
printf "Weeks: %d  Hours: %.1f\n", $weeks, $weeks * 1.5;
Always first use strict forces you to declare all variables with my. use warnings warns about things like using undef as a number. Never skip these.
$ sigil Every scalar starts with $. Think of it as "give me one thing."
Interpolation Double quotes "..." expand variables and \n escapes. Single quotes '...' are completely literal — nothing is expanded.
say vs print say is print with a newline automatically appended. It requires use feature 'say'.
Operators . concatenates strings (not +). x repeats a string. ** is exponentiation.
output
Class:    Introduction to Programming
Teacher:  $teacher
Teacher:  Ms. Hernandez

==================================================
Class: Introduction to Programming — Fall 2025

Semester:             Fall 2025
Weeks: 16  Hours: 24.0
02 Arrays @var push/pop sort slice qw()

An array is an ordered list of scalars. It uses the @ sigil. When you pull one element out, the sigil becomes $ — you're extracting a scalar. Perl arrays grow and shrink dynamically.

@array = (...$array[0]$#arrayscalar @arraypush / popshift / unshiftsort { $a <=> $b }@array[0..2]
gradebook.plperl
my @subjects = ("Math", "English", "Science");
my @scores   = (88, 74, 92, 61, 95);
my @counts   = (1..10);   # range → (1,2,3,...,10)

# qw() = "quote words" — shorthand for a list of strings
my @days = qw(Mon Tue Wed Thu Fri);

say join(", ", @subjects);  # glue into a string
say $subjects[0];           # "Math"  — zero-indexed
say $subjects[-1];          # "Science" — last element
say scalar(@subjects);      # 3 — element count
say $#subjects;             # 2 — last index

push    @subjects, "Art";   # append
my $p = pop  @subjects;     # remove & return last
unshift @subjects, "PE";   # prepend
my $s = shift @subjects;    # remove & return first

# Slices — extract multiple elements at once
my @first_two = @subjects[0..1];
my @picked    = @scores[0, 2, 4];

# Sorting — cmp is string sort, <=> is numeric
my @alpha = sort                   @subjects;
my @numer = sort { $a <=> $b } @scores;
my @rev   = reverse @alpha;
Sigil shift @subjects is the whole array. $subjects[0] is one element (sigil changes to $). @subjects[0,2] is a slice (sigil stays @).
qw() shortcut qw(Mon Tue Wed) is exactly the same as ("Mon","Tue","Wed") — just faster to type.
Stack & queue push/pop work on the end (stack). unshift/shift work on the front (queue). Both return the removed element.
$a and $b Inside a sort block, $a and $b are special — they're the two elements being compared. Never name your own variables $a or $b.
03 Hashes %var => keys values exists delete

A hash stores key-value pairs — like a dictionary in Python or an object in JavaScript. It uses the % sigil. Keys are always strings; values are scalars. Order is not guaranteed, so use sort keys %hash for consistent output.

gradebook.plperl
my %grade_letter = (
    'A' => 90,  # => is the "fat comma"
    'B' => 80,  # it auto-quotes the left side
    'C' => 70,  # and reads as "maps to"
    'D' => 60,
    'F' =>  0,
);

my %info = (
    name  => "Alice Chen",
    gpa   => 3.8,
    year  => 2,
);

# Access one value
say $info{name};     # "Alice Chen"
say $info{missing};  # undef (no error)

# exists: is the key there?  defined: is value not undef?
if (exists $info{name})  { say "has name"  }
if (exists $info{phone}) { say "has phone" }

# Add and delete
$info{email} = 'a@school.edu';
delete $info{email};

# Iterate in sorted key order
for my $key (sort keys %info) {
    printf "  %-8s => %s\n", $key, $info{$key};
}

# each() gives (key, value) pairs — for while loops
while ((my ($k, $v) = each %grade_letter)) {
    say "  $k : $v%";
}
% sigil, $ access The hash is %info. One value is $info{key} — the sigil shifts to $ because you're getting one scalar.
Fat comma => => is just a comma that auto-quotes the left side. name => "Alice" and "name", "Alice" are identical — but => reads more like a map.
exists vs defined exists $h{k} — is the key in the hash at all?
defined $h{k} — is the value not undef?
A key can exist but have an undef value.
Unordered Hash key order is not preserved. Always sort keys %h for consistent, repeatable output.
04 Complex data structures [ ] hashref { } arrayref -> nested access

Real programs need nested data. A hashref { } and arrayref [ ] create anonymous data structures stored as scalars. The -> arrow operator dereferences them. This is the most common pattern in Perl — arrays of hashrefs (like a list of records).

gradebook.plperl
# An array of hashrefs — each element is a student record
my @students = (
    { name => "Alice Chen",
      scores => [92, 88, 95, 91, 87],
      email => 'alice@school.edu'   },
    { name => "Bob Martinez",
      scores => [74, 68, 72, 80, 65],
      email => 'bob@school.edu'     },
    # ... more students ...
);

# array[i]{key}  — element of array, then hash field
say $students[0]{name};

# array[i]{key}[j] — nested array inside the hash
say $students[0]{scores}[2];   # 95 (third score)

# @{ } dereferences the arrayref into a real array
my @alices_scores = @{ $students[0]{scores} };
say join(", ", @alices_scores);  # 92, 88, 95, 91, 87

# Arrow notation is equivalent and often clearer
say $students[0]->{name};
say $students[0]->{scores}->[2];
# Adjacent brackets: arrow between [] and {} is optional:
say $students[0]{scores}[2];   # same thing
[ ] vs ( ) [1,2,3] creates an arrayref — a scalar holding an array. (1,2,3) creates a plain list. Use [ ] inside other data structures.
{ } overloaded {key => val} creates a hashref. But { } also delimits code blocks. Perl infers which you mean from context. When in doubt, add a +: +{key=>val} always means hashref.
Arrow rules Between two brackets — [0]{name} or {k}[0] — the -> is optional. Write whichever is clearest.
05 Subroutines sub @_ my (...) = @_ return

Perl subroutines receive all their arguments as a flat list in @_. There are no declared parameter types — you unpack @_ yourself at the top of the sub. The last expression evaluated is returned implicitly, but explicit return is clearer.

gradebook.plperl
# Basic: unpack @_ into named variables
sub calculate_average {
    my (@scores) = @_;
    return 0 unless @scores;  # guard clause
    return sum(@scores) / scalar(@scores);
}

# Postfix if/unless as guard clauses is idiomatic
sub letter_grade {
    my ($avg) = @_;
    return 'A' if $avg >= 90;
    return 'B' if $avg >= 80;
    return 'C' if $avg >= 70;
    return 'D' if $avg >= 60;
    return 'F';
}

# Returning multiple values — Perl makes this natural
sub score_stats {
    my (@scores) = @_;
    return (
        avg => calculate_average(@scores),
        max => max(@scores),
        min => min(@scores),
    );
}

# Receiving multiple return values into a hash
my %stats = score_stats(80, 90, 85, 70);
printf "avg=%.1f  max=%d  min=%d\n",
    $stats{avg}, $stats{max}, $stats{min};

# Named parameters: receive a hash of arguments
sub make_report {
    my (%args) = @_;
    my $title = $args{title} // "Report";  # // = defined-or
    return "[$title]";
}
make_report(title => "Grades", term => "Fall");
@_ holds all args Arguments arrive as a flat list. If you pass (1,2,3) and an array @a, they all merge. Always unpack first.
Guard clauses return 0 unless @scores; at the top of a sub is an idiomatic "early exit" — it prevents the rest of the function from running on bad input.
Multiple returns Perl subs can return a list. The caller decides whether to capture it as a list or a hash. No special syntax needed.
Defined-or // $x // $default returns $x if defined, otherwise $default. Better than || when 0 or "" are valid values.
06 Control flow if elsif unless ternary postfix

Perl's conditionals should feel familiar, with two distinctive features: unless (which is "if not") and postfix modifiers that let you put the condition after the statement — great for guard clauses and short actions.

block formperl
if ($avg >= 90) {
    say "Excellent";
} elsif ($avg >= 70) {
    say "Passing";
} else {
    say "Failing";
}

# unless = "if not"
unless ($avg >= 60) {
    say "Failing!";
}
postfix formperl
# Condition goes AFTER the statement
say "Pass"  if     $avg >= 60;
say "Fail"  unless $avg >= 60;

# Great for guard clauses
return      if     !defined $input;
die "bad\n" if     $error;

# Ternary: COND ? TRUE : FALSE
my $msg = $avg >= 60
    ? "Passing"
    : "Failing";
elsif not else if It is elsif — not else if and not elif. Curly braces are always required; there is no braceless one-liner form like C has.
07 Loops for foreach while last next labels

for and foreach are identical keywords. $_ is the implicit loop variable — when you don't give a name, that's what you get. last = break, next = continue, redo = restart iteration.

gradebook.plperl
# foreach with a named variable — preferred for readability
for my $student (@students) {
    printf "%-18s  avg:%5.1f  %s\n",
        $student{name}, $student{avg},
        ($student{pass} ? "passing" : "FAILING");
}

# C-style for — useful when you need the index
for (my $i = 0; $i < @students; $i++) {
    say "[$$i] $students[$i]{name}";
}

# while — condition checked before each iteration
while (my $line = <STDIN>) {
    chomp $line;
    last if $line eq 'quit';  # break out of loop
    next unless $line;         # skip empty lines
    say "  Got: $line";
}

# Postfix for — neat one-liners
say "  $_" for @names;   # $_ = each element

# Labels for nested loop control
STUDENT: for my $s (@students) {
    for my $score (@{$s{scores}}) {
        if ($score < 65) {
            say "at risk: $s{name}";
            next STUDENT;   # jump to next $s
        }
    }
}
08 map · grep · sort Functional list transforms — Perl's power tools

These three functions transform lists functionally — they never modify the original. map transforms each element, grep filters, and sort orders. They compose naturally and replace most explicit loops. $_ is the current element inside the block.

gradebook.plperl
# map: transform — runs block once per element, returns new list
my @all_avgs = map {
    my @s = @{ $_{scores} };   # $_ = current student hash
    calculate_average(@s)
} @students;

# grep: filter — keeps elements where block returns true
my @passing   = grep { $_{pass}        } @students;
my @honor     = grep { $_{avg} >= 90  } @students;
my @failing   = grep { !$_{pass}       } @students;

# sort with a comparison block ($a and $b are special)
my @ranked = sort { $b{avg} <=> $a{avg} } @students;

# Schwartzian Transform: efficient sort by computed key
# 1. map: attach the key   2. sort   3. map: strip it off
my @by_best = map  { $_[0] }
              sort { $b[1] <=> $a[1] }
              map  { [$_, max(@{$_{scores}})] }
              @students;

# Build a frequency hash with map + a loop
my %grade_count;
$grade_count{ $_{grade} }++ for @students;

# Grade distribution bar chart
for my $g (sort keys %grade_count) {
    my $bar = "█" x $grade_count{$g};
    printf "  %s: %s (%d)\n", $g, $bar, $grade_count{$g};
}
output
Passing: 5 students    Failing: 1    Honor roll: 2

Rankings (sorted by average):
  1. Eve Patel            97.8  A
  2. Alice Chen           90.6  A
  3. Carol Williams       89.8  B
  4. Bob Martinez         71.8  C
  5. David Kim            61.0  D
  6. Frank Johnson        45.8  F

Grade distribution:
  A: ██ (2)
  B: █ (1)
  C: █ (1)
  D: █ (1)
  F: █ (1)
09 Regular expressions =~ // s/// tr/// captures modifiers

Regex is baked into the Perl language — it's not a library. =~ binds a string to an operation. // matches, s/// substitutes, tr/// transliterates. The /g flag in list context returns all matches at once.

gradebook.plperl
# =~ tests a match. !~ tests non-match.
if ($email =~  /\@/)  { say "has @"    }
if ($email !~  /\d/)  { say "no digits" }

# /g in list context returns ALL matches as a list
my @caps = ($text =~ /\b[A-Z]{2,}\b/g);
say join(", ", @caps);   # ALICE, MATH

# Capture groups ( ) → $1, $2, ...
if ($name =~ /^(\w+)\s+(.+)$/) {
    my ($first, $last) = ($1, $2);
}

# Named captures (?<name>) → %+
if ($date =~ /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/) {
    say "Year: $+{y}  Month: $+{m}";
}

# Substitution: s/PATTERN/REPLACEMENT/flags
$str =~ s/foo/bar/;        # replace first
$str =~ s/foo/bar/g;       # replace all
$str =~ s/foo/bar/gi;      # global + case-insensitive

# /r flag: return copy instead of modifying $str
my $new = $str =~ s/old/new/gr;

# /e flag: evaluate replacement as Perl code
$str =~ s/(\d+)/$1*2/ge;   # double every number

# Fix ALLCAPS to Title Case
(my $clean = $text) =~ s/\b([A-Z]+)\b/\u\L$1/g;

# tr///: character mapping (not regex — char-by-char)
$str =~ tr/a-z/A-Z/;          # uppercase all
my $vowels = ($str =~ tr/aeiou//); # count vowels
$str =~ tr/aeiou//d;            # /d = delete matches
output
All-caps words: ALICE, MATH
Date parsed: year=2025  month=09  day=15
Original:  student scored EXCELLENT results in all SUBJECTS today.
Fixed:     student scored Excellent results in all Subjects today.
Template:  Alice avg is 90.6 and Bob avg is 71.8.
ROT13 of 'Hello World': Uryyb Jbeyq
10 References \ [ ] { } sub{} -> ref()

A reference is a scalar that holds the memory address of another value. Use \ to reference an existing variable, or [ ] / { } to create anonymous data inline. Code references — sub { } — let you store functions in variables.

gradebook.plperl
# Creating references
my $aref = \@array;           # reference to existing array
my $href = \%hash;            # reference to existing hash
my $anon = [1, 2, 3];          # anonymous arrayref
my $rec  = {name => "Alice"};  # anonymous hashref

# Arrow notation to dereference
say $aref->[0];           # array element via ref
say $rec->{name};          # hash value via ref

# @{} and %{} dereference the whole structure
my @copy = @{$aref};

# ref() returns the type of reference
say ref($aref);           # "ARRAY"
say ref($rec);            # "HASH"

# Code references — storing subs in variables (lambdas)
my $square = sub { $_[0] ** 2 };
say $square->(5);         # 25

# Dispatch table: hash of code refs
my %actions = (
    'A' => sub { "Excellent!"           },
    'B' => sub { "Good job."             },
    'F' => sub { "Please see me."        },
);
my $msg = $actions{ $grade }->();  # call by grade letter

# Passing by reference — modifies the original
sub double_all {
    my ($ref) = @_;
    $_ *= 2 for @{$ref};
}
double_all(\@scores);   # @scores is modified
11 Closures Factory functions state captured variables

A closure is a subroutine that captures variables from its surrounding scope — it "closes over" them, keeping them alive even after the outer function returns. This is Perl's lambda / function factory. state variables persist between calls without being globals.

gradebook.plperl
# Factory function — each call returns a NEW closure
# with its own private copy of $min_avg
sub make_grade_filter {
    my ($min_avg) = @_;
    return sub {             # $min_avg is "closed over"
        my ($student) = @_;
        return $student{avg} >= $min_avg;
    };
}

# Each closure remembers its own $min_avg independently
my $is_honor    = make_grade_filter(90);
my $is_passing  = make_grade_filter(60);
my $is_at_risk  = make_grade_filter(70);

my @honor_roll = grep { $is_honor->($_) } @students;
my @at_risk    = grep { !$is_at_risk->($_) } @students;

# state: variable persists between calls (like a private static in C)
use feature 'state';
sub running_average {
    my ($new_val) = @_;
    state $count = 0;   # initialized once, remembered forever
    state $total = 0;
    $count++;
    $total += $new_val;
    return $total / $count;
}

# Each call updates the running total
printf "Running avg: %.1f\n", running_average($_{avg})
    for @students;
Closure = function + environment The inner sub captures $min_avg. Even after make_grade_filter returns, $min_avg lives on inside the closure. Each call creates a separate $min_avg.
state vs my my $x resets to the initial value on every call. state $x keeps its value across calls — like a private static variable.
output
Honor roll: Alice, Eve
At risk:    David, Frank

Running stats (state variables):
After Alice     : n=1  running_avg=90.6
After Bob       : n=2  running_avg=81.2
After Carol     : n=3  running_avg=84.1
After David     : n=4  running_avg=78.3
After Eve       : n=5  running_avg=82.2
After Frank     : n=6  running_avg=76.1
12 File I/O open close <> chomp file tests

Always use the three-argument form of open(). The or die $! idiom handles failures by printing the system error message stored in $!. Always close() explicitly when done.

gradebook.plperl
# Writing — '>' truncates, '>>' appends, '<' reads
open(my $fh, '>',  'report.txt') or die $!;
open(my $fh, '>>', 'log.txt')  or die $!;
open(my $fh, '<', 'data.txt')  or die $!;

# Writing: print to a filehandle (no comma!)
print $fh "line of text\n";
printf $fh "%-15s %s\n", "Label:", $value;

# Reading line by line — most memory-efficient
while (my $line = <$fh>) {
    chomp $line;   # removes trailing newline
    # process $line...
}

# Slurp all lines into an array
my @lines = <$fh>;
chomp @lines;      # chomp works on arrays too

# Slurp entire file into one string
my $content = do { local $/; <$fh> };
# local $/ = undef disables line-splitting for this block

close $fh;         # always close when done

# Diamond <> reads from @ARGV files or STDIN
while (<>) { print }   # cat-like: prints every line

# File test operators — check without opening
-e $path    # exists
-f $path    # is a plain file (not dir, symlink...)
-d $path    # is a directory
-r $path    # readable by current user
-s $path    # file size in bytes
-M $path    # age in days since last modification
13 Error handling eval die $@ warn structured exceptions

eval { } is Perl's try block. die throws an exception. The caught value lands in $@. You can die with a string or with a reference (for structured exceptions with typed fields). warn prints to STDERR but doesn't stop execution.

basic eval/dieperl
sub safe_divide {
    my ($a, $b) = @_;
    # \n suppresses "at line N" suffix
    die "Division by zero!\n"
        if $b == 0;
    return $a / $b;
}

my $result = eval {
    safe_divide(10, 0);
};
if ($@) {
    print "caught: $@";
}
structured exceptionsperl
# Die with a hashref for typed exceptions
die {
    type    => "FileNotFound",
    file    => $filename,
    message => "No such file",
};

eval { risky() };
if (my $e = $@) {
    if (ref($e) eq 'HASH') {
        say $e->{type};
        say $e->{message};
    } else {
        die $e;  # re-throw unknown
    }
}
output
  OK:     10 / 2 = 5.00
  CAUGHT: 7/0 — Division by zero!
  OK:     15 / 4 = 3.75

  [FileNotFound] ghost_file.csv: The file does not exist
14 Object-oriented Perl package bless new methods inheritance SUPER

Perl's OOP is built on three primitives: a package is a class, bless() ties a hashref to a class name making it an object, and any subroutine in that package is a method. The first argument to every method is the object itself — called $self by convention.

gradebook.pl — Student classperl
package Student;
use parent  # for inheritance later

# Constructor — 'new' is convention, any name works
sub new {
    my ($class, %args) = @_;   # $class = "Student"
    die "name required\n"  unless defined $args{name};
    my $self = {
        name   => $args{name},
        email  => $args{email},
        scores => $args{scores} // [],
    };
    return bless $self, $class;  # bless makes it an object
}

# Accessor: get/set name
sub name {
    my ($self, $new) = @_;
    $self->{name} = $new if defined $new;
    return $self->{name};
}

# Method chaining: return $self to chain calls
sub add_score {
    my ($self, $score) = @_;
    push @{ $self->{scores} }, $score;
    return $self;   # enables chaining
}

# --- Subclass with inheritance ---
package HonorsStudent;
use parent -norequire, 'Student';

sub new {
    my ($class, %args) = @_;
    my $self = $class->SUPER::new(%args);  # call Student::new
    $self->{bonus} = $args{bonus} // 5;
    return $self;
}

package main;

# Method chaining
my $s = Student->new(name => "Grace", email => 'g@s.edu')
         ->add_score(88)->add_score(92)->add_score(95);

ref($s);                   # "Student"
$s->isa('Student');        # 1 = true
$s->can('add_score');     # coderef or undef
output
  Grace Lee           avg:91.7  grade:A
  Henry Park          avg:87.3  grade:B [Honors +5pts]
  Grace Lee     class=Student         isa Student=yes
  Henry Park    class=HonorsStudent   isa Student=yes
15 Special variables $_ $! $@ $0 @ARGV %ENV wantarray

Perl uses punctuation variables for things most languages need keywords for. The most important is $_ — the default variable used implicitly by map, grep, for, print, chomp, and many other operations.

VariableMeaningWhen to use it
$_Default variableLoop body, map/grep block, print, chomp — implicit everywhere
@_Sub argumentsInside every subroutine — always unpack first
$!System errorAfter a failed open, rename, or other OS call
$@eval errorAfter an eval { } block to check if it threw
$?Child process exitAfter system() or backtick command
$/Input record separatorSet to undef to slurp files; default is \n
$0Script nameUseful in error messages and log lines
@ARGVCommand-line argsArguments passed to the script on the command line
%ENVEnvironment variables$ENV{PATH}, $ENV{HOME}, etc.
$. Current line numberWhile reading a file with <$fh>
gradebook.plperl
# $_ in action — used implicitly
my @words = qw(apple BANANA cherry);
for (@words) {
    $_ = lc;    # lc with no args operates on $_
    print;     # print with no args prints $_
}

# wantarray: detect caller's context
sub flexible {
    return wantarray ? (1,2,3) : "all-in-one";
}
my @list   = flexible();  # list context  → (1, 2, 3)
my $scalar = flexible();  # scalar context → "all-in-one"

# Context affects built-ins too
my @arr    = (5,3,8,1);
my $count  = @arr;        # 4 — scalar context = count
my ($first) = @arr;       # 5 — list context (parens!) = first element

# Command-line args and environment
my $arg1  = $ARGV[0] // "default";
my $home  = $ENV{HOME};
my $path  = $ENV{PATH};
Complete program output Everything the program prints when you run it
$ perl gradebook.pl
Class:    Introduction to Programming
Teacher:  $teacher
Teacher:  Ms. Hernandez

==================================================
Class: Introduction to Programming — Fall 2025

Semester:             Fall 2025
Weeks: 16  Days: 80  Hours: 120.0

STUDENT REPORT  (6 students)
------------------------------------------------------------
  Alice Chen          avg: 90.6  ⭐  passing
  Bob Martinez        avg: 71.8  ~   passing
  Carol Williams      avg: 89.8  ✓   passing
  David Kim           avg: 61.0  △   passing
  Eve Patel           avg: 97.8  ⭐  passing
  Frank Johnson       avg: 45.8  ✗   FAILING

Rankings (sorted by average):
  1. Eve Patel            97.8  A
  2. Alice Chen           90.6  A
  3. Carol Williams       89.8  B
  4. Bob Martinez         71.8  C
  5. David Kim            61.0  D
  6. Frank Johnson        45.8  F

Grade distribution:
  A: ██ (2)   B: █ (1)   C: █ (1)   D: █ (1)   F: █ (1)

REGULAR EXPRESSIONS
  All-caps words: ALICE, MATH
  Date parsed: year=2025  month=09  day=15
  Fixed: student scored Excellent results in all Subjects today.

REFERENCES
  Grade messages:
  Alice (A): Excellent — scholarship eligible!
  Frank (F): Failing — mandatory intervention.

CLOSURES
  Honor roll: Alice, Eve
  Running avg after all 6: 76.1

FILE I/O
  Report written → grade_report.txt
  CSV written    → students.csv
  Lines read: 18   Words: 83

ERROR HANDLING
  OK:     10 / 2 = 5.00
  CAUGHT: 7/0 — Division by zero!
  [FileNotFound] ghost_file.csv: The file does not exist

OBJECT-ORIENTED PERL
  Grace Lee    avg:91.7  grade:A
  Henry Park   avg:87.3  grade:B [Honors +5pts]

============================================================
TOUR COMPLETE — 17 language features covered
============================================================