summaryrefslogtreecommitdiff
path: root/posts/first-impressions-of-the-kotlin-programming-language.org
blob: 406d0a3bd30dec7470e1843a608e7825d64017b1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
#+TITLE: First Impressions of the Kotlin Programming Language
#+DATE: <2018-12-17 Mon 00:00>
#+TAGS: opinion programming java kotlin android

In the introduction of the previous post I wrote for this series, [[http://jakob.space/blog/first-impressions-of-the-rust-programming-language.html][First
Impressions of the Rust Programming Language]], I alluded to the presence of
arguments that programming language safety should be achieved by moving to
languages such as Java which run on a virtual machine. While "safety" may no
longer be the first thing that comes to mind in discussion of these languages,
especially with the hundreds[fn:1] of vulnerabilities in various implementations
of the Java virtual machine, it would be unfair to deny that the principle of
running programs in a sandboxed virtual machine is safer than running machine
code directly. This post won't be making any claims about safety, though, as I'm
more interested in writing about my impressions from a language design
perspective. So, how does Java fare in this regard?

My first run-in with Java was when I was 16 and still in high school; I had
enrolled in AP computer science, of which the curriculum was and still is taught
using Java. I already knew Python and C at that point, but enrolled anyway as I
was never given a formal computer science education, and because the idea of
university credits was enticing to me. The portion of the class that regarded
concepts of computer science -- algorithm design and data structures -- was a
blast, but I quickly became frustrated with the programming assignments because
of Java's horribly unwieldy nature. Despite my distaste for the way the course
was taught, my grades stood out enough that the teacher approached me about
working as a teaching assistant the following year. I took him up on the offer,
and suffered. The position involved troubleshooting students' issues during lab
periods, and nearly every time I sat down to help a student, the issue was with
one of Java's numerous pitfalls rather than a conceptual misunderstanding of
computer science. At of the time of writing this, I have just completed my first
semester of university, and Java is apparently inescapable; I have been forced
to use it once more in my introductory data structures course. It has not grown
on me at all in these past three years, and I am not exaggerating when I say
that the language has literally given me nightmares.

Refusing to use Java outside of my coursework would seemingly prevent me from
programming for any platform which makes use of Java, such as Android, but it
turns out that there's an alternative: using a language that compiles down to
the same bytecode as Java does. For those not in the know, Java works by
compiling source code ahead of time into a Java class file, which is a container
format for JVM bytecode. Bytecode is comparable to assembly, but it doesn't run
on actual hardware.[fn:2] Instead, it runs on an abstract machine implemented in
software. JVM bytecode as a compiler target is not a new concept; there are
plenty of languages targeting the JVM specifically, such as Groovy, Scala, and
Clojure (the latter of which I hope to cover in a future post), but Kotlin was
the first I heard of.

