Raindrops in Common-lisp: Complete Solution & Deep Dive Guide

water droplets on gray surface

Mastering Conditional Logic in Common Lisp: The Complete Raindrops Guide

The Raindrops challenge is a classic programming problem that elegantly demonstrates how to handle multiple conditional checks in Common Lisp. This guide explains how to convert a number into a string of raindrop sounds—"Pling," "Plang," and "Plong"—using a data-driven approach with the powerful loop macro, moving beyond simple nested if statements.


The Frustration of Endless `if` Statements

Remember when you first learned to code? The if-else statement felt like a superpower. You could finally make your program make decisions. But then came a problem that required checking not just one condition, but two, three, or even more, all at once. Your code quickly devolved into a tangled mess of nested if blocks, a structure often called the "arrow anti-pattern" because of how it indents across the screen.

This kind of code is difficult to read, a nightmare to debug, and almost impossible to extend. What if a new condition is added? You have to dive back into that labyrinth of logic, hoping you don't break something in the process. It’s a common pain point for developers, and it’s a sign that a more elegant solution is needed. This is where a language like Common Lisp truly shines, offering patterns that turn that convoluted mess into clean, scalable, and readable code.

In this deep dive, we'll dissect the Raindrops problem from the exclusive kodikra.com learning path. We will not only solve it but also understand the powerful Lisp concepts that make the solution so elegant. You'll learn how to replace rigid conditional blocks with a flexible, data-driven design that you can apply to countless real-world programming challenges.


What Exactly is the Raindrops Problem?

The Raindrops problem is a step up in complexity from the well-known FizzBuzz challenge. It serves as a fantastic exercise for practicing conditional logic, string manipulation, and data structures. The rules are straightforward, but they require a solution that can handle combinations of conditions gracefully.

The core task is to write a function that takes an integer and converts it into a string based on the number's factors. The specific rules are as follows:

  • If the number has 3 as a factor, the resulting string should include "Pling".
  • If the number has 5 as a factor, the resulting string should include "Plang".
  • If the number has 7 as a factor, the resulting string should include "Plong".

The key here is that these conditions are not mutually exclusive. If a number has multiple factors, the corresponding sounds are concatenated in order. For example, the number 15 is divisible by both 3 and 5, so the result is "PlingPlang". The number 105 is divisible by 3, 5, and 7, yielding "PlingPlangPlong".

There's one final rule: If the number is not divisible by 3, 5, or 7, the function should simply return the number itself, converted to a string. For instance, the input 34 would result in the output "34".


Why is This a Perfect Challenge for Common Lisp?

While you could solve this problem in any language with a series of if statements, Common Lisp encourages a more sophisticated and powerful paradigm. This problem is an ideal vehicle for showcasing several core strengths of the Lisp family of languages.

  1. Data-Driven Design: Instead of hardcoding the rules (3, 5, 7) into the logic of the function, Lisp makes it natural to store these rules as data. We can use an association list (alist) to pair each factor with its corresponding sound. This separates the "what" (the rules) from the "how" (the processing logic), making the code instantly more maintainable and extensible.
  2. Powerful Iteration with the loop Macro: Common Lisp's loop macro is one of the most versatile iteration constructs in any programming language. It allows for complex iteration patterns, filtering, and data collection in a highly readable, declarative style. For Raindrops, we can use it to iterate through our rules, test each one, and collect the results, all within a single, elegant expression.
  3. Functional Programming Principles: The Lisp approach naturally leans towards functional programming. We can build a solution that takes an input, transforms it without side effects, and returns a new output. This leads to code that is easier to reason about, test, and debug.

By tackling this kodikra module, you're not just learning to solve one problem; you're learning a new way of thinking about problems that involve multiple, non-exclusive conditions—a pattern that appears everywhere from configuration management to game development logic.


How to Solve Raindrops: A Deep Dive into the Lisp Way

Let's break down the solution step-by-step, starting with the foundational concepts and building up to the complete, idiomatic Common Lisp code. We'll first analyze the provided solution from the kodikra curriculum and then present a refined version that improves its clarity and robustness.

The Core Logic: Checking for Divisibility

At its heart, this problem is about checking for divisibility. In Common Lisp, the primary tool for this is the mod function. It returns the remainder of a division operation.

;; (mod number divisor)
(mod 28 7) ; => 0
(mod 30 3) ; => 0
(mod 34 5) ; => 4

A number is perfectly divisible by another if the remainder is 0. To check for this, the idiomatic Lisp approach is to use the zerop function, which returns T (true) if its argument is zero, and NIL (false) otherwise.

