Kotlin - 03. 함수 정의와 호출

Kotlin 함수에 대해서…

Kotlin 의 함수 관련된 다양한 API 를 살펴보고자 한다. 실제 꽤 많은 내용을 포함하고 있다.

  • 컬렉션 Collection 만들기
  • 함수 호출
    • 이름 붙인 인자
    • Default 파라미터 값
    • 최상위 함수와 최상위 프로퍼티 const
  • 확장 extensions 함수 & 프로퍼티
  • 컬렉션 처리 : 가변 길이 인자, 중위 함수 호출
  • 문자열과 정규식
  • 로컬 함수와 확장

Collection 만들기

Kotlin 에서는 Java 와 달리 Collection 생성을 손쉬운 방식으로 제공하고 있다.
(아래 방식 외 다양한 함수를 통해 Collection 생성이 가능하다.)

// List 생성
val list = listOf(1, 2, 3)
// Map 생성
val map = mapOf("1" to "One", "2" to "Two", "3" to "Three")

함수 호출

함수를 호출하는 방식을 단순히 파라미터만 넘기는 Java 와 달리 개발자가 코딩 상에 실수는 줄이기 위한 다양한 방식을 제공해주는 Kotlin 이다.

이름 붙인 인자

fun <T> joinToString(
    collection: Collection<T>,
    separator: String,
    prefix: String,
    postfix: String
) {
    // collection 의 정보를 문자열로 결합하는 함수
    ...
}

위와 같은 joinToString 함수를 호출한다고 하였을 때, 일반적으로는 joinToString(list, ",", "[", "]") 처럼 지정된 매개 변수 순서대로 값을 채워줘야했다. 이런 코딩은 함수의 매개 변수 순서를 확인하고 맞게 제대로 넘겨주는지 확인해야하며, 이런 과정에서 실수가 발생될 수 있는 여지가 있다.
그래서 Kotlin 에서는 코딩상 오류 범하기 쉬운 이 함수 호출 방식에 이름을 붙인 인자 방식을 활용하였다.

fun main() {
    val list = listOf(1, 2, 3)
    val str = joinToString(
      collection = list,
      separator = ", ",
      prefix = "[",
      postfix = "]"
    )
    println(str)	// [1, 2, 3]
}

Java 에서는 Builder Pattern 방식으로 이런 부분을 보완해주고 있고, IDE 기능을 통해 충분히 보완이 가능하다.

Default 파라미터 값

함수의 매개 변수의 기본 Default 값 설정을 이용하여 굳이 인자를 넣어주지 않더라도 함수 호출이 가능하다.
이런 부분은 무자비하게 늘어나는 오버로딩 overloading 메소드를 방지할 수 있다.

fun <T> joinToString(
    collection: Collection<T>,
    separator: String = ", ",
    prefix: String = "[",
    postfix: String = "]"
) {
    // collection 의 정보를 문자열로 결합하는 함수
    ...
}

fun main() {
    val list = listOf(1, 2, 3)
    println(joinToString(list)) 		// [1, 2, 3]
    println(joinToString(list, " | "))	 // [1 | 2 | 3]
    println(joinToString(list, ", ", "", ""))   // 1, 2, 3
}

Java@JvmOverloads 라는 어노테이션을 함수에 추가하면, 매개 변수별로 오버로딩 함수를 자동 생성해준다.

최상위 함수와 프로퍼티

실제 Java 프로그램을 개발하면서 정적 static 인 클래스나 함수를 생성하여 유틸리티 클래스로 많이 활용한다.
이런 유틸리티 클래스를 Kotlin 에서는 무의미한 클래스로 판단하고, 유틸리티 함수를 별로 클래스 아래 포함시키지 않기 위해 최상위 함수 사용을 지원한다.

최상위 함수
// 파일명 : Join.kt
package strings

fun joinToString(...) : String { ... }

Join.kt 라는 파일 안에는 joinToString 함수만 포함하고 있고, 클래스는 존재하지 않는다. Kotlin 은 이런 형식을 함수도 컴파일 에러는 발생하지 않는다. 위 파일이 Java 로 변환되면 아래와 같다.

package strings;

public class JoinKt {
    public static String joinToString(...) { ... }
}

최상위 함수를 통해 무의미한 클래스를 호출하는 부분을 줄일 수 있게 되면서 가독성을 높일 수 있는 부분이 있지만, 한편으로는 코드 분석이 어려워질 수도 있겠다라는 생각이 든다.

최상위 함수가 포함된 클래스명을 바꾸거나 지정하고 싶다면, @JvmName 어노테이션을 활용하면 가능하다.

