Effective Kotlin - Part1: Good code (2)

7hong13·2023년 7월 2일
0

Effective Kotlin

목록 보기
2/2
post-thumbnail
* 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의 내용을 다룬다.

Chapter2: Readability

코틀린은 가독성에 초점을 둔 언어이다. 가독성 있는 코틀린 작성법을 알아보자.

Item 11: Design for 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와 같은 네이밍이 더 적절하다.
  • 넷째, 코틀린에서 제공하는 문자열 연결 함수를 쓰는 게 좋다.

이처럼 관습을 어기는 코드는 가독성을 떨어트린다.

Item 12: Operator meaning should be consistent with its function name

operator overloading은 코틀린의 강력한 기능 중 하나이다.
(코틀린에서 각 operator의 의미는 링크에서 확인 가능하다.)
예를 들어 operator overloading을 통해 x + y == zx.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"
    }
}

Item 13: Avoid returning or operating on Unit?

Unit?을 함수의 반환 타입으로 설정해야 할 상황이 있을까?
혹자는 아래와 같은 코드를 제시할 것이다.

fun verifyKey(key: String): Unit? = // ...

verifyKey(key) ?: return

코드를 짜는 입장에선 좋아보일지 몰라도, 읽는 입장에선 썩 좋아보이진 않다.

차라리 이런 상황에선 Boolean 반환값을 설정하는 게 좋다.

fun isKeyCorrect(key: String): Boolean = // ...

if (!isKeyCorrect(key)) return

단언컨대 Unit?을 반드시 사용해야 할 상황은 없다.

Item 14: Specify the variable type when it is not clear

코틀린의 타입 추론 시스템은 분명 유용하지만, 너무 남용하는 건 좋지 않다.

val data = getSomeData()

위 코드를 읽고 data의 타입을 이해할 수 있는가?
위 코드는 타입 체킹을 위해 getSomeData() 함수의 선언 지점을 찾아봐야 하며,
깃헙 같은 환경에서 코드를 읽을 땐 그 조차도 하기 어렵다.

따라서 가능하다면 타입을 명시적으로 적는 게 좋다.

val data: UserData = getSomeData()

Item 15: Consider referencing receivers explicitly

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 활용법은 아니다.
이 상황에서 더 좋은 방법은 alsolet을 활용하는 것이다.

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
}

따라서 예기치 못한 에러를 방지하기 위해 수신자를 명시적으로 표현하는 것이 좋다.

Item 16: Properties should represent state, not behavior

프로퍼티는 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으로 표현되지 않는다면 해당 프로퍼티는 함수로 표현하는 것이 옳다.

프로퍼티 대신 함수로 선언해야 하는 상황은 다음과 같다.

  • O(1) 이상의 복잡도를 갖거나 수행하는 데 리소스가 많이 쓰이는 동작을 포함할 때
  • 비즈니스 로직을 포함할 때
  • 결정적이지 않을 때
  • 변환하는 동작일 때(예: Int.toDouble())
  • getter가 해당 프로퍼티의 상태를 변경할 때

프로퍼티는 상태를 표현하고 설정하며, 함수는 동작을 표현한다.

Item 17: Consider naming arguments

named argument의 장점

// 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의 매개변수를 마지막 매개변수로 배치해 람다로 함수를 정의한다.

아래와 같이 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

Item 18: Respect coding conventions

코틀린은 잘 정립된 컨벤션을 지닌다.
모든 프로젝트에서 준수하는 컨벤션을 갖는 건 커뮤니티 관점에서 중요하다.
컨벤션을 따르면 다음과 같은 이점이 있다.

  • 프로젝트 간 스위칭이 쉽다.
  • 외부 개발자에게도 읽기 쉬운 코드가 된다.
  • 코드의 동작을 유추하기 쉽다.
  • 코드를 병합하거나 기타 프로젝트로 이관하기 쉽다.

이를 지키기 위해 IDE 단의 formatter 혹은 Ktlint와 같은 것들을 활용하는 걸 추천한다.

모든 프로젝트는 한 사람에 의해 작성된 것처럼 보여야 한다.

profile
안드로이드/코틀린

0개의 댓글