함수(Functions)

함수(Functions)

Kotlin에서 함수를 선언하는 방법은 이렇게 세 가지가 정해져 있어요:

  • fun 키워드를 사용해요.
  • 파라미터를 괄호 () 안에 지정해요.
  • 필요하면 반환 타입을 적어요.

예를 들어 볼게요:

//sampleStart
// 'double' is the name of the function
// 'x' is a parameter of Int type
// The expected return value is of Int type too
fun double(x: Int): Int {
    return 2 * x
}
//sampleEnd

fun main() {
    println(double(5))
    // 10
}

출처: Kotlin 공식 문서

본문

함수 사용하기

함수는 일반적인 방식으로 호출해요:

val result = double(2)

멤버 함수나 확장 함수를 호출할 때는 마침표 .를 사용합니다:

// Creates an instance of the Stream class and calls read()
Stream().read()

파라미터

함수 파라미터는 name: Type 같은 Pascal 표기로 선언해요. 파라미터는 쉼표로 구분해야 하고, 각 파라미터의 타입을 명시적으로 지정해야 합니다:

fun powerOf(number: Int, exponent: Int): Int { /*...*/ }

객체를 함수에 넘기면 컴파일러는 그 객체에 대한 참조의 복사본을 넘겨요. 복사된 참조는 같은 객체를 가리키기 때문에, 함수는 그 객체의 가변 상태를 변경할 수 있습니다. 함수 파라미터의 값은 함수 본문 안에서 읽기 전용(암시적으로 val로 선언됨)이라서 다시 할당할 수 없어요:

class Counter(var value: Int)

fun reset(counter: Counter) {
    counter.value = 0    // Allowed: modifies the object
    counter = Counter(0) // Error: 'val' cannot be reassigned
}

함수 파라미터를 선언할 때 후행 쉼표(trailing comma)를 쓸 수도 있어요:

fun powerOf(
    number: Int,
    exponent: Int, // trailing comma
) { /*...*/ }

후행 쉼표는 리팩터링과 코드 유지보수에 도움이 돼요. 선언 안에서 파라미터를 옮길 때 어느 것이 마지막일지 신경 쓰지 않아도 되니까요.

기본값이 있는 파라미터

파라미터에 기본값을 지정하면 함수 파라미터를 선택적으로 만들 수 있어요. 해당 파라미터에 대응하는 인자를 넘기지 않고 함수를 호출하면 Kotlin이 기본값을 사용하죠. 기본값이 있는 파라미터를 선택적 파라미터(optional parameter) 라고도 불러요.

선택적 파라미터는 여러 오버로드를 줄여줘요. 합리적인 기본값으로 파라미터를 생략할 수 있게 하려고 함수의 다른 버전을 여러 개 선언할 필요가 없어지거든요. 기본값은 파라미터 선언에 =을 붙이면 설정됩니다:

fun read(
    b: ByteArray,
    // The default value of 'off' is 0
    off: Int = 0,
    // The default value of 'len' is calculated
    // as the size of the 'b' array
    len: Int = b.size,
) { /*...*/ }

기본값이 있는 파라미터를 기본값이 없는 파라미터 앞에 선언하면, 그 기본값을 쓰려면 인자에 이름을 붙여 넘길 수밖에 없어요:

fun greeting(
    userId: Int = 0,
    message: String,
) { /*...*/ }

fun main() {
    // Uses 0 as the default value for 'userId'
    greeting(message = "Hello!")
    
    // Error: No value passed for parameter 'message'
    greeting("Hello!")
}

후행 람다(trailing lambda)는 이 규칙의 예외예요. 마지막 파라미터는 전달되는 함수에 대응해야 하거든요:

fun main () {
//sampleStart    
fun greeting(
    userId: Int = 0,
    message: () -> Unit,
)
{ println(userId)
  message() }
    
// Uses the default value for 'userId'
greeting() { println ("Hello!") }
// 0
// Hello!
//sampleEnd
}

