◈ Netsurfer / perl.gg / Sorting

Sorting

2024-07-19

This page walks through how Perl's sort function actually works, from the basic $a and $b variables to the Schwartzian Transform.

The sort function hands you two magic variables called $a and $b. They hold the two items Perl is currently comparing, and your job is to return a number that tells Perl which one should come first.

sort { $a <=> $b } @numbers
That is the entire contract. The spaceship operator (<=>) returns a negative, zero, or positive number so Perl can order the list.

Part 1: THE TWO OPERATORS

Perl ships with two comparison operators for sorting:
OPERATOR TYPE RETURNS -------- -------- -------------------------------- <=> Numeric -1 if $a < $b, 0 if equal, 1 if $a > $b cmp String Same, but compares as strings
The number your block returns tells sort which element should appear first:
-1 $a comes before $b 0 They're equal (order doesn't matter) 1 $b comes before $a
You do not have to return exactly negative one, zero, or one. Any negative, zero, or positive number works, but these two operators already give you the exact values sort expects.

Part 2: NUMERIC SORTING

Perl sorts strings by default, so a list of numbers gets ordered by character code rather than magnitude:
my @nums = (1, 20, 3, 100, 15); print join(", ", sort @nums);
Output:
1, 100, 15, 20, 3
That result follows ASCII order, not numeric order. The string "100" lands before "15" because Perl compares the strings character by character, and "10" sorts before "15" in that comparison.

The fix is to use the spaceship operator:

my @nums = (1, 20, 3, 100, 15); print join(", ", sort { $a <=> $b } @nums);
Output:
1, 3, 15, 20, 100
.--. |o_o | |:_/ | // \ \ (| | ) /'\_ _/`\ \___)=(___/

Part 3: DESCENDING ORDER

You can flip the comparison by swapping
$a and $b:
sort { $b <=> $a } @numbers # Largest first sort { $b cmp $a } @strings # Z before A
You can also wrap the call in
reverse, which is less efficient but reads clearly:
reverse sort { $a <=> $b } @numbers
The swap trick wins because it avoids a second pass through the list.

Part 4: CASE-INSENSITIVE SORTING

Perl sorts strings case-sensitively by default:
my @words = qw(apple Banana cherry APRICOT); print join(", ", sort @words);
Output:
APRICOT, Banana, apple, cherry
Uppercase letters carry lower ASCII codes than lowercase letters, so
"A" (code 65) sorts before "a" (code 97).

You can force case-insensitive ordering by comparing the lowercase form of each string:

sort { lc($a) cmp lc($b) } @words
Output:
apple, APRICOT, Banana, cherry
The comparison runs on the lowered strings, but the original casing stays intact in the final output.

Part 5: THE $a AND $b MYSTERY

Where do
$a and $b actually come from? They are **package variables** that sort creates on the fly, so you never need to declare them.

The catch is that they live at package scope, not lexical scope, and that distinction matters when you enable use strict:

use strict; my @sorted = sort { $a <=> $b } @nums; # Works fine
Perl special-cases
$a and $b so strict does not warn about them.

Do not declare your own lexical version inside the same scope:

my $a = 5; # Shadows the sort variables! sort { $a <=> $b } @nums; # Broken
Your lexical
$a hides the magic one, and the comparison breaks.

Part 6: SORTING BY EXTRACTED KEYS

You can sort by any value you can compute from the list elements.

Here is how to sort a list of URLs by the title that follows the pipe character:

my @urls = ( 'https://example.com | Example Site', 'https://perl.org | The Perl Foundation', 'https://cpan.org | CPAN', ); my @sorted = sort { my ($title_a) = $a =~ /\| (.+)$/; my ($title_b) = $b =~ /\| (.+)$/; lc($title_a) cmp lc($title_b); } @urls;
The regex
\| (.+)$ captures everything after the pipe, and we compare those extracted titles.

Output:

https://cpan.org | CPAN https://example.com | Example Site https://perl.org | The Perl Foundation

Part 7: THE SCHWARTZIAN TRANSFORM

The URL sort above runs the regex twice for every comparison. Since
sort performs O(n log n) comparisons, a large list will repeat that regex work many times.

The **Schwartzian Transform** solves that by computing the sort key once per element:

