Musings on Kotlin Ranges
Here are a few interesting aspects of Kotlin ranges, some of which I've found to be less-than-intuitive.
Ranging on Empty
Pop quiz: What does the following code output?
(1..3).forEach(System.out::print)
(3..1).forEach(System.out::print)
If you think the answer is 123321
, guess again. It only prints out 123
.
That's because IntRange
is defined by its minimum start
and its maximum endInclusive
values. In other words, when endInclusive < start
, the range is empty.
The solution is to use a progression instead. A progression can be any arithmetic sequence, so it can go up or down. Here are two ways to make a decrementing progression:
(3 downTo 1).forEach(System.out::print)
(1..3).reversed().forEach(System.out::print)
Home on the Range
You can use ranges to easily determine inclusion:
fun inRange(target: Int) = target in 0..100
What's really neat is how the Kotlin compiler optimizes the above code. Instead of allocating an IntRange
then using IntRange.contains()
, it actually compiles to a simple if-statement. Here's how it looks in Java:
boolean inRange(int target) {
return 0 <= target && target <= 100;
}
Pretty awesome!
This optimization extends to any conditional statement. Check out this awesome code a coworker wrote recently:
fun bestSizeMatch(target: Int) = when (target) {
in 0..256 -> urlThumb
in 256..800 -> urlSmall
in 800..2048 -> urlRegular
else -> urlRaw
}
Again, the above compiles to a series of simple conditionals. No allocations necessary!
Range Against The Machine
Given that the compiler can optimize ranges, which of the following range-based for-loops do you think is the most efficient?
// Option 1
for (i in 1..3) {
System.out.print(i)
}
// Option 2
for (i in 1..3 step 1) {
System.out.print(i)
}
// Option 3
(1..3).forEach(System.out::print)
Here's what happens for each:
Option 1 compiles down to a normal loop. It allocates a couple primitives and has a couple if-checks, but is otherwise fairly simple. The same compilation behavior is seen for simple decrementing progressions as well (e.g. 3 downTo 1
).
Option 2 would seem to be just as good as option 1, but adding the step
parameter makes Kotlin use a more complex set of logic that can handle any step
value you might throw at it. It requires one extra object allocation and additional if-checks.
Option 3 has to create an IntRange
, then get its Iterator
and use that to while-loop over the numbers. That's two object allocations and a lot of function calls. It's the least efficient by far.
So: Option 1 is the most efficient.
It's important to realize that the Kotlin compiler can take advantage of certain situations and make optimizations, but you won't really know about it unless you use the bytecode viewer and/or decompile to Java to see what's really going on underneath the hood.
In case you're curious, here's what those three loops look like in Java. I've cleaned it up from what you would view in the decompiled bytecode, but they're essentially the same:
// Option 1
int i = 1;
byte end = 3;
if (i <= end) {
while (true) {
System.out.print(i);
if (i == end) {
break;
}
++i;
}
}
// Option 2
IntProgression progression = RangesKt.step((new IntRange(1, 3)), 1);
int i = progression.getFirst();
int last = progression.getLast();
int step = progression.getStep();
if (step > 0) {
if (i > last) {
return;
}
} else if (i < last) {
return;
}
while (true) {
System.out.print(i);
if (i == last) {
return;
}
i += step;
}
// Option 3
Iterable range = new IntRange(1, 3);
IntIterator iterator = (IntIterator) range.iterator();
while (iterator.hasNext()) {
int element = iterator.nextInt();
System.out.print(element);
}