본문 바로가기
프로그래밍/코틀린

코틀린 스터디 - 3장.함수의 정의와 호출

by 직장인 투자자 2021. 12. 6.

3.1 코틀린에서 컬렉션 만들기

val set = hashSetOf(1, 7, 53)
val list = arrayListOf(1, 7, 53)
val map = hashMapOf(1 to "one", 7 to "seven", 53 to "fifty-three")

>>> println (set.javaClass)
class java.util.HashSet
>>> println (list.javaClass)
class java.util.ArrayList
>>> println (map.javaClass)
class java.util.HashMap

코틀린이 새로운 컬렉션을 사용하지 않고 자바 컬렉션 기능을 사용한다.
자바 개발자들이 기존 컬렉션을 활용할 수 있다는 뜻이다.
자바 코드와 코틀린 코드 간의 상호작용하기가 쉽다. 다만 코틀린에서 더 많은 기능을 제공한다.

>>> val strings = listOf("first", "second", "fourteenth")
>>> println(string.last())
fourteenth

>>> val numbers = setOf(1, 14, 2)
>>> println(numbers.max())
14

3.2 함수를 호출하기 쉽게 만들기

>>> val list = listOf(1, 2, 3)
>>> println(list)
[1, 2, 3]

자바 컬렉션에는 기본으로 toString 구현이 들어있다. 기본 구현과 달리
(1; 2; 3;) 처럼 출력 형태를 변경하기 위해서는 외부 라이브러리 (Apach Commons)를 사용하거나 직접 로직을 구현해야 했다.
하지만 코틀린에서는 이런 요구사항을 처리할 수 있는 함수가 표준 라이브러리에 이미 포함되어 있다.

joinToString()

fun <T> joinToString( 
   collection: Colletion<T>,
   separator: String,
   prefix: String, 
   postfix: String
) : String { 
  val result = StringBuilder(prefix)
  for((index, element) in collection.withIndex()) { 
    if(index > 0) result.append(separator)
    result.append(element) 
  }
  result.append(postfix)
  return result.toString()
}

>>> val list = listOf(1, 2, 3)
>>> println(joinToString(list, "; ", "(", ")") 
(1; 2; 3;)

잘 동작하는 함수이다. 선언 부분과, 호출하는 문장을 덜 번잡하게 만들어 보자.

3.2.1 이름 붙인 인자

joinToString(collection, " ", " ", ".")
인자로 전달하는 각 문자열이 어떤 역할을 하는지 구분하기 어렵다.
시그니처를 매번 확인해야 한다. 자바 코딩 스타일에서는 파라미터 이름을 주석에 넣으라고 하기도 한다.
코틀린 스타일에서는 더 잘할 수 있다.

> 자바스타일
joinToString(collection, /* separator */ " ", /* prefix */ " ", /* postfix */ ".");

> 코틀린 스타일
joinToString(collection, separator = " ", perfix = " ", postfix = ".")

코틀린으로 작성한 함수를 호출할 때는 함수에 전달하는 인자 중 일부(또는 전부)의 이름을 명시할 수 있다.
호출 시 인자 중 어느 하나라도 이름을 명시하고 나면 혼동을 막기 위해 그 뒤에 오는 모든 인자는 이름을 꼭 명시해야 한다.

3.2.2 디폴트 파라미터 값

자바에서는 일부 클래스에서 오버 로딩한 메서드가 너무 많아진다는 문제가 있다.
이런 메서드들은 하위 호환성을 유지하거나 API 사용자에게 편의를 더하는 등의 여러 가지 이유로 만들어진다.
하지만 중복이라는 결과는 같다.
코틀린에서는 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있으므로 이런 오버로드 중 상당수를 피할 수 있다.
joinToString을 개선해보자.

joinToString()

fun <T> joinToString( 
   collection: Colletion<T>,
   separator: String = ", ",
   prefix: String = "", 
   postfix: String = ""
) : String { 
  val result = StringBuilder(prefix)
  for((index, element) in collection.withIndex()) { 
    if(index > 0) result.append(separator)
    result.append(element) 
  }
  result.append(postfix)
  return result.toString()
}

>>> joinToSTring(list, ", ", "", "")
1, 2, 3
>>> joinToSTring(list)
1, 2, 3
>>> joinToSTring(list, "; ")
1; 2; 3

단순 호출하려면 함수를 선언할 때와 같은 순서로 인자를 지정해야 한다. 그런 경우
일부를 생략하면 뒷부분의 인자들이 생략된다. 이름 붙은 인자를 사용하는 경우에는 인자 목록의 중간에 있는 인자를 생략하고,
지정하고 싶은 인자를 이름을 붙여서 순서와 관계없이 지정할 수 있다.

>>> joinToString(list, postfix = ";", prefix= "# ")
# 1, 2, 3;
  • 디폴트 값과 자바
    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);

