C++ • COLLECTIONS AND STL

Custom Comparators — Write custom comparators for sorting (intro)

Master the art of defining custom ordering logic so the STL sorts any data type exactly the way you need.

Historical Context & Motivation

The C++ Standard Template Library was designed from the outset around a powerful idea: algorithms should be decoupled from data structures. Alexander Stepanov, the principal architect of the STL, drew on decades of work in generic programming to create a library where sorting, searching, and transforming could be expressed independently of the container holding the data. A critical enabler of this flexibility is the concept of a comparator — a callable object that defines how two elements relate in an ordering. Without custom comparators, std::sort would be limited to whatever operator< provides, which is often insufficient for real-world applications where sorting criteria vary by context.

1979
Early Generic Programming
Alexander Stepanov begins formal research into generic programming at GE Research, exploring how to abstract algorithms from their underlying data representations.
1994
STL Accepted into C++
The ANSI/ISO C++ committee votes to include the STL in the C++ Standard Library. Algorithms like std::sort accept optional comparison functors, establishing the comparator pattern.
1998
C++98 Standard Published
The first ISO C++ standard formalizes the STL, including strict weak ordering requirements for comparators used in associative containers and sorting algorithms.
2011
C++11 — Lambdas Arrive
Lambda expressions dramatically simplify writing inline comparators, removing the need for separate functor classes in most cases.
2020
C++20 — Ranges & Three-Way Comparison
The spaceship operator (<=>) and the Ranges library introduce projection-based sorting and unified comparison categories, building on the comparator foundation.

The fundamental question that custom comparators answer is deceptively simple: How should two elements be ordered when the default ordering is wrong, absent, or context-dependent? Consider sorting a vector of students by GPA descending, or ordering a priority queue of tasks by deadline. The STL provides the algorithmic machinery; you provide the ordering logic via a comparator.

Core Principles & Definitions

A comparator in C++ is any callable entity that accepts two arguments of the element type and returns a bool. It returns true if the first argument should come before the second in the desired ordering. The STL imposes a precise mathematical requirement on comparators: they must define a strict weak ordering. Violating this requirement leads to undefined behavior — the algorithm may loop infinitely, corrupt memory, or produce incorrect results.

1

Irreflexivity

A comparator must never report that an element is "less than" itself. Formally, comp(a, a) must return false for every element a.
2

Asymmetry

If comp(a, b) is true, then comp(b, a) must be false. Two elements cannot each precede the other.
3

Transitivity

If comp(a, b) and comp(b, c) are both true, then comp(a, c) must also be true.
4

Transitivity of Equivalence

If neither comp(a, b) nor comp(b, a) is true (a and b are equivalent), and similarly b ≡ c, then a ≡ c must also hold.
✦ KEY TAKEAWAY
Think of a comparator like a referee in a tournament who must decide match outcomes. The referee can declare "A beats B" or "B beats A" or "it's a draw," but the decisions must be consistent: if A beats B and B beats C, the referee cannot claim C beats A. Similarly, the referee can never declare a player loses to themselves. As long as these rules hold, the tournament bracket (sorting algorithm) will always produce a valid ranking.

In practice, C++ supports three main forms for comparators: function pointers, functor classes (classes that overload operator()), and lambda expressions. All three are interchangeable from the algorithm's perspective because the STL uses templates to accept any callable type. Modern C++ strongly favors lambdas for their conciseness and inline readability, though functor classes remain essential when the comparator needs to be passed as a template type parameter, as with std::set or std::priority_queue.

Visual Explanation — How std::sort Invokes a Comparator

The following diagram illustrates how std::sort interacts with a custom comparator during a single comparison step. The algorithm selects two elements from the range, passes them to the comparator, and uses the boolean result to decide their relative placement. This callback pattern is the essential mechanism that makes STL algorithms customizable.

The sorting algorithm repeatedly invokes the comparator on pairs of elements. Here a descending-GPA lambda returns true when the first student's GPA exceeds the second's, causing higher GPAs to migrate to the front of the vector.

Notice that the algorithm and the comparator are entirely independent units. The algorithm knows nothing about GPAs, names, or student records — it only sees true or false. This separation of concerns is the hallmark of generic programming: you write the ordering policy once, and the STL handles all the element movement, partitioning, and recursion internally.

How Comparators Work — Three Forms

Form 1: Function Pointer

The simplest form is a standalone function that takes two const references and returns a bool. When you pass its name to std::sort, the compiler decays it to a function pointer. This approach is straightforward but cannot capture state from an enclosing scope, limiting its flexibility.