오버라이드하는 메서드는 항상 기본 메서드의 기본 파라미터 값을 사용해요. 기본값이 있는 메서드를 오버라이드할 때는 시그니처에서 기본 파라미터 값을 생략해야 합니다:

open class Shape {
    open fun draw(width: Int = 10, height: Int = 5) { /*...*/ }
}

class Rectangle : Shape() {
    // It's not allowed to specify default values here
    // but this function also uses 10 for 'width' and 5 for 'height'
    // by default.
    override fun draw(width: Int, height: Int) { /*...*/ }
}
기본값으로 상수가 아닌 표현식

파라미터에 상수가 아닌 기본값을 지정할 수 있어요. 예를 들어 기본값이 함수 호출의 결과이거나, 다른 인자의 값을 사용하는 계산일 수 있죠 — 이 예시의 len 파라미터처럼요:

fun read(
    b: ByteArray,
    off: Int = 0,
    len: Int = b.size,
) { /*...*/ }

다른 파라미터의 값을 참조하는 파라미터는 선언 순서상 나중에 와야 해요. 이 예시에서 lenb 다음에 선언되어야 하죠.

일반적으로 파라미터의 기본값으로는 어떤 표현식이든 지정할 수 있어요. 다만 기본값은 해당 파라미터 없이 함수가 호출되고 기본값을 할당해야 할 때에만 평가됩니다. 예를 들어 다음 함수는 print 파라미터 없이 호출될 때만 한 줄을 출력해요:

fun main() {
//sampleStart
    fun read(
        b: Int,
        print: Unit? = println("No argument passed for 'print'")
    ) { println(b) }
    
    // Prints "No argument passed for 'print'", then "1"
    read(1)
    // Prints only "1"
    read(1, null)
    //sampleEnd
}

함수 선언의 마지막 파라미터가 함수 타입이면, 해당 람다 인자는 이름을 붙인 인자로 보내거나 괄호 밖에 두는 두 가지 방식으로 넘길 수 있어요:

fun main() {
    //sampleStart
    fun log(
        level: Int = 0,
        code:  Int = 1,
        action: () -> Unit,
    ) { println (level)
        println (code)
        action() }
    
    // Passes 1 for 'level' and uses the default value 1 for 'code'
    log(1) { println("Connection established") }
    
    // Uses both default values, 0 for 'level' and 1 for 'code'
    log(action = { println("Connection established") })
    
    // Equivalent to the previous call, uses both default values
    log { println("Connection established") }
    //sampleEnd
}

이름 붙인 인자(Named arguments)

함수를 호출할 때 인자 하나 이상에 이름을 붙일 수 있어요. 함수 호출에 인자가 많을 때 특히 유용하죠. 그런 경우 특히 인자가 null이거나 불리언 값이면 값을 어떤 인자와 연결해야 할지 구분하기 어렵거든요.

함수 호출에서 이름 붙인 인자를 사용하면 아무 순서로 나열할 수 있어요. 기본값이 있는 인자 4개를 가진 reformat() 함수를 생각해 볼게요:

fun reformat(
    str: String,
    normalizeCase: Boolean = true,
    upperCaseFirstLetter: Boolean = true,
    divideByCamelHumps: Boolean = false,
    wordSeparator: Char = ' ',
) { /*...*/ }

이 함수를 호출할 때 인자의 일부에 이름을 붙일 수 있어요:

reformat(
    "String!",
    normalizeCase = false,
    upperCaseFirstLetter = false,
    divideByCamelHumps = true,
    '_'
)

기본값이 있는 인자를 모두 건너뛸 수도 있어요:

reformat("This is a long String!")

기본값이 있는 인자를 전부 생략하는 대신 일부만 건너뛸 수도 있습니다. 다만 첫 번째로 건너뛴 인자 이후에는 모든 인자에 이름을 붙여야 해요:

reformat(
    "This is a short String!",
    upperCaseFirstLetter = false,
    wordSeparator = '_'
)

