I've been writing code in Kotlin on and off over a few months, and I think I'm now at this unique stage of learning something new when I already have a sense of what's what, but not yet so far advanced so I don't remember beginner's pain points.

Here's a dump of some of my impressions, good and bad.

Functional

We were not out to win over the Lisp programmers; we were after the C++ programmers. We managed to drag a lot of them about halfway to Lisp.

Guy Steele

Kotlin drags Java programmers another half of the rest of the way.

— me

That is to say, Kotlin doesn't feel like a real functional-first language. It's still mostly Java with all its imperativism, mutability and OO, but layered with some (quite welcome) syntactic sugar that makes it less verbose and actually encourages functional style. Where it still feels mostly Java-ish is when you need to work with Java libraries. Which is most of the time, since the absolutely transparent Java interop doesn't make writing Kotlin-flavored libraries a necessity.

Classes are not required

For starters, you don't have to put everything in classes with methods any more. Plain top-level functions are perfectly okay.

You also don't need to write/generate a full-blown class if what you really need is a struct/record. Instead you just do:

data class Person(val name: String, val age: Int)

These have some handy features (like comparability) implemented out of the box, which is nice. And then you can pass them to functions as plain arguments, without necessarily having to make them methods on those argument's classes.

Extension functions

Like other newer languages (Swift, Rust) Kotlin allows you to add your own methods to existing classes, even to built-in types. They are neatly scoped to whatever package they're defined in, and don't hijack the type for the entirety of the code in your program. The latter is what happens when you add a new method to a built-in class dynamically in Ruby, and as far as I know, it's a constant source of bad surprises.

It doesn't require any special magic. Just keep in mind that T.func() is not really different from func(T), only the name of the first parameter is going to be this, and it's going to be available implicitly.

This, I think, is actually a big deal, becasue looser coupling between types and functions operating on them pushes you away from building rigid heirarchies. And by now I believe most people have realized that inheritance doesn't scale. So these days the only real value in having T.func() over func(T) is the ability to compose functions in the natural direction:

data.prepare().process().finalize()

… as opposed to

finalize(process(prepare(data)))

Yes, I know your Haskell/OCaml/Clojure have their own way of doing it. Good. Kotlin has chaining.

Immutable declarations

Kotlin uses val and var for declaring local data as immutable and mutable, respectively. val is encouraged to be used by default, and the compiler will yell at you if you use var without actually needing to mutate the variable.

This is very similar to Rust's let and let mut. Unfortunately however, Kotlin doesn't enforce immutability of a class instance inside its methods, so it's still totally possible to do:

val obj = SomeObject( ... )
obj.someMethod()

… and have internal state changed unpredictably.

Expressions

Kotlin is another new language adopting "everyhing is an expression" paradigm. You can assign the result of, say, an if statement to a variable or return it. This plays well with a shortened syntax for functions consisting of a single expression, which doesn't involve curly braces and the return keyword:

fun recencyScore(item: Item): Int =
    if (item.crated < LocalDateTime.now().minusDays(RECENT_DAYS)) 1 else 0

You still need return in imperative functions and for early bail-outs.

This is all good, I don't know of any downsides.

Lambda syntax

I think Kotlin has easily the best syntax for nameless in-place functions out of all languages with curly braces:

So filtering, mapping and reducing a collection looks like:

entries.filter { it < 5 }
       .map { it * 2 }
       .fold(10) { acc, value -> acc + value }

Note the absence of () after the first two functions. The line with .fold is more complicated because it does have an extra argument, an initial value, which has to go into parentheses, and it also has a two-argument lambda, so it needs to name them.

.let

Many times you can get away with not inventing a name for another temporary variable:

val reader = File(directory, name).let {
    if (!it.exists()) {
        it.createNewFile()
    }
    it.bufferedReader()  // last expression returned from `let`
}

.let takes the object on which it was called (File() in this case), passes it as a single argument to its lambda, where you can use it as, well, it, and then returns whatever was returned from the lambda. This makes for succinct, closed pieces of code which otherwise would either bleed their local variables outside the scope, or require a named function.

This reminds me of Clojure's let, and Kotlin also has its own idiom similar to when-let which is a variant that only works when the value is not null:

val something = nullableValue()?.let { ... }

If the result of nullableValue is null the operator ?. would safely short-cirquit the whole thing and not call the .let block.

Friends of .let

Speaking of .let, it's actually one of no fewer than five slight variations of the same idea. They vary by which name the object is passed inside the lambda block, and by waht it returns, the object itself or the result of the lambda.

Here they are:

Technically, you can get by with only ever using .let, because you can always return it explicitly, and the difference between it and this is mostly cosmetic: sometimes you can save more characters by omitting typing this., sometimes you still need it to avoid things like name = name, so you switch to using it.

The real reason for all these variations is they're supposed to convey different semantics. In practice I would say it creates more fuss than it helps, but it may be just my lack of habit.

And no, I didn't forget about the fifth one, with, which is just a variant of run, but you pass the object in parentheses instead of putting it in front of a dot:

