Ch 03. 함수를 사용하자

About

코틀린은 개발자에게 무조건 클래스를 사용하도록 강요하지 않는다. 재사용 가능한 가장 작은 단위가 클래스인 Java와 달리, 코틀린에서는 클래스는 물론 단독 함수(standalone function)까지 모두 재사용 가능하다.

코틀린에서 우리는 함수가 특정 타입의 파라미터를 사용하도록 정해야 한다. 하지만 단일 표현식 함수(single-expression function)에 대한 리턴 유형을 유추하도록 요청할 수 있다. 함수를 호출할 때, 모든 파라미터를 전달하지 않고 기본 파라미터를 전달할 수 있다. 이런 특징을 이용해서 함수와 메소드를 쉽게 확장할 수 있다.

메소드 사용을 효율적으로 만들기 위해서 아규먼트에 이름을 넣을 수 있도록 허용했다. 이런 발전으로 인해 코드의 가독성이 올라갔다.

게다가 함수에 가변 아규먼트를 컴파일 안정성 저해 없이 넘길 수 있다.

코틀린은 구조분해(destructuring) 기능을 가지고 있어서 객체에서 속성을 독립 변수(standalone variable)로 뽑아낼 수 있다.

이 챕터에서 우리는 전역 함수와 단독(standalone) 함수를 사용하는 법을 배운다. 코틀린의 함수를 정의하는 규칙을 시작으로, 어떻게 함수가 표현식으로 취급되는지를 살펴보며, 기본 아규먼트, 명시적(named) 아규먼트, 가변 아규먼트 정의, 스프레드 연산자, 구조분해 등 많은 유용한 특징들에 대해서도 알아볼 것이다. 이런 특징을 이용해 읽기 쉽고, 유연하고, 유지보수하기 좋은 고품질 코드를 만들 수 있다. 객체지향 코드를 작성할 때에도 여기에서 배운 컨셉들이 클래스으 ㅣ메소드를 만드는데 적용될 것이다.

3-1. 함수 생성

코틀린에서 함수와 메소드를 만드는 방법은 Java의 메소드 만드는 방법과 차이가 있다. 코틀린은 함수를 만들 때, 불필요한 관행적인 코드들을 없애 버려서 훌륭한 유연성을 제공한다. 짧은 함수부터 시작해서 타입추론, 파라미터 정의와 멀티라인 함수를 알아보자.

키스 (KISS) 함수

코틀린은 함수를 정의할 때 "단순하게 해, 멍청아!(Keep It Simple, Stupid - KISS 원칙)"을 준수한다.

작은 함수들은 단순하게 작성하고, 방해요소가 없고, 실수가 없어야 한다.

// functions/kiss.kts
fun greet() = "Hello"
println(greet())

함수 정의는 fun 키워드로 시작한다. 함수 이름 다음엔 파라미터 리스트가 나온다. 만약 함수가 매우 짧은 단일표현함수(single-expression function)라면 함수 바디를 {} 로 만드는 대신 함수 정의 부분과 함수 바디를 =로 구분할 수 있다. 그리고 {} 블록 바디가 없는 단일표현함수에서는 return 키워드를 사용할 수 없다.

결과는 다음과 같다.

Hello

리턴타입과 타입 추론

greet() 함수는 문자열을 리턴한다. 그러나 리턴타입(String)을 명시하지 않았다. 이것이 가능한 이유는 코틀린이 {} 블록 바디가 없는 함수에 대해 타입 추론을 해주기 때문이다. 리턴 타입 추론은 컴파일할 때 진행된다.

코드에 일부러 오류를 넣어보자.

fun greet() = "Hello"
val message: Int = greet() // ERROR
// type mismatch: inferred type is String but Int was expected

코틀린은 컨텍스트에 기반해 greet()의 리턴을 String이라고 결정했다. 그리고 우리는 Int형 변수 messagegreet()의 결과를 할당하려고 시도했다. 코드는 컴파일 오류가 났다.