자바에서 코틀린 코드를 사용할 때 그 코틀린 함수가 디폴트 함수로 선언되어 있다고 하더라도
사용할 때는 모든 변수를 전달해야 한다. 이때 @JvmOverloads 를 붙여주면 컴파일러가 자바 오버 로딩 메서드를 추가해준다.

3.2.3 정적인 유틸리티 클래스 없애기: 최상위 함수와 프로퍼티

자바에서는 모든 코드를 클래스의 메서드로 작성해야 한다. 이때
포함시키고 싶은 코드가 클래스 안의 특정 메서드라고 하더라도 다른 곳에서 사용하려면 그 클래스를 포함하여야 한다.
Utils의 클래스가 그 예이다.
코틀린에서는 이런 무의미한 클래스가 필요 없다. 함수를 직접 소스 파일의 최상위 수준, 모든 다른 클래스의 밖에 위치시킬 수 있다.
이렇게 하면 import시 무의미한 클래스 명이 앞에 붙지 않아도 된다.

3.3 joinToString() 함수를 최상위 함수로 선언하기

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

JVM이 클래스 안에 들어있는 코드만을 실행할 수 있기 때문에 컴파일러는 이 파일을 컴파일할 때 새로운 클래스를 정의해 준다.

/* 자바 */
package strings;
public class JoinKt { 
   public static String joinToString(...) { ... }
}

코틀린 컴파일러가 생성하는 클래스의 이름은 최상위 함수가 들어있던 코틀린 소스파일의 이름과 대응한다.
코틀린 파일의 모든 최상위 함수는 이 클래스의 정적인 메서드가 된다.

  • 파일에 대응하는 클래스의 이름 변경하기
    코틀린 최상위 함수가 포함되는 클래스의 이름을 변경하고 싶다면 @JvmName 어노테이션을 추가하면 된다.
    @file:JvmName("StringFunctions")    // 클래스 이름을 지정하는 어노테이션  

최상위 프로퍼티

함수와 마찬가지로 프로퍼티도 파일의 최상위 수준으로 놓을 수 있음.

var opCount = 0 
fun performOperation() { 
   opCount++
}

fun reportOperationCount() {
   println("Operation performed $opCount times");
}

정적 필드에 저장

val UNIX_LINE_SEPARATOR = "\n"

최상위 프로퍼티도 다른 모든 프로퍼티처럼 접근자 메서드(게터, 세터)를 통해 자바 코드에 노출된다.
코틀린에서는 상수로 보이는데, 실제로는 게터를 사용해야 한다면 자연스럽지 못하다.
자연스럽게 사용하려면 public static final로 컴파일되도록 해야 한다.
const 변경자를 추가하면 public static final 필드로 컴파일하게 만들 수 있다, (원시 타입과 String타입의 프로퍼티만 허용)

const val UNIX_LINE_SEPARATOR = '\n' -> public static final String UNIX_LINE_SEPARATOR = "\n"

3.3 메서드를 다른 클래스에 추가: 확장 함수와 확장 프로퍼티

확장 함수는 어떤 클래스의 멤버 메서드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수다.

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

확장 함수를 만들려면 추가하려는 함수 이름 앞에 그 함수가 확장할 클래스의 이름을 덧붙이기만 하면 된다.
클래스 이름을 수신 객체 타입이라고 부르며, 확장 함수가 호출되는 대상이 되는 값을 수신 객체 타입이라고 한다.

 

>>> println("Kotlin".lastChar())
n

위와 같이 사용할 수 있다.

  • 캡슐화를 깨지 않는다. (클래스 내부에 정의한 메서드와 달리 확장 함수 안에서는 private, protected 멤버 사용 불가능)

3.3.1 임포트와 확장 함수

확장 함수를 정의하자마자 어디서든 그 함수를 쓸 수 있다면, 임포트 한 후 같은 클래스 내에서
이름이 충돌하는 경우가 자주 생길 수 있다.

import strings.lastChar 

val c = "kotlin".lastChar()
  • 다른 패키지에 속해있는 이름이 같은 함수를 가져와 사용해야 하는 경우 이름을 바꿔서 임포트 하면 이름 충돌을 막을 수 있음
    import strings.lastChar as last
    

val c = "Kotlin".last()