FUNCTION POINTER COMPARATOR
bool cmpDescending(const Student& a, const Student& b) { return a.gpa > b.gpa; } std::sort(v.begin(), v.end(), cmpDescending);
The third argument to std::sort is the name of the function, which decays to a pointer of type bool(*)(const Student&, const Student&).

Form 2: Functor Class

A functor (function object) is a class or struct that overloads operator(). Because it is a type, it can be passed as a template argument to containers like std::set<T, Compare> or std::priority_queue<T, Container, Compare>. Functors can also hold state — for example, a threshold value — making them more powerful than plain function pointers.

FUNCTOR CLASS COMPARATOR
struct CmpByGPA { bool operator()(const Student& a, const Student& b) const { return a.gpa > b.gpa; } }; std::sort(v.begin(), v.end(), CmpByGPA{});
The const qualifier on operator() is important: it signals that the comparator does not modify internal state during comparison, which enables the algorithm to copy or move it freely.

Form 3: Lambda Expression

Since C++11, lambda expressions provide the most concise syntax for defining comparators inline. Under the hood, the compiler generates an anonymous functor class, so lambdas and functors are semantically equivalent. Lambdas can capture variables from the enclosing scope by value or reference, enabling context-dependent comparisons. For most uses of std::sort, lambdas are the idiomatic choice in modern C++.

LAMBDA COMPARATOR
std::sort(v.begin(), v.end(), [](const Student& a, const Student& b) { return a.gpa > b.gpa; });
The capture list [] is empty here because no external variables are needed. To sort relative to a cutoff, you could capture a threshold: [cutoff](const Student& a, const Student& b) { ... }.
⚠️ Common Pitfall
Using <= instead of < (or >= instead of >) violates irreflexivity because comp(a, a) would return true. This is undefined behavior in the STL and can crash your program. Always use strict inequality operators in comparators.

Comparator Forms at a Glance

Choosing the right comparator form depends on the context in which it will be used. The diagram below provides a decision flowchart, and the subsequent table offers a side-by-side comparison of the three forms along key dimensions.

Decision flowchart for selecting a comparator form. Functor classes are required when the comparator must be specified as a template type parameter. Lambdas are preferred everywhere else.
Comparison of three comparator forms across practical dimensions
FeatureFunction PointerFunctor ClassLambda
Syntax verbosityModerateHigh (separate class)Low (inline)
Can capture stateNoYes (data members)Yes (capture list)
Usable as template type argYes (via decltype)Yes (natural)Awkward (unique type)
Inline optimizationUnlikely (indirect call)Likely (type known)Likely (type known)
Reusable across filesYesYesNo (local scope)

Worked Example — Multi-Key Sorting

Suppose you have a std::vector<Student> where each Student has a std::string name and a double gpa. The goal is to sort students by GPA in descending order, and when two students share the same GPA, sort them alphabetically by name in ascending order.

