Raindrops in Clojure: Complete Solution & Deep Dive Guide

a circular object with holes in it

Mastering Conditional Logic in Clojure: The Complete Raindrops Guide

The Clojure Raindrops problem is a classic programming exercise that involves converting a number into a string based on its factors. If the number is divisible by 3, 5, or 7, you append "Pling", "Plang", or "Plong" respectively. If it has no such factors, the number itself is returned as a string.

You’ve probably heard of FizzBuzz. It’s the go-to screening question, the first hurdle in countless technical interviews. But what happens when you’ve moved past that? You encounter its more thoughtful, elegant cousin: the Raindrops problem. It feels simple on the surface, but solving it the right way in a functional language like Clojure reveals a deeper understanding of the language's philosophy.

Many developers approaching Clojure from an imperative background might reach for a series of `if-else` statements and a mutable string builder. While that might produce the correct answer, it misses the beauty and power of Clojure's data-driven, compositional approach. This guide promises to not only give you a solution but to transform how you think about solving problems with data transformations, setting you on the path to true Clojure fluency.


What is the Raindrops Problem?

The Raindrops challenge, featured in the kodikra learning path, is a programming task designed to test your understanding of conditional logic, string manipulation, and basic mathematics. The core concept is to translate a number into a specific string of "raindrop sounds" based on its prime factors.

The Core Rules

The logic is governed by a simple set of rules. For any given integer, you must check for the following conditions:

  • 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 twist is that these conditions are not mutually exclusive. If a number has multiple of these factors, the sounds are concatenated in the order of the factors (3, 5, 7). For example, a number divisible by both 3 and 5 will result in "PlingPlang".

Finally, there's a default case: if the number is not divisible by 3, 5, or 7, the function should simply return the number itself, converted to a string.

Illustrative Examples

To make the requirements crystal clear, let's look at a few examples that cover all the possible scenarios.

Input Number Factors Checked Logic Resulting String
28 7 Divisible by 7, but not 3 or 5. "Plong"
30 3, 5 Divisible by 3 ("Pling") and 5 ("Plang"). "PlingPlang"
105 3, 5, 7 Divisible by 3, 5, and 7. "PlingPlangPlong"
34 None Not divisible by 3, 5, or 7. "34"

Why This Problem is a Perfect Fit for Clojure

At first glance, Raindrops seems like a simple exercise in `if-else` logic. However, its structure is a perfect canvas to showcase the elegance and power of Clojure's functional and data-oriented philosophy. Tackling this problem idiomatically teaches core concepts that are fundamental to writing effective Clojure code.

Embracing Data-Driven Development

The most idiomatic Clojure solutions treat the problem's rules not as a series of hardcoded conditional checks, but as data. The relationship between a factor (like 3) and its sound ("Pling") is a key-value pair. This immediately suggests a data structure, and in Clojure, the hash map ({}) is the king of key-value data.

By defining the rules as a map, you separate the logic (how to check for divisibility and build the string) from the data (the specific rules themselves). This makes the code incredibly easy to extend. Want to add a rule for the factor 11 producing "Plink"? You just add one entry to the map, without touching the core processing functions.


;; The rules defined as data
{3 "Pling", 5 "Plang", 7 "Plong"}

The Power of Composition and Transformation

Clojure encourages you to think of problems as a pipeline of data transformations. The Raindrops problem fits this model perfectly. The process can be visualized as a sequence of steps, where the output of one step becomes the input for the next.

Here is the logical flow of data transformation for this problem:

● Input: 105 (a number)
│
▼
┌───────────────────────────┐
│ Apply Rules (from a map)  │
└────────────┬──────────────┘
             │
             ▼
◆ Filter for matching rules
╱            │            ╲
[3, "Pling"] [5, "Plang"] [7, "Plong"]
             │
             ▼
┌───────────────────────────┐
│ Map to extract sounds     │
└────────────┬──────────────┘
             │
             ▼
◆ Result: ("Pling", "Plang", "Plong")
             │
             ▼
┌───────────────────────────┐
│ Concatenate into a string │
└────────────┬──────────────┘
             │
             ▼
