자바 개발자를 위한 코틀린 입문(Java to Kotlin Starter Guide)
강의를 들으며 내용 정리
코틀린의 이모저모
Type Alias와 as import
긴 이름의 클래스 혹은 함수 타입이 있을 때, 축약하거나 더 좋은 이름을 쓰고 싶다면?
fun filterFruits(fruits: List<Fruit>, filter: (Fruit) -> Boolean) {
// (Fruit) -> Boolean이 너무 길어
}
이럴 때는 typealias를 사용할 수 있다.
typealias FruitFilter = (Fruit) -> Boolean
fun filterFruits(fruits: List<Fruit>, filter: FruitFilter) {
}
이름 긴 클래스를 컬렉션에 사용할 때도 간단히 줄일 수 있다.
data class UltraSuperGuardianTribe(
val name: String,
)
typealias USGTMap = Map<String, UltraSuperGuardianTribe>
다른 패키지의 같은 이름 함수를 동시에 가져오고 싶다면?
package com.lannstark.lec19.a
fun printHelloWorld() {
println("Hello World A")
}
package com.lannstark.lec19.b
fun printHelloWorld() {
println("Hello World B")
}
이럴 때는 as import를 사용할 수 있다.
import com.lannstark.lec19.a.printHelloWorld as printHelloWorldA
import com.lannstark.lec19.b.printHelloWorld as printHelloWorldB
fun main() {
printHelloWorldA()
printHelloWorldB()
}
구조분해와 componentN 함수
구조분해는 복합적인 값을 분해하여 여러 변수를 한 번에 초기화하는 것을 말한다.
data class Person(
val name: String,
val age: Int,
)
fun main() {
val person = Person("홍길동", 100)
val (name, age) = person // 구조분해
}
이렇게 할 수 있는 이유는 data class가 componentN이란 함수도 자동으로 만들어주기 때문이다.
componentN은 클래스에서 N번째 필드를 출력해주는 함수이다.
fun main() {
val person = Person("홍길동", 100)
val name = person.component1()
val age = person.component2()
}
이전 코드와 같은 코드이다.
Q: 만약 data class가 아닌데 구조분해를 사용하고 싶다면?
A: componentN 함수를 직접 구현해줄 수 있다.
class Person(
val name: String,
val age: Int,
) {
operator fun component1(): String {
return this.name
}
operator fun component2(): Int {
return this.age
}
}
componentN은 연산자 속성을 가지기 때문에 operator를 붙여줘야 한다.
+) 추가로 이 문법 역시 구조분해이다.
val map = mapOf(1 to "A", 2 to "B")
for ((key, value) in map.entries) {
}
Jump와 Label
forEach는 코틀린에서 컬렉션을 반복 처리 하는데 사용하는 함수이다.
val numbers = listOf(1, 2, 3, 4, 5)
numbers.map { number -> number + 1 }
.forEach { number -> println(number) }
일반적인 for, while 문에서는 break, continue가 가능하다.
그러나, 코틀린의 forEach에서는 불가능하다.
numbers.forEach {number ->
if (number == 3) continue // 컴파일 에러
println(number)
}
Q: forEach문과 함께 break, continue를 꼭 쓰고 싶다면?
<break>
run {
numbers.forEach { number ->
if (number == 3) {
return@run
}
println(number)
}
}
run을 종료함으로써, forEach 전체를 종료한다.
<continue>
run {
numbers.forEach { number ->
if (number == 3) {
return@forEach
}
println(number)
}
}
현재 forEach(반복)를 중단함으로써, 다음 step으로 넘어간다.
두 방법 모두 실제 break, continue가 아닌 그렇게 동작하는 것처럼 코딩하는 방식이다.
forEach 함수의 목적은 주어진 동작을 컬렉션의 모든 요소에 적용하는 것이다.
따라서, break, continue와 같은 제어 흐름이 필요한 경우, 다른 반복 방식을 사용하는 것을 권장한다.
코틀린에는 라벨이라는 기능이 있다.
특정 expression에 라벨이름@을 붙여 하나의 라벨로 간주하고, break, continue, return 등을 사용하는 기능이다.
loop@ for (i in 1..100) {
for (j in 1..100) {
if (j == 2) {
break@loop // loop 라벨 종료
}
print("${i} ${j}")
}
}
여기서 break는 가장 가까운 반복문이 아닌, loop@ 라벨이 있는 반복문을 종료한다.
+) 물론 라벨을 사용한 Jump는 사용하지 않는 것을 권장한다.
코드의 흐름이 위아래로 많이 움직일수록 복잡도는 증가하고, 유지보수는 힘들어진다.
TakeIf와 TakeUnless
이런 함수가 있다.
fun getNumberOrNull(): Int? {
return if (number <= 0) {
null
} else {
number
}
}
코틀린에서는 method chaining을 위한 특이한 함수를 제공한다.
fun getNumberOrNullV2(): Int? {
return number.takeIf {it > 0}
}
주어진 조건을 만족하면 그 값이, 그렇지 않으면 null이 반환된다.
fun getNumberOrNullV3(): Int? {
return number.takeUnless {it <= 0}
}
주어진 조건을 만족하지 않으면 그 값이, 그렇지 않으면 null이 반환된다.
코틀린의 scope function
scope function이란 무엇인가?
scope function: 일시적인 영역을 형성하는 함수
이 코드를 일시적인 영역과 함께 리팩토링 해보자.
fun printPerson(person: Person?) {
if (person != null) {
println(person.name)
println(person.age)
}
}
<리팩토링>
fun printPerson(person: Person?) {
person?.let {
println(it.name)
println(it.age)
}
}
let은 scope function의 한 종류이다.
let 함수를 살펴보자.
public inline fun <T, R> T.let(block: (T) -> R): R {
...
return block(this)
}
let은 확장함수이기 때문에, person.let처럼 멤버함수처럼 사용할 수 있었던 것이고,
람다를 파라미터로 받아, 해당 람다를 실행하고, 람다 결과를 반환하는 함수이다.
scope function을 다시 정의하면,
람다를 사용해 일시적인 영역을 만들고, 코드를 더 간결하게 만들거나, method chaining에 활용하는 함수이다.
scope function의 분류
외울 필요는 없고, 그때 그때 찾아서 사용하면 된다.
반환타입/ | it 사용 | this 사용 |
람다의 결과 | let | run |
객체 그 자체 | also | apply |
val value1: Int = person.let {
it.age
}
val value2: Int = person.run {
this.age
}
val value3: Person = person.also {
it.age
}
val value4: Person = person.apply {
this.age
}
let과 run은 age를 반환하고, also와 apply는 Person을 반환한다.
let과 also는 it를 사용하고, run과 apply는 this를 사용한다.
it: 생략이 불가능한 대신, 다른 이름을 붙일 수 있다.
this: 생략이 가능한 대신, 다른 이름을 붙일 수 없다.
val value1: Int = person.let { p ->
p.age
}
val value2: Int = person.run {
age
}
왜 이런 차이가 발생할까?
대표적으로 let과 run을 보자.
public inline fun <T, R> T.let(block: (T) -> R): R {
...
return block(this)
}
public inline fun <T, R> T.run(block: T.() -> R): R {
...
return block()
}
let은 일반 함수 (T) -> R을 받는다.
따라서, 자기자신(this)을 파라미터로 넣어준다.
실제 함수에서는 파라미터(it)를 사용한다.
run은 확장 함수 T.() -> R을 받는다.
따라서, 파라미터를 넣어줄 필요가 없다.
실제 함수에서는 this를 통해 자기 자신에 접근한다.
추가로 with라는 scope function도 있다.
with(person) {
println(name)
println(this.age)
}
with는 확장함수가 아니다.
this를 사용해 접근하고, this는 생략 가능하다.
언제 어떤 scope function을 사용해야 할까?
let
하나 이상의 함수를 call chain 결과로 호출할 때
strings.map { it.length }
.filter { it > 3 }
.let { lengths -> println(lengths) }
non-null 값에 대해서만 code block을 실행시킬 때
val length = str?.let {
println(it.uppercase())
it.length
}
보통 이 방식으로 많이 사용한다.
일회성으로 제한된 영역에 지역 변수를 만들 때
val numbers = listOf("one", "two", "three", "four")
val modifiedFirstItem = numbers.first()
.let {firstItem ->
if (firstItem.length >= 5) firstItem else "!$firstItem!"
}.uppercase()
println(modifiedFirstItem)
물론 이 부분은 private function 등을 활용하여 depth를 줄일 수도 있다.
run
객체 초기화와 반환 값의 계산을 동시에 해야할 때
val person = Person("홍길동", 100).run(personRepository::save)
객체를 만들어 DB에 바로 저장하고, 그 인스턴스를 활용
혹은 이렇게 생성자에 없는 필드에 대한 값을 지정하고 저장할 수도 있다. (생성 후 처리)
val person = Person("홍길동", 100).run {
hobby = "독서"
personRepository.save(this)
}
+) 물론, 자바에 익숙해져 있다 보니, 이런 식의 코드가 더 친근하다.
val person = personRepository.save(Person("홍길동", 100))
만약 생성 후 처리가 필요하다면 프로퍼티, init block 등을 활용할 수도 있다.
+) 만약 생성자가 복잡하거나 builder 등을 사용하여 많은 필드로 객체를 생성할 때는 run이 더 깔끔한 코드를 만들어낼 수 있다.
apply
객체를 수정하는 로직이 call chain 중간에 필요할 때
fun createPerson(
name: String,
age: Int,
hobby: String,
): Person {
return Person(
name = name,
age = age,
).apply {
this.hobby = hobby
}
}
ex) 어떤 컨벤션, 룰에 따라서 hobby가 생성자에 존재하지 않는데, Test Fixture를 만들어야 할 때
also
객체를 수정하는 로직이 call chain 중간에 필요할 때
mutableListOf("one", "two", "three")
.also { println("four 추가 이전 지금 값: $it") }
.add("four")
자바에 익숙하다면 아래 코드가 더 익숙하다.
val numbers = mutableListOf("one", "two", "three")
println("four 추가 이전 지금 값: $numbers")
numbers.add("four")
나는 이런 코드가 더 편하다.
with
특정 객체를 다른 객체로 변환해야 하는데, 모듈 간의 의존성에 의해 정적 팩토리 혹은 toClass 함수를 만들기 어려울 때
return with(person) {
PersonDto(
name = name,
age = age,
)
}
PersonDto(표현)에 Person(도메인)을 의존하기 어려울 때 등 객체 간에 converting 시 사용할 수 있다.
scope function과 가독성
Q: scope function을 사용한 코드가 그렇지 않은 코드보다 가독성 좋은 코드일까?
A: 이펙티프 코틀린의 예제를 살펴보자.
// 1번 코드
if (person != null && pereson.isAdult) {
view.showPerson(person)
} else {
view.showError()
}
// 2번 코드
person?.takeIf { it.isAdult }
?.let(view::showPerson)
?: view.showError
강의자는 1번 코드가 훨씬 좋은 코드라고 생각한다.
1. 2번 코드는 숙련된 코틀린 개발자만 더 알아보기 쉽다. 어쩌면 숙련된 코틀린 개발자도 잘 이해하지 못할 수도 있다.
2. 1번 코드의 디버깅이 더 쉽다.
3. 1번 코드의 수정도 더 쉽다.
+) 추가로 view::showPerson에서 null을 반환한다면?
2번 코드는 showPerson, showError를 모두 수행할 수도 있는 가능성이 있다.
(만약 테스트코드가 없다면 잡기 힘든 버그가 생길 수 있다)
사용 빈도가 적은 관용구는 코드를 더 복잡하게 만든다.
하지만 scope function을 사용하면 안되는 것도 아니다. 적절한 convention을 적용하면 유용하게 활용할 수 있다.
개인의 선호도, 팀의 코틀린 숙련도 등을 다양한 요인을 고려하여 적절한 convention을 찾는 것이 좋겠다.
실제로 요즘 프로젝트에서 함수형 프로그래밍을 많이 사용하는데, 코드를 이해하는 시간이 오래 걸릴 때가 있다.
특히 코틀린의 경우 null 처리도 해줘야 하기 때문에 더더욱 복잡하다.
명확한 순수함수로 표현하는 코딩 방식이 왜 이해하기 어려운가에 대해 고민해본적이 있다.
1. 우선 내가 함수형 프로그래밍에 익숙하지 않다는 점 + 기존 if ~ else 문에 익숙해져 있다는 점
2. 익숙함을 떠나서 if ~ else에 여러 지역변수를 사용하는 코드가 그냥 보기 쉽다. 이런 코드는 읽다가 중간중간 쉬는 순간이 있다.
함수형 코드는 여러 동작이 끊이지 않고 발생하기 때문에 중간에 사고가 쉬지 못할 때가 있어서 복잡성이 증가한다고 생각한다.
그러나, 강의에서 말한 것처럼 이러한 함수형 방식을 사용하면 안되는 것은 아니다.
앞선 with 예제 처럼 적절하게 사용하면 의존성 문제, 무수한 변수, 메서드를 만드는 문제 등을 극복할 수 있을 때가 많다.
함수형 프로그래밍으로 가독성을 얻을 때도 많다.
앞선 run 예제처럼 무수히 많은 필드를 갖는 객체를 생성할 때, builder를 사용하면서 run으로 후처리를 하면 가독성이 훨씬 좋아진 경험을 했었다. (코드가 가로로 길어지지 않는다)
즉, 함수형 프로그래밍의 장단점을 알려면 적절히 사용할 줄도 알아야 하는데,
무조건 인텔리제이가 권장하는 표현 방식이니까, 코드가 깔끔해지는 느낌이니까 함수형 프로그래밍을 사용하지 않고,
이 코드에 함수형 프로그래밍을 적용함으로써 얻게 되는 나의, 우리 팀의 이점은 무엇일까에 대해 고민하며 개발해야겠다.
'kotlin' 카테고리의 다른 글
[Kotlin] Private primary constructor is exposed via the generated 'copy()' method of a 'data' class. (0) | 2024.07.20 |
---|---|
[Kotlin] JPA 플러그인 정리 (0) | 2024.07.19 |
[Kotlin] 코틀린에서의 FP (0) | 2023.06.04 |
[Kotlin] 코틀린에서의 OOP (0) | 2023.05.07 |
[Kotlin] 코틀린에서 코드를 제어하는 방법 (4) | 2023.05.07 |