Hello World & the Toolchain
Hello, World
No includes, no return, and no semicolons — but a
main function, unlike Swift or Python.#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}fun main() {
println("Hello, World!")
}println appends the newline and renders any value through toString, so it behaves like std::cout << … << std::endl without the operator chain. Semicolons are optional and idiomatic Kotlin omits them; the parser ends a statement at the newline unless the line is obviously incomplete. main returns Unit — Kotlin's void, except that it is a real type with one value — so there is no return 0, and a non-zero exit comes from kotlin.system.exitProcess.Where a C++ programmer actually meets Kotlin
Worth stating plainly, because it decides how to read the rest of the page: almost nobody moves from C++ to Kotlin. They end up on both sides of a boundary.
// On Android, the C++ half is the part you already own:
//
// - the game engine, the physics, the renderer
// - codecs, DSP, computer vision, ML inference
// - an existing cross-platform core shared with iOS and desktop
//
// It is built with the NDK, ships as a .so, and talks to the rest of
// the app across JNI — which is the boundary the last section covers.
#include <iostream>
int main() {
std::cout << "the engine, the codec, the shared core" << std::endl;
return 0;
}// The Kotlin half is everything above that line:
//
// - the UI, the lifecycle, permissions, storage, networking
// - anything touching an Android framework API
// - the glue that loads your .so and calls into it
//
// Kotlin replaced Java as Android's default language in 2019, so this
// is not optional if you ship on Android — Java is the alternative,
// and it is the worse one.
fun main() {
println("the UI, the lifecycle, the glue")
}That framing changes what matters. You are unlikely to rewrite a renderer in Kotlin, so the sections on the object model are there to stop your C++ intuitions misleading you when you read or write the layer above — and the JNI section at the end is the part you will actually use. If you are not shipping on Android or the JVM, this is a good language with little reason to reach for it, and Rust or Go answers more of a C++ programmer's questions. If you are, it is not really a choice.
Everything Is a Reference
There are no value types for your own classes
This is the deepest break with C++ on the page, and everything about performance and API design on the JVM follows from it.
#include <iostream>
struct Point { int x; int y; };
int main() {
Point first{1, 2};
Point second = first; // a COPY — this is the default
second.x = 99;
// And it lives on the stack unless you say otherwise.
std::cout << first.x << " " << second.x << std::endl;
return 0;
}class Point(var x: Int, var y: Int)
fun main() {
val first = Point(1, 2)
val second = first // a REFERENCE — two names, one object
second.x = 99
// And it is on the heap. There is no stack allocation to choose.
println("${first.x} ${second.x}")
}Every class instance is heap-allocated and every variable holding one is a reference, so
second = first aliases rather than copies and the C++ column prints 1 99 while the Kotlin column prints 99 99. There is no stack allocation you can request, no &, no pointer, and no way to embed one object inside another by value — a field holding a Point stores a reference to a separate heap object. The partial escape hatches are data class with copy() (explicit duplication, next section) and value class, which is limited to wrapping exactly one field.Primitives, and where they get boxed
Kotlin writes
Int everywhere and compiles it to two different things, which is the performance trap that catches C++ programmers on the JVM.#include <iostream>
#include <vector>
int main() {
// int is int everywhere: in a local, in a struct, in a vector.
// std::vector<int> stores 4-byte integers contiguously.
std::vector<int> readings{1, 2, 3};
std::cout << readings.size() << " " << sizeof(readings[0]) << " bytes each"
<< std::endl;
return 0;
}fun main() {
// Int is a machine int as a local and inside IntArray — but a
// List<Int> stores BOXED java.lang.Integer objects, so a million
// ints becomes a million heap objects plus a pointer array.
val boxed: List<Int> = listOf(1, 2, 3)
val unboxed: IntArray = intArrayOf(1, 2, 3)
println("${boxed.size} ${unboxed.size}")
println(boxed[0] == 1)
}A local
Int and an IntArray element are machine integers. An Int in a generic container is a boxed java.lang.Integer — a heap object with a header — because JVM generics cannot hold primitives (the erasure section explains why). So List<Int> costs perhaps twenty times the memory of a std::vector<int> and loses all cache locality. The primitive-array types (IntArray, DoubleArray, …) exist exactly for this, and they are what you reach for in a hot path — which is also why they appear all over Android graphics APIs.Null Safety
Nullability is in the type, and enforced
This is the row where Kotlin is unambiguously ahead of C++, and unlike C#'s retrofit it is enforcement rather than a warning.
#include <iostream>
#include <string>
// A pointer may be null and the type does not say so. Every caller
// must remember, and nothing checks.
void report(const std::string* text) {
if (text == nullptr) { std::cout << "(none)" << std::endl; return; }
std::cout << *text << std::endl;
}
int main() {
std::string value = "hello";
report(&value);
report(nullptr);
return 0;
}// String — cannot be null; the compiler REFUSES null here
// String? — may be null, and cannot be used without handling it
fun report(text: String?) {
if (text == null) { println("(none)"); return }
println(text) // smart-cast to String here
}
fun main() {
report("hello")
report(null)
val maybe: String? = null
println(maybe?.length ?: -1) // ?. short-circuits, ?: defaults
}A
String cannot hold null and a String? cannot be dereferenced without handling the null case — both are compile errors, not warnings, so the null-dereference bug is gone from Kotlin-only code. After if (text == null) return the compiler smart-casts the variable to the non-null type, so no cast is written. The operators are worth knowing: ?. short-circuits to null, ?: supplies a default (the "Elvis" operator), and !! asserts non-null and throws. The gap is the boundary: values arriving from Java or JNI are "platform types" the compiler cannot check, which is exactly where your C++ code hands things over.Memory & Cleanup
No destructors — use blocks
The JVM has finalizers and they are deprecated, unreliable, and never the answer.
use is the mechanism.#include <iostream>
#include <string>
class Transaction {
public:
explicit Transaction(std::string name) : name_(std::move(name)) {
std::cout << "begin " << name_ << std::endl;
}
~Transaction() { std::cout << "end " << name_ << std::endl; }
private:
std::string name_;
};
void work() {
Transaction transaction("payment"); // guaranteed by the TYPE
std::cout << "working" << std::endl;
}
int main() {
work();
return 0;
}class Transaction(private val name: String) : AutoCloseable {
init { println("begin $name") }
override fun close() { println("end $name") }
}
fun work() {
// use() calls close() at the end of the block, including on an
// exception. The CALLER must remember to write it.
Transaction("payment").use {
println("working")
}
}
fun main() {
work()
}use is an extension function on AutoCloseable that runs the block and calls close() in a finally — so the guarantee holds, and the obligation sits with every caller instead of with the type. That is the same trade Go, Zig and C# make. Note the two places C++ puts constructor work: init { } runs at construction, and the primary constructor parameters double as property declarations when marked val/var. Nothing in the language stops you forgetting use; static analysis and code review are what catch it.Classes, Data Classes & Properties
data class vs the rule of five
A
data class is the JVM's answer to the boilerplate every value-like type needs, and its copy() is where the missing value semantics come back.#include <iostream>
struct Point {
int x;
int y;
bool operator==(const Point& other) const = default;
};
std::ostream& operator<<(std::ostream& stream, const Point& point) {
return stream << "Point(x=" << point.x << ", y=" << point.y << ")";
}
int main() {
Point origin{0, 0};
Point same{0, 0};
std::cout << std::boolalpha << origin << " " << (origin == same) << std::endl;
return 0;
}// One keyword generates equals, hashCode, toString, copy and the
// component functions used for destructuring.
data class Point(val x: Int, val y: Int)
fun main() {
val origin = Point(0, 0)
val same = Point(0, 0)
val moved = origin.copy(x = 5) // explicit, named duplication
println("$origin ${origin == same} $moved")
val (x, y) = moved // destructuring
println("$x $y")
}The generated members are the ones you would otherwise write by hand:
equals comparing fields, hashCode, a readable toString, and copy() with named parameters for changing one field. Because assignment still aliases, copy() is how you get a duplicate — explicit where C++'s is implicit, which is arguably clearer and certainly easier to forget. The component1/component2 functions are what make destructuring work, and they are positional, so reordering the fields of a data class silently changes what every destructuring site binds.Properties, and no getter boilerplate
The primary constructor declares the properties, so the three-line C++ ceremony of parameter, member, and accessor collapses to one.
#include <iostream>
class Rectangle {
public:
Rectangle(double width, double height) : width_(width), height_(height) {}
double width() const { return width_; }
void setWidth(double value) { width_ = value; }
double area() const { return width_ * height_; }
private:
double width_;
double height_;
};
int main() {
Rectangle card(3.0, 4.0);
card.setWidth(6.0);
std::cout << card.width() << " " << card.area() << std::endl;
return 0;
}class Rectangle(var width: Double, val height: Double) {
// A computed property: reads as a field, runs on each access.
val area: Double
get() = width * height
}
fun main() {
val card = Rectangle(3.0, 4.0)
card.width = 6.0 // looks like a field, may run code
println("${card.width} ${card.area}")
}Marking a constructor parameter
val or var makes it a property, with the backing field, getter and (for var) setter generated — so the class body is often empty. Turning a stored property into a computed one later changes no caller, which is the same benefit C# properties give and the reason the C++ habit of writing accessors defensively is unnecessary. Custom accessors go under the declaration as get()/set(value), and by lazy { … } gives a thread-safe compute-once property in one line.Inheritance, Interfaces & Delegation
Classes are final by default
Kotlin inverted C++'s default: a class is closed to inheritance unless its author opens it.
#include <iostream>
#include <memory>
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
};
// Every class is open to inheritance, and every method is
// non-virtual unless marked. "final" is the opt-out.
class Square : public Shape {
public:
explicit Square(double side) : side_(side) {}
double area() const override { return side_ * side_; }
private:
double side_;
};
int main() {
std::unique_ptr<Shape> shape = std::make_unique<Square>(3.0);
std::cout << shape->area() << std::endl;
return 0;
}abstract class Shape {
abstract val area: Double
}
// A class cannot be subclassed unless marked `open`, and a method
// cannot be overridden unless marked `open`. `override` is required.
class Square(private val side: Double) : Shape() {
override val area: Double
get() = side * side
}
fun main() {
val shape: Shape = Square(3.0)
println(shape.area)
}The reasoning is that inheriting from a class not designed for it is how fragile base classes happen — so
open is a deliberate act, and override is mandatory rather than the optional courtesy it is in C++. Combined with single inheritance (one superclass, any number of interfaces), this removes the diamond problem, virtual bases and slicing in one go. Note that abstract members are implicitly open, and that this default is genuinely annoying when you want to mock a class in a test, which is why Android projects commonly add the all-open compiler plugin.Delegation is a keyword
Composition over inheritance is standard C++ advice, and the reason people ignore it is the forwarding boilerplate. Kotlin deleted the boilerplate.
#include <iostream>
#include <memory>
#include <string>
class Greeter {
public:
virtual ~Greeter() = default;
virtual std::string greet() const = 0;
virtual std::string farewell() const = 0;
};
class English : public Greeter {
public:
std::string greet() const override { return "hello"; }
std::string farewell() const override { return "goodbye"; }
};
// Forwarding by hand: one method per interface method, forever.
class Loud : public Greeter {
public:
explicit Loud(std::unique_ptr<Greeter> inner) : inner_(std::move(inner)) {}
std::string greet() const override { return inner_->greet() + "!"; }
std::string farewell() const override { return inner_->farewell(); }
private:
std::unique_ptr<Greeter> inner_;
};
int main() {
Loud loud(std::make_unique<English>());
std::cout << loud.greet() << " " << loud.farewell() << std::endl;
return 0;
}interface Greeter {
fun greet(): String
fun farewell(): String
}
class English : Greeter {
override fun greet() = "hello"
override fun farewell() = "goodbye"
}
// `by inner` generates the forwarding for EVERY interface method.
// Override only the one you want to change.
class Loud(private val inner: Greeter) : Greeter by inner {
override fun greet() = inner.greet() + "!"
}
fun main() {
val loud = Loud(English())
println("${loud.greet()} ${loud.farewell()}")
}: Greeter by inner generates a forwarding implementation of every interface method, and any method you write yourself wins. So the decorator pattern costs one clause rather than one method per interface member — and adding a method to the interface later does not break the decorator. There is no C++ equivalent short of a macro or a CRTP template. The same by keyword also does property delegation (by lazy, by observable, and the by viewModels() you will see throughout Android code).Templates vs Erased Generics
Generics are erased
This is the one that genuinely surprises C++ programmers: the type argument does not survive compilation.
#include <iostream>
#include <string>
#include <typeinfo>
#include <vector>
int main() {
std::vector<int> numbers{1, 2};
std::vector<std::string> words{"a"};
// Two distinct types, each with its own generated code, each
// knowing its element type at runtime and at compile time.
std::cout << std::boolalpha
<< (typeid(numbers) == typeid(words)) << std::endl;
std::cout << sizeof(numbers[0]) << std::endl;
return 0;
}fun main() {
val numbers: List<Int> = listOf(1, 2)
val words: List<String> = listOf("a")
// ONE type at runtime. The element type is ERASED: both are
// just java.util.List, and there is no generated code per T.
println(numbers.javaClass == words.javaClass)
// Which is why you cannot ask, and cannot write `is List<Int>`.
println(numbers is List<*>)
}A C++ template generates separate code per instantiation, so
std::vector<int> stores real integers and knows its type at runtime. JVM generics are erased: List<Int> and List<String> are the same class, the type argument exists only for compile-time checking, and elements must be reference types — which is why List<Int> boxes. The practical consequences are that you cannot write is List<Int>, cannot create an array of T, and cannot overload on List<Int> versus List<String>. There is no runtime dispatch cost, but there is no specialization either.reified brings the type back
Kotlin has a targeted escape hatch from erasure, and it is essentially a small, opt-in version of what C++ templates do by default.
#include <iostream>
#include <string>
#include <vector>
// A template knows its type parameter, so this is trivial.
template <typename Element>
void describe() {
std::cout << "size of element: " << sizeof(Element) << std::endl;
}
int main() {
describe<int>();
describe<double>();
return 0;
}// `inline` + `reified` makes the compiler substitute the real type at
// each CALL SITE — which is monomorphization, done by hand.
inline fun <reified Element> describe() {
println("type of element: ${Element::class.simpleName}")
}
fun main() {
describe<Int>()
describe<Double>()
}Marking a function
inline means the compiler pastes its body at each call site; reified then lets the type parameter be used as a real type inside — so Element::class, is Element and Element() all work. This is monomorphization, applied per function rather than per type. The limits: it only works on inline functions, so not on classes and not on virtual methods, and inlining a large function at many call sites grows the bytecode. It is what makes idioms like gson.fromJson<MyType>(text) possible on a platform that otherwise cannot see MyType.Collections
Read-only interfaces, not immutability
Kotlin splits every collection into a read-only interface and a mutable one, which looks like
const-correctness and is weaker in a specific way.#include <iostream>
#include <vector>
// const propagates through the type system and is enforced.
void observe(const std::vector<int>& readings) {
// readings.push_back(4); // error: read-only
std::cout << readings.size() << std::endl;
}
int main() {
std::vector<int> readings{1, 2, 3};
observe(readings);
return 0;
}// List has no add(). But it is a read-only VIEW, not a guarantee:
// whoever holds the underlying MutableList can still change it.
fun observe(readings: List<Int>) {
// readings.add(4) // error: no such method
println(readings.size)
}
fun main() {
val backing = mutableListOf(1, 2, 3)
observe(backing)
backing.add(4) // and the "read-only" view sees it
println(backing.size)
}List declares no mutating methods and MutableList extends it with them, so a function taking a List cannot modify it. What it does not get is a promise that nobody else will: the same object is reachable through a MutableList reference, and changes show through the read-only view. So it is a capability restriction on this reference rather than an immutability guarantee — closer to const than to Rust's borrow rules, and weaker than both because there is no const-ness to propagate into elements. For a real guarantee, copy with toList() or use the immutable collections library.Sequences are the lazy ones
The chained operators look exactly like a ranges pipeline and behave differently by default, which is the performance trap in this section.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> readings{1, 2, 3, 4, 5, 6};
// Views are LAZY: nothing runs until the pipeline is consumed,
// and no intermediate container is built.
auto pipeline = readings
| std::views::filter([](int value) { return value % 2 == 0; })
| std::views::transform([](int value) { return value * value; });
int total = 0;
for (int value : pipeline) { total += value; }
std::cout << total << std::endl;
return 0;
}fun main() {
val readings = listOf(1, 2, 3, 4, 5, 6)
// Collection operators are EAGER: each step builds a new list.
val eager = readings.filter { it % 2 == 0 }.map { it * it }.sum()
// asSequence() makes the same chain lazy, like a ranges view.
val lazy = readings.asSequence()
.filter { it % 2 == 0 }
.map { it * it }
.sum()
println("$eager $lazy")
}Kotlin's collection operators are eager:
filter allocates a new list, then map allocates another, so a four-stage chain over a large list allocates four intermediate lists. A C++ ranges view allocates none. asSequence() switches to lazy evaluation and matches the ranges behavior. The rule of thumb is that eager is fine and often faster for small collections (no wrapper objects, better inlining) and sequences win once the collection is large or the chain is long — and for a C++ programmer used to views being free, the default is worth remembering. it is the implicit name of a single lambda parameter.Expressions & Smart Casts
when is an expression, and so is if
Almost everything in Kotlin produces a value, which removes the mutable-variable-then-fill-it-in pattern C++ needs for anything more than a ternary.
#include <iostream>
int main() {
int code = 2;
// switch is a statement, so producing a value needs a variable
// and every case needs a break.
const char* described;
switch (code) {
case 1: described = "one"; break;
case 2:
case 3: described = "two or three"; break;
default: described = "other"; break;
}
std::cout << described << std::endl;
return 0;
}fun main() {
val code = 2
// when is an EXPRESSION: it produces a value, has no fallthrough,
// and takes comma-separated cases and ranges.
val described = when (code) {
1 -> "one"
2, 3 -> "two or three"
in 4..10 -> "several"
else -> "other"
}
println(described)
}when has no fallthrough, so no break to forget, and cases can be values, comma-separated lists, ranges (in 4..10), types (is String), or arbitrary boolean conditions when the subject is omitted. As an expression it must be exhaustive, so else is required unless the compiler can prove coverage — which it can for enums and sealed classes, the subject of the next section. if is an expression too, so Kotlin has no ternary operator and does not need one.Smart casts
Checking a type and then using it as that type is two steps in C++ and one in Kotlin.
#include <iostream>
#include <string>
#include <variant>
int main() {
std::variant<int, std::string> value = std::string("hello");
// The check and the extraction are separate steps.
if (std::holds_alternative<std::string>(value)) {
const std::string& text = std::get<std::string>(value);
std::cout << "text of length " << text.size() << std::endl;
}
return 0;
}fun main() {
val value: Any = "hello"
// After `is`, the compiler NARROWS the type — no cast is written.
if (value is String) {
println("text of length ${value.length}")
}
// The same narrowing works in when, and after a null check.
val described = when (value) {
is String -> "string of ${value.length}"
is Int -> "int ${value + 1}"
else -> "something else"
}
println(described)
}After
if (value is String) the compiler treats value as a String inside the branch, so no static_cast, no std::get, and no second chance to name the wrong type. The same narrowing applies after a null check (which is why the null-safety row needed no cast) and inside a when branch. The limitation worth knowing: smart casting requires the compiler to prove the value cannot change between the check and the use, so it does not apply to a mutable property that another thread could write — which is a reasonable restriction that occasionally forces a local copy.Sealed Classes & when
Sealed classes vs std::variant
A sealed hierarchy is a closed set of types, which is what lets
when be checked for exhaustiveness — the thing switch gives up the moment a default: appears.#include <iostream>
#include <string>
#include <variant>
using Message = std::variant<int, std::string>;
int main() {
Message message = std::string("hello");
std::visit([](const auto& value) {
using Held = std::decay_t<decltype(value)>;
if constexpr (std::is_same_v<Held, int>) {
std::cout << "number " << value << std::endl;
} else {
std::cout << "text " << value << std::endl;
}
}, message);
return 0;
}// sealed means the compiler knows every subclass, because they must
// be declared in the same module.
sealed interface Message {
data class Number(val value: Int) : Message
data class Text(val value: String) : Message
}
fun main() {
val message: Message = Message.Text("hello")
// No else needed — the compiler checks EXHAUSTIVENESS, so adding
// a subclass breaks every incomplete when in the codebase.
val described = when (message) {
is Message.Number -> "number ${message.value}"
is Message.Text -> "text ${message.value}"
}
println(described)
}Because every subclass must be declared in the same module, the compiler can enumerate them and reject a
when that misses one — so adding a case is a compile error at every site rather than a silent fallthrough. That is the same guarantee Rust enums and Swift enums give, and it is the strongest argument for this shape over an open class hierarchy. Compared with std::variant: the cases are named and can carry different structured payloads, no std::visit is needed, and each case is a real type with its own methods — at the cost of a heap allocation per value, since these are still classes.Functions, Lambdas & inline
Default and named arguments
Kotlin has both halves — defaults and named arguments — and the combination is what makes them useful.
#include <iostream>
#include <string>
// Defaults exist but are positional-only: to set the third, you must
// name the first two, and the default is baked into every CALLER.
void connect(const std::string& host, int port = 80, bool secure = false) {
std::cout << host << ":" << port << " secure=" << std::boolalpha
<< secure << std::endl;
}
int main() {
connect("example.com");
connect("example.com", 80, true); // must repeat the default
return 0;
}fun connect(host: String, port: Int = 80, secure: Boolean = false) {
println("$host:$port secure=$secure")
}
fun main() {
connect("example.com")
connect("example.com", secure = true) // skip the middle one
connect(port = 8080, host = "localhost")
}Because any parameter can be passed by name, a default in the middle can be skipped without repeating it, which is precisely what C++ defaults cannot do. Named arguments also make a call site self-documenting, so the boolean-trap call
connect(host, 80, true) becomes connect(host, secure = true). One JVM detail worth knowing: default arguments compile to a synthetic method with a bitmask rather than to overloads, so the default lives in one place at runtime instead of being baked into every caller's object file — which means changing it does not require recompiling callers, unlike C++.Extension functions
The same idea Swift and Rust have, and the mechanism underneath is different in a way worth knowing on the JVM.
#include <iostream>
#include <string>
// You cannot add a member function to std::string, so a free function
// it is — and it does not participate in method-call syntax.
bool isShouting(const std::string& text) {
if (text.empty()) { return false; }
for (char character : text) {
if (std::islower(static_cast<unsigned char>(character))) { return false; }
}
return true;
}
int main() {
std::string message = "HELLO";
std::cout << std::boolalpha << isShouting(message) << std::endl;
return 0;
}// An extension function reads as a method on a type you do not own.
fun String.isShouting(): Boolean =
isNotEmpty() && none { it.isLowerCase() }
fun main() {
println("HELLO".isShouting())
// They can be generic and constrained, too.
println(listOf(1, 2, 3).sum())
}An extension function is not added to the class: it compiles to a static function taking the receiver as its first parameter, and the call syntax is sugar. That means it is resolved statically — an extension cannot be overridden, and if a real member with the same signature exists, the member always wins. So it is a syntactic convenience rather than the true retroactive conformance Swift and Rust offer. Most of Kotlin's standard library is extension functions on Java types, which is how Kotlin makes the JVM feel like a different language without changing it.
Coroutines
Coroutines with the library included
Kotlin's coroutines are structured, like Swift's — a child cannot outlive its scope — and they are a library rather than a language feature.
#include <iostream>
#include <thread>
#include <vector>
// C++20 gives you co_await and the machinery to build on; there is no
// std::task, no scheduler and no async standard library, so real use
// means adopting asio or cppcoro.
int main() {
// So the ordinary answer stays a thread, at ~8MB of stack each.
std::vector<std::thread> workers;
std::vector<int> squares(3);
for (int number = 0; number < 3; number += 1) {
workers.emplace_back([number, &squares]() { squares[number] = number * number; });
}
for (std::thread& worker : workers) { worker.join(); }
for (int square : squares) { std::cout << square << " "; }
std::cout << std::endl;
return 0;
}import kotlinx.coroutines.*
// A coroutine costs a few hundred bytes, not megabytes, and suspends
// without blocking its thread.
fun main() = runBlocking {
val results = (0..2).map { number ->
async { // launches concurrently
delay(1) // SUSPENDS; does not block a thread
number * number
}
}.awaitAll() // structured: none can outlive this
println(results.joinToString(" "))
}The language provides only
suspend and the compiler transformation; everything else — async, launch, dispatchers, channels, flows — is kotlinx.coroutines. That is closer to C++'s position than to Go's, with the difference that Kotlin has one such library and everyone uses it. Structured concurrency means a scope waits for its children and cancellation propagates down, so leaking a coroutine takes effort. The cost is around a few hundred bytes per coroutine against ~8MB of stack per OS thread, which is why Android does all its I/O this way. Note this row needs the coroutines dependency, unlike everything else on the page.JNI, the NDK & Kotlin/Native
JNI is the boundary you will actually work on
This is the section the reader came for, and the honest headline is that JNI is the least pleasant part of both languages.
// The C++ side of a JNI function. The name encodes the package,
// class and method, which is why it is so long and so easy to get
// wrong — a mismatch is an UnsatisfiedLinkError at runtime, never a
// compile error.
//
// #include <jni.h>
//
// extern "C" JNIEXPORT jint JNICALL
// Java_com_example_Engine_addNative(JNIEnv* env, jobject self,
// jint first, jint second) {
// return first + second;
// }
//
// Built with the NDK, shipped as libengine.so inside the APK.
#include <iostream>
int main() {
std::cout << "an unmangled symbol, named by convention" << std::endl;
return 0;
}// The Kotlin side declares the method as external and loads the
// library. This is illustrative — there is no .so to load here.
//
// class Engine {
// external fun addNative(first: Int, second: Int): Int
//
// companion object {
// init { System.loadLibrary("engine") }
// }
// }
//
// println(Engine().addNative(2, 3))
//
// Nothing checks that the declaration matches the C++ signature. The
// error arrives at the first call, in production, as
// UnsatisfiedLinkError or a JVM crash.
fun main() {
println("external fun, resolved by name at runtime")
}The rules that cost people the most time: the symbol name encodes the full package path and a typo is a runtime failure, not a compile error;
jstring is not a char* and needs GetStringUTFChars with a matching Release; local references have a limited table and leak if you create them in a loop without DeleteLocalRef; and the boundary crossing is expensive enough that the design rule is the usual one — cross rarely with a lot of work. Direct ByteBuffer is the standard way to share a buffer without copying. Modern alternatives worth knowing are the JNI generator in AndroidX and, on the JVM proper, Project Panama's FFM API.Kotlin/Native compiles without a JVM
Worth knowing before you file Kotlin under "JVM language and therefore irrelevant to me": it is not only a JVM language.
// C++ compiles to a native binary. That is the baseline every
// comparison on this page has assumed.
#include <iostream>
int main() {
std::cout << "machine code, no runtime to install" << std::endl;
return 0;
}// Kotlin has three backends, and only one of them is the JVM:
//
// Kotlin/JVM — bytecode; Android and server
// Kotlin/Native — LLVM; native binaries for iOS, macOS, Linux,
// Windows and embedded, with direct C and
// Objective-C interop and NO JVM
// Kotlin/JS — JavaScript
//
// Kotlin/Native uses tracing garbage collection too, so the object
// model on this page still applies — but there is no VM to ship, and
// C interop is a cinterop def file rather than JNI.
//
// This is what Compose Multiplatform and Kotlin Multiplatform are
// built on, and it is why a shared core can target both Android and
// iOS from one Kotlin codebase.
fun main() {
println("bytecode, or LLVM, or JavaScript")
}Kotlin/Native compiles through LLVM to a real binary with no VM, and its C interop is a
.def file describing headers rather than JNI's hand-written glue — considerably more pleasant than the previous row. What does not change is the object model: it still has tracing garbage collection, still has reference semantics, and still boxes generics, so everything earlier on this page applies. What you get is the multiplatform story — one Kotlin core targeting Android through the JVM and iOS through Native — which is the actual reason a C++ team with a shared cross-platform core might look at it.What You Give Up
What you keep and what you trade
Every other target on this anchor is a language a C++ programmer might pick. This one is a language they end up next to, and the summary should say so.
// What C++ keeps that Kotlin does not offer:
//
// Value semantics for your own types → references, and copy()
// Deterministic destruction → use blocks, by convention
// const-correctness → read-only interfaces only
// Templates and specialization → erased generics, plus reified
// Contiguous storage of user types → pointer arrays and headers
// Stack allocation you control → the heap, and escape analysis
// Predictable latency → GC pauses
// Compiling to a binary by default → the JVM (or Kotlin/Native)
#include <iostream>
int main() {
std::cout << "the machine is yours" << std::endl;
return 0;
}// What you get for it:
//
// Null safety ENFORCED by the compiler, not warned about
// Sealed classes with exhaustive when
// Data classes, properties, delegation, extension functions
// Structured coroutines at a few hundred bytes each
// No undefined behavior as a category
// The whole JVM ecosystem, and Android's own APIs
//
// The honest summary: this is the only target on this anchor you are
// unlikely to CHOOSE. You will end up here because your C++ ships
// inside an Android app, and then the question is not whether Kotlin
// is better than C++ — it is that the layer above your .so has to be
// written in something, and Kotlin is the best answer available.
fun main() {
println("the platform is theirs")
}Judged purely as a language Kotlin is a good one — null safety that is actually enforced, sealed classes with exhaustive matching, and less ceremony than almost anything else on the JVM. Judged as a destination for a C++ programmer it is the narrowest on the list, because its reach is the JVM and Android rather than systems work, and Rust or Go answers more of the questions that send a C++ developer looking. The case for reading this page is the boundary: if your engine ships inside an Android app, you will be reading and writing the layer above it, and knowing where your C++ intuitions stop applying is worth an afternoon.