● Output: "PlingPlangPlong"

This pipeline thinking is realized in Clojure through the composition of higher-order functions like map, filter, and reduce (or helpers like apply). Each function is small, pure, and does one thing well. Chaining them together creates a powerful, declarative, and highly readable solution.


How to Solve Raindrops: A Detailed Clojure Implementation

Let's construct a complete, idiomatic Clojure solution from the ground up. We will break the problem down into small, manageable functions, each with a single responsibility. This approach is central to functional programming and makes our code easier to test, debug, and understand.

Step 1: Defining the Rules as Data

As discussed, our first step is to represent the core logic as data. A Clojure map is the perfect tool for this. We'll define it as a private top-level var using def with the ^:private metadata tag, indicating it's an implementation detail of this namespace.


(ns raindrops
  (:require [clojure.string :as str]))

(def ^:private sound-map
  {3 "Pling"
   5 "Plang"
   7 "Plong"})

By using a map, we've immediately made our code more maintainable. The logic that uses this map won't need to change if we decide to add or remove rules.

Step 2: Identifying Applicable Rules

Next, we need a function that takes a number and our sound-map and returns only the rules that apply. A rule "applies" if the number is evenly divisible by the rule's key (the factor).

Clojure's filter function is ideal for this. It takes a predicate function and a collection, and it returns a new sequence containing only the items from the collection for which the predicate returned a truthy value.

Our predicate will check for divisibility using the rem function, which calculates the remainder of a division. If (rem number divisor) is zero, the number is evenly divisible.


(defn- divisors [number]
  (filter
    (fn [[div _]] (zero? (rem number div)))
    sound-map))

Let's break down this private function (defn-):

  • (fn [[div _]] ...): This is an anonymous function that serves as our predicate for filter.
  • [div _]: This is an example of destructuring. When filter iterates over our sound-map, it passes each entry as a vector like [3 "Pling"]. Destructuring allows us to immediately bind the first element to the name div and ignore the second element (the sound) with _.
  • (zero? (rem number div)): This is the core logical test. It returns true if the remainder of number divided by div is zero, and false otherwise.

If we call (divisors 30), it will return a sequence of map entries: ([3 "Pling"] [5 "Plang"]).

Step 3: Aggregating the Sounds

Now that we have the matching rules, we need to extract the sounds (e.g., "Pling", "Plang") and concatenate them into a single string. This can be done in two steps:

  1. Use map to transform our sequence of rules into a sequence of sounds.
  2. Use apply str to concatenate the sounds together.

(defn- sounds-for [number]
  (let [matching-divisors (divisors number)
        sounds (map last matching-divisors)]
    (apply str sounds)))

