PONYλM2Modula-2

C.CodeCompared.To/Kotlin

An interactive executable cheatsheet comparing C and Kotlin

C17 (GCC) Kotlin 2.4
Program Structure & Output
Hello, World
The entry point is still called main and still takes an optional argument array. What is gone is the include, the return type, the semicolons, and the class wrapper that a Java programmer would expect here.
#include <stdio.h> int main(void) { printf("Hello, World!\n"); return 0; }
fun main() { println("Hello, World!") }
println appends the newline and converts whatever it is given, so it is closer to puts than to printf; print is the no-newline form. Semicolons are optional and idiomatic Kotlin omits them — the parser ends a statement at the newline unless the line is obviously incomplete. There is no return 0: main returns Unit, and a non-zero status comes from kotlin.system.exitProcess.
String Templates Instead of a Format String
A Kotlin string literal can carry expressions inline. The compiler reads the type out of each expression, so there is no conversion specifier to get wrong and no way to pass the wrong number of arguments.
#include <stdio.h> int main(void) { const char *name = "Ada"; int count = 3; double average = 2.5; printf("%s has %d items averaging %.2f\n", name, count, average); printf("%-10s|%10s|\n", "left", "right"); return 0; }
fun main() { val name = "Ada" val count = 3 val average = 2.5 println("$name has $count items averaging ${"%.2f".format(average)}") // The C conversions are still available when the widths matter: println("%-10s|%10s|".format("left", "right")) }
A bare $name substitutes a variable and ${…} takes any expression, so ${list.size} works without a temporary. "…".format(…) is sprintf with the format string on the left — it accepts C’s conversion specifiers including the flags and widths, and it throws on a mismatch rather than reading whatever was next on the stack.
Arguments Without argc and argv
The argument array is a real array of strings that knows its own size, so there is no count parameter — and, unlike argv, it does not include the program name.
#include <stdio.h> int main(int argc, char *argv[]) { printf("argument count: %d\n", argc - 1); for (int index = 1; index < argc; index++) { printf(" %d: %s\n", index, argv[index]); } return 0; }
fun main(args: Array<String>) { println("argument count: ${args.size}") args.forEachIndexed { index, value -> println(" ${index + 1}: $value") } // System.err.println("a diagnostic") writes to the other stream. // It is left commented out here because the runner behind this page // treats anything on stderr as a failed run. }
The counts already agree without the C column subtracting one, because args holds only the arguments — the program name is not there at all, and on the JVM there is no way to ask for it. The two output streams are System.out and System.err, and both are PrintStream objects that anything accepting one can be pointed at, which is the piece C makes you build out of FILE * plumbing. The runner behind this page reports any output on System.err as a failed run, which is why the diagnostic above is commented out rather than shown running.
Types That Never Convert Themselves
val and var, With the Type Inferred
Kotlin is statically typed and rarely makes you say so. val is a binding that cannot be reassigned and var one that can — and unlike C’s const, the immutable one is what idiomatic code reaches for first.
#include <stdio.h> int main(void) { const int count = 42; /* const is the exception, not the habit */ double ratio = 2.5; const char *label = "answer"; printf("%d %.1f %s\n", count, ratio, label); /* count = 1; does not compile. */ return 0; }
fun main() { val count = 42 // inferred Int var ratio = 2.5 // inferred Double val label = "answer" // inferred String println("$count $ratio $label") ratio = 3.5 // var, so this is fine // count = 1 // error: val cannot be reassigned val explicit: Long = 42 // say the type where inference is not enough println(explicit) }
Inference runs from the initializer only, so this is nothing like a dynamic language: the type is fixed at compile time and count = "text" is an error. Note that val constrains the name, not the object — a val holding a mutable list can still have things added to it, exactly as a C int *const can still be written through.
No Implicit Numeric Conversion At All
C converts between numeric types silently, following rules most people cannot recite, which is where the sign-comparison and narrowing bugs come from. Kotlin converts nothing implicitly — every mixed-type expression needs a written conversion.
#include <stdio.h> int main(void) { int whole = 7; double fraction = 2.5; /* Silently promotes whole to double. */ printf("%.1f\n", whole + fraction); /* And silently narrows, losing the fraction: */ int narrowed = fraction; printf("%d\n", narrowed); /* The classic: the signed operand converts, so this is FALSE. */ unsigned int zero = 0u; printf("-1 < 0u ? %d\n", -1 < zero); return 0; }
fun main() { val whole = 7 val fraction = 2.5 // Arithmetic operators ARE defined across numeric types, so this // compiles and produces a Double: println(whole + fraction) val narrowed = fraction.toInt() // must be written; truncates println(narrowed) val zero: UInt = 0u println("-1 < 0 ? ${-1 < zero.toInt()}") // Assignment, though, never widens on its own: val asLong: Long = whole.toLong() // whole alone would be an error println(asLong) }
Kotlin draws the line in a specific place: the arithmetic operators are overloaded for mixed numeric types, so 7 + 2.5 is a Double, but assignment and argument passing never widen — val asLong: Long = whole is an error even though every Int fits in a Long. The toInt/toLong/toByte family is the explicit conversion, and it truncates silently, so it is the one place the old hazard survives.
Fixed Widths, and Overflow That Wraps by Specification
Every Kotlin integer type has one size everywhere: Int is 32 bits, Long is 64, Byte is 8 and signed. Overflow wraps — and, unlike C, that wrapping is specified, so no optimizer may reason from the assumption that it did not happen.
#include <stdio.h> #include <limits.h> #include <stdint.h> int main(void) { printf("int %zu bytes\n", sizeof(int)); printf("long %zu bytes\n", sizeof(long)); printf("int max %d\n", INT_MAX); /* Signed overflow is undefined behavior, so the wrap has to be laundered through unsigned to be legal at all. */ unsigned int wrapped = (unsigned int)INT_MAX + 1u; printf("wrapped %d\n", (int)wrapped); return 0; }
fun main() { println("Int ${Int.SIZE_BYTES} bytes") println("Long ${Long.SIZE_BYTES} bytes") println("Int max ${Int.MAX_VALUE}") println("wrapped ${Int.MAX_VALUE + 1}") // specified to wrap // And the checked version, when you would rather find out: try { println(Math.addExact(Int.MAX_VALUE, 1)) } catch (error: ArithmeticException) { println("addExact: ${error.message}") } }
Both columns print the same wrapped value, and the C one had to go through unsigned to get there legally. There is no platform variation to worry about: Long is 64 bits on a 32-bit machine too, which is what makes a serialization format written in Kotlin portable without a <stdint.h>. Math.addExact and friends come from the JVM and throw rather than wrapping.
Unsigned Types Exist, and Do Not Mix
Kotlin has UByte, UShort, UInt and ULong — but they are separate types rather than a signedness flag, so the mixed comparison that trips up C programs is a compile error rather than a surprise.
#include <stdio.h> int main(void) { unsigned int large = 4000000000u; printf("as unsigned %u\n", large); printf("as signed %d\n", (int)large); /* The signed operand converts, so this is FALSE. */ int negative = -1; unsigned int zero = 0u; printf("-1 < 0u ? %d\n", negative < zero); return 0; }
fun main() { val large: UInt = 4000000000u println("as UInt $large") println("as Int ${large.toInt()}") val negative = -1 val zero: UInt = 0u // println(negative < zero) // error: Int and UInt do not compare println("-1 < 0 ? ${negative < zero.toInt()}") println("UInt max ${UInt.MAX_VALUE}") }
The C column’s last line prints 0, which is correct by the conversion rules and almost never what the author meant. Kotlin refuses to compare the two without a written conversion, so the bug has no spelling. The representation is identical — a UInt is an Int’s bits with unsigned operations — so there is no boxing and no cost; only the type checking differs.
null Is Part of the Type
A Value That May Be Absent Has a Different Type
This is the central idea. In C any pointer may be NULL and nothing in the type says whether this one can be, so the check is a discipline. In Kotlin a String can never be null and a String? might be — and the compiler will not let you use the second as the first.
#include <stdio.h> #include <string.h> /* NULL is allowed by the type, so the check is on you — and forgetting it is undefined behavior rather than an error. */ static size_t length_of(const char *text) { if (text == NULL) { return 0; } return strlen(text); } int main(void) { printf("%zu\n", length_of("hello")); printf("%zu\n", length_of(NULL)); return 0; }
fun lengthOf(text: String?): Int { // text.length does not compile: the value might not be there. if (text == null) return 0 return text.length // smart-cast to String after the check } fun main() { println(lengthOf("hello")) println(lengthOf(null)) val definitely: String = "never null" println(definitely.length) // no check needed, and none allowed // val bad: String = null // error: null cannot be a String }
The smart cast is what makes this pleasant: after if (text == null) return 0, the compiler knows text is a String for the rest of the function and lets you use it directly, with no unwrapping ceremony. On the JVM a nullable type is still just a reference that may be null — there is no wrapper object and no cost — so all of this checking is at compile time.
?. and ?: Instead of a Ladder of NULL Checks
Reaching into a nested structure in C means checking every pointer on the way down. Two operators collapse that: ?. yields null instead of dereferencing, and ?: supplies a fallback when the left side is null.
#include <stdio.h> struct Address { const char *city; }; struct Person { const char *name; struct Address *address; }; int main(void) { struct Person known = { "Ada", NULL }; /* Every level needs its own check. */ const char *city = "(unknown)"; if (known.address != NULL && known.address->city != NULL) { city = known.address->city; } printf("%s lives in %s\n", known.name, city); return 0; }
class Address(val city: String?) class Person(val name: String, val address: Address?) fun main() { val first = Person("Ada", null) println("${first.name} lives in ${first.address?.city ?: "(unknown)"}") val second = Person("Grace", Address("Arlington")) println("${second.name} lives in ${second.address?.city ?: "(unknown)"}") // ?: also works as an early return, which is the guard shape: fun cityOf(person: Person): String { val address = person.address ?: return "(unknown)" return address.city ?: "(unknown)" } println(cityOf(first)) }
The right-hand side of ?: can be a return or a throw, because both have type Nothing and Nothing fits anywhere — which is how the operator doubles as an early-exit guard. ?.let { … } is the third member of the family: it runs a block only when the value is present, and is what you reach for when the non-null case is several lines rather than one expression.
!!, and Why It Is Rare
Sometimes you know a value is present and the compiler cannot. !! is the assertion — and it is deliberately ugly, because every use of it is a promise that the type system is not checking.
#include <stdio.h> #include <stdlib.h> int main(void) { /* The C equivalent is dereferencing without checking, which is undefined behavior rather than a diagnosed failure. */ char *text = malloc(8); if (text == NULL) return 1; text[0] = 'x'; text[1] = '\0'; printf("%s\n", text); free(text); /* Dereferencing NULL here would be a segfault at best. */ printf("checked instead\n"); return 0; }
fun main() { val present: String? = "x" println(present!!) // asserts; throws if it is null val absent: String? = null try { println(absent!!.length) } catch (error: NullPointerException) { println("NullPointerException, not a segfault") } // The alternatives that do not assert: println(absent?.length ?: -1) println(absent.orEmpty().length) }
The failure is a diagnosed NullPointerException with a stack trace naming the line, rather than undefined behavior — so even the unsafe option here is safer than the C column’s. Style guides treat !! as something to justify in a comment; the usual reason it appears is a value that came from Java, where the compiler has no nullability information and has to trust you.
No malloc, No free, and No &
Nothing Is Allocated by Hand, and Nothing Is Freed
There is no malloc to call, no size to compute, and no free to match. An object exists as long as something can reach it, and the collector reclaims it some time after nothing can.
#include <stdio.h> #include <stdlib.h> #include <string.h> struct Person { char *name; int age; }; int main(void) { struct Person *person = malloc(sizeof *person); if (person == NULL) return 1; person->name = malloc(4); if (person->name == NULL) { free(person); return 1; } strcpy(person->name, "Ada"); person->age = 36; printf("%s is %d\n", person->name, person->age); free(person->name); /* the inner one first */ free(person); return 0; }
class Person(val name: String, val age: Int) fun main() { val person = Person("Ada", 36) println("${person.name} is ${person.age}") // Nothing to free. Dropping the last reference is all there is: var temporary: IntArray? = IntArray(1000) temporary = null println("dropped; the collector will take it when it likes") }
The three C failure modes — leak, double free, use-after-free — all disappear, and so does the ownership question that shapes every C interface. What replaces the leak is the retained reference: a value still held by a long-lived list, a closure, or a listener nobody removed is reachable and therefore alive, and that is the leak you will actually hunt on the JVM.
Assigning an Object Copies the Reference
This is the C pointer semantics you already know, with no punctuation to announce it. There is no * and no ->, and no value types either — every non-primitive is a reference.
#include <stdio.h> #include <stdlib.h> struct Counter { int value; }; int main(void) { struct Counter *first = malloc(sizeof *first); if (first == NULL) return 1; first->value = 1; struct Counter *second = first; /* aliases */ second->value = 99; printf("first sees %d\n", first->value); struct Counter byValue = *first; /* a real copy */ byValue.value = 7; printf("first still %d\n", first->value); free(first); return 0; }
class Counter(var value: Int) fun main() { val first = Counter(1) val second = first // aliases — no punctuation says so second.value = 99 println("first sees ${first.value}") // There is no by-value copy of an object. Copying is explicit: val byValue = Counter(first.value) byValue.value = 7 println("first still ${first.value}") }
Kotlin has no value types for your own classes, so the C struct-assignment on the left has no direct counterpart — a copy is a constructor call or, for a data class, the generated copy(). The primitives (Int, Double, Boolean) do copy, because the JVM stores them unboxed wherever it can, so the distinction survives exactly where it costs nothing.
use Instead of goto cleanup
The collector handles memory and nothing else. File handles, sockets and connections still need releasing at a known moment, and use closes one on the way out of a block — including on the way out through an exception.
#include <stdio.h> int main(void) { FILE *handle = fopen("/tmp/c-kotlin-io.txt", "w"); if (handle == NULL) return 1; fprintf(handle, "written\n"); fclose(handle); /* miss this and the handle leaks */ handle = fopen("/tmp/c-kotlin-io.txt", "r"); if (handle == NULL) return 1; char line[32] = {0}; if (fgets(line, sizeof line, handle) != NULL) { printf("read back: %s", line); } fclose(handle); remove("/tmp/c-kotlin-io.txt"); return 0; }
import java.io.File fun main() { val path = File(System.getProperty("java.io.tmpdir"), "c-kotlin-io.txt") path.bufferedWriter().use { writer -> writer.write("written\n") } // close() runs here, even if write threw path.bufferedReader().use { reader -> println("read back: ${reader.readLine()}") } path.delete() }
use is an extension function on Closeable that wraps a try/finally around your block, so it is the goto cleanup ladder made exception-safe and scoped to one resource at a time. The rule that transfers cleanly: memory is automatic, handles are not. Kotlin also offers path.writeText and path.readText for the whole-file case, which is the operation C makes you write four calls for.
Arrays, Lists, and Maps
The Array Knows How Long It Is
The sizeof(array) / sizeof(array[0]) trick works only where the array has not decayed to a pointer, which is why every C function taking one also takes a length. A Kotlin array carries its size, so the parameter list gets shorter and cannot disagree with itself.
#include <stdio.h> static int sum_of(const int *values, size_t count) { int total = 0; for (size_t index = 0; index < count; index++) { total += values[index]; } return total; } int main(void) { int numbers[] = { 3, 1, 4, 1, 5 }; size_t count = sizeof numbers / sizeof numbers[0]; printf("count %zu, sum %d\n", count, sum_of(numbers, count)); /* Nothing stops the caller lying about the length: */ printf("lied %d\n", sum_of(numbers, 3)); return 0; }
fun sumOf(values: IntArray): Int { var total = 0 for (value in values) { total += value } return total } fun main() { val numbers = intArrayOf(3, 1, 4, 1, 5) println("count ${numbers.size}, sum ${sumOf(numbers)}") println("sum ${numbers.sum()}") }
IntArray is the one to reach for over Array<Int>: it compiles to a JVM int[] with no boxing, so it has the same layout and the same cost as the C array — where Array<Int> is an array of pointers to boxed integers. The same distinction exists for DoubleArray, ByteArray and the rest, and it is the one place on this page where the choice affects performance rather than only style.
Every Subscript Is Checked
Indexing past the end of a C array reads whatever is there and is the root of most memory-corruption bugs. Every Kotlin subscript is checked, and a bad one throws with a message naming the index.
#include <stdio.h> int main(void) { int numbers[3] = { 10, 20, 30 }; int wanted = 5; /* numbers[5] is undefined behavior, so the check is yours. */ if (wanted >= 0 && wanted < 3) { printf("%d\n", numbers[wanted]); } else { printf("index %d is out of range\n", wanted); } return 0; }
fun main() { val numbers = intArrayOf(10, 20, 30) try { println(numbers[5]) } catch (error: ArrayIndexOutOfBoundsException) { println("index 5 is out of range") } println("getOrNull: ${numbers.getOrNull(5)}") println("getOrElse: ${numbers.getOrElse(5) { -1 }}") println("last: ${numbers.last()}") }
The check costs a compare and a branch per subscript, and the JVM removes most of them — a for (value in numbers) loop is proved safe and emits no check at all. Note what the standard library offers instead of throwing where absence is legitimate: getOrNull returns a nullable and getOrElse takes a fallback, so a missing element becomes a value rather than a control-flow event.
List Instead of realloc
The capacity-tracking, doubling, realloc-and-check loop that appears in every C program is one call here. What is more interesting is that Kotlin splits the type in two: a read-only view and a mutable one.
#include <stdio.h> #include <stdlib.h> int main(void) { size_t capacity = 2, count = 0; int *numbers = malloc(capacity * sizeof *numbers); if (numbers == NULL) return 1; for (int value = 1; value <= 5; value++) { if (count == capacity) { capacity *= 2; int *grown = realloc(numbers, capacity * sizeof *numbers); if (grown == NULL) { free(numbers); return 1; } numbers = grown; /* the old pointer is now dangling */ } numbers[count++] = value * value; } for (size_t index = 0; index < count; index++) { printf("%d ", numbers[index]); } printf("\n"); free(numbers); return 0; }
fun main() { val numbers = mutableListOf<Int>() for (value in 1..5) { numbers.add(value * value) } println(numbers.joinToString(" ")) println("size ${numbers.size}") // A read-only view of the same data — no copying, no adding: val readOnly: List<Int> = numbers // readOnly.add(36) // error: no add on List println("read-only sees ${readOnly.size}") }
The growth strategy is the same doubling, and ArrayList(1000) pre-sizes it exactly as picking a first malloc size would. What is gone is the realloc hazard, because callers hold a reference to the list object rather than to its backing array. Note that List is read-only rather than immutable: it is a view that offers no mutating methods, and the underlying list can still change through the other reference.
Map Instead of Writing a Hash Table
C has no hash table in its standard library, so every project either writes one or links a third-party one. Map is in the standard library and knows how to hash strings, numbers, and anything with a sensible hashCode.
#include <stdio.h> #include <string.h> #define SLOTS 16 struct Entry { const char *key; int value; int used; }; static unsigned hash_of(const char *key) { unsigned hash = 2166136261u; while (*key) { hash = (hash ^ (unsigned char)*key++) * 16777619u; } return hash; } static void put(struct Entry *table, const char *key, int value) { unsigned slot = hash_of(key) % SLOTS; while (table[slot].used && strcmp(table[slot].key, key) != 0) { slot = (slot + 1) % SLOTS; } table[slot].key = key; table[slot].value = value; table[slot].used = 1; } static int get(const struct Entry *table, const char *key, int fallback) { unsigned slot = hash_of(key) % SLOTS; while (table[slot].used) { if (strcmp(table[slot].key, key) == 0) return table[slot].value; slot = (slot + 1) % SLOTS; } return fallback; } int main(void) { struct Entry table[SLOTS] = {0}; put(table, "apples", 3); put(table, "pears", 7); printf("apples %d\n", get(table, "apples", -1)); printf("plums %d\n", get(table, "plums", -1)); return 0; }
fun main() { val counts = mutableMapOf("apples" to 3, "pears" to 7) println("apples ${counts["apples"]}") println("plums ${counts["plums"] ?: -1}") counts["plums"] = 2 for ((fruit, count) in counts) { println(" $fruit: $count") } println("total ${counts.values.sum()}") }
The C column is fifty lines that still cannot grow, cannot delete, and cannot distinguish "absent" from "stored −1". Subscripting a Kotlin map returns a nullable value, which is why ?: -1 appears — so the absent case is in the type rather than in a sentinel, and getOrDefault or getOrPut handle the two common ways of resolving it.
map, filter, and the Lazy Version
Filtering, transforming and accumulating are three hand-written loops in C. Here they are named operations — and Kotlin gives you both an eager form that builds a list at each step and a lazy one that fuses them into a single pass.
#include <stdio.h> int main(void) { int numbers[8] = { 3, 1, 4, 1, 5, 9, 2, 6 }; size_t count = sizeof numbers / sizeof numbers[0]; int total = 0, matches = 0; for (size_t index = 0; index < count; index++) { if (numbers[index] % 2 == 0) { total += numbers[index] * numbers[index]; matches++; } } printf("%d even values, squares total %d\n", matches, total); return 0; }
fun main() { val numbers = listOf(3, 1, 4, 1, 5, 9, 2, 6) val evenSquares = numbers.filter { it % 2 == 0 }.map { it * it } println("${evenSquares.size} even values, squares total ${evenSquares.sum()}") // The lazy form: one pass, nothing built in between. val firstBig = numbers.asSequence() .map { it * it } .first { it > 20 } println("first square above 20: $firstBig") println("largest three: ${numbers.sortedDescending().take(3).joinToString(" ")}") }
it is the implicit name for a single lambda parameter, which keeps short transformations short. The eager form allocates a list per stage, so a three-stage chain over a million elements does three passes and two allocations; asSequence() switches to one fused pass and stops as soon as the answer is known — which is why the first above never squares the last three elements.
Strings Are Objects With a Length
A String Is an Object With a Stored Length
A C string is a convention: a pointer, and a zero byte somewhere after it. A Kotlin string is an object with a stored length, a known encoding, and methods — and no contents can make it lie about how long it is.
#include <stdio.h> #include <string.h> int main(void) { const char *greeting = "hello"; printf("length %zu\n", strlen(greeting)); /* walks to the NUL */ printf("upper: "); for (size_t index = 0; index < strlen(greeting); index++) { putchar(greeting[index] - 'a' + 'A'); } printf("\n"); printf("contains ell: %d\n", strstr(greeting, "ell") != NULL); return 0; }
fun main() { val greeting = "hello" println("length ${greeting.length}") // stored, not walked println("upper: ${greeting.uppercase()}") println("contains ell: ${greeting.contains("ell")}") println("char 1: ${greeting[1]}") }
strlen is O(n) and the C loop calls it once per iteration, which is the classic accidental O(n²); length is a field read. uppercase() is locale-correct rather than the ASCII arithmetic the C column needs, and a Kotlin string is immutable, so two variables holding the same one can share the object with no aliasing hazard.
UTF-16 Code Units, and No Terminator
A Kotlin Char is a 16-bit UTF-16 code unit, not a byte — so length counts code units and anything outside the Basic Multilingual Plane takes two. There is also no terminator, so a zero character is ordinary data.
#include <stdio.h> #include <string.h> int main(void) { const char *accented = "caf\u00e9"; /* UTF-8: 5 bytes */ const char *emoji = "\U0001F600"; /* UTF-8: 4 bytes */ char embedded[] = "safe\0evil"; printf("accented %zu bytes\n", strlen(accented)); printf("emoji %zu bytes\n", strlen(emoji)); printf("embedded %zu (strlen stops at the NUL)\n", strlen(embedded)); printf("array is %zu bytes\n", sizeof embedded); return 0; }
fun main() { val accented = "caf\u00e9" val emoji = "\uD83D\uDE00" // one character, two code units val embedded = "safe\u0000evil" println("accented ${accented.length} chars, ${accented.toByteArray().size} UTF-8 bytes") println("emoji ${emoji.length} chars, ${emoji.toByteArray().size} UTF-8 bytes") println("embedded ${embedded.length} chars, and the zero is just data") println("emoji is one code point: ${emoji.codePointCount(0, emoji.length) == 1}") }
The mental model that survives the move: Kotlin Char ≈ C char16_t, Kotlin Byte ≈ C signed char (it is signed, −128 to 127), and any time you want real bytes out of text you go through toByteArray(), which is UTF-8 by default. The embedded-zero line is the interop warning: a Kotlin string can hold something a C function will silently truncate.
Immutable Strings, and the Buffer You Still Want
Every operation that appears to change a string returns a new one, so building text in a loop needs a different tool — StringBuilder is the growable character buffer you would have written by hand.
#include <stdio.h> #include <string.h> int main(void) { char buffer[64]; buffer[0] = '\0'; for (int index = 1; index <= 5; index++) { char piece[8]; snprintf(piece, sizeof piece, "%d,", index); strncat(buffer, piece, sizeof buffer - strlen(buffer) - 1); } printf("%s\n", buffer); /* Mutation in place: only possible because we own the array. */ char editable[] = "hello"; editable[0] = 'H'; printf("%s\n", editable); return 0; }
fun main() { val builder = StringBuilder() for (index in 1..5) { builder.append(index).append(',') } println(builder.toString()) val greeting = "hello" // greeting[0] = 'H' // error: no set on a String val capitalized = "H" + greeting.substring(1) println(capitalized) println("original unchanged: $greeting") }
The Kotlin compiler already rewrites a run of + on strings into a StringBuilder, so the concatenation you write inline is not the trap it looks like — but a += inside a loop still allocates per iteration, which is exactly the strcat-in-a-loop problem in a different costume. What is genuinely gone is sizeof buffer - strlen(buffer) - 1, the arithmetic behind a large share of C buffer overflows.
Split, Join, Compare
strtok writes terminators into your buffer, keeps its place in a static variable, and cannot be nested or threaded. split returns a list and touches nothing — and == compares contents rather than addresses.
#include <stdio.h> #include <string.h> int main(void) { char text[] = "red,green,blue"; /* must be writable */ char *piece = strtok(text, ","); while (piece != NULL) { printf("[%s]", piece); piece = strtok(NULL, ","); } printf("\n"); char first[] = "hello"; char second[] = "hello"; printf("pointers equal: %d\n", first == second); printf("contents equal: %d\n", strcmp(first, second) == 0); return 0; }
fun main() { val text = "red,green,blue" println(text.split(",").joinToString("") { "[$it]" }) println("original intact: $text") val first = StringBuilder("hello").toString() val second = "hello" println("same object: ${first === second}") println("contents equal: ${first == second}") println("ordering: ${"apple" < "banana"}") }
This is the one place Kotlin quietly diverges from the JVM underneath it: == compiles to equals, so it compares contents, and === is the reference comparison — which is the opposite of Java, where == on strings is the classic bug. The C column’s first == second prints 0 and is that same bug; here the intuitive spelling is the correct one.
A Struct With Functions Is a Class
The Struct-Plus-Functions Pattern, Built In
The C pattern is a struct and a family of functions taking a pointer to it as their first argument. Kotlin folds that argument into the syntax and calls it this — and the constructor sits in the class header rather than in a separate function.
#include <stdio.h> struct Rectangle { double width; double height; }; static double rectangle_area(const struct Rectangle *self) { return self->width * self->height; } static void rectangle_scale(struct Rectangle *self, double factor) { self->width *= factor; self->height *= factor; } int main(void) { struct Rectangle shape = { 3.0, 4.0 }; printf("area %.1f\n", rectangle_area(&shape)); rectangle_scale(&shape, 2.0); printf("scaled area %.1f\n", rectangle_area(&shape)); return 0; }
class Rectangle(var width: Double, var height: Double) { fun area(): Double = width * height fun scale(factor: Double) { width *= factor height *= factor } } fun main() { val shape = Rectangle(3.0, 4.0) println("area ${"%.1f".format(shape.area())}") shape.scale(2.0) println("scaled area ${"%.1f".format(shape.area())}") }
The parameters in the class header are the properties — var width: Double declares a field, a getter and a setter and assigns it, which is three lines of Java in one. Nothing conceptual changed: shape.area() passes shape as the hidden first argument exactly as rectangle_area(&shape) does. What you gain is that the name lookup is scoped to the type, so there is no rectangle_ prefix to invent.
data class: Equality and Printing for Free
Comparing two C structs means memcmp — wrong the moment there is padding or a pointer that should be followed — or a hand-written field-by-field function. A data class generates the comparison, a hash code that agrees with it, a readable toString, and a copy-and-change.
#include <stdio.h> struct Point { int x; int y; }; static int point_equals(const struct Point *left, const struct Point *right) { return left->x == right->x && left->y == right->y; } int main(void) { struct Point first = { 1, 2 }; struct Point second = { 1, 2 }; /* memcmp would also compare padding bytes, which are indeterminate — so the comparison is written by hand. */ printf("equal: %d\n", point_equals(&first, &second)); printf("first is (%d, %d)\n", first.x, first.y); return 0; }
data class Point(val x: Int, val y: Int) fun main() { val first = Point(1, 2) val second = Point(1, 2) println("equal: ${first == second}") println("first is $first") println("moved: ${first.copy(y = 99)}") val (x, y) = first // destructuring, also generated println("x=$x y=$y") }
One line of declaration produced the constructor, two read-only properties, equals, hashCode, toString, copy, and the componentN functions that make destructuring work. The hash code matters more than it looks: it is what makes the type usable as a map key, and hand-writing one that agrees with equals is a classic source of quiet bugs.
A Sealed Hierarchy Is a Tagged Union
C’s tagged union is a struct holding a tag and a union, with the correspondence between them left entirely to you. A sealed class lists every possible case at the declaration, so the payload lives in the case and a when over it is checked for exhaustiveness.
#include <stdio.h> enum ValueKind { KIND_INTEGER, KIND_TEXT }; struct Value { enum ValueKind kind; /* the tag you must remember to set */ union { int as_integer; const char *as_text; } data; }; static void describe(const struct Value *value) { switch (value->kind) { case KIND_INTEGER: printf("integer %d\n", value->data.as_integer); break; case KIND_TEXT: printf("text %s\n", value->data.as_text); break; } } int main(void) { struct Value first = { KIND_INTEGER, { .as_integer = 42 } }; struct Value second; second.kind = KIND_TEXT; second.data.as_text = "hello"; describe(&first); describe(&second); /* Nothing stops reading the wrong member: */ printf("misread %d\n", second.data.as_integer != 0); return 0; }
sealed interface Value { data class Number(val value: Int) : Value data class Text(val value: String) : Value data class Pair(val left: Int, val right: Int) : Value } fun describe(value: Value): String = when (value) { is Value.Number -> "integer ${value.value}" is Value.Text -> "text ${value.value}" is Value.Pair -> "pair ${value.left},${value.right}" // Omitting a case is a compile error: the when must be exhaustive. } fun main() { println(describe(Value.Number(42))) println(describe(Value.Text("hello"))) println(describe(Value.Pair(1, 2))) }
The tag and the payload cannot get out of step, because they are the same thing — and is Value.Number smart-casts, so value.value inside that branch is an Int with no cast written. Adding a fourth case makes every when over the type stop compiling, listing exactly the places that need updating, which is the check -Wswitch only approximates for C enums.
Interfaces Instead of a Table of Function Pointers
Polymorphism in C is a struct of function pointers each implementation fills in, plus the discipline of pairing the right table with the right data. An interface is that table, built and checked by the compiler.
#include <stdio.h> struct ShapeOperations { double (*area)(const void *self); }; struct Square { double side; }; struct Circle { double radius; }; static double square_area(const void *self) { double side = ((const struct Square *)self)->side; return side * side; } static double circle_area(const void *self) { double radius = ((const struct Circle *)self)->radius; return 3.14159265358979 * radius * radius; } int main(void) { struct ShapeOperations squareOps = { square_area }; struct ShapeOperations circleOps = { circle_area }; struct Square square = { 3.0 }; struct Circle circle = { 1.0 }; printf("%.2f\n", squareOps.area(&square)); printf("%.2f\n", circleOps.area(&circle)); return 0; }
interface Shape { fun area(): Double fun describe(): String = "a shape of area ${"%.2f".format(area())}" } class Square(private val side: Double) : Shape { override fun area(): Double = side * side } class Circle(private val radius: Double) : Shape { override fun area(): Double = Math.PI * radius * radius } fun main() { val shapes: List<Shape> = listOf(Square(3.0), Circle(1.0)) for (shape in shapes) { println("%.2f".format(shape.area())) } println(shapes.first().describe()) }
A class that says : Shape and omits area fails to compile, which is the check the C column cannot make — nothing stops you leaving a function pointer null. An interface may also carry a default implementation, as describe does, which C has no way to express at all. Both shapes fitting in one list is the other half; the C version needs a common header struct or a void * and a tag.
Functions, Lambdas, and Extensions
Defaults and Names Instead of Three Wrappers
C’s answer to an optional parameter is another function, or a flags word, or varargs. Kotlin gives the parameter a default and lets the caller name the ones it sets — which also documents the call site.
#include <stdio.h> static void log_message(const char *text, const char *level, int show_time) { printf("[%s]%s %s\n", level, show_time ? " 12:00" : "", text); } /* The shorter forms are separate functions. */ static void log_simple(const char *text) { log_message(text, "info", 0); } int main(void) { log_simple("started"); log_message("careful", "warn", 0); log_message("detailed", "info", 1); return 0; }
fun logMessage(text: String, level: String = "info", showTime: Boolean = false) { println("[$level]${if (showTime) " 12:00" else ""} $text") } fun main() { logMessage("started") logMessage("careful", "warn") logMessage("detailed", showTime = true) // skip the middle one by name }
A named argument lets you skip past a default without repeating it, and makes a boolean at a call site readable — copy(source, target, overwrite = true) beats copy(source, target, 1). Unlike C#, the default is compiled into the function rather than into each caller, so changing one takes effect for already-compiled callers as soon as the library is replaced.
A Lambda Is a Function Pointer With Its Context
Every C callback interface carries a void * beside the function pointer, because the pointer remembers nothing. A Kotlin lambda captures the variables in scope where it was written, so the extra parameter disappears from every layer.
#include <stdio.h> static void for_each(const int *values, size_t count, void (*action)(int value, void *context), void *context) { for (size_t index = 0; index < count; index++) { action(values[index], context); } } static void add_to_total(int value, void *context) { *(int *)context += value; } int main(void) { int numbers[] = { 1, 2, 3, 4 }; int total = 0; for_each(numbers, 4, add_to_total, &total); printf("total %d\n", total); return 0; }
fun forEach(values: IntArray, action: (Int) -> Unit) { for (value in values) { action(value) } } fun multiplier(factor: Int): (Int) -> Int = { value -> value * factor } fun main() { val numbers = intArrayOf(1, 2, 3, 4) var total = 0 forEach(numbers) { total += it } // total is captured println("total $total") val triple = multiplier(3) // built at run time println("triple(7) = ${triple(7)}") }
The captured variable is not copied — the lambda holds the same storage, which is why total ends at 10 and why factor outlives the call to multiplier. That last part is the piece C cannot do. Note the call syntax: a lambda that is the last argument goes outside the parentheses, which is why so much Kotlin reads like it has custom control structures.
Adding a Method to a Type You Did Not Write
In C, a helper for a type someone else declared is a free function with a prefix on its name. An extension function attaches it to the type — including to Int, String and the standard collections — with no subclassing and no wrapper.
#include <stdio.h> #include <stdbool.h> /* A helper for int lives at file scope with a name that says what it is for, because there is nowhere else to put it. */ static bool int_is_even(int value) { return value % 2 == 0; } static int int_clamped(int value, int lowest, int highest) { if (value < lowest) return lowest; if (value > highest) return highest; return value; } int main(void) { printf("%d %d\n", int_is_even(4), int_is_even(5)); printf("%d\n", int_clamped(99, 0, 10)); return 0; }
val Int.isEven: Boolean get() = this % 2 == 0 fun Int.clamped(range: IntRange): Int = minOf(maxOf(this, range.first), range.last) fun String.reversedWords(): String = split(" ").reversed().joinToString(" ") fun main() { println("${4.isEven} ${5.isEven}") println(99.clamped(0..10)) println("the quick brown fox".reversedWords()) }
An extension is not really added to the type: it compiles to a static function taking the receiver as its first parameter — which is exactly what the C column already does, with the call site rearranged. That also means an extension cannot access private members and is resolved on the static type rather than dynamically, so it is dispatch-free and costs nothing at run time.
vararg Instead of stdarg.h
A C variadic function cannot know how many arguments it got or what types they were — printf only works because you tell it, and lying about it is undefined behavior. A vararg parameter is an ordinary typed array the compiler fills in.
#include <stdio.h> #include <stdarg.h> /* The count must be passed explicitly; va_arg checks nothing. */ static int sum_of(int count, ...) { va_list arguments; va_start(arguments, count); int total = 0; for (int index = 0; index < count; index++) { total += va_arg(arguments, int); } va_end(arguments); return total; } int main(void) { printf("%d\n", sum_of(3, 1, 2, 3)); printf("%d\n", sum_of(0)); return 0; }
fun sumOf(vararg values: Int): Int = values.sum() fun main() { println(sumOf(1, 2, 3)) println(sumOf()) val pieces = intArrayOf(4, 5, 6) println(sumOf(*pieces)) // * spreads an existing array }
The leading count is gone because the array knows its size, and every element is type-checked at the call site — sumOf(1, "two") does not compile, where sum_of(2, 1, "two") does and then reads a pointer as an int. The * spread operator is the reverse of collecting, and it is the only place that character means anything in Kotlin: there are no pointers for it to dereference.
when, and Expressions Everywhere
when Instead of switch, and It Is an Expression
C’s switch falls into the next label unless you write break, takes only integers, and is a statement. when runs exactly one branch, matches on anything, and produces a value.
#include <stdio.h> static const char *describe(int code) { switch (code) { case 1: case 2: return "low"; case 3: case 4: case 5: return "medium"; default: return "high"; } } int main(void) { printf("%s %s %s\n", describe(1), describe(4), describe(9)); /* The classic bug: no break. */ int total = 0; switch (2) { case 2: total += 1; case 3: total += 10; default: total += 100; } printf("fell through to %d\n", total); return 0; }
fun describe(code: Int): String = when (code) { 1, 2 -> "low" in 3..5 -> "medium" else -> "high" } fun main() { println("${describe(1)} ${describe(4)} ${describe(9)}") println("no fallthrough is possible") // The subject can be omitted, making it an if/else ladder: for (value in listOf(-5, 0, 7, 99)) { val label = when { value < 0 -> "negative" value == 0 -> "zero" value < 10 -> "small" else -> "large" } println("$value: $label") } }
Because when is an expression it can be the whole body of a function, as describe is — and when it is used as an expression the else branch is required unless the subject is a sealed type or an enum whose cases are all covered. Branches can match a value, a range with in, a type with is, or an arbitrary condition, which is four C constructs collapsed into one.
if Produces a Value, So There Is No ?:
C has two constructs for choosing — the if statement and the ?: operator — because a statement cannot produce a value. In Kotlin if already does, so the ternary operator does not exist and is not missed.
#include <stdio.h> int main(void) { int value = 7; /* The statement form: */ const char *label; if (value > 5) { label = "big"; } else { label = "small"; } printf("%s\n", label); /* And the operator form, for when you want a value: */ printf("%s\n", value > 5 ? "big" : "small"); return 0; }
fun main() { val value = 7 val label = if (value > 5) "big" else "small" println(label) // Multi-line branches work too: the last expression is the value. val described = if (value > 5) { val excess = value - 5 "big by $excess" } else { "small" } println(described) }
Used as an expression the else is required, since there would otherwise be no value on one path. The same is true of try: val parsed = try { text.toInt() } catch (e: NumberFormatException) { 0 } is an ordinary expression, which is a shape C has no equivalent of at all.
Loops Over Ranges Instead of Three Expressions
A C for is three arbitrary expressions you assemble each time. Kotlin has no such loop: it iterates over something, and a range is the something you use when you want to count.
#include <stdio.h> int main(void) { for (int index = 1; index <= 5; index++) { printf("%d ", index); } printf("\n"); for (int index = 10; index >= 1; index -= 3) { printf("%d ", index); } printf("\n"); int numbers[3] = { 10, 20, 30 }; for (size_t index = 0; index < 3; index++) { printf("%zu=%d ", index, numbers[index]); } printf("\n"); return 0; }
fun main() { for (index in 1..5) print("$index ") println() for (index in 10 downTo 1 step 3) print("$index ") println() val numbers = intArrayOf(10, 20, 30) for ((index, value) in numbers.withIndex()) print("$index=$value ") println() // until excludes the upper bound, which is the C idiom: for (index in 0 until numbers.size) print("${numbers[index]} ") println() }
A range is a real object, so 1..5 can be stored, passed, and asked 3 in range — and the compiler still compiles for (i in 1..5) down to a plain counted loop with no allocation. until is the half-open form that matches C’s < count, and it is the one to reach for when indexing; ..< is the newer spelling of the same thing.
Exceptions Instead of Return Codes
An Exception Cannot Be Ignored
A returned status can be ignored and usually nothing warns. A thrown exception unwinds until something catches it, so the failure path runs whether or not the caller wrote code for it.
#include <stdio.h> #include <stdlib.h> int main(void) { const char *input = "not a number"; char *end = NULL; long parsed = strtol(input, &end, 10); if (end == input || *end != '\0') { printf("parse failed, and only the check found out\n"); } else { printf("parsed %ld\n", parsed); } strtol("also bad", NULL, 10); /* result ignored, silently */ printf("ignored, program continues\n"); return 0; }
fun main() { try { println("parsed ${"42".toInt()}") println("parsed ${"not a number".toInt()}") } catch (error: NumberFormatException) { println("NumberFormatException: ${error.message}") } // The non-throwing sibling, for when failure is expected: println("toIntOrNull: ${"bad".toIntOrNull() ?: -1}") // try is an expression, so the fallback can be inline: val value = try { "bad".toInt() } catch (error: NumberFormatException) { 0 } println("inline: $value") }
The standard library offers both styles on purpose: toInt throws because a malformed number is usually a bug, and toIntOrNull returns null because parsing user input is not. Unlike Java, Kotlin has no checked exceptions — nothing forces a caller to declare or handle one — which is a deliberate reversal, on the grounds that the declarations were routinely swallowed rather than handled.
finally, and an Error That Carries Its Details
The goto cleanup ladder handles the failures this function noticed; finally also handles the one thrown three frames down. And an exception is an object, so a custom type can carry whatever the failure knew.
#include <stdio.h> #include <stdlib.h> #include <string.h> #define ERROR_NONE 0 #define ERROR_TOO_SHORT 1 static int validate(const char *password, size_t *shortfall) { size_t length = strlen(password); if (length < 8) { *shortfall = 8 - length; return ERROR_TOO_SHORT; } return ERROR_NONE; } int main(void) { char *buffer = malloc(16); if (buffer == NULL) return 1; size_t shortfall = 0; if (validate("abc", &shortfall) == ERROR_TOO_SHORT) { printf("too short by %zu\n", shortfall); } free(buffer); printf("cleanup ran\n"); return 0; }
class TooShortException(val shortfall: Int) : Exception("short by $shortfall") fun validate(password: String) { if (password.length < 8) throw TooShortException(8 - password.length) } fun main() { try { validate("abc") } catch (error: TooShortException) { println("too short by ${error.shortfall}") } finally { println("cleanup ran") } }
The catch clause dispatches on type, so adding a third failure mode does not renumber anything and does not require every caller to learn a new constant. Two rules from the standard library are worth adopting: catch the narrowest type you can actually handle, and derive from Exception rather than Throwable — a bare catch (error: Throwable) swallows the out-of-memory and stack-overflow cases along with your parse error.
Generics Instead of void *
Generics Instead of void *
C has two ways to write a container for any type: void * with casts, which loses all checking, or a macro that pastes the type in, which loses readable errors. A generic keeps the type in the signature and checks it.
#include <stdio.h> #include <stdlib.h> /* A void * stack: nothing prevents pushing an int and popping a char *, and the compiler cannot help. */ struct Stack { void **items; size_t count; }; static void push(struct Stack *self, void *item) { self->items = realloc(self->items, (self->count + 1) * sizeof(void *)); self->items[self->count++] = item; } static void *pop(struct Stack *self) { return self->count ? self->items[--self->count] : NULL; } int main(void) { struct Stack stack = { NULL, 0 }; int first = 42; push(&stack, &first); printf("%d\n", *(int *)pop(&stack)); /* the cast is a promise */ free(stack.items); return 0; }
class Stack<T> { private val items = mutableListOf<T>() fun push(item: T) { items.add(item) } fun pop(): T? = items.removeLastOrNull() } fun main() { val numbers = Stack<Int>() numbers.push(42) println(numbers.pop()) val names = Stack<String>() names.push("hello") println(names.pop()?.uppercase()) // numbers.push("wrong type") // error at compile time }
Kotlin generics are erased on the JVM: the type argument is checked at compile time and gone at run time, so Stack<Int> and Stack<String> are the same class and each Int is boxed into an object. That is the cost, and it is why the primitive array types (IntArray) exist separately from Array<Int> — the first is a real int[], the second is an array of pointers.
reified: Getting the Type Back at Run Time
Erasure means a generic function normally cannot ask what T is. An inline function marked reified can, because the compiler pastes the body into each call site with the real type substituted — which is a C macro, done properly.
#include <stdio.h> /* The macro approach: the type is pasted in, and the error when it fails names this line rather than the caller's. */ #define PRINT_SIZE(type) printf(#type " is %zu bytes\n", sizeof(type)) int main(void) { PRINT_SIZE(int); PRINT_SIZE(double); PRINT_SIZE(char *); return 0; }
inline fun <reified T> describeType(): String = T::class.simpleName ?: "?" inline fun <reified T> List<*>.onlyOfType(): List<T> = filterIsInstance<T>() fun main() { println(describeType<Int>()) println(describeType<String>()) val mixed = listOf(1, "two", 3, "four") println(mixed.onlyOfType<Int>().joinToString(" ")) println(mixed.onlyOfType<String>().joinToString(" ")) }
The inline is what makes it possible — the function body is copied into the caller with T replaced by the real type, so T::class is a constant by the time it runs. That is the same mechanism as the C macro on the left, with three differences: the body is type-checked once at the declaration, the error messages name your call, and the argument is evaluated exactly once.
Coroutines Instead of Threads
Coroutines Instead of Threads
A POSIX thread costs a stack — typically eight megabytes of address space — so a few thousand is a lot. A coroutine is a function that can suspend, costing a small object on the heap, so a hundred thousand is unremarkable.
#include <stdio.h> #include <pthread.h> static void *worker(void *argument) { int identifier = *(int *)argument; printf("worker %d ran\n", identifier); return NULL; } int main(void) { pthread_t threads[3]; int identifiers[3] = { 1, 2, 3 }; for (int index = 0; index < 3; index++) { pthread_create(&threads[index], NULL, worker, &identifiers[index]); } for (int index = 0; index < 3; index++) { pthread_join(threads[index], NULL); } printf("all joined\n"); return 0; }
import kotlinx.coroutines.* fun main() = runBlocking { val jobs = (1..3).map { identifier -> launch { delay(10) // suspends; does not block println("worker $identifier ran") } } jobs.forEach { it.join() } println("all joined") // Ten thousand of these is unremarkable: val total = (1..10_000).map { async { it } }.awaitAll().sum() println("sum of 10,000 coroutines: $total") }
delay is the piece with no C equivalent short of an event loop: it suspends the coroutine and frees the underlying thread to run something else, where sleep parks the whole thread. runBlocking is the bridge from ordinary code into the suspending world and is the one place a real thread is blocked; inside it, launch starts work and async starts work that returns a value. The output order of the three workers is not fixed, exactly as with the threads on the left.
A Scope That Cannot Be Left Early
A detached pthread that outlives its creator is a routine C bug — nothing connects the thread’s lifetime to the code that started it. A coroutine belongs to a scope, and the scope does not finish until every child has, including when one of them fails.
#include <stdio.h> #include <pthread.h> static void *worker(void *argument) { (void)argument; printf("worker ran\n"); return NULL; } int main(void) { pthread_t thread; pthread_create(&thread, NULL, worker, NULL); /* Forgetting the join is legal, and the thread may outlive main or be killed halfway through. Nothing warns. */ pthread_join(thread, NULL); printf("joined explicitly\n"); return 0; }
import kotlinx.coroutines.* fun main() = runBlocking { coroutineScope { launch { delay(20); println("slow child") } launch { delay(10); println("fast child") } // No join needed: coroutineScope waits for both. } println("both finished before this line") // And a failure cancels the siblings rather than leaking them: try { coroutineScope { launch { delay(50); println("never printed") } launch { throw IllegalStateException("one child failed") } } } catch (error: IllegalStateException) { println("caught: ${error.message}") } println("no orphaned work is left running") }
This is structured concurrency, and it is the property that makes coroutines manageable at scale: work cannot escape the block that started it, a failure propagates to the siblings and cancels them, and cancellation is cooperative — a suspending function checks for it at every suspension point. The equivalent discipline in C is a convention you enforce by review, and the failure mode is a thread still running against freed memory.
Where They Meet: JNI and Bytes
Real Bytes: ByteArray and ByteBuffer
Nothing above this row has let you see a layout. ByteArray is a genuine byte[] and ByteBuffer is the view that reads and writes wider values at chosen offsets — with the byte order named, rather than assumed.
#include <stdio.h> #include <stdint.h> int main(void) { unsigned char wire[6]; uint16_t kind = 7; uint32_t length = 1024; /* Big-endian on the wire, by hand. */ wire[0] = (unsigned char)(kind >> 8); wire[1] = (unsigned char)kind; wire[2] = (unsigned char)(length >> 24); wire[3] = (unsigned char)(length >> 16); wire[4] = (unsigned char)(length >> 8); wire[5] = (unsigned char)length; printf("packed:"); for (size_t index = 0; index < 6; index++) { printf(" %02x", wire[index]); } printf("\n"); uint32_t decoded = ((uint32_t)wire[2] << 24) | ((uint32_t)wire[3] << 16) | ((uint32_t)wire[4] << 8) | (uint32_t)wire[5]; printf("decoded %u\n", decoded); return 0; }
import java.nio.ByteBuffer import java.nio.ByteOrder fun main() { val buffer = ByteBuffer.allocate(6).order(ByteOrder.BIG_ENDIAN) buffer.putShort(7) buffer.putInt(1024) println("packed: " + buffer.array().joinToString(" ") { "%02x".format(it) }) buffer.flip() println("kind ${buffer.short}") println("decoded ${buffer.int}") println("native order is ${ByteOrder.nativeOrder()}") }
Both columns produce the same six bytes. ByteBuffer defaults to big-endian regardless of the machine, which is the opposite of C’s "whatever the platform does" and is why a JVM wire format is portable by default — order(ByteOrder.LITTLE_ENDIAN) is the deliberate switch. Note also that a Kotlin Byte is signed, so a byte above 127 reads as negative and toInt() and 0xFF is the idiom for treating it as unsigned.
Calling C From the JVM: JNI
This is the destination on the JVM side. You declare the function external, load a shared library, and the runtime binds the call to a C symbol whose name follows a mangling scheme — which is where the work actually is.
#include <jni.h> #include <string.h> /* The C side of a JNI binding. The symbol name encodes the package, the class, and the method: Java_<package>_<class>_<method>, with dots replaced by underscores. */ JNIEXPORT jlong JNICALL Java_Native_stringLength(JNIEnv *environment, jclass clazz, jstring text) { (void)clazz; const char *bytes = (*environment)->GetStringUTFChars(environment, text, NULL); jlong length = (jlong)strlen(bytes); (*environment)->ReleaseStringUTFChars(environment, text, bytes); return length; }
object Native { init { // Loads libnative.so / libnative.dylib from java.library.path. System.loadLibrary("native") } external fun stringLength(text: String): Long } fun main() { // Needs the compiled library present, which the runner has not got. println(Native.stringLength("hello")) }
Three things must agree and none is checked at compile time: the mangled symbol name, the parameter types after JNI’s mapping, and the presence of the library — a mismatch surfaces as UnsatisfiedLinkError at the first call. The two calls around the string in the C column are the real tax: a JVM string is not a char *, so GetStringUTFChars hands you a copy that ReleaseStringUTFChars must return, and forgetting the second is a leak the collector cannot see.
Kotlin/Native: Reading the Header Directly
Off the JVM, the story is much closer to Swift’s. Kotlin/Native’s cinterop tool reads a C header and generates the declarations, so the functions and structs are simply available — no mangling, no GetStringUTFChars, no shared-library loading at run time.
/* geometry.h */ #ifndef GEOMETRY_H #define GEOMETRY_H typedef struct { double x; double y; } Point; double point_distance(Point first, Point second); #endif /* geometry.c */ #include "geometry.h" #include <math.h> double point_distance(Point first, Point second) { double dx = first.x - second.x; double dy = first.y - second.y; return sqrt(dx * dx + dy * dy); }
// geometry.def, read by the cinterop tool: // // headers = geometry.h // headerFilter = geometry.h // staticLibraries = libgeometry.a // // Then, in Kotlin/Native: import kotlinx.cinterop.* import geometry.* @OptIn(ExperimentalForeignApi::class) fun main() = memScoped { val first = alloc<Point>().apply { x = 0.0; y = 0.0 } val second = alloc<Point>().apply { x = 3.0; y = 4.0 } println(point_distance(first.readValue(), second.readValue())) // 5.0 }
memScoped is the piece to notice: native allocations made inside it are freed when the block ends, which is defer and goto cleanup in one construct. The C struct comes across as a real type with the C layout, so passing it costs no conversion. The trade against the JVM is the whole platform — no JIT, no JVM libraries, a different memory model — so this is the answer when the program is fundamentally native rather than when one function is.
There Is No sizeof, and That Is the Point
Every question sizeof answers is about storage you control. On the JVM you do not control it: the object header, the field order and the padding are the runtime’s business, and it may change them between versions.
#include <stdio.h> #include <stddef.h> struct Point { int x; int y; }; int main(void) { printf("int %zu\n", sizeof(int)); printf("Point %zu\n", sizeof(struct Point)); printf("x at %zu\n", offsetof(struct Point, x)); printf("y at %zu\n", offsetof(struct Point, y)); printf("ptr %zu\n", sizeof(void *)); return 0; }
data class Point(val x: Int, val y: Int) fun main() { println("Int ${Int.SIZE_BYTES}") println("Long ${Long.SIZE_BYTES}") // What you CAN measure is a primitive array, which really is // a contiguous block: val block = IntArray(1000) println("IntArray(1000) holds ${block.size * Int.SIZE_BYTES} bytes of data") // And what you cannot: the layout of an object. println("Point layout: decided by the JVM, not by the source") println("A Point is a reference plus a heap object with a header") }
The practical consequence is that anything with a fixed layout has to be expressed as a ByteArray or a ByteBuffer — which is what the row two above does — rather than as a class you hope is laid out a particular way. That is more work than a C struct and it is honest work: on the JVM there was never a guarantee to rely on. The projects that need one, such as memory-mapped file formats, use the newer foreign-memory API for exactly this reason.