While targeting Android is what I ended up using Kotlin for, my initial reason
for learning it was not nearly as practical. For context, I go between phases of
absolutely hating everything related to Java and phases of almost being able to
tolerate it, but that tolerance is mostly out of curiosity about the
implementation -- the most recent case of piqued interest coming about through
obtaining the J2ME JAR for [[https://en.wikipedia.org/wiki/Doom_RPG][Doom RPG]] and having an inclination to indulge in some
retro Java (circa JRE 1.3) reverse engineering. Really, I wanted to learn Kotlin
so that I could compare the bytecode generated by the Kotlin compiler to the
bytecode generated by past and present Java compilers,[fn:3] but I won't be
talking about that much in this post.

Throughout this post, I'll be treating Kotlin as a language that only runs on
the JVM, but it's worth noting that the compiler has recently gained support for
targeting Javascript and LLVM as well, so this perspective doesn't fully
represent the language.

As my first foray into writing nontrivial[fn:4] Kotlin, I decided to write a
version of Minesweeper, my favorite logic puzzle. The code is available on
[[https://git.sr.ht/~jakob/mines][sr.ht]]. Implementing Minesweeper might be a bit redundant as [[https://f-droid.org/en][F-Droid]] already has
a [[https://f-droid.org/en/packages/csd.qtproject.minesweeper][GPLv3'd implementation of Minesweeper]] in its repositories, but that one's
implemented in C++, QML, and Javascript.

For the purposes of illustration, here's a simplified implementation of the
Minesweeper logic sans Android API:

#+BEGIN_SRC kotlin :hl_lines 0
data class Tile(val adjacentMines: Int = 0, val mine: Boolean = false, val masked: Boolean = true)

/**
 * The Minesweeper "grid", containing instances of [Tile].
 */
class Grid(val width: Int, val height: Int, val tiles: Array<Tile>) {
    constructor(width: Int = 8, height: Int = 8, mines: Int = 10)
    : this(width, height, Array<Tile>(width * height) { Tile() }) {
        for (i in 0 until mines) {
            val x = (0 until width).random()
            val y = (0 until height).random()
            placeMine(x, y)
        }
    }

    private fun index(x: Int, y: Int) = y * width + x
    private fun valid(x: Int, y: Int) = y in 0 until height && x in 0 until width
    private fun place(x: Int, y: Int, tile: Tile) {
        tiles[index(x, y)] = tile
    }

    /**
     * Returns the tile at the given coordinates.
     *
     * @throws IllegalArgumentException if the X coordinate is outside
     * the range of [0, width), or if the Y coordinate is outside the
     *  range of [0, height).
     */
    operator fun get(x: Int, y: Int) = if (valid(x, y)) {
        tiles[index(x, y)]
    } else {
        throw IllegalArgumentException("Invalid coordinates (${x}, ${y})")
    }

    /**
     * Places a mine at the given coordinates.
     *
     * @throws IllegalArgumentException if the X coordinate is outside
     * the range of [0, width), or if the Y coordinate is outside the
     *  range of [0, height).
     */
    fun placeMine(x: Int, y: Int) {
        if (!valid(x, y)) {
            throw IllegalArgumentException("Invalid coordinates (${x}, ${y})")
        }

        if (this[x, y].mine) {
            return;
        }

        place(x, y, Tile(mine = true))

        val xMin = (x - 1).coerceAtLeast(0)
        val xMax = (x + 1).coerceAtMost(width - 1)
        val yMin = (y - 1).coerceAtLeast(0)
        val yMax = (y + 1).coerceAtMost(height - 1)

        for (y in yMin..yMax) {
            for (x in xMin..xMax) {
                with (tiles[index(x, y)]) {
                    if (!mine) {
                        place(x, y, copy(adjacentMines + 1))
                    }
                }
            }
        }
    }

    /**
     * Reveals a tile at the given coordinates according to the game rules.
     *
     * @throws IllegalArgumentException if the X coordinate is outside
     * the range of [0, width), or if the Y coordinate is outside the
     *  range of [0, height).
     */
    fun reveal(x: Int, y: Int) {
        if (!valid(x, y) || !this[x, y].masked) {
            return;
        }

        with (this[x, y]) {
            place(x, y, copy(masked = false))

            if (!mine && adjacentMines == 0) {
                val xMin = (x - 1).coerceAtLeast(0)
                val xMax = (x + 1).coerceAtMost(width - 1)
                val yMin = (y - 1).coerceAtLeast(0)
                val yMax = (y + 1).coerceAtMost(height - 1)

                for (y in yMin..yMax) {
                    for (x in xMin..xMax) {
                        reveal(x, y)
                    }
                }
            }
        }
    }

    override fun toString() = buildString {
        for (y in 0 until height) {
            for (x in 0 until width) {
                val tile = this@Grid[x, y]
                append(when {
                    tile.masked -> "."
                    tile.mine -> "M"
                    tile.adjacentMines == 0 -> " "
                    else -> tile.adjacentMines.toString()
                } + " ")
            }
            append("\n")
        }
    }
}

fun main(args: Array<String>) {
    val grid = Grid()

    for (i in 0 until 9) {
        val x = (0 until 8).random()
        val y = (0 until 8).random()
        grid.reveal(x, y)
        println(grid)
    }
}
#+END_SRC

And just within the first line, we're introduced to a feature that provides
massive gains in readability over Java. Data classes.

#+BEGIN_SRC kotlin :hl_lines 0
data class Tile(val adjacentMines: Int = 0, val mine: Boolean = false, val masked: Boolean = true)
#+END_SRC

In Java, if you want to group a few related values together, you'd probably
write a full implementation of a class with "getter" and "setter" methods, and
potentially other methods for things like testing for equality. That's a whole
new file (assuming that you want to use this structure between classes) and
several lines of code for something that should really be expressed in one line.
Kotlin allows for structures to be declared this way. Here, we define a =Tile=
structure, which acts as an immutable container for the information we want to
associate with a tile in the Minesweeper grid. The compiler automatically
derives methods to check for equality (=equals=), to create a unique hash code
(=hashCode=), to provide a string representation (=toString=), and to make a
copy of the structure. That last point on making copies brings me to another
feature of Kotlin that makes programming with immutable data structures a
breeze: named and optional parameters. Notice that there are default values in
the above declaration -- =adjacentMines= is 0, =mine= is false, and =masked= is
true. If I wanted to create a tile that was a mine, but was still masked, I
could simply call =Tile(mine = true)=. Java only supports positional
overloading, so in Java, it would be =new Tile(0, true)=, assuming that I had an
overloaded constructor with a default =masked= value. Returning to the point on
the automatically-generated =copy= method, the named and optional parameters
really shine here. Take a look at how it's used in the =reveal= method:

#+BEGIN_SRC kotlin :hl_lines 0
with (this[x, y]) {
    place(x, y, copy(masked = false))
#+END_SRC

I should probably explain the =with= statement to fully unpack what's happening
here. =with= essentially allows us to run a block of code in the scope of an
object. =copy= is a method of =this[x, y]=, but we don't need to write =this[x,
y].copy= since we are in the class scope of =Tile= (the type of =this[x, y]=).

The code for initializing a new =Grid= object does a nice job of illustrating
Kotlin's ranges.

#+BEGIN_SRC kotlin :hl_lines 0
for (i in 0 until mines) {
    val x = (0 until width).random()
    val y = (0 until height).random()
    placeMine(x, y)
}
#+END_SRC

Kotlin is similar to Python in that there are no longer C-styled for loops
(initialization + condition + afterthought). Instead, there are iterator-based
for loops, and enumeration is done with ranges. =a..b= represents the range of
integers from [a, b], and =a until b= represents the range from [a, b). Ranges
are also objects, which is why we can write something like =(0 until
width).random()=, which picks a random integer in the range [0, width).

Another big feature is type inference, which eliminates another pain in reading
and writing Java. Take this facetious example:

#+BEGIN_SRC java :hl_lines 0
InternalFrameInternalFrameTitlePaneInternalFrameTitlePaneMaximizeButtonWindowNotFocusedState myState = new InternalFrameInternalFrameTitlePaneInternalFrameTitlePaneMaximizeButtonWindowNotFocusedState();
#+END_SRC

The class name shows up twice on the same line, which I personally think is
absurd. Is the type of =myState= really not obvious from the rvalue?

#+BEGIN_SRC kotlin :hl_lines 0
val myState = InternalFrameInternalFrameTitlePaneInternalFrameTitlePaneMaximizeButtonWindowNotFocusedState()
#+END_SRC

Ah, much better. Of course, sometimes variables /need/ type information, such as
in the case of function parameters and return values. Actually, the type of a
return value can be inferred, too. This can be seen in the definitions of
=index= and =valid=.

#+BEGIN_SRC kotlin :hl_lines 0
private fun index(x: Int, y: Int) = y * width + x
private fun valid(x: Int, y: Int) = y in 0 until height && x in 0 until width
#+END_SRC

Functions can be written this way if their body is a single expression, and the
compiler can infer the type of the return value from that expression.
Expressions are a big thing in Kotlin. =if= is an expression (which you can see in
the implementation of =get=), much like it is in Rust, as is =when= -- Kotlin's
replacement to Java's =switch= (which is sadly lacking in the way of
pattern-matching). Assignment, fortunately, is not an expression like it is in C
and Java.[fn:5] Here's an example of =when= used as an expression:

#+BEGIN_SRC kotlin :hl_lines 0
override fun toString() = buildString {
    for (y in 0 until height) {
        for (x in 0 until width) {
            val tile = this@Grid[x, y]
            append(when {
                tile.masked -> "."
                tile.mine -> "M"
                tile.adjacentMines == 0 -> " "
                else -> tile.adjacentMines.toString()
            } + " ")
        }
        append("\n")
    }
}
#+END_SRC

This is yet another example of a function body being written as a single
expression. =buildString= is a function in the Kotlin standard library that takes
a lambda as a parameter,[fn:6] executes it in the context of a Java StringBuilder,
and returns the result of building that string. Kotlin provides a number of
facilities to make working with strings more pleasant, including string
interpolation:

#+BEGIN_SRC kotlin :hl_lines 0
val myNum = 7
return "myNum is ${myNum}" // --> "myNum is 7"
#+END_SRC

One last feature from the example above -- Kotlin supports operator overloading.
I'm sure that there was some rationale behind omitting operator overloading
from Java, but I'm a proponent of languages that offer support for it. Where the
operator overloading occurs in the example might not have been obvious, though.

#+BEGIN_SRC kotlin :hl_lines 0
operator fun get(x: Int, y: Int) = if (valid(x, y)) {
    tiles[index(x, y)]
} else {
    throw IllegalArgumentException("Invalid coordinates (${x}, ${y})")
}
#+END_SRC

=get= corresponds to the indexing notation, which is why =this[x, y]= has shown
up a few times in the code. Yes, the indexing notation can take multiple
parameters. The names that Kotlin associates with different operators tends to
draw parallels with the conventions of the Java standard library. In the case of
=get=, this means that you can use indexing notation on a =Map=. Pretty neat.

There are a few other features that I think are worth mentioning, but don't
appear in the Minesweeper example.

* Explicit type conversion

Kotlin lacks implicit type coercion, which I think is a huge benefit in terms of
readability. While it isn't as much of an issue in Java, having to explicity
mark type conversion is an excellent way of avoiding issues with [[https://www.owasp.org/images/6/6b/PHPMagicTricks-TypeJuggling.pdf][type juggling]].
Again, my opinions here have largely been shaped by my experience as a teaching
assistant.

* Explicit nullability

This is probably the crowning feature of Kotlin: a solution to "The Billion
Dollar Mistake" that is =null=. I'm actually not a fan of explicit nullability as
a solution, since I prefer the use of an =Option= type like in Rust.[fn:7] That
said, it does put the type system to work enforcing null safety at compile time,
and it is a lot more pleasant than dealing with =null= Java, so I'd call it a win.

Basically, a variable can be =null= if its type is suffixed with a =?=. For
example, =Int?= can be =null=, but =Int= can't. An expression of type =Int?=
must be checked for =null= before it can be used, which can be done in a number
of ways. The most simple being to make use of another Kotlin feature: "smart
casts".

#+BEGIN_SRC kotlin :hl_lines 0
val myNum: Int? = null

if (myNum != null) {
    println("${myNum + 4}") // myNum has been casted from Int? to Int at this point.
}
#+END_SRC

There are other ways, too. Kotlin has a null-coalescing operator, a "not null"
assertion, and so on. Explicit nullability is definitely a pain when starting
out, though. In Jouri Mamaev's [[https://kukuruku.co/post/why-kotlin-sucks/]["Why Kotlin Sucks"]], the following issue is
described as a "histerically-useless war with nullable."

#+BEGIN_SRC kotlin :hl_lines 0
var value : Int? = null

fun F() : Int {
  if (value != null) return 0
  return value // Compiler error: "Smart cast to 'Int' is impossible, because 'value' is a mutable property that could have been changed by this time"
}
#+END_SRC

Mamaev goes on to show an example using the aforementioned "not null" assertion,
but the way I prefer to deal with this issue (other than not having variables
that are both nullable and mutable) is:

#+BEGIN_SRC kotlin :hl_lines 0
var value : Int? = null

value?.let {
    it.something()
}
#+END_SRC

=let= will capture =value= and pass it to a lambda, allowing you to run some
code without having to worry about the value changing from under you. I have no
idea if this is idiomatic or not, but it works for me.

Of course, Kotlin was meant to interoperate with existing Java code, which has
little notion of explicit nullability, so the benefits of explicit nullability
go out the window more often than not.

* No more checked exceptions

I'm sorry, but I really don't want to write about how much I hate checked
exceptions in Java. If you aren't familiar with them, but still want to know
what they are, I'd suggest looking for an article elsewhere. Otherwise, all you
need to know is that they aren't an issue in Kotlin.

* == for value equality as opposed to reference equality

This comes back to the operator overloading feature, ==== calls out to =equals=.
I do have a slight problem with this, though. ===== is used for reference
equality. I'm thankful that the operator exists, but this is completely
orthogonal to what Javascript does and I think that this might be a barrier for
anyone coming from there. Again, this is a slight problem; I think the gains in
clarity from using ==== for value equality outweigh the awkward ===== operator.

---

Whew, listing all of the features that make Kotlin a better choice than Java is
exhausting. This is one of the complaints that I have about the language: it's
massive in scope. Of course, this is a consequence of Java having an absurd
number of warts that Kotlin tries to mend, but learning the ins and outs of
Kotlin is a significant undertaking. This is a _shortened_ list of the features
I enjoyed but didn't mention in this post:

- Lambdas having access to variables that are not final.
- Classes and methods being final by default.
- Inner classes being static by default.
- Properties (declaring 'get' and 'set').
- Unpacking.
- Spreading.
- Collection literals.
- Common I/O functions such as =println= being included in the prelude.
- Top-level visibility.
- Module visibility.
- Named imports.
- Infix functions.
- Nested functions.
- Decorator classes with =by=.
- Anonymous objects being able to implement multiple interfaces.
- Immutable collections.
- Lazy evaluation.
- =with= and =apply= expressions.
- Safe type casting.
- Lazy member initialization.
- Function inlining.
- Support for DSL creation.
- ...

I could seriously just go on for days. There's /a lot/ to keep in your head all
at once.

Other than the huge scope, there are few things I'd say I dislike about Kotlin.
Sure, there are things I wish it had, like Rust-styled variable shadowing, but
nothing drives me up the wall or anything. =kotlin-mode= feels like it was put
together in a few hours and compilation times are miserable, but other than
that, Kotlin is a solid language.

Also, I'll forgo talking about the community and the ecosystem. It's just
completely transparent to me right now. From what I /can/ gather, it seems to be
pretty corporate ([[https://kotlin.link/][kotlin.link]] links to a Linkedin group, Google+, Slack...),
which might be because of its use case in Android development, but whatever.

To conclude, Kotlin's alright. It isn't a miracle of language design, but it's
designed in a way that makes it easy to map onto the JVM. In that sense, it's
pragmatic. I'm not /excited/ about it, but it's levels beyond Java in terms of
how tolerable it is, so I'll take it. Well, for Android development, at least.
For projects where I'm not wrestling with a Java-based platform, I'll use
something more fun. To me, Rust is like Marshmallow Froot Loops, and Kotlin is
like Cheerios.[fn:8]

I'd also like to take a minute to thank everyone who's given feedback on the
previous post. I really appreciate all the suggestions! I've put Ada and Pony on
my list of languages to cover in the future, and hopefully I'll be able to make
some progress on shrinking that list now that I'm done with the semester and
finally have some free time.

[fn:1] Source: [[https://cve.mitre.org/cgi-bin/cvekey.cgi?keyword=Java+Runtime+Environment][Common Vulnerabilities and Exposures, MITRE]]

[fn:2] Well, that statement is only partially true. [[https://en.wikipedia.org/wiki/Jazelle][ARM processors can execute JVM bytecode in hardware]], and I would not be surprised if there are other chips out there with similar capabilities.

[fn:3] Not that it would help me at all in reversing Doom RPG, I was just curious about bytecode.

[fn:4] I would typically consider Minesweeper to be somewhat trivial, but implementing it for Android was not an easy task.

[fn:5] I do think that assignment as an expression can occasionally afford some clarity, especially in the way of C, but in my time as a teaching assistant, I can say that I have seen its usage be erroneous more often than clever.

[fn:6] In case it is unclear from the code, lambdas can be passed to functions sans parentheses. This is just syntactic sugar.

[fn:7] Java 8 actually has an =Optional= type, but people seem to dislike it. Wonder why...

[fn:8] This [[https://en.wikipedia.org/wiki/Metacity#Aim][analogy]] is painfully overused, but it just can't be beat.