타입 추론은 안전하게 사용할 수 있고, 컴파일 시간에 타입 체크를 한다. 내부 API에서 사용가능하고, 단일표현 함수가 =로 정의된 경우 사용할 수 있다. 하지만, 함수가 외부에서 사용되거나 복잡하다면 리턴타입을 지정해주자. 리턴타입을 지정해줘야 코드를 개발한 사람과 사용하는 사람 모두에게 리턴타입을 명확하게 알려줄 수 있다. 또한, 리턴 타입 추론이 구현에 의해서 다른 타입으로 변경되는 것을 막을 수 있다.

코틀린의 함수 리턴타입 추론은 함수의 바디가 단일표현식이고, {} 블록이 아닐 때만 가능하다. 리턴타입을 명시해보자.

fun greet(): String = "Hello"

리턴타입은 앞에는 : 를 붙이고, 파라미터 리스트 뒤에 작성한다. return 키워드는 단일표현식 함수이고, 바디가 블록이 아니라면 허용되지 않는다. 리턴타입이 명확하면 타입추론을, 아니면 리턴타입을 명시하자.

모든 함수는 표현식이다

코틀린은 명령문보다는 표현식을 좋아한다. 그 원칙에 기반해 함수는 명령문보다는 표현식으로 취급되어야 한다.

코틀린은 Unit이라는 특별한 타입을 사용하는데, Java의 void와 대응된다. Unit이라는 이름은 타입 이론에서 아무런 정보를 갖지 않는 싱글톤인 Unit에서 유래했다. 리턴할 게 없는 경우 Unit을 사용할 수 있다. 코틀린은 함수에 리턴이 없으면 Unit 타입을 리턴타입으로 추론한다.

// functions/inferunit.kts
fun sayHello() = println("Well, hello")
val message: String = sayHello() // ERROR
// type mismatch: inferred type is Unit but String was expected

sayHello() 함수는 println() 함수를 이용해 일반 출력하는데, Java에서 println()의 리턴값은 알다시피 void이다. 코틀린에서는 Unit을 리턴한다.

타입 추론 사용 대신에 리턴 타입으로 Unit을 지정할 수도 있다.

// functions/specifyunit.kts
fun sayHello(): Unit = println("Well, hello")
val message: Unit = sayHello()
println("The result of sayHello is $message")

코틀린에서는 void 함수도 Unit을 리턴해주기 때문에 모든 함수가 표현식으로 취급될 수 있다. 그리고 모든 함수 결과에 대해서 메소드를 호출할 수도 있다.

Unit 타입은 toString(), equals(), hashCode() 메소드를 가지고 있다. 물론 엄청나게 유용하진 않겠지만, 위의 함수들을 실행시킬 수 있다. 이전 예제에서도 println()에서 내부적으로 UnittoString() 메소드를 호출했다. 출력 결과이다.

Well, hello
The result of sayHello is kotlin.Unit

모든 함수들은 유용한 리턴을 준다. 혹은 리턴이 없다면 Unit을 리턴해 준다. 그래서 모든 함수는 표현식으로 취급될 수 있고, 그 결과들은 변수에 할당되거나 추후 프로세스를 위해서 사용될 수 있다.

파라미터 정의하기

Haskell, F# 같은 일부 언어는 함수 안으로 들어가서 파라미터의 타입을 추론할 수 있다. 저자는 이 방식을 마음에 들어하지는 않는다고 한다. 함수의 구현을 바꾸는 것은 파라미터의 타입을 바꾸는 결과를 초래할 수 있어 불안하게 만든다.

코틀린은 함수나 메소드에 파라미터의 타입을 명시하도록 했다. 파라미터의 타입을 파라미터 이름 바로 뒤에 :로 구분해서 명시해 주는 것이다.

// functions/passingarguments.kts
fun greet(name: String): String = "Hello $name"
println(greet("Eve")) // Hello Eve

코틀린에서 함수 파라미터 타입을 지정할 때 "candidate(후보): Type" 형태의 문법을 사용한다. 이 문법은 함수 파라미터 지정 외에도 var, val을 이용한 변수 선언, 함수의 리턴 타입 선언, 함수 파라미터, 캐치블록에 전달될 아규먼트 타입 지정에도 사용된다.

greet() 함수를 정의할 때 파라미터에 var이나 val을 사용하지 않는다. 여기엔 합리적인 이유가 있다.

