프로그래밍의 대부분의 작업은 쓰는 것이 아니라 읽는 것이다. 그만큼 가독성을 좋게 설계하는 것은 중요하다.
다음 두 코드를 살펴보자.
// 코드 A
if (person != null && person.isAdult) {
view.showPerson(person)
} else {
view.showError()
}
// 코드 B
person?.takeIf { it.isAdult }
?.let(view::showPerson)
?: view.shoeError()
얼마나 빨리 이해할 수 있는지에 따라 두 코드의 가독성은 달라진다. 숙련된 Kotlin 개발자에게는 takeIf
, let
, Elvis 연산자
등 kotlin 만의 관용구를 쓰는 코드 B가 더 이해하기 쉬울 수 있다. 하지만 반대로 아직 숙련되지 않은 개발자는 코드 A가 더 이해하기 쉬울 것이다. 따라서 코드 B와 같이 숙련된 개발자만을 위한 코드는 좋은 코드는 아니다.
// 코드 A
if (person != null && person.isAdult) {
view.showPerson(person)
view.hideProgressWithSuccees()
} else {
view.showError()
view.hideProgress()
}
// 코드 B
person?.takeIf { it.isAdult }
?.let {
view.showPerson(it)
view.hideProgressWithSuccess()
} ?: run {
view.showError()
view.hideProgress()
}
추가 작업을 실행하도록 할 때 코드 A가 더 수정하기 쉽다. 여기서, (person == null && !person.isAdult)
일 때 다른 오류를 보여주도록 조건문 분기를 추가한다고 가정하면, A가 훨씬 쉬울 것이다.
뿐만 아니라, 코드 A와 코드 B의 실행 결과는 다를 수 있다. let
은 람다식의 결과를 반환하기 때문에 코드 B의 view.showPerson(it)
이 null
을 반환하면 run
블록이 호출되어 다른 결과가 발생할 수 있다.
따라서, 기본적으로 인지 부하
를 줄이는 방향으로 코드를 작성하는 것이 중요하다.
이전 예제에서 let
의 오용 사례를 보여줬다고 해서 항상 사용하지 말아야 되는 것은 아니다.
Nullable한 가변 프로퍼티가 있고 그것이 null이 아닐 때만 작업을 수행하는 경우, let
을 사용할 수 있다.
class Person(val name: String)
var person: Person? = null
fun printName() {
preson?.let {
print(it.name)
}
}
뿐만 아니라
students
.filter { it.result >= 50 }
.joinToString(separtor = "\n") {
"${it.name} ${it.surname}. ${it.result}"
}
.let(::print)
var obj = FileInputStream("/file.gz")
.let(::BufferInputStream)
.let(::ZipInputStream)
.let(::ObjectInputStream)
.readObject() as SomeObject
에도 let
을 효율적으로 사용할 수 있다.
사람마다 가독성에 대한 관점이 다르다. 하지만, 이해하고 따라야 할 일반적인 관습은 존재한다.
관습을 무시한 최악의 코드의 예시를 살펴보자.
val abc = "A" { "B" } and "C"
print(abc) // ABc 출력
operator fun String.invoke(f: () -> String): String = this = f()
infix fun String.and(s: String) = this + s
이 코드는 다음과 같은 문제점이 있다.
invoke
는 일반적으로 메소드나 함수를 호출할 때 사용된다. invoke
메소드의 마지막 인수로 람다식을 사용하면 이해하기 어렵고 예상치 못한 결과를 가져올 수 있다.and
보다 append
나 plus
가 메소드 이름으로 더 적당하다.따라서, 지켜야할 일반적인 관습은 따르며 코드를 작성해야 한다.
fun Int.factorial(): Int = (1..this).product()
fun Iterable<Int>.product(): Int = fold(1) { acc, i -> acc * i }
operator fun Int.not() = factorial()
!
연산자로 팩토리얼을 계산하기 위해 not 연산자를 오버로딩한 코드이다.
print(10 * !6) // 7200
비록 기능은 제대로 동작할지라도, 이러한 연산자 오버로딩은 사용자로 하여금 혼란을 준다. !
은 본래 팩토리얼 계산이 아닌 논리적 연산의 기능을 수행해야 한다.
사용자의 오용 가능성을 없애기 위해 연산자는 본래의 의미가 가진 기능대로 사용되어야 한다.
operator fun Int.times(operation: () -> Unit): () -> Unit = {
repeat(this) { operation() }
}
val tripledHello = 3 * { print("Hello") }
tipledHello() // HelloHelloHello
다음과 같이 사용법이 모호한 경우도 있다. times 연사자를 오버로드한 경우 사용자에 따라 함수를 세 번 반복하여 호출한다고 생각할 수도 있고 아닐 수도 있다. 이처럼 의미가 명확하지 않을 때는 infix
를 활용한 확장 함수를 사용하는 것이 좋다.
infix fun Int.timesRepeated(operation: () -> Unit): () -> Unit = {
repeat(this) { operation() }
}
val tripledhello = 3 timesRepeated { print("Hello") }
tripledHello()
infix
를 통해 이항 연산자처럼 사용함으로써 사용법을 좀 더 명확하게 할 수 있다.
repeat(3) { print("Hello") }
하지만, 가장 좋은 방법은 이미 정의된 top-level 함수인 repeat
를 사용하는 것이다.
도메인 특화 언어(Domain Specific Language, DSL)
을 설계할 때는 연산자 오버로딩 규칙을 무시해도 괜찮다.
body {
div {
+"Some text"
}
}
다음은 HTML에서 String.unaryPlus
의 오버로딩 규칙을 무시한 예시이다.
infix
확장 함수 또는 Top-level 함수를 사용하자Unit?은 Unit 또는 null 두 가지 값이 가능하다.
fun verifyKey(key: String): Unit? = //...
verifyKey(key) ?: return
다음 코드처럼 elvis 연산자를 이용한 활용도 가능하다.
getData()?.let{ view.showData(it) } ?: view.showError()
하지만, 이런 방식은 예상치 못한 오류를 발생시키기도 한다. 해당 코드에서 showData
가 null을 반환하고 getData
가 null이 아닌 값을 반환할 시, showData
와 showError
가 모두 호출된다.
따라서, Unit?은 가독성이 떨어지고 예상치 못한 오류를 발생시킬 수도 있기 때문에 사용하는 것을 피해야 한다.
Kotlin은 타입을 생략할 수 있는 훌륭한 타입 추론 시스템을 가지고 있다. 그러나, 해당 기능은 타입이 명확하지 않은 경우 사용해서는 안 된다.
val data = getSomeData()
해당 코드를 보면 getSomeData
가 어떤 타입의 데이터를 반환해주는지 알 수 있다. 물론 IDE에서는 직접 함수로 이동해 확인할 수 있지만, Github와 같은 환경에서는 확인도 어려울 뿐더러 일일이 확인하는 일에는 시간이 낭비된다.
따라서, 타입을 명시하는 것은 중요하다.