Master Key Comparison in Common-lisp: Complete Learning Path

a close up of a computer screen with code on it

Master Key Comparison in Common-lisp: Complete Learning Path

Key comparison in Common Lisp is a powerful technique using the :key argument to specify how elements in a sequence are evaluated for comparison, sorting, or searching. This allows you to manipulate complex data structures by focusing the logic on a specific attribute of each element, leading to cleaner, more reusable code.

Have you ever found yourself with a list of complex objects—say, a list of employee records—and needed to sort them by salary? Or perhaps you had a list of user profiles and needed to find a specific user by their unique ID. Your first instinct might be to write a complicated, custom comparison function from scratch, a function that knows how to dig into your specific data structure. This approach works, but it's brittle, verbose, and not very reusable. Every new sorting criterion requires a new custom function. It feels clunky, and you know there must be a more elegant way. This is a common pain point for developers coming from other paradigms, but in Common Lisp, the solution is built right into the language's DNA.

This is where the magic of the :key argument comes in. It's a simple yet profound concept that separates the "what to compare" from the "how to compare." Instead of writing a complex predicate, you simply tell functions like sort or find which piece of data—which "key"—to look at for each element. This guide will take you from zero to hero, transforming how you manipulate data in Common Lisp. You will learn not just the syntax, but the philosophy behind key comparison, enabling you to write more expressive, powerful, and quintessentially Lisp-y code.


What is Key Comparison in Common Lisp?

At its core, key comparison is a mechanism provided by many standard Common Lisp functions that operate on sequences. It allows you to specify a function that will be applied to each element of the sequence *before* any comparison or testing takes place. The result of this function application—the "key"—is then used for the actual operation.

This is facilitated by the keyword argument :key. A keyword argument is a special type of optional argument in Lisp identified by a symbol that starts with a colon, like :key, :test, or :start.

The value you provide for the :key argument must be a function designator. This can be:

  • A function name (a symbol): For example, #'car to get the first element of a list, or #'second to get the second. The hash-quote (#') is syntactic sugar for the (function ...) special operator, ensuring the compiler knows you are referring to the function binding of the symbol.
  • A lambda expression: An anonymous function you define on the fly, perfect for one-off key extractions. For example, (lambda (user) (getf user :id)) to extract an ID from a property list.

Consider the standard sort function. Its signature often looks something like this: (sort sequence predicate &key key). Without a :key, the predicate (like < or >) is applied directly to the elements of the sequence. With a :key, the process changes fundamentally.

The Key Comparison Logic Flow

