class BankAccount {
var balance = 0.0
private set
fun deposit(depositAmount: Double) {
balance += depositAmount
}
@Throws(InsufficientFunds::class)
fun withdraw(withdrawAmount: Double) {
if (balance < withdrawAmount) {
throw InsufficientFunds()
}
balance -= withdrawAmount
}
}
class InsufficientFunds : Exception()
fun main() {
val account = BankAccount()
println(account.balance)
account.deposit(100.0)
println(account.balance)
account.withdraw(50.0)
println(account.balance)
}
다음 코드를 보면 BankAccount라는 클래스는 balance라는 가변 변수를 통해 상태 관리를 하고 있다.
하지만 이처럼 상태를 가지도록 코드를 작성하는 것은 단점이 존재한다.
fun main() {
var num = 0
for (i in 1..1000) {
thread {
Thread.sleep(10)
num += 1
}
}
Thread.sleep(5000)
print(num)
}
suspend fun main() {
var num = 0
coroutineScope {
for (i in 1..1000) {
launch {
delay(10)
num += 1
}
}
}
print(num)
}
다음 코드는 각각 멀티 스레드, 멀티 코루틴을 활용하여 가변 변수의 값을 변경하고 있다. 기대되는 값은 1000이지만 동기화 처리가 되어 있지 않아 몇몇 + 연산이 손실된다. 따라서 원하는 값을 반환받지 못한다.
fun main() {
val lock = Any()
var num = 0
for (i in 1..1000) {
thread {
Thread.sleep(10)
synchronized(lock) {
num += 1
}
}
}
Thread.sleep(1000)
print(num)
}
다음 코드처럼 synchronzied 키워드를 통해 동기화를 처리해 1000이라는 원하는 값을 반환받을 수 있다.
val a = 10
a = 20 // 변경 불가
val list = mutableListOf(1, 2, 3)
list.add(4)
print(list)
읽기 전용 속성은 반드시 불변이거나 final값이 아니라, 가변 객체 또한 가질 수 있다.
var name: String = "Marcin
var surname: String = "Moskala"
val fullName
get() = "$name $surname"
fun main() {
println(fullName)
name = "Maja"
println(fullName)
}
또한, 위 코드와 같이 사용자 정의 getter를 만들 수 있다.
읽기 전용 속성(val)의 값은 변경될 수 있지만, 변경 지점(mutations)를 제공하진 않는다. 이것이 var 대신 val을 선호하는 이유다.
하지만, val은 불변을 의미하진 않는다. getter 또는 delegate를 통해 정의될 수 있다. 이는 유연성을 제공하며, 필요하지 않을 때는 final을 사용한다.
val name: String? = "Marton"
val surname: String = "Braun"
val fullName: String?
get() = name?.let { "$it $surname"}
val fullName2: String? = name?.let { "$it $surname" }
fun main() {
if (fullName != null) {
println(fullName.length) // 스마트 캐스팅 불가
}
if (fullName2 != null) {
println(fullName2.length)
}
}
다음 코드에서 fullName은 getter로 지정, fullName2는 직접 값을 지정했다.
fullName은 필드 값이 존재하지 않고 getter 메소드만 존재한다. getter 메소드가 호출될 때마다 name과 surname을 확인하기 때문에 값이 달라질 수 있다. 따라서 스마트 캐스팅이 불가능하다.
반면에, fullName2는 필드 값이 존재하고 getter에 현재의 값이 저장되므로 스마트 캐스팅이 가능하다.
왼쪽의 Iterable, Collection, Set, List는 읽기 전용 인터페이스, 오른쪽의 MutableIterable, MutableCollection, MutableSet, MutableList는 왼쪽 인터페이스를 상속받은 가변 인터페이스이다.
읽기 전용 컬렉션은 반드시 불변한 것은 아니지만, 제공하는 인터페이스를 통해서는 변경 불가능하다.
inline fun <T, R> Iterable<T>.map(
transformation: (T) -> R
): List<R> {
val list = ArrayList<R>()
for (elem in this) {
list.add(transformation(elem))
}
return list
}
예를 들어, Iterable.map 함수는 ArrayList(가변 리스트)를 List(읽기 전용 인터페이스)로 변환한다.
val list = listOf(1, 2, 3)
if (list is MutableList) {
list.add(4) // 에러 발생
}
val list = listOf(1, 2, 3)
val mutableList = list.toMutableList()
mutableList.add(4)
만약, 컬렉션 안의 값을 수정하고 싶다면 List.toMutableList
메소드처럼 가변 컬렉션으로 변경하는 메소드를 사용해야 한다.
절대로 읽기 전용 인터페이스를 읽고 쓸 수 있는 인터페이스로 다운캐스팅을 하면 안된다. 이는 추상화를 무시하고 예기치 못한 결과를 초래하기 때문이다.
불변 객체를 사용했을 때 얻을 수 있는 이점은 다음과 같다.
하지만 불변 객체를 사용하더라도 때로는 데이터가 변경해야 될 때가 있다. 이를 위해 변경된 새로운 객체를 반환할 수 있어야 한다.
data class User(val name: String, val surname: String)
fun main() {
var user = User("Maja", "Markiewicz")
user = user.copy(surname = "Moskala")
print(user)
}
다음 코드처럼, data class의 copy 기능을 사용하면 손쉽게 변경된 객체를 반환받을 수 있다.
val list1: MutableList<Int> = mutableListOf()
var list2 : List<Int> = listOf()
list1 += 1
list2 += 1
각각 1을 추가한다는 점에서 결과는 같지만, 내부적인 동작 방식은 차이가 있다.
@kotlin.internal.InlineOnly
public inline operator fun <T> MutableCollection<in T>.plusAssign(element: T) {
this.add(element)
}
public operator fun <T> Collection<T>.plus(element: T): List<T> {
val result = ArrayList<T>(size + 1)
result.addAll(this)
result.add(element)
return result
}
list1 += 1은 list1.plusAssign(1)로 컬렉션에 직접 값을 추가한다.
하지만, list2 += 1은 list2.plus(1)로 ArrayList를 생성 후 기존 갑과 새로운 값을 추가하여 반환한다.
list1 += 1은 변경이 리스트 구현체 add 내에서 발생하고, list2 += 1은 plus 메소드를 사용할 때 발생한다. 변경 가능 단일 속성에만 존재하기 때문에 전반적인 보안성이 더 우수하다. (해당 속성에 대한 동기화만 필요하기 때ㅜㅁㄴ이다.)
fun main() {
var names by Delegates.observable(listOf<String>()) {
_, old, new ->
println("Names changed from $old to $new")
}
names += "Fabio" // Names changed from [] to [Fabio]
names += "Bill" // Names changed from [Fabio] to [Fabio, Bill]
}
다음 코드처럼 가변 리스트 대신 가변 속성을 사용하면 커스텀 setter나 delegate를 사용하여 속성이 어떻게 변경되는지 추적할 수 있다.
var list3 = mutableListOf<Int>()
하지만 다음과 같이, 가변 속성과 가변 컬레션을 동시에 사용하면 안된다. 속성 변경과 내부 상태 변경 모두를 동기화해야 할 뿐만 아니라, plus를 사용할지 plusAssign 메소드를 상용할지 모호해진다.
따라서, 상태를 변경하는 불필요한 방법은 생성하지 않아야 한다. 상태를 변경하는 각각의 방법은 비용이 발생하기 때문이다.
data class User(val name: String)
class UserRepository {
private val storedUsers: MutableMap<Int, User> = mutableMapOf()
fun loadAll(): MutableMap<Int, User> {
return storedUsers
}
}
다음 코드는 읽기 전용 속성 storedUsers를 loadAll 메소드를 통해 노출시킨다.
val userRepository = UserRepository()
val storedUsers = userRepository.loadAll()
storedUsers[4] = User("Kirill")
따라서 읽기 전용 속성임에도 불구하고 변경이 가능하다는 문제점이 있다.
class UserHolder {
private val user: MutableUser()
fun get(): MutableUser {
return user.copy()
}
}
이를 해결하기 위한 방법으로, 방어적 복사(defensive copy)를 이용하거나
data class User(val name: String)
class UserRepository {
private val storedUsers: MutableMap<Int, User> = mutableMapOf()
fun loadAll(): Map<Int, User> {
return storedUsers
}
}
읽기 전용 타입으로 업캐스팅하여 반환하는 방법이 있다.
상태를 정의할 때, 변수와 속성의 범위를 좁히는 것을 선호한다.
// Bad
var user: User
for (i in users.indices) {
user = users[i]
print("User at $i is $user")
}
// Better
for (i in users.indices) {
val user = users[i]
print("User at $i is $user")
}
// Same variables scope, nicer syntax
for ((i, user) in users.withIndex()) {
print("User at $i is $user")
}
첫 번째 예제에서 user
변수를 for 루프 범위 뿐만 아니라 외부에서도 접근 가능하고, 나머지 예제에서는 user
변수의 범위를 for 루프의 범위로 제한한다.
이런 방식을 선호하는 이유는 변수의 범위를 좁혀 프로그램을 추적하고 관리하기 쉽게 하기 위해서다. 또한, 범위가 넓은 변수가 다른 개발자에 의해 남용되는 것을 막기 위해서다.
// Bad
val user: User
if (hasValue) {
user = getValue()
} else {
user = User()
}
// Better
val user: User = if(hasValue) {
getValue()
} else {
User()
}
가변 변수이든 불변 변수이든 정의할 떄 초기화해서 개발자가 정의된 곳을 찾아보도록 하지 말자.
// Bad
fun updateWeather(degrees: Int) {
val description: String
val color: Color
if (degrees < 5) {
description = "cold"
color = Color.BLUE
} else if (degrees < 23) {
description = "mild"
color = Color.YELLOW
} else {
description = "hot"
color = Color.RED
}
}
// Better
fun updateWeather(degrees: Int) {
val (description, color) = when {
degrees < 5 -> "cold" to Color.BLUE
degrees < 23 -> "mild" to Color.YELLOW
else -> "hot" to Color.RED
}
}
에라토스테네스의 체를 구현하는 과정은 다음과 같다.
val primes: Sequence<Int> = sequence {
var numbers = generateSequence(2) { it + 1 }
while (true) {
val prime = numbers.first()
yield(prime)
numbers = numbers.drop(1).filter { it % prime != 0 }
}
}
print(primes.take(10).toList())
다음 코드를 실행하면 [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
가 출력된다. 이 때, prime
변수를 매번 선언하는 것이 효율적이지 못하다고 생각해 while 문 밖으로 옮기는 발상을 할 수도 있다.
val primes: Sequence<Int> = sequence {
val numbers = generateSequence(2) { it + 1 }
var prime: Int
while (true) {
prime = numbers.first()
yield(prime)
numbers = numbers.drop(1).filter { it % prime != 0 }
}
}
print(primes.take(10).toList())
하지만, 다음 코드를 실행하면 [2, 3, 5, 6, 7, 8, 9, 10, 11, 12]
가 출력된다.
이러한 결과가 발생하는 이유는 변수 prime
이 캡쳐되었기 때문이다. Sequences를 사용하므로 필터링은 게으르게(lazily) 이루어진다. 따라서, 해당 코드에서는 항상 마지막 prime
의 값으로 필터링된다. 이로 인해 필터링이 제대로 동작하지 않는다.
가능한 한 좁은 범위에 변수를 정의하는 것이 좋다. 또한 지역 변수는 var
보다 val
을 선호해야 한다. 변수가 람다에 캡쳐된다는 사실도 항상 인지해야 한다.
플랫폼 타입이란 다른 언어로부터 가져온 타입이며, null 여부를 알 수 없는 타입
이다.
// Java
public class UserRepo {
public User getUser() {
// ...
}
}
// Kotlin
val repo = UserRepo()
val user1: User = repo.user!
val user2: User = repo.user
val user3: User? = repo.user?
Kotlin 변수나 속성에 플랫폼 값이 할당될 때, 유추될 수는 있지만 명시적으로 설정할 수 없다. 대신 위와 같이 예상되는 타입을 선택할 수 있다.
하지만, 현재 null이 아니라고 가정한 것이 미래에는 null을 반환할 수도 있기 때문에 주의를 기울여야 한다. 이를 방지하기 위해 @Nullable
, @NotNull
같은 어노테이션을 표시하는 것이 좋다.
// Java
public class JavaClass {
public String getValue() {
return null;
}
}
// Kotlin
fun statedType() {
val value: String = JavaClass().value
// ...
println(value.length)
}
fun platformType() {
val value = JavaClass().value
// ...
println(value.length)
}
다음 코드에서 getValue가 null을 반환하지 않을 것이라고 예상했기 때문에 두 경우 모두 NPE(Null Pointer Exception)
이 발생한다. 하지만 에러가 발생하는 곳은 다르다.
statedType 메소드에서는 String 타입이 아닌 null 값이 들어와 값을 가져오는 위치에서 NPE
가 발생한다. platformType 메소드에서는 플랫폼 타입으로 지정하였기 때문에 nullable 일 수도 아닐 수도 있다. 따라서, println(value.length)를 호출하는 과정에서 NPE
가 발생한다. 값을 찾는 곳에서 에러가 발생하기 때문에 오류를 찾기가 어려울 수 있다.
interface UserRepo {
fun getUserName() = JavaClass().value
}
class RepoImpl : UserRepo {
override fun getuserName(): String? {
return null
}
}
fun main() {
val repo: UserRepo = RepoImpl()
val text: String = repo.getUserName() // NPE 발생
플랫폼 타입을 더 넓은 범위로 전파한다면 누구나 타입이 nullable인지 아닌지를 결정할 수 있기 때문에 문제가 심각해질 수 있다.
플랫폼 타입이란 다른 언어에서 가져온 null 여부를 알 수 없는 타입이다. 이러한 플랫폼 타입은 위험하기 떄문에 가능한 한 빨리 제거하고 전파되지 않도록 해야 한다. 또한 노출된 Java 생성자, 메소드 및 필드에 null 여부를 지정하는 주석 (@NotNull, @Nullable)을 사용하여 타입을 명시하는 것이 좋다.
타입 추론은 기본적으로 상위 클래스나 인터페이스의 타입으로 추론되지 않는다. 다음 코드를 살펴보자.
open class Animal
class Zebra: Animal()
fun main() {
var animal = Zebra()
animal = Animal() // 타입 불일치
}
animal은 Zebra의 상위 클래스인 Animal로 타입 추론이 되지 않고, Zebra로 추론되기 때문에 에러가 발생한다.
open class Animal
class Zebra: Animal()
fun main() {
var animal: Animal = Zebra()
animal = Animal()
}
이 문제는 타입을 명시적으로 선언하면 해결할 수 있다.
외부 라이브러리나 모듈을 사용할 때도 추론된 타입을 사용하는 것은 지양해야 한다.
interface CarFactory {
fun produce(): Car = DEFAULT_CAR
}
DEFAULT_CAR: Car = Fiat126P()
CarFactory 인터페이스가 존재하고 produce 메소드에서는 Car를 반환하도록 명시적으로 지정되어있다.
interface CarFactory {
fun produce() = DEFAULT_CAR
}
여기서 DEFAULT_CAR가 Car를 명시적으로 반환하기 때문에 produce 메소드에서 추론된 타입을 사용한 코드이다. 여기까지는 크게 문제가 되지 않는다.
val DEFAULT_CAR = Fiat126P()
하지만, 누군가가 이 코드를 유지보수할 때, DEFAULT_CAR가 추론된 타입을 사용하도록 하면 문제가 된다. 기존에 Car를 반환하는 의도와는 다르게 Fiat126P만을 반환하게 된다.
따라서, 리턴 타입을 명시적으로 지정해주는 것이 중요하다.
타입에 대해 확신이 없다면, 반드시 명시해야 한다. 또한, 보안을 위해 외부 API에서는 항상 타입을 명시해야 하며 임의로 변경되어서는 안 된다.
기대하는 바가 있다면 다음 수단을 통해 명시하자
require
- 인수에 대해 기대하는 바를 명시, 참이 아니면 IllegalArgumentException
발생check
- 상태에 대해 기대하는 바를 명시, 참이 아니면 IllegalStateException
발생assert
- 참으로 생각하는 바에 대한 체크return
이나 throw
를 활용한 Elvis 연산자fun pop(num: Int = 1): List<T> {
require(num <= size) {
"Cannot remove more elements than current size"
}
check(isOpen) { "Cannot pop from closed stack" }
val collection = innerCollection ?: return emptyList()
val ret = collection.take(num)
innerCollection = collection.drop(num)
assert(ret.size == num)
return ret
}
이와 같이 기대 사항을 명시하면 다음과 같은 이점이 있다.
스마트 캐스팅
: 변수나 식을 자동으로 적절한 유형으로 캐스팅
fun factorial(n: Int): Long {
require(n >= 0)
return if (n <= 1) 1 else factorial(n - 1) * n
}
fun sendEmail(user: User, title: String, message: String) {
requireNotNull(user.email)
require(isValidEmail(user.email)
}
인수에 대한 요구사항 명시는 require
를 사용한다. 요구사항을 충족하지 못할 시 IllegalArgumentException
을 throw한다.
requireNotNull
같이 변형된 형태도 존재한다.
fun factorial(n: Int): Long {
require(n >= 0) { "Cannot calculate factorial of $n because it is smaller than 0" }
return if (n <= 1) 1 else factorial(n - 1) * n
다음 코드와 같이 lambda 식을 통해 에러 메시지도 전달할 수 있다.
fun speak(text: String) {
check(isInitialized)
// ...
}
fun getUserInfo(): UserInfo {
checkNotNull(token)
// ...
}
상태에 대한 요구사항 명시는 check
를 사용한다. 요구사항을 충족하지 못할 시 IllegalStateException
을 throw한다.
checkNotNull
같이 변형된 형태도 존재한다.
fun getUserInfo(): UserInfo {
checkNotNull(token) { "Cannot find user info" }
}
마찬가지로 lambda 식을 통해 에러 메시지를 전달할 수 있다.
올바르게 구현했다면, 참이어야만 하는 것들이 있다. 예를 들어, 10개의 요소를 반환하는 함수를 구현했다면 10개의 요소를 반환해야 한다. 이를 확인하기 위해 assert
를 사용한다.
class StackTest {
@Test
fun `Stack pops correct number of elements`() {
val stack = Stack(20) { it }
val ret = stack.pop(10)
assertEquals(10, ret.size)
}
}
오류가 나더라도 어떤 에러도 throw하지 않기 때문에 오류가 발생하더라도 유저에게 알리고 싶지 않을 때 사용한다.
또한, 단위 테스트가 아닌 pop 함수 내에서도 assert
를 사용할 수 있다.
fun pop(num: Int = 1): List<T> {
// ...
assert(ret.size == num)
return ret
}
단위 테스트 대신 함수 내에 assert
를 포함하는 것은 다음 장점을 가진다.
assert
를 사용할 수 있다.require
나 check
블록으로 조건 확인 시 이후부터는 해당 조건이 만족됐다고 가정하고 실행된다.
class Person(val email: String?)
fun validateEmail(email: String) { /*...*/ }
fun sendEmail(person: Person, title: String, text: String) {
val email = requireNotNull(person.email)
validateEmail(email) // String으로 스마트 캐스팅
}
따라서, 조건 확인 후에는 스마트 캐스팅이 실행된다. 다음 코드를 보면 requireNotNull
로 person.email이 null인지 아닌지 확인 후 validateEmail에 스마트 캐스팅된 email 변수를 String 처럼 사용할 수 있다.
fun sendEmail(person: Person, title: String, text: String) {
val email: String = person.email ?: return
// ...
}
Nullability를 체크하기 위해 throw나 return를 활용한 Elvis 연산자를 사용할 수 있다.
fun sendEmail(person: Person, title: String, text: String) {
val email: String = person.email ?: run {
log("Email not sent because of lack of email address")
return
}
// ...
}
Nullability 체크 후, 특정 동작을 수행하고 return 이나 throw 를 하고 싶은 경우 run을 사용할 수 있다.
가능하면 사용자 정의 오류보다 표준 오류를 사용해야 한다. 표준 오류는 이미 많은 개발자들이 알고 있으므로 코드를 더 쉽게 이해하는 데 도움이 된다.
다음은 몇 가지 표준 오류들이다.
때때로, 원하는 결과를 받지 못하는 상황이 있을 수 있다.
이러한 상황은 두 가지 방법을 통해 해결할 수 있다.
하지만 예외는 정보를 전달하는 방버으로 사용하면 안되고, 잘못된 특별한 상황을 처리해야 한다. 그 이유는 다음과 같다.
inline fun <reified T> String.readObjectOrNull(): T? {
// ...
if (isCorrectSign) {
return null
}
// ...
return result
}
inline fun <reified T> String.readObject(): Result<T> {
// ...
if (isCorrectSign) {
reutrn Failure(JsonParsingException())
}
// ....
return Success(result)
}
sealed class Result<out T>
class Success<out T>(val result: T): Result<T>()
class Failure(val throwable: Throwable): Result<Nothing>()
class JsonParsingException: Exception()
이러한 에러 처리 방식은 try-catch 블록보다 효율적이며, 사용하기 쉽고 더 명확하다.
val age = userText.readObjectOrNull<Person>?.age ?: -1
null을 반환한다면, Elvis 연산자를 통해 표현할 수 있고
sealed class Result<out T>
class Success<out T>(val result: T): Result<T>()
class Failure(val throwable: Throwable): Result<Nothing>()
val age = when(val person = userText.readObjectOrNull<Person>()) {
is Success -> person.age
is Failure -> -1
}
result 클래스를 사용할 경우 when 표현식을 이용해 다음 코드와 같이 사용할 수 있다.
nullable한 결과와 sealed result class를 비교할 때, 실패할 경우 추가 정보를 전달하고 싶다면, 후자를 선호해야 한다. 그렇지 않은 경우 null을 사용한다.
실제로 이러한 에러 처리 방식은 List의 getOrNull
함수에서 찾아볼 수 있다.
get
: 주어진 위치에 요소가 있지 않다면 IndexOutOfBoundsException을 throw한다.getOrNull
: 주어진 위치에 요소가 있지 않다면 null을 반환한다. ?: 을 사용해 안전하게 처리할 수 있다.함수에서 null을 반환한다면 함수에 따라 null의 의미가 다를 수 있다.
String.toIntOrNull()
: String이 Int로 파싱이 불가능할 때 null 반환Iterable<T>.firstOrNull(() -> Boolean)
: 해당하는 요소가 없을 때 null 반환따라서, null은 해석의 의지가 다분하기 때문에, 가능한 한 의미가 명확해야 한다.
nullable 타입은 3가지 방법으로 다룰 수 있다.
printer?.print() // Safe call
if (printer != null) printer.print() // Smart casting
Safe call이나 스마트 캐스팅을 통해 null을 안전하게 처리할 수 있다.
null이 아니어야만 하는 상황에서, null일 경우 에러를 throw함으로써 개발자가 에러를 쉽게 찾아낼 수 있다.
fun process(user: User) {
requireNotNull(user.name)
val context = checkNotNull(context)
val networkService =
getNetworkService(context) ?: throw NoInternetConnection()
networkService.getData { data, userData ->
show(data!!, userData!!)
}
}
throw를 사용하거나 !!, requireNotNull, checkNotNull 또는 다른 오류를 throw하는 함수를 사용하여 이를 수행할 수 있다.
not-null assertion 연산자 !!는 주로 타입이 nullable하지만 null이 예상되지 않는 상황에서 사용되며 null일 시 NPE(NullPointerException)
을 발생시킨다.
사용하기 쉽다는 장점이 있지만 에러를 throw하는 방식에 비해 정보를 적게 제공한다는 단점이 있다. 또한 !!을 선언한 변수도 추후에는 null이 될 수 있는 가능성이 존재한다. 이 때, nullabilty를 숨기게 된다는 단점도 있다.
따라서, 최대한 !!의 사용은 피해야 한다.
Nullabilty를 위해서는 비용이 발생한다. 따라서, 의미없는 nullabilty를 피해야 한다.
lateinit
이나 notNull delegate
를 사용한다.class UserControllerTest {
private lateinit var dao: UserDao
private lateinit var controller: UserController
@BeforeEach
fun init() {
dao = mockk()
controller = UserController(dao)
}
@Test
fun test() {
controller.doSomething()
}
클래스 생성 중 초기화할 수 없지만 첫 사용 전에 반드시 초기화되는 변수의 경우 lateinit를 사용할 수 있다.
class DoctorActivity : Activity() {
private var doctorId: Int by Delegates.notNull()
private var fromNotification: Boolean by Delegates.notNull()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
doctorId = intent.extras.getInt(DOCTOR_ID_ARG)
fromNotification = intent.extras.getBoolean(FROM_NOTIFICATION_ARG)
}
}
lateinit을 사용할 수 없는 경우는 JVM에서 원시 타입(Int, Long, Double, Boolean 등으으로 초기화하는 경우이다. 이러한 경우 Delegates.notNull
을 사용해야 한다.
class DoctorActivity: Activity() {
private var doctorId: Int by arg(DOCTOR_ID_ARG)
private var fromNotification: Boolean by arg(FROM_NOTIFICATION_ARG)
}
다음 코드처럼 property delegate pattern
을 사용할 수도 있다.
Kotlin에서 일부 리소스는 자동으로 닫히지 않으며, 더 이상 필요하지 않을 때, close 메소드를 호출해야 한다.
이러한 리소스는 AutoCloseable을 상속받는 CLoseable 인터페이스를 구현하고 있다. 전통적으로, 해당 리소스를 닫기 위해 try-finally
블록으로 감싸고 close
를 호출했다.
fun countCharactersInFile(path: String): Int {
val reader = BufferedReader(FileReader(path))
try {
return reader.lineSequence().sumBy { it.length }
}
} finally {
reader.close()
}
}
하지만 이러한 구조는 문제점이있다. close 메소드는 예외를 throw할 수 있는데, 해당 예외는 catch되지 않을 것이다. 또한, try 블로과 finally 블록 모두 에러가 발생하면 한 가지만 올바르게 전파가 된다.
fun countCharactersInFile(path: String): Int {
BufferedReader(FileReader(path)).use { reader ->
reutrn reader.lineSequence().sumBy { it.length }
}
}
이를 use
메소드를 사용함으로써 처리할 수 있다.
fun countCharactersInFile(path: String): Int {
File(path).useLines { lines ->
reutrn lines.sumBy { it.length }
}
}
추가로 파일을 한 줄 씩 처리하는 useLines
메소드도 존재한다. 메모리에 파일 전체가 아닌, 파일의 내용을 한 줄씩만 유지하므로 대용량 파일도 적절하게 처리할 수 있다. 하지만, 파일의 특정 줄을 두 번 이상 반복처리하려면 파일을 여러 번 열어야 된다는 단점이 있다.
시스템의 구체적인 요소가 올바르게 작동하는지 확인하고 개발 중에 빠른 피드백을 받기 위해 단위 테스트를 작성할 필요가 있다.
@Test
fun `fib works correctly for the first 5 positions`() {
assertEquals(1, fib(0))
assertEquals(1, fib(1))
assertEquals(2, fib(2))
assertEquals(3, fib(3))
assertEquals(5, fib(4))
}
다음 코드는 피보나치 수를 계산하는 fib 메소드가 올바른 값을 반환하는지 확인하는 코드이다.
이러한 단위 테스트에서 확인하는 내용은 다음과 같다.
단위 테스트를 통해 다음과 같은 이점을 얻을 수 있다.
물론, 단위 테스트의 단점도 존재한다.
테스트 코드를 작성해야 하는 중요 파트들은 다음과 같다.
프로그램이 올바르게 작동하는지 확인하기 위해 단위 테스트를 수행하는 것이 중요하다.