* 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)의 Chapter1: Safety의 내용을 다룬다.
코틀린은 안정성에 강점을 지니는 언어이다. 이러한 강점을 더 최대화할 수 있는 코틀린 활용법을 알아보자.
코틀린에서는 아래와 같이 상태를 담을 수 있다.
var a = 10
val list: MutableList<Int> = mutableListOf()
상태를 갖는 다는 건 유용하지만 아래와 같은 단점도 지닌다.
val
copy
메서드각각에 대해 구체적으로 살펴보자.
val
이 read-only라 하여 immutable하거나 final인 것은 아니다.
val
은 mutable object를 담을 수도 있고, custom getter를 설정할 수도 있다.
val list = mutableListOf(1, 2, 3)
list.add(4) // mutable object를 담고 있다.
val name = "Marcin"
val surname = "Moskala"
val fullName
get() = "$name $surname"
따라서 val
은 flexible 하다는 장점을 지닌다.
custom getter 설정시 value에 접근할 때마다 값이 새로 생성된다.
즉, 접근할 때마다 값이 달라지므로 smart-cast가 지원되지 않는 점을 유의하자.
val name: String? = "Marcin"
val surname: String? = "Moskala"
val fullName
get() = name?.let { "$it $surname" }
val fullName2 = name?.let { "$it $surname" }
fun main() {
if (fullName != null) {
println(fullName.length) // Error: 값이 새로 계산되면서 null로 바뀔 수 있다.
}
if (fullName2 != null) {
println(fullName2.length) // OK.
}
}
mutable collections는 MutableCollection
, MutableIterable
과 같은 것들이며,
read-only collections는 Collection
, Iterable
과 같은 것들이다.
각 mutable interface는 이에 상응하는 read-only interface를 상속받으며, 변경을 위한 메서드만 추가한 구조이다.
하지만 read-only interface를 mutable interface로 down-casting 해선 안된다.
read-only를 mutable하게 바꾸고 싶다면 List.toMutableList
함수를 사용해 변경가능한 리스트 복사본을 만들어야 한다.
val list = listOf(1, 2, 3)
// 절대 안된다!
if (list is MutableList) {
list.add(4)
}
copy
메서드immuable object를 사용하면 여러 장점이 있다.
하지만 immutable object의 최대 단점은 data의 변경이 필요할 때도 분명 있다는 것이다.
따라서 변경을 적용한 새로운 object를 생성하는 방식을 사용한다.
plus
, minus
메서드는 값이 변경된 새 Int를 반환한다.map
, filter
메서드는 조건에 맞춰 처리된 새 collection을 반환한다.이와 마찬가지로 data class의 copy를 활용할 수 있다.
data class User(
val name: String,
val surname: String,
)
var user = User("Hello", "World")
user = user.copy(surname = "Kotlin")
print(user) // User(name=Hello, name=Kotlin)
mutable object를 사용하는 것보다 효율성은 떨어지지만, 그 이상의 장점을 갖는 방식이다.
변경가능한 list를 만들 때 둘중 어떤 방법이 더 좋을까?
(1) val list1: MutableList = mutableListOf()
(2) var list2: List = listOf()
(1)의 경우 완전히는 아니지만, 멀티스레딩에 어느정도 대응한다. 또한 (2)에 비해 살짝 더 빠르다.
(2)의 경우 멀티스레딩 환경에서의 동기화에 직접 대응해야 한다. 하지만 custom setter 및 delegate를 통해 object가 수정되는 과정을 추적할 수 있다는 점에서 장점이 있다.
두 방식 모두 맞는 방법이며,var list = mutableListOf<Int>()
와 같은 방식만 지양하도록 하자.
상태를 정의할 때, 최대한 좁은 범위로 변수 및 프로퍼티를 활용해야 한다.
var
대신 val
로 선언한다.범위를 좁힐 수록 프로그램을 관리하고 추적하기 쉬워지며, 다른 개발자에 의한 잘못된 사용을 방지할 수 있다.
또한 변수를 선언할 때 초기화도 동시에 진행하자.
// Bad
fun updateWeather(degrees: Int) {
val color: Int
val description: String
if (degree < 5) {
description = "cold"
color = Color.Blue
}
...
}
// Better
fun updateWeather(degrees: Int) {
val (description, color) = when {
degrees < 5 -> "cold" to Color.Blue
...
}
}
플랫폼 타입은 가능한 빨리 제거하는 것이 좋다.
플랫폼 타입이란?
: 다른 언어로부터 반환되어 nullability가 명확하지 않은 타입이다.
플랫폼 타입은 프로그래머가 코드로 직접 작성할 수 없다. (non-denotable)
예를 들어 자바 API를 호출할 때, @Nullable 혹은 @NotNull annotation이 명시적으로 없으면 반환값의 nullability를 규정하기 어렵다.
(코틀린 단에서 안전하게 모든 반환값을 nullable하다고 해석할 수도 있지만, 이 경우 사실상 불필요한 null checking 로직이 매번 수반되어 코드가 복잡해진다.)
플랫폼 타입을 처리하지 않으면 나중에 NPE 발생시 에러 발생 지점을 캐치하기 어려우며, 최악의 경우 플랫폼 타입이 전파될 수도 있다.
따라서 가능한 빨리 없애는 것이 좋은 방법이다.
아래 예시는 플랫폼 타입을 빨리 제거했을 때 에러 캐치가 더 쉽다는 장점을 보여준다.
// Java
public class JavaClass {
public String getValue() {
return null;
}
}
// Kotlin
fun statedType() {
val value: String = JavaClass().value // 해당 라인에서 NPE가 발생하므로 비교적 쉽게 에러 캐치가 가능하다.
println(value.length)
}
fun platformType() {
val value = JavaClass().value
println(value.length) // 함수가 호출된 라인에서 NPE가 발생하므로 에러 원인 파악이 비교적 어렵다.
}
코틀린의 타입 추론은 매우 유용하지만, 추론된 타입에만 의존하는 것은 위험하다.
타입 추론은 해당 타입의 super class나 interface까지는 대변하지 못한다.
open class Animal
class Zebra: Animal()
fun main() {
var animal = Zebra()
animal = Animal() // Error: Type mismatch (super class임에도 타입 에러가 난다.)
var animal2: Animal = Zebra()
animal2 = Animal() // OK.
}
특히 외부에 노출되는 API의 경우 타입을 명시적으로 지정하는 것이 좋다.
추론된 타입은 개발 과정에서 쉽게 바뀔 수 있기 때문이다.
interface CarFactory {
fun produce(): Car
}
val DEFAULT_CAR: Car = Fiat126P()
위와 같은 코드가 있다고 하자.
대부분의 차 공장에서 DEFAULT_CAR
를 생산하므로, produce()
의 디폴트 반환값으로 지정하고자 한다.
interface CarFactory {
fun produce() = DEFAULT_CAR // 이 때까지 반환 타입은 Car이다.
}
다른 개발자가 컴파일러의 타입 추론에 기대 DEFAULT_CAR
의 명시적 타입 지정을 지웠다고 가정해보자. (val DEFAULT_CAR = Fiat126P()
)
이 때 produce()
의 반환 타입이 Fiat126P
로 바뀌어 API가 의도와 다르게 훼손된다.
타입 추론은 때때로 제한적이고 쉽게 바뀐다.
따라서 타입이 불확실할 때는 타입을 명시적으로 지정해주는 게 좋으며, 특히 외부에 노출되는 API는 타입을 항상 명시해주어야 한다.
인자와 상태에 대해 처리해야 할 조건이 있다면, 최대한 빨리 선언하는 것이 좋다.
코틀린의 여러 메서드를 사용해 이를 처리할 수 있다.
require
block: 인자에 대한 조건 처리를 할 때 사용한다.check
block: 상태에 대한 조건 처리를 할 때 사용한다.assert
block: 특정 조건이 true
인지 확인할 때 사용한다.fun factorial(n: Int) {
require(n >= 0)
return if (n <= 1) 1 else factorial(n - 1) * n
}
fun sendEmail(user: User, message: String) {
requireNotNull(user.email)
require(isValidEmail(user.email)) { // smart casting 되어 user.email을 non-null value로 인식한다.
"user email is invalid"
}
}
위와 같이 require
함수를 활용해 인자값에 대한 제약 조건을 걸 수 있다.
만약 제약 조건에 맞지 않다면 함수에서 IllegalArgumentException
을 발생시킨다.
람다로 exception 메세지 또한 지정해줄 수 있다.
requireNotNull
과 같은 특수 함수를 사용해 smart casting의 도움을 받을 수도 있다.
fun speak(text: String) {
check(isInitialized)
// ...
}
특정 상태(예: 오브젝트가 초기화된 상태 등)에서만 함수 사용을 해야할 때 check
함수를 활용하면 좋다.
제약 조건에 맞지 않을 경우 IllegalStateException
을 발생시킨다.
마찬가지로 exception message를 지정해줄 수 있으며, checkNotNull
과 같은 null checking 함수도 활용 가능하다.
더 효과적인 유닛 테스트를 위해 assert
함수를 활용할 수 있다.
fun pop(num: Int = 1): List<T> {
// ...
assert(ret.size == num)
return ret
}
// Unit test
class StackTest {
@Test
fun `Stack pops correct number of elements`() {
val stack = Stack(20) { it }
val ret = stack.pop(10)
assertEquals(10, ret.size)
}
}
예제코드를 보았을 때, pop()
내부적으로 정상적으로 동작이 되었는지 assert
함수로 확인을 거친다.
따라서 더 많은 케이스에 대응해 테스트의 정확도를 높일 수 있다.
assert
함수는 production 코드에서는 어떠한 에러도 발생시키지 않으며, 테스트를 실행할 때만 동작한다.
이와 같이 require
, check
, assert
를 활용해 제약 조건을 더 가시적으로 표현할 수 있다.
가능하다면 custom exception을 직접 정의해 사용하기 보단, 표준 라이브러리에서 제공하는 기본 exception들을 사용하는 게 좋다.
이 방식이 개발자들이 공통적으로 이해하기도, 재사용하기도 쉽다.
가장 많이 사용되는 exception들은 다음과 같다.
IllegalArugmentException
, IllegalStateException
IndexOutOfBoundsException
: collections, arrays 등에 접근할 때 인덱스가 범위에서 벗어나면 발생ConcurrentModificationException
UnsupportedOperationException
: Interface Segregation Princicple을 위반했을 때 발생NoSuchElementException
함수에서 예상치 못한 결과가 발생할 때가 종종 있다. (예: 네트워크 문제로 서버에서 데이터 로드 실패 등)
이러한 상황을 다루는 방법은 크게 두가지가 있다.
이 두가지 중 전자, 즉 null 및 Failure 반환이 더 적절한 방식이다.
exception의 경우 특수한 상황에서만 발생되어야 하며, 일반적으로는 null 혹은 Failure를 반환해야 한다.
exception은 코틀린에서 unchecked, 즉 핸들링이 강제되지 않으며 가독성도 떨어지기 때문이다.
sealed class Result<out T>
class Success<out T>(val result: T) : Result<T>()
class Failure(
val throwable: Throwable
) : Result<Nothing>()
위와 같이 Failure
class를 정의해 활용할 수 있다.
그렇다면 null 혹은 Failure
중 어떤 것을 반환하는 게 더 적절할까?
실패 상황에 대한 추가 정보를 전달하려면 Failure
class를, 아니면 null을 반환하면 된다.
Failure
는 당연히 class로서 필요한 정보를 담을 수 있다.
null은 프로퍼티 및 함수의 타입으로 활용되며 여러 의미를 가질 수 있다.
nullable value는 무조건 핸들링 되어야 하기 때문에 null 사용 의도를 분명히 해야 한다.
nullable type을 핸들링하는 방식으론 크게 세 가지가 있다.
각각에 대해 자세히 살펴보자.
printer?.print() // safe call
if (print != null) {
printer.print() // smart casting
}
val printerName = printer?.name ?: "unnamed" // elvis operator
safe call, smart casting, 및 elvis operator를 활용해 위와 같이 처리할 수 있다.
위 코드 예시와 같이 safe handling을 통해 printer를 처리할 때 단점이 있다.
예기치 못한 문제로 printer가 null이 될 경우, 의도한 메서드 자체가 호출되지 않으므로 개발자 입장에서는 당혹스러울 수 있다는 것이다.
확실하게 맞지 않는 상황에선 null 핸들링 대신 에러를 발생시키는 게 낫다.
requireNotNull
, checkNotNull
, !!
(not-null assertion)을 통해 쉽게 구현할 수 있다.
다만 !!
사용은 최대한 지양되어야 한다. 아무런 설명이 없는 generic exception(NPE)을 발생시켜 문제 파악이 어려울 수 있다.
설사 null이 절대 될일 없어보이는 코드이더라도, 나중에는 코드가 어떻게 변할지 모른다.
사용하기 간단한만큼 남용에 더 유의하고 사용을 지양하자.
가능하다면 nullable하지 않은 타입을 유도하는 것이 좋다.
collection의 결과로 아무런 아이템이 없으면, null이 아닌 empty collection을 반환해야 한다.
특정 프로퍼티의 초기화 여부를 체크하기 위해선 nullable type이 아닌 lateinit
및 property delegation
을 활용해야 한다.
위 리소스들은 모두 Closeable
인터페이스를 상속하며, 사용후에는 명시적으로 close
함수를 호출해 닫아줘야 한다.
(호출하지 않아도 결과적으론 GC에 의해 핸들링되나, 시간이 걸리고 그 사이에 불필요한 지출이 생긴다.)
// 지양해야 하는 방식
val reader = BufferedReader(FileReader(path))
try {
return reader.lineSequence().sumBy { it.length }
} finally {
reader.close()
}
// 지향해야 하는 방식
BufferedReader(FileReader(path)).use { reader ->
return reader.lineSequence().sumBy { it.length }
}
전통적으로는 try-finally
구문을 통해 close
함수를 호출해 리소스 처리를 해왔다.
하지만 close
자체도 에러를 발생시킬 수 있으며, 해당 에러는 핸들링되지 않는다는 점에서 위 구조는 옳지 않다.
대신, 표준 라이브러리에서 제공하는 use
함수를 사용하는 것을 권장한다.
use
는 호출시 리소스를 자동으로 닫고 에러 핸들링까지 가능하다는 점에서 장점을 가진다.
해당 함수는 Closeable
를 상속하는 모든 object에 대해 호출이 가능하다.
더 효율적이고 안전한 코드 작성을 위해 유닛 테스트 작성은 적극 권장된다.
유닛 테스트는 아래 케이스를 주로 커버한다.
유닛 테스트의 장점은 명백하다.
물론 당장 코드 작성에 오랜 시간이 걸리고, 테스트 가능한 구조로 코드 변경을 해야한다는 점이 단점으로 다가올 수 있다.
하지만 위 단점도 장기적 관점에선 더 효율적인 유지 보수를 위한 길이란 점에서 장점으로 다가온다.
잘못 짜인 유닛 테스트는 오히려 독이다.
코틀린 개발자라면 적어도 주요 로직에 대해서는 적절한 유닛 테스트 코드를 작성할 줄 알아야 한다.