관리 메뉴

나만을 위한 블로그

[Kotlin in Action] 2장. 코틀린 기초 본문

책/Kotlin in Action

[Kotlin in Action] 2장. 코틀린 기초

참깨빵위에참깨빵 2023. 9. 28. 20:36
728x90
반응형
함수와 변수

 

모든 프로그래밍 언어가 그러하듯 코틀린 또한 함수와 변수를 바탕으로 프로그램을 만들 수 있다.

이 2가지 요소를 코틀린에선 어떻게 사용할 수 있는지 확인한다. 아래는 오랜 전통인 "Hello, World!"의 출력 방법이다.

인텔리제이를 설치하고 코틀린을 사용하도록 설정하고 아래 코드를 복붙해서 실행한다. 만약 설치하기 귀찮다면 아래 링크를 확인한다. 웹 페이지를 통해 코틀린 코드를 실행할 수 있다.

 

https://play.kotlinlang.org/#eyJ2ZXJzaW9uIjoiMS43LjIxIiwicGxhdGZvcm0iOiJqYXZhIiwiYXJncyI6IiIsIm5vbmVNYXJrZXJzIjp0cnVlLCJ0aGVtZSI6ImlkZWEiLCJjb2RlIjoiLyoqXG4gKiBZb3UgY2FuIGVkaXQsIHJ1biwgYW5kIHNoYXJlIHRoaXMgY29kZS5cbiAqIHBsYXkua290bGlubGFuZy5vcmdcbiAqL1xuZnVuIG1haW4oKSB7XG4gICAgcHJpbnRsbihcIkhlbGxvLCB3b3JsZCEhIVwiKVxufSJ9

 

Kotlin Playground: Edit, Run, Share Kotlin Code Online

 

play.kotlinlang.org

 

fun main(args: Array<String>) {
    println("Hello, World!")
}

 

1줄로도 만들 수 있는 위 코드에서 볼 수 있는 코틀린 문법과 특성은 아래와 같다.

 

  • 함수 선언 시 사용하는 키워드는 fun이다
  • 파라미터 이름 뒤에 그 파라미터의 타입을 쓴다. 변수 선언도 마찬가지다
  • 함수를 최상위 수준에 정의할 수 있다. 이 말은 꼭 클래스 안에 함수를 작성할 필요가 없단 뜻이라서 자바와 다른 차이점이다
  • soutSystem.out.println() 대신 println()을 사용한다. 여러 자바 표준 라이브러리를 간결하게 사용할 수 있도록 래퍼를 제공하는 코틀린 표준 라이브러리라는 게 존재하는데, println()은 그 함수 중의 하나다
  • 세미콜론을 붙일 필요가 없다

 

함수

 

위의 메인 함수는 어떤 값도 리턴하지 않는 함수 선언 방법도 보여준다. 만약 어떤 값을 리턴해야 하는 함수를 만들어야 한다면 아래와 같이 작성한다.

 

fun sayHello(text: String): String {
    return "Hello, $text"
}

fun main() {
    println(sayHello("Kotlin!"))
}

// >> Hello, Kotlin!

 

메인 함수의 형태가 조금 바뀌었지만 신경쓸 필요 없다. args를 쓸 필요가 없다면 이렇게 생략해서 사용할 수 있을 뿐이다.

위에서 말한 대로 함수 선언은 fun 키워드로 시작한다. 그 후 당연히 함수명을 써야 한다. 그리고 함수의 리턴타입은 매개변수들을 작성하는 소괄호 오른쪽의 콜론 우측에 작성한다. 콜론은 함수 매개변수와 리턴타입을 구분하는 구분선 같은 거라고 생각하자.

매개변수가 둘 이상인 경우 각 매개변수는 콤마로 구분한다. {매개변수명: 타입, 매개변수명2: 타입2}와 같은 형태다.

 

fun add(a: Int, b: Int): Int {
    return a + b
}

 

이 함수는 좀 더 간결하게 작성할 수 있다. 바로 리턴타입(Int) 뒤의 중괄호 블록과 return 키워드를 없앨 수 있다.

 

fun add(a: Int, b: Int): Int = a + b

 

이처럼 등호, 식(a + b 부분, expression이라고도 함)으로 이뤄진 함수를 식이 본문인 함수라 부르고, 중괄호와 return 키워드가 존재하는 함수를 블록이 본문인 함수라고 부른다.

그럼 코틀린은 어떻게 이렇게 작성할 수 있는가? 코틀린은 정적 타입 언어인데 컴파일 시점에 모든 식의 타입을 다 알아야 하는 거 아닌가?

