열한 번째, 흐름 제어입니다.
조건과 루프
if 표현식
코틀린에서 if는 표현식으로서 값을 반환할 수 있습니다. 그래서, 코틀린에는 3항 연산자( 조건 ? 그러면 : 아닌경우)가 없습니다. 왜냐하면, 평범한 if 표현식이 3항 연산자를 대체할 수 있습니다.
fun main() {
val a = 2
val b = 3
var max = a
if (a < b) max = b
// else와 같이 사용
if (a > b) {
max = a
} else {
max = b
}
// 표현식
max = if (a > b) a else b
// 표현식에서 `else if` 사용
val maxLimit = 1
val maxOrLimit = if (maxLimit > a) maxLimit else if (a > b) a else b
println("max is $max")
println("maxOrLimit is $maxOrLimit")
}
문(statement)과 표현식(expression)
이 연재에서 표현식이라는 말이 자주 나오고 있습니다. 그리고, if는 보통 문(statement)으로 쓰이나 Kotlin에서는 if를 표현식으로도 쓸 수 있기 때문에 그 사용법에 대해서 얘기하고 있습니다. 이 후에도 문과 표현식으로 쓰이는 것들이 나오기 때문에 여기에서 둘의 명확한 구분을 위해 간단히 얘기해 보도록 하겠습니다.
프로그래밍 언어에서 문과 표현식은 위키피디아 내용대로 정의하면 다음과 같습니다.
문(statement)
실행되야할 행동을 표현하는 명령형(imperative) 프로그래밍 언어에서의 구문적 단위입니다. 명령형 프로그래밍 언어로 작성된 프로그램은 하나 이상의 문으로 구성됩니다. 문은 표현식 같은 내부 컴포넌트를 가질 수 있습니다. ...
표현식
표현식은 값을 결정하기 위해 평가될 수 있는 구문적 엔티티입니다. ...
명령형 프로그래밍 언어 같은 몇가지 더 설명해야 할 부분들이 있지만 여기에서 얘기하려는 내용의 범위를 벗어나므로 생략하고, 표현식에 초점을 맞춰보면. 표현식은 값을 반환으로 평가, 즉 값을 반환할 수 있다는 것을 의미합니다. 이 의미는 다르게 애기하면 할당하는 부분에는 표현식을 사용할 수 있다고 얘기할 수 있습니다.
그러므로, 문은 (한 줄 또는 그 이상에)표현되어 그 자체로 수행되야할 행위를 표현하는 단위이며, 표현식은 해당 코드 내용이 평가(해석)되어 하나의 값으로 해석될 수 있는 부분입니다.
※ 언어마다 문이나 표현식의 정확한 해석은 보통 BNF로 표현되는 해당 언어의 문법 정의를 확인해야 합니다. 예를 들어, Kotlin의 if 표현식 같은 경우 여기에서 문법적 정의를 확인할 수 있습니다.
if 표현식의 분기 부분은 블록일 수 있습니다. 이런 경우 마지막 표현식이 해당 블록의 (반환) 값이 됩니다.
val max = if (a > b) {
print("Choose a")
a // a가 b보다 큰 경우 이 표현식이 max에 할당됩니다.
} else {
print("Choose b")
b
}
if를 표현식으로 사용하는 경우 else 분기는 필수입니다.
when 표현식
when 다중 분기를 갖는 조건적 표현식을 정의합니다. 이는 C 유형의 언어에서 switch 문과 비슷합니다. when의 단순한 형태는 다음과 같습니다.
when (x) {
1 -> print("x == 1")
2 -> print("x == 2")
else -> {
print("x is neither 1 nor 2")
}
}
when은 넘겨받은 인수를 만족하는 분기 조건을 찾을 때까지 순차적으로 모든 분기와 조건을 비교해 봅니다.
when은 문(statement)과 표현식(expression) 모두로 사용될 수 있습니다. 표현식으로 사용되는 경우에는 처음으로 조건이 맞는 분기의 값이 when 표현식 전체의 값이 됩니다. 문(statement)으로 사용되는 경우에는 개별 분기의 값은 무시됩니다. if와 같이 각각의 분기는 블록일 수 있으며, 블록의 마지막 표현식의 값이 해당 블록의 값이 됩니다.
else 분기는 만족하는 조건 분기를 찾지 못했을 때 평가(사용)됩니다.
when이 표현식으로 사용될 때는 컴파일러가 각 분기들로 모든 가능한 조건이 만족됐다고 입증하지 못하는 한 else 분기는 필수입니다. 컴파일러가 else 없이 모든 조건이 충족됐다고 확인할 수 있는 예는 sealed 클래스의 하위 타입이나 아래와 같은 enum 클래스에 대한 조건 분기 등입니다.
enum class Bit {
ZERO, ONE
}
val numericValue = when (getRandomBit()) {
Bit.ZERO -> 0
Bit.ONE -> 1
// 모든 경우를 다루고 있기 때문에 else가 필요 없습니다.
}
when이 문으로 쓰이는 경우 다음 조건에서는 else가 필수입니다.
- Boolean, enum, sealed 타입이나 그에 상응하는 널 가능한 타입을 다루는 경우, when의 분기들이 모든 경우를 다루지 않을 때
enum class Color {
RED, GREEN, BLUE
}
when (getColor()) {
Color.RED -> println("red")
Color.GREEN -> println("green")
Color.BLUE -> println("blue")
// 모든 경우가 다뤄지므로 else는 불필요
}
when (getColor()) {
Color.RED -> println("red") // GREEN과 BLUE를 위한 분기가 없음
else -> println("not red") // else는 필수
}
여러 조건에 대한 공통적인 행위를 정의할 때는 해당 조건들을 , 로 구분하여 한 줄로 나타낼 수 있습니다.
when (x) {
0, 1 -> print("x == 0 or x == 1")
else -> print("otherwise")
}
분기 조건에는 상수 뿐만 아니라 임의의 표현식도 사용할 수 있습니다.
when (x) {
s.toInt() -> print("s encodes x")
else -> print("s does not encode x")
}
in 이나 !in을 사용하여 값이 범위나 컬렉션에 속하는지도 검사할 수 있습니다.
when (x) {
in 1..10 -> print("x is in the range")
in validNumbers -> print("x is valid")
!in 10..20 -> print("x is outside the range")
else -> print("none of the above")
}
is나 !is를 사용하여 값이 특정 타입인지 아닌지도 검사할 수 있습니다. 스마트 캐스트 덕분에 해당 타입에 대한 모든 메소드나 프로퍼티에 추가적인 검사 없이 접근할 수 있습니다.
fun hasPrefix(x: Any) = when(x) {
// x가 Any 타입이지만, 이 분기 조건을 만족한다면
// x는 String으로 오른쪽 부분에서는 자동으로 String 캐스트 됩니다.
is String -> x.startsWith("prefix")
else -> false
}
when은 또한 if - else if 연속의 대체제로 사용될 수 있습니다. 인수가 없고, 분기 조건은 단순한 불리언 표현식이면, 해당 조건이 만족될 때 그 분기가 실행됩니다.
when {
x.isOdd() -> print("x is odd")
y.isEven() -> print("y is even")
else -> print("x+y is odd")
}
다음과 같이 when의 인수 부분을 변수에 담을 수 있습니다(when (인수) 에서 괄호포함 인수 부분을 when subject라고 부릅니다. 문법적 정의는 여기에서 확인할 수 있습니다. Kotlin 관련 영문 문서를 보게 되는 경우도 있으니 용어는 기억해 두시면 좋습니다. 여기에서는 편의상 인수 부분이라고 표현합니다).
fun Request.getBody() =
when (val response = executeRequest()) {
is Success -> response.body
is HttpError -> throw HttpException(response.status)
}
when 인수 부분(subject)에서 정의된 변수의 유효 범위(scope)는 when의 몸체로 제한됩니다.
for 루프
for 루프는 반복자(iterator)를 제공하는 어떤 것이든 반복(iterate)합니다. 이는 C# 같은 언어에서의 foreach 루프와 동일합니다. for 구문은 다음과 같습니다.
for (item in collection) print(item)
for의 몸체는 블록일 수 있습니다.
for (item: Int in ints) {
// ...
}
앞서 언급한 것처럼, for 루프는 반복자(iterator)를 제공하는 어떤 것이든 반복(iterate)합니다. 여 기에서 어떤 것은 다음을 의미합니다.
- Iterator<>를 반환하는 iterator() 멤버나 확장 함수를 가져야 합니다.
- 반환하는 Iterator<>는 next()라는 멤버나 확장 함수를 가져야 합니다.
- 반환하는 Iterator<>는 hasNext()라는 Boolean을 반환하는 멤버나 확장 함수를 가져야 합니다.
이 세 함수는 모두 operator로 지정돼야 합니다.
숫자 범위만큼 반복하기 위해서는 범위 연산자를 사용합니다.
fun main() {
for (i in 1..3) {
println(i)
}
for (i in 6 downTo 0 step 2) {
println(i)
}
}
범위나 배열에 대한 for 루프는 반복자 객체를 만들지 않고 색인 기반 루프로 컴파일됩니다.
배열이나 리스트를 색인을 갖고 반복하고 싶은 경우 다음과 같이 할 수 있습니다.
fun main() {
val array = arrayOf("a", "b", "c")
for (i in array.indices) {
println(array[i])
}
}
/* 결과
a
b
c
*/
다른 방법으로, withIndex라는 라이브러리 함수를 사용할 수 있습니다.
fun main() {
val array = arrayOf("a", "b", "c")
for ((index, value) in array.withIndex()) {
println("the element at $index is $value")
}
}
/* 결과
the element at 0 is a
the element at 1 is b
the element at 2 is c
*/
while 루프
while과 do-while 루프는 조건이 만족하는한 계속해서 몸체를 실행합니다. 둘 사이의 차이점은 조건을 확인하는 시점입니다.
- while은 조건을 (먼저) 검사하고 만족하면 몸체를 실행한 후에 다시 돌아와서 조건을 검사합니다.
- do-while은 몸체를 실행한 후에 조건을 검사합니다. 조건이 만족되면 루프가 반복됩니다. 그래서, do-while의 몸체는 조건과 상관 없이 적어도 한 번은 실행됩니다.
while (x > 0) {
x--
}
do {
val y = retrieveData()
} while (y != null) // y는 여기에서도 범위가 유효합니다.
break와 continue
Kotlin에서는 루프에서 전통적인 break와 continue 연산자를 지원합니다. 관련된 내용은 이어지는 부분에서 설명합니다.
반환과 점프
Kotlin은 3개의 구조적 점프 표현식을 가지고 있습니다.
- return : 기본적으로, 둘러쌓인 가장 가까운 함수나 익명 함수로부터 돌아옵니다(return 반환)
- break : 둘러쌓인 가장 가까운 루프를 종료합니다.
- continue : 둘러쌓인 가장 가까운 루프의 다음 단계(반복)로 갑니다.
이 모든 표현식은 더 큰 표현식의 일부분으로 사용될 수 있습니다.
// return이 엘비스 연산자(?:)를 포함한 = 왼쪽 부분 전체 표현식의 일부로 사용된 경우
val s = person.name ?: return
이 세 표현식의 타입은 (이 문서 아랫부분에서 설명하고 있는) Nothing입니다.
break와 continue의 라벨
코틀린에서는 모든 표현식에 라벨을 지정할 수 있습니다. 라벨은 abc@, fooBar@ 같이 식별자 + @ 형태입니다. 표현식에 라벨을 추가하는 방법은 (단지) 해당 표현식 앞에 붙이는 겁니다.
loop@ for (i in 1..100) {
// ...
}
이렇게 라벨을 추가하면 break나 continue의 대상을 지정할 수 있습니다.
loop@ for (i in 1..100) {
for (j in 1..100) {
if (...) break@loop // 가장 가까운 바로 위의 for 루프가 아니라 바깥쪽에 라벨이 붙은 for 루프를 벗어납니다.
}
}
라벨이 지정된 break는 해당 라벨이 붙은 루프 다음 지점으로 점프하게 됩니다. continue은 라벨이 붙은 루프의 다음 반복으로 이동합니다.
라벨로 반환
코틀린에서 함수는 함수 리터럴이나 지역함수 또는 객체 표현식을 사용하여 중첩될 수 있습니다. 라벨이 지정된 return은 (중첩된 함수인 경우) 외부 함수로부터 반환할 수 있게 해 줍니다. 이와 관련하여 가장 중요한 사례는 람다 표현식으로부터 반환할 때입니다. 다음과 같은 foo 함수에서 (일반적인) return 표현식은 가장 가깝게 감싸고 있는 함수를 반환합니다.
fun foo() {
listOf(1, 2, 3, 4, 5).forEach {
if (it == 3) return // 직접적으로 foo()를 호출한 쪽으로 반환합니다.
print(it)
}
println("this point is unreachable")
}
fun main() {
foo()
}
/* 결과
12
*/
이러한 지역적이지 않은 반환은 인라인 함수에 넘겨진 람다에서만 가능합니다. 람다 표현식에서(만) 반환하기 위해서는 람다 표현식에 라벨을 추가하고 return에 라벨을 지정해야 합니다.
fun foo() {
listOf(1, 2, 3, 4, 5).forEach lit@{
if (it == 3) return@lit // local return to the caller of the lambda - the forEach loop
print(it)
}
print(" done with explicit label")
}
fun main() {
foo()
}
/* 결과
1245 done with explicit label
*/
이렇게 하면 이제 람다 표현식에서만 반환할 수 있습니다. 때때로, 묵시적 라벨을 사용하는 것이 유용할 수 있습니다. 묵시적 라벨은 람다가 넘겨지는 함수와 같은 이름을 가지고 있습니다.
fun foo() {
listOf(1, 2, 3, 4, 5).forEach {
if (it == 3) return@forEach // 람다를 호출한 쪽(forEach 루프)으로 지역적 반환
print(it)
}
print(" done with implicit label")
}
fun main() {
foo()
}
/* 결과
1245 done with implicit label
*/
다른 방법으로 람다 표현식으로 익명 함수로 대체하여 사용할 수 있습니다. 익명 함수에 있는 return은 익명 함수 자체로부터 (익명 함수 호출쪽으로) 반환됩니다.
fun foo() {
listOf(1, 2, 3, 4, 5).forEach(fun(value: Int) {
if (value == 3) return // local return to the caller of the anonymous function - the forEach loop
print(value)
})
print(" done with anonymous function")
}
fun main() {
foo()
}
/* 결과
1245 done with anonymous function
*/
앞선 3개의 예제에서처럼 지역적인 반환은 일반적인 루프에서 continue의 사용법과 유사합니다.
break에 대해서는 직접적인 동일한 사용법이 없습니다. 하지만, 다음과 같이 또 하나의 중첩된 람다와 해당 람다에서 지역적이지 반환을 통해서 비슷하게 흉내 낼 수는 있습니다.
fun foo() {
run loop@{
listOf(1, 2, 3, 4, 5).forEach {
if (it == 3) return@loop // run 넘겨진 람다로부터 (비지젹적인) 반환. 이렇게 함으로써 이 루프를 break 할 수 있게 됩니다.
print(it)
}
}
print(" done with nested loop")
}
fun main() {
foo()
}
/* 결과
12 done with nested loop
*/
값을 반환할 때, 파서는 라벨이 붙은 return에 우선권을 줍니다. 즉, 일반적인 return의 기본 행위(둘러쌓인 가장 가까운 함수나 익명 함수로부터 반환)보다는 라벨을 우선시해서 찾고, 찾은 경우는 그에 받게 반환, 못 찾은 경우에는 라벨 지정 안 된 것처럼 동작합니다.
return@a 1
이 의미는 라벨이 붙은 표현식 (@a 1)을 반환한다기 보다는 라벨 @a에 1을 반환한다는 의미입니다.
예외(Exceptions)
예외 클래스
Kotlin에서 모든 예외 클래스는 Throwable 클래스를 상속합니다. 모든 예외는 메시지와 스택 트레이스, 선택적인 사유(optional cause)를 가지고 있습니다.
Kotlin 공식 문서의 Concepts 부분은 내용을 읽는 사람이 어느 정도 Java나 프로그래밍을 알고 있다는 (묵시적) 전제하에 서술하고 있습니다. 그래서, 여기에서도 예외가 뭔지부터 시작하는 기본 내용은 나오지 않습니다. 예외에 대한 기본 내용이 필요한 경우 인터넷이나 여타 도서를 통해 해당 내용을 참고하셔야 합니다. 개인적으로는 오라클의 튜토리얼 문서를 추천 드립니다.
예외 객체를 던지기 위해서는, 다음과 같인 throw 표현식을 사용합니다.
fun main() {
throw Exception("Hi There!")
}
예외를 잡기(catch) 위해서는, try ... catch 표현식을 사용합니다.
try {
// some code
} catch (e: SomeException) {
// 예외 처리 부분
} finally {
// 선택 사항인 finally 블록
}
0개 이상의 catch 블록이 있을 수 있으며, finally 블록은 선택 사항입니다. 하지만, 적어도 하나의 catch나 finally 블록은 반드시 있어야 합니다.
try 표현식
try는 문 뿐만 아니라 표현식으로도 사용 가능합니다. 즉, 반환값을 가질 수 있습니다.
val a: Int? = try { input.toInt() } catch (e: NumberFormatException) { null }
try 표현식의 반환값은 try 블록의 마지막 표현식이나 catch 블록의 마지막 표현식입니다. finally 블록은 반환값에 영향을 주지 않습니다.
확인된 예외(Checked exceptions)
코틀린에는 확인된 예외가 없습니다. 이에 대해서는 많은 이유가 있지만, 여기서는 왜 그런지를 보여주는 간단한 예를 들어 보겠습니다.
다음은 JDK에서 StringBuilder로 구현되는 Appendable 인터페이스의 한 부분입니다.
Appendable append(CharSequence csq) throws IOException;
이 시그니처는 문자열을 어딘가(로그나 콘솔 등을 위한 StringBuilder)에 붙일때마다 IOException을 포착(catch)해야만 한다고 말하고 있습니다(IOException은 항상 포착해야하는 확인된 예외입니다). 왜 그럴까요? 구현체가 아마 IO 연산을 수행할 것이기 때문입니다(Writer 도 Appendable를 구현합니다). 그 결과로 여기 저기서 코드는 이런 형태일겁니다.
try {
log.append(message)
} catch (IOException e) {
// Must be safe
}
이 것은 좋지 않습니다. 이펙티드 자바 3판 (번역본)의 Item 77: 예외를 무시하지 말라 부분을 살펴보시기 바랍니다.
브루스 에켈(Bruce Eckel) 확인된 예외에 관해서 다음과 같이 얘기합니다.
소규모 프로그램을 조사한 보면, 예외 사양을 요구하는 것은 개발자 생산성을 향상시키고 코드 품질을 향상시킬 수 있다고 귀결됩니다. 하지만, 대규모 소프트웨어 프로젝트의 경험은 다른 결과를 시사합니다 - 생산성 감소 및 코드 품질의 증가가 거의 없거나 전혀 없다는 쪽으로요.
그리고, 다음과 같은 (이 문제에 대한) 추가적인 생각들이 있습니다.
- Java의 확인된 예외는 실수였다 (Rod Waldhoff)
- 확인된 예외 문제 (Anders Hejlsberg)
Java, Swift, Objective-C에서 Kotlin 코드를 호출할 때 호출쪽에 발생 가능한 예외에 대해서 경고하고 싶은 경우 @Throws 어노테이션을 사용할 수 있습니다. 이 어노테이션을 사용하는 방법을 Java와 Swift/Objective-C 별도 더 확인해 볼 수 있습니다.
Kotlin에서는 확인된 예외(Checked exception)이 없지만 JVM 환경하에서는 주의할 필요가 있습니다. JVM 환경하에서는 Exception 클래스가 java.lang.Exception의 별칭으로 지정돼 있습니다.
https://github.com/JetBrains/kotlin/blob/1.2.30/libraries/stdlib/jvm/runtime/kotlin/TypeAliases.kt
그러므로, 확인된 예외가 없다는 생각으로 Exception을 (확인 안 된 예외로 생각하고)상속 받는 별도의 클래스를 만들면, 해당 클래스는 원하는대로 되지 않고 확인된 클래스가 돼 버립니다. 물론, Exception 클래스를 상속 받아도 Kotlin 컴파일러는 확인된 예외에 대한 포착을 하라고 강요하지는 않지만, Java 라이브러리들과 혼용되거나 하는 경우에는 때때로 원하는 대로 되지 않을 수 있습니다.
JVM 환경, 특히 Java와 혼용되는 환경에서는 Kotlin의 기본 개념처럼 확인 안 된 예외 클래스를 별도로 만들고자 하는 경우 RuntimeException을 상속 받는게 여러모로 용이합니다.
부가적으로 Kotlin이 JVM 환경에서 별칭으로 해 놓은 이유는 멀티 플랫폼을 지원하기 때문인 것으로 보입니다. 즉, 자체 컴파일러는 예외를 모두 확인 안 된 예외로 보고 포착을 강요하지 않지만, 실제 각 플랫폼마다 해당하는 구현체에 맞추기 위해서 그렇게 한 것으로 보입니다.
Nothing 타입
Kotlin에서 throw는 표현식이기 때문에, 예를 들어 엘비스 연산자의 일부분으로 사용할 수 있습니다.
val s = person.name ?: throw IllegalArgumentException("Name required")
throw 표현식은 Nothing 타입을 갖습니다. 이 타입은 어떠한 값도 가지고 있지 않으며, 절대 도달하지 못하는 코드의 위치를 표시하는 사용됩니다. 코드에서 결코 반환되지 않는다는 의미로 다음과 같이 Nothing 타입을 사용할 수 있습니다.
fun fail(message: String): Nothing {
throw IllegalArgumentException(message)
}
이 함수를 호출하면, 컴파일러는 이 호출 이후로는 더 이상 실행이 진행되지 않는다는 것을 알게 됩니다.
val s = person.name ?: fail("Name required")
println(s) // s는 이 시점에 초기화 됩니다.
또한, Nothing 타입은 타입 추론을 다룰 때 마주치게 됩니다. 이 것의 널 가능한 타입인 Nothing?의 가능한 값은 null 하나 뿐입니다. 추론 타입에 null을 할당하고, 타입을 구체적으로 추론할 수 있는 그 이상의 정보가 없는 경우 컴파일러는 해당 타입을 Nothing?으로 추론합니다.
val x = null // 'x'는 `Nothing?` 타입
val l = listOf(null) // 'l'은 `List<Nothing?> 타입
Java와 상호운용성
예외와 관련된 Java와 상호 운용성은 위의 확인된 예외 부분 하위에 박스 형태로 추가적으로 적어 놓은 내용과 Java 상호 운용성의 예외 부분을 살펴 보시기 바랍니다.
'Kotlin' 카테고리의 다른 글
공식 문서로 배우는 코틀린 - 13. Classes (0) | 2024.03.07 |
---|---|
공식 문서로 배우는 코틀린 - 12. Packages and imports (0) | 2024.03.07 |
공식 문서로 배우는 코틀린 - 10. Types - Type checks and casts (0) | 2024.03.05 |
공식 문서로 배우는 코틀린 - 9. Basic Types - Arrays (1) | 2024.03.05 |
공식 문서로 배우는 코틀린 - 8. Basic Types - Strings (0) | 2024.03.02 |