PONY λ M2 Modula-2

Kotlin.CodeCompared.To/Go

An interactive executable cheatsheet comparing Kotlin and Go

Kotlin 2.3 Go 1.26.2
Basics & Variables
Hello, World
fun main() { println("Hello, World!") }
package main import "fmt" func main() { fmt.Println("Hello, World!") }
Go asks for more ceremony up front: every file declares its package, executables live in package main, and even printing needs the fmt import. There are no top-level statements and no REPL-style script mode — and gofmt (tabs, brace placement, everything) is not a suggestion but the single mechanical style every Go codebase shares.
val/var becomes var/:=
Kotlin's mutability split (val/var) does not exist in Go — every variable is mutable. Go's split is about where: var anywhere, := shorthand inside functions, const for compile-time constants only.
fun main() { val fixed = 1 var counter = 0 counter += 1 println(fixed + counter) }
package main import "fmt" const fixed = 1 func main() { counter := 0 counter += 1 fmt.Println(fixed + counter) }
The loss of val stings: Go has no way to declare an immutable local (its const covers only compile-time numbers, strings, and booleans). In exchange Go enforces something Kotlin only warns about: an unused variable is a compile error, which is why quick experiments end up assigning to _.
Zero values instead of initialization
Kotlin forces you to initialize (or mark lateinit). Go initializes for you: every type has a zero value, and a bare declaration gives you one.
fun main() { // Kotlin will not compile a read of an uninitialized value: // val count: Int ← error: must be initialized val count = 0 val name = "" val ready = false println("$count / '$name' / $ready") }
package main import "fmt" func main() { var count int var name string var ready bool fmt.Printf("%d / %q / %t\n", count, name, ready) }
The zero value is a design pillar, not a default: 0, "", false, and nil are meant to be immediately useful (a zero sync.Mutex is an unlocked mutex; a nil slice appends fine). Well-designed Go types advertise "the zero value is ready to use." The dark side is the nil section below — reference types zero to nil, and nothing like Kotlin's T? tracks it.
Functions
Multiple return values
Where Kotlin returns a Pair and destructures, Go functions return multiple values natively — the feature its whole error-handling story is built on.
fun divide(numerator: Int, denominator: Int): Pair<Int, Int> { return Pair(numerator / denominator, numerator % denominator) } fun main() { val (quotient, remainder) = divide(17, 5) println("$quotient remainder $remainder") }
package main import "fmt" func divide(numerator, denominator int) (int, int) { return numerator / denominator, numerator % denominator } func main() { quotient, remainder := divide(17, 5) fmt.Println(quotient, "remainder", remainder) }
No tuple object exists — the values come back as two real assignments, no allocation, no componentN(). Note the parameter shorthand too: numerator, denominator int declares both as int, with the type written once, after the names (Go reverses Kotlin's name: Type order throughout).
Lambdas are function literals
fun main() { val double = { number: Int -> number * 2 } println(double(21)) val multiplier = 10 val scale = { number: Int -> number * multiplier } println(scale(5)) }
package main import "fmt" func main() { double := func(number int) int { return number * 2 } fmt.Println(double(21)) multiplier := 10 scale := func(number int) int { return number * multiplier } fmt.Println(scale(5)) }
Go's anonymous functions are full closures — scale captures multiplier exactly as Kotlin's lambda does — but there is no shorthand: no it, no expression body, no trailing-lambda syntax. func(number int) int { return … } is as terse as it gets, which previews the collections section: without light lambda syntax, map/filter chains never became idiomatic Go.
No defaults, no named arguments, no overloading
Three Kotlin conveniences deleted at once: Go functions have exactly one signature, every argument is positional, and every argument is required.
fun greet(name: String = "world", punctuation: String = "!") = "Hello, $name$punctuation" fun main() { println(greet()) println(greet(punctuation = "?")) }
package main import "fmt" type GreetOptions struct { Name string Punctuation string } func greet(options GreetOptions) string { if options.Name == "" { options.Name = "world" } if options.Punctuation == "" { options.Punctuation = "!" } return "Hello, " + options.Name + options.Punctuation } func main() { fmt.Println(greet(GreetOptions{})) fmt.Println(greet(GreetOptions{Punctuation: "?"})) }
The idiomatic substitute is an options struct: struct literals support field names (GreetOptions{Punctuation: "?"}), omitted fields take their zero values, and the function fills defaults in — Kotlin's named-and-defaulted parameters rebuilt from plainer parts. The verbosity is real and accepted; Go bets that call sites reading identically everywhere beats concision.
Control Flow
if is a statement again
Kotlin's if is an expression — val max = if (a > b) a else b. Go's is a statement, there is no ternary, and the workaround is a plain variable.
fun main() { val first = 17 val second = 42 val larger = if (first > second) first else second println(larger) }
package main import "fmt" func main() { first := 17 second := 42 larger := second if first > second { larger = first } fmt.Println(larger) }
This is the adjustment Kotlin developers feel hourly at first. Go's if does offer one thing Kotlin's lacks — an init statement scoped to the condition, if value, ok := lookup(key); ok { … } — which the nil and error sections lean on constantly.
when becomes switch
fun describe(number: Int): String = when { number == 0 -> "zero" number < 0 -> "negative" number % 2 == 0 -> "positive even" else -> "positive odd" } fun main() { println(describe(-4)) }
package main import "fmt" func describe(number int) string { switch { case number == 0: return "zero" case number < 0: return "negative" case number%2 == 0: return "positive even" default: return "positive odd" } } func main() { fmt.Println(describe(-4)) }
Go's conditionless switch is Kotlin's subjectless when almost line for line, and like when it never falls through (no break needed; an explicit fallthrough keyword exists for the rare opposite wish). The differences: switch is a statement, so each arm returns rather than the whole construct producing a value, and nothing enforces exhaustiveness — default is optional and missing cases are silent.
One loop keyword
Kotlin has for, while, and do-while. Go has for — wearing all three costumes, plus range.
fun main() { for (number in 1..3) { println(number) } val fruits = listOf("apple", "banana") for ((index, fruit) in fruits.withIndex()) { println("$index: $fruit") } var countdown = 2 while (countdown > 0) { countdown -= 1 } println("liftoff") }
package main import "fmt" func main() { for number := 1; number <= 3; number++ { fmt.Println(number) } fruits := []string{"apple", "banana"} for index, fruit := range fruits { fmt.Printf("%d: %s\n", index, fruit) } countdown := 2 for countdown > 0 { countdown-- } fmt.Println("liftoff") }
for condition { } is the while loop, for { } alone loops forever, and for index, value := range collection is Kotlin's withIndex() built in (drop the index with _, or the value entirely). One keyword, four shapes — a fair summary of Go's whole design.
nil Is Back
No T? — nil is unchecked
The safety net is gone: Go has no nullable types, no ?., no compiler tracking. Pointers, slices, maps, and interfaces can all be nil, and dereferencing one panics at runtime.
fun findUser(id: Int): String? = if (id == 1) "Ada" else null fun main() { val name: String? = findUser(2) println(name?.length ?: 0) }
package main import "fmt" func findUser(id int) *string { if id == 1 { name := "Ada" return &name } return nil } func main() { name := findUser(2) if name != nil { fmt.Println(len(*name)) } else { fmt.Println(0) } }
Absence is modeled with a pointer (*string), and the if name != nil check is on you, every time, with no smart cast confirming you did it. Forgetting is a runtime nil pointer dereference panic — precisely the billion-dollar mistake Kotlin's type system closed. Idiomatic Go often sidesteps pointers-for-absence using the comma-ok pattern next, or multiple returns with an error.
The comma-ok idiom
Go's home-grown alternative to ?.: lookups return a second boolean, conventionally named ok.
fun main() { val ages = mapOf("Ada" to 36) val age: Int? = ages["Grace"] println(age ?: 0) ages["Ada"]?.let { found -> println("Ada is $found") } }
package main import "fmt" func main() { ages := map[string]int{"Ada": 36} age, ok := ages["Grace"] if !ok { age = 0 } fmt.Println(age) if found, ok := ages["Ada"]; ok { fmt.Println("Ada is", found) } }
A missing key hands back the value type's zero value plus ok == false — no null, no Option, just two returns and a convention strong enough that "comma-ok" is the pattern's actual name. The if-with-init form keeps found scoped to the check, which is as close as Go gets to ?.let { }.
Errors Are Values
if err != nil is the whole story
Go has no exceptions. A fallible function returns its result and an error, and the caller checks — the most famous three lines in Go.
fun safeDivide(numerator: Double, denominator: Double): Double { require(denominator != 0.0) { "cannot divide by zero" } return numerator / denominator } fun main() { try { println(safeDivide(10.0, 2.0)) } catch (exception: IllegalArgumentException) { println(exception.message) } }
package main import ( "errors" "fmt" ) func safeDivide(numerator, denominator float64) (float64, error) { if denominator == 0 { return 0, errors.New("cannot divide by zero") } return numerator / denominator, nil } func main() { quotient, err := safeDivide(10.0, 2.0) if err != nil { fmt.Println(err) return } fmt.Println(quotient) }
An error is an ordinary value (any type with an Error() string method), returned last by convention and checked immediately. There is no unwinding, no invisible control flow, and no forgetting quietly — ignoring the return is at least visible in the code. The cost is repetition Kotlin never asks for; the benefit is that every failure path is written down where it happens.
Wrapping errors with context
fun loadConfig(path: String): String { try { throw IllegalStateException("disk unreadable") } catch (cause: IllegalStateException) { throw RuntimeException("loading config from $path failed", cause) } } fun main() { try { loadConfig("/etc/app.conf") } catch (exception: RuntimeException) { println(exception.message) println("caused by: ${exception.cause?.message}") } }
package main import ( "errors" "fmt" ) var errDiskUnreadable = errors.New("disk unreadable") func loadConfig(path string) error { return fmt.Errorf("loading config from %s failed: %w", path, errDiskUnreadable) } func main() { err := loadConfig("/etc/app.conf") fmt.Println(err) fmt.Println("is disk error:", errors.Is(err, errDiskUnreadable)) }
The %w verb wraps a cause the way exception chaining does, and errors.Is/errors.As walk the chain the way catch matches exception types. Declared sentinel errors (var errDiskUnreadable = …) play the role of Kotlin's exception classes: stable identities code can test against.
panic is not try/catch
Go does have panic and recover, but they are for bugs and package boundaries — using them as exceptions is the classic newcomer misstep.
fun checkedIndex(items: List<Int>, index: Int): Int { check(index in items.indices) { "index $index out of bounds" } return items[index] } fun main() { println(checkedIndex(listOf(10, 20, 30), 1)) }
package main import "fmt" func checkedIndex(items []int, index int) int { if index < 0 || index >= len(items) { panic(fmt.Sprintf("index %d out of bounds", index)) } return items[index] } func main() { fmt.Println(checkedIndex([]int{10, 20, 30}, 1)) }
A panic unwinds the goroutine running deferred functions, and an unrecovered panic kills the program — like an uncaught exception, but the culture treats reaching for it as failure: expected problems travel as error values. recover exists (inside a defer) mainly so libraries can convert an internal panic into an error at their public boundary.
Structs & Methods
data class becomes struct
data class Person(val name: String, val age: Int) fun main() { val person = Person("Ada", 36) println(person) println(person == Person("Ada", 36)) }
package main import "fmt" type Person struct { Name string Age int } func main() { person := Person{Name: "Ada", Age: 36} fmt.Printf("%+v\n", person) fmt.Println(person == Person{Name: "Ada", Age: 36}) }
Structs are plain value types: == compares field by field out of the box (for comparable fields), %+v prints fields with names, and assignment copies the whole struct — closer to a Kotlin data class's copy() on every assignment than to JVM reference semantics. The capitalized field names are not a style choice, as the next concepts explain.
Methods live outside the struct
Go has no class body. Methods are functions with a receiver, declared wherever you like — and the receiver's pointer-ness decides whether mutation sticks.
class Counter { var count = 0 private set fun increment() { count += 1 } } fun main() { val counter = Counter() counter.increment() counter.increment() println(counter.count) }
package main import "fmt" type Counter struct { count int } func (counter *Counter) Increment() { counter.count++ } func (counter Counter) Count() int { return counter.count } func main() { counter := Counter{} counter.Increment() counter.Increment() fmt.Println(counter.Count()) }
The receiver — (counter *Counter) — is Kotlin's implicit this made explicit and named. The star matters: a pointer receiver mutates the caller's struct, a value receiver gets a copy (an Increment on a value receiver would count into the void — a legendary Go beginner bug Kotlin's reference semantics never exposed you to). Convention: if any method needs a pointer, give them all pointers.
Capitalization is the visibility modifier
No public/private/internal keywords: an identifier starting with a capital letter is exported from its package, lowercase is package-private. That is the whole system.
class Account(private val balanceCents: Long) { fun balanceFormatted(): String = "$${balanceCents / 100}.${balanceCents % 100}" } fun main() { println(Account(123456).balanceFormatted()) }
package main import "fmt" type Account struct { balanceCents int64 // lowercase: invisible outside this package } // BalanceFormatted is exported: capitalized. func (account Account) BalanceFormatted() string { return fmt.Sprintf("$%d.%02d", account.balanceCents/100, account.balanceCents%100) } func main() { fmt.Println(Account{balanceCents: 123456}.BalanceFormatted()) }
Two levels only — exported or package-local — where Kotlin offers four. It reads oddly for a week, then becomes information: every call site shows you what crosses a package boundary (fmt.Println, account.BalanceFormatted()) without a jump to the declaration. This is also why earlier examples capitalized struct fields: fmt could not print unexported ones from another package.
No inheritance — embedding instead
Go has no open class, no extends, no super. Composition is the only tool, sweetened by embedding: an anonymous field whose methods hoist to the outer type.
open class Animal(val name: String) { fun describe() = "$name the animal" } class Dog(name: String) : Animal(name) { fun fetch() = "$name fetches!" } fun main() { val dog = Dog("Rex") println(dog.describe()) println(dog.fetch()) }
package main import "fmt" type Animal struct { Name string } func (animal Animal) Describe() string { return animal.Name + " the animal" } type Dog struct { Animal // embedded: Describe() is promoted onto Dog } func (dog Dog) Fetch() string { return dog.Name + " fetches!" } func main() { dog := Dog{Animal{Name: "Rex"}} fmt.Println(dog.Describe()) fmt.Println(dog.Fetch()) }
Embedding looks like inheritance — dog.Describe() and even dog.Name resolve through the embedded Animal — but it is delegation without a subtype relationship: a Dog is not assignable where an Animal is expected, there is no override, and no fragile-base-class problem. Polymorphism belongs entirely to interfaces, next section.
Implicit Interfaces
Interfaces are satisfied, not declared
The most distinctive idea in Go's type system: a type never says : Speaker. If it has the methods, it is the interface — structural typing, checked at compile time.
interface Speaker { fun speak(): String } class Dog : Speaker { override fun speak() = "Woof!" } class Robot : Speaker { override fun speak() = "BEEP." } fun main() { val speakers: List<Speaker> = listOf(Dog(), Robot()) for (speaker in speakers) { println(speaker.speak()) } }
package main import "fmt" type Speaker interface { Speak() string } type Dog struct{} func (dog Dog) Speak() string { return "Woof!" } type Robot struct{} func (robot Robot) Speak() string { return "BEEP." } func main() { speakers := []Speaker{Dog{}, Robot{}} for _, speaker := range speakers { fmt.Println(speaker.Speak()) } }
Neither Dog nor Robot mentions Speaker — the compiler checks the method set at the point of use. The payoff is decoupling Kotlin cannot express: you can define an interface for a third-party type that has never heard of you, and the type satisfies it retroactively. The cost is discoverability; "what implements this?" needs tooling, not a : Speaker to grep for.
Small interfaces & Stringer
Go's standard library is built from one- and two-method interfaces — io.Reader, io.Writer, fmt.Stringer. Stringer is Go's toString().
class Temperature(private val celsius: Double) { override fun toString() = "${celsius}°C" } fun main() { println(Temperature(21.5)) }
package main import "fmt" type Temperature struct { Celsius float64 } func (temperature Temperature) String() string { return fmt.Sprintf("%g°C", temperature.Celsius) } func main() { fmt.Println(Temperature{Celsius: 21.5}) }
Implementing String() string satisfies fmt.Stringer, and every fmt verb picks it up — no Any base class required, just structural satisfaction of a one-method interface. The design advice that follows is Go's most quoted proverb: "the bigger the interface, the weaker the abstraction." Accept interfaces, return structs, keep both small.
Smart casts become type switches
fun describe(value: Any): String = when (value) { is Int -> "an Int of $value" is String -> "a String of length ${value.length}" else -> "something else" } fun main() { println(describe(42)) println(describe("hello")) println(describe(3.14)) }
package main import "fmt" func describe(value any) string { switch typed := value.(type) { case int: return fmt.Sprintf("an int of %d", typed) case string: return fmt.Sprintf("a string of length %d", len(typed)) default: return "something else" } } func main() { fmt.Println(describe(42)) fmt.Println(describe("hello")) fmt.Println(describe(3.14)) }
The type switch value.(type) binds typed with the matched case's concrete type in each arm — Kotlin's smart cast, spelled once for the whole ladder. The single-type form is the assertion text, ok := value.(string), comma-ok again. any is a built-in alias for the empty interface interface{}, satisfied by everything — Kotlin's Any, structurally.
Slices & Maps
Lists become slices — and append returns
Go's workhorse sequence is the slice. The API surprise for Kotlin developers: append does not mutate in place — it returns the (possibly reallocated) slice, and you must keep the result.
fun main() { val numbers = mutableListOf(1, 2, 3) numbers.add(4) println(numbers) println(numbers.subList(1, 3)) }
package main import "fmt" func main() { numbers := []int{1, 2, 3} numbers = append(numbers, 4) fmt.Println(numbers) fmt.Println(numbers[1:3]) }
numbers = append(numbers, 4) is mandatory, not style: when the backing array is full, append allocates a bigger one and the old slice header still points at the old memory. Slicing with numbers[1:3] is a view sharing the same backing array (mutations show through), unlike subList's wrapper semantics — the sharing is where subtle Go bugs live.
Maps: literal, delete, random order
fun main() { val ages = mutableMapOf("Ada" to 36, "Grace" to 45) ages["Alan"] = 41 ages.remove("Grace") for ((name, age) in ages.toSortedMap()) { println("$name is $age") } }
package main import ( "fmt" "slices" ) func main() { ages := map[string]int{"Ada": 36, "Grace": 45} ages["Alan"] = 41 delete(ages, "Grace") names := make([]string, 0, len(ages)) for name := range ages { names = append(names, name) } slices.Sort(names) for _, name := range names { fmt.Printf("%s is %d\n", name, ages[name]) } }
Go map iteration order is deliberately randomized — the runtime varies it run to run so nobody accidentally depends on it, where Kotlin's LinkedHashMap default preserves insertion order. Printing sorted therefore means collecting the keys and sorting them yourself. delete is a built-in function, not a method, like len and append.
Where did map/filter go?
Kotlin's collection chains are the language's bread and butter. Go's answer will feel like a demotion: write the loop.
fun main() { val result = listOf(1, 2, 3, 4, 5, 6) .filter { it % 2 == 0 } .map { it * it } .sum() println(result) }
package main import "fmt" func main() { numbers := []int{1, 2, 3, 4, 5, 6} total := 0 for _, number := range numbers { if number%2 == 0 { total += number * number } } fmt.Println(total) }
The standard library has grown helpers — slices.Contains, slices.SortFunc, slices.IndexFunc, and iterator support since Go 1.23 — but no Map/Filter chain, and the community mostly does not want one: without lightweight lambda syntax the loop is often shorter, always allocation-transparent, and reads identically in every codebase. Expect to miss this for a month; expect to stop noticing after.
Sorting
data class Person(val name: String, val age: Int) fun main() { val people = listOf(Person("Grace", 45), Person("Ada", 36)) val byAge = people.sortedBy { it.age } println(byAge.map { it.name }) }
package main import ( "fmt" "slices" ) type Person struct { Name string Age int } func main() { people := []Person{{"Grace", 45}, {"Ada", 36}} slices.SortFunc(people, func(first, second Person) int { return first.Age - second.Age }) for _, person := range people { fmt.Println(person.Name) } }
slices.SortFunc takes a three-way comparator returning negative/zero/positive — Kotlin's compareBy world rather than its sortedBy key-extractor sugar — and sorts in place, where sortedBy returns a new list. The generic slices package (Go 1.21+) replaced the older reflection-based sort for most uses.
Generics
Generics, thirteen years late
Go shipped generics in 2022 after famously refusing them for a decade. The shape is Kotlin's with square brackets: type parameters with constraints.
fun <T : Comparable<T>> largest(items: List<T>): T = items.max() fun main() { println(largest(listOf(3, 7, 2))) println(largest(listOf("banana", "apple"))) }
package main import ( "cmp" "fmt" ) func largest[T cmp.Ordered](items []T) T { result := items[0] for _, item := range items[1:] { result = max(result, item) } return result } func main() { fmt.Println(largest([]int{3, 7, 2})) fmt.Println(largest([]string{"banana", "apple"})) }
[T cmp.Ordered] is Kotlin's <T : Comparable<T>>; constraints are interfaces (sometimes listing type sets like ~int | ~string). What Go still refuses: variance annotations (out/in have no counterpart), generic methods on types, and any use of generics where an interface would do — the proverb is "write concrete code first." The built-in min/max (Go 1.21+) are themselves generic.
Goroutines vs Coroutines
launch becomes go
The marquee comparison. Kotlin's coroutines were designed a decade after — and partly in response to — Go's goroutines. Both are cheap, multiplexed onto OS threads, and launched with one keyword.
import kotlinx.coroutines.* fun main() = runBlocking { val jobs = (1..3).map { number -> launch { println("worker $number") } } jobs.joinAll() println("done") }
package main import ( "fmt" "sync" ) func main() { var waitGroup sync.WaitGroup for number := 1; number <= 3; number++ { waitGroup.Add(1) go func() { defer waitGroup.Done() fmt.Println("worker", number) }() } waitGroup.Wait() fmt.Println("done") }
The deep difference is structure. A Kotlin coroutine is born inside a scope that owns it: runBlocking cannot exit until its children finish, and canceling the scope cancels them. A goroutine has no parent — go func() and it is loose in the runtime, with sync.WaitGroup bookkeeping (Add/Done/Wait) standing in for what Kotlin's structured concurrency does automatically. Forgetting Wait means main exits and the workers silently die; there is no compiler or scope to catch it.
Channel came from chan
Kotlin's Channel is a direct homage: send, receive, close, and iterate until closed — the Go patterns, suspending instead of blocking.
import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel fun main() = runBlocking { val channel = Channel<Int>() launch { for (number in 1..3) { channel.send(number * 10) } channel.close() } for (value in channel) { println(value) } }
package main import "fmt" func main() { channel := make(chan int) go func() { for number := 1; number <= 3; number++ { channel <- number * 10 } close(channel) }() for value := range channel { fmt.Println(value) } }
Line for line the same program: an unbuffered channel, a producer that closes when done, a consumer that ranges until close. Go's arrow syntax (channel <- to send, <-channel to receive) is dedicated language surface where Kotlin uses methods, and "don't communicate by sharing memory; share memory by communicating" is the Go proverb both APIs encode.
select races channels in both
import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.selects.select fun main() = runBlocking { val first = Channel<String>() val second = Channel<String>() launch { first.send("from the first channel") } launch { second.send("from the second channel") } repeat(2) { val message = select<String> { first.onReceive { it } second.onReceive { it } } println(message) } }
package main import "fmt" func main() { first := make(chan string) second := make(chan string) go func() { first <- "from the first channel" }() go func() { second <- "from the second channel" }() for range 2 { select { case message := <-first: fmt.Println(message) case message := <-second: fmt.Println(message) } } }
Both selects wait on multiple channel operations and take whichever is ready first (choosing randomly on ties — Go randomizes deliberately, like its map order, to keep programs honest). Kotlin's is an expression returning a value, in character for each language. Go's adds a default arm for non-blocking polls and is bread-and-butter for timeouts via <-time.After(…).
Structured vs unstructured, in one example
The philosophical difference, side by side: Kotlin's coroutineScope guarantees every child completes or the whole scope fails together; Go hands you goroutines and a WaitGroup and trusts you.
import kotlinx.coroutines.* suspend fun compute(number: Int): Int { delay(10) return number * number } fun main() = runBlocking { val results = coroutineScope { (1..4).map { number -> async { compute(number) } }.awaitAll() } println(results) }
package main import ( "fmt" "sync" ) func main() { results := make([]int, 4) var waitGroup sync.WaitGroup for index := 0; index < 4; index++ { waitGroup.Add(1) go func() { defer waitGroup.Done() number := index + 1 results[index] = number * number }() } waitGroup.Wait() fmt.Println(results) }
Kotlin's version cannot leak: if one async throws, the scope cancels its siblings and rethrows — lifecycle, cancellation, and error propagation are the scope's job. Go's version works because each goroutine writes a distinct index (safe without locks) and the WaitGroup count is right — both properties you maintain, unchecked. The community's golang.org/x/sync/errgroup reconstructs a scope-like pattern, and Kotlin's designers cite exactly this gap as why coroutines are structured.
defer vs use { }
Cleanup: defer vs use { }
Kotlin scopes cleanup with a use { } block; Go schedules it with defer: the call runs when the surrounding function returns, however it returns.
class Connection(val name: String) : AutoCloseable { override fun close() = println("closing $name") } fun main() { Connection("database").use { connection -> println("using ${connection.name}") } println("after the block") }
package main import "fmt" type Connection struct { Name string } func (connection Connection) Close() { fmt.Println("closing", connection.Name) } func work() { connection := Connection{Name: "database"} defer connection.Close() fmt.Println("using", connection.Name) } func main() { work() fmt.Println("after the function") }
The idiom is acquire-then-defer on adjacent lines, so the cleanup is visible at the acquisition site. Differences from use { }: defer is function-scoped, not block-scoped; multiple defers run last-in-first-out; and deferred calls run even during a panic — which is how recover gets its chance. Nothing enforces the pairing, though: forget the defer and the resource leaks silently, where Kotlin's use can't be half-applied.
Strings & Formatting
No string templates
Kotlin's "$name" has no Go equivalent — formatting goes through fmt verbs, C-printf style with Go additions.
fun main() { val name = "world" val answer = 42 println("Hello, $name! The answer is $answer.") }
package main import "fmt" func main() { name := "world" answer := 42 fmt.Printf("Hello, %s! The answer is %d.\n", name, answer) greeting := fmt.Sprintf("Hello again, %s!", name) fmt.Println(greeting) }
The verbs to learn first: %s strings, %d integers, %v anything (its "just print it" default), %+v structs with field names, %q quoted, %T the value's type. Sprintf returns the string instead of printing. Unlike Kotlin's compile-checked templates, a wrong verb is only caught by go vet — run it.
String methods live in a package
fun main() { val greeting = "hello world" println(greeting.uppercase()) println(greeting.replace("world", "Go")) println(greeting.split(" ")) println(" padded ".trim()) }
package main import ( "fmt" "strings" ) func main() { greeting := "hello world" fmt.Println(strings.ToUpper(greeting)) fmt.Println(strings.Replace(greeting, "world", "Go", 1)) fmt.Println(strings.Split(greeting, " ")) fmt.Println(strings.TrimSpace(" padded ")) }
Go strings have almost no methods — the operations are package functions taking the string first, so Kotlin's greeting.uppercase() becomes strings.ToUpper(greeting). Strings are immutable UTF-8 byte sequences in both languages; note strings.Replace takes a count (-1 for all), where Kotlin's replace always replaces every occurrence.