In Kotlin, Functions Don't Need a Class

Functions as first-class declarations: fun and single-expression bodies, the = { } trap, default and named arguments that retire overloads, vararg and spread, infix and tailrec, local functions and closures, and how defaults reach Java.

In Java, there is no such thing as a function — only methods, each one trapped inside a class. Want a helper that belongs to no object? You make a final class full of static methods and call it StringUtils. Kotlin drops that ceremony. A function can live on its own, at the top level of a file, and you call it by name. The keyword is fun, and by the end of this chapter you’ll see how three separate Java workarounds — the utility class, the overload pile, and the telescoping constructor — all collapse into it.

This is the second chapter. We’ve met the basic types; now we write the functions that move them around. Everything here was compiled and run against Kotlin 2.4.10.

Declaring a function

The shape is fun, a name, a parameter list, and an optional return type after a colon:

fun add(a: Int, b: Int): Int {
    return a + b
}

Parameters always carry their type — Kotlin infers local variables, but never parameters, because a parameter type is part of the contract and the language won’t let it drift. The return type comes after the parameter list, mirroring the way val count: Int puts the type after the name; once you notice Kotlin always reads name-then-type, the ordering stops feeling backwards.

A parameter is a val. You cannot reassign it inside the body — a = 5 is a compile error. This trips up Java engineers who mutate parameters as scratch variables; in Kotlin you introduce a local var instead. It’s a small discipline that makes function bodies easier to reason about, because the thing you were passed is the thing it stays.

A function that returns nothing has the return type Unit, Kotlin’s stand-in for void. You almost never write it — it’s the default when you omit the return type:

fun greet(name: String) {          // returns Unit
    println("Hello, $name")
}

Unit being a real type (from the previous chapter) is what lets a Unit-returning function be passed around as a value later. void can’t; Unit can.

Single-expression functions — and the trap in the sugar

When a body is a single expression, the braces and return are noise. Replace them with =:

fun add(a: Int, b: Int): Int = a + b

Once the body is an expression, the compiler infers its type, so you can drop the return type too:

fun add(a: Int, b: Int) = a + b                     // returns Int
fun square(x: Int) = x * x                          // returns Int
fun fullName(p: Person) = "${p.first} ${p.last}"    // returns String

This is the idiomatic form for the small, pure functions that make up most of a codebase. Keep the explicit return type on public API, where it’s documentation and a guard against accidental type changes; drop it on short local helpers, where it’s clutter.

Now the trap, because it catches everyone once. A single-expression body is = <expression>. A block body is { <statements> }. Put them together and you get something that looks like a short function but isn’t:

fun makeAdder() = { a: Int, b: Int -> a + b }

That = { ... } does not mean “a function whose body is these statements.” It means “a function that returns a lambda.” makeAdder() doesn’t add anything — it hands you back a function value, and you’d call the result: makeAdder()(2, 3) is 5. If what you meant was a block-bodied function, you drop the =:

fun addThings() { /* statements, returns Unit */ }

The distinction is = expression versus { block }. Writing = { block } fuses both and quietly gives you a function factory. When a “why is my function returning a lambda” bug appears, this is always it.

Default arguments

Java’s answer to optional parameters is the overload pile: five versions of a method, each forwarding to the next. Kotlin gives a parameter a default value and the pile disappears:

fun connect(host: String, port: Int = 443, timeoutMs: Int = 5_000) {
    // ...
}

connect("example.com")           // both defaults
connect("example.com", 8080)     // override the port

One declaration covers every combination Java needed separate overloads for. Defaults are evaluated at the call site, and — usefully — a later default can be computed from an earlier parameter:

fun rect(width: Int, height: Int = width) = width * height
rect(5)       // 25 — a square
rect(5, 3)    // 15

The default expression is only evaluated when the argument is omitted, and it can call other functions, read earlier parameters, or build an object. That makes it strictly more expressive than a constant default; it’s a small computation the caller can opt out of.

The same mechanism drives constructors, because a constructor is a function — the classes chapter leans on exactly this to retire the telescoping-constructor pattern.

Named arguments

There’s a catch in connect("example.com", 8080) — is 8080 a port or a timeout? At the call site you can’t tell. Named arguments fix that, and they’re what make defaults pleasant:

connect("example.com", timeoutMs = 10_000)    // skip the port default, set the timeout
connect(host = "example.com", port = 8080)     // self-documenting

Naming an argument lets you skip over earlier defaults and set only the one you care about — impossible positionally. It also kills the boolean-parameter mystery: setEnabled(true) tells you nothing, setEnabled(enabled = true) reads itself.

Kotlin lets you mix positional and named arguments, and since 1.4 the rule is forgiving: a positional argument after a named one is fine as long as it still lands in its correct slot. Named arguments can also appear in any order:

rect(height = 4, width = 2)    // 8 — order doesn't matter when both are named

The one hard rule worth remembering: once you’ve skipped a parameter by relying on its default, every argument after it must be named, because there’s no positional slot left to anchor them.

vararg and the spread operator

A vararg parameter accepts zero or more values, arriving inside the function as an array:

fun sum(vararg numbers: Int): Int {
    var total = 0
    for (n in numbers) total += n
    return total
}

sum()           // 0
sum(1, 2, 3)    // 6

A function may have only one vararg, and it needn’t be last — but any parameter after it must be passed by name, since the vararg greedily consumes the positional arguments. That’s how a signature like fun tag(vararg classes: String, id: String = "") works: classes swallows the positional strings and id is reached by name.

If you already hold an array and want to pass its elements as separate arguments, the spread operator * unpacks it:

val nums = intArrayOf(1, 2, 3)
sum(*nums)      // 6

Spread copies the array’s references into the call, so it’s not free on a hot path with large arrays — worth knowing, rarely worth worrying about.

infix functions

A function of one argument, marked infix, can be called without the dot and parentheses:

infix fun Int.pow(exp: Int): Int {
    var result = 1
    repeat(exp) { result *= this }
    return result
}

2 pow 10        // 1024

You’ve already used infix functions without noticing — to in mapOf("a" to 1) is one, and so are and, or, shl on integers. Infix reads well for genuinely binary operations and turns into line noise if you overuse it; the standard library’s taste is a good guide. Note infix has lower precedence than arithmetic, so 2 pow 10 + 1 is 2 pow 11, not 1025 — parenthesise when mixing.

tailrec: recursion that doesn’t blow the stack

A self-recursive function whose recursive call is the last thing it does can be marked tailrec, and the compiler rewrites it into a plain loop — no growing stack, no StackOverflowError:

tailrec fun sumTo(n: Long, acc: Long = 0): Long =
    if (n == 0L) acc else sumTo(n - 1, acc + n)

sumTo(1_000_000)    // 500000500000 — a million frames deep, and it just works

Run that without tailrec and a million-deep recursion overflows the stack. With it, the bytecode is a loop and the recursion is an illusion you wrote for clarity. The constraint is strict: the recursive call must be in tail position (its result returned directly, not used in further arithmetic), or the compiler refuses the annotation with an error rather than silently ignoring it — which is the right behaviour, because a tailrec that silently fell back to real recursion would be a stack overflow waiting for a bigger input.

Functions don’t need a home

Because functions are first-class declarations, where you put one is a design decision, not a rule the language imposes.

A top-level function sits directly in a file, outside any class — where utility functions belong, no Utils holder required:

// in file MathUtils.kt
fun gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b)

On the JVM this compiles to a static method on a synthesized class named after the file (MathUtilsKt), which is exactly how Java code will call it — a detail the interop chapter returns to.

A local function is declared inside another function and can see that function’s variables — it closes over them:

fun printReport(rows: List<String>) {
    fun separator() = println("-".repeat(40))

    separator()
    rows.forEach { println(it) }
    separator()
}

separator exists only inside printReport, and because it captures the enclosing scope it can read rows or any local without being passed them. Reach for a local function when logic repeats within a single function but has no meaning outside it — it removes duplication without leaking a helper into the file’s namespace. The capturing behaviour is the same closure mechanism the closures chapter treats in full.

Defaults, seen from Java

One caveat that only matters at a language boundary, but matters a lot there: default arguments are a Kotlin feature, and Java callers don’t see them. To Java, fun connect(host, port = 443, timeoutMs = 5000) is a single three-argument method — Java must pass all three. If a function is part of an API that Java will call, annotate it @JvmOverloads and the compiler generates the overload pile for Java’s benefit while Kotlin callers keep the single clean declaration. It’s the one place the overloads come back, and only for the neighbours who need them. The interop chapter covers when to reach for it.

Final thoughts

The throughline is that Kotlin treats a function as a thing in its own right, not a fragment smuggled inside a class. That one decision retires the static-utility class (top-level functions), the overload pile (defaults plus named arguments), and the telescoping constructor (defaults again) all at once — three Java patterns replaced by two features.

The parts worth carrying forward are the sharp ones: a parameter is a val, = { } builds a function factory rather than a body, and @JvmOverloads is the seam where defaults meet a language that doesn’t have them. Get those, and functions in Kotlin are exactly as light as they first appear.

Practice: reinforce this with the Functions workbook — short problems with solutions you can reveal as you go.

We’ve been writing bodies that branch and loop without explaining the syntax. That’s next: control flow and ranges, where if returns a value, for loops over a range, and there’s no ternary operator because there doesn’t need to be.

Comments