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.
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:
-
You put the body of the function within
{ .. }
, no extra keywords or symbols required. -
If it has one argument (which is very common), it has an implicit short name,
it
. -
This one is really cool: if the lambda is the last argument of the accepting function, you can take it outside the parentheses, and if there are no other arguments, you can omit the parentheses altogether.
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 what it returns, the object itself or the result of the lambda.
Here they are:
.apply
takes the object asthis
, returns the object.run
takes the object asthis
, returns the result of the block.also
takes the object asit
, returns the object.let
takes the object asit
, returns the result of the block
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:
-
Define comparability for your custom class. Which you do at the class declaration, way too far away from the place where you're sorting them. Or it may not work for you at all if you need to sort these same elements in more than one way.
-
Define a comparator function in place. Kotlin lambdas help here, but since it needs to return a -1/0/1, it's going to be sprawling and repetitive: for all elements, subtract one from another, check for zero, return if not, move to the next element otherwise. Bleh…
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
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.
... 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
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!
:-))
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.