최상위 프로퍼티

개발 도중 정적인 상수를 사용하고 싶은 경우, 간편하게 변경자 하나로 생성 가능하다.

const val ZERO = 0

const 변경자로 선언된 변수는 최상위 함수처럼 클래스안에 있지 않아도 정적인 최상위 프로퍼티로 선언이 된다.
const 변경자는 public static final 로 변환된다.


확장 extensions 함수 & 프로퍼티

확장 함수 & 프로퍼티의 개념은 Kotlin 에서 Java 의 자연스럽게 통합하고 운용할 수 있는 핵심 목표를 충족시켜주는 역할을 하였다.
Java에서 제공하는 APIKotlin으로 직접 변환하지 않고도 사용할 수 있게 하고, 재작성하지 않고도 Kotlin 에서 제공하는 여러 편리한 기능을 사용하기 위해 확장 함수가 나왔다고 한다.

Kotlin의 확장 함수는 어떻게 JavaAPI를 통합하다는 걸까?

아래 코드는 JavaCollection 객체에서 Kotlin의 추가된 함수를 활용한 예제이다.

> val list = listOf(1, 2, 3)
>
> println(list.last())	// 3
> println(list.max())	// 3
> 


어떻게 기존 JavaCollection 객체의 함수를 추가할 수 있었을까? 바로 확장 함수 때문이다.

확장 함수