가변 개수의 인자(vararg)도 해당 인자에 이름을 붙여 넘길 수 있어요. 이 예시에서는 배열이네요:

fun mergeStrings(vararg strings: String) { /*...*/ }

mergeStrings(strings = arrayOf("a", "b", "c"))

반환 타입

블록 본문(중괄호 {} 안에 명령을 넣는 방식)으로 함수를 선언하면 반환 타입을 항상 명시적으로 지정해야 해요. 유일한 예외는 함수가 Unit을 반환할 때인데, 그 경우 반환 타입을 지정하는 건 선택 사항이에요.

Kotlin은 블록 본문을 가진 함수의 반환 타입을 추론하지 않아요. 제어 흐름이 복잡할 수 있어서 반환 타입이 독자에게는 물론 때로는 컴파일러에게도 불분명하거든요. 다만 지정하지 않으면 단일 표현식 함수의 반환 타입은 Kotlin이 추론할 수 있습니다.

Kotlin 함수는 단일 값을 반환해요. 하지만 그 값에 여러 데이터가 담길 수도 있죠. 그런 값들을 표현하는 방법은 아래 '여러 값 반환하기'를 보세요.

여러 값 반환하기

서로 다른 의미를 가진 여러 관련 값을 반환해야 한다면, 한 함수에서만 쓰더라도 data class를 선언하세요:

data class OrderSummary(
    val subtotal: Double,
    val tax: Double,
)

fun calculateOrderSummary(prices: List<Double>): OrderSummary {
    val subtotal = prices.sum()
    val tax = subtotal * 0.2
    return OrderSummary(subtotal, tax)
}

fun main() {
    val summary = calculateOrderSummary(listOf(12.50, 8.00, 4.50))

    println(summary.subtotal)
    // 25.0
    println(summary.tax)
    // 5.0
}

서로 다른 의미를 가진 여러 값을 반환할 때는 data class가 잘 맞아요. 반환되는 값들이 같은 종류여서 하나의 묶음으로 다루고 싶다면 컬렉션을 반환하는 것도 고려해 보세요:

data class Person(val name: String)

val friendGroups = listOf(
    listOf(Person("Alice"), Person("Bob")),
    listOf(Person("Charlie"), Person("Diana"), Person("Eve")),
    listOf(Person("Frank"))
)

fun findLargestGroupOfFriends(): List<Person> {
    return friendGroups.maxByOrNull { it.size } ?: emptyList()
}

fun main() {
    val largestGroup = findLargestGroupOfFriends()

    println(largestGroup.map { it.name })
    // [Charlie, Diana, Eve]
}

고정된 개수의 값을 반환해야 한다면 표준 라이브러리의 PairTriple data class를 쓸 수도 있어요. 다만 그 속성 이름이 first, second, third처럼 일반적이라 결과를 이해하기 어려울 수 있어요. 예를 들어 calculateOrderTotals() 함수가 Pair를 반환해도 각 Double이 무엇을 뜻하는지는 분명하지 않죠:

fun calculateOrderTotals(prices: List<Double>): Pair<Double, Double> {
    val subtotal = prices.sum()
    val tax = subtotal * 0.2
    return Pair(subtotal, tax)
}

fun main() {
    val totals = calculateOrderTotals(listOf(12.50, 8.00, 4.50))

    // What does 'first' mean?
    println(totals.first)
    // 25.0
  
    // What does 'second' mean?
    println(totals.second)
    // 5.0
}

서로 다른 의미를 가진 결과에는 OrderSummary data class 예시에서 보여준 것처럼, 설명적인 속성 이름을 가진 data class를 쓰는 편이 좋아요.

단일 표현식 함수(Single-expression functions)

함수 본문이 단일 표현식으로 이루어져 있으면 중괄호를 생략하고 = 기호 뒤에 본문을 지정할 수 있어요:

fun double(x: Int): Int = x * 2