<이펙티브 자바 3/E>(인사이트, 2018)에서 프로그래머는 final과 immutable을 가능한 많이 사용해야 한다고 조언한다. 코틀린은 우리가 함수나 메소드 파라미터를 만들 때 immutable, mutable을 선택하는 것을 원치 않는다. 코틀린은 함수로 전달된 파라미터를 변경하는 것은 나쁜 생각이란 결론을 내렸기 때문에, 우리는 파라미터를 val이나 var로 단정지을 수 없다(하지만 굳이 따지자면 val이라고 할 수 있겠다). 그리고 함수, 메소드에서 파라미터의 값을 변화시키려 하면 컴파일 오류를 발생시킨다.

블록바디로 만 함수

함수가 작을 때(단일 표현식일 때) 우리는 =를 통해 바디와 함수 선언부를 나눴고, 리턴타입은 생략할 수 있었다.

함수가 복잡하다면 {} 블록을 사용하여 바디를 만든다. {} 블록 바디를 이용해 함수를 정의하면 항상 리턴타입을 정의해줘야 하며, 정의하지 않는다면 리턴타입은 Unit으로 추론된다.

// functions/multilinefunction.kts
fun max(numbers: IntArray): Int {
    var large = Int.MIN_VALUE
    for (number in numbers) {
        large = if (number > large) number else large
    }
    return large
}
println(max(intArrayOf(1, 5, 2, 12, 7, 3))) // 12

max() 함수는 파라미터를 배열로 받고 리턴으로 Int를 반환한다고 정의되어 있다. 함수의 바디는 {} 블록으로 둘러싸여있으므로 리턴타입은 선택사항이 아니다. return 키워드도 필수이다.

한 가지 주의사항은, ={} 블록 바디 대신 사용하면 안 된다. 만약 특정 리턴타입을 명시하고 =을 사용한 뒤 {} 블록 바디를 사용한다면 컴파일러가 오류를 발생시킨다.

fun notReally() = {2}

코틀린은 코드 블록 안으로 들어가서 리턴타입을 추론하지 않는다. 이 경우 코틀린은 블록을 람다표현식이나 익명함수로 취급할 것이다. 코틀린은 notReally() 함수를 람다표현식을 리턴하는 함수라고 판단한다.

3-2. 기본 인자와 명시적 인자

오버로딩은 함수가 기존과는 다른 타입과 다른 수의 아규먼트를 받을 수 있도록 만들 수 있다. 코틀린 또한 오버로딩이 가능하다.

기본 아규먼트 기능은 단순하고, 함수를 변경하는 좋은 방법이다. 하지만 기본 아규먼트 기능을 쓰면 바이너리가 변경되므로 컴파일을 다시 해야 한다. 그럼에도 기본 아규먼트 기능은 매우 좋다.

그리고 명시적 아규먼트(named argument)는 읽기 좋은 코드를 만드는 아주 유용한 방법이다.

기본 아규먼트를 통한 함수 변경

fun greet(name: String): String = "Hello $name"
println(greet("Eve")) // Hello Eve

지금은 "Hello"라는 내용이 하드코딩되어있다. 함수를 호출하는 사람이 선택할 수 있도록 유연성을 제공한다면 어떨까?

만약 함수에 새로운 파라미터를 추가한다면 이전에 함수를 호출하던 코드는 추가된 파라미터를 전달하고 있지 않기 때문에 모두 오류를 만들어낼 것이다. Java에서는 이런 목적을 달성하기 위해 오버로딩을 사용했지만, 그렇게 하면 코드의 중복이 발생한다. 코틀린은 기본 아규먼트를 이용해 이 문제를 해결한다.

// functions/defaultarguments.kts
fun greet(name: String, msg: String = "Hello"): String = "$msg $name"
println(greet("Eve")) // Hello Eve
println(greet("Eve", "Howdy")) // Howdy Eve

기본 아규먼트를 효과적으로 사용하고 싶다면 맨 마지막에 사용하는 것이 좋다. 람다표현식이 파라미터로 들어오는 경우엔 람다표현식 앞에서 사용하면 된다.

명시적 아규먼트(Named Argument)를 이용한 가독성 향상

