Artur Dumchev
15 Dec 2022
•
15 min read
”If you like Java, program in Java. If you like C#, program in C#. If you like Ruby, Swift, Dart, Elixir, Elm, C++, Python, or even C; by all means use those languages. But whatever you do, learn Clojure, and learn it well.” --- Uncle Bob (and several more tweets: 1, 2, 3).
There are lots of articles about Clojure. In this one, I'd like to share my thoughts on the advantages of using it for cross-platform mobile development. Dart programmers are the target audience, but anyone interested in Flutter and/or Clojure is welcome.
Clojure was not created in a hurry (JS), it didn't try to seize the market with a multimillion budget (Java). It wasn't backed by a powerful company (Go, Dart, Kotlin), and it wasn't the only platform's language (Swift, C#).
So why did Clojure happen to be noticed? Why is it the most paid language and one of the three most loved? Why do people write ports for Java, JS, C#, Unity, Python, Elm and, finally, Flutter? (proofs in FAQ below)
The answer could be found in Rich Hickey's "Simple Made easy" talk. My unforgiving short and shameless version: the pal thought on how to make good stuff, directed by principles of simplicity, stability, and practicality, and made Clojure. He did not depend on deadlines, budgets, or the pursuit of hype and vogue.
You should first be aware that an expression in Clojure is represented as a list, with the first element being a function and the other elements being arguments, in order to make it easier to read.
(fn arg-1 arg-2 ... arg-n)
Each argument and even a function could be the same kind of expression, for example:
(fn-2 (fn-3 arg) arg-2)
At first, the (fn-3 arg)
expression gets evaluated:
(fn-2 evaluated-arg arg-2)
Although it is simplified, it is sufficient to comprehend the examples I will provide.
Examples in Dart and Clojure doing the same:
Dart: max(1, 2);
Clojure: (max 1 2)
Dart: a > b || (c > d && d > e && e >f);
Clojure: (or (> a b) (> c d e f))
Dart: if (a == 1) a else b;
Clojure: (if (= a 1) a b)
Dart: int square(int n) => n * n;
Clojure: (defn square [n] (* n n))
Note that in each case where parentheses are used in Dart, they signify different things, such as calling a function, controlling the order in which statements are executed, capturing a special form of if
, or grouping function parameters.
Contrarily, in Clojure parentheses are always used to combine a function or a macro with arguments.
In addition, it is not immediately obvious on Dart (you must be aware of or use parentheses), which operator (<
, ||
, or &&
) will be executed first. The parentheses in Clojure indicate everything.
++i;
(inc i)
One more thing. Dart uses infix (1 < 2
), prefix (max(1, 2)
), and postfix (i++
) notations, whereas Clojure has only prefix notation.
Since Clojure is more consistent and has fewer rules, its syntax is typically much simpler than Dart's. Check the Antlr parsers (and lexers) for Clojure and Dart for the simplest way to demonstrate this difference.
The descriptions of Clojure use five times fewer words in the links I've provided. There will be a 9-times difference if we use the official Dart parser (spec).
And here is the Tree-sitter grammar: Clojure has 1673 lines and Dart --- 10462.
Take a look at this example with the same code in Dart and Clojure side by side. Another is this cookbook implemented in Clojure with 50 percent fewer lines of code.
To demonstrate why code in Clojure is typically more concise, I'll give several examples in this section of the article.
In Dart, nested widget resemble a ladder.:
Container(
color: Colors.red,
child: const SizedBox(
child: Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Oh my god, how “clutter” is it!',
),
),
),
),
);
Why shouldn't we eliminate this duplication if we are aware that each nested widget has a ":child" argument? Like:
nest(
Container(color: Colors.red),
SizedBox(),
Center(),
Padding(padding: EdgeInsets.all(16)),
Text('Oh my god, how “flutter” is it!'));
Since the :child
parameter is a required
one, there is no way to solve this during compile time (as I wrote it above or with annotations). We could ask dart compiler developers to make the nest
, but will they agree to add a new keyword (no)?
For a limited number of widget classes, we could use the reflection API to address this in runtime (although since :child
fields are final
, we couldn't modify them; we would have to copy instead). The drawbacks include performance and the amount of code required.
Because we are unsure of the widget implementation that the user will supply to our nest
function, it is also impossible to solve this problem in runtime for a general case. Perhaps there is a widget with a :child
named parameter, but the user saves it as a field with a different name inside the implementation. How do we find this name?
A solution is possible (example: nested lib), but it doesn't work at compile time, and there is a lot of code needed to solve the problem.
Regarding Clojure, the original code appears as follows:
(Container
:color m.Colors/red
:child
(SizedBox
:child
(Center
:child
(Padding
:padding (EdgeInsets/all 16)
:child (Text "Oh my god, how “clutter” is it")))))
However, we can simply rewrite it:
(nest
(Container :color m.Colors/red)
(SizedBox)
(Center)
(Padding :padding (EdgeInsets/all 16))
(Text "Oh my god, how “flutter” is it"))
It will work in a general case and resolve all the aforementioned issues.
Code as data, or homoiconicity. You can put a program code into a data structure and evaluate it. For example, code written in Dart could not be put into Dart's array, but we can do this in Clojure.
If the language is homoiconic, then you can easily manipulate the syntax tree: write and modify a Clojure code with Clojure, that is, expand the compiler. The user's tool is macros, and the following is an example of a macro that allows you to "straighten" the nesting:
(defmacro nest [form & forms]
(let [[form & forms] (reverse (cons form forms))]
`(->> ~form ~@(for [form forms]
(-> form
(cond-> (symbol? form) list)
(concat [:child])
(with-meta (meta form)))))))
We don't have to write this as it's already exist (nest). And the main widget
macro supports it.
Thanks to the apply
function, we can do things that are not possible in other languages. Let's go over the examples first, and then we'll examine how it works.
In Dart:
bool isSorted(List<int> list) {
if (list.length < 2) return true;
int prev = list.first;
for (var i = 1; i < list.length; i++) {
int next = list[i];
if (prev > next) return false;
prev = next;
}
return true;
}
isSorted([0, 1, 2, 3, 4, 5]); // true
var list = [for(var i=0; i<6; i+=1) i];
isSorted(list); // true
In Clojure:
(apply < [0 1 2 3 4 5]) ;;=> true
(apply < (range 6)) ;;=> true
How does it work? applу
accepts a function and a list, then passes the list's items as arguments. As a result, the expression becomes (< 1 2 3 4 5)
. This is a slightly unfair example as Clojure's >
function accepts any number of arguments.
Let's glance at another, more intricate example.
In Dart:
List<List<int>> transposeList<R>(List<List<int>> input) {
return List.generate(
input[0].length,
(i) => List.generate(input.length, (j) => input[j][i]),
);
}
transposeList([[1, 2],
[3, 4],
[5, 6]]); // [[1, 3, 5],
// [2, 4, 6]]
In Clojure:
;; function declaration, without external libraries
(defn transpose [m] (apply map list m))
;; function invocation
(transpose [[1 2]
[3 4] ;; => [[1 3 5]
[5 6]]) ;; [2 4 6]]
How does it work? The map
function expects a function and one or several sequences. Examples:
(map inc [1 2 3]) ;;=> (2 3 4)
(map odd? [1 2 3]) ;;=> (true false true)
(map + [1 2] [1 1]) ;;=> (2 3)
(map max [1 2] [2 1]) ;;=> (2 2)
As a result, the expression (apply map list [[1 2] [3 4] [5 6]])
becomes:
(map list [1 2] [3 4] [5 6])
The map function is called with vectors of two elements. This means that in the end we will get only 2 calls to the list
function, which was passed as the first argument to the map
function.
Arguments 1, 3, and 5 will be presented first; arguments 2, 4, and 6 will be used in the second call.
[(list 1 3 5) (list 2 4 6)] ;;=> [(1 3 5) (2 4 6)]
My point goes beyond the simple fact that in Clojure functions like equals
, compare
, sort
, and others just work (unlike Dart, where you must use a Collection library to compare two lists).
Clojure's core collections are persistent, which means they are immutable and can be copied almost instantly (Log32). For example, a function that adds a key to a map would return a new map without changing the original. And it's effectively in constant time.
Because memory is reused internally (only the difference counts, per wiki), you can have many maps for the same memory cost as one; which makes it possible to write effective, thread-safe code.
To put it simply, a data-oriented approach separates code from data. In doing so, we separate the data hierarchy from the logic hierarchy (classes), which results in a lower coupling. Following are some examples from the related article:
Without separation:
With separation:
The subject is huge and won't fit into one article, so look into the advantages of this approach in the book and the blog. I would also recommend this video with OOP-experts code simplification. I will only discuss one aspect here.
The boilerplate code overriding hash
, ==
, toMap
, fromMap
, toString
, copyWith
is not necessary when representing domain models as data structures, such as maps. This would merely be one of many helpful functions to work with Clojure collections that are readily available.
To illustrate this, I want you to picture a representation of a "human" where we want to increase an age.
(def human {:name "Bob", :age 30})
(def old-human (update human :age inc)) ;=> {:name "Bob", :age 31}
In OOP, a Human
class could have an increaseAge
method. What if, however, we wanted to be able to decrease our age? In Clojure, we'll simply pass dec
(instead of inc
), but what about Dart? Add another method?
The issue with this approach is not just that we have to add more code, but also that we are constantly dealing with new classes that have their own methods. This can be seen by contrasting the Dart (Java, Kotlin, Swift) and Clojure experiences when trying out new libraries. Clojure ones are easier to work with, at least due to the fact that you do not need to learn new methods/funcitons.
The same in other words: (from Clojure website): Putting information in such classes is a problem, much like having every book being written in a different language would be a problem. You can no longer take a generic approach to information processing. This results in an explosion of needless specificity, and a dearth of reuse.
You can manipulate the data of any library, as if you had written these methods yourself, thanks to the vast array of functions I previously mentioned. That's what Alan Perlis meant when he said:
"It is better to have 100 functions operate on one data structure than to have 10 functions operate on 10 data structures".
I've been developing for Android for seven years, and during that time, a lot has changed only in the specific fields of concurrency and "asynchronousy."
People initially used raw Threads, IntentServices, and AsyncTasks. Then RxJava emerged, articles and books were written, lots of libraries supported it. I remember having argument with my teamlead about using RxJava2. He refused and proved to be right as RxJava3 appeared, but Kotlin Coroutines was the new trend.
WorkManager came to replace JobScheduler, that replaced AlarmManager (in tandem with BroadcastReceiver and Service). Who will replace WorkManager? Lets wait for a year or two and find out.
In the world of Clojure, everything is different. Core.async is still in use today, nine years after it was developed. In general, Clojure code is extremely durable. Here is a link on "A History of Clojure" by Rich Hickey, where (page 26, or this twit) you can compare Scala and Clojure in terms of code retention.
At last, Clojure could be used not only for a Flutter development. Below is the list of all the possible applications that I consider practical:
C++ host jank is upcoming.
In my most recent project Dart to Clojure translator, for example, the core, dependencies, deploy, and build scripts are all written in the same language. The application contains a jar file, a native image (Graalvm), npm (ClojureScript), as well as Java and JS libraries.
Aside from the fact that Clojure is the third most loved language according to the StackOverflow survey, and my own experience --- which I try to keep separate from --- I can think of a number of other examples to support this claim.
Here is the reproduction research on the impact of programming languages on code quality. In essence, commits that fixed bugs were examined in GitHub projects.
Some highlights:
Libraries' sizes and salaries (next 2 questions) are two additional potential proofs.
I don't know of any scientific research, but there are enthusiastic comparisons, like this one, where Clojure is the 2nd among 24 other frameworks. In this "Love Letter To Clojure" Gene Kim rewrote his React/Js app (1500 lines) to ClojureScript (500 lines).
According to StackOverflow survey in 2022 (and 2021 as well), Clojure developers are paid the most. I came across a Reddit post that provided some justifications for the idea that the language attracts seasoned (and expensive) developers, which could be supported by the same survey (link). The thesis might also be backed up by State of Clojure 2022, question 8. More than 76% of developers have experience of at least six years, and 50% have more than eleven years.
I think we should take two things into account. The first is that popularity and quality are not necessarily correlated. The most popular language is JS, for instance.
Another thing is The Lisp Curse. It's assumed that Lisp allows one to be extremely productive. So, instead of relying on companies to achieve results, the developer does it himself/herself.
"Most of these projects will be lone-wolf operations. Thus, they will have eighty percent of the features that most people need (a different eighty percent in each case). They will be poorly documented. They will not be portable across Lisp systems."
For instance, 75% of Java engineers use IntelliJ IDEA as an editor (2021), while in Clojure there is no one most popular editor and distribution is somewhat even (Emacs, Idea, VSCode, Vim and less popular Atom, Sublime, NightCode). Same with web frameworks.
It is believed, however, that Clojure partially escapes the LISP curse by making use of the well-maintained host libraries Java/Js/Dart/C# (since Clojure is a hosted language).
It's difficult to give a simple answer to this question, but keep in mind that software development has marketing-related aspects. More than 500 million dollars were invested in marketing campaigns for Java. Why does Java support the OOP paradigm? Maybe it was easier to sell this to C++ developers.
If you are interested in this subject, I suggest watching Why Isn't Functional Programming the Norm? video:
My impression is that different people "despise" dynamic typing for different reasons.
If your experience with JS has made you dislike dynamic typing, you might actually dislike implicit weak typing. But unlike JS, Clojure does not have the odd problems like listed below.
1 === '1'; // false
1 == '1'; // true
true === 1; // false
true == 1; // true
[0] == 0 // true
{} + [] == 0 // true`
If you were not pleasantly impressed by Python, you probably had two problems with it: performance and large code base maintenance (difficult to understand others' code without types).
When using Clojure, you won't encounter performance problems (for the most cases) because your functions are transformed into hosted language methods; if reflection is required, you can use type hints to avoid it. Maintenance issues are also not a problem; in my opinion, REPL is a game-changer here (it's not supported for ClojureDart yet).
If you conceptually disagree with dynamic typing and don't see any benefits to using it, it might make sense to look into Gödel's incompleteness theorems regarding the drawbacks of formal systems (or should we say typed systems?).
Maybe not is a great talk about the topic.
Many options are available, and it depends.
If you are unfamiliar with Clojure, you could attempt to get up to speed quickly by Learning X in Y minutes and completing a number of tasks from the 4clojure website. Another choice is Practical.li, which offers both written and visual content. And if you are into books, lots of people started with BraveClojure.
If you are already comfortable with Clojure and want to write Flutter applications right away, visit the ClojureDart page and check out the quick start guide. You could also check How to Start ClojureDart which is a piece I wrote on the topic.
A ClojureDart workshop or a ClojureDart YouTube channel are other options.
And if it has suddenly occurred to you that Clojure is a highly sophisticated technology that necessitates extensive knowledge of mathematics, this is unquestionably untrue. Since the language is pragmatic, one can avoid thinking about or even being aware of category theory entirely. Although reading difficult books like SICP is useful, it is not required.
Read Evaluate Print Loop. In Clojure, the REPL connects to the application, allowing you to edit the program's code while it is still in progress without losing any state. For instance, you could connect to the handler on the backend in production, see what information is passed through it, and fix a critical bug there without having to re-deploy. REPL is integrated into the workflow (both in the editor and in the application).
This is not supported on consoles, replicas, or shells by Dart, JS, Python, Swift, Kotlin, Java, Scala, or Haskell. Their shells don't integrate with the program; instead, they resemble a console or a debugger that can be used to check the functionality of some pieces of code.
ClojureDart does not yet support REPL, but it soon will.
What makes Clojure a good choice for Flutter development? Basically, to acquire tools that increase development simplicity and speed.
We can extend the language whatever we like thanks to macros. For instance, to make the code base smaller and increase readability (this article discusses the example with "nest," which removes widget nesting).
By mastering the basics of working with data in Clojure, you can advance to the point where any project or library's code is predictable and understandable, as everyone uses the same approach to modeling data.
Additionally, writing immutable code is easier since you may copy collections with O(Log32)
.
Functional programming. Reliable libraries. Elegant and consistent syntax. Supporting community.
Ground Floor, Verse Building, 18 Brunswick Place, London, N1 6DZ
108 E 16th Street, New York, NY 10003
Join over 111,000 others and get access to exclusive content, job opportunities and more!