대부분은 반환 타입을 명시적으로 선언하지 않아도 됩니다:

// Compiler infers that the function returns Int
fun double(x: Int) = x * 2

컴파일러가 단일 표현식에서 반환 타입을 추론할 때 가끔 문제를 겪을 수 있어요. 그런 경우에는 반환 타입을 명시적으로 추가해야 해요. 예를 들어 재귀적이거나 상호 재귀적(서로를 호출하는) 함수와 fun empty() = null처럼 타입이 없는 표현식의 함수는 항상 반환 타입이 필요합니다.

추론된 반환 타입을 쓸 때는 실제 결과가 무엇인지 꼭 확인하세요. 컴파일러가 당신에게 덜 유용한 타입을 추론할 수 있거든요. 위 예시에서 double() 함수가 Int 대신 Number를 반환하게 하고 싶다면 명시적으로 선언해야 합니다.

표현식 본문 안에서 return 문을 사용한다면 반환 타입을 명시적으로 지정해야 해요:

fun getDisplayNameOrDefault(userId: String?): String =
    getDisplayName(userId ?: return "default")

Unit을 반환하는 함수

블록 본문(중괄호 {} 안의 명령)을 가지면서 유용한 값을 반환하지 않는 함수가 있으면, 컴파일러는 그 반환 타입이 Unit이라고 가정해요. Unit은 값이 하나뿐인 타입인데, 그 값도 Unit이라고 부릅니다.

함수 타입 파라미터가 아니라면 Unit을 반환 타입으로 지정할 필요가 없어요. Unit을 명시적으로 반환할 필요도 없고요. 예를 들어 printHello() 함수는 Unit을 반환하지 않는 채로 선언할 수 있습니다:

// The declaration of the functional type parameter ('action') still 
// needs an explicit return type
fun printHello(name: String?, action: () -> Unit) {
    if (name != null)
        println("Hello $name")
    else
        println("Hi there!")

    action()
}

fun main() {
    printHello("Kodee") {
        println("This runs after the greeting.")
    }
    // Hello Kodee
    // This runs after the greeting.

    printHello(null) {
        println("No name provided, but action still runs.")
    }
    // No name provided, but action still runs
}

이 선언은 다음의 장황한 선언과 동일해요:

//sampleStart
fun printHello(name: String?, action: () -> Unit): Unit {
    if (name != null)
        println("Hello $name")
    else
        println("Hi there!")

    action()
    return Unit
}
//sampleEnd
fun main() {
    printHello("Kodee") {
        println("This runs after the greeting.")
    }
    // Hello Kodee
    // This runs after the greeting.

    printHello(null) {
        println("No name provided, but action still runs.")
    }
    // No name provided, but action still runs
}

가변 개수의 인자(varargs)

함수에 가변 개수의 인자를 넘기려면 파라미터 중 하나(보통 마지막)에 vararg 수정자를 붙일 수 있어요. 함수 안에서 타입 Tvararg 파라미터는 T의 배열로 사용할 수 있습니다:

fun <T> asList(vararg ts: T): List<T> {
    val result = ArrayList<T>()
    for (t in ts) // ts is an Array
        result.add(t)
    return result
}

그러면 함수에 가변 개수의 인자를 넘길 수 있어요:

fun <T> asList(vararg ts: T): List<T> {
    val result = ArrayList<T>()
    for (t in ts) // ts is an Array
        result.add(t)
    return result
}

fun main() {
    //sampleStart
    val list = asList(1, 2, 3)
    println(list)
    // [1, 2, 3]
    //sampleEnd
}

vararg로 표시할 수 있는 파라미터는 하나뿐이에요. 파라미터 목록에서 마지막이 아닌 다른 위치에 vararg 파라미터를 선언하면, 다음 파라미터들의 값은 이름 붙인 인자로 넘겨야 합니다. 파라미터가 함수 타입이면 괄호 밖에 람다를 두는 방식으로 값을 넘길 수도 있어요.