// Totally equivalent

obj.run {
    someMethod()
}

with(obj) {
    someMethod()
}

I can only probably justify its existence by a (misplaced) nostalgia for a similar with from Pascal and early JavaScript. And there's a reason nobody uses it anymore: the implicit this was a reliable source of hard to spot bugs.

By the way, this sudden language complexity is something that Lisps manage to avoid by simply not having the distinction between "functions" and "methods", and always returning the last expression from a form. "An elegant weapon for a more civilized age", and all that :-)

Collection transformations and laziness

That one caught me off guard. Turns out there's a difference on what kind of value you call .map, .filter and such. Calling them on a List<T> does not produce a lazy sequence, it actually produce a concrete list. If you want a lazy result you should cast a concrete collection to Sequence<T> first:

val names = listOf("john", "mary")
val upperNames = names.map { it.toUpperCase() }  // List<String>, eagerly built
val lazyUpperNames = names.asSequence().map { ... }  // lazy Sequence<String>

// Or:

fun upper(items: Sequence<String>): Sequence<String> =
    items.map { it.toUpperCase() }

val lazyUpperNames = upper(names)  // implicit cast to Sequence<String>

That's one more gotcha to be aware of if you want to avoid allocating memory for temporary results at every step of your data transformations.

No tuples

In Python, tuples are a workhorse as much as dicts and lists. One of their underappreciated properties is their natural orderability: as long as corresponding elements of two tuples are comparable with each other, tuples are also comparable, with leftmost elements being the most significant, so you have:

(1, "A", 20) < (1, "B", 10)

This is tremendously convenient when sorting collections of custom elements, because you only need to provide a function mapping your custom value to a tuple:

sorted(autocomplete, key=lambda a: (a.frequency, a.recency, a.title))

Kotlin doesn't have tuples. It has pairs, but they aren't orderable and, well, sometimes you need three elements. Or four! So when you want to compare custom elements you have two options:

Type inference

It's probably to widespread type inference that we owe the resurgence in popularity of typed languages. It's what makes them palatable. But implementations are not equally capable across the board. I can't claim a lot of cross-language experience here, but one thing I noticed about Kotlin is that it often doesn't go as far as, say, Rust in figuring out what is it that you meant.

For example, Kotlin can't figure out the type of an item of an initially empty list based on what data you're adding to it:

val items = mutableListOf()
items.add("One")
println(items)

// Type inference failed: Not enough information to infer parameter T in inline fun <T> mutableListOf(): MutableList<T>
// Please specify it explicitly.

Rust does this just fine:

let mut items = vec![];
items.push("One");
println!("{:?}", items);

It's a contrived example, but in paractice I also had stumbled against Kotlin's inability to look into how the type is being used later. This is not a huge problem of course…

Messy parts

I'm going to bury the lead here and first give you two examples that look messy (to me) before uncovering the True Source of Evil.

The first thing are in and out modifiers for type parameters. There is a long detailed article about them in the docs about generics which I could only sort of understand after the third time I read it. It all has to do with trying to explain to the compiler the IS-A relationship between containers of sub- and supertypes. Like List<String> could be treated as List<Object> if you only read items from it, but you obviously can't write a random Object into it. Or something…

The second example is about extension methods (those that you define on some third-party class in your namespace) that can't be virtual. It may not be immediately apparent why until you realize that slapping a method on a class is not the same as overriding it in a descendant, but is simply a syntactic sugar for f(this: T, ... ). So when you call T.f() it doesn't actually look into the VMT of T, it looks for a free-standing function in a local namespace.

Now, things like these made me acutely realize how much I appreciate Rust, a language that simply doesn't have inheritance! It still has all the power to express any polymorphic behavior you care about, yet doesn't have any complexity coming with hierarchies of objects! Kick me if you want me to elaborate on that…

Comments: 4

  1. Vadim Ridosh

    Well, I'm a Kotlin newcomer - started ~1 month ago. I'm more or less agree with everything here, but I don't see a word about coroutines - which, I think, is a killer feature of Kotlin and is the reason to use it even if coroutines would be its only difference from Java.

  2. Vadim Ridosh

    ... and yet another thing - the image I liked a lot, everyone who tried Kotlin should understand it: https://miro.medium.com/max/1400/1*jM7Tnm5l8SIJZIPlgRSRMg.png

  3. Ivan Sagalaev

    but I don't see a word about coroutines

    I'm sure I missed a lot of things, it wasn't meant to be exhaustive :-) I haven't gotten to asynchronous stuff in Kotlin, but I'll have a look, thanks!

    https://miro.medium.com/max/1400/1*jM7Tnm5l8SIJZIPlgRSRMg.png

    :-))

  4. Denis

    Didn't an idea of structured concurrency come to Kotlin from Python world and now every Python developer dreams on bringing its implementation from Kotlin to Python?

    And I don't think dynamic dispatch is easily understandable in Rust. So I'm looking forward to hear your experience on it.

Add comment