my @sorted = map { $_->[0] } sort { $a->[1] cmp $b->[1] } map { [ $_, /\| (.+)$/ ] } @urls;
Read the chain from bottom to top:
1. map: Transform each URL into [$url, $title] 2. sort: Compare by $title (the second element) 3. map: Extract just the URL (first element)
Each regex now runs exactly once per element instead of once per comparison, which pays off quickly on large datasets.

Part 8: BREAKING IT DOWN

Here is a step-by-step trace of the transform:
INPUT: @urls = ( 'https://example.com | Example Site', 'https://perl.org | The Perl Foundation', 'https://cpan.org | CPAN', ) STEP 1 - First map (bottom): [ ['https://example.com | Example Site', 'Example Site'], ['https://perl.org | The Perl Foundation', 'The Perl Foundation'], ['https://cpan.org | CPAN', 'CPAN'], ] STEP 2 - Sort by index [1]: [ ['https://cpan.org | CPAN', 'CPAN'], ['https://example.com | Example Site', 'Example Site'], ['https://perl.org | The Perl Foundation', 'The Perl Foundation'], ] STEP 3 - Final map (top): ( 'https://cpan.org | CPAN', 'https://example.com | Example Site', 'https://perl.org | The Perl Foundation', )
The key insight is **compute once, compare many times**.

Part 9: SORTING COMPLEX DATA

You can sort hash keys by their values:
my %scores = ( alice => 87, bob => 92, carol => 85, ); my @ranked = sort { $scores{$b} <=> $scores{$a} } keys %scores;
We sort the keys, but the comparison looks up the corresponding values. Putting
$b first in the spaceship operator flips the order to descending, so the highest score lands first.

Output:

bob, alice, carol

Part 10: MULTI-KEY SORTING

You can chain multiple criteria with the
or operator:
my @people = ( { name => 'Alice', age => 30 }, { name => 'Bob', age => 25 }, { name => 'Alice', age => 25 }, ); my @sorted = sort { $a->{name} cmp $b->{name} or $a->{age} <=> $b->{age} } @people;
The block first compares names, then falls back to age. The **
or short-circuits**: if the name comparison returns non-zero, Perl stops there. It only checks age when the names match and the comparison returns zero.

Part 11: CUSTOM COMPARISON FUNCTIONS

You can extract complex sort logic into a named subroutine:
sub by_domain { my ($domain_a) = $a =~ m{://([^/]+)}; my ($domain_b) = $b =~ m{://([^/]+)}; return lc($domain_a) cmp lc($domain_b); } my @sorted = sort by_domain @urls;
Notice that you drop the block braces when you pass a named function. The
$a and $b magic variables still work inside that subroutine.

Part 12: GOTCHAS

Do not modify
$a or $b inside the comparison block:
sort { $a++ <=> $b } # BAD: modifies source array
Your comparison function should be **pure**, meaning it produces no side effects.

Do not return non-numeric values:

sort { 'apple' } # Meaningless
The return value must be numeric, so
sort can decide ordering.

Watch out for undef values:

sort { $a <=> $b } (1, undef, 3); # Warnings!
You can handle missing values with the
//` operator:
sort { ($a // 0) <=> ($b // 0) } @list;

Part 13: THE COMPLETE TOOLKIT

# Numeric ascending sort { $a <=> $b } @nums # Numeric descending sort { $b <=> $a } @nums # String ascending sort { $a cmp $b } @strings # String descending sort { $b cmp $a } @strings # Case-insensitive sort { lc($a) cmp lc($b) } @strings # By hash value sort { $hash{$a} <=> $hash{$b} } keys %hash # Multi-key sort { $a->{x} cmp $b->{x} or $a->{y} <=> $b->{y} } @list # Schwartzian (compute once) map { $_->[0] } sort { $a->[1] <=> $b->[1] } map { [$_, expensive_computation($_)] } @list
+---------+ | UNSORTED| | 3 1 4 1 | | 5 9 2 6 | +----+----+ | V .-----. / sort \ | <=> | \ / '-----' | V +---------+ | SORTED | | 1 1 2 3 | | 4 5 6 9 | +---------+
perl.gg
✓ Document: Doneessentials / 14.7 KiB▧