vararg 함수를 호출할 때는 asList(1, 2, 3) 예시처럼 인자를 개별적으로 넘길 수 있어요. 이미 배열을 가지고 있고 그 내용물을 vararg 파라미터로(또는 그 일부로) 함수에 넘기고 싶다면, 배열 이름 앞에 *를 붙이는 spread 연산자를 사용하세요:

fun <T> asList(vararg ts: T): List<T> {
    val result = ArrayList<T>()
    for (t in ts)
        result.add(t)
    return result
}

fun main() {
    //sampleStart
    val a = arrayOf(1, 2, 3)

    // The function receives the array [-1, 0, 1, 2, 3, 4]
    list = asList(-1, 0, *a, 4)

    println(list)
    // [-1, 0, 1, 2, 3, 4]
    //sampleEnd
}

기본 타입 배열을 vararg로 넘기려면 .toTypedArray() 함수로 일반(타입 있는) 배열로 변환해야 해요:

// 'a' is an IntArray, which is a primitive type array
val a = intArrayOf(1, 2, 3)
val list = asList(-1, 0, *a.toTypedArray(), 4)

중위 표기(Infix notation)

infix 키워드를 쓰면 괄호나 마침표 없이 호출할 수 있는 함수를 선언할 수 있어요. 코드에서 단순한 함수 호출을 더 읽기 쉽게 만들 때 도움이 됩니다.

infix fun Int.shl(x: Int): Int { /*...*/ }

// Calls the function using the general notation 
1.shl(2)

// Calls the function using the infix notation
1 shl 2

Infix 함수는 다음 요구사항을 충족해야 해요:

  • 클래스의 멤버 함수이거나 확장 함수여야 해요.
  • 파라미터가 하나뿐이어야 해요.
  • 파라미터는 가변 개수의 인자(vararg)를 받지 않아야 하고 기본값도 없어야 해요.

Infix 함수는 항상 수신자(receiver)와 파라미터가 모두 지정되어야 한다는 점을 기억하세요. 현재 수신자에 대해 중위 표기로 메서드를 호출할 때는 this를 명시적으로 써야 합니다. 그래야 모호함 없이 해석돼요:

class MyStringCollection {
    val items = mutableListOf<String>()

    infix fun add(s: String) {
        println("Adding: $s")
        items += s
    }

    fun build() {
        add("first")      // Correct: ordinary function call
        this add "second" // Correct: infix call with an explicit receiver
        // add "third"    // Compiler error: needs an explicit receiver
    }

    fun printAll() = println("Items = $items")
}

fun main() {
    val myStrings = MyStringCollection()
    // Adds "first" and "second" to the list
    myStrings.build()
      
    myStrings.printAll()
    // Adding: first
    // Adding: second
    // Items = [first, second]
}

함수의 범위(Function scope)

Kotlin 함수는 파일의 최상위에 선언할 수 있어요. 즉 함수를 담기 위해 클래스를 만들 필요가 없습니다. 함수는 로컬 함수나 멤버 함수, 확장 함수로도 선언할 수 있어요.

로컬 함수(Local functions)

Kotlin은 다른 함수 안에 선언되는 로컬 함수를 지원해요. 예를 들어 다음 코드는 주어진 그래프에 대해 깊이 우선 탐색(Depth-first search)을 구현한 것입니다. 바깥쪽 dfs() 함수 안의 로컬 dfs() 함수는 구현을 숨기고 재귀 호출을 처리해요:

class Person(val name: String) {
    val friends = mutableListOf<Person>()
}
class SocialGraph(val people: List<Person>)
//sampleStart
fun dfs(graph: SocialGraph) {
    fun dfs(current: Person, visited: MutableSet<Person>) {
        if (!visited.add(current)) return
        println("Visited ${current.name}")
        for (friend in current.friends)
            dfs(friend, visited)
    }
    dfs(graph.people[0], HashSet())
}
//sampleEnd
fun main() {
    val alice = Person("Alice")
    val bob = Person("Bob")
    val charlie = Person("Charlie")
    alice.friends += bob
    bob.friends += charlie
    charlie.friends += alice
    val network = SocialGraph(listOf(alice, bob, charlie))
    dfs(network)
}

