아이템 11: 가독성을 목표로 설계하라
프로그래밍은 쓰기보다 읽기가 더 중요하다. 따라서 항상 가독성을 생각하며 코드를 작성해야 한다.
인식 부하 감소
// 구현 A
if(person != null && person.isAdult) {
view.showPerson(person)
} else {
view.showError()
}
// 구현 B
person?.takeIf { it.isAdult }
?.let(view::showPerson)
?: view.showError()
- 구현 B는 짧지만 이해하기 어려움
- 일반적으로 코틀린에서 사용되는 관용구가 많아 경험이 많은 코틀린 개발자라면 쉽게 이해 가능
- but, 숙련된 개발자만을 위한 코드는 좋은 코드가 아님
- 구현 A는 수정이 쉬움
- if 블록에 작업 추가할 경우
- 구현 B는 함수 참조를 사용할 수 없으므로 코드 수정 필요
- else 블록에 작업을 추가할 경우
- 추가적인 함수 필요
// 구현 A if(person != null && person.isAdult) { view.showPerson(person) view.hideProgressWithSuccess() } else { view.showError() view.hideProgress() } // 구현 B person?.takeIf { it.isAdult } ?.let( view.showPerson() view.hideProgressWithSuccess() ) ?: run { view.showError() view.hideProgress() }
- if 블록에 작업 추가할 경우
- 구현 A는 디버깅이 더 간단
- 구현 A와 B의 결과가 다름
- let은 람다식의 결과를 반환
- showPerson이 null 반환하면 두번째 구현 때는 showError도 호출
- let은 람다식의 결과를 반환
따라서 인지 부하를 줄이는 방향으로 코드를 작성하자.
극단적이 되지 않기
nullable 가변 프로퍼티가 있고, null이 아닐때만 어떤 작업을 수행해야 하는 경우
가변 프로퍼티는 스레드 관련 문제 발생 위험 때문에 스마트 캐스팅이 불가능하다.
- 가변 프로퍼티 스마트 캐스팅
var s = readLine() // String? if (s != null) { s = readLine() // 변수 값이 바뀌므로 스마트 캐스트를 쓸 수 없음 // error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String? println(s.length) }
- 스마트 캐스트를 실행하려면 대상 변수의 값이 검사 지점과 사용 지점 사이에서 변하지 않는다고 컴파일러가 확신할 수 있어야 한다. 특히 지금까지 살펴본 불변 지역 변수는 초기화 후 변경되지 않으므로 항상 제한 없이 스마트 캐스트를 쓸 수 있다. 하지만 널 검사와 사용 지점 사이에서 값이 변경되는 경우에는 스마트 캐스트가 작동하지 않는다.
이때 일반적으로 let을 이용해 안전한 호출을 한다.
class Person(val name: String)
var person: Person? = null
fun printName() =
person?.let {
print(it.name)
}
let이 자주 사용되는 경우
- 연산을 아규먼트 처리 후로 이동시킬 때
- print(student.filter{}.joinToString{})을 student.filter{}.joinToString{}.let(::print)로 만드는 경우
- 데코레이터를 사용해 객체를 래핑할 때
- var obj = FileInputStream("/file.gz") .let(::BufferedInputStream) .let(::ZipInputStream) .let(::ObjectInputStream) .readObject() as SomeObject
- 복잡성이 증가하지만 그만한 가치가 있는 경우 사용해도 좋다.
- 복잡성과 생산성 사이의 균형이 필요하다.
컨벤션
val abc = "A" {"B"} and "C"
print(abc)
위 코드는 수많은 규칙을 위반한다.
- 연산자는 의미에 맞게 사용되어야 함
- ‘람다를 마지막 아규먼트로 사용한다’라는 컨벤션을 적용할 경우 코드가 복잡해짐
- 현재 코드에서 and라는 함수 이름이 실제 함수 내부에서 일어나는 처리와 적합하지 않음
- 이미 있는 기능을 다시 만들 필요 없음
아이템 12: 연산자 오버로드를 할 때는 의미에 맞게 사용하라
연산자 오버로딩은 강력하지만 위험 요소가 있다.
fun Int.factorial(): Int = (1..this).product()
// 생략
operator fun Int.not() = factorial()
print(10 * !6) // 7200
코틀린은 위와 같은 연산자를 지원하지 않지만 연산자 오버로딩을 활용해 만들 수 있다.
하지만 연산자는 대응되는 함수에 대한 별칭일 뿐이다.
// 함수 이름의 의미와 동작이 다름
print(10 * 6.not()) // 7200
따라서 팩토리얼을 계산하기 위해 ! 연산자를 사용하면 안된다. 이는 관례에 어긋나기 때문이다.
분명하지 않은 경우
관례를 충족하는지 아닌지 확실하지 않은 경우도 존재한다.
이 경우는 infix를 활용한 확장 함수를 사용하는 것이 좋다.
infix fun Int.timesRepeated(operation: () -> Unit) = {
repeat(this) { operation() }
}
val tripledHello = 3 timesRepeated { print("Hello") }
top-level 함수를 사용하는 것도 좋다.
repeat(3) { print("hello") }
규칙을 무시해도 되는 경우
연산자 오버로딩 규칙을 무시해도 되는 중요한 경우가 있다.
도메인 특화 언어(DSL)을 설계할 때이다.
body {
div {
+"Some Text"
}
}
문자열 앞에 String.unaryPlus가 사용되었지만 이는 DSL 코드이기 때문에 이렇게 작성해도 문제없다.
아이템 13: Unit?을 리턴하지 말라
Unit? 타입은 Unit 또는 null 값을 가질 수 있다.
fun keyIsCorrect(key: String): Boolean = //...
if(!keyIsCorrect(key)) return
위 코드는 아래처럼 사용할 수 있다.
fun verifyKey(key: String): Unit? = //..
verifyKey(key) ?: return
같은 동작을 하지만 Unit? 타입으로 Boolean을 표현한다는 것은 오해의 소지가 있으며, 예측이 힘든 오류를 만들 수 있다.
if(!keyIsCorrect(key)) return
verifyKey(key) ?: return
위의 두 코드를 보면 if문으로 작성한 것이 더 쉽게 읽힌다.
Unit?을 쉽게 읽을 수 있는 경우는 거의 없다.
따라서 Unit?을 사용하는 부분은 Boolean으로 대체하여 사용하는 것이 좋다.
아이템 14: 변수 타입이 명확하게 보이지 않는 경우 확실하게 지정하라
val num = 10
val name = "Paul"
코틀린의 타입 추론은 생산성을 높이고, 유형이 명확할 경우 가독성이 크게 향상된다.
하지만 유형이 명확하지 않을 때 남용하는 것은 좋지 않다.
val data1 = getSomedata()
코드를 읽으며 함수 정의를 확인하면 되지 않나? 생각하는 것은 가독성이 떨어진다는 소리와 같음
또한 깃허브와 같이 쉽게 코드 정의로 이동하지 못할 수도 있다.
안정성 측면에서도 명시를 해주는 것이 좋다.
타입은 굉장히 중요한 정보 중 하나이므로 타입 추론은 적절한 상황에서 사용해야 한다.
→ 변수 정의 등에서는 타입을 바로 확인할 수 있으므로 생략해도 되지만, 함수 호출에 대한 반환 값의 타입은 명시해주는 것이 좋다.
아이템 15: 리시버를 명시적으로 참조하라
일부러 코드를 더 길게 만들어 자세히 설명하는 경우가 있다.
함수와 프로퍼티가 다른 리시버로부터 가져온 것이라는 것을 나타낼 때가 대표적인 예시이다.
// 명시적 표시 X
fun <T: Comparable<T>> List<T>.quickSort(): List<T> {
if(size < 2) return this
val pivot = first()
val (smaller, bigger) = drop(1).partition {it < pivot }
return smaller.quickSort() + pivot + bigger.quickSork()
}
// 명시적 표시 O
fun <T: Comparable<T>> List<T>.quickSort(): List<T> {
if(this.size < 2) return this
val pivot = this.first()
val (smaller, bigger) = this.drop(1).partition {it < pivot }
return smaller.quickSort() + pivot + bigger.quickSork()
}
위 두 함수는 같은 동작을 하지만 this를 명시적으로 나타낸 쪽의 코드가 더 높은 가독성을 가진다.
여러 개의 리시버
스코프 내부에 리시버가 여러 개 있을 경우, 리시버를 명시적으로 나타내는 것이 좋다.
apply, run, let 함수와 같은 상황이 대표적인 예이다.
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.child 결과가 출력된다고 생각하지만 실제로는 Created parent가 출력된다.
리시버를 추가하면 아래와 같다.
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")
}
apply의 this 타입이 Node? 이기 때문에 this.name과 같이 직접 사용할 수 없다.
따라서 아래처럼 언팩 후 사용해야 한다.
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")
}
추가적으로 위 코드는 apply의 잘못된 예시이다.
만약 also 함수와 name을 사용했다면 이런 문제가 일어나지 않는다. also를 사용하면 명시적으로 리시버를 지정하게 된다. 일반적으로 also 또는 let을 사용하는 것이 nullable 값을 처리할 때 훨씬 좋은 선택이다.
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")
}
레이블 없는 리시버를 사용하면 가장 가까운 리시버를 의미한다. 외부에 있는 리시버를 사용하려면 레이블을 사용해야 한다.
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 parent.child in parent
}
위처럼 레이블을 사용하면 코드를 안전하게 사용할 수 있고 가독성도 향상된다.
DSL 마커
코틀린 DSL은 여러 리시버를 가진 요소가 중첩돼도 리시버를 명시적으로 붙이지 않도록 설계되었다.
그런데 DSL에서 외부 함수를 사용하는 것이 위험한 경우가 있다.
table {
tr {
td { +"Column 1" }
td { +"Column 2" }
}
tr {
td { +"Value 1" }
td { +"Value 2" }
}
}
기본적으로 모든 스코프에서 외부 스코프에 있는 리시버 메소드를 사용할 수 있지만 아래와 같은 코드는 문제가 발생한다.
table {
tr {
td { +"Column 1" }
td { +"Column 2" }
tr {
td { +"Value 1" }
td { +"Value 2" }
}
}
}
잘못된 사용을 막으려면 암묵적으로 외부 리시버 사용을 막는 DslMarker라는 메타 어노테이션을 사용한다.
@DslMarker
anntation class HtmlDsl
fun table(f: TableDsl.() -> Unit) { /* ... */ }
@HtmlDsl
class TableDsl { /* ... */ }
위처럼 하면 암묵적으로 외부 리시버를 사용하는 것이 금지된다.
위와 같은 상황에서는 명시적으로 해야한다.
table {
tr {
td { +"Column 1" }
td { +"Column 2" }
this@table.tr {
td { +"Value 1" }
td { +"Value 2" }
}
}
}
Dsl 마커는 가장 가까운 리시버만 사용하게 하거나, 명시적으로 외부 리시버를 사용하지 못하게 할 때 유용하다.
아이템 16: 프로퍼티는 동작이 아니라 상태를 나타내야 한다
자바에서는 필드(field)라는 용어를 사용하지만, 코틀린에서는 val , var로 선언한 변수를 프로퍼티(property)라고 부른다.
그 이유는 변수 선언 시 자동으로 getter, setter(var 로 변수 선언시에만) 내장 함수가 생성되기 때문인데, 이 부분을 더 자세히 살펴보자.
// Kotlin Code
val name = "Java"
val surname = "Cafe"
val fullName: String
get() = "$name $surname"
// Java Decompile
public final class MainKt {
@NotNull
private static final String name = "Java";
@NotNull
private static final String surname = "Cafe";
@NotNull
public static final String getName() {
return name;
}
@NotNull
public static final String getSurname() {
return surname;
}
@NotNull
public static final String getFullName() {
return name + ' ' + surname;
}
}
위 코드에서는 fullName변수는 읽기 전용 프로퍼티로써 초기화 하지 않고 getter만 별도로 정의했다. 이 코드를 자바로 디컴파일 해보면 name과 surname과는 다르게 은 필드가 존재하지 않는 것을 확인할 수 있다.
많은 글에서 코틀린의 프로퍼티를 설명할 때 필드 + getter + setter라고 정의하지만, 엄밀히 말하면 프로퍼티는 필드가 필요하지 않고 val 접근자에서는 getter, var 접근자에서 getter + setter를 나타내는 것이라 할 수 있다.
위처럼 프로퍼티가 캡슐화되어 있다는 점을 이용할 수 있다. 예를 들어 Date 타입의 프로퍼티를 사용하고 있었는데 직렬화 등의 문제로 객체의 타입을 바꾸어야 한다는 상황이라 가정하자. 이미 이 프로퍼티는 프로젝트의 여러 곳에서 사용하고 있어 직접적인 변경이 어려울 때 아래와 같이 기존 프로퍼티를 wrap/unwrap 하여 해결할 수 있다.
var date: Date
get() = Date(millis)
set(value) {
millis = value.time
}
프로퍼티는 필드가 필요하지 않다는 점, 즉 프로퍼티는 본질적으로 함수라는 점에서 자바의 필드와 비교해 아래의 추가적인 차이점들도 가지게 된다.
- 인터페이스에 프로퍼티 정의 가능
interface Book { val name: String }
- 오버라이드 가능
open class Developer {
open val language: String = "C++"
}
class BackendDeveloper : Developer() {
override val language: String = "Kotlin"
}
- 위임 가능
val db: Database by lazy { connectToDb() }
- 확장 프로퍼티 가능
val Context.preferences: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(this)
val Context.inflater: LayoutInflater
get() = getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
위처럼 프로퍼티는 본질적으로는 함수이다. 하지만 원칙적으로 프로퍼티를 함수의 대체로 사용해서는 안되며 상태를 나타내거나 설정하기 위한 목적으로만 사용해야 한다. 관습적으로 getter에는 많은 계산량이 필요하지 않다고 생각하기 때문이다. 만약 계산량이 많은 경우에는 함수로 선언해야 사용자가 캐싱 등을 고려할 수 있다.
아래는 프로퍼티 대신 함수로 선언해야 하는 경우이다.
- 복잡도가 O(1)보다 높은 경우
- 비즈니스 로직을 포함하는 경우
- 동작마다 다른 결과가 나오는 경우
- 변환의 경우
- getter에서 프로퍼티의 상태 변경이 일어나는 경우
반대로 상태를 추출하거나 설정하는 경우에는 함수가 아닌 프로퍼티를 사용해야 한다.
// Incorrect example
class UserIncorrect {
private var name: String = ""
fun getName() = name
fun setName(name: String) {
this.name = name
}
}
// Correct example
class UserCorrect {
var name: String = ""
}
// Incorrect example
val Tree<Int>.sum: Int
get() = when (this) {
is Leaf -> value
is Node -> left.sum + right.sum
}
// Correct example
fun Tree<Int>.sum(): Int = when (this) {
is Leaf -> value
is Node -> left.sum() + right.sum()
}
이런 점에서 프로퍼티는 상태를 나타내고, 함수는 동작을 나타낸다.
아이템 17: 이름 있는 아규먼트를 사용하라
코드에서 아규먼트의 의미가 명확하지 않은 경우가 있다.
이런 경우 아규먼트의 용도를 오해할 수 있다.
따라서 파라미터를 직접 지정해서 명확하게 만드는 것이 좋다.
val text = (1..10).joinToString(seperator = "|")
또는
val seperator = "|"
val text = (1..10).joinToString(seperator = seperator)
위처럼 파라미터를 지정하면 입력 순서와 상관 없이 정상 작동하므로 안전하고, 가독성이 높아진다.
이름 있는 아규먼트를 사용하는 경우
이름 있는 아규먼트를 사용하면 다음 장점을 가진다.
- 이름을 기반으로 값이 무엇을 나타내는지 알 수 있다.
- 파라미터 입력 순서와 상관 없으므로 안전하다.
아규먼트 이름은 코드를 읽는 사람에게 중요한 정보를 제공한다.
sleep(timeMillis = 100)
sleep(Millis(100))
또는 DSL과 유사하게 확장 프로퍼티로 문법을 만들어 사용할 수도 있다.
sleep(100.ms)
만약 성능 저하가 걱정된다면 아이템 46: 함수 타입 파라미터를 갖는 함수에 inline 한정자를 붙여라 챕터에서 말하는 인라인 클래스를 사용한다.
아래와 같은 상황에서 이름 있는 아규먼트는 더욱 장점을 갖는다.
- 디폴트 아규먼트의 경우
- 같은 타입의 파라미터가 많은 경우
- 함수 타입의 파라미터가 있는 경우
디폴트 아규먼트의 경우
일반적으로 함수 이름은 파라미터와 관련되어 있기 때문에 디폴트 값을 갖는 옵션 파라미터의 설명이 명확하지 않다. 따라서 이름을 붙이는 것이 좋다.
같은 타입의 파라미터가 많은 경우
파라미터가 모두 같은 타입이라면 입력 순서가 잘못될 경우 문제를 찾아내기 어렵다.
fun sendEmail(to: String, from: String)
sendEmail(to = "client@email.com", from = "me@email.com")
함수 타입 파라미터
일반적으로 함수 타입 파라미터는 마지막에 위치하고, 함수의 이름이 함수 타입 아규먼트를 설명해준다. (ex. repeat, thread 함수)
이런 이름들은 일반적으로 마지막에 위치한 함수 파라미터에 대해서만 설명한다.
이런 경우가 아닐 때 모든 함수 타입 아규먼트는 이름이 있는 것이 좋다.
val view = linearLayout {
text("Click below")
button({ /* 1 */ }, { /* 2 */ })
}
val view = linearLayout {
text("Click below")
button(onClick = { /* 1 */ }) {
/* 2 */
}
}
여러 함수 타입 옵션 파라미터가 있는 경우는 더욱 헷갈릴 수 있다.
fun call(before: () -> Unit = {}, after: () -> Unit = {}) {
before()
print("middle")
after()
}
call({ print("CALL") }) // CALLmiddle
call { print("CALL") } // middleCALL
call(before = { print("CALL") })
call(after = { print("CALL") })
아이템 18: 코딩 컨벤션 지켜라
Coding conventions | Kotlin
kotlinlang.org
코틀린은 굉장히 잘 정리된 코딩 컨벤션을 갖고 있다.
이 컨벤션을 잘 지켜야 다른 개발자들도 쉽게 코드를 이해하고 추측할 수 있으며, 코드 병합이나 이동이 쉽다.
아래 도구는 컨벤션을 지키는데 도움을 준다.
- Intellij 포매터
- 공식 코딩 컨벤션 스타일에 맞춰 코드 변경
- Settings → Editor → Code style → Kotlin 오른쪽 위 “Set from…” 링크 클릭 후 ‘Predefined style/Kotlin style guide’를 선택
- ktlink
- 자주 사용되는 코드 분석 후 컨벤션 위반을 알려주는 린터
- EditorConfig
- 많은 개발자들이 다양한 Editor나 IDE의 관계없이 일정한 코드 스타일을 유지하기 위해 도와주는 설정파일
- Intellij에서 적용 방법
EditorConfig
What is EditorConfig? EditorConfig helps maintain consistent coding styles for multiple developers working on the same project across various editors and IDEs. The EditorConfig project consists of a file format for defining coding styles and a collection o
editorconfig.org
EditorConfig | IntelliJ IDEA
www.jetbrains.com
자주 위반되는 규칙
class FullName(val name: String, val surname: String)
class Person(
val id: Int = 0,
val name: String = "",
val surname: String = ""
)
파라미터가 많은 클래스는 파라미터를 한 줄씩 적는 규칙이다.
비슷하게 함수도 파라미터가 많다면 동일하게 한 줄씩 작성한다.
하지만 아래는 좋지 않은 예시이다.
// Don't do like this
class Person(val id: Int = 0,
val name: String = "",
val surname: String = "") : Human(id, name){
}
위 코드는 클래스의 이름에 따라 다른 크기의 들여쓰기를 갖는다.
또한 클래스가 차지하는 공간의 너비가 너무 크다.
'Kotlin' 카테고리의 다른 글
[이펙티브 코틀린] 안정성 (0) | 2024.02.21 |
---|---|
[kotlin] 코틀린 코루틴 테스트 (0) | 2024.01.07 |
[kotlin] Flow (0) | 2024.01.07 |
[kotlin] Select (0) | 2024.01.07 |
[kotlin] Channel (0) | 2024.01.07 |