Here is a conceptual breakdown of how a function like sort uses the :key argument. This illustrates the separation of concerns that makes this feature so powerful.

    ● Start with an Unsorted Sequence
    │   e.g., '((2 "B") (1 "A") (3 "C"))
    │
    ▼
  ┌──────────────────────────────┐
  │ `sort` is called with a :key │
  │ e.g., (sort sequence #'< :key #'car)
  └──────────────┬───────────────┘
                 │
                 ▼
  ╭─ Loop through each element ─╮
  │                              │
  │  Apply the :key function     │
  │  (in this case, `car`)       │
  │                              │
  │  (car '(2 "B")) ⟶ 2          │
  │  (car '(1 "A")) ⟶ 1          │
  │  (car '(3 "C")) ⟶ 3          │
  │                              │
  ╰──────────────┬───────────────╯
                 │
                 ▼
  ┌──────────────────────────────┐
  │  Use the extracted keys for  │
  │  comparison with predicate   │
  │  e.g., (< 1 2), (< 2 3), etc.│
  └──────────────┬───────────────┘
                 │
                 ▼
  ┌──────────────────────────────┐
  │ Reorder the *original*       │
  │ elements based on the result │
  │ of the key comparisons.      │
  └──────────────┬───────────────┘
                 │
                 ▼
    ● End with Sorted Sequence
        e.g., '((1 "A") (2 "B") (3 "C"))

This abstraction is the cornerstone of writing generic and reusable code in Common Lisp. You don't need to change the sorting algorithm; you just change the lens through which it views the data.


Why is This Feature So Crucial in Lisp?

The :key argument is more than just a convenience; it's a reflection of Lisp's functional programming heritage and its philosophy of treating code as data. Here’s why it's a game-changer for data manipulation.

Promotes Data Abstraction

Key comparison allows you to work with complex data structures without exposing their internal implementation to the sorting or searching function. Imagine you have a user struct:


(defstruct user
  id
  name
  email
  (active-p t))

You can sort a list of these users by their ID using the auto-generated accessor function user-id as the key. The sort function doesn't need to know what a user is; it only needs a function that can extract a comparable value from it.


(let ((users (list (make-user :id 103 :name "Bob")
                   (make-user :id 101 :name "Alice")
                   (make-user :id 102 :name "Charlie"))))
  (sort users #'< :key #'user-id))

;; Result: A list of user structs sorted by ID
;; (#S(USER :ID 101 ...) #S(USER :ID 102 ...) #S(USER :ID 103 ...))

Enhances Code Reusability and Readability

Without :key, you would need to write a custom comparison function using defun or a verbose lambda for the :test argument:


;; The verbose way (without :key)
(sort users (lambda (user1 user2)
              (< (user-id user1) (user-id user2))))

This is much harder to read and couples the comparison logic (<) with the data extraction logic (user-id). The :key version is declarative. It clearly states the intent: "sort these items using the less-than operator on the value returned by the user-id function." This separation makes the code cleaner and easier to reason about.

Improves Performance (Potentially)

In some Common Lisp implementations, using a :key can be more efficient than a complex :test predicate. The key for each element might be extracted only once and cached internally during the operation. In contrast, a :test predicate is called for every pair-wise comparison, potentially re-calculating the same values multiple times. While this is implementation-dependent, it's a good practice that often aligns with better performance.


How to Wield Key Comparison: A Practical Guide

Many built-in Common Lisp functions that operate on sequences support the :key argument. Let's explore the most common ones with practical examples.

Sorting Sequences with sort and stable-sort

sort is the most common use case. It rearranges a sequence in place. stable-sort does the same but guarantees that the relative order of elements that compare as equal is preserved.

Example: Sorting an Association List (alist)

An alist is a list of cons cells, often used as a simple key-value map. Let's sort a list of products by their price.


(defvar *products*
  '(("Laptop" . 1200)
    ("Mouse" . 25)
    ("Keyboard" . 75)
    ("Monitor" . 300)))

;; Sort by price (the cdr of each element) in ascending order
(sort (copy-list *products*) #'< :key #'cdr)
;; => (("Mouse" . 25) ("Keyboard" . 75) ("Monitor" . 300) ("Laptop" . 1200))

;; Sort by product name (the car of each element) alphabetically
(sort (copy-list *products*) #'string< :key #'car)
;; => (("Keyboard" . 75) ("Laptop" . 1200) ("Monitor" . 300) ("Mouse" . 25))

Note: sort is a destructive function. We use copy-list here to avoid modifying the original *products* variable.

Finding Items with find, member, and position

These functions search for an item in a sequence. The :key argument tells them what part of each element to compare against the item you're looking for.

Example: Finding a User by Name

Let's use our user struct from before.


(defvar *user-db*
  (list (make-user :id 101 :name "Alice")
        (make-user :id 102 :name "Bob")
        (make-user :id 103 :name "Charlie")))

;; Find the user struct whose name is "Bob"
;; The :test is #'string= because we are comparing strings.
;; The :key is #'user-name because we want to extract the name from each struct for the test.
(find "Bob" *user-db* :key #'user-name :test #'string=)
;; => #S(USER :ID 102 :NAME "Bob" :EMAIL NIL :ACTIVE-P T)

;; Check if a user named "David" is a member of the list
(member "David" *user-db* :key #'user-name :test #'string=)
;; => NIL

;; Get the position of the user with ID 103
(position 103 *user-db* :key #'user-id)
;; => 2

Visualizing the find Operation

This diagram shows how find uses the :key to abstract the search process.

    ● Start with Sequence and Target Value
    │   e.g., *user-db* and "Bob"
    │
    ▼
  ┌───────────────────────────────┐
  │ `find` is called with :key    │
  │ and :test.                    │
  │ `(find "Bob" *user-db*        │
  │       :key #'user-name        │
  │       :test #'string=)`       │
  └───────────────┬───────────────┘
                  │
  ╭─ Loop through each element (user struct) ─╮
  │                                            │
  │ 1. Apply :key function `user-name`         │
  │    (user-name #S(USER :ID 101...)) ⟶ "Alice"│
  │                                            │
  │ 2. Apply :test function `string=`          │
  │    (string= "Alice" "Bob") ⟶ NIL           │
  │                                            │
  ├────────────────────────────────────────────┤
  │                                            │
  │ 1. Apply :key function `user-name`         │
  │    (user-name #S(USER :ID 102...)) ⟶ "Bob"  │
  │                                            │
  │ 2. Apply :test function `string=`          │
  │    (string= "Bob" "Bob") ⟶ T (Match!)      │
  │                                            │
  ╰───────────────┬───────────────╯
                  │
                  ▼
  ┌───────────────────────────────┐
  │ Match found. Stop iterating.  │
  │ Return the *original element* │
  │ that produced the match.      │
  └───────────────┬───────────────┘
                  │
                  ▼
    ● Result: #S(USER :ID 102 :NAME "Bob" ...)

Filtering and Removing Duplicates

Functions like remove-duplicates and the remove family also benefit greatly from :key.

Example: Removing Duplicate Events Based on Timestamp

Imagine a list of log events, where each event is a property list (plist). We want to deduplicate this list, considering two events as duplicates if they have the same timestamp.


(defvar *events*
  '((:timestamp 1672531200 :event "login" :user "alice")
    (:timestamp 1672531201 :event "click" :user "bob")
    (:timestamp 1672531200 :event "login" :user "alice") ; Duplicate timestamp
    (:timestamp 1672531202 :event "logout" :user "alice")))

;; Define a helper to extract the timestamp
(defun get-timestamp (event)
  (getf event :timestamp))

;; Remove duplicates based on the value returned by get-timestamp
(remove-duplicates *events* :key #'get-timestamp)
;; By default, remove-duplicates keeps the first occurrence.
;; => ((:TIMESTAMP 1672531201 ...) (:TIMESTAMP 1672531200 ...) (:TIMESTAMP 1672531202 ...))
;; The order might seem strange because the original list is traversed from the end.

;; To keep the first-occurring elements in their original relative order:
(remove-duplicates *events* :key #'get-timestamp :from-end nil)
;; => ((:TIMESTAMP 1672531200 ...) (:TIMESTAMP 1672531201 ...) (:TIMESTAMP 1672531202 ...))

Where This Shines: Real-World Applications

The :key argument isn't just an academic feature; it's a workhorse in production Common Lisp code.

  • Data Processing and ETL: When cleaning and transforming data from APIs or databases, you often get lists of objects. Using :key with remove-duplicates is perfect for data sanitation.
  • GUI Development: Sorting items in a list view or table column is a direct application. A user clicks a column header (e.g., "Date Created"), and you simply call sort with the appropriate accessor function as the key.
  • AI and Symbolic Computation: In AI, you might work with lists of possible moves or states, each with a calculated score. Sorting these states by score to find the best option is a trivial task with (sort states #'> :key #'get-score).
  • Web Development: When handling a REST API response that returns a JSON array of objects, you can parse it into a Lisp list of plists or structs and then easily sort, filter, or search it using keys corresponding to the JSON fields.

Best Practices and Common Pitfalls

While powerful, there are some nuances to using the :key argument effectively.

When to Use :key vs. :test

The general rule is:

  • Use :key when the comparison logic is simple (like <, =, string=) but the value you need to compare is buried inside an object.
  • Use a custom :test predicate (often a lambda) when the comparison logic itself is complex and involves multiple fields from the objects being compared. For example, sorting users first by last name, then by first name if last names are the same.

Pros and Cons of Using :key

Here's a comparison to help you decide on the best approach for your situation.

Aspect Using :key Using a Custom :test Predicate
Readability High. Clearly separates data extraction from comparison logic. Very declarative. Lower. Mixes data extraction and comparison, making the intent less obvious at a glance.
Reusability High. The key function (e.g., user-id) can be reused across many different operations (sorting, finding, etc.). Low. The custom predicate is often specific to one particular sorting or searching task.
Complexity Ideal for single-attribute comparisons. Simple and elegant. Necessary for multi-attribute or complex relational comparisons.
Performance Potentially faster as the key may be extracted only once per element. Can be slower if the predicate repeatedly extracts the same values for each comparison.

Pitfall: Forgetting the Right :test

A very common mistake is providing a :key but forgetting to change the default :test. The default test is often #'eql, which is fine for numbers but will fail for strings.


;; WRONG: This will likely not find the user!
;; It tries to compare "Bob" with extracted names using EQL, not STRING=.
(find "Bob" *user-db* :key #'user-name)

;; CORRECT: Always provide a compatible :test for the key's data type.
(find "Bob" *user-db* :key #'user-name :test #'string=)

Your Learning Path: The Key Comparison Module

Theory is essential, but mastery comes from practice. The concepts you've learned are put to the test in the core module from the kodikra.com exclusive curriculum. This hands-on challenge will solidify your understanding and prepare you for real-world data manipulation tasks.

This module is designed to give you practical experience by implementing a function that leverages key comparison to perform a complex selection task. You'll need to think about how to apply a key function and a predicate to achieve the desired outcome.

By completing this exercise, you will gain confidence in using one of Common Lisp's most elegant and powerful features for data processing.


Frequently Asked Questions (FAQ)

What is the difference between :key and :test?

:key specifies a function to be applied to each element to get a value for comparison. :test specifies the function used to perform the actual comparison between two values (which may or may not be keys). Think of it as: :key extracts *what* to compare, and :test defines *how* to compare it.

Can I use a lambda function for the :key argument?

Absolutely. Using a lambda is very common for one-off key extractions where defining a named function would be overkill. For example: (sort users #'< :key #'(lambda (u) (getf (user-profile u) :score))).

How does :key improve code readability?

It separates concerns. The main function call clearly states the high-level operation (e.g., sort), the predicate states the comparison rule (e.g., <), and the key states what data is being compared (e.g., user-id). This declarative style is much easier to read than an imperative loop or a complex comparison predicate.

What happens if the :key function returns nil?

nil is a valid value like any other. The comparison function (the :test predicate) will simply receive nil as its argument. How it behaves depends on the predicate. For example, (< nil 5) would signal an error because < expects numbers. If you expect keys to be potentially missing, you might need a more robust :test function that can handle nil.

Does using :key have a significant performance impact?

Generally, it has a positive or neutral performance impact. In many modern Common Lisp compilers (like SBCL), using :key can be faster than a complex :test because the key extraction can be optimized and happens only once per element, not for every comparison.

Can I use :key with my own custom data structures?

Yes, that's one of its primary strengths. As long as you can write an accessor function that takes an instance of your data structure and returns a comparable value, you can use it as a key. This is standard practice with defstruct and defclass (CLOS).

Which common functions support the :key argument?

A large number of sequence functions do. The most common ones include: sort, stable-sort, find, find-if, position, position-if, count, count-if, member, member-if, remove, remove-if, delete, delete-if, remove-duplicates, delete-duplicates, substitute, substitute-if, assoc, and rassoc.


Conclusion: The Lisp Way of Thinking

Mastering the :key argument is a significant step towards thinking like a Lisp programmer. It encourages you to build small, reusable functions (your key accessors) and compose them with powerful, generic higher-order functions (like sort and find). This approach leads to code that is not only more concise but also more robust, readable, and adaptable to change.

You've seen how this single feature can simplify complex data manipulation tasks, from sorting database records to filtering event logs. By separating the "what" from the "how," you empower yourself to write cleaner, more expressive solutions. Now, take this knowledge and apply it to the practical challenges in the kodikra learning path to truly make it your own.

Disclaimer: All code examples are written for a modern, ANSI Common Lisp compliant implementation such as Steel Bank Common Lisp (SBCL) 2.4+ or a similar environment. Ensure your Lisp environment is up to date for best results.

Back to Common-lisp Guide


Published by Kodikra — Your trusted Common-lisp learning resource.