* Contents
Part1: Good code
Chapter1: Safety
Chapter2: Readability ✔
Part2: Code design
Chapter3: Reusability
Chapter4: Abstraction desgin
Chapter5: Object creation
Chapter6: Class design
Part3: Efficiency
Chapter7: Make it cheap
Chapter8: Efficient collection processing
해당 글은 Effective Kotlin(Marcin Moskala)의 Chapter2: Readability의 내용을 다룬다.
코틀린은 가독성에 초점을 둔 언어이다. 가독성 있는 코틀린 작성법을 알아보자.
코드는 짜는 시간 보다 읽는 시간이 훨씬 많다.
따라서 읽기 쉬운 코드를 늘 염두에 두어야 한다.
아래 두 코드가 있다고 하자.
// Implementation A
if (person != null && person.isAdult) {
view.showPerson(person)
} else {
view.showError()
}
// Implementation B
person?.takeIf { it.isAdult }
?.let(view::showPerson)
?: view.showError()
A와 B 중 어떤 게 더 좋은 코드일까?
코틀린에 익숙하지 않다면 A가 더 읽기 쉬운 코드일 것이며, 코틀린에 익숙하다면 B도 충분히 읽기 쉬운 코드일 것이다.
하지만 B처럼 코틀린스러운 코드가 마냥 좋은 건 아니다.
코틀린에 익숙하더라도, 코틀린이 주 개발 언어가 아니거나 주니어 개발자라면 이해하는 데 더 오랜 시간이 걸린다.
수정 또한 A가 더 간단하다.
각 조건에 동작을 추가한다고 해보자.
// Implementation A
if (person != null && person.isAdult) {
view.showPerson(person)
view.hideProgressWithSuccess()
} else {
view.showError()
view.hideProgress()
}
// Implementation B
person?.takeIf { it.isAdult }
?.let {
view.showPerson(it)
view.hideProgressWithSuccess(it)
} ?: run {
view.showError()
view.hideProgress()
}
A가 더 적은 수정이 발생하며, 디버깅 또한 더 쉽다.
흔하지 않고 독창적인 구조일수록 유연성이 떨어지고 유지보수가 어렵다.
따라서 짧지만 더 흔히 쓰이는 구조를 지향해야 한다.
그러한 코드가 즉 이해하기 쉬운 코드이다.
위처럼 예시를 들었다해서, 코틀린스러운 코드를 아예 쓰지 말라는 건 아니다.
코틀린 idiom들을 잘 활용하면 더 좋은 코드를 짤 수 있다.
예를 들어 let
을 다음과 같이 활용할 수 있다.
fun printName() {
person?.let {
print(it)
}
}
students
.filter { it.result >= 50 }
.joinToString(separator = "\n") {
"${it.name} ${it.surname} ${it.result}"
}
.let(::print)
val object = FileInputStream("/file.gz")
.let(::BufferedInputStream)
.let(::ZipInputStream)
.let(::ObjectInputStream)
.readObject() as SomeObject
이는 디버깅이 어렵고 숙련되지 않은 코틀린 개발자에겐 읽기 불편한 코드일지라도,
그러한 단점을 감수할만큼 더 간단한 코드이다.
따라서 이분법적으로 쓸 것과 쓰지 말아야 할 것을 나누기 보단, 적절한 균형을 맞추는 게 중요하다.
다음과 같은 코드가 있다고 하자.
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()
를 String에 호출하는 건 맥락상 맞지 않다.and
보단 append
, plus
와 같은 네이밍이 더 적절하다.이처럼 관습을 어기는 코드는 가독성을 떨어트린다.
operator overloading은 코틀린의 강력한 기능 중 하나이다.
(코틀린에서 각 operator의 의미는 링크에서 확인 가능하다.)
예를 들어 operator overloading을 통해 x + y == z
는 x.plus(y).equals(z)
로 해석될 것이다.
하지만 operator와 함수 이름의 조합이 불명확하면 의미 파악이 어려워진다.
operator fun Int.times(operations: () -> Unit): () -> Unit =
{ repeat(this) { operation() } }
val tripleHello = 3 * { print("Hello") }
tripleHello() // Prints: HelloHelloHello
위 예시의 경우 3 * { print("Hello") }
가 람다 속 함수를 3번 실행하고 끝인지,
혹은 그러한 동작을 하는 새로운 함수를 반환하는지 헷갈릴 수 있다.
따라서 해당 함수의 동작을 명확히 전달하도록 함수 이름을 짓는 게 좋다.
위 예시의 경우 operator 대신 infix를 활용할 수 있다.
infix fun Int.timesRepeated(operation: () -> Unit) = {
repeat(this) { operation() }
}
val tripleHello = 3 timesRepeated { print("Hello") }
tripleHello() // Prints: HelloHelloHello
단, DSL을 설계할 땐 이러한 규칙을 어겨도 괜찮다.
body {
div {
+"Some text"
}
}
Unit?
을 함수의 반환 타입으로 설정해야 할 상황이 있을까?
혹자는 아래와 같은 코드를 제시할 것이다.
fun verifyKey(key: String): Unit? = // ...
verifyKey(key) ?: return
코드를 짜는 입장에선 좋아보일지 몰라도, 읽는 입장에선 썩 좋아보이진 않다.
차라리 이런 상황에선 Boolean
반환값을 설정하는 게 좋다.
fun isKeyCorrect(key: String): Boolean = // ...
if (!isKeyCorrect(key)) return
단언컨대 Unit?
을 반드시 사용해야 할 상황은 없다.
코틀린의 타입 추론 시스템은 분명 유용하지만, 너무 남용하는 건 좋지 않다.
val data = getSomeData()
위 코드를 읽고 data의 타입을 이해할 수 있는가?
위 코드는 타입 체킹을 위해 getSomeData()
함수의 선언 지점을 찾아봐야 하며,
깃헙 같은 환경에서 코드를 읽을 땐 그 조차도 하기 어렵다.
따라서 가능하다면 타입을 명시적으로 적는 게 좋다.
val data: UserData = getSomeData()
class User : Person() {
private var beersDrunk: Int = 0
fun drinkBeers(num: Int) {
// ...
this.beersDrunk += num // receiver referencing
// ...
}
}
위 코드처럼 로컬 혹은 최상위 변수를 참조하는 게 아니면, 수신자를 명시적으로 표시하는 게 좋다.
특히 한 scope 내에 여러 수신자가 있다면 반드시 수신자를 명시하자.
명시하지 않을 경우 생기는 문제는 다음과 같다.
class Node(val name: String) {
fun makeChild(childName: String) = {
create("$name.$childName")
.apply { print("Created $name") }
}
fun create(name: String): Node? = Node(name)
}
fun main() {
val node = Node("parent")
node.makeChild("child") // Created parent
}
위 코드는 의도와 달리 Created Child
가 아닌 Created Parent
를 출력한다.
create()
가 nullable Node
를 반환하므로, apply
block 내에서 자식 노드를 참조하려면 아래처럼 수정해야 한다.
class Node(val name: String) {
fun makeChild(childName: String) = {
create("$name.$childName")
.apply { print("Created ${this?.name}") }
}
fun create(name: String): Node? = Node(name)
}
fun main() {
val node = Node("parent")
node.makeChild("child") // Created child
}
위 예시처럼 수신자를 명시하면 의도한대로 동작하지만, 사실 이는 좋은 apply
활용법은 아니다.
이 상황에서 더 좋은 방법은 also
나 let
을 활용하는 것이다.
class Node(val name: String) {
fun makeChild(childName: String) = {
create("$name.$childName")
.also { print("Created ${it?.name}") }
}
fun create(name: String): Node? = Node(name)
}
fun main() {
val node = Node("parent")
node.makeChild("child") // Created child
}
아래처럼 수신자를 명시할 시 부모 노드와 자식 노드의 이름을 모두 참조할 수 있다.
class Node(val name: String) {
fun makeChild(childName: String) = {
create("$name.$childName")
.apply { print("Created ${this?.name} in ${this@Node.name}") }
}
fun create(name: String): Node? = Node(name)
}
fun main() {
val node = Node("parent")
node.makeChild("child") // Created child
}
따라서 예기치 못한 에러를 방지하기 위해 수신자를 명시적으로 표현하는 것이 좋다.
프로퍼티는 backing field 없이 접근자로만 구성될 수 있다.
val fullName: String
get() = "$name $surname"
하지만 이러한 특징이 프로퍼티가 알고리즘적인 행위를 나타내도 된다는 뜻은 아니다.
// DON'T DO THIS
val Tree<Int>.sum: Int
get() = when (this) {
is Leaf -> value
is Node -> left.sum + right.sum
}
프로퍼티는 상태를 설정하거나 표시할 때만 사용해야 하며, 그 외 로직이 수반되어선 안된다.
쉽게 구별하는 법은 내가 만들고자 하는 프로퍼티를 함수로 바꿀 때, 함수 이름이 get/set으로 시작할지 확인하는 것이다.
get/set으로 표현되지 않는다면 해당 프로퍼티는 함수로 표현하는 것이 옳다.
프로퍼티 대신 함수로 선언해야 하는 상황은 다음과 같다.
프로퍼티는 상태를 표현하고 설정하며, 함수는 동작을 표현한다.
// without named arguments
val text = (1..10).joinToString("|")
// with named arguments
val text = (1..10).joinToString(separator = "|")
named argument를 사용했을 때 이점은 무엇일까?
잘못된 매개변수에 값을 넘기는 상황을 방지하므로 안전하다.
또한 코드를 읽을 때 이해가 더 쉽다.
특히 아래처럼 같은 타입의 매개변수가 반복될 경우, 실수를 방지할 수 있다.
fun sendEmail(to: String, message: String) { /*...*/ }
sendEmail(
to = "contact@kt.academy",
message = "Hello..."
)
특히 function type의 매개변수는 더 특별히 다뤄야 한다.
코틀린에선 function type의 매개변수를 마지막 매개변수로 배치해 람다로 함수를 정의한다.
아래와 같이 function type의 매개변수가 여러 개일 경우, 모든 인자값을 네이밍하는 게 좋다.
fun call(before: () -> Unit = {}, after: () -> Unit() = {}) {
before()
print("Middle")
after()
}
// Don't do this.
call({ print("CALL") }) // CALLMiddle
call { print("CALL") } // MiddleCALL
// Do this instead.
call(before = { print("CALL") }) // CALLMiddle
call(after = { print("CALL") }) // MiddleCALL
코틀린은 잘 정립된 컨벤션을 지닌다.
모든 프로젝트에서 준수하는 컨벤션을 갖는 건 커뮤니티 관점에서 중요하다.
컨벤션을 따르면 다음과 같은 이점이 있다.
이를 지키기 위해 IDE 단의 formatter 혹은 Ktlint와 같은 것들을 활용하는 걸 추천한다.
모든 프로젝트는 한 사람에 의해 작성된 것처럼 보여야 한다.