컴퓨터가 인식할 수 있는 코드는 바보라도 작성할 수 있지만, 인간이 이해할 수 있는 코드는 실력 있는 프로그래머만 작성할 수 있다.
- 마틴 파울러(Martin Fowler), <리팩토링>
코틀린은 간결성을 목표로 설계된 프로그래밍 언어가 아니라, 가독성(readability)을 좋게 하는 데 목표를 두고 설계된 프로그래밍 언어입니다.
아이템 11 - 가독성을 목표로 설계하라
개발자는 어떤 코드를 작성하는 것보다 읽는 데 많은 시간을 소모한다.
예를 들어, 오류가 발생했을 때 오류를 찾기 위해 코드를 작성할 때보다 오랜 시간 코드를 읽는 자신을 발견할 수 있다.
가독성이란, 코드를 읽고 얼마나 빠르게 이해할 수 있는지를 의미합니다. 이는 우리의 뇌가 얼마나 많은 관용구(구조, 함수, 패턴)에 익숙해져 있는지에 따라 다릅니다.
숙련된 개발자만을 위한 코드는 좋은 코드가 아니며, 사용 빈도가 적은 관용구는 오히려 코드를 복잡하게 만듭니다.
// A
if (person != null && person.isAdult) {
view.showPerson(person)
} else {
view.showError()
}
// B
person?.takeIf { it.isAdult }
?.let(view::showPerson)
?: view.showError()
- 구현 A는 B에 비해 수정 및 디버깅이 간편합니다.
- 예를 들어, if 문을 통과했을 때 특정 구문을 추가로 실행해야 한다면, 구현 A는 if 문 내에 로직을 추가하면 끝나지만 구현 B는 코드가 좀 더 수정되어야 합니다.
- 디버깅 시에도, 구현 A는 if문 또는 그 내부에 디버깅을 찍으면 쉽게 확인이 되지만, 구현 B는 디버깅 포인트를 람다 내부에도 찍을 수 있고 줄 전체에도 찍을 수 있어 복잡합니다.
- 관용구에 익숙하지 않다면, 잘못된 동작의 코드를 작성할 수 있습니다.
인지 부하를 줄이는 방향으로 코드를 작성하자.
다만, 극단적으로 관용구를 사용하지 말라는 의미는 아닙니다. 디버깅하기 어렵고, 코틀린 경험치가 적을 때 이해하기 어려워 비용이 발생하더라도 해당 비용이 가치가 있다면 사용해도 괜찮습니다.
- 정당한 이유 없이 복잡성을 추가하는 것을 방지해야 하는 것입니다.
어떤 것이 비용을 지불할만한 가치가 있는지는 논란이 될 수 있으며, 균형을 맞추는 것이 중요합니다.
Score Functions
코틀린은 객체의 컨텍스트 내에서 코드 블록을 수행하고자 하는 목적으로 여러 함수를 제공합니다. 람다 표현식을 가진 객체에서 이런 함수를 호출하면, 이미 스코프를 갖게 됩니다. 이 스코프에서는 해당 객체에 이름 없이 접근이 가능합니다. scope functions에는 let
, run
, with
, apply
, also
가 있습니다.
기본적으로, 이 함수들은 동일한 동작(객체에서 코드 블록 수행)을 합니다. 다른점은, 어떻게 객체가 해당 블록에서 사용되는지와 결과물이 다릅니다.
let
: non-nullable 객체에서 람다를 수행할 때 사용apply
: 객체 초기화할 때 사용run
: 객체 초기화 또는 결과를 계산할 때 사용also
: 추가 작업할 때 사용with
: 객체에 대한 여러 함수 호출을 그루핑할 때 사용
this VS it
run
,with
,apply
는 컨텍스트 객체를 람다 리시버로 받습니다. 보통,this
은 생략될 수 있지만,this
를 생략하면 외부 객체와 리시버 사이에서 구별이 어려울 수 있습니다.let
,also
는 컨텍스트 객체를 람다 파라미터로 받습니다. 파라미터 이름을 따로 지정하지 않으면it
으로 접근 가능하며,it
을 통한 표현이 this를 이용한 것보다 읽기 쉽습니다.
반환 값
val numberList = mutableListOf<Double>()
numberList.also { println("Populating the list") }
.apply {
add(2.71)
add(3.14)
add(1.0)
}
.also { println("Sorting the list") }
.sort()
apply
와also
는 컨텍스트 객체를 반환합니다. 따라서, 호출 체이닝 중 부가 스텝으로 사용될 수 있습니다.
val numbers = mutableListOf("one", "two", "three")
val countEndsWithE = numbers.run {
add("four")
add("five")
count { it.endsWith("e") }
}
println("There are $countEndsWithE elements that end with e.")
let
,run
,with
은 람다 결과를 반환합니다. 따라서, 람다 결과를 특정 변수에 할당하고자 할 때 주로 사용됩니다.
scope function을 사용하면, 코드가 간결해지는 효과가 있습니다.
다만, 과도하게 사용하면 오히려 가독성을 헤치고 오류를 일으킬 수 있기 때문에 주의해야 합니다.
예) 현재 컨텍스트 객체와this
또는it
간에 혼동이 일어날 수 있음
아이템 12 - 연산자 오버로드를 할 때는 의미에 맞게 사용하라
연산자 오버로딩은 강력한 기능이지만, '큰 힘에는 큰 책임이 따른다'는 말처럼 위험할 수 있습니다.
코틀린의 모든 연산자는 다음 표와 같이 구체적인 이름을 가진 함수에 대한 별칭일 뿐입니다. 코틀린에서 각 연산자의 의미는 항상 같게 유지됩니다.
연산자 | 대응되는 함수 |
+a | a.unaryPlus() |
-a | a.unaryMinus() |
!a | a.not() |
++a | a.inc() |
--a | a.dec() |
a+b | a.plus(b) |
a-b | a.minus(b) |
a*b | a.times(b) |
a/b | a.div(b) |
a..b | a.rangeTo(b) |
a in b | b.contains(a) |
a+=b | a.plusAssign(b) |
a-=b | a.minusAssign(b) |
a*=b | a.timesAssign(b) |
a/=b | a.divAssign(b) |
a==b | a.equals(b) |
a>b | a.compareTo(b) > 0 |
a<b | a.compareTo(b) < 0 |
a>=b | a.compareTo(b) >= 0 |
a<=b | a.compareTo(b) <= 0 |
연산자 오버로딩이 필요한데, 의미가 누구에게나 명확하지 않다면, infix를 활용한 확장 함수를 사용해볼 수 있습니다.
infix fun
infx 키워드가 붙은 함수는 아래 요구사항을 충족해야 사용할 수 있습니다.
- 멤버 함수 또는 확장 함수여야 합니다.
- 단일 파라미터를 가져야 합니다.
- 파라미터는 가변 개수의 인자를 허용해서는 안되며, 기본값이 없어야 합니다.
infix fun Int.shl(x: Int): Int { ... }
// calling the function using the infix notation
1 shl 2
// is the same as
1.shl(2)
중위 함수 호출은 산술 연산자, type casting, rangeTo 연산자보다 우선순위가 낮습니다.
도메인 특화 언어(Domain Specific Language, DSL)를 설계할 때에는 연산자 오버로딩 규칙을 무시해도 괜찮습니다.
도메인 특화 언어(Domain Specific Language, DSL)
DSL이란, 특정 도메인에 국한해 사용하는 언어를 의미합니다.
- 반대되는 말로는 Kotlin, C++과 같이 General Purpose Language가 있습니다.
plugins {
// kotlin을 사용하기 위한 설정
kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.22"
kotlin("plugin.jpa") version "1.9.22"
application
}
DSL의 예시로는 위와 같이 build.gradle.kts에서 확인해볼 수 있습니다.
- plugins 함수는 org.gradle.kotlin.dsl 패키지에 들어있는 DSL입니다.
아이템 13 - Unit?을 리턴하지 말라
Unit?을 반환하게 되면, 모든 타입 + null을 리턴할 수 있기 때문에 예측하기 어려운 오류를 만들 수 있습니다.
따라서, Unit?을 리턴하거나 이를 기반으로 연산하지 않는 것이 좋습니다.
아이템 14 - 변수 타입이 명확하지 않은 경우 확실하게 지정하라
가독성을 위해 코드를 설계할 때 읽는 사람에게 중요한 정보를 숨겨서는 안 됩니다. 함수에 마우스오버해서 함수 정의를 확인하면 된다고 생각할 수도 있지만, gitlab과 같이 쉽게 코드에서 이동할 수 없는 환경에서 코드를 읽을 수도 있다는 점을 염두해야 합니다.
아이템 15 - 리시버를 명시적으로 참조하라
리시버
리시버란, 객체 외부의 람다 코드 블록을 마치 해당 객체 내부에서 사용하는 것처럼 작성할 수 있게 해주는 장치
block: T.() -> R
위 람다 블록은 객체 T를 receiver로 이용하여 객체 R을 반환합니다.
- 위에서 객체 T를 receiver라고 하며, receiver를 사용하는 람다를 lambda with receiver (수신 객체 지정 람다)라고 합니다.
block: (T) -> R
반면, 위와 같은 경우는 객체 T를 파라미터로 받는 람다입니다.
여러 개의 리시버가 있는 상황에서는 리시버를 명시적으로 적어주는 것이 좋습니다.
- 바깥
Node
의name
과 안쪽Node
의name
중 어떤name
이 호출되는지는 명시한 리시버에 따라 달라집니다. - 어떤 파라미터를 사용할지 명시하면 좀 더 가독성을 얻을 수 있습니다.
DSL 마커
코틀린 DSL을 사용할 때는 여러 리시버를 가진 요소들이 중첩되더라도, 리시버를 명시적으로 붙이지 않습니다.
다만, 잘못된 사용이 우려될 경우 암묵적으로 외부 리시버를 사용하는 것을 막는 DslMarker라는 메타 어노테이션(어노테이션을 위한 어노테이션)을 사용합니다.
아이템 16 - 프로퍼티는 동작이 아니라 상태를 나타내야 한다
많은 사람들은 경험적으로, 프로퍼티는 상태 집합을 나타내고 함수는 행동을 나타낸다고 생각합니다.
코틀린의 프로퍼티는 자바의 필드와 비슷해보이지만 서로 다른 개념입니다.
// 코틀린의 프로퍼티
var name: String? = null
// 자바의 필드
String name = null;
- 둘 다 데이터를 저장한다는 점은 같지만, 프로퍼티에 더 많은 기능이 있습니다.
프로퍼티는 아래 예시처럼, 사용자 정의 세터와 게터를 가질 수 있습니다.
var name: String? = null
get() = field?.toUpperCase()
set(value) {
if(!value.isNullOrBlank()) {
field = value
}
}
- 위 예시에서 field라는 식별자를 확인할 수 있습니다.
- 이는, 프로퍼티의 데이터를 저장해두는 백킹 필드(backing field)에 대한 레퍼런스입니다.
- 백킹 필드는 세터와 게터의 디폴트 구현에 사용되므로, 따로 만들지 않아도 var 선언 시 자동으로 생성됩니다. (val은 생성 X)
프로퍼티는 필드가 필요하지 않습니다. 오히려 프로퍼티는 개념적으로 접근자(val의 경우 게터, var의 경우 게터와 세터)를 나타냅니다.
- 이로 인해 코틀린에서는 인터페이스에도 프로퍼티를 정의할 수 있는 것입니다.
프로퍼티는 본질적으로 함수이기 때문에, 확장 프로퍼티를 만들 수도 있습니다.
다만, 프로퍼티를 완전히 함수처럼 사용할 경우 프로퍼티로써 예상되는 작동을 하는 것이 아니기 때문에 아래 규칙에 따라 프로퍼티와 함수를 선택하여 사용하면 좋습니다.
- 연산 비용이 높거나, 복잡도가 O(1)보다 큰 경우: 프로퍼티를 사용할 때 연산 비용이 많이 필요할 것이라 여기지 않으므로 함수 사용 권장
- 비즈니스 로직을 포함하는 경우: 프로퍼티는 단순하게 값을 지정하고 조회하는 정도만 할 것이라 예상되므로 함수 사용 권장
- 변환을 할 경우: 프로퍼티가 값을 변환할 것이라고 여기지 않기 때문에 함수 사용 권장
- 게터를 통해 프로퍼티 상태 변경이 일어나야 하는 경우: 함수 사용 권장
아이템 17 - 이름 있는 아규먼트를 사용하라
이름 있는 파라미터를 사용하면 의미를 명확하게 파악할 수 있고, 신뢰할 수 있습니다.
특히, intelliJ가 아닌 gitLab과 같은 각 파라미터가 의미하는 바를 나타내주지 않아도, 파라미터를 한 눈에 파악할 수 있습니다.
이름 있는 아규먼트(Named Argument)를 사용해야 하는 시점
디폴트 아규먼트가 있는 경우
디폴트 아규먼트를 가질 경우, 이름을 항상 붙이는 것을 권장합니다. 디폴트 값을 갖을 때는 옵션 파라미터일 경우가 많아 함수 이름만으로는 해당 파라미터에 대해 알기 어렵기 때문입니다.
같은 타입의 파라미터가 많은 경우
동일한 타입의 파라미터가 많을 경우, 잘못된 순서로 파라미터를 입력할 수도 있기 때문에 실수를 막기 위해 이름 있는 아규먼트를 사용하면 좋습니다.
함수 타입의 파라미터가 있는 경우(마지막 인자 제외)
마지막 인자로 들어오는 함수 타입의 파라미터의 경우, 보통 람다로 사용되고 함수 이름에서 어떤 역할을 하는지가 드러납니다.
하지만, 함수 타입의 파라미터가 여러개 있는 경우, 이름을 붙여주지 않으면 각각이 어떨 때 하는 행동인지 알기 어렵기 때문에 이름을 붙여주는 것이 좋습니다.
이름 있는 아규먼트를 사용하면 성능에 영향을 줄까?
기본적으로, 이름 있는 아규먼트를 사용한다고 해서 성능에 영향을 주지 않습니다. 다만, 아규먼트의 순서를 선언된 순서와 다르게 전달한다면 조금 비용이 발생합니다.
아이템 18 - 코딩 컨벤션을 지켜라
코틀린 공식 문서에는 Coding conventions이 있고, 아래 사유로 지키는 것이 좋습니다.
- 어떤 프로젝트를 접해도 쉽게 이해가 가능합니다.
- 다른 외부 개발자도 프로젝트의 코드를 쉽게 이해할 수 있습니다.
- 다른 개발자도 코드의 작동 방식을 쉽게 추측할 수 있습니다.
- 코드를 병합하고, 한 프로젝트의 코드 일부를 다른 코드로 이동하기 쉽습니다.
코틀린 컨벤션에 도움되는 도구로는 IntelliJ 포매터와 ktlint가 있습니다. ktlint를 이용하면, 커밋을 하기 전에 코틀린 컨벤션을 위반한 줄을 찾아주기 때문에 컨벤션 맞추는데 도움이 됩니다.
자주 위반되는 규칙 - 클래스와 함수 형식
클래스 선언 컨벤션
기본적으로 클래스는 아래와 같이 한 줄로 선언을 합니다.
class Person(val id: Int, val name: String)
만약, 한 줄로 선언하기에 너무 길다면 indent를 활용해서 줄을 나누어 선언합니다. 이때, 닫는 괄호는 새로운 줄에 작성해야 합니다.
class Person(
val id: Int,
val name: String,
val surname: String
) : Human(id, name) { /*...*/ }
만약, 인터페이스를 여러개 가지고 있다면 각 인터페이스는 다른 줄에 선언해주어야 합니다.
class Person(
val id: Int,
val name: String,
val surname: String
) : Human(id, name),
KotlinMaker { /*...*/ }
클래스 선언 시, 파라미터들을 새로운 줄에 나열할 경우 첫 파라미터를 클래스 선언 라인에 넣으면 안됩니다.
class Person(val id: Int,
val name: String,
val surname: String
) : Human(id, name) { /*...*/ }
- 모든 클래스의 아규먼트는 클래스 이름에 따라 다른 크기의 들여쓰기를 갖게됩니다. 따라서, 클래스명이 변경될 경우 모든 파라미터에 대해 들여쓰기가 조정되어야 할 수 있습니다.
함수 선언 컨벤션
함수의 경우, 앞서서 본 클래스와 비슷하게 한 줄로 선언하기 어려운 함수의 경우 아래와 같이 선언을 합니다.
fun longMethodName(
argument: ArgumentType = defaultValue,
argument2: AnotherArgumentType,
): ReturnType {
// body
}
함수 본문이 단일 표현식일 때에는 중괄호 없이 사용하는 것이 좋습니다.
fun foo(): Int { // bad
return 1
}
fun foo() = 1 // good
스터디 준비
궁금한 점
- 아이템 12
- 실무에서 infix 확장 함수 사용한 경험이 있으신지? 있다면, 어떤 상황에서 유용했는지?
- 아이템 15
- 왜 apply보다 also나 let을 사용하는 것이 nullable 값을 처리할 때 좋은지?
- DslMarker 써 본 적 있는지?
- 아이템 16
- 프로퍼티 vs 필드 (프로퍼티는 함수다?)
- 아이템 18
- 코딩 컨벤션 중에 인터페이스 여러개 구현할 때, 각각 한 줄에 넣으라는 게 있는데요. 이 부분을 지키시는지 궁금합니다. (ktlint에 안 걸림)
- single expression일 때도 중괄호({})로 함수를 감싸는 것을 선호하시는 경우도 있는데, 컨벤션 상으로는 중괄호 없는게 맞는 것으로 보이는데요. 팀에서 이 부분에 대한 컨벤션이 있으신가요? (ktlint에 안 걸림)
고찰
- 아이템 17
- 이름 있는 아규먼트 사용 시, 기왕이면 선언된 순서대로 보내야겠다는 생각이 들었습니다. 굳이 임시 변수가 생길 이유가 없으니 순서를 지키는 습관을 들여도 좋을 것 같다고 느꼈습니다.
저번 스터디 추가 공부
kotlin에서 class가 default로 final인 이유
- 코틀린은 함수형 프로그래밍에서 아이디어를 얻어왔고, 가변으로 사용했을 때 발생하는 문제점들을 줄이기 위해 불변을 사용합니다.
- 예측 가능성을 높이고 안정성을 챙김
- 개발자들의 실수로 인해 올바른 방향으로 상속을 하지 않는 경우가 있어 상속이 필요할 때에만 명시적으로 열도록 했습니다.
- final 클래스와 멤버 함수를 사용할 경우 컴파일 시점에 정적 바인딩을 진행하여 컴파일 시점에 오류를 먼저 파악할 수 있어 런타임 오류를 줄일 수 있습니다.
참고 자료
- [Kotlin] apply, run, with, let, also 차이 한 번에 정리하기
- 이펙티브 코틀린(마르친 모스칼라 저)
- Kotlin docs - scope function
- Kotlin docs - coding conventions
- Does using named arguments cost performance in kotlin?
- Kotlin docs = infix notation
- kotlin dsl 간단히 알아보기
'PROGRAMMING LANGUAGE > KOTLIN' 카테고리의 다른 글
[Kotlin Coroutine] kotlinx-coroutines-test를 활용한 coroutine 테스트 (0) | 2024.06.29 |
---|---|
[Kotlin Coroutine] 고급 코루틴 구조 및 패턴 이해 (0) | 2024.06.27 |
[Effective Kotlin] 1장 안정성 (0) | 2024.06.23 |
[Kotlin Coroutine] 코루틴 컨텍스트와 디스패처의 이해 (0) | 2024.06.19 |
[Kotlin Coroutine] 코루틴 빌더와 비동기 패턴의 이해 (0) | 2024.06.16 |