(zerop (mod 28 7)) ; => T
(zerop (mod 34 5)) ; => NIL

This simple expression, (zerop (mod integer divisor)), forms the conditional heart of our solution.

The Data-Driven Approach: Using an Association List

Instead of writing separate if statements for 3, 5, and 7, we can store these rules as data. An association list, or `alist`, is a perfect fit. An `alist` is a list of pairs, where each pair associates a key with a value. In Common Lisp, these pairs are typically represented as cons cells.

Here's how we define our rules as a global parameter:

(defparameter *raindrops*
  '((3 . "Pling")
    (5 . "Plang")
    (7 . "Plong"))
  "An association list mapping raindrop factors to their sounds.")

Let's break this down:

  • defparameter: Defines a global, dynamic variable. It's conventional in Lisp to surround the names of global variables with asterisks, or "earmuffs," like *raindrops*.
  • '((3 . "Pling") ...): This is the alist itself. The leading quote ' prevents Lisp from trying to evaluate it as code. Each element like (3 . "Pling") is a cons cell, pairing the number 3 (the key) with the string "Pling" (the value).

This design is incredibly powerful. If we wanted to add a new rule for the factor 11 making a "Plong" sound, we wouldn't touch our function's logic. We would simply add a new pair to this list: (11 . "Plong").

Initial Code Walkthrough

Now let's examine the initial solution provided in the kodikra module and understand how it works, line by line. This solution contains a subtle bug, which presents a fantastic learning opportunity.

(defpackage :raindrops
  (:use :common-lisp)
  (:export :convert))

(in-package :raindrops)

(defparameter *raindrops*
  '((3 . "Pling")
    (5 . "Plang")
    (7 . "Plong")))

(defun convert (integer)
  "String of integer or raindrop sound."
  (declare (type integer integer))
  (let ((sounds (loop for (div . sound) of-type (integer . string) in *raindrops*
                      when (zerop (mod integer div))
                      collect sound)))
    (or (format nil "~{~A~}" sounds)
        (format nil "~A" integer))))

1. Package Definition:

(defpackage :raindrops ...) creates a new namespace called raindrops. This prevents our function names from clashing with functions in other parts of a larger program. (:use :common-lisp) makes all the standard CL functions available, and (:export :convert) makes our convert function publicly accessible from other packages.

2. Function Definition:

(defun convert (integer) ...) defines our main function, which accepts one argument, integer.

3. The loop Macro:

This is where the magic happens. (loop ...) iterates through our *raindrops* alist.

  • for (div . sound) ... in *raindrops*: For each element in the list, it destructures the pair. In the first iteration, div will be 3 and sound will be "Pling".
  • when (zerop (mod integer div)): This is our conditional clause. The code that follows only executes if this condition is true (i.e., the integer is divisible by div).
  • collect sound: If the when condition is met, the current sound (e.g., "Pling") is collected. The loop macro automatically gathers all collected items into a list.

After the loop finishes, the variable sounds will contain a list of the corresponding sounds. For an input of 30, sounds would be ("Pling" "Plang"). For an input of 34, sounds would be NIL (the empty list).

4. The Final Expression and Its Flaw:

(or (format nil "~{~A~}" sounds) (format nil "~A" integer)) is intended to handle the two possible outcomes.

  • (format nil "~{~A~}" sounds): This is a powerful feature of format. The ~{...~} directive iterates over a list. The ~A inside prints each element. So, for ("Pling" "Plang"), it produces the single string "PlingPlang".
  • (or ...): The or macro evaluates its arguments from left to right and returns the value of the first one that is not NIL.

Here lies the subtle bug. What happens when the input is 34? The sounds list is NIL. The expression (format nil "~{~A~}" nil) evaluates to an empty string: "". In Common Lisp, an empty string is not NIL. It is a "truthy" value. Therefore, the or macro sees the empty string, considers it true, and returns it immediately. The intended behavior was to return "34", but instead, it returns "".

A Corrected and More Idiomatic Solution

We can fix this by being more explicit with our logic. The goal is to check if the sounds list is empty (NIL), not if the resulting string is empty. A simple if statement is the clearest way to express this.

;; --- Corrected and Optimized Version ---