// functions/namedarguments.kts
createPerson("Jake", 12, 152, 43)

이 숫자들이 무엇을 의미하는 것일까? 추론하기는 쉽지 않다. 의미를 파악하기 위해 흐름을 끊고 함수에 대한 문서나 함수 정의 부분을 봐야 한다.

// functions/namedarguments.kts
fun createPerson(name: String, age: Int = 1, height: Int, weight: Int) {
    println("$name $age $height $weight")
}

여기서 명시적 아규먼트가 나올 차례이다.

// functions/namedarguments.kts
createPerson(name = "Jake", age = 12, weight = 152, height = 43)

훨씬 낫다. 파라미터들이 어떤 의미인지 추측할 필요가 없다. 또한, 순서와 상관 없이 사용할 수 있다.

위치에 기반해서 값만 전달한 아규먼트를 일부 사용할 수도 있다.

// functions/namedarguments.kts
createPerson("Jake", age = 12, weight = 152, height = 43)

3-3. 다중인자와 스프레드

println() 같은 함수는 여러 개의 인자를 받는다. 코틀린의 다중인자(vararg) 기능은 함수가 한 번에 여러 개의 인자를 받을 때 타입 안정성을 제공하는 기능이다. 스프레드 연산자는 컬렉션의 값을 개별 값으로 분해하거나 나열할 때 유용한다.

여러 개의 인자

코틀린 함수들은 많은 수의 인자를 받을 수 있다.

// functions/vararg.kts
fun max(vararg numbers: Int): Int {
    var large = Int.MIN_VALUE
    for (number in numbers) {
        large = if (number > large) number else large
    }
    return large
}
  • 파라미터 numbers가 vararg라는 키워드로 선언되었다.

  • 파라미터 타입이 IntArray에서 Int로 변경되었다.

vararg 키워드가 파라미터로 특정 타입의 배열이 들어갈 수 있다는 것을 알려준 것이다.

// functions/vararg.kts
println(max(1, 5, 2)) // 5
println(max(1, 5, 2, 12, 7, 3)) // 12

단, vararg 키워드는 함수에서 하나의 파라미터에서만 사용할 수 있다.

// functions/mixvararg.kts
function greetMany(msg: String, vararg names: String) {
    println("$msg ${names.joinToString(", ")}")
}
greetMany("Hello", "Tom", "Jerry", "Spike") // Hello Tom, Jerry, Spike

첫 번째 인자만 첫 번째 파라미터에 할당되고, 나머지 인자들은 vararg 파라미터로 전달된다. 함수를 정의할 때 vararg 파라미터를 반드시 마지막 파라미터에 넣을 필요는 없지만 그렇게 하기를 강력하게 추천한다. vararg를 마지막에 사용하지 않는다면, 함수를 호출할 때 반드시 명시적 인자를 사용해야 한다.

함수에 전달할 인자가 많을 때 쉽게 처리할 수 있는 기능을 확인했다. 그런데 이미 배열이 있다면 어떻게 할까?

스프레드 연산자

때때로 우리는 배열이나 리스트에 있는 값들을 vararg 인자로 함수에 전달해야 하는 경우가 있다. 이럴 때 스프레드 연산자가 필요하다.

// functions/vararg.kts
val values = intArrayOf(1, 21, 3)

vararg는 하나의 파라미터에 여러 개의 인자를 넘길 수 있다는 뜻을 함축하고 있지만, 우리가 해당 파라미터에 배열을 넘기면 오류가 난다.

println(max(values)) // ERROR
// type mismatch: inferred type is IntArray but Int was expected

배열에 있는 값을 넘기기 위해 아래처럼 입력한다.

// functions/vararg.kts
println(max(values[0], values[1], values[2]))

하지만 이런 방법은 너무 장황하다.

파라미터가 vararg라고 작성되어있는 경우, 우리는 스프레드 연산자 *을 이용해서 배열을 넘길 수 있다(당연히 같은 타입이어야 한다).

// functions/vararg.kts
println(max(*values)) // 21

배열이 있으면 스프레드를 사용할 수 있지만 보통은 배열보다 리스트를 많이 사용한다. 그러나 리스트에 속한 값을 전달하고 싶을 때 리스트에 직접 스프레드 연산자를 적용할 수 없다. 대신 리스트를 배열로 변환하면 적용할 수 있다. 예제를 통해 확인해본다.

