Kotlin Function Definition and Call
Topics: collections · named arguments · default parameters · top-level functions · extension functions · varargs · infix calls · destructuring · strings · local functions
1. Collections in Kotlin
Kotlin uses standard Java collection classes — no wrappers, no conversion overhead.
val set = setOf(1, 7, 53) // java.util.LinkedHashSet
val list = listOf(1, 7, 53) // java.util.Arrays$ArrayList
val map = mapOf(1 to "one", 7 to "seven", 53 to "fifty-three") // java.util.LinkedHashMapto is a regular function call, not a keyword.
Kotlin's collection interfaces are read-only by default. Mutable counterparts exist.
The extended API (last(), shuffled(), sum(), etc.) comes from extension functions declared in the standard library, automatically imported into every Kotlin file.
val strings = listOf("first", "second", "fourteenth")
strings.last() // fourteenth
strings.shuffled() // random order
val numbers = setOf(1, 14, 2)
numbers.sum() // 172. Named Arguments
Without named arguments, positional calls with multiple parameters of the same type are ambiguous:
// Hard to read — which string is the separator?
joinToString(collection, " ", " ", ".")With named arguments:
joinToString(collection, separator = " ", prefix = " ", postfix = ".")Named arguments can appear in any order — specify all names and reorder freely:
joinToString(
postfix = ".",
separator = " ",
collection = collection,
prefix = " "
)Note: Named arguments only work with functions declared in Kotlin, not Java.
3. Default Parameter Values
Java addresses optional parameters with method overloads — repeated type signatures, repeated documentation, no clear defaults at the call site. Kotlin encodes defaults in the function declaration itself:
fun <T> joinToString(
collection: Collection<T>,
separator: String = ", ",
prefix: String = "",
postfix: String = ""
): String { /* ... */ }Callers omit trailing arguments or skip middle ones using named syntax:
joinToString(list) // 1, 2, 3
joinToString(list, "; ") // 1; 2; 3
joinToString(list, postfix = ";", prefix = "# ") // # 1, 2, 3;Default values are encoded in the callee, not the call site. Changing a default and recompiling propagates to all callers that didn't provide an explicit value.
Java interop: Java has no concept of default parameters, so callers must supply all arguments. Annotate with @JvmOverloads to generate the Java overload ladder:
@JvmOverloads
fun <T> joinToString(
collection: Collection<T>,
separator: String = ", ",
prefix: String = "",
postfix: String = ""
): String { /* ... */ }The compiler emits one overload per omittable parameter (dropping from the right):
// Generated Java overloads
String joinToString(Collection<T> collection, String separator, String prefix, String postfix);
String joinToString(Collection<T> collection, String separator, String prefix);
String joinToString(Collection<T> collection, String separator);
String joinToString(Collection<T> collection);4. Top-Level Functions and Properties
Java forces every function into a class. Utility logic that doesn't belong to any class ends up in containers like StringUtils or Collections. Kotlin lets you declare functions at the file level directly:
// join.kt
package strings
fun joinToString(/* ... */): String { /* ... */ }The compiler generates a class named after the file (JoinKt) with a static method. From Java:
import strings.JoinKt;
JoinKt.joinToString(list, ", ", "", "");To rename the generated class, add a file annotation before the package declaration:
@file:JvmName("StringFunctions")
package strings
fun joinToString(/* ... */): String { /* ... */ }// Java call site
import strings.StringFunctions;
StringFunctions.joinToString(list, ", ", "", "");Top-level properties
Properties also live at the file level:
var opCount = 0 // stored as a static field
fun performOperation() { opCount++ }
fun reportOperationCount() { println("Operation performed $opCount times") }Constants exposed as public static final require const:
const val UNIX_LINE_SEPARATOR = "\n"
// → public static final String UNIX_LINE_SEPARATOR = "\n";Without const, a val property generates a getter method, not a constant field.
5. Extension Functions
An extension function is declared outside a class but callable as if it were a member. It extends a type you don't own (Java SDK classes, third-party libraries, final classes).
package strings
fun String.lastChar(): Char = this.get(this.length - 1)- Receiver type (
String): the class being extended. - Receiver object (
this): the instance the function is called on — can be omitted like in any regular method.
fun String.lastChar(): Char = get(length - 1) // this omittedprintln("Kotlin".lastChar()) // nAs top-level declarations, extension functions cannot access private or protected members of the class they extend — they don't bypass encapsulation. (This restriction is specific to top-level extensions; an extension declared as a member of a class, or in the same class as the type it extends, can reach protected members)
5.1 Imports
Extension functions are not automatically in scope. Import them explicitly:
import strings.lastChar
val c = "Kotlin".lastChar()
// or with alias to resolve name conflicts
import strings.lastChar as last
val c = "Kotlin".last()5.2 Java interop
An extension function compiles to a static method with the receiver as the first argument. If declared in StringUtil.kt:
char c = StringUtilKt.lastChar("Java");No adapter objects, no runtime overhead.
5.3 Extension functions on collection types
Rewriting joinToString as an extension on Collection<T>:
fun <T> Collection<T>.joinToString(
separator: String = ", ",
prefix: String = "",
postfix: String = ""
): String {
val result = StringBuilder(prefix)
for ((index, element) in this.withIndex()) {
if (index > 0) result.append(separator)
result.append(element)
}
result.append(postfix)
return result.toString()
}val list = listOf(1, 2, 3)
list.joinToString(separator = "; ", prefix = "(", postfix = ")") // (1; 2; 3)The receiver type can be more specific than a class. This extension only accepts Collection<String>:
fun Collection<String>.join(separator: String = ", ", prefix: String = "", postfix: String = "") =
joinToString(separator, prefix, postfix)
listOf("one", "two", "eight").join(" ") // one two eight
listOf(1, 2, 8).join() // Error: receiver type mismatchThis is a compile-time check, not a runtime one — listOf(1, 2, 8).join() fails to compile at all, since List<Int> is never a subtype of Collection<String>. There's no way to reach this code path and fail at runtime instead; the call site simply doesn't type-check.
5.4 No overriding for extension functions
Member functions dispatch dynamically at runtime. Extension functions dispatch statically at compile time — the declared type of the variable determines which extension is called, not the runtime type.
open class View { open fun click() = println("View clicked") }
class Button : View() { override fun click() = println("Button clicked") }
fun View.showOff() = println("I'm a view!")
fun Button.showOff() = println("I'm a button!")
fun main() {
val view: View = Button()
view.click() // Button clicked ← runtime dispatch (member function)
view.showOff() // I'm a view! ← compile-time dispatch (extension)
}If a class has a member function with the exact same signature as an extension function, the member always wins — the extension is effectively shadowed and unreachable through that type.
If the signatures differ (different parameter types or count), there's no collision: both are visible at the call site, and normal overload resolution picks the best match based on the argument types, the same as it would between two regular overloaded functions. Only an identical signature triggers the "member wins" shadowing rule described above.
5.5 Extension properties
Extension properties use property syntax but cannot hold state (no backing field, no initializer). They must define custom accessors:
// Read-only extension property
val String.lastChar: Char
get() = get(length - 1)
// Mutable extension property
var StringBuilder.lastChar: Char
get() = get(length - 1)
set(value) { setCharAt(length - 1, value) }val sb = StringBuilder("Kotlin?")
println(sb.lastChar) // ?
sb.lastChar = '!'
println(sb) // Kotlin!From Java, call the getter and setter explicitly:
StringUtilKt.getLastChar("Java");
StringUtilKt.setLastChar(sb, '!');6. varargs, Infix Calls, and Destructuring
6.1 varargs
vararg replaces Java's T... syntax:
fun <T> listOf(vararg values: T): List<T> { /* ... */ }
val list = listOf(2, 3, 5, 7, 11)When the arguments are already in an array, use the spread operator (*) to unpack:
fun main(args: Array<String>) {
val list = listOf("args: ", *args) // mix fixed values and array contents
println(list)
}Java passes arrays directly; Kotlin requires explicit spreading. This also allows mixing fixed values and array contents in one call — Java cannot do this.
Gotcha: the spread operator only unpacks arrays (
Array<T>), notListor other collection types. If you have aList, convert it first:listOf("args: ", *myList.toTypedArray()). Passing aListdirectly where an array-spread is expected won't compile.
6.2 Infix calls
A function marked infix with exactly one required parameter can be called without a dot or parentheses:
infix fun Any.to(other: Any) = Pair(this, other)1.to("one") // regular call
1 to "one" // infix call — equivalentto is not a keyword. It is an extension function declared in the standard library.
6.3 Destructuring declarations
A Pair (or any class that supports destructuring) can be unpacked into multiple variables:
val (number, name) = 1 to "one"
// number = 1, name = "one"Also works in for loops — withIndex() returns IndexedValue which destructures into index and element:
for ((index, element) in collection.withIndex()) {
println("$index: $element")
}6.4 mapOf signature
mapOf uses both vararg and the to infix call:
fun <K, V> mapOf(vararg values: Pair<K, V>): Map<K, V>
val map = mapOf(1 to "one", 7 to "seven", 53 to "fifty-three")There is no special map-literal syntax — this is a regular function call with infix pairs.
7. Strings and Regular Expressions
Kotlin strings are Java strings. No wrappers, no conversion — any Kotlin String passes directly to Java methods.
7.1 split()
Java's String.split(".") returns an empty array — . is a regex wildcard. Kotlin's split overloads force the distinction:
// Regex overload: requires Regex type, not String
"12.345-6.A".split("\\.|-".toRegex()) // [12, 345, 6, A]
// Plain-text overload: multiple string delimiters
"12.345-6.A".split(".", "-") // [12, 345, 6, A]
// Character overload
"12.345-6.A".split('.', '-') // [12, 345, 6, A]Passing a String to split always means plain text. Passing Regex always means regex. Ambiguity is gone.
Gotcha — the trap runs the opposite direction from Java: in Java,
split(".")silently does regex matching and returns an empty array, which surprises people who wanted a literal split. In Kotlin it's the reverse:split(".")is always plain text, so if you actually meant a regex pattern (e.g. splitting on.or-), passing a raw string like"\\.|-"does nothing useful — you must explicitly call.toRegex()on it, or the compiler will simply treat the whole pattern as one literal delimiter. Forgetting.toRegex()is the Kotlin-side version of the same overload-resolution mistake.
7.2 String extensions for path parsing
The standard library provides substringBeforeLast / substringAfterLast for common string slicing without regex:
fun parsePath(path: String) {
val directory = path.substringBeforeLast("/")
val fullName = path.substringAfterLast("/")
val fileName = fullName.substringBeforeLast(".")
val extension = fullName.substringAfterLast(".")
println("Dir: $directory, name: $fileName, ext: $extension")
}
parsePath("/Users/yole/kotlin-book/chapter.adoc")
// Dir: /Users/yole/kotlin-book, name: chapter, ext: adoc7.3 Triple-quoted strings and regular expressions
Triple-quoted strings ("""...""") need no escaping and backslashes are literal. Use them for regex patterns:
fun parsePathRegex(path: String) {
val regex = """(.+)/(.+)\.(.+)""".toRegex()
val matchResult = regex.matchEntire(path)
if (matchResult != null) {
val (directory, filename, extension) = matchResult.destructured
println("Dir: $directory, name: $filename, ext: $extension")
}
}In a triple-quoted string \. is a literal dot escape and in a regular string you would write \\..
7.4 Multiline strings
Triple-quoted strings preserve all whitespace and newlines between the quotes. Kotlin gives you two trimming functions and they are not interchangeable:
trimIndent()strips the common leading whitespace from every line (based on the least-indented line) and drops leading/trailing blank lines. It does not look for any marker character.trimMargin()strips everything up to and including a marker prefix on each line.|by default, or a custom string passed as an argument (trimMargin(">")).
Using trimIndent() on text written with | margin markers leaves the | characters in the output, since trimIndent() never strips them:
// trimMargin(), the | characters are stripped
val kotlinLogo = """
| //
|//
|/ \
""".trimMargin()
// //
///
/ \// trimIndent(), no markers needed; common leading whitespace is stripped instead
val kotlinLogo = """
//
//
/ \
""".trimIndent()
// //
// //
// / \A practical use is embedding expected output in tests avoids file loading and string escaping:
val expectedJson = """
{
"name": "Sebastian",
"age": 27,
"homeTown": "Munich"
}
""".trimIndent()IntelliJ IDEA and Android Studio inject syntax highlighting inside triple-quoted strings (Alt+Enter → Inject Language or Reference).
Multiline strings do not support escape sequences like \n. To embed a literal $ use ${"$"}. To embed a Unicode escape: ${"\uD83E\uDD14"}.
8. Local Functions
Local functions are functions declared inside another function. They remove duplication without polluting class scope.
Before (duplication):
fun saveUser(user: User) {
if (user.name.isEmpty()) {
throw IllegalArgumentException("Can't save user ${user.id}: empty Name")
}
if (user.address.isEmpty()) {
throw IllegalArgumentException("Can't save user ${user.id}: empty Address")
}
// save...
}After (local function):
fun saveUser(user: User) {
fun validate(value: String, fieldName: String) {
if (value.isEmpty()) {
throw IllegalArgumentException("Can't save user ${user.id}: empty $fieldName")
}
}
validate(user.name, "Name")
validate(user.address, "Address")
// save...
}Local functions close over the outer function's parameters and user is accessible directly inside validate without being passed as an argument.
This closure is not read-only. A local function can also capture and mutate a var from the enclosing scope:
fun countInvalidFields(user: User): Int {
var invalidCount = 0
fun checkField(value: String) {
if (value.isEmpty()) invalidCount++ // mutates the enclosing var
}
checkField(user.name)
checkField(user.address)
return invalidCount
}This is a real point of contrast with Java, where lambdas and anonymous classes can only capture variables that are effectively final. Mutating a captured local from inside the lambda is a compile error. Kotlin lifts that restriction for both local functions and lambdas: a captured var is wrapped in a Ref object under the hood specifically so it can be shared and mutated across the closure.
Further refinement is moving to an extension function:
fun User.validateBeforeSave() {
fun validate(value: String, fieldName: String) {
if (value.isEmpty()) {
throw IllegalArgumentException("Can't save user $id: empty $fieldName")
}
}
validate(name, "Name")
validate(address, "Address")
}
fun saveUser(user: User) {
user.validateBeforeSave()
// save...
}The validation logic is now separate from saveUser but doesn't pollute User's public API. User's properties (id, name, address) are accessible directly because the function is an extension.
Avoid nesting local functions more than one level deep (readability degrades quickly).
Summary
| Feature | Effect |
|---|---|
| Named arguments | Readable call sites; no comment hacks |
| Default parameters | Replaces overload sets; callers choose what to specify |
| Top-level functions | No more XxxUtils static-only classes |
const val |
public static final constant without getter overhead |
| Extension functions | Add methods to any type without subclassing |
| Extension dispatch | Resolved at compile time and no overriding |
vararg + spread * |
Flexible arity; mix arrays and fixed values (arrays only, not List) |
infix |
Single-argument functions callable without dot/parens |
| Destructuring | Unpack composite values into named variables |
split overloads |
Regex vs plain-text always explicit and no Java trap (but its own .toRegex() trap) |
trimIndent() vs trimMargin() |
Indent-based vs marker-based trimming, they are not interchangeable |
| Local functions | Structured deduplication without polluting enclosing scope; can mutate captured vars |
Common Pitfalls (Java → Kotlin)
A consolidated list of the traps that specifically catch developers coming from Java habits:
- Forgetting
@JvmOverloads, default parameters are invisible to Java callers unless this annotation generates the overload ladder. - Forgetting
.toRegex(),split(".")is always plain text in Kotlin; a raw pattern string is never treated as regex. - Expecting dynamic dispatch on extensions, the compile-time type of the reference picks the extension function, not the runtime type, unlike member function overrides.
- Spreading a
Listdirectly, the*spread operator only works on arrays; convert with.toTypedArray()first. - Confusing
trimIndent()andtrimMargin(), one strips common whitespace, the other strips a marker prefix (|by default); mixing them up leaves stray characters or unwanted indentation in the output. - Assuming captured locals are read-only, unlike Java's effectively-final capture rule, Kotlin lets local functions and lambdas mutate a captured
var.