(defun convert (integer)
  "Converts an integer to its raindrop sound representation."
  (let ((sounds (loop for (divisor . sound) in *raindrops*
                      when (zerop (mod integer divisor))
                      collect sound)))
    (if sounds
        (apply #'concatenate 'string sounds)
        (write-to-string integer))))

Let's analyze the improvements:

  1. let binding: We still use let to store the result of the loop in the sounds variable. This is clean and efficient.
  2. Clear if condition: (if sounds ...) directly checks if the list is non-empty. In Lisp, any non-NIL value is true, so this works perfectly. If sounds is ("Pling"), the condition is true. If it's NIL, the condition is false.
  3. Idiomatic String Concatenation: (apply #'concatenate 'string sounds) is the canonical way to join a list of strings into a single string. concatenate joins sequences, and apply passes the elements of the sounds list as individual arguments to it.
  4. Direct Type Conversion: (write-to-string integer) is a more direct and explicit function for converting a number to its string representation than using format.

This corrected version is not only bug-free but also clearer about its intent, making it a superior and more professional solution.


Visualizing the Logic Flow

To better understand the process, let's visualize the logic of our corrected function using a flow diagram.

Diagram 1: Overall Function Logic

    ● Start: convert(integer)
    │
    ▼
  ┌─────────────────────────┐
  │ Initialize empty list   │
  │ for sounds              │
  └──────────┬──────────────┘
             │
             ▼
  ╭─── Loop through rules ───╮
  │  (3, 5, 7)              │
  │          │              │
  │          ▼              │
  │   ◆ Is integer divisible?
  │    ╱           ╲        │
  │  Yes            No      │
  │   │              │      │
  │   ▼              │      │
  │ ┌───────────┐    │      │
  │ │ Add sound │    │      │
  │ │ to list   │    │      │
  │ └───────────┘    │      │
  │   │              │      │
  │   └──────►───────┘      │
  │                         │
  ╰──────────┬──────────────╯
             │
             ▼
    ◆ Is the sounds list empty?
   ╱                         ╲
  Yes (List is NIL)         No (List has sounds)
  │                          │
  ▼                          ▼
┌──────────────────┐       ┌────────────────────┐
│ Convert integer  │       │ Concatenate all    │
│ to string        │       │ sounds in the list │
└─────────┬────────┘       └──────────┬─────────┘
          │                           │
          └────────────┬──────────────┘
                       ▼
                   ● End: Return final string

This diagram shows the high-level decision-making process. The input number is tested against each rule, sounds are collected, and a final decision is made based on whether any sounds were collected at all.

Diagram 2: The Data-Driven Loop in Detail

    ● Input: integer=30, *raindrops* list
    │
    ▼
  ┌─────────────────────────────┐
  │ LOOP starts                 │
  │ `sounds` list is initially () │
  └──────────┬──────────────────┘
             │
    Iteration 1: (3 . "Pling")
             │
             ▼
    ◆ zerop(mod(30, 3))? → True
             │
             ▼
    sounds → ("Pling")
             │
    ─────────┼──────────────────
             │
    Iteration 2: (5 . "Plang")
             │
             ▼
    ◆ zerop(mod(30, 5))? → True
             │
             ▼
    sounds → ("Pling" "Plang")
             │
    ─────────┼──────────────────
             │
    Iteration 3: (7 . "Plong")
             │
             ▼
    ◆ zerop(mod(30, 7))? → False
             │
             ▼
    sounds → ("Pling" "Plang")  (no change)
             │
  ┌──────────┴──────────────────┐
  │ LOOP ends                   │
  └──────────┬──────────────────┘
             │
             ▼
    ● Return collected list: ("Pling" "Plang")

This second diagram focuses specifically on how the loop macro processes the rules list, building up the list of sounds step-by-step. It highlights the power of separating the rules (the data) from the engine that processes them (the loop).


When to Use This Pattern in Real-World Applications

The data-driven pattern we've used here is far from being a purely academic exercise. It's a highly practical technique used in professional software development for a wide range of applications:

  • Configuration Parsers: When an application reads a configuration file, it often has to apply a set of rules or transformations based on the settings. Storing these rules in a list or map allows for easy updates without changing the core parsing logic.
  • Simple Rule Engines: In business applications, you might need to calculate discounts, assign risk scores, or categorize transactions based on a set of criteria. This pattern allows business analysts to update the rules (the data) without requiring programmers to re-deploy code.
  • Feature Flag Systems: A feature flag system determines which features are visible to which users. The rules for this (e.g., "users in Germany get feature X," "beta testers get feature Y") can be stored in a data structure and processed by a generic engine.
  • Game Development: In a game, you might have various effects that trigger based on certain conditions (e.g., an item gives +10 strength, a magic spell causes a "burn" effect). Representing these effects as data processed by a central loop is a common and effective design.

Learning this pattern through the Raindrops module on the kodikra Common Lisp track provides a solid foundation for building flexible, maintainable, and scalable software systems.

Pros and Cons of the Data-Driven Approach

Pros Cons
  • Extensible: Adding new rules is trivial. You just modify the data list, not the function logic.
  • Readable: The code's intent is clearer because the rules are separated from the execution mechanism.
  • Maintainable: It's easier to debug and reason about the rules when they are explicitly listed as data.
  • Reusable: The core processing loop can be abstracted and reused for other sets of rules.
  • Slight Over-engineering for Trivial Cases: If you truly only have one or two simple, fixed conditions, a direct if might be simpler.
  • Minor Performance Overhead: Iterating over a list can be marginally slower than a series of direct compile-time checks, but this is negligible in almost all real-world scenarios.

Frequently Asked Questions (FAQ)

What is an association list (alist) in Common Lisp?

An association list, or `alist`, is a fundamental data structure in Lisp. It's a list where each element is a pair (specifically, a cons cell) that maps a key to a value. It's a simple way to implement a dictionary or map. For example, in `((3 . "Pling") (5 . "Plang"))`, `3` is a key associated with the value `"Pling"`.

Why use `zerop` with `mod`? Isn't `(= 0 (mod ...))` the same?

Yes, `(= 0 (mod ...))` is functionally identical to `(zerop (mod ...))`. However, using `zerop` is considered more idiomatic and expressive in the Lisp community. It clearly communicates the intent: "is the result of this operation zero?". It's a small stylistic choice that improves code readability.

What's the difference between `defparameter` and `defvar`?

Both define global variables. The key difference is in re-evaluation. If you re-evaluate a `defparameter` form, it will always assign the new value to the variable. If you re-evaluate a `defvar` form, it will only assign the value if the variable is not already bound. `defvar` is typically used for defining global constants or variables that should only be initialized once, while `defparameter` is for global settings that you might want to change during development and testing.

How would I add a new rule, like for the number 11 making a "Plink" sound?

This is where the beauty of the data-driven design shines. You don't touch the `convert` function at all. You simply update the `*raindrops*` alist:

(defparameter *raindrops*
  '((3 . "Pling")
    (5 . "Plang")
    (7 . "Plong")
    (11 . "Plink")))

Your `convert` function will now automatically handle the factor 11 without any further changes.

Is the `loop` macro efficient?

Yes. Modern Common Lisp compilers, like SBCL, are highly optimizing. The `loop` macro is translated into highly efficient, low-level machine code at compile time. Despite its high-level, declarative syntax, the resulting code is often just as fast as a manually written, lower-level loop using constructs like `do` or `dolist`.

What exactly does `(apply #'concatenate 'string sounds)` do?

Let's break it down. `concatenate` is a function that joins sequences. `(concatenate 'string "a" "b" "c")` would produce `"abc"`. The `apply` function takes another function and a list, and calls the function with the elements of the list as its arguments. So, if `sounds` is `("Pling" "Plang")`, then `(apply #'concatenate 'string sounds)` is equivalent to calling `(concatenate 'string "Pling" "Plang")`, which correctly produces `"PlingPlang"`. The `#'` is shorthand for `(function ...)`, which gets the function object associated with a symbol.

Why is this pattern better than a series of `if` statements?

A series of `if` statements would require you to manually build the string and handle the default case. The code would be more verbose and rigid. For example:

(let ((result ""))
  (if (zerop (mod integer 3)) (setf result (concatenate 'string result "Pling")))
  (if (zerop (mod integer 5)) (setf result (concatenate 'string result "Plang")))
  (if (zerop (mod integer 7)) (setf result (concatenate 'string result "Plong")))
  (if (string= result "")
      (write-to-string integer)
      result))
This imperative style is harder to read, involves mutation (`setf`), and is much more difficult to extend with new rules compared to the clean, data-driven `loop` solution.


Conclusion: From Simple Rules to Powerful Patterns

The Raindrops problem, while simple on the surface, serves as a powerful lesson in software design. By progressing from a naive `if-else` approach to a sophisticated, data-driven solution, we've unlocked a pattern that is central to writing flexible and maintainable code in Common Lisp. You've learned how to separate data from logic using association lists, wield the expressive power of the `loop` macro for iteration and collection, and write clear, functional code that is both robust and easy to understand.

This is the essence of the Lisp philosophy: building intelligent systems by representing logic and rules as data that can be manipulated by general-purpose functions. Mastering this concept is a significant step in your journey as a programmer.

Disclaimer: The code in this article is based on modern Common Lisp standards and is expected to work with current compilers like SBCL 2.4+ and CCL. The core concepts are timeless and applicable across all standard implementations.

Ready to tackle the next challenge? Continue your journey through the kodikra Common Lisp learning path or explore our complete Common Lisp language guide for more in-depth tutorials.


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