// functions/vararg.kts
println(max(*listOf(1, 4, 8, 12).toIntArray()))

리스트 안의 요소의 타입과 vararg의 타입이 Int가 아니라면 List<T>의 메소드인 to...Array() 메소드를 사용해서 적절한 타입의 배열로 변환한 후 사용하면 된다.

// litsynp/functions/myvararg.kts
data class Person(val name: String, val age: Int? = null)

fun printNames(vararg persons: Person) {
	persons.forEach { println(it.name) }
}

val persons = listOf(Person("Frank", 20), Person("Tom", 20), Person("Jerry", 20))
printNames(*persons.toTypedArray())

3-4. 구조분해

구조화란 다른 변수의 값으로 객체를 만드는 것이다. 구조분해(Destructuring)는 그 반대다. 이미 존재하는 객체에서 값을 추출해 변수로 넣는 작업이다. 이런 작업은 방해요소와 반복되는 코드를 제거하는 데 유용하다. JavaScript와 유사하지만, 코틀린의 구조분해는 속성의 이름이 아닌 속성의 위치를 기반으로 진행된다.

// functions/destructuring.kts
fun getFullName() = Triple("John", "Quincy", "Adams")

val result = getFullName()
val first = result.first
val second = result.second
val last = result.third
println("$first $middle $last") // John Quincy Adams

함수의 리턴 타입이 PairTriple, 다른 데이터 클래스인 경우 구조분해로 값을 추출해 우아하고 명확하게 값을 변수에 할당할 수 있다. 구조분해를 사용해보자.

// functions/destructuring.kts
val (first, second, last) = getFullName()
println("$first $middle $last") // John Quincy Adams

네 줄의 코드를 명확한 한 줄의 코드로 바꿨다. 이게 가능한 이유는 Triple 클래스가 구조분해를 위한 특별한 메소드를 지니고 있기 때문이다. 객체의 속성들이 구조분해되는 순서는 객체의 생성자가 객체를 초기화할 때 속성을 만드는 순서와 동일하다.

리턴된 객체 중 속성 하나가 필요없다면 언더스코어(_)를 사용해 스킵할 수 있다.

// functions/destructuring.kts
val (first, _, last) = getFullName()
println("$first $last") // John Adams

// val (_, _, last) = getFullName() 도 가능하다.
// val (_, middle) = getFullName() 도 가능하다.

리턴 타입이 데이터 클래스인 경우 구조분해를 사용할 때는 구조분해를 Map 자료구조의 key와 value를 추출해 내는 용도로 사용할 수도 있다.

정리

  • 코틀린은 사용자가 메소드를 만들도록 강요하지 않는다. 코틀린에서는 개발자가 최상위 함수(top-level functions)도 만들 수 있다. 이런 점이 코틀린으로 개발을 할 때 Java에 비해 좀 더 많은 디자인 선택사항을 제공한다.

  • 애플리케이션이 반드시 객체로 이루어질 필요가 없다. 애플리케이션을 함수로 구성 가능하다. 이런 점들은 개발자가 절차적, 객체지향적, 함수형 코드 중 아무거나 상황에 맞게 선택이 가능하도록 한다.

  • 컴파일러는 단일 표현식이면서 블록이 없는 함수의 경우 리턴타입을 추론해준다. 파라미터를 정의할 땐 항상 타입이 필요하다.

  • 코틀린의 기본인자 기능은 함수를 확장하기 쉽게 해준다. 그리고 함수를 오버로드하는 일을 줄여준다.

  • vararg는 타입 안정성을 제공하면서 여러 개의 인자를 넘기는 것을 아주 유연하게 해준다.

  • 스프레드 연산자는 vararg 파라미터에 배열을 넘기는 것을 쉽게 만들어준다.

  • 명시적 인자를 사용하는 것은 코드의 가독성을 높여준다. 명시적 인자를 사용한다면 코드 자체가 문서화된다.

  • 구조분해는 코드의 방해요소를 줄여주고, 코드를 매우 간결하게 만들어 준다.

Last updated