Multi-Key Custom Sort on Student Records
1
Step 1 — Define the Data StructureWe define a simple struct to represent each student: struct Student { std::string name; double gpa; };. This struct has no built-in comparison operator, so std::sort without a comparator would fail to compile.
2
Step 2 — Identify the Ordering LogicThe primary key is GPA (descending), so if a.gpa > b.gpa, student a comes first. The secondary key is name (ascending): if GPAs are equal, a.name < b.name determines precedence.
3
Step 3 — Write the Lambda ComparatorWe combine both keys using an if statement. A cleaner idiom uses std::tie to leverage lexicographic tuple comparison:
auto cmp = [](const Student& a, const Student& b) { // Negate GPA for descending; name ascending is natural return std::tie(b.gpa, a.name) < std::tie(a.gpa, b.name); };
4
Step 4 — Apply the Comparator to std::sortPass the lambda as the third argument to std::sort:
std::sort(students.begin(), students.end(), cmp);
5
Step 5 — Verify the OutputGiven the input {"Alice",3.8}, {"Bob",3.9}, {"Carol",3.8}, {"Dave",4.0}, the sorted order is Dave (4.0), Bob (3.9), Alice (3.8), Carol (3.8). Notice Alice precedes Carol because 'A' < 'C' alphabetically, resolving the GPA tie.
Output: Dave 4.0 → Bob 3.9 → Alice 3.8 → Carol 3.8
💡 The std::tie Trick
Using std::tie to build tuples of references enables lexicographic comparison for free. To reverse the sort direction for a specific key, swap the positions of a and b for that key within the tuple. This is more maintainable than writing nested if chains for multi-key sorts.

Strengths, Limitations & Common Mistakes

Trade-offs when using custom comparators in C++
AspectStrengthsLimitations / Pitfalls
FlexibilitySort any type by any criteria — no need to modify the class itselfComplex comparators can become hard to read and verify for correctness
PerformanceLambdas and functors are typically inlined by the compiler, yielding zero-overhead abstractionFunction pointers may inhibit inlining, adding indirect-call overhead per comparison
CorrectnessThe strict weak ordering contract is well-documented and enforced by sanitizers in debug modeUsing ≤ or ≥ instead of < or > violates irreflexivity and causes undefined behavior
ReusabilityFunctors and named functions can be shared across translation unitsLambdas have unique, unnamed types; difficult to store in data structures or pass across API boundaries
Container compatibilityFunctors naturally serve as template type arguments for ordered containersPassing a lambda type to std::set or std::map requires decltype and constructor arguments
✦ KEY TAKEAWAY
Custom comparators follow the same principle as plug-in modules in a signal-processing pipeline: the pipeline (STL algorithm) handles the data flow and orchestration, while the plug-in (your comparator) defines the domain-specific decision logic. This modularity means you can swap ordering strategies without rewriting or even recompiling the algorithm. However, just as a faulty plug-in can corrupt an audio pipeline, a comparator that violates strict weak ordering can corrupt your data.

Connection to Advanced Theory

Custom comparators are the foundation upon which several advanced C++ features are built. Understanding comparators positions you to work with ordered associative containers, priority queues, and the C++20 Ranges library's projections and custom views. The table below maps introductory comparator concepts to their advanced counterparts.

Mapping introductory comparator concepts to advanced C++ features
Introductory ConceptAdvanced Extension
Lambda comparator for std::sortProjections in std::ranges::sort — sort by a member without writing a comparator at all
Functor class as Compare template argumentTransparent comparators (std::less<>) enabling heterogeneous lookup in std::set
Strict weak ordering requirementThree-way comparison (operator<=>) and comparison categories (strong_ordering, weak_ordering, partial_ordering)
Multi-key sort via std::tieDefaulted operator<=> that auto-generates all six comparison operators from member declarations
Custom ordering for std::priority_queuePolicy-based data structures (e.g., pb_ds) and custom allocator-aware ordered containers

As you progress, you will find that comparators are not limited to sorting. They underpin std::lower_bound, std::upper_bound, std::merge, std::nth_element, and many other algorithms. The consistent interface — a binary predicate returning bool — means that mastering comparators once pays dividends across the entire STL.

Practice Problems

PROBLEM 1 — CONCEPTUAL
Explain why a comparator that uses <= instead of < violates the strict weak ordering requirement. Which specific property is broken, and what could happen at runtime?
PROBLEM 2 — BASIC CALCULATION
Write a lambda comparator that sorts a std::vector<int> in descending order. Show the complete call to std::sort. For the input {3, 1, 4, 1, 5, 9}, what is the output?
PROBLEM 3 — INTERMEDIATE
Given struct Event { std::string name; int priority; double time; };, write a comparator that sorts events by priority (highest first), breaking ties by time (earliest first). Use the std::tie idiom.
PROBLEM 4 — APPLIED
You are building a task scheduler. Tasks have a deadline (int, lower is sooner) and a weight (int, higher is more important). Define a functor class TaskCompare suitable for use with std::priority_queue<Task, std::vector<Task>, TaskCompare>. The most urgent task (earliest deadline, breaking ties by highest weight) should be at the top.
PROBLEM 5 — CRITICAL THINKING
Consider a comparator that sorts strings by length first (shortest to longest), and for strings of equal length, by lexicographic order. Someone proposes: [](const std::string& a, const std::string& b) { return a.size() <= b.size() && a < b; }. Identify all correctness issues with this comparator, explain which strict weak ordering properties are violated, and provide a corrected version.

Summary

A custom comparator is a callable object that defines a strict weak ordering over elements, enabling STL algorithms like std::sort to order any data type by any criteria. The ordering must satisfy four properties: irreflexivity, asymmetry, transitivity, and transitivity of equivalence. Violating any of these causes undefined behavior.

Comparators come in three forms: function pointers (simple but cannot capture state), functor classes (essential for container template parameters like std::set and std::priority_queue), and lambda expressions (the modern default for inline use). For multi-key sorting, the std::tie idiom provides clean lexicographic comparison, and swapping the positions of a and b within the tuple reverses the sort direction for that key. Mastering comparators is foundational to effective use of the entire STL.

Varsity Tutors • C++ • Custom Comparators — Write custom comparators for sorting (intro)