식이 본문인 함수의 경우, 굳이 리턴타입을 적지 않아도 컴파일러가 함수 본문인 식을 분석해서 해당 식의 결과 타입을 함수의 리턴타입으로 정해준다. 이렇게 컴파일러가 타입을 분석해서 정해주는 기능을 타입 추론이라고 한다.

주의할 것은 식이 본문인 함수의 리턴타입만 생략 가능하다는 것이다. 블록이 본문인 함수가 값을 리턴한다면 반드시 리턴타입을 지정하고 return 키워드로 리턴값을 명시해야 한다.

 

식(expression)을 말하면 문(statement)도 같이 말해야 한다. 코틀린에서 if는 식이고, 문이 아니다. 흔히 if를 if문이라고 부르는데 코틀린에선 if식이라고 불러야 한다.

식은 어떤 값을 만들어 내며 다른 식의 하위 요소로 계산에 참여할 수 있지만, 문은 자신을 둘러싼 가장 안쪽 블록의 최상위 요소로 존재하며 아무런 값도 만들지 않는단 차이가 있다. 자바에선 모든 제어 구조가 문인 반면 코틀린에선 루프를 제외한 대부분 제어 구조가 식이다. 나도 자바에서 코틀린으로 넘어오면서 이 부분에서 헷갈렸는데, 계속 쓰다 보면 익숙해진다.

 

변수

 

자바는 변수를 선언할 때 타입을 먼저 쓴다.

 

public class Main {
    String text;
    ...
}

 

코틀린은 변수명 뒤에 타입을 붙이거나 생략할 수 있다. 예를 들어 아래처럼 할 수 있다.

 

fun main() {
    val name = "김철수"
    val name2: String = "김영희"
    val age = 20
    val height: Double = 192.4
}

 

내가 타입을 지정하지 않으면 컴파일러가 초기화 식을 분석해서 초기화 식의 타입을 변수 타입으로 지정한다.

age 변수의 경우 초기화 식은 20으로 Int 타입이다. 따라서 변수도 Int 타입이 된다.

IDE에서 직접 타이핑한다면 컨트롤 키를 누른 채로 age 변수에 마우스를 대고 잠시 기다리면 타입이 나온다. 이 때 타입을 지정하지 않았는데도 Int로 타입이 설정된 걸 확인할 수 있다. 그리고 부동소수점 상수를 사용한다면 변수 타입은 Double이 된다.

초기화 식을 쓰지 않고 변수를 선언하려면 변수 타입은 반드시 명시해야 한다. 왜냐면 초기화 식이 없을 경우 변수에 저장될 값에 대한 정보가 없기 때문에 컴파일러가 타입을 추론할 수 없기 때문이다.

 

fun main() {
    val age: Int
    age = 20
}

 

그리고 코틀린에는 변수 선언 시 2개의 키워드를 사용할 수 있다.

 

  • val : value에서 이름을 딴 키워드. 불변 참조를 저장하는 변수. 한 번 초기화되면 다른 값을 대입할 수 없음. 자바의 final 변수
  • var : variable에서 이름을 딴 키워드. 가변 참조를 저장하는 변수. 한 번 초기화되도 다른 값을 대입할 수 있음

 

기본적으론 모든 변수를 val로 선언해서 불변으로 선언하고 나중에 필요할 때만 var로 변경하는 게 좋다.

그러나 val 변수의 참조 자체는 불변이라도 그 참조가 가리키는 객체의 내부 값은 바뀔 수도 있다. 아래 예제를 확인한다.

 

fun main() {
    val names = arrayListOf("김철수", "김영희")
    names.add("박영찬")
}

 

names는 val로 선언됐으니 불변 성격을 띠는 ArrayList다. 그러나 add()를 통해 값을 넣을 수 있고, 출력하면 정상적으로 값도 출력된다.

var로 선언하면 변수 값을 바꿀 순 있지만 변수의 타입은 바뀌지 않는다.

 

문자열 템플릿

 

위의 sayHello() 코드를 다시 보면 문자열 안에 달러 기호($)를 썼다.

 

fun sayHello(text: String): String {
    return "Hello, $text"
}

fun main() {
    println(sayHello("Kotlin!"))
}

// >> Hello, Kotlin!

 

이것은 문자열 템플릿이란 기능인데 매개변수로 받은 text 변수를 문자열 안에서 달러 기호를 먼저 쓰고, 그 뒤에 붙여서 사용했다.

이 기능은 자바의 문자열 접합 연산과 같은 기능을 하지만 좀 더 간결하다. $ 문자를 넣어야 한다면 이스케이프 처리해서 넣어주면 된다.

 

fun main() {
    println("\$100")
}

// >> $100

 

클래스, 프로퍼티

 

자바에서 클래스를 선언할 때는 아래와 같이 한다.

 

public class Person {
    private String name;

