Статьи

Создание кодекса в Clojure

На днях я работал над небольшим количеством кода в Clojure, просто исправляя некоторые сообщения об исключениях, когда меня внезапно поразила одна из фундаментальных причин того, что Clojure настолько приятен для кодирования. Clojure легко поддается обработке : в Clojure у вас есть возможность создать свой код, чтобы сделать его более кратким, легким для чтения и простым в обслуживании. Это не относится ко всем или, возможно, даже к большинству языков программирования.

В моем случае я создавал сообщение об ошибке, в котором мне нужно было преобразовать ключи двух карт в строку, разделенную запятыми (я не люблю говорить «ты угадал», не говоря «вот что ты мог сказать») ,

То, что я хочу, чтобы мой код делал, легко выражается как неформальный рецепт:

  • Извлеките все ключи из обеих карт
  • Удалить все дубликаты
  • Преобразовать ключи в строки
  • Сортировка строк в порядке возрастания
  • Создайте и верните одну большую строку, объединяя все ключевые строки, используя «,» в качестве разделителя
  • Вернуть «<none>», если обе карты пусты

Если бы я писал это на Java, это выглядело бы примерно так:

package com.howardlewisship;
 
import java.util.*;
 
public class MapUtils {
public static String sortedKeyList(Map<?, ?> map1, Map<?, ?> map2) {
 
Set<Object> allKeys = new HashSet<Object>(map1.keySet());
allKeys.addAll(map2.keySet());
 
if (allKeys.isEmpty()) {
return "<none>";
}
 
List<String> sortableKeys = new ArrayList<String>();
 
for (Object k : allKeys) {
sortableKeys.add(k.toString());
}
 
Collections.sort(sortableKeys);
 
StringBuilder builder = new StringBuilder(100);
boolean first = true;
 
for (String s : sortableKeys) {
if (!first) {
builder.append(", ");
}
 
builder.append(s);
 
first = false;
}
 
return builder.toString();
}
}

В этом коде достаточно циклов и условных выражений (вместе с подсказками по Java Generics), чтобы было проще взглянуть на спецификацию теста (написанную на Spock ), чтобы увидеть, что он должен делать:

import com.howardlewisship.MapUtils
import spock.lang.Specification
 
class SortedKeysSpec extends Specification {
 
def "<none> returned when both maps are empty"() {
 
expect:
MapUtils.sortedKeyList([:], [:]) == "<none>"
}
 
def "keys from both maps are merged into sorted list"() {
expect:
MapUtils.sortedKeyList([fred: true], [barney: true, wilma: true]) == "barney, fred, wilma"
}
 
def "no separator for single key"() {
expect:
MapUtils.sortedKeyList([fred: true], [:]) == "fred"
}
 
def "duplicates between maps are ignored"() {
expect:
MapUtils.sortedKeyList([fred:true], [fred: false, barney: true]) == "barney, fred"
}
 
def "keys may be other than string"() {
when:
def map1 = [:]
def map2 = [:]
 
map1[200] = true
map2[[1, 2]] = true
map2[Collection] = true
 
then:
MapUtils.sortedKeyList(map1, map2) == "200, [1, 2], interface java.util.Collection"
}
}

Первый проход в версии Clojure уже проще, чем в версии Java …

(ns com.howardlewisship.map-utils
(import [java.util Collection])
(use clojure.test)
(require [clojure.string :as s]))
 
(defn sorted-key-list-1
[map1 map2]
(let [all-keys (set (concat (keys map1) (keys map2)))]
(if (empty? all-keys)
"<none>"
(let [key-names (map str all-keys)
sorted-names (sort key-names)]
(s/join ", " sorted-names)))))
 
(deftest sorted-key-list
(are [map1 map2 result]
(= (sorted-key-list-1 map1 map2) result)
 
{} {} "<none>"
 
{:fred true} {:barney true, :wilma true} ":barney, :fred, :wilma"
 
{:fred true} {} ":fred"
 
{:fred true} {:fred true, :barney true} ":barney, :fred"
 
{200 true, [1, 2] true, Collection true} {} "200, [1 2], interface java.util.Collection")))
 
(run-tests)

I couldn’t resist using the clojure.string/join function, rather than building the string directly (which would be slightly tedious in Clojure). In many ways, this is a lot like the Java version; we’re using let to create local symbols for each step in the process in just the same way that the Java version defines local variables for each step.

However, there’s room for improvement here. Let’s start to craft.

For example, let’s assume that both maps being empty is rare, or at least, that the cost of sorting an empty list is low (it is!). Our code becomes much more readable if we merge it into one big let:

(defn sorted-key-list-2
[map1 map2]
(let [all-keys (set (concat (keys map1) (keys map2)))
key-names (map str all-keys)
sorted-names (sort key-names)]
(if (empty? sorted-names)
"<none>"
(s/join ", " sorted-names))))