Let's analyze this function:

  • let: This creates local bindings. It's generally cleaner than nesting function calls too deeply.
  • (divisors number): We call our previously defined function to get the applicable rules.
  • (map last matching-divisors): The last function returns the last item in a collection. For a map entry vector like [3 "Pling"], last returns "Pling". We `map` this function over all our matching divisors. For an input of 30, this produces the sequence ("Pling" "Plang").
  • (apply str sounds): This is a powerful and idiomatic Clojure construct. apply takes a function (str) and a sequence of arguments (sounds). It effectively calls the function with the items of the sequence as if they were passed as individual arguments. So, (apply str '("Pling" "Plang")) is equivalent to calling (str "Pling" "Plang"), which results in the string "PlingPlang".

Step 4: The Final Public Function

Finally, we need to create the main convert function. This function will be the public API of our namespace. It needs to orchestrate the process and handle the default case where no sounds are generated.


(defn convert [number]
  (let [sounds (sounds-for number)]
    (if (str/blank? sounds)
      (str number)
      sounds)))

Here's the final piece of the puzzle:

  • We call sounds-for to get our concatenated string of raindrop sounds.
  • (str/blank? sounds): We use the blank? function from the clojure.string library (which we aliased as str in our namespace declaration). This function returns true if a string is null, empty, or contains only whitespace. It's a robust way to check for an "empty" result.
  • The if statement directs the flow. If sounds is blank, we return the original number converted to a string with (str number). Otherwise, we return the generated sounds string.

Putting It All Together: The Complete Namespace

Here is the final, complete code for the raindrops namespace. It's clean, modular, and perfectly demonstrates the functional approach.


(ns raindrops
  (:require [clojure.string :as str]))

(def ^:private sound-map
  "A map defining the factor-to-sound relationship."
  {3 "Pling"
   5 "Plang"
   7 "Plong"})

(defn- divisors
  "Filters sound-map to find factors of the given number."
  [number]
  (filter
    (fn [[div _]] (zero? (rem number div)))
    sound-map))

(defn- sounds-for
  "Generates the concatenated sound string for a number."
  [number]
  (let [matching-divisors (divisors number)
        sounds (map last matching-divisors)]
    (apply str sounds)))

(defn convert
  "Converts a number to its raindrop sound representation.
  If no sounds are generated, returns the number as a string."
  [number]
  (let [sounds (sounds-for number)]
    (if (str/blank? sounds)
      (str number)
      sounds)))

This solution is not just correct; it's also extensible and readable. It tells a story about how data flows through a series of transformations, which is the heart of programming in Clojure. You can learn more about these fundamental concepts in our complete Clojure guide.


Alternative Approaches and Optimizations

While the solution above is robust and idiomatic, Clojure often provides multiple ways to solve a problem. Exploring alternatives can deepen your understanding of the language's rich feature set. One particularly elegant alternative uses the cond-> macro.

The `cond->` Macro: A Declarative Approach

The cond-> (conditional thread-first) macro is designed for building up a value through a series of conditional steps. It's a perfect fit for Raindrops because we are essentially starting with an empty string and conditionally appending sounds to it.

Here’s how you could implement convert using cond->:


(defn convert-cond [number]
  (let [sounds (cond-> ""
                 (zero? (rem number 3)) (str "Pling")
                 (zero? (rem number 5)) (str "Plang")
                 (zero? (rem number 7)) (str "Plong"))]
    (if (empty? sounds)
      (str number)
      sounds)))

Let's break down the cond-> form:

  • "": This is the initial value that gets "threaded" through the following expressions.
  • (zero? (rem number 3)) (str "Pling"): This is a clause. If the test expression (zero? (rem number 3)) is true, the macro applies the form (str "Pling") to the current value. The -> in cond-> means the value is inserted as the first argument, but since str can take multiple arguments, (str acc "Pling") is effectively called.
  • The macro proceeds through each clause, accumulating the results. If a test is false, it does nothing and passes the current value to the next clause.

This data flow is visualized below:

● Start with "" (empty string)
│
▼
◆ Is `(rem number 3)` zero?
├─ Yes → Current value becomes `(str "" "Pling")` → "Pling"
└─ No  → Current value remains ""
│
▼
◆ Is `(rem number 5)` zero?
├─ Yes → Current value becomes `(str "Pling" "Plang")` → "PlingPlang"
└─ No  → Current value remains "Pling"
│
▼
◆ Is `(rem number 7)` zero?
├─ Yes → Current value becomes `(str "PlingPlang" "Plong")` → "PlingPlangPlong"
└─ No  → Current value remains "PlingPlang"
│
▼
● Final `sounds` value

Pros and Cons of Each Approach

Choosing between the map-based approach and the cond-> macro involves trade-offs in readability, extensibility, and explicitness.

Aspect Map-Based Functional Pipeline `cond->` Macro
Extensibility Excellent. To add a new rule, you only modify the sound-map data. The logic functions remain untouched. Good. Adding a new rule requires adding a new clause (two lines) to the cond-> form.
Separation of Concerns Excellent. The rules (data) are completely separate from the processing logic (functions). Fair. The rules (conditions) and the logic (actions) are intertwined within the macro's body.
Readability Highly readable for those familiar with functional composition (map, filter). The flow is very explicit. Extremely concise and can be very readable for those who understand threading macros well. It reads like a list of conditional rules.
Performance Negligible difference for this problem scale. May involve slightly more overhead due to creating intermediate sequences. Potentially slightly faster as it avoids creating intermediate collections, but this is not a meaningful optimization here.

For the Raindrops problem, the map-based approach is often preferred in educational contexts because it better exemplifies the "data as code" philosophy of LISPs like Clojure. However, the cond-> approach is undeniably elegant and is a valuable tool to have in your Clojure arsenal.


Frequently Asked Questions (FAQ)

1. Why use a map for the rules instead of a series of `if-else` statements?

Using a map separates your data (the rules: 3 maps to "Pling") from your logic (the code that processes those rules). This makes the code more flexible and extensible. If you need to add a new rule (e.g., 11 -> "Plunk"), you simply add an entry to the map. With `if-else` statements, you would have to modify the core function's code, which is more error-prone.

2. What is the difference between `rem` and `mod` in Clojure?

For positive numbers, rem (remainder) and mod (modulo) behave identically. The difference appears with negative numbers. (rem -10 3) is -1, while (mod -10 3) is 2. Since the Raindrops problem typically deals with positive integers, either function would work, but rem is often slightly more performant and its name more clearly states the intention of checking for a zero remainder.

3. Can you explain what `(apply str sounds)` does in more detail?

The apply function takes a function and a sequence, and calls the function with the elements of the sequence as its arguments. For example, if sounds is the list ("Pling" "Plang"), then (apply str sounds) is the equivalent of executing (str "Pling" "Plang"). It's a concise way to pass a variable number of arguments from a collection to a function.

4. Is the `cond->` approach always better or more "idiomatic"?

Not necessarily. "Idiomatic" depends on context. The cond-> macro is very idiomatic for building up a value based on a fixed set of conditions. However, the map-based functional pipeline is arguably more idiomatic when the rules themselves are considered dynamic data. For this specific problem, both are considered excellent Clojure style.

5. How is the Raindrops problem a step up from FizzBuzz?

FizzBuzz has mutually exclusive outcomes for its primary rules (a number is divisible by 15, or just 3, or just 5). Raindrops requires you to handle conditions that can be simultaneously true and combine their results (e.g., 30 is divisible by both 3 and 5). This forces a shift from simple `if-else if-else` logic to a model of accumulating results, which is a more complex and common real-world pattern.

6. What does the `^:private` metadata do?

The ^:private true metadata (or its shorthand ^:private) is a hint to the Clojure compiler and other tools that the `var` being defined (like our sound-map) is intended for internal use within the current namespace only. It prevents it from being accessible from other namespaces, enforcing good encapsulation.

7. Could this be solved with `reduce`?

Absolutely. You could use reduce to iterate over the `sound-map` and build the string. It's a great exercise in functional thinking. Here's a quick example:


(defn convert-reduce [number]
  (let [sounds (reduce
                 (fn [acc [div sound]]
                   (if (zero? (rem number div))
                     (str acc sound)
                     acc))
                 ""
                 sound-map)]
    (if (empty? sounds) (str number) sounds)))
    

This approach works well but can be slightly less readable than the `filter` -> `map` pipeline for developers new to the concept of reduction.


Conclusion: More Than Just a Puzzle

The Raindrops problem, as presented in the kodikra.com curriculum, is a gateway to understanding the functional paradigm that makes Clojure so powerful. By solving it idiomatically, you move beyond simple command-based thinking and begin to see programming as a series of data transformations. You learn to separate data from logic, compose small, pure functions into powerful pipelines, and leverage Clojure's expressive data structures like maps.

Mastering this pattern of thinking is crucial for building robust, scalable, and maintainable applications. The skills you've honed here—decomposing a problem, using higher-order functions, and writing declarative code—are directly applicable to complex, real-world challenges in data processing, web development, and beyond.

Ready to continue your journey? Challenge yourself with the next module in our Clojure Learning Path or deepen your foundational knowledge by exploring our complete Clojure programming guide.

Disclaimer: All code examples in this article are written for Clojure 1.11+ and Java 11+. While the core concepts are timeless, specific function availability and behavior may vary with different versions.


Published by Kodikra — Your trusted Clojure learning resource.