확장 함수는 아래와 같은 기본 형식을 가지고 있다.
fun [수신 객체 타입].함수명() { this // 수신 객체 }
확장 함수가 호출되는 대상이 되는 값(객체)을 수신 객체 (this) 라고 부르며, 생략도 가능하다.

package strings

fun String.lastChar() : Char = this.get(this.length - 1)

// this 생략
fun String.lastChar() : Char = get(length - 1)

Java에서 Kotlin의 확장 함수 사용하기

> char c = StringUtilKt.lastChar("Java");
> 
확장 함수 주의할 점
  • 확장 함수도 일반적인 클래스의 변수와 메소드를 사용할 수 있지만, 캡슐화를 깰 수는 없다. 그 의미는 클래스 내부에서만 사용 가능한 privateprotected 멤버 변수나 함수는 사용할 수 없음을 뜻한다.
  • 확장 함수를 선언하였더라도, 모든 클래스에서 바로 사용하는 것이 아니라 import 헤야만 사용 가능하다.
  • 동일한 확장 함수명을 다른 파일에서 생성하게 되면 충돌이 발생할 수 있다. 하지만, 별도 import 를 받아서 사용한다면 가능하다.
  • 확장 함수는 오버라이드가 불가하다.

확장 프로퍼티

확장 프로퍼티는 이름과 다르게 실제로는 아무 상태(값)도 저장할 수 없다. 하지만 확장 프로퍼티를 활용하여 좀 더 간결한 코드를 작성 가능하다.

val String.lastChar: Char
    get() = get(length - 1)

println("Kotlin".lastChar)	// n

위 코드처럼 String 객체의 확장 프로퍼티 lastChar를 생성하였다. 하지만 확장 프로퍼티에는 상태를 저장할 수 있는 뒷받침하는 필드 backing field 가 없기 때문에, getter 접근자 메소드가 필요하고, 필요한 경우setter 를 통해서 변경 가능한 확장 프로퍼티도 생성 가능하다.

var StringBuilder.lastChar: Char
    get() = get(length - 1)
    set(value: Char) {
      this.setCharAt(length - 1, value)
    }

val sb = StringBuilder("Hello Kotlin?")
sb.lastChar = '!'
println(sb)		// Hello Kotlin!

컬렉션 처리 : 가변 길이 인자, 중위 함수 호출

가변 길이 인자 vararg

listOf() 와 같은 함수의 매개 변수를 가변 길이의 인자로 받는다. Java 에서는 타입 뒤에 ... 활용하여 가변 길이 인자를 받았지만, Kotlin 에서는 vararg 변경자를 활용하면 된다.

fun listOf<T>(vararg values: T): List<T> { ... }

중위 함수 호출

처음 Kotlin 으로 개발할 때, map 객체를 선언하다보면 의아한 문법이 보이게 된다. 바로 mapOf() 함수이다.

val map = mapOf(1 to "One", 2 to "Two", 3 to "Three")

mapOf에서 toKotlin의 키워드가 아니라 일반 메소드이다. 이 코드는 중위 함수이라는 특별한 방식으로 일반 메소드를 호출하는 코드이다. 1 to "One"1.to("One") 과 동일하다.
중위 함수는 일반 메소드도 가능하지만, 확장 함수도 가능하며, infix 라는 변경자를 이용하여 선언 가능하다. 다음은 to() 메소드의 구현체이다.

infix fun Any.to(other: Any) = Pair(this, other)

구조 분해 선언
구조 분해에 대해서는 7장에서 좀 더 자세하게 설명할 예정이다. 여기서는 간단하게 사진으로 개념 이해만 하면 될 것 같다.
kotlin-destructured


문자열과 정규식

실제 프로그램 개발하면서 문자열을 다루는 코딩은 많이 있는 일이다. 또한 정규식을 활용한다면 문자열을 분리하거나 특정 문자를 추출 또는 거르는 작업을 수월하게 할 수 있다. 하지만 이런 과정은 실수가 많이 발생할 수 있고, 많은 테스트 또는 디버깅을 통해서 제대로 된 결과를 도출해낸다.

그런 부분도 Kotlin 에서는 보완하기 위해 문자열 즉 String 객체에 대한 다양한 확장 함수를 지원해주고 있다. 개발자 누구나 한번쯤은 해봤을 파일명 파싱하는 코드로 대략 Kotlin 에서 지원하는 String 객체의 확장 함수를 살펴보고자 한다.

fun parsePath(path: String) {
    val directory = path.substringBeforeLast("/")
    val fullName = path.substringAfterLast("/")
    val fileName = fullName.substringBeforeLast(".")
    val extension = fullName.substringAfterLast(".")
    print("Dir : $directory, name : $fileName, ext: $extension")
}

정규식 다루기

Kotlin 의 확장 함수를 통해서 비교적 간략한 파일 경로를 분리하는 작업을 할 수 있었다. 정규식을 통해서 더 간략하고 강력한 문자열 분리 작업을 할 수도 있지만, 책에서도 나온 것처럼 정규식은 알아보기 힘들다라는 단점이 있다. (실제로 정규식으로 개발했더라도, 다음에 코드를 보면 한번에 이해하기 쉽지 않는 경험이 많다.)
그래도 kotlin 에서는 정규식도 어느정도 개발자들의 편의를 위해 개선한 부분이 있으니 살펴보고자 한다.

fun parsePath(path: String) {
    val regex = """(.+)/(.+)\.(.+)""".toRegex()
    val matchResult = regex.matchEntire(path)
    if (matchResult != null) {
        val (directory, fileName, extension) = matchResult.destructured
        print("Dir : $directory, name : $fileName, ext: $extension")
    }
}

Kotlin 의 정규식 사용법을 보면 독특한 문법을 발견하게 된다. 바로 """ 부분이다. 3중 따옴표 문자열을 활용한 정규식 기법은 역슬래시(\)를 포함한 어떤 문자도 이스케이프 excape 할 필요가 없다.

3중 따옴표 문자열을 활용하면 정규식 개발외에도 여러 줄의 문자열을 활용한 코딩에도 유용하게 사용된다고 한다. 자세한 내용은 깔끔하게 정리가 되어있는 투덜이님의 블로그로 대체한다.

투덜이의 리얼 블로그 - 코틀린 삼중따옴표, 정규식, 문자열, 중첩함수 , 확장함수


로컬 함수와 확장

많은 개발자가 좋은 코드로 판단하고 중시하는 원칙 중 하나가 중복이 없는 것이라고 생각한다. (DRY, Don’t Repeat Yourself!) 그래서 그 원칙을 수행하기 위해서 개발했던 코드도 계속 리팩토링을 하고 메소드를 추출하고 나누는 작업을 하게 된다.

Kotlin 에서는 DRY 원칙을 최대한 지키기 위해 로컬 함수라는 기능을 개발한 것이 아닐까싶다.
아래 코드는 최종적으로 확장 함수와 로컬 함수를 활용한 리팩토링 결과라고 할 수 있다.

class User(
    val id: Int,
    val name: String,
    val address: String
)

fun User.validationBeforeSave() {
    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.validationBeforeSave()
    // Saved user
}

로컬 함수는 자신이 속한 Outer 함수의 모든 파라미터와 변수를 사용할 수 있다. 그래서 바로 User 객체의 변수를 활용할 수 있다.

이런 함수의 지원은 확실히 중복 코드를 줄일 수 있는 큰 장점이 될 것 같다. 하지만 무자비하게 확장 함수와 로컬 함수를 활용한다면 가독성은 다소 떨어질 수도 있을 것 같다.
좋은 코드라면 물론 중복이 없는 것도 중요하지만, 다른 사람이 처음 접하더라도 이해할 수 있으며, 유지 보수도 쉬워야한다고 생각한다.


출처