Now we’re getting somewhere. I think this version makes it much more clear what is going on that the prior Clojure version, or the Java version.

However, if you’ve written enough code, you know one of the basic rules of all programming: names are hard. Anything that frees you from having to come up with names is generally a Good Thing. In Java, we have endless names: not just for methods and variables, but for classes and interfaces … even packages. Long years of coding Java has made me dread naming things, because names never quite encompass what a thing does, and often become outdated as code evolves.

So, what names can we get rid of, and how? Well, if we look at the structure of our code, we can see that each step creates a value that is passed to the next expression as the final parameter. So all-keys is passed as the last parameter of the (map) expression, resulting in key-names, and then key-names is passed as the last parameter of the (sort) expression. In fact, ignoring the empty check for a moment, the sorted-names value is passed to the (s/join) expression as the last parameter as well.

This is a very important concept in Clojure; you may have heard people trying to express that you code in Clojure in terms of a «flow» of data through a series of expressions. We’ll, you’ve just seen a very small example of this.

На самом деле, это не простое совпадение, что последний параметр так важен; это представляет собой тщательное и аргументированное выравнивание параметров многих различных функций в clojure.core и в других местах, чтобы гарантировать, что поток может быть передан в качестве этого конечного параметра, поскольку он становится центральным в способности комбинировать функции и выражения вместе с минимальным суетой.

Мы можем использовать
->>макрос (произносится как «последний поток»), чтобы перестроить наш поток без необходимости придумывать имена для каждого шага:

(defn sorted-key-list-3
[map1 map2]
(let [sorted-names
(->>
(set (concat (keys map1) (keys map2)))
(map str)
sort)]
(if (empty? sorted-names)
"<none>"
(s/join ", " sorted-names))))

The ->> macro juggles our expressions into an appropriate order; without it we’d have to deeply nest our expressions in an unreadable way: (sort (map str (set (concat (keys map1) (keys map2))))). Even with a short flow of expressions, that’s hard to parse and interpret, so ->> is an invaluable and frequently used tool in the Clojure toolbox.

We can continue to craft; the first expression (that builds the set from the keys), can itself be broken apart into a few smaller steps. This is really to get us ready to do something a bit more dramatic:

(defn sorted-key-list-4
[map1 map2]
(let [sorted-names
(->>
(keys map1)
(concat (keys map2))
set
(map str)
sort)]
(if (empty? sorted-names)
"<none>"
(s/join ", " sorted-names))))

This is getting ever closer to our original recipe; you can more clearly see the extraction of keys from the maps before building the set (which is only used to ensure key uniqueness), before continuing on to convert keys from objects to strings, sort them, and combine the final result.

In fact, we’re going to go beyond our original brief, and support any number of input maps, not just two:

(defn sorted-key-list-5
[& maps]
(let [sorted-names
(->>
(mapcat keys maps)
set
(map str)
sort)]
(if (empty? sorted-names)
"<none>"
(s/join ", " sorted-names))))

The mapcat function is like map, but expects that each invocation will create a collection; mapcat concatinates all those collections together … just what we want to assemble a collection of all the keys of all the input maps.

At this point, we don’t have much more to go … but can we get rid of the sorted-names symbol? In fact, we can: what if part of our flow replaced the empty list with a list containing just the string «<none>»? It would look like this:

(defn replace-empty
[replacement coll]
(if (empty? coll)
replacement
coll))
 
(defn sorted-key-list-6
[& maps]
(->>
(mapcat keys maps)
set
(map str)
sort
(replace-empty ["<none>"])
(s/join ", ")))

… and that’s about as far as I care to take it; a clean flow starting with the maps, and going through a series of expressions to transform those input maps into a final result. But what’s really important here is just how fast and easy it is to start with an idea in Clojure and refine it from something clumsy (such as the initial too-much-like-Java version) into something elegant and surgically precise, such as the final version.

That’s simply not something you can do in less expressive languages such as Java. For example, Tapestry certainly does quite a number of wonderful things, and supports some very concise and elegant code (especially in green code) … but that is the result of organizing large amounts of code in service of specific goals. We’re talking tons of interfaces, a complete Inversion-Of-Control container, and runtime bytecode manipulation to support that level of conciseness. That’s the hallmark of a quite consequential framework.

That isn’t crafting code; that’s a big engineering effort. It isn’t local and invisible, it tends to be global and intrusive.

In Java, your only approach to simplifying code in one place is build up a lot of complexity somewhere else.

That is simply not the case in Clojure; by adopting, leveraging, and extending the wonderful patterns already present in the language and its carefully designed standard library, you can reach a high level of readability. You are no longer coding to make the compiler happy, you are in control, because the Clojure languge gives you the tools you need to be in control. And that can be intoxicating.

The source code for this blog post is available on GitHub.