## 3.3.2 자바에서 확장 함수 호출
내부적으로 확장 함수는 *수신 객체를 첫 번째 인자로 받는 정적 메소드이다. 
따라서 자바에서 확장 함수를 사용하려면, 정적 메소드를 호출하면서 첫 번째 인자로 수신 객체를 넘기기만 하면된다. 
```java
char c = StringUtilKr.lastChar("java");    

3.3.3 확장 함수로 유틸리티 함수 정의

fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = ""
) : String {
    val result = StringBuilder(prefix)
    for ((index, element) in this.withIndex())
    if (index >) result.append(separator)
    result.append(element)
    }
    result.,append(postfix)
    return result.toString()
}
>>> val list = listOf(1, 2, 3)
>>> println(list.joinToString(separator = "; ", 
...      prefix = "(", postfix = ")"))
(1;2;3;)

이제 joinToString을 마치 클래스의 멤버인 것처럼 호출할 수 있다.

>>> val list = arrayListOf(1, 2, 3)
>>> println(list.joinToString(" "))

제네릭을 사용하지 않고 문자열 컬렉션에 대해서만 호출할 수 있도록 하려면 다음과 같이 변경하면 된다.

fun Collection<String>.join(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = ""
) = joinToString(separator, prefix, postfix)

>>> println(listOf("one", "two", "eight").join(" "))
one two eight

이 함수를 객체의 리스트에 대해 호출할 수는 없다.

>>> listOf(1, 2, 8).join()
Error: Type mismatch: inferred type is List<Int> but Collection<String> was expected.

3.3.4 확장 함수는 오버라이드 할 수 없다

일반적인 멤버 함수 오버라이드 하기

open class View {
    open fun click() = println("View clicked")
}

class Button: View() {
    override fun click() = println("Button clicked")
}
>>> val view: View = Button()
>>> view.click()     // view 에 저장된 값의 실제 타입에 따라 호출할 메소드가 결정된다.
Button clicked

확장 함수는 클래스 밖에 선언되기 때문에 수신 객체로 지정한 변수의 정적 타입에 의해 어떤 함수가
호출될지 결정된다.

fun View.showOff() = println("I'm a view!")
fun Button.showOff() = println("I'm a button!")
>>> val view: View = Button()
>>> view.showOff()    // 확장 함수는 정적으로 결정됨 
I'm a view!

확장 함수를 첫 번째 인자가 수신 객체인 정적 자바 메서드로 컴파일한다는 이유로 위와 같이 동작한다.
자바도 호출할 정적 static 함수를 같은 방식으로 정적으로 결정한다.

>>> View view = new Button();
>>> ExtensionsKt.showOff(view);
I'm a view!
  • 클래스를 확장한 함수와 멤버 변수의 이름과 시그니처가 같다면, 멤버 함수가 우선한다.

3.3.5 확장 프로퍼티

확장 프로퍼티를 사용하면 기존 클래스 객체에 대한 프로퍼티 형식의 구문으로 사용할 수 있는 API를 추가할 수 있다.
하지만 상태를 가질 수 없다.

val String.lastChar: Char
    get() = get(length -1)
  • 게터를 필수적으로 정의해야 한다.
  • 상태를 가질 수 없으므로 초기화가 불가능하다.
var StringBuilder.lastChar: Char
    get() = get(length - 1)    // 프로퍼티 게터
    set(value: Char) {
        this.setCharAt(length - 1, value)    // 프로퍼티 세터 
    }
>>> println("Kotlin".lastChar)
n

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

3.4 컬렉션 처리: 가변 길이 인자, 중위 함수 호출, 라이브러리 지원

3.4.1 자바 컬렉션 API 확장

>>> val strings: List<String> = listOf("first", "second", "fourteenth")
>>> strings.last
fourteenth
>>> val numbers: Collection<Int> = setOf(1, 14, 2)
>>> numbers.max()
14

위처럼 처음 자바 API보다 더 많은 기능을 코틀린이 제공 가능했던 이유는 확장 함수 개념 때문이다.

3.4.2 가변 인자 함수: 인자의 개수가 달라질 수 있는 함수 정의

val list = listOf(2, 3, 5, 7, 11)

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

코틀린의 가변 길이 인자도 자바와 비슷하다. (vararg) 문법이 조금 달라, 자바와 달리 타입 뒤에 ... 를 붙이는 대신 vararg 를 붙인다.
이미 배열에 들어있는 원소를 가변 길이 인자로 넘길 때도 코틀린과 자바 구문이 다르다.

fun main(args: Array<String>) {
    val list = listOf("args: ", *args)
    println(list)
}

* (스프레드) 연산자를 붙이면 된다.

3.4.3 값의 쌍 다루기: 중위 호출과 구조 분해 선언

val map = mapOf(1 to "one", 7 to "seven", 53 to "fifty-three")

to 라는 단어는 코틀린 키워드가 아니라, 중위 호출 방식으로 일반 메서드를 호출한 것이다.

1.to("one")
1 to "one"

위의 두 호출은 동일하다.

  • 인자가 하나뿐인 일반 메서드나 인자가 하나뿐인 확장 함수에 중위 호출을 사용할 수 있다.
  • 함수를 중위 호출에 사용하게 허용하고 싶으면 infix 변경자를 함수 선언 앞에 추가해야 한다.
infix fun Any.to(other: Any) = Pair(this, other)

val (number, num) = 1 to "one"   <- 즉시 초기화 가능.
  • 구조 분해 선언을 이용해 두 변수를 즉시 초기화할 수 있다.
  • val (number, name) = 1 to "one"
  • 루프에서도 구조 분해 선언을 활용할 수 있다.
  • for ((index, element) in collection.withIndex()) { println("$index: $element") }

3.5 문자열과 정규식 다루기

코틀린과 자바 문자열은 같다. 특별환 변환 및, 래퍼가 생성되지 않는다.
코틀린에서 다양한 확장 함수를 제공하고 있다.

3.5.1 문자열 나누기

"12.345-6.A".split(".")

자바에서 위 코드의 호출 결과는 [12, 345-6, A] 가 될 것 같지만 아니다.
String.split("") 은 정규표현식으로 해석하기 때문이다.
코틀린에서는 다른 여러 가지 조합의 파라미터를 받는 split 확장 함수를 제공한다.

>>> println("12.345-6.A".split("\\.|-".toRegex())) <- 정규식을 명시적으로 만든다. 
[12, 345, 6, A]
>>> println("12.345-6.A".split(".", "-")) <- 여러 구분 문자열을 지정한다.
[12, 345, 6, A]

3.5.2 정규식과 3중 따옴표로 묶은 문자열

"Users/yole/kotlin-book/chapter.adoc" 의 문자열을 경로 파싱 해보자.

쌩으로 구현하기

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.adhoc")
Dir : /Users/yole/kotlin-book, name : chapter, ext: adhoc

정규식으로 구현하기

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

3중 따옴표 문자열을 사용해 정규식을 썼다. 3중 따옴표 문자열에서는 역슬래시를 포함한 어떤 문자도 이스케이프 할 필요가 없다.

3.5.3 여러 줄 3중 따옴표 문자열

3중 따옴표 문자열을 문자열 이스케이프를 피하기 위해서만 사용하지는 않는다.
3중 따옴표 문자열에는 줄 바꿈을 표현하는 아무 문자열이나 그대로 들어간다.
프로그램 텍스트를 쉽게 문자열로 만들 수 있다.

val kotlinLogo = """|   //
                   .|  //
                   .| /  \"""
>>> println(kotilinLogo.trimMargin("."))
|   //
|  //
| /  \

3중 따옴표 문자열 내에서는 이스케이프를 할 수 없기 때문에 $ 를 사용해야 한다면 다음과 같이 한다.
val price = """${'$'}99.9"""

3.6 코드 다듬기 : 로컬 함수의 확장

메서드 추출 방법은 좋은 리팩터링 방법이지만, 함수가 많아져서 난해해진다는 단점이 있다.
코틀린에서는 함수에서 추출한 함수를 원 함수 내부에 중첩시킬 수 있다.

코드 중복을 보여주는 예제

class User(val id: Int, val name: String, val address: String)
fun saveUser(user: User) {
  if (user.name.isEmpty()) {
    throw IllegalArgumentException(
        "Can't save user ${user.id}: empty Name")
    }
  if (user.adress.isEmpty()) {
    throw IllegalArgumentException(
        "Can't save user ${user.id}: empty Address")
    }
  }
}

로컬 함수를 사용해 코드 중복 줄이기

class User(val id: Int, val name: String, val address: String)
fun saveUser(user: User) {
  fun validate(value: String, fieldName: String) {
    if (value.isEmpty()) {
      throw IllegalArgumentException(
          "Can't save user ${user.id}: empty $filedName")     
    }
  }
  validate(user.name, "Name")
  validate(user.address, "Address")
}
  • 로컬 함수는 자신이 속한 바깥 함수의 모든 파라미터와 변수를 사용할 수 있다.

로컬 함수에서 바깥 함수의 파라미터 접근하기

class User(val id: Int, val name: String, val address: String)
fun saveUser(user: User) {
  fun validate(value: String, fieldName: String) {
    if (value.isEmpty()) {
      throw IllegalArgumentException(
          "Can't save user ${user.id}: empty $filedName")    // 바깥 함수의 파라미터에 직접 접근할 수 있다.
    }
  }
  validate(user.name, "Name")
  validate(user.address, "Address")
}

검증 로직을 User 클래스를 확장한 함수로 만들 수도 있다.

class User(val id: Int, val name: String, val address: String)
fun User.validateBeforSave() {
  fun validate(value: String, fieldName: String) {
    if (value.isEmpty()) {
      throw IllegalArgumentException(
          "Can't save user ${id}: empty $filedName")   
    }
  }
  validate(user.name, "Name")
  validate(user.address, "Address")
}

fun saveUser(user: User) {
  user.validateBeforeSave()  
}
반응형

댓글