로컬 함수는 바깥 함수의 로컬 변수(클로저)에 접근할 수 있어요. 위 경우 visited 함수 파라미터를 로컬 변수로 만들 수 있습니다:

class Person(val name: String) {
    val friends = mutableListOf<Person>()
}
class SocialGraph(val people: List<Person>)
//sampleStart
fun dfs(graph: SocialGraph) {
    val visited = HashSet<Person>()
    fun dfs(current: Person) {
        if (!visited.add(current)) return
        println("Visited ${current.name}")
        for (friend in current.friends)
            dfs(friend)
    }
    dfs(graph.people[0])
}
//sampleEnd
fun main() {
    val alice = Person("Alice")
    val bob = Person("Bob")
    val charlie = Person("Charlie")
    alice.friends += bob
    bob.friends += charlie
    charlie.friends += alice
    val network = SocialGraph(listOf(alice, bob, charlie))
    dfs(network)
}

멤버 함수(Member functions)

멤버 함수는 클래스나 객체 안에 정의된 함수예요:

class Sample {
    fun foo() { print("Foo") }
}

멤버 함수를 호출하려면 인스턴스나 객체 이름을 쓰고 .을 붙인 뒤 함수 이름을 쓰면 됩니다:

// Creates an instance of the Stream class and calls read()
Stream().read()

클래스와 멤버 오버라이드에 대한 자세한 내용은 '클래스와 상속(Classes and Inheritance)' 문서를 보세요.

제네릭 함수(Generic functions)

함수 이름 앞에 꺾쇠 괄호 <>를 사용해서 함수의 제네릭 파라미터를 지정할 수 있어요:

fun <T> singletonList(item: T): List<T> { /*...*/ }

제네릭 함수에 대한 자세한 내용은 '제네릭(Generics)' 문서를 보세요.

꼬리 재귀 함수(Tail recursive functions)

Kotlin은 꼬리 재귀(tail recursion) 라고 하는 함수형 프로그래밍 스타일을 지원해요. 보통은 루프를 써야 할 알고리즘 중 일부를, 스택 오버플로 위험 없이 재귀 함수로 작성할 수 있습니다. 함수에 tailrec 수정자를 붙이고 필요한 형식 조건을 충족하면, 컴파일러가 재귀를 최적화해서 없애고 빠르고 효율적인 루프 기반 버전을 남겨줘요:

import kotlin.math.cos
import kotlin.math.abs

// An arbitrary "good enough" precision
val eps = 1E-10

tailrec fun findFixPoint(x: Double = 1.0): Double =
    if (abs(x - cos(x)) < eps) x else findFixPoint(cos(x))

이 코드는 코사인의 고정점(수학 상수)을 계산해요. 1.0에서 시작해 결과가 더는 변하지 않을 때까지 cos()을 반복 호출하며, 지정한 eps 정밀도에 대해 0.7390851332151611이라는 결과를 만들어냅니다. 이 코드는 좀 더 전통적인 스타일과 동일해요:

import kotlin.math.cos
import kotlin.math.abs

// An arbitrary "good enough" precision
val eps = 1E-10

private fun findFixPoint(): Double {
    var x = 1.0
    while (true) {
        val y = cos(x)
        if (abs(x - y) < eps) return x
        x = cos(x)
    }
}

tailrec 수정자는 함수가 자신을 마지막 연산으로 호출할 때만 적용할 수 있어요. 재귀 호출 뒤에 코드가 더 있거나, try/catch/finally 블록 안에 있거나, 함수가 open이면 꼬리 재귀를 쓸 수 없습니다.

함께 보세요:

더 알아보기