    public Person(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

 

이 때 필드가 2개 이상이 되면 그만큼 생성자 본문 길이도 길어진다. 코틀린에선 위의 클래스 코드를 단 한 줄로 작성할 수 있다.

 

class Person(val name: String)

 

정말 기묘할 정도로 코드량이 줄어든다. 코틀린의 기본 접근 제어자는 public이라서 접근 제어자를 생략할 수도 있다.

이 Person 클래스 안에 함수를 작성하려면 닫는 소괄호 뒤에 중괄호를 열고, 안에 필드들과 함수들을 작성하면 된다.

 

그리고 이 클래스의 목적은 데이터를 캡슐화하고, 캡슐화된 데이터를 다루는 코드를 가두는 것이다. 자바에선 데이터를 필드에 저장하고 접근 제어자는 보통 private를 사용한다.

코틀린에선 필드+접근 제어자를 묶어서 프로퍼티라고 부른다. 즉 "val name: String"이 프로퍼티인 것이다.

이 프로퍼티에는 대부분 프로퍼티의 값을 저장하기 위한 backing field가 존재한다. 이걸 사용하거나 필요하면 프로퍼티의 값을 그때그때 계산할 수도 있다.

 

커스텀 접근자

 

직사각형 클래스 Rectangle을 정의하면서 자신이 정사각형인지 알려주는 기능을 만든다고 가정한다. 직사각형이 정사각형인지 여부를 별도 필드에 저장하지 않고 사각형 너비, 높이가 같은지 검사하면 곧바로 정사각형인지 알 수 있다.

 

class Rectangle(val height: Int, val width: Int) {
    val isSquare: Boolean
        get() { // 프로퍼티 게터 선언
            return height == width
        }
}

 

isSquare 프로퍼티에는 자체 값을 저장하는 필드가 필요없다. 이 프로퍼티에는 자체 구현을 제공하는 게터만 존재한다. 누군가가 프로퍼티에 접근할 때마다 게터가 프로퍼티 값을 매번 다시 계산한다.

위 코드처럼 블록을 본문으로 하는 구문을 쓰지 않아도 된다. 식이 본문인 함수 형태로 만들어도 된다.

 

class Rectangle(val height: Int, val width: Int) {
    val isSquare: Boolean
        get() = height == width
}

 

자바에서 isSquare 프로퍼티를 사용하려면 아래처럼 isSquare()를 호출하면 된다.

 

public static void main(String[] args) {
    Rectangle r = new Rectangle(10, 10);
    boolean isSquare = r.isSquare();
    System.out.println(isSquare);   // true
}

 

파라미터가 없는 함수를 정의하는 방식, 커스텀 게터를 정의하는 방식은 각각 구현이나 성능 상 차이가 없다. 가독성만 차이가 날 뿐이다. 일반적으로 클래스의 특성을 정의하고 싶다면 프로퍼티로 그 특성을 정의해야 한다.

 

선택 표현과 처리 : enum, when

 

when은 자바의 switch와 같은 것이다. 먼저 enum의 경우, 아래처럼 색을 표현하는 enum을 정의한다고 가정한다.

 

enum class Color {
    RED, ORANGE, YELLOW, GREEN, BLUE, INDIGO, VIOLET
}

 

자바에서 enum을 사용한다면 코틀린에선 enum class를 사용한다. 코틀린에서 enum은 소프트 키워드라고 부른다. enum은 class 앞에 있을 땐 특별한 의미를 갖지만 다른 곳에선 이름에 사용할 수 있다. 반면 class는 키워드기 때문에 class라는 이름을 쓸 수 없으므로 다른 이름을 써야 한다.

자바와 마찬가지로 enum은 단순히 값만 열거하는 게 아니다. enum 클래스 안에 프로퍼티, 메서드를 정의할 수 있다.

 

enum class Color(
    val r: Int,
    val g: Int,
    val b: Int,
) {
    RED(255, 0, 0),
    ORANGE(255, 165, 0),
    YELLOW(255, 255, 0),
    GREEN(0, 255, 0),
    BLUE(0, 0, 255),
    INDIGO(75, 0, 130),
    VIOLET(238, 130, 238);

    fun rgb() = (r * 256 + g) * 256 + b
}

fun main() {
    println(Color.BLUE.rgb())
}

// >> 255

 

enum에서도 생성자, 프로퍼티를 선언한다. 각 enum 상수 정의 시에는 그 상수에 해당하는 프로퍼티 값을 지정해야 한다.

위 코드를 보면 코틀린에서 유일하게 세미콜론이 필요한 걸 볼 수 있다. enum 클래스 안에 메서드를 정의할 경우, 반드시 enum 상수 목록과 메서드 정의 사이에 세미콜론을 넣어야 한다. 그렇지 않으면 Expecting ';' after the last enum entry or '}' to close enum class body라는 컴파일 에러가 발생한다.

 

when으로 enum class 다루기

 

if와 마찬가지로 when도 코틀린에선 값을 만드는 식이다. 따라서 식이 본문인 함수에 when을 바로 쓸 수 있다.

 

enum class Color(
    val r: Int,
    val g: Int,
    val b: Int,
) {
    RED(255, 0, 0),
    ORANGE(255, 165, 0),
    YELLOW(255, 255, 0),
    GREEN(0, 255, 0),
    BLUE(0, 0, 255),
    INDIGO(75, 0, 130),
    VIOLET(238, 130, 238);

    fun rgb() = (r * 256 + g) * 256 + b
}

fun getMnemonic(color: Color) = when (color) {
    Color.RED -> "Richard"
    Color.ORANGE -> "Of"
    Color.YELLOW -> "York"
    Color.GREEN -> "Gave"
    Color.BLUE -> "Battle"
    Color.INDIGO -> "In"
    Color.VIOLET -> "Vain"
}

fun main() {
    println(getMnemonic(Color.BLUE))
}

// >> Battle

 

자바와 달리 각 분기의 끝에 break;를 넣지 않아도 된다. 일치하는 분기를 찾으면 곧바로 그 분기가 실행된다.

하나의 분기 안에서 여러 값을 매치 패턴으로 쓸 수도 있는데, 그럴 경우 값 사이를 콤마로 구분한다.

 

enum class Color(
    val r: Int,
    val g: Int,
    val b: Int,
) {
    RED(255, 0, 0),
    ORANGE(255, 165, 0),
    YELLOW(255, 255, 0),
    GREEN(0, 255, 0),
    BLUE(0, 0, 255),
    INDIGO(75, 0, 130),
    VIOLET(238, 130, 238);

    fun rgb() = (r * 256 + g) * 256 + b
}

fun getMnemonic(color: Color) = when (color) {
    Color.RED -> "Richard"
    Color.ORANGE -> "Of"
    Color.YELLOW -> "York"
    Color.GREEN -> "Gave"
    Color.BLUE -> "Battle"
    Color.INDIGO -> "In"
    Color.VIOLET -> "Vain"
}

fun getWarmth(color: Color) = when (color) {
    Color.RED, Color.ORANGE, Color.YELLOW -> "warm"
    Color.GREEN -> "neutral"
    Color.BLUE, Color.INDIGO, Color.VIOLET -> "cold"
}

fun main() {
    println(getWarmth(Color.ORANGE))
}

// >> warm

 

또한 분기 조건에 상수만 쓸 수 있는 자바 switch와 달리 코틀린 when은 임의의 객체를 허용한다. 두 색을 혼합했을 때 미리 정해둔 색이 될 수 있는지 알려주는 mix()를 작성한다.

 

enum class Color(
    val r: Int,
    val g: Int,
    val b: Int,
) {
    RED(255, 0, 0),
    ORANGE(255, 165, 0),
    YELLOW(255, 255, 0),
    GREEN(0, 255, 0),
    BLUE(0, 0, 255),
    INDIGO(75, 0, 130),
    VIOLET(238, 130, 238);

    fun rgb() = (r * 256 + g) * 256 + b
}

fun getMnemonic(color: Color) = when (color) {
    Color.RED -> "Richard"
    Color.ORANGE -> "Of"
    Color.YELLOW -> "York"
    Color.GREEN -> "Gave"
    Color.BLUE -> "Battle"
    Color.INDIGO -> "In"
    Color.VIOLET -> "Vain"
}

fun getWarmth(color: Color) = when (color) {
    Color.RED, Color.ORANGE, Color.YELLOW -> "warm"
    Color.GREEN -> "neutral"
    Color.BLUE, Color.INDIGO, Color.VIOLET -> "cold"
}

fun mix(c1: Color, c2: Color) = when (setOf(c1, c2)) {
    // when 식의 인자로 아무 객체나 사용 가능하다. when은 인자로 받은 객체가 각 분기 조건의 객체와 같은지 확인한다
    setOf(Color.RED, Color.YELLOW) -> Color.ORANGE
    setOf(Color.YELLOW, Color.BLUE) -> Color.GREEN
    setOf(Color.BLUE, Color.VIOLET) -> Color.INDIGO
    else -> throw Exception("색을 잘못 넣은 거 같은데요?") // 일치하는 분기 조건이 없으면 else가 실행된다
}

fun main() {
    println(mix(Color.BLUE, Color.YELLOW))
}

// >> GREEN

 

c1, c2를 합친 결과를 확인하는 로직 구현에 집합 비교(setOf)를 사용한다. 코틀린 라이브러리에는 인자로 받은 여러 객체를 집합인 Set 객체로 만드는 setOf()가 있다. 집합은 원소가 모여 있는 컬렉션으로 원소 순서는 중요하지 않다. 따라서 setOf(c1, c2)와 setOf(RED, YELLOW)가 같단 말은 c1은 RED 또는 YELLOW고, c2는 YELLOW 또는 RED라는 뜻이다.

 

when 식은 인자값과 일치하는 조건값을 찾을 때까지 각 분기를 검사한다. 여기선 setOf(c1, c2)와 분기 조건 사이의 객체 사이를 매치할 때 동등성을 사용한다. 그래서 앞의 코드는 setOf(c1, c2)와 setOf(RED, YELLOW)를 비교하고 둘이 같지 않ㅇ다면 계속 다음 분기의 조건 객체와 setOf(c1, c2)를 비교한다. 모든 분기 식에서 만족하는 조건이 없다면 else를 실행한다.

 

인자 없는 when 사용

 

mix()는 호출될 때마다 여러 Set 인스턴스를 사용한다. 인자가 없는 when 식을 쓰면 불필요한 객체 생성을 막을 수 있다.

 

import Color.*

enum class Color(
    val r: Int,
    val g: Int,
    val b: Int,
) {
    RED(255, 0, 0),
    ORANGE(255, 165, 0),
    YELLOW(255, 255, 0),
    GREEN(0, 255, 0),
    BLUE(0, 0, 255),
    INDIGO(75, 0, 130),
    VIOLET(238, 130, 238);

    fun rgb() = (r * 256 + g) * 256 + b
}

fun getMnemonic(color: Color) = when (color) {
    RED -> "Richard"
    ORANGE -> "Of"
    YELLOW -> "York"
    GREEN -> "Gave"
    BLUE -> "Battle"
    INDIGO -> "In"
    VIOLET -> "Vain"
}

fun getWarmth(color: Color) = when (color) {
    RED, ORANGE, YELLOW -> "warm"
    GREEN -> "neutral"
    BLUE, INDIGO, VIOLET -> "cold"
}

fun mix(c1: Color, c2: Color) = when (setOf(c1, c2)) {
    // when 식의 인자로 아무 객체나 사용 가능하다. when은 인자로 받은 객체가 각 분기 조건의 객체와 같은지 확인한다
    setOf(RED, YELLOW) -> ORANGE
    setOf(YELLOW, BLUE) -> GREEN
    setOf(BLUE, VIOLET) -> INDIGO
    else -> throw Exception("색을 잘못 넣은 거 같은데요?") // 일치하는 분기 조건이 없으면 else가 실행된다
}

fun mixOptimized(c1: Color, c2: Color) = when {
    (c1 == RED && c2 == YELLOW) || (c1 == YELLOW && c2 == RED) -> ORANGE
    (c1 == YELLOW && c2 == BLUE) || (c1 == BLUE && c2 == YELLOW) -> GREEN
    (c1 == BLUE && c2 == VIOLET) || (c1 == VIOLET && c2 == BLUE) -> INDIGO
    else -> throw Exception("색을 잘못 넣은 거 같은데요?")
}

fun main() {
    println(mixOptimized(BLUE, YELLOW))
}

// >> GREEN

 

when에 아무 인자도 없으려면 각 분기의 조건이 Boolean 결과를 계산하는 식이어야 한다.

 

스마트 캐스트

 

이번엔 "(1+2)+4"같은 산술식을 계산하는 함수를 만든다. 함수가 받을 산술식에선 두 수를 더하는 연산만 가능하다.

우선 식을 인코딩하는 방법을 생각해야 하는데 여기선 트리 구조로 저장한다. 노드는 합계(Sum)나 수(Num) 중의 하나다. Num은 항상 말단 노드지만 Sum은 자식이 있는 중간 노드다. Sum 노드의 두 자식은 덧셈의 두 인자다.

 

interface Expr

class Num(val value: Int): Expr

class Sum(val left: Expr, val right: Expr): Expr

 

Expr 인터페이스에는 어떤 메서드도 없고 여러 타입의 식 객체를 아우르는 공통 타입 역할만 수행한다. 클래스에서 인터페이스를 구현하려면 자바에선 implements를 사용하지만 코틀린은 콜론을 사용하는 걸 볼 수 있다.

Sum()의 left, right는 각각 Num, Sum일 수 있다. "(1+2)+4"라는 식을 저장하면 "Sum(Sum(Num(1), Num(2)), Num(4))" 구조의 객체가 생긴다.

 

이제 Expr 인터페이스는 2가지 구현 클래스가 존재한다. 따라서 식을 평가하려면 2가지 경우를 고려해야 한다.

 

  • 어떤 식이 수라면 그 값을 리턴한다
  • 어떤 식이 합계라면 좌항, 우항의 값을 계산한 다음 그 두 값을 합한 결과값을 리턴한다

 

아래는 if 연쇄를 써서 식을 계산하는 함수다.

 

fun eval(e: Expr): Int {
    if (e is Num) {
        val n = e as Num
        return n.value
    }
    if (e is Sum) {
        return eval(e.right) + eval(e.left)
    }
    throw IllegalArgumentException("식이 잘못된 거 같은데요?")
}

fun main() {
    println(eval(Sum(Sum(Num(1), Num(2)), Num(4))))
}

// >> 7

 

코틀린에선 is를 써서 변수 타입을 검사한다. 이는 자바의 instanceOf와 비슷하다.

어떤 변수가 원하는 타입인지 일단 is로 검사하면 굳이 변수를 원하는 타입으로 캐스팅하지 않아도 처음부터 그 변수가 내가 원하는 타입으로 선언됐던 것처럼 쓸 수 있다. 하지만 실제론 컴파일러가 캐스팅을 수행한다. 이를 스마트 캐스트라고 부른다. 자바에선 어떤 변수의 타입을 instanceOf로 검사한 다음, 그 타입인 멤버에 접근하려면 명시적으로 변수 타입을 캐스팅해야 한다.

 

이제 if 연쇄를 else if를 사용해서 변경한다. 코틀린에는 if가 값을 만들기 때문에 자바와 달리 삼항 연산자가 존재하지 않는다. 이런 특성을 사용하면 eval()에서 return 키워드와 중괄호를 없애고 if식을 본문으로 사용해서 간단하게 만들 수 있다.

 

fun eval(e: Expr): Int = if (e is Num) {
    e.value
} else if (e is Sum) {
    eval(e.right) + eval(e.left)
} else {
    throw IllegalArgumentException("식이 잘못된 거 같은데요?")
}

fun main() {
    println(eval(Sum(Num(1), Num(2))))
}

// >> 3

 

또한 if 분기에 식이 하나 뿐이라면 중괄호를 생략해도 된다. if 분기에 블록을 사용하는 경우, 그 블록의 마지막 식이 그 분기의 결과값이다.

when을 써서 더 다듬을 수 있다. IDE를 쓴다면 등호 오른쪽 if 밑에 노란 줄이 표시되고, 여기서 Cmd + Enter를 누르면 when으로 리팩토링하겠냐고 물어본다.

 

fun eval(e: Expr): Int = when (e) {
    is Num -> e.value
    is Sum -> eval(e.right) + eval(e.left)
    else -> throw IllegalArgumentException("식이 잘못된 거 같은데요?")
}

fun main() {
    println(eval(Sum(Num(1), Num(2))))
}

// >> 3

 

위 코드는 IDE가 리팩토링해준 코드에서 분기마다 존재하는 중괄호 블록을 없앤 코드다. 위 코드처럼 곧바로 값을 리턴하지 않고 로그를 심거나 추가 로직이 필요하다면 중괄호를 사용할 수 있다.

 

fun evalWithLog(e: Expr): Int = when (e) {
    is Num -> {
        println("num : ${e.value}")
        e.value
    }

    is Sum -> {
        val left = evalWithLog(e.left)
        val right = evalWithLog(e.right)
        println("sum : $left + $right")
        left + right
    }

    else -> throw IllegalArgumentException("식이 잘못된 거 같은데요?")
}

fun main() {
    println(evalWithLog(Sum(Sum(Num(1), Num(2)), Num(4))))
}

// >> 7

 

대상을 이터레이션 : while, for 루프

 

코틀린 while 루프는 자바와 동일하다. for 루프는 자바의 forEach 루프에 해당하는 형태만 존재한다. 이런 for 루프는 자바에서와 같이 컬렉션에 대한 이터레이션에 가장 많이 쓰인다.

 

while 루프

 

코틀린에는 while, do-while 루프 2가지가 존재한다. 이것들의 문법은 자바와 크게 다르지 않고, 특별한 기능도 없다. 자바 경험자라면 그냥 평소처럼 사용하면 된다.

 

fun main() {
    while (조건) {
        /* 로직 */ 
    }
    
    do {
        /* 로직 */
    } while (조건)
}

 

수에 대한 이터레이션 : 범위와 수열

 

코틀린에는 자바의 for 루프처럼 초기값, 증가값, 최종값을 사용한 루프가 없다. 대신 코틀린에선 범위(range)를 사용한다.

범위는 기본적으로 두 값으로 이뤄진 구간이다. 보통 이 두 값은 정수 등의 숫자값이고 ".." 연산자로 시작 값, 끝 값을 연결해 범위를 만든다.

코틀린의 범위는 닫힌 구간 또는 양 끝을 포함하는 구간이다. 아래 예시에선 2번째 값이 항상 범위에 포함된다는 뜻이다.

 

val range = 1..10

 

정수 범위로 수행 가능한 가장 단순한 작업은 범위에 속한 모든 값에 대한 이터레이션이다. 이렇게 어떤 범위에 속한 값을 일정 순서로 이터레이션하는 경우를 수열(progression)이라고 부른다.

 

피즈버즈(Fizz-Buzz) 게임으로 정수 범위를 사용해 본다. 참가자는 수를 세면서 3으로 나눠 떨어지는 수에 대해선 Fizz, 5로 나눠 떨어지는 수에 대해선 Buzz라고 말해야 한다. 어떤 수가 3, 5로 모두 나눠 떨어지면 FizzBuzz라고 말한다. 아래 예시는 1~100까지의 피즈버즈 결과를 보여준다.

 

fun fizzBuzz(i: Int) = when {
    i % 15 == 0 -> "FizzBuzz "
    i % 3 == 0 -> "Fizz "
    i % 5 == 0 -> "Buzz "
    else -> "$i "
}

fun main() {
    for (i in 1 .. 100) {
        print(fizzBuzz(i))
    }
}

// >> 1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz...

 

100부터 거꾸로 세지만 짝수만으로 게임을 진행하는 피즈버즈 예시는 아래와 같다.

 

fun fizzBuzz(i: Int) = when {
    i % 15 == 0 -> "FizzBuzz "
    i % 3 == 0 -> "Fizz "
    i % 5 == 0 -> "Buzz "
    else -> "$i "
}

fun main() {
    for (i in 100 downTo 1 step 2) {
        print(fizzBuzz(i))
    }
}

// >> Buzz 98 Fizz 94 92 FizzBuzz 88 86 Fizz 82 Buzz Fizz 76 74 Fizz Buzz 68 Fizz 64 62 FizzBuzz...

 

step은 증가값이다. 증가값을 사용하면 수를 건너뛸 수 있다. 증가값을 음수로 만들면 정방향 수열이 아닌 역방향 수열을 만들 수 있다.

위 코드에서 100 downTo 1은 역방향 수열을 만든다. 역방향 수열의 기본 증가값은 -1인데, 그 뒤에 step 2를 붙이면 증가값의 절대값이 2로 바뀐다. 이 때 증가값의 방향은 바뀌지 않는다. 따라서 증가값은 실제론 -2와 같다.

앞서 말한 대로 ".."는 항상 범위의 끝 값(".."의 우항)을 포함한다. 끝 값을 포함하지 않는 반만 닫힌 범위에 대해 이터레이션할 경우 until을 사용하면 된다.

 

Map에 대한 이터레이션

 

문자에 대한 이진 표현을 출력하는 예시를 확인한다. 아래 코드는 Map을 만들고 글자 몇 개에 대한 이진 표현으로 Map을 채운 다음, 그 Map의 내용을 출력한다.

 

fun main() {
    val binaryReps = TreeMap<Char, String>() // 키에 대해 정렬하기 위해 TreeMap 사용
    for (c in 'A' .. 'F') {
        val binary = Integer.toBinaryString(c.toInt()) // 아스키 코드를 이진 표현으로 변환. "c.code"로 써도 같은 효과를 낸다
        binaryReps[c] = binary
    }
    for ((letter, binary) in binaryReps) {
        println("$letter = $binary")
    }
}

// >> A = 1000001
// >> B = 1000010
// >> C = 1000011
// >> D = 1000100
// >> E = 1000101
// >> F = 1000110

 

".." 연산자는 숫자 뿐 아니라 문자 타입 값에도 적용 가능하다. 2번째 for에선 구조 분해를 사용해 letter, binary 변수에 Map의 원소를 풀어 저장한다. 이 때 letter에는 키(A, B, C, ...)가 들어가고 binary에는 이진 표현(1000001, 100010, ...)이 들어간다.

 

Map 뿐 아니라 컬렉션에도 구조 분해를 적용할 수 있다.

 

fun main() {
    val list = arrayListOf("10", "11", "1101")
    for ((index, element) in list.withIndex()) { // 인덱스와 함께 컬렉션 이터레이션(순회)
        println("$index = $element")
    }
}

// >> 0 = 10
// >> 1 = 11
// >> 2 = 1101

 

in으로 컬렉션이나 범위의 원소 검사

 

in 연산자를 써서 어떤 값이 범위에 속하는지 검사하는 것도 가능하다. 반대로 "!in"을 쓰면 어떤 값이 범위에 속하지 않는지 검사할 수 있다.

아래는 어떤 문자가 정해진 문자의 범위에 속하는지 검사하는 방법이다.

 

fun isLetter(c: Char) = c in 'a' .. 'z' || c in 'A' .. 'Z'
fun isNotDigit(c: Char) = c !in '0' .. '9'

fun main() {
    println(isLetter('q'))
    println(isNotDigit('x'))
}

// >> true
// >> true

 

isLetter()의 비교 로직은 코틀린 표준 라이브러리의 범위 클래스 구현 안에 감춰져 있다.

c in 'a' .. 'z' 코드는 내부적으로 아래와 같이 변환된다.

 

'a' <= c && c <= 'z'

 

또한 in과 !in 연산자는 when 식에서 써도 된다.

 

fun recognize(c: Char) = when (c) {
    in '0' .. '9' -> "숫자입니다"
    in 'a' .. 'z', in 'A' .. 'Z' -> "문자입니다"
    else -> "이게 뭐죠?"
}

fun main() {
    println(recognize('8'))
}

// >> 숫자입니다

 

범위는 문자에만 국한되지 않는다. 비교 가능한 클래스(java.lang.Comparable 인터페이스를 구현한 클래스)라면 그 클래스의 인스턴스 객체를 써서 범위를 만들 수 있다.

Comparable을 쓰는 범위의 경우, 그 범위 내의 모든 객체를 항상 이터레이션하진 못한다. 'Java'와 'Kotlin' 사이의 모든 문자열을 이터레이션할 순 없다. 하지만 in 연산자를 쓰면 값이 범위 안에 속하는지 항상 결정할 수 있다.

 

fun main() {
    // 대소문자에 주의!
    println("Kotlin" in "Java" .. "Scala")
}

// >> true

 

컬렉션에도 마찬가지로 in 연산을 사용할 수 있다.

 

fun main() {
    // 대소문자에 주의!
    println("Kotlin" in setOf("Java", "Scala"))
}

// >> false

 

코틀린의 예외 처리

 

코틀린 예외 처리는 자바나 다른 언어의 예외 처리와 비슷하다. 함수는 정상 종료할 수 있지만 오류 발생 시 예외를 던질 수 있다. 함수를 호출하는 쪽에선 그 예외를 잡아서 처리할 수 있다. 발생한 예외를 함수 호출 단에서 처리하지 않으면 함수 호출 스택을 거슬러 올라가면서 예외를 처리하는 부분이 나올 때까지 예외를 다시 던진다.

 

fun foo(percentage: Int) {
    if (percentage !in 0 .. 100) {
        throw IllegalArgumentException("percentage는 0~100 사이에 있어야 합니다 : $percentage")
    }
}

 

다른 클래스와 마찬가지로 예외 인스턴스를 만들 때도 new를 쓸 필요가 없다. 자바와 달리 코틀린의 throw는 식이므로 다른 식에 포함될 수 있다.

 

fun foo(number: Int) {
    val percentage = if (number in 0 .. 100)
        number
    else throw IllegalArgumentException("percentage는 0~100 사이에 있어야 합니다 : $number")
}

 

try, catch, finally

 

자바와 같이 예외를 처리하려면 try, catch, finally 절을 같이 사용한다.

 

import java.io.BufferedReader
import java.io.StringReader

fun main() {
    val reader = BufferedReader(StringReader("239"))
    println(readNumber(reader))
}

// >> 239

fun readNumber(reader: BufferedReader): Int? {
    try {
        val line = reader.readLine()
        return Integer.parseInt(line)
    } catch (e: NumberFormatException) {
        return null
    } finally {
        reader.close()
    }
}

 

 

자바에선 함수 선언 시 함수 선언 뒤에 throws IOException을 붙여야 한다. IOException이 체크 예외(checked exception)기 때문이다. 자바에선 체크 예외를 명시적으로 처리해야 한다. 어떤 함수가 던질 가능성이 있는 예외나 그 함수가 호출한 다른 함수에서 발생할 수 있는 예외를 모두 catch로 처리해야 하며, 처리하지 않은 예외는 throws 절에 명시해야 한다

코틀린도 체크 예외와 언체크 예외(unchecked exception)를 구별하지 않는다. 함수가 던지는 예외를 지정하지 않고 발생한 예외를 잡아내도 되고 잡아내지 않아도 된다.

 

try를 식으로 사용

 

코틀린의 try는 if, when과 마찬가지로 식이다. 따라서 try의 값을 변수에 대입할 수 있다.

 

fun readNumber(reader: BufferedReader) {
    val number = try {
        val line = reader.readLine()
        Integer.parseInt(line)
    } catch (e: NumberFormatException) {
        return
    }
    println(number)
}

 

if와 달리 try의 본문은 반드시 중괄호로 감싸야 한다. 그리고 catch에서 return을 썼기 때문에 예외가 발생하면 catch 블록 다음의 코드들은 실행되지 않는다.

반응형
Comments