본문 바로가기

(4)

4장 - 클래스, 객체, 인터페이스

코틀린의 클래스와 인터페이스는 자바 클래스, 인터페이스와는 약간 다르다. 이번장에서는 코틀린의클래스, 객체, 인터페이스에 대해 알아본다. 클래스 계층 정의 코틀린에서 클래스 계층을 정의하는 방식과 자바 방식을 비교한다. 그후 코틀린의 가시성과 접근 변경자에 대해 살펴본다. 코틀린 가시성/접근 변경자는 자바와 비슷하지만 아무것도 지정하지 않는 경우 기본 가시성은 다르다. 또한 코틀린에 새로 도입한 sealed 변경자에 대해 설명한다. sealed는 클래스 상속을 제한한다. 코틀린 인터페이스 인터페이스를 정의하고 구현하는 방법을 살펴보자. 코틀린 인터페이스는 자바 8 인터페이스와 비슷하다. 코틀린 인터페이스 안에는 추상 메서드뿐 아니라 구현이 있는 메서드도 정의할 수 있다(이는 자바 8의 디폴트 메서드와 비..

3장 - 함수 정의와 호출

3장에서는 모든 프로그램에서 핵심이라 할 수 있는 함수 정의와 호출 기능을 코틀린이 어떻게 개선했는지 살펴본다. 추가로 확장 함수와 프로퍼티를 사용해 자바 라이브러리를 활용하면 코틀린과 자바를 함께 쓰는 프로젝트에서 코틀린의 장점을 최대한 살릴 수 있다. 이번 장에서 설명하는 내용이 더 유용하기 (그리고 덜 추상적이기 위해)위해서 코틀린 컬렉션, 문자열, 정규식(regulear expression)만으로 문제 영역을 한정한다. 우선 코틀린에서 컬렉션을 만드는 방법을 살펴보자. 코틀린에서 컬렉션 만들기 일단 컬렉션을 만드는 방법을 배워보자. 앞에서 setOf 함수로 집합을 만드는 방법을 살펴본 적이 있다. val set = hashSetOf(1, 7, 53) 비슷한 방법으로 리스트와 맵도 만들 수 있다. ..

2장 - 코틀린 기초

이번 장에서는 모든 프로그램의 필수 요소인 변수(variable), 함수(function), 클래스(class) 등을 코틀린에서 어떻게 선언하는지 살펴보고 프로퍼티(property)라는 개념을 배운다. 또한 코틀린의 여러 제어 구조를 배운다. 대부분의 코틀린 제어 구조는 자바와 비슷하지만 몇 가지 중요한 개선이 이뤄졌다. 그런 다음 스마트 캐스트(smart cast)에 대해 설명한다. 스마트 캐스트는 타입 검사와 타입 캐스트, 타입 강제 변환을 하나로 엮은 기능이다. 마지막으로 예외 처리(exception handling)를 살펴본다. 이번장을 다 읽고 나면 기본적인 코틀린 개념을 활용해 코드를 작성할 수 있다. (한 가지 주의할 점은 여기서 배운 내용만으로 작성할 수 있는 코드는 코틀린 다운 코드라고 ..

1장 - 코틀린이란 무엇이며, 왜 필요한가?

코틀린(Kotlin)은 무엇인가? 코틀린은 자바 플랫폼에서도 돌아가는 새로운 프로그래밍 언어다. 코틀린은 간결하고 실용적이며, 자바 코드와의 상호운용성을 중시한다. 현재 자바를 사용 중인 곳이라면 거의 대부분 코틀린을 활용할 수 있다. 코틀린을 사용하는 대표적인 분야는 서버 개발, 안드로이드 앱 개발 등의 분야에서 사용한다. 코틀린은 기존 자바 라이브러리나 프레임워크와 함께 잘 작동하며, 성능도 자바와 같은 수준이다. 코틀린 맛보기 data class Person(val name: String, val age: Int? = null) // 데이터 클래스, 널이 될 수 있는 타입(Int?)과 파라미터 디폴트 값 fun main(args: Array) { val persons = listOf(Person("..

Kotlin/Kotlin In Action

4장 - 클래스, 객체, 인터페이스

728x90

코틀린의 클래스와 인터페이스는 자바 클래스, 인터페이스와는 약간 다르다. 이번장에서는 코틀린의클래스, 객체, 인터페이스에 대해 알아본다.

클래스 계층 정의

코틀린에서 클래스 계층을 정의하는 방식과 자바 방식을 비교한다. 그후 코틀린의 가시성과 접근 변경자에 대해 살펴본다. 코틀린 가시성/접근 변경자는 자바와 비슷하지만 아무것도 지정하지 않는 경우 기본 가시성은 다르다. 또한 코틀린에 새로 도입한 sealed 변경자에 대해 설명한다. sealed는 클래스 상속을 제한한다.

코틀린 인터페이스

인터페이스를 정의하고 구현하는 방법을 살펴보자. 코틀린 인터페이스는 자바 8 인터페이스와 비슷하다. 코틀린 인터페이스 안에는 추상 메서드뿐 아니라 구현이 있는 메서드도 정의할 수 있다(이는 자바 8의 디폴트 메서드와 비슷하다). 다만 인터페이스에는 아무런 상태(필드)도 들어갈 수 없다.
코틀린에서 클래스는 class로 정의하지만 인터페이스는 interface를 사용한다.

간단한 인터페이스 선언하기

interface Clickable {
    fun click()
}

이 코드는 click이라는 추상 메서드가 있는 인터페이스를 정의한다. 이 인터페이스를 구현하는 모든 비추상 클래스(또는 구체적 클래스)는 click에 대한 구현을 제공해야 한다. 다음은 이 인터페이스를 구현하는 방법을 보여준다.

단순한 인터페이스 구현하기

class Button : Clickable {
    override fun click() = println("I was clicked")
}

>>> Button().click()
I was clicked

자바에서 extend와 implements 키워드를 사용하지만, 코틀린에서는 클래스 이름 뒤에 콜론(;)을 붙이고 인터페이스와 클래스 이름을 적는 것으로 클래스 확장과 인터페이스 구현을 모두 처리한다. 자바와 마찬가지로 클래스는 인터페이스를 원하는 만큼 개수 제한 없이 마음대로 구현할 수 있지만, 클래스는 오직 하나만 확장할 수 있다. 자바의 @Override 애너테이션과 비슷한 override 변경자는 상위 클래스나 상위 인터페이스에 있는 프로퍼티나 메서드를 오버라이드한다는 표시다. 하지만 자바와 달리 코틀린에서는 override 변경자를 꼭 사용해야 한다. override 변경자는 실수로 상위 클래스의 메서드를 오버라이드하는 경우를 방지해준다. 상위 클래스에 있는 메서드와 시그니처가 같은 메서드를 우연히 하위 클래스에서 선언하는 경우 컴파일이 안 되기 때문에 override를 붙이거나 메서드 이름을 바꿔야만 한다.

인터페이스 메서드도 디폴트 구현을 제공할 수 있다. 그런 경우 메서드 앞에 default를 붙여야 하는 자바 8과 달리 코틀린에서는 메서드를 특별한 키워드로 꾸밀 필요가 없다. 그냥 메서드 본문을 메서드 시그니처 뒤에 추가하면 된다. Clickable에 디폴트 구현이 포함된 메서드를 하나 추가해보자.

interface Clickable {
    fun click() // 일반 메서드 선언
    fun showOff() = println("I'm clickable!") // 디폴트 구현이 있는 메서드 
}

이 인터페이스를 구현하는 클래스는 click에 대한 구현을 제공해야 한다. 반면 showOff 메서드의 경우 새로운 동작을 정의할 수도 있고, 그냥 정의를 생략해서 디폴트 구현을 사용할 수도 있다.
이제 showOff 메서드를 정의하는 다른 인터페이스가 다음과 같은 구현을 포함한다고 하자.

동일한 메서드를 구현하는 다른 인터페이스 정의

interface Focusable {
    fun setFocus(b: Boolean) =
        println("I ${if (b) "got" else "lost"} focus.")
    fun showOff() = println("I'm focusable!")
}

한 클래스에서 이 두 인터페이스를 함께 구현하면 어떻게 될까? 두 인터페이스 모두 디폴트 구현이 들어있는 showOff 메서드가 있다. 어느 쪽 showOff 메서드가 있다. 어느 쪽 showOff 메서드가 선택될까? 어느 쪽도 선택되지 않는다. 클래스가 구현하는 두 상위 인터페이스에 정의된 showOff 구현을 대체할 오버라이딩 메서드를 직접 제공하지 않으면 다음과 같은 컴파일러 오류가 발생한다.

The class 'Button' must
override public open fun showOff() because it inherits
many implementations of it.

코틀린 컴파일러는 두 메서드를 아우르는 구현을 하위 클래스에 직접 구현하게 강제한다.

상속한 인터페이스의 메서드 구현 호출하기

class Button : Clickable, Focusable {
    override fun click() = println("I was clicked")
    override fun showOff() {
        super<Clickable>.showOff() // 상위 타입의 이름을 꺾쇠 괄호 (<>)에 넣어서 'super'를 지정하면
        super<Focusable>.showOff() // 어떤 상위 타입의 멤버 메서드를 호출할지 지정할 수 있다. 
    }
}

Button 클래스는 이제 두 인터페이스를 구현한다. Button은 상속한 두 상위 타입의 showOff() 메서드를 호출하는 방식으로 showOff()를 구현한다. 상위 타입의 구현을 호출할 때는 자바와 마찬가지로 super를 사용한다. 하지만 구체적으로 타입을 지정하는 문법은 다르다. 자바에서는 Clickable.super.showOff() 처럼 super앞에 기반 타입을 적지만, 코틀린에서는 super.showOff()처럼 꺾쇠 괄호 안에 기반 타입 이름을 지정한다.

상속한 구현 중 단 하나만 호출해도 된다면 다음과 같이 쓸 수도 있다.
override fun showOff() = super<Clickable>.showOff()

이 클래스의 인스턴스를 만들고 showOff()가 구현대로 상속한 모든 메서드를 호출하는지 검증해볼 수 있다.

자바에서 코틀린의 메서드가 있는 인터페이스 구현하기
코틀린은 자바 6와 호환되게 설계됐다. 따라서 인터페이스의 디폴트 메서드를 지원하지 않는다. 따라서 코틀린은 디폴트 메서드가 있는 인터페이스를 일반 인터페이스와 디폴트 메서드 구현이 정적 메서드로 들어있는 클래스를 조합해 구현한다. 인터페이스에는 메서드 선언만 들어가며, 인터페이스와 함께 생성되는 클래스에는 모든 디폴트 메서드 구현이 정적 메서드로 들어간다. 그러므로 디폴트 인터페이스가 포함된 코틀린 인터페이스를 자바 클래스에서 상속해 구현하고 싶다면 코틀린에서 메서드 본문을 제공하는 메서드를 포함하는 모든 메서드에 대한 본문을 작성해야 한다. (즉 자바에서는 코틀린 디폴트 메서드 구현에 의존할 수 없다)

(위의 내용은 코틀린 인 액션이 쓰여진 시기와 지금은 조금 다른이면이 있을 것이라 생각한다. 이 부분에 대해서는 추후 찾아보고 간략하게라도 정리를 해봐야할 것 같다.)

지금까지 코틀린에서 메서드가 정의된 인터페이스를 사용하는 방법을 살펴봤다. 이제는 기발 클래스에 정의된 메서드를 오버라이드하는 방법을 살펴보자.

open, final, abstract 변경자: 기본적으로 final

우리가 아는 대로 자바에서는 final로 명시적으로 상속을 금지하지 않는 모든 클래스를 다른 클래스가 상속할 수 있다. 이렇게 기본적으로 상속이 가능하면 편리한 경우도 많지만 문제가 생기는 경우도 많다.

취약한 기반 클래스(fragile base class)라는 문제는 하위 클래스가 기반 클래스에 대해 가졌던 가정이 기반 클래스를 변경함으로써 깨져버린 경우에 생긴다. 어떤 클래스가 자신을 상속하는 방법에 대해 정확한 규칙(어떤 메서드를 어떻게 오버라이드해야 하는지..etc)을 제공하지 않는다면 그 클래스의 클라이언트는 기반 클래스를 작성한 사람의 의도와 다른 방식으로 메서드를 오버라이드할 위험이 있다. 모든 하위 클래스를 분석하는 것은 불가능하기 때문에 기반 클래스를 변경하는 경우 하위 클래스의 동작이 예기치 않게 바뀔 수도 있다는면에서 기반 클래스는 '취약'하다.

이 문제를 해결하기 위해 자바 프로그래밍 기법에 대한 책 중 가장 유명한 책인 '이펙티브 자바'에서는 상속을 위한 설계와 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라라는 조언을 한다.(

필자가 이 내용과 관련된 영상을 찍었었는데, 너무 상속을 금지하는 늬앙스로 말을해서 비난의 댓글을 받은 적이 있다

) 이는 특별히 하위 클래스에서 오버라이드하게 의도된 클래스와 메서드가 아니라면 모두 final로 만들라는 뜻이다.

코틀린도 마찬가지 철학을 따른다. 자바의 클래스와 메서드는 기본적으로 상속에 대해 열려있지만 코틀린의 클래스와 메서드는 기본적으로 final이다.

어떤 클래스의 상속을 허용하려면 클래스 앞에 open 변경자를 붙여야 한다. 그와 더불어 오버라이드를 허용하고 싶은 메서드나 프로퍼티의 앞에도 open 변경자를 붙여야한다.

열린 메서드를 포함하는 열린 클래스 정의하기

open class RichButton : Clickable { // 이 클래스는 열려있다. 다른 클래스가 이 클래스를 상속할 수 있다. 

    fun disable() { } // 이 함수는 파이널이다. 하위 클래스가 이 메서드를 오버라이드할 수 없다. 

    open fun animate() { } // 이 함수는 열려있다. 하위 클래스에서 이 메서드를 오버라이드해도 된다. 

    override fun click() { } // 이 함수는 (상위 클래스에서 선언된) 열려있는 메서드를 오버라이드한다. 오버라이드한 메서드는 기본적으로 열려있다.

기반 클래스나 인터페이스의 멤버를 오버라이드하는 경우 그 메서드는 기본적으로 열려있다. 오버라이드하는 메서드의 구현을 하위 클래스에서 오버라이드하지 못하게 금지하려면 오버라이드하는 메서드 앞에 final을 명시해야 한다.

오버라이드 금지하기

open class RichButton : Clickable {
    final override fun click() { } // "final"이 없는 override 메서드나 프로퍼티는 기본적으로 열려있다. 

가시성 변경자: 기본적으로 공개

가시성 변경자(visibility modifier)는 코드 기반에 있는 선언에 대한 클래스 외부 접근을 제어한다. 어떤 클래스의 구현에 대한 접근을 제한함으로써 그 클래스에 의존하는 외부 코드를 깨지 않고도 내부 구현을 변경할 수 있다.

기본적으로 코틀린 가시성 변경자는 자바와 비슷하다. 자바와 같은 public, protected, private 변경자가 있다. 하지만 코틀린의 기본 가시성은 자바와 다르다. 아무 변경자도 없는 경우 선언은 모두 공개(public)된다.

자바의 기본 가시성인 패키지 전용(package-private)은 코틀린에 없다. 코틀린은 패키지를 네임스페이스(namespace)를 관리하기 위한 용도로만 사용한다. 그래서 패키지를 가시성 제어에 사용하지 않는다.

패키지 전용 가시성에 대한 대안으로 코틀린에는 internal이라는 새로운 가시성 변경자를 도입했다(우리말로는 모듈 내부라고 번역). internal은 "모듈 내부에서만 볼 수 있음"이라는 뜻이다. 모듈(module)은 한 번에 한꺼번에 컴파일되는 코틀린 파일들을 의미한다. IntelliJ, Eclipse, Maven, Gradle 등의 프로젝트가 모듈이 될 수 있고, 앤트 태스크(task)가 한 번 실행될 때 함께 컴파일되는 파일의 집합도 모듈이 될 수 있다.

모듈 내부 가시성은 우리의 모듈의 구현에 대해 진정한 캡슐화를 제공한다는 장점이 있다. 자바에서는 패키지가 같은 클래스를 선언하기만 하면 어떤 프로젝트의 외부에있는 코드라도 패키지 내부에 있는 패키지 전용 선언에 쉽게 접근할 수 있다. 그래서 모듈의 캡슐화가 쉽게 깨진다.

다른 차이는 코틀린에서는 최상위 선언에 대해 private 가시성(비공개 가시성)을 허용한다는 점이다. 그런 최상위 선언에는 클래스, 함수, 프로퍼티 등이 포함된다. 비공개 가시성인 최상위 선언은 그 선언이 들어있는 파일 내부에서만 사용할 수 있다. 이 또한 하위 시스템의 자세한 구현 사항을 외부에 감추고 싶을 때 유용한 방법이다. 아래의 표는 모든 가시성 변경자를 요약해 보여준다.

변경자 클래스 멤버 최상위 선언
public(기본 가시성임) 모든 곳에서 볼 수 있다. 모든 곳에서 볼 수 있다.
internal 같은 모듈 안에서만 볼 수 있다. 같은 모듈 안에서만 볼 수 있다.
protected 하위 클래스에서만 볼 수 있다. (최상위 선언에 적용할 수 없음)
private 같은 클래스 안에서만 볼 수 있다. 같은 파일 안에서만 볼 수 있다.

예제를 하나 살펴보자. giveSpeech 함수 안의 각 줄은 가시성 규칙을 위반한다. 컴파일하면 오류를 볼 수 있다.

internal open class TalkativeButton : Focusable {
    private fun yell() = println("Hey!")
    protected fun whisper() = println("Let's talk!")
}

fun TalkativeButton.giveSpeech() { // 오류: "public" 멤버가 자신의 "internal" 수신 타입인 "TalkativeButton"을 노출함
    yell() // 오류: "yell"은 "TalkativeButton"의 "private" 멤버임

    whisper() // 오류: "whisper"는 "TalkativeButton"의 "protected" 멤버임 
}

코틀린은 public 함수인 giveSpeech 안에서 그보다 가시성이 더 낮은(이 경우 internal) 타입인 TalkativeButton을 참조하지 못하게 한다. 이는 어떤 클래스의 기반 타입 목록에 들어있는 타입이나 제네릭 클래스의 타입 파라미터에 들어있는 타입의 가시성은 그 클래스 자신의 가시성과 같거나 더 높아야 하고, 메서드의 시그니처에 사용된 모든 타입의 가시성은 그 메서드의 가시성과 같거나 더 높아야 한다는 더 일반적인 규칙에 해당한다. 이런 규칙은 어떤 함수를 호출하거나 어떤 클래스를 확장할 때 필요한 모든 타입에 접근할 수 있게 보장해준다. 여기서 컴파일 오류를 없애려면 giveSpeech 확장 함수의 가시성을 internal로 바꾸거나, TalkativeButton 클래스의 가시성을 public으로 바꿔야 한다.

자바에서는 같은 패키지 안에서 protected 멤버에 접근할 수 있지만, 코틀린에서는 그렇지 않다는 점에서 자바와 코틀린의 protected가 다르다는 사실에 유의하자. 코틀린의 가시성 규칙은 단순하다. protected 멤버는 오직 어떤 클래스나 그 클래스를 상속한 클래스 안에서만 보인다. 클래스를 확장한 함수는 그 클래스의 private이나 protected 멤버에 접근할 수 없다는 사실을 여기서 한번 더 짚고 넘어가자.

코틀린의 가시성 변경자와 자바

코틀린의 public, protected, private 변경자는 컴파일된 자바 바이트코드 안에서도 그대로 유지된다. 그렇게 컴파일된 코틀린 선언의 가시성은 마치 자바에서 똑같은 가시성을 사용해 선언한 경우와 같다. 유일한 예외는 private 클래스다. 자바에서는 클래스를 private으로 만들 수 없으므로 내부적으로 코틀린은 private 클래스를 패키지-전용 클래스로 컴파일한다.

그렇다면 internal 변경자는 어떻게 처리될지 궁금할 것이다. 자바에는 internal에 딱 맞는 가시성이 없다. 패키지-전용 가시성은 internal과는 전혀 다르다. 모듈은 보통 여러 패키지로 이뤄지며 서로 다른 모듈에 같은 패키지에 속한 선언이 들어 있을 수도 있다. 따라서 internal 변경자는 바이트코드상에서는 public이 된다.

코틀린 선언과 그에 해당하는 자바 선언(또는 바이트코드 표현)에 이런 차이가 있기 때문에 코틀린에서는 접근할 수 없는 대상을 자바에서 접근할 수 있는 경우가 생긴다. 예를 들어 다른 모듈에 정의된 internal 클래스나 internal 최상위 선언을 모듈 외부의 자바 코드에서 접근할 수 있다. 또한 코틀린에서 protected로 정의한 멤버를 코틀린 클래스와 같은 패키지에 속한 자바 코드에서는 접근할 수 있다(이는 자바에서 자바 protected 멤버에 접근하는 경우와 같다).

하지만 코틀린 컴파일러가 internal 멤버의 이름을 보기 나쁘게 바꾼다는(mangle) 사실을 기억하라. 그로 인해 기술적으로는 internal 멤버를 자바에서 문제없이 사용할 수 있지만, 멤버 이름이 보기 불편하고 코드가 못생겨 보인다. 이렇게 이름을 바꾸는 이유는 두 가지다. 첫 번째는 한 모듈에 속한 어떤 클래스를 모듈 밖에서 상속한 경우 그 하위 클래스 내부의 메서드 이름이 우연히 상위 클래스의 internal 메서드와 같아져서 내부 메서드를 오버라이드하는 경우를 방지하기 위함이고, 두 번째는 실수로 internal 클래스를 모듈 외부에서 사용하는 일을 막기 위함이다.

코틀린과 자바 가시성 규칙의 또 다른 차이는 코틀린에서는 외부 클래스가 내부 클래스나 중첩 클래스의 private 멤버에 접근할 수 없다는 점이다. 다음 절에서 내부 클래스와 중첩된 클래스에 대해 설명하고 가시성과 관련된 예제도 살펴보자.

내부 클래스와 중첩된 클래스: 기본적으로 중첩 클래스

자바처럼 코틀린에서도 클래스 안에 다른 클래스를 선언할 수 있다. 클래스 안에 다른 클래스를 선언하면 도우미 클래스를 캡슐화하거나 코드 정의를 그 코드를 사용하는 곳 가까이에 두고 싶을 때 유용하다. 자바와의 차이는 코틀린의 중첩 클래스(nested class)는 명시적으로 요청하지 않는 한 바깥쪽 클래스 인스턴스에 대한 접근 권한이 없다는 점이다.

View 요소를 하나 만들다고 상상해보자. 그 View의 상태를 직렬화해야 한다. 뷰를 직렬화하는 일은 쉽지 않지만 필요한 모든 데이터를 다른 도우미 클래스로 복사할 수는 있다. 이를 위해 State 인터페이스를 선언하고 Serializable을 구현한다. View 인터페이스 안에는 뷰의 상태를 가져와 저장할 때 사용할 getCurrentState와 restoreState 메서드 선언이 있다.

직렬화할 수 있는 상태가 있는 뷰 선언

interface State: Serializable 

interface View {
    fun getCurrentState(): State
    fun restoreState(state: State) { }
}

Button 클래스의 상태를 저장하는 클래스는 Button 클래스 내부에 선언하면 편하다. 자바에서 그런 선언을 어떻게 하는지 살펴보자

자바에서 내부 클래스를 사용해 View 구현하기

/* 자바 */ 
public class Button implements View {
    @Override
    public State getCurrentState() {
        return new ButtonState();
    }

    @Override
    public void restoreState(State state) { /*...*/ }

    public class ButtonState implements State { /*...*/ }
}

State 인터페이스를 구현한 ButtonState 클래스를 정의해서 Button에 대한 구체적인 정보를 저장한다. getCurrentState 메서드 안에서는 ButtonState의 새 인스턴스를 만든다. 실제로는 getCurrentState 안에 필요한 모든 정보를 추가해야 한다.

이 코드의 어디가 잘못된 걸까? 왜 선언한 버튼의 상태를 직렬화하면 java.io.NotSerializableException: Button이라는 오류가 발생할까? 처음에는 이 상황이 이상해 보일지도 모르겠다. 직렬화하려는 변수는 ButtonState 타입이 state 였는데 왜 Button을 직렬화할 수 없다는 예외가 발생할까?

자바에서 다른 클래스 안에 정의한 클래스는 자동으로 내부 클래스(inner class)가 된다는 사실을 기억한다면 어디가 잘못된 건지 명확히 알 수 있다. 이 예제의 ButtonState 클래스는 바깥쪽 Button 클래스에 대한 참조를 묵시적으로 포함한다. 그 참조로 인해 ButtonState를 직렬화할 수 없다.Button을 직렬화할 수 없으므로 버튼에 대한 참조가 ButtonState의 직렬화를 방해한다.

이 문제를 해결하려면 ButtonState를 static 클래스로 선언해야 한다. 자바에서 중첩 클래스를 static으로 선언하면 그 클래스를 둘러싼 바깥쪽 클래스에 대한 묵시적인 참조가 사라진다. 코틀린에서 중첩된 클래스가 기본적으로 동작하는 방식은 방금 설명한 것과 정반대다. 다음 예제를 보자.

중첩 클래스를 사용해 코틀린에서 View 구현하기

class Button : View {
    override fun getCurrentState(): State = ButtonState()

    override fun restoreState(state: State) { /*...*/ }

    class ButtonState : State { /*...*/ } // 이 클래스는 자바의 정적 중첩 클래스와 대응한다. 
}

코틀린 중첩 클래스에 아무런 변경자가 붙지 않으면 자바 static 중첩 클래스와 같다. 이를 내부 클래스로 변경해서 바깥쪽 클래스에 대한 참조를 포함하게 만들고 싶다면 inner 변경자를 붙여야 한다. 아래의 표는 이와 관련한 자바와 코틀린 사이의 차이를 보여준다. 중첩 클래스와 내부 클래스 사이의 차이를 그림 4.1에서 볼 수 있다.

클래스 B 안에 정의된 클래스 A 자바에서는 코틀린에서는
중첩 클래스(바깥쪽 클래스에 대한 참조를 저장하지 않음) static class A class A
내부 클래스(바깥쪽 클래스에 대한 참조를 저장함) class A inner class A

image

중첩 클래스 안에는 바깥쪽 클래스에 대한 참조가 없지만 내부 클래스에는 있다.

(위의 표는 아이패드와 애플펜슬을 산 기념으로 직접 그려봤다 😆)

코틀린에서 바깥쪽 클래스의 인스턴스를 가리키는 참조를 표기하는 방법도 자바와 다르다. 내부 클래스 Inner 안에서 바깥쪽 클래스 Outer의 참조에 접근하려면 this@Outer라고 써야 한다.

class Outer {
    inner class Inner {
        fun getOuterReference(): Outer = this@Outer
    }
}

자바와 코틀린의 내부 클래스의 중첩 클래스 간의 차이에 대해 배웠다. 이제는 코틀린 중첩 클래스를 유용하게 사용하는 용례를 하나 살펴보자. 클래스 계층을 만들되 그 계층에 속한 클래스의 수를 제한하고 싶은 경우 중첩 클래스를 쓰면 편리하다.

봉인된 클래스: 클래스 계층 정의 시 계층 확장 제한

식을 표현하는 클래스 계층을 다시 생각해보자. 상위 클래스인 Expr에는 숫자를 표현하는 Num과 덧셈 연산을 표현하는 Sum이라는 두 하위 클래스가 있다. when 식에서 이 모든 하위 클래스를 처리하면 편리하다. 하지만 when 식에서 Num과 Sum이 아닌 경우를 처리하는 else 분기를 반드시 넣어줘야만 한다.

인터페이스 구현을 통해 식 표현하기

interface Expr 

class Num(val value: Int): Expr

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

fun eval(e: Expr) : Int = 
    is Num -> e.value
    is Sum -> eval(e.right) + eval(e.left)
    else -> 
        throw IllegalArgumentException("Unknown expression")
}

코틀린 컴파일러는 when을 사용해 Expr 타입의 값을 검사할 때 꼭 디폴트 분기인 else 분기를 덧붙이게 강제한다. 이 예제의 else 분기에서는 반환할 만한 의미 있는 값이 없으므로 예외를 던진다.

항상디폴트 분기를 추가하는 게 편하지는 않다. 그리고 디폴트 분기가 있으면 이런 클래스 계층에 새로운 하위 클래스를 추가하더라도 컴파일러가 when이 모든 경우를 처리하는지 제대로 검사할 수 없다. 혹 실수로 새로운 클래스 처리를 잊어버렸더라도 디폴트 분기가 선택되기 때문에 심각한 버그가 발생할 수 있다.

코틀린은 이런 문제에 대한 해법을 제공한다. sealed 클래스가 그 답이다. 상위 클래스에 sealed 변경자를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있다. sealed 클래스의 하위 클래스를 정의할 때는 반드시 상위 클래스 안에 중첩시켜야 한다.

sealed 클래스로 식 표현하기

sealed class Expr { // 기반 클래스를 sealed로 봉인한다. 
    class Num(val value: Int) : Expr() // 기반 클래스의 모든 하위 클래스를 중첩 클래스로 나열한다. 
    class Sum(val left: Expr, val right: Expr) : Expr()
}

fun eval(e: Expr): Int = 
    when (e) { // 'when' 식이 모든 하위 클래스를 검사하므로 별도의 'else' 분기가 없어도 된다. 
        is Expr.Num -> e.value
        is Expr.Sum -> eval(e.right) + eval(e.left)
    }

when 식에서 sealed 클래스의 모든 하위 클래스를 처리한다면 디폴트 분기(else 분기)가 필요 없다. sealed로 표시된 클래스는 자동으로 open임을 기억하라. 따라서 별도로 open 변경자를 붙일 필요가 없다. 봉인된 클래스의 동작을 아래의 그림에서 볼 수 있다.

image

sealed 클래스에 속한 값에 대해 디폴트 분기를 사용하지 않고 when 식을 사용하면 나중에 sealed 클래스의 상속 계층에 새로운 하위 클래스를 추가해도 when 식이 컴파일되지 않는다. 따라서 when 식을 고쳐야 한다는 사실을 쉽게 알 수 있다. 내부적으로 Expr 클래스는 private 생성자를 가진다. 그 생성자는 클래스 내부에서만 호출할 수 있다. sealed 인터페이스를 정의할 수는 없다. 왜냐하면 봉인된 인터페이스를 만들 수 있다면 그 인터페이스를 자바 쪽에서 구현하지 못하게 막을 수 있는 수단이 코틀린 컴파일러에게 없기 때문이다.

코틀린에서는 클래스를 확장할 때나 인터페이스를 구현할 때 모두 콜론(:)을 사용한다. 하위 클래스 선언을 자세히 살펴보자.

class Num(val value: Int) : Expr()

우리는 이 선언에서 맨 마지막의 Expr()에 쓰인 괄호를 제외한 모든 부분을 명확히 이해할 수 있어야 한다. Expr()에 쓰인 괄호에 대해서는 코틀린의 클래스 초기화에 대해 다루는 다음 절에서 설명한다.

뻔하지 않은 생성자와 프로퍼티를 갖는 클래스 선언

자바에서는 생성자를 하나 이상 선언할 수 있다. 코틀린도 비슷하지만 한 가지 바뀐 부분이 있다. 코틀린은 주 생성자(보통 주 생성자는 클래스를 초기화할 때 주로 사용하는 간략한 생성자로, 클래스 본문 밖에서 정의한다)와 부 생성자(클래스 본문안에서 정의한다)를 구분한다. 또한 코틀린에서는 초기화 블록(initializer block)을 통해 초기화 로직을 추가할 수 있다. 먼저 주 생성자와 초기화 블록을 선언하는 문법을 살펴보고 나중에 생성자를 여럿 선언하는 방법을 설명한다. 그 다음에는 프로퍼티에 대해 좀 더 자세히 알아보자.

클래스 초기화: 주 생성자와 초기화 블록

간단한 클래스를 선언하는 방법은 아래와 같다.

class User(val nickname: String)

보통 클래스의 모든 선언은 중괄호({}) 사이에 들어간다. 하지만 이 클래스 선언에는 중괄호가 없고 괄호 사이에 val 선언만 존재한다. 그 이유가 궁금할 것이다. 이렇게 클래스 이름 뒤에 오는 괄호로 둘러싸인 코드를 주 생성자(primary constructor)라고 부른다. 주 생성자는 생성자 파라미터를 지정하고 그 생성자 파라미터에 의해 초기화되는 프로퍼티를 정의하는 두 가지 목적에 쓰인다. 이제 이 선언을 같은 목적을 달성할 수 있는 가장 명시적인 선언으로 풀어서 실제로는 어떤 일이 벌어지는지 살펴보자.

class User constructor(_nickname: String) { // 파라미터가 하나만있는 주 생성자 
    val nickname: String

    init { // 초기화 블록 
        nickname = _nickname
    }
}

이 예제에서 constructor와 init이라는 새로운 키워드를 볼 수 있다. constructor와 init이라는 새로운 키워드를 볼 수 있다. constructor 키워드는 주 생성자나 부 생성자 정의를 시작할 때 사용한다. init 키워드는 초기화 블록을 시작한다. 초기화 블록에는 클래스의 객체가 만들어질 때(인스턴스화될 때) 실행될 초기화 코드가 들어간다. 초기화 블록은 주 생성자와 함께 사용된다. 주 생성자는 제한적이기 때문에 별도의 코드를 포함할 수 없으므로 초기화 블록이 필요하다. 필요하다면 클래스 안에 여러 초기화 블록을 선언할 수 있다.

생성자 파라미터 _nickname에서 맨 앞의 밑줄(_)은 프로퍼티와 생성자 파라미터를 구분해준다. 다른 방법으로 자바에서 흔히 쓰는 방식처럼 this.nickname = nickname 같은 식으로 생성자 파라미터와 프로퍼티의 이름을 같게 하고 프로퍼티에 this를 써서 모호성을 없애도 된다.

이 예제에서는 nickname 프로퍼티를 초기화하는 코드를 nickname 프로퍼티 선언에 포함시킬 수 있어서 초기화 코드를 초기화 블록에 넣을 필요가 없다. 또 주 생성자 앞에 별다른 애너테이션이나 가시성 변경자가 없다면 constructor를 생략해도 된다.

class User(_nickname: String) { // 파라미터가 하나뿐인 주 생성자
    val nickname = _nickname // 프로퍼티를 주 생성자의 파라미터로 초기화한다. 
}

이 예제는 같은 클래스를 정의하는 여러 방법 중 하나다. 프로퍼티를 초기화하는 식이나 초기화 블록 안에서만 주 생성자의 파라미터를 참조할 수 있다는 점에 유의하자.

방금 살펴본 두 예제는 클래스 본문에서 val 키워드를 통해 프로퍼티를 정의했다. 하지만 주 생성자의 파라미터로 프로퍼티를 초기화한다면 그 주 생성자 파라미터 이름앞에 val을 추가하는 방식으로 프로퍼티 정의와 초기화를 간략히 쓸 수 있다.

class User(val nickname: String) // 'val'은 파라미터에 상응하는 프로퍼티가 생성된다는 뜻이다.

지금까지 살펴본 세 가지 User 선언은 모두 같다. 하지만 마지막 선언이 가장 간결하다. 함수 파라미터와 마찬가지로 생성자 파라미터에도 디폴트 값을 정의할 수 있다.

class User(val nickname: String, val isSubscribed: Boolean = true) // 생성자 파라미터에 대한 디폴트 값을 제공한다

클래스의 인스턴스를 만들려면 new 키워드 없이 생성자를 직접 호출하면 된다.

모든 생성자 파라미터에 디폴트 값을 지정하면 컴파일러가 자동으로 파라미터가 없는 생성자를 만들어준다. 그렇게 자동으로 만들어진 파라미터 없는 생성자는 디폴트 값을 사용해 클래스를 초기화한다. 의존관계 주입(DI, Dependency Injection) 프레임워크 등 자바 라이브러리 중에는 파라미터가 없는 생성자를 통해 객체를 생성해야만 라이브러리 사용이 가능한 경우가 있는데, 코틀린이 제공하는 파라미터 없는 생성자는 그런 라이브러리와의 통합을 쉽게 해준다.

클래스에 기반 클래스가 있다면 주 생성자에서 기반 클래스의 생성자를 호출해야 할 필요가 있다. 기반 클래스를 초기화하려면 기반 클래스 이름 뒤에 괄호를 치고 생성자인자를 넘긴다.

open class User(val nickname: String) { ... } 

class TwitterUser(nickname: String) : User(nickname) { ... }

클래스를 정의할 때 별도로 생성자를 정의하지 않으면 컴파일러가 자동으로 아무 일도 하지 않는 인자가 없는 디폴트 생성자를 만들어준다.

open class Button // 인자가 없는 디폴트 생성자가 만들어진다.

Button의 생성자는 아무 인자도 받지 않지만, Button 클래스를 상속한 하위 클래스는 반드시 Button 클래스의 생성자를 호출해야 한다.

class RadioButton: Button()

이 규칙으로 인해 기반 클래스의 이름 뒤에는 꼭 빈 괄호가 들어간다(물론 생성자 인자가 있다면 괄호 안에 인자가 들어간다). 반면 인터페이스는 생성자가 없기 때문에 어떤 클래스가 인터페이스를 구현하는 경우 그 클래스의 상위 클래스 목록에 있는 인터페이스 이름 뒤에는 아무 괄호도 없다. 클래스 정의에 있는 상위 클래스 및 인터페이스 목록에서 이름 뒤에 괄호가 붙었는지 살펴보면 쉽게 기반 클래스와 인터페이스를 구별할 수 있다.

어떤 클래스를 클래스 외부에서 인스턴스화하지 못하게 막고 싶다면 모든 생성자를 private으로 만들면 된다. 다음과 같이 주 생성자에 private 변경자를 붙일 수 있다.

class Secretive private constructor() {} // 이 클래스의 (유일한) 주 생성자는 비공개다.

Secretive 클래스 안에는 주 생성자밖에 없고 그 주 생성자는 비공개이므로 외부에서는 Secretive를 인스턴스화할 수 없다. 나중에 동반 객체(companion object)에 대해 설명하면서 동반 객체 안에서 이런 비공개 생성자를 호출하면 좋은 이유에 대해 알아본다.

비공개 생성자에 대한 대안

유틸리티 함수를 담아두는 역할만을 하는 클래스는 인스턴스화할 필요가 없고, 싱글턴인 클래스는 미리 정한 팩토리 메서드 등의 생성 방법을 통해서만 객체를 생성해야 한다. 자바에서는 이런 더 일반적인 요구 사항을 명시할 방법이 없으므로 어쩔 수 없이 private 생성자를 정의해서 클래스를 다른 곳에서 인스턴스화하지 못하게 막는 경우가 생긴다. 코틀린은 그런 경우를 언어에서 기본 지원한다. 정적 유틸리티 함수 대신 최상위 함수를 사용할 수 있고, 싱글턴을 사용하고 싶으면 객체를 선언하면 된다.

실제로 대부분의 경우 클래스의 생성자는 아주 단순하다. 생성자에 아무 파라미터도 없는 클래스도 많고, 생성자 코드 안에서 생성자가 인자로 받은 값을 프로퍼티에 설정하기만 하는 생성자도 많다. 그래서 코틀린은 간단한 주 생성자 문법을 제공한다. 대부분 이런 간단한 주 생성자 구문만으로도 충분하다. 하지만 삶에는 어려움이 있기 마련이다. 코틀린도 그런 경우를 대비해 필요에 따라 다양한 생성자를 정의할 수 있게 해준다. 이제부터는 생성자를 만드는 여러 방법을 살펴보자.

부 생성자: 상위 클래스를 다른 방식으로 초기화

일반적으로 코틀린에서는 생성자가 여럿 있는 경우가 자바보다 훨씬 적다. 자바에서 오버로드한 생성자가 필요한 상황 중 상당 수는 코틀린의 디폴트 파라미터 값과 이름 붙인 인자 문법을 사용해 해결할 수 있다.

인자에 대한 디폴트 값을 제공하기 위해 부 생성자를 여럿 만들지 말라. 대신 파라미터의 디폴트 값을 생성자 시그니처에 직접 명시하라.

그래도 생성자가 여럿 필요한 경우가 가끔 있다. 가장 일반적인 상황은 프레임워크 클래스를 확장해야 하는데 여러 가지 방법으로 인스턴스를 초기화할 수 있게 다양한 생성자를 지원해야 하는 경우다. 예를 들어 자바에서 선언된 생성자가 2개인 View 클래스가 있다고 하자(안드로이드 개발자라면 이 클래스를 알아볼 수 있을 것이다). 그 클래스를 코틀린으로 다음과 비슷하게 정의할 수 있다.

open clas View {
    constructor(ctx: Context) { // 부 생성자
        // 코드
    }
    constructor(ctx: Context, attr: AttributeSet) { // 부 생성자
        // 코드
    }
}

이 클래스는 주 생성자를 선언하지 않고(클래스 헤더에 있는 클래스 이름 뒤에 괄호가 없다), 부 생성자만 2가지 선언한다. 부 생성자는 constructor 키워드로 시작한다. 필요에 따라 얼마든지 부 생성자를 많이 선언해도 된다.

이 클래스를 확장하면서 똑같이 부 생성자를 정의할 수 있다.

class MyButton: View {
    constructor(ctx: Context): super(ctx) {
        // ...  // 상위 클래스의 생성자를 호출한다. 
    }
    constructor(ctx: Context, attr: AttributeSet): super(ctx, attr) {
        // ... // 상위 클래스의 생성자를 호출한다. 
    }
}

여기서 두 부 생성자는 super() 키워드를 통해 자신에 대응하는 상위 클래스 생성자를 호출한다. 이 관계를 아래의 그림에서 볼 수 있다. 그림에서 화살표는 생성자가 상위 클래스 생성자에게 객체 생성을 위임한다는 사실을 표시한다.

image

자바와 마찬가지로 생성자에서 this()를 통해 클래스 자신의 다른 생성자를 호출할 수 있다. 다음을 살펴보자.

class MyButton : View {
    constructor(ctx: Context): this(ctx, MY_STYLE) { // 이 클래스의 다른 생성자에게 위임한다. 
        // ...
    }

    constructor(ctx: Context, attr: AttributeSet) : super(ctx, attr) { // 상위 클래스의 생성자를 호출한다. 
        // ... 
    }
}

MyButton 클래스의 생성자 중 하나가 아래의 그림처럼 파라미터의 디폴트 값을 넘겨서 같은 클래스의 다른 생성자(this를 사용해 참조함)에게 생성을 위임한다. 두 번째 생성자는 여전히 super()를 호출한다.

image

클래스에 주 생성자가 없다면 모든 부 생성자는 반드시 상위 클래스를 초기화하거나 다른 생성자에게 생성을 위임해야 한다. 위의 그림을 바탕으로 생각해보면 각 부 생성자에서 객체 생성을 위임하는 화살표를 따라가면 그 끝에는 상위 클래스 생성자를 호출하는 화살표가 있어야 한다는 뜻이다.

부 생성자가 필요한 주된 이유는 자바 상호운용성이다. 하지만 부 생성자가 필요한 다른 경우도 있다. 클래스 인스턴스를 생성할 때 파라미터 목록이 다른 생성 방법이 여럿 존재하는 경우에는 부 생성자를 여럿 둘 수밖에 없다.

지금까지 뻔하지 않은 생성자를 정의하는 방법을 살펴봤다. 이제는 뻔하지 않은 프로퍼티를 살펴볼 때다.

인터페이스에 선언된 프로퍼티 구현

코틀린에서는 인터페이스 추상 프로퍼티 선언을 넣을 수 있다. 다음은 추상 프로퍼티 선언이 들어있는 인터페이스 선언의 예이다.

interface User {
    val nickname: String
}

이는 User 인터페이스를 구현하는 클래스가 nickname의 값을 얻을 수 있는 방법을 제공해야 한다는 뜻이다. 인터페이스에 있는 프로퍼티 선언에는 뒷받침하는 필드나 게터 등의 정보가 들어있지 않다. 사실 인퍼테이스는 아무 상태도 포함할 수 없으므로 상태를 저장할 필요가 있다면 인터페이스를 구현한 하위 클래스에서 상태 저장을 위한 프로퍼티 등을 만들어야 한다.

이제 이 인터페이스를 구현하는 방법을 몇 가지 살펴보자. PrivateUser는 별명을 저장하기만 하고 SubscribingUser는 이메일을 함께 저장한다. FacebookUser는 페이스북 계정의 ID를 저장한다. 이 세 클래스는 각각 다른 방식으로 추상 프로퍼티 nickname을 구현한다.

인터페이스의 프로퍼티 구현하기

class PrivateUser(override val nickname: String) : User // 주 생성자에 있는 프로퍼티 

class SubscribingUser(val email: String) : User {
    override val nickname: String
      get() = email.substringBefore('@') // 커스텀 게터
}

class FacebookUser(val accountId: Int) : User {
    override val nickname = getFacebookName(accountId) // 프로퍼티 초기화 식 
}

>>> println(PrivateUser("test@kotlinlang.org").nickname)
test@kotlinlang.org
>>> println(SubscribingUser("test@kotlinlang.org").nickname)
test

PrivateUser는 주 생성자 안에 프로퍼티를 직접 선언하는 간결한 구문을 사용한다. 이 프로퍼티는 User의 추상 프로퍼티를 구현하고 있으므로 override를 표시해야 한다. SubscribingUser는 커스텀 게터로 nickname 프로퍼티를 설정한다. 이 프로퍼티는 뒷받침하는 필드에 값을 저장하지 않고 매번 이메일 주소에서 별명을 계산해 반환한다.

FacebookUser에서는 초기화 식으로 nickname 값을 초기화한다. 이때 페이스북 사용자 ID를 받아서 그 사용자의 이름을 반환해주는 getFacebookName 함수(이 함수는 다른 곳에 정의돼 있다고 가정한다)를 호출해서 nickname을 초기화한다. getFacebookName은 페이스북에 접속해서 인증을 거친 후 원하는 데이터를 가져와야 하기 때문에 비용이 많이 들 수도 있다. 그래서 객체를 초기화하는 단계에 한 번만 getFacebookName을 호출하게 설계했다.

SubscribingUser와 FacebookUser의 nickname 구현 차이에 주의하라. 그 둘은 비슷해 보이지만, SubscribingUser의 nickname은 매번 호출될 때마다 substringBefore를 호출해 계산하는 커스텀 게터를 활용하고, FacebookUser의 nickname은 객체 초기화 시 계산한 데이터를 뒷받침하는 필드에 저장했다가 불러오는 방식을 활용한다.

인터페이스에는 추상 프로퍼티뿐 아니라 게터와 세터가 있는 프로퍼티를 선언할 수도 있다. 물론 그런 게터와 세터는 뒷받침하는 필드를 참조할 수 없다(뒷받침하는 필드가 있다면 인터페이스에 상태를 추가하는 셈인데 인터페이스는 상태를 저장할 수 없다).

예를 하나 살펴보자.

interface User {
    val email: String
    val nickname: String
      get() = email.substringBefore('@') // 프로퍼티에 뒷받침하는 필드가 없다. 대신 매번 결과를 계산해 돌려준다. 
}

이 인터페이스에는 추상 프로퍼티인 email과 커스텀 게터가 있는 nickname 프로퍼티가 함께 들어있다. 하위 클래스는 추상 프로퍼티인 email을 반드시 오버라이드해야 한다. 반면 nickname은 오버라이드하지 않고 상속할 수 있다.

인터페이스에 선언된 프로퍼티와 달리 클래스에 구현된 프로퍼티는 뒷받침하는 필드를 원하는 대로 사용할 수 있다. 이제 접근자에서 뒷받침하는 필드를 가리키는 방법을 살펴보자.

게터와 세터에서 뒷받침하는 필드에 접근

지금까지 프로퍼티의 두 가지 유형(값을 저장하는 프로퍼티와 커스텀 접근자에서 매번 값을 계산하는 프로퍼티)에 대해 살펴봤다. 이제는 이 두 유형을 조합해서 어떤 값을 저장하되 그 값을 변경하거나 읽을 때마다 정해진 로직을 실행하는 유형의 프로퍼티를 만드는 방법을 살펴보자. 값을 저장하는 동시에 로직을 실행할 수 있게 하기 위해서는 접근자 안에서 프로퍼티를 뒷받침하는 필드에 접근할 수 있어야 한다.

프로퍼티에 저장된 값의 변경 이력을 로그에 남기려는 경우를 생각해보자. 그런 경우 변경 가능한 프로퍼티를 정의하되 세터에서 프로퍼티 값을 바꿀 때마다 약간의 코드를 추가로 실행해야 한다.

세터에서 뒷받침하는 필드 접근하기

class User(val name: String) {
    var address: String = "unspecified"
      set(valeu: String) {
          println("""
              Address was changed for $name:
              "$field" -> "$value".""".trimIndent()) // 뒷받침하는 필드 값 읽기 
          field = value // 뒷받침하는 필드 값 변경하기 
      }
}

>>> val user = User("Alice")
>>> user.address = "Elsenheimerstrasse 47, 80687 Muechen"
Address was changed for Alice:
"unspecified" -> "Elsenheimerstrasse 47, 80687 Muenchen".

코틀린에서는 프로퍼티의 값을 바꿀 때는 user.address = "new value" 처럼 필드 설정 구문을 사용한다. 이 구문은 내부적으로 address의 세터를 호출한다. 이 예제에서는 커스텀 세터를 정의해서 추가 로직을 실행한다(여기서는 단순화를 위해 화면에 값의 변화를 출력하기만 한다).

접근자의 본문에서는 field라는 특별한 식별자를 통해 뒷받침하는 필드에 접근할 수 있다. 게터에서는 field 값을 읽을 수만 있고, 세터에서는 field 값을 읽거나 쓸 수 있다.

변경 가능 프로퍼티의 게터와 세터 중 한쪽만 직접 정의해도 된다는 점을 기억하라. 위의 코드에서 address의 게터는 필드 값을 그냥 반환해주는 뻔한 게터다. 따라서 게터를 굳이 직접 정의할 필요가 없다.

뒷받침하는 필드가 있는 프로퍼티와 그런 필드가 없는 프로퍼티에 어떤 차이가 있는지 궁금한 독자가 있을 것이다. 클래스의 프로퍼티를 사용하는 쪽에서 프로퍼티를 읽는 방법이나 쓰는 방법은 뒷받침하는 필드의 유무와는 관계가 없다. 컴파일러는 디폴트 접근자 구현을 사용하건 직접 게터나 세터를 정의하건 관계없이 게터나 세터에 field를 사용하는 프로퍼티에 대해 뒷받침하는 필드를 생성해준다. 다만 field를 사용하지 않는 커스텀 접근자 구현을 정의한다면 뒷받침하는 필드는 존재하지 않는다(프로퍼티가 val인 경우에는 게터에 field가 없으면 되지만, var인 경우에는 게터나 세터 모두에 field가 없어야 한다).

때로 접근자의 기본 구현을 바꿀 필요는 없지만 가시성을 바꿀 필요가 있는 때가 있다. 이제 접근자의 가시성을 어떻게 바꾸는지 살펴보자.

접근자의 가시성 변경

접근자의 가시성은 기본적으로는 프로퍼티의 가시성과 같다. 하지만 원한다면 get이나 set 앞에 가시성 변경자를 추가해서 접근자의 가시성을 변경할 수 있다. 접근자의 가시성을 변경하는 방법을 다음 예제에서 살펴보자.

비공개 세터가 있는 프로퍼티 선언하기

class LengthCounter {
    var counter: Int = 0
      private set // 이 클래스 밖에서 이 프로퍼티의 값을 바꿀 수 없다.

    fun addWord(word: String) {
        counter += word.length
    }
}

이 클래스는 자신에게 추가된 모든 단어의 길이를 합산한다. 전체 길이를 저장하는 프로퍼티는 클라이언트에게 제공하는 API의 일부분이므로 public으로 외부에 공개된다. 하지만 외부 코드에서 단어 길이의 합을 마음대로 바꾸지 못하게 이 클래스 내부에서만 길이를 변경하게 만들고 싶다. 그래서 기본 가시성을 가진 게터를 컴파일러가 생성하게 내버려 두는 대신 세터의 가시성을 private으로 지정한다.
다음은 이 클래스를 사용하는 방법을 보여준다.

>>> val lengthCounter = LengthCounter()
>>> lengthCounter.addWord("Hi!")
>>> println(lengthCounter.counter)
3

LengthCounter의 인스턴스를 만들고 "Hi!"라는 문자열을 추가한다. 이제 counter 프로퍼티에는 3이 들어있다.

프로퍼티에 대해 나중에 다룰 내용
이 책의 뒷부분에서 프로퍼티에 대해 다룰 내용을 참고할 수 있게 여기 미리 밝혀둔다.

  • lateinit 변경자를 널이 될 수 없는 프로퍼티에 지정하면 프로퍼티를 생성자가 호출된 다음에 초기화한다는 뜻이다. 일부 프레임워크에서는 이런 특성이 꼭 필요하다. 6장에서 이 내용을 다룬다.
  • 요청이 들어오면 비로소 초기화되는 지연 초기화(lazy initialized) 프로퍼티는 더 일반적인 위임 프로퍼티(delegated properly)의 일종이다. 위임 프로퍼티 및 지연 초기화 프로퍼티에 대해서는 7장에서 다룬다.
  • 자바 프레임워크와의 호환성을 위해 자바의 특징을 코틀린에서 에뮬레이션하는 애노테이션을 활용할 수 있다. 예를 들어 @JvmField 애너테이션을 프로퍼티에 붙이면 접근자가 없는 public 필드를 노출시켜준다. 애너테이션에 대해서는 10장에서 다룬다. const 변경자를 사용하면 애너테이션을 더 편리하게 다룰 수 있고 원시 타입이나 String 타입인 값을 애너테이션 인자로 활용할 수 있다. 이에 대해서는 10장에서 다룬다.

이것으로 코틀린의 뻔하지 않은 프로퍼티와 생성자에 대한 설명을 마친다. 다음으로는 값-객체 클래스를 더 편하게 작성하는 방법인 data 클래스에 대해 다룬다.

컴파일러가 생성한 메서드: 데이터 클래스와 클래스 위임

자바 플랫폼에서는 클래스가 equals, hashCode, toString 등의 메서드를 구현해야한다. 그리고 이런 메서드들은 보통 비슷한 방식으로 기계적으로 구현할 수 있다. 다행히 자바 IDE들이 이런 메서드를 자동으로 만들어줄 수 있어서 직접 이런 메서드를 작성할 일은 많지 않다. 하지만 자동으로 equals, hashCode, toString 등을 생성한다고 해도 코드베이스가 번잡해진다는 면은 동일하다. 코틀린 컴파일러는 한걸음 더 나가서 이런 메서드를 기계적으로 생성하는 작업을 보이지 않는 곳에서 해준다. 따라서 필수 메서드로 인한 잡음 없이 소스코드를 깔끔하게 유지할 수 있다.

그런 코틀린의 원칙이 잘 드러나는 경우로 클래스 생성자나 프로퍼티 접근자를 컴파일러가 자동으로 만들어주는 것을 살펴봤다. 이제 코틀린 컴파일러가 데이터 클래스에 유용한 메서드를 자동으로 만들어주는 예와 클래스 위임 패턴을 아주 간단하게 쓸 수 있게 해주는 예를 살펴보자.

모든 클래스가 정의해야 하는 메서드

자바와 마찬가지로 코틀린 클래스도 toString, equals, hashCode 등을 오버라이드할 수 있다. 각각이 어떤 메서드이고 어떻게 그런 메서드를 정의해야 하는지 살펴보자. 코틀린은 이런 메서드 구현을 자동으로 생성해줄 수 있다. 고객 이름과 우편번호를 저장하는 간단한 Client 클래스를 만들어서 예제에 사용하자.

Client 클래스의 초기 정의

class Client(val name: String, val postalCode: Int)

이제 이 클래스의 인스턴스를 어떻게 문자열로 표현할지 생각해보자.

문자열 표현: toString()

자바처럼 코틀린의 모든 클래스도 인스턴스 문자열 표현을 얻을 방법을 제공한다. 주로 디버깅과 로깅 시 이 메서드를 사용한다. 물론 다른 맥락에서도 이를 사용할 수 있다. 기본 제공되는 객체의 문자열 표현은 Client@5e9f23b4 같은 방식인데, 이는 그다지 유용하지 않다. 이 기본 구현을 바꾸려면 toString 메서드를 오버라이드해야 한다.

Client에 toString() 구현하기

class Client(val name: String, val postalCode: Int) {
    override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}

이제 어떤 고객에 대한 문자열 표현은 다음과 같다.

>>> val client1 = Client("오현석", 4122)
>>> println(client1)
Client(name=오현석, postalCode=4122)

이런 문자열 표현으로부터 기본 문자열 표현보다 더 많은 정보를 얻을 수 있다.

객체의 동등성: equals()

Client 클래스를 사용하는 모든 계산은 클래스 밖에서 이뤄진다. Client는 단지 데이터를 저장할 뿐이며, 그에 따라 구조도 단순하고 내부 정보를 투명하게 외부에 노출하게 설계됐다. 그렇지만 클래스는 단순할지라도 동작에 대한 몇 가지 요구 사항이 있을 수 있다. 예를 들어 서로 다른 두 객체가 내부에 동일한 데이터를 포함하는 경우 그 둘을 동등한 객체로 간주해야 할 수도 있다.

>>> val client1 = Client("로키", 4122)
>>> val client2 = Client("로키", 4122)
>>> println(client1 == client2) // 코틀린에서 == 연산자는 참조 동일성을 검사하지 않고 객체의 동등성을 검사한다. 따라서 == 연산은 equals를 호출하는 식으로 컴파일된다. 
false 

위 예제에서는 두 객체가 동일하지 않다. 이는 Client 클래스의 요구 사항을 만족시키고 싶다면 equals를 오버라이드할 필요가 있다는 뜻이다.

동등성 연산에 ==를 사용함

자바에서는 ==를 원시 타입과 참조 타입을 비교할 때 사용한다. 원시 타입의 경우 ==는 두 피연산자의 값이 같은지 비교한다(동등성(equality)). 반면 참조 타입의 경우 ==는 두 피연산자의 주소가 같은지를 비교한다(참조 비교(reference comparision)). 따라서 자바에서는 두 객체의 동등성을 알려면 equals를 호출해야 한다. 자바에서는 equals 대신 ==를 호출하면 문제가 될 수 있다는 사실도 아주 잘 알려져 있다.
코틀린에서는 == 연산자가 두 객체를 비교하는 기본적인 방법이다. ==는 내부적으로 equals를 호출해서 객체를 비교한다. 따라서 클래스가 equals를 오버라이드하면 ==를 통해 안전하게 그 클래스의 인스턴스를 비교할 수 있다. 참조 비교를 위해서는 === 연산자를 사용할 수 있다. === 연산자는 자바에서 객체의 참조를 비교할 때 사용하는 == 연산자와 같다.

이제 equals를 추가한 Client 클래스를 살펴보자.

Client에 equals() 구현하기

class Client(val name: String, val postalCode: Int) {
    override fun equals(other: Any?): Boolean { // "Any"는 java.lang.Object에 대응하는 클래스로, 코틀린의 모든 클래스의 최상위 클래스다. "Any?"는 널이 될 수 있는 타입이므로 "other"는 null일 수 있다. 
    if (other == null || other !is Client) // "other"가 Client인지 검사한다. 
        return false
    return name == other.name && // en rorcpdml 프로퍼티 값이 서로 같은지 검사한다. 
        postalCode == other.postalCode
    }

    override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}

다시 말하지만 코틀린의 is 검사는 자바의 instanceof와 같다. is는 어떤 값의 타입을 검사한다. in 연산자의 결과를 부정해주는 연산자가 !in 연산자인 것과 마찬가지로, !is의 결과는 is 연산자의 결과를 부정한 값이다. 이런 연산자를 사용하는 코드가 읽기 편해진다. 6장에서는 널이 될 수 있는 타입(nullable type)에 대해 설명하고, 왜 other == null || other !is Client라는 조건식을 other !is Client로 간단히 써도 되는지 알려준다.

코틀린에서는 override 변경자가 필수여서 실수로 override fun equals(other: Any?) 대신 override fun equals(other: Client)를 작성할 수는 없다. 그래서 equals를 오버라이드하고 나면 프로퍼티의 값이 모두 같은 두 고객 객체는 동등하리라 예상할 수 있다. 실제로 client1 == client2는 이제 true를 반환한다. 하지만 Client 클래스로 더 복잡한 작업을 수행해보면 제대로 작동하지 않는 경우가 있다. 이와 관련해 흔히 면접에서 질문하는 내용이 "Client"가 제대로 작동하지 않는 경우를 말하고 문제가 무엇인지 설명하시오"다. hashCode 정의를 빠뜨려서 그렇다고 답하는 개발자가 많을 것이다. 이 경우에는 실제 hashCode가 없다는 점이 원인이다. 이제 왜 hashCode가 중요한지 알아보자.

해시 컨테이너: hashCode()

자바에서는 equals를 오버라이드할 때 반드시 hashCode도 함께 오버라이드해야 한다. 이번 절은 그 이유를 설명한다.
원소가 '로키'라는 고객 하나뿐인 집합을 만들자. 그 후 새로 원래의 '로키'와 똑같은 프로퍼티를 포함하는 새로운 Client 인스턴스를 만들어서 그 인스턴스가 집합안에 들어있는지 검사해보자. 프로퍼티가 모두 일치하므로 새 인스턴스와 집합에 있는 기존 인스턴스는 동등하다. 따라서 새 인스턴스가 집합에 속했는지 여부를 검사하면 true가 반환되리라 예상할 수 있다. 하지만 실제로는 false가 나온다.

>>> val processed = hashSetOf("로키", 4122))
>>> println(processed.contains(Client("로키", 4122))
false

이는 Client 클래스가 hashCode 메서드를 정의하지 않았기 때문이다. JVM 언어에서는 hashCode가 지켜야하는 "equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다"라는 제약이 있는데, Client는 이를 어기고 있다. processed 집합은 HashSet이다. HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 해시 코드를 비교하고 해시 코드가 같은 경우에만 실제 값을 비교한다. 방금 본 예제의 두 Client 인스턴스는 해시 코드가 다르기 때문에 두 번째 인스턴스가 집합 안에 들어있지 않다고 판단한다.해시 코드가 다를 때 equals가 반환하는 값은 판단 결과에 영향을 끼치지 못한다. 즉, 원소 객체들이 해시 코드에 대한 규칙을 지키지 않는 경우 HashSet은 제대로 작동할 수 없다.

이 문제를 고치려면 Client가 hashCode를 구현해야 한다.

리스트 4.20 Client에 hashCode 구현하기

class Client(val name: String, val postalCode: Int) {
    ...
    override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}

이제 이 클래스는 예상대로 작동한다. 하지만 지금까지 얼마나 많은 코드를 작성해야 했는지 생각해보자. 다행히 코틀린 컴파일러는 이 모든 메서드를 자동으로 생성해줄 수 있다. 어떻게 하면 코틀린이 이런 메서드를 생성하게 만들 수 있는지 살펴보자.

데이터 클래스: 모든 클래스가 정의해야 하는 메서드 자동 생성

어떤 클래스가 데이터를 저장하는 역할만을 수행한다면 toStirng, equals, hashCode를 반드시 오버라이드해야 한다. 다행히 이런 메서드를 정의하기는 그리 어렵지 않으며, IntelliJ와 같은 IDE는 자동으로 그런 메서드를 정의해주고, 작성된 메서드의 정확성과 일관성을 검사해준다.

코틀린은 더 편리하다. 이제는 이런 메서드를 IDE를 통해 생성할 필요도 없다. data라는 변경자는 클래스 앞에 붙이면 필요한 메서드를 컴파일러가 자동으로 만들어준다. data 변경자가 붙은 클래스를 데이터 클래스라고 부른다.

리스트 4.21 Client를 data 클래스로 선언하기

data class Client(val name: String, val postalCode: Int)

아주 쉽다! 이제 Client 클래스는 자바에서 요구하는 모든 메서드를 포함한다.

  • 인스턴스 간 비교를 위한 equals
  • HashMap과 같은 해시 기반 컨테이너에서 키로 사용할 수 있는 hashCode
  • 클래스의 각 필드를 선언 순서대로 표시하는 문자열 표현을 만들어주는 toString

equals와 hashCode는 주 생성자에 나열된 모든 프로퍼티를 고려해 만들어진다. 생성된 equals 메서드는 모든 프로퍼티 값의 동등성을 확인한다. hashCode 메서드는 모든 프로퍼티의 해시 값을 바탕으로 계산한 해시 값을 반환한다. 이때 주 생성자 밖에 정의된 프로퍼티는 equals나 hashCode를 계산할 때 고려의 대상이 아니라는 사실에 유의하라.

코틀린 컴파일러는 data 클래스에게 방금 말한 세 메서드뿐 아니라 몇 가지 유용한 메서드를 더 생성해준다.

데이터 클래스와 불변성: copy() 메서드

데이터 클래스의 프로퍼티가 꼭 val일 필요는 없다. 원한다면 var프로퍼티를 써도 된다. 하지만 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변(immutable) 클래스로 만들라고 권장한다. HashMap 등의 컨테이너에 데이터 클래스 객체를 담는 경우엔 불변성이 필수적이다. 데이터 클래스 객체를 키로 하는 값을 컨테이너에 담은 다음에 키로 쓰인 데이터 객체의 프로퍼티를 변경하면 컨테이너 상태가 잘못될 수 있다. 게다가 불변 객체를 사용하면 프로그램에 대해 훨씬 쉽게 추론할 수 있다. 특히 다중스레드 프로그램의 경우 이런 성질은 더 중요하다. 불변 객체를 주로 사용하는 프로그램에서는 스레드가 사용 중인 데이터를 다른 스레드가 변경할 수 없으므로 스레드를 동기화해야 할 필요가 줄어든다.

데이터 클래스 인스턴스를 불변 객체로 더 쉽게 활용할 수 있게 코틀린 컴파일러는 한 가지 편의 메서드를 제공한다. 그 메서드는 객체를 복사(copy)하면서 일부 프로퍼티를 바꿀 수 있게 해주는 copy 메서드다. 객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 편이 더 낫다. 복사본은 원본과 다른 생명주기를 가지며, 복사를 하면서 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램에서 원본을 참조하는 다른 부분에 전혀 영향을 끼치지 않는다. Clientcopy를 구현한다면 다음과 같을 것이다.

fun copy(name: String = this.name, postalCode: Int = this.postalCode) = Client(name, postalCode)

지금까지 data 변경자를 통해 값 객체를 더 편리하게 사용하는 방법을 살펴봤다. 이제는 IDE가 생성해주는 코드를 사용하지 않고도 위임을 쉽게 사용할 수 있게 해주는 코틀린 기능인 클래스 위임(class delegation)에 대해 살펴보자.

클래스 위임: by 키워드 사용

대규모 객체지향 시스템을 설계할 때 시스템을 취약하게 만드는 문제는 보통 구현 상속 implementation inheritance에 의해 발생한다. 하위 클래스가 상위 클래스의 메서드 중 일부를 오버라이드하면 하위 클래스는 상위 클래스의 세부 구현 사항에 의존하게 된다. 시스템이 변함에 따라 상위 클래스의 구현이 바뀌거나 상위 클래스에 새로운 메서드가 추가된다. 그 과정에서 하위 클래스가 상위 클래스에 대해 갖고 있던 가정이 깨져서 코드가 정상적으로 작동하지 못하는 경우가 생길 수 있다.

코틀린을 설계하면서 우리는 이런 문제를 인식하고 기본적으로 클래스를 final로 취급하기로 결정했다. 모든 클래스를 기본적으로 final로 취급하면 상속을 염두에 두고 open 변경자로 열어둔 클래스만 확장할 수 있다. 열린 상위 클래스의 소스코드를 변경할 때는 open 변경자를 보고 해당 클래스를 다른 클래스가 상속하리라 예상할 수 있으므로, 변경 시 하위 클래스를 깨지 않기 위해 좀 더 조심할 수 있다.

하지만 종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다. 이럴 때 사용하는 일반적인 방법이 데코레이터 패턴(Decorator)이다. 이 패턴의 핵심은 상속을 허용하지 않는 클래스(기존 클래스) 대신 사용할 수 있는 새로운 클래스(데코레이터)를 만들되 기존 클래스와 같은 인터페이스를 데코레이터가 제공하게 만들고, 기존 클래스를 데코레이터 내부에 필드로 유지하는 것이다. 이때 새로 정의해야 하는 기능은 데코레이터의 메서드에 새로 정의하고 기존 기능이 그대로 필요한 부분은 데코레이터의 메서드가 기존 클래스의 메서드에게 요청을 전달(forwarding)한다.

이런 접근 방법의 단점은 준비 코드가 상당히 많이 필요하다는 점이다(필요한 준비 코드가 너무 많기 때문에 IntelliJ 아이디어 등의 IDE는 데코레이터의 준비 코드를 자동으로 생성해주는 기능을 제공한다). 예를 들어 Collection 같이 비교적 단순한 인터페이스를 구현하면서 아무 동작도 변경하지 않는 데코레이터를 만들 때조차도 다음과 같이 복잡한 코드를 작성해야 한다.

class DelegatingCollection<T> : Collection<T> {
    private val innerList = arrayListOf<T>()

    override val size: Int get() = innerList.size
    override fun isEmpty(): Boolean = innerList.isEmpty()
    override fun contains(elements: T): Boolean = innerList.contains(element)
    override fun iterator(): Iterator<T> = innerList.iterator()
    override fun containsAll(elements: Collection<T>): Boolean = innerList.containsAll(elements)
}

이런 위임을 언어가 제공하는 일급 시민 기능으로 지원한다는 점이 코틀린의 장점이다. 인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다. 다음은 앞의 예제를 위임을 사용해 재작성한 코드다.

class DelegatingCollection<T> (
    innerList: Collection<T> = ArrayList<T>()
) : Collection<T> by innerList { }

클래스 안에 있던 모든 메서드 정의가 없어졌다. 컴파일러가 그런 전달 메서드를 자동으로 생성하며 자동 생성한 코드의 구현은 DelegatingCollection에 있던 구현과 비슷하다. 그런 단순한 코드 중 관심을 가질 만한 부분은 거의 없기 때문에 컴파일러가 자동으로 해줄 수 있는 작업을 굳이 직접 해야 할 이유가 없다.

메서드 중 일부의 동작을 변경하고 싶은 경우 메서드를 오버라이드하면 컴파일러가 생성한 메서드 대신 오버라이드한 메서드가 쓰인다. 기존 클래스의 메서드에 위임하는 기본 구현으로 충분한 메서드는 따로 오버라이드할 필요가 없다.

이 기법을 이용해서 원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현해보자. 예를 들어 중복을 제거하는 프로세스를 설계하는 중이라면 원소 추가 횟수를 기록하는 컬렉션을 통해 최종 컬렉션 크기와 원소 추가 시도 횟수 사이의 비율을 살펴봄으로써 중복 제거 프로세스의 효율성을 판단할 수 있다.

리스트 4.22 클래스 위임 사용하기

class CountingSet<T> {
    val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSEt { // MutableCollection의 구현을 innerSet에게 위임한다. 

    var objectsAdded = 0

    override fun add(element: T): Boolean {
        objectsAdded++
        return innerSet.add(element)
    }

    override fun addAll(c: Collection<T>): Boolean {
        objectsAdded++
        return innerSet.addAll(c)
    }
}

예제를 보면 알 수 있지만 addaddAll을 오버라이드해서 카운터를 증가시키고, MutableCollection 인터페이스의 나머지 메서드는 내부 컨테이너(innerSet)에게 위임한다.

이때 CountingSetMutableCollection의 구현 방식에 대한 의존관계가 생기지 않는다는 점이 중요하다. 예를 들어 내부 컨테이너가 addAll을 처리할 때 루프를 돌면서 add를 호출할 수도 있지만, 최적화를 위해 다른 방식을 택할 수도 있다. 클라이언트 코드가 CountingSet의 코드를 호출할 때 발생하는 일은 CountingSet 안에서 마음대로 제어할 수 있지만, CountingSet 코드는 위임 대상 내부 클래스 MutableCollection이 문서화된 API를 변경하지 않는 한 CountingSet 코드가 계속 잘 작동할 것임을 확신할 수 있다.

방금 코틀린 컴파일러가 클래스에 유용한 메서드를 생성해주는 방식에 대한 설명을 마쳤다. 이제 코틀린 클래스에 대해 남은 마지막 중요한 요소인 object 키워드와 언제 그 키워드를 활용할 수 있는지 살펴보자.

object 키워드: 클래스 선언과 인스턴스 생성

코틀린에서는 object 키워드를 다양한 상황에서 사용하지만 모든 경우 클래스를 정의하면서 동시에 인스턴스(객체)를 생성한다는 공통점이 있다. object 키워드를 사용하는 여러 상황을 살펴보자.

  • 객체 선언(object declaration)은 싱글턴을 정의하는 방법 중 하나다.
  • 동반 객체(companion object)는 인스턴스 메서드는 아니지만 어떤 클래스와 관련 있는 메서드와 팩토리 메서드를 담을 때 쓰인다. 동반 객체 메서드에 접근할 때는 동반 객체가 포함된 클래스의 이름을 사용할 수 있다.
  • 객체 식은 자바의 무명 내부 클래스(anonymous inner class) 대신 쓰인다.

지금부터 이런 코틀린의 특성에 대해 자세히 설명한다.

객체 선언: 싱글턴을 쉽게 만들기

객체지향 시스템을 설계하다 보면 인스턴스가 하나만 필요한 클래스가 유용한 경우가 많다. 자바에서는 보통 클래스의 생성자를 private으로 제한하고 정적인 필드에 그 클래스의 유일한 객체를 저장하는 싱글턴 패턴(singleton pattern)을 통해 이를 구현한다.

코틀린은 객체 선언 기능을 통해 싱글턴을 언어에서 기본 지원한다. 객체 선언은 클래스 선언과 그 클래스에 속한 단일 인스턴스의 선언을 합친 선언이다.

예를 들어 객체 선언을 사용해 회사 급여 대장을 만들 수 있다. 한 회사에 여러 급여 대장이 필요하지는 않을 테니 싱글턴을 쓰는 게 정당해 보인다.

object Payroll {
    val allEmployees = arrayListOf<Person>()

    fun calculateSalary() {
        for (person in allEmployees) {
            ...
        }
    }
}

객체 선언은 object 키워드로 시작한다. 객체 선언은 클래스를 정의하고 그 클래스의 인스턴스를 만들어서 변수에 저장하는 모든 작업을 단 한 문장으로 처리한다. 클래스와 마찬가지로 객체 선언 안에도 프로퍼티, 메서드, 초기화 블록 등이 들어갈 수 있다. 하지만 생성자는(주 생성자와 부 생성자 모두) 객체 선언에 쓸 수 없다. 일반 클래스 인스턴스와 달리 싱글턴 객체는 객체 선언문이 있는 위치에서 생성자 호출 없이 즉시 만들어진다. 따라서 객체 선언에는 생성자 정의가 필요 없다.

변수와 마찬가지로 객체 선언에 상요한 이름 뒤에 마침표(.)를 붙이면 객체에 속한 메서드나 프로퍼티에 접근할 수 있다.

Payroll.allEmployees.add(Person(...))
Payroll.calculateSalary()

객체 선언도 클래스나 인터페이스를 상속할 수 있다. 프레임워크를 사용하기 위해 특정 인터페이스를 구현해야 하는데, 그 구현 내부에 다른 상태가 필요하지 않은 경우에 이런 기능이 유용하다. 예를 들어 java.util.Comparator 인터페이스를 살펴보자.

Comparator 구현은 두 객체를 인자로 받아 그중 어느 객체가 더 큰지 알려주는 정수를 반환한다. Comparator 구현은 두 객체를 인자로 받아 그중 어느 객체가 더 큰지 알려주는 정수를 반환한다. Comparator 안에는 데이터를 저장할 필요가 없다. 따라서 어떤 클래스에 속한 객체를 비교할 때 사용하는 Comparator는 보통 클래스마다 단 하나씩만 있으면된다. 따라서 Comparator 인스턴스를 만드는 방법으로는 객체 선언이 가장 좋은 방법이다.

구체적인 예제로 두 파일 경로를 대소문자 관계없이 비교해주는 Comparator를 구현해보자.

리스트 4.23 객체 선언을 사용해 Comparator 구현하기

object CaseInsensitiveFileComparator: Comparator<File> {
    override fun compare(file1: File, file2: File): Int {
        return file1.path.compareTo(file2.path,
            ignoreCase = true)
    }
}

일반 객체(클래스 인스턴스)를 사용할 수 있는 곳에서는 항상 싱글턴 객체를 사용할 수 있다. 예를 들어 이 객체를 Comparator를 인자로 받는 함수에게 인자로 넘길 수 있다.

이 예제는 전달받은 Comparator에 따라 리스트를 정렬하는 sortedWith 함수를 사용한다.

싱글턴과 의존관계 주입
싱글턴 패턴과 마찬가지 이유로 대규모 소프트웨어 시스템에서는 객체 선언이 항상 적합하지는 않다. 의존관계가 별로 많지 않은 소규모 소프트웨어에서는 싱글턴이나 객체 선언이 유용하지만, 시스템을 구현하는 다양한 구성 요소와 상호작용하는 대규모 컴포넌트에는 싱글턴이 적합하지 않다. 이유는 객체 생성을 제어할 방법이 없고 생성자 파라미터를 지정할 수 없어서다.
생성을 제어할 수 없고 생성자 파라미터를 지정할 수 없으므로 단위 테스트를 하거나 소프트웨어 시스템의 설정이 달라질 때 객체를 대체하거나 객체의 의존관계를 바꿀 수 없다. 따라서 그런 기능이 필요하다면 자바와 마찬가지로 의존관계 주입 프레임워크(예: 구글 주스(Guice))와 코틀린 클래스를 함께 사용해야 한다.

클래스 안에서 객체를 선언할 수도 있다. 그런 객체도 인스턴스는 단 하나뿐이다(바깥 클래스의 인스턴스마다 중첩 객체 선언에 해당하는 인스턴스가 하나씩 따로 생기는 것이 아니다). 예를들어 어떤 클래스의 인스턴스를 비교하는 Comparator를 클래스 내부에 정의하는 게 더 바람직하다.

리스트 4.24 중첩 객체를 사용해 Comparator 구현하기

data class Person(val name: String) {
    object NameComparator: Comparator<Person> {
        override fun compare(p1: Person, p2: Person): Int = p1.name.compareTo(p2.name)
    }
}

코틀린 객체를 자바에서 사용하기
코틀린 객체 선언은 유일한 인스턴스에 대한 정적인 필드가 있는 자바 클래스로 컴파일된다. 이때 인스턴스 필드의 이름은 항상 INSTANCE다. 싱글턴 패턴을 자바에서 구현해도 비슷한 필드가 필요하다. 자바 코드에서 싱글턴 객체를 사용하려면 정적인 INSTANCE 필드를 통하면 된다.

이제 클래스 안에 중첩된 객체 중에서도 독특한 객체를 살펴보자. 그 객체는 바로 동반 객체(companion object)다.

동반 객체: 팩토리 메서드와 정적 멤버가 들어갈 장소

코틀린 클래스 안에는 정적인 멤버가 없다. 코틀린 언어는 자바 static 키워드를 지원하지 않는다. 그 대신 코틀린에서는 패키지 수준의 최상위 함수(자바의 정적 메서드 역할을 거의 대신 할 수 있다)와 객체 선언(자바의 정적 메서드 역할 중 코틀린 최상위 함수가 대신할 수 없는 역할이나 정적 필드는 대신할 수 있다)을 활용한다. 대부분의 경우 최상위 함수는 private으로 표시된 클래스 비공개 멤버에 접근할 수 없다. 그래서 클래스의 인스턴스와 관계없이 호출해야 하지만, 클래스 내부 정보에 접근해야 하는 함수가 필요할 때는 클래스에 중첩된 객체 선언의 멤버 함수로 정의해야 한다. 그런 함수의 대표적인 예로 팩토리 메서드를 들 수 있다.

image

클래스 안에 정의된 객체 중 하나에 companion이라는 특별한 표시를 붙이면 그 클래스의 동반 객체로 만들 수 있다. 동반 객체의 프로퍼티나 메서드에 접근하려면 그 동반객체가 정의된 클래스 이름을 사용한다. 이때 객체의 이름을 따로 지정할 필요가 없다. 그 결과 동반 객체의 멤버를 사용하는 구문은 자바의 정적 메서드 호출이나 정적 필드 사용 구문과 같아진다.

class A {
    companion object {
        fun bar() {
            println("Companion object called")
        }
    }
}

private 생성자를 호출하기 좋은 위치를 알려준다고 했던 사실을 기억하는가? 바로 동반 객체가 private 생성자를 호출하기 좋은 위치다. 동반 객체는 자신을 둘러싼 클래스의 모든 private 멤버에 접근할 수 있다. 따라서 동반 객체는 바깥쪽 클래스의 private 생성자도 호출할 수 있다. 따라서 동반 객체는 팩토리 패턴을 구현하기 가장 적합한 위치다.

이제 예제로 부 생성자 2개 있는 클래스를 살펴보고, 다시 그 클래스를 동반 객체 안에서 팩토리 클래스를 정의하는 방식으로 변경해보자. 이 예제는 FacebookUserSubscribingUser 예제를 바탕으로 한다. 두 클래스 모두 User 클래스를 상속했다. 하지만 이제는 두 클래스를 한 클래스로 합치면서 사용자 객체를 생성하는 여러 방법을 제공하기로 결정했다.

리스트 4.25 부 생성자가 여럿 있는 클래스 정의하기

class User {
    val nickname: String

    constructor(email: String) { // 부 생성자
        nickname = email.substringBefore('@')
    }

    constructor(facebookAccountId: Int) { // 부 생성자 
        nickname = getFacebookName(facebookAccountId)
    }
}

이런 로직을 표현하는 더 유용한 방법으로 클래스의 인스턴스를 생성하는 팩토리 메서드가 있다. 아래의 리스트 4.26에 있는 구현에서는 생성자를 통해 User 인스턴스를 만들 수 없고 팩토리 메서드를 통해야만 한다.

리스트 4.26 부 생성자를 팩토리 메서드로 대신하기

class User private constructor(val nickname: String) { // 주 생성자를 비공개로 만든다. 
    companion object {
        fun newSubscribingUser(email: String) = User(email.substringBefore('@'))
        fun newFacebookUser(accountId: Int) = User(getFacebookName(accountId)
    }
}

클래스 이름을 사용해 그 클래스에 속한 동반 객체의 메서드를 호출할 수 있다.

팩토리 메서드는 매우 유용하다. 이 예제처럼 목적에 따라 팩토리 메서드 이름을 정할 수 있다. 게다가 팩토리 메서드는 그 팩토리 메서드가 선언된 클래스의 하위 클래스 객체를 반환할 수도 있다. 예를 들어 SubscribingUserFacebookUser 클래스가 따로 존재한다면 그때그때 필요에 따라 적당한 클래스의 객체를 반환할 수 있다. 또 팩토리 메서드는 생성할 필요가 없는 객체를 생성하지 않을 수도 있다. 예를 들어 이메일 주소별로 유일한 User 인스턴스를 만드는 경우 팩토리 메서드가 이미 존재하는 인스턴스에 해당하는 이메일 주소를 전달받으면 새 인스턴스를 만들지 않고 캐시에 있는 기존 인스턴스를 반환할 수 있다. 하지만 클래스를 확장해야만 하는 경우에는 동반 객체 멤버를 하위 클래스에서 오버라이드할 수 없으므로 여러 생성자를 사용하는 편이 더 나은 해법이다.

동반 객체를 일반 객체처럼 사용

동반 객체는 클래스 안에 정의된 일반 객체다. 따라서 동반 객체에 이름을 붙이거나, 동반 객체가 인터페이스를 상속하거나, 동반 객체 안에 확장 함수와 프로퍼티를 정의할 수 있다.

예를 들어 회사의 급여 명부를 제공하는 웹 서비스를 만든다고 가정하자. 서비스에서 사용하기위해 객체를 JSON으로 직렬화하거나 역직렬화해야 한다. 직렬화 로직을 동반 객체 안에 넣을 수 있다.

리스트 4.27 동반 객체에 이름 붙이기

class Person(val name: String) {
    companion object Loader { // 동반 객체에 이름을 붙인다. 
        fun fromJSON(jsonText: String) : Person = ...
    }
}

>>> person = Person.Loader.fromJSON("{name: 'Dmitry'}") // 두 방법 모두 제대로 fromJSON을 호출할 수 있다. 
>>> person.name
Dmitry
>>> person2 = Person.fromJSON("{name: 'Brent'}")
>>> person2.name
Brent

대부분의 경우 클래스 이름을 통해 동반 객체에 속한 멤버를 참조할 수 있으므로 객체의 이름을 짓느라 고심할 필요가 없다. 하지만 필요하다면 리스트 4.27처럼 companion object Loader 같은 방식으로 동반 객체에도 이름을 붙일 수 있다. 특별히 이름을 지정하지 않으면 동반 객체 이름은 자동으로 Companion이 된다. 이 이름을 사용하는 예제를 나중에 동반 객체 확장을 다룰 때 볼 수 있다.

동반 객체에서 인터페이스 구현

다른 객체 선언과 마찬가지로 동반 객체도 인터페이스를 구현할 수 있다. 잠시 후 보겠지만 인터페이스를 구현하는 동반 객체를 참조할 때 객체를 둘러싼 클래스의 이름을 바로 사용할 수 있다.

시스템에 Person을 포함한 다양한 타입의 객체가 있다고 가정하자. 이 시스템에서는 모든 객체를 역직렬화를 통해 만들어야 하기 때문에 모든 타입의 객체를 생성하는 일반적인 방법이 필요하다. 이를 위해 JSON을 역직렬화하는 JSONFactory 인터페이스가 존재한다. Person은 다음과 같이 JSONFactory 구현을 제공할 수 있다.

리스트 4.28 동반 객체에서 인터페이스 구현하기

interface JSONFactory<T> {
    fun fromJSON(jsonText: String): T
}

class Person(val name: String) {
    companion object: JSONFactory<Person> {
        override fun fromJSON(jsonText: String): Person = ... // 동반 객체가 인터페이스를 구현한다.
    }
}

이제 JSON으로부터 각 원소를 다시 만들어내는 추상 팩토리가 있다면 Person 객체를 그 팩토리에게 넘길 수 있다.

fun loadFromJSON<T>(factory: JSONFactory<T>): T {
    ...
}
loadFromJSON(Person) // 동반 객체의 인스턴스를 함수에 넘긴다. 

여기서 동반 객체가 구현한 JSONFactory의 인스턴스를 넘길 때 Person 클래스의 이름을 사용했다는 점에 유의하라.

코틀린 동반 객체와 정적 멤버
클래스의 동반 객체는 일반 객체와 비슷한 방식으로, 클래스에 정의된 인스턴스를 가리키는 정적 필드로 컴파일된다. 동반 객체에 이름을 붙이지 않았다면 자바 쪽에서 Companion이라는 이름으로 그 참조에 접근할 수 있다.
/* 자바 */
Person.Companion.fromJSON("..."):
동반 객체에게 이름을 붙였다면 Companion 대신 그 이름이 쓰인다.
때로 자바에서 사용하기 위해 코틀린 클래스의 멤버를 정적인 멤버로 만들어야 할 필요가 있다.
그런 경우 @JvmStatic 애너테이션을 코틀린 멤버에 붙이면 된다. 정적 필드가 필요하다면 @JvmField 애너테이션을 최상위 프로퍼티나 객체에서 선언된 프로퍼티 앞에 붙인다. 이 기능은 자바와의 상호운용성을 위해 존재하며, 정확히 말하자면 코틀린 핵심 언어가 제공하는 기능이 아니다. 애너테이션에 대해서는 Chap 10에서 자세히 다룬다.
코틀린에서도 자바의 정적 필드나 메서드를 사용할 수 있다. 그런 경우 자바와 똑같은 구문을 사용한다.

동반 객체 확장

확장함수를 사용하면 코드 기반의 다른 곳에서 정의된 클래스의 인스턴스에 대해 새로운 메서드를 정의할 수 있음을 보였다. 그렇다면 자바의 정적 메서드나 코틀린의 동반 객체 메서드처럼 기존 클래스에 대해 호출할 수 있는 새로운 함수를 정의하고 싶다면 어떻게 해야할까? 클래스에 동반 객체가 있으면 그 객체 안에 함수를 정의함으로써 클래스에 대해 호출할 수 있는 확장 함수를 만들 수 있다. 더 구체적으로 설명해보자. C라는 클래스 안에 동반 객체가 있고 그 동반 객체(C.Companion) 안에 func를 정의하면 외부에서는 func()를 C.func()로 호출할 수 있다.

예를 들어 앞에서 살펴본 Person의 관심사를 좀 더 명확히 분리하고 싶다고 하자. Person 클래스는 핵심 비즈니스 로직 모듈의 일부다. 하지만 그 비즈니스 모듈이 특정 데이터 타입에 의존하기를 원치는 않는다. 따라서 역직렬화 함수를 비즈니스 모듈이 아니라 클라이언트/서버 통신을 담당하는 모듈 안에 포함시키고 싶다. 확장 함수를 사용하면 이렇게 구조를 잡을 수 있다. 다음 예제에서는 이름 없이 정의된 동반 객체를 가리키기 위해서 동반 객체의 기본 이름인 Companion을 사용했다.

리스트 4.29 동반 객체에 대한 확장 함수의 정의하기

// 비즈니스 로직 모듈 
class Person(val firstName: String, val lastName: String) {
    companion object { // 비어있는 동반 객체를 선언한다. 
    }
}

// 클라이언트/서버 통신 모듈 
fun Person.Companion.fromJSON(json: String): Person { // 확장 함수를 선언한다. 
    ...
}

val p = Person.fromJSON(json)

마치 동반 객체 안에서 fromJSON 함수를 정의한 것처럼 fromJSON을 호출할 수 있다. 하지만 실제로 fromJSON은 클래스 밖에서 정의한 확장 함수다. 다른 보통 확장 함수처럼 fromJSON도 클래스 멤버 함수처럼 보이지만, 실제로는 멤버 함수가 아니다. 여기서 동반 객체에 대한 확장 함수를 작성할 수 있으려면 원래 클래스에 동반 객체를 꼭 선언해야 한다는 점에 주의하라. 설령 빈 객체라도 동반 객체가 꼭 있어야 한다.
지금까지 동반 객체가 얼마나 유용한지 살펴봤다. 이제는 코틀린에서 object 키워드를 사용하는 또 다른 기능인 객체 식(object expression)에 대해 살펴보자.

객체 식: 무명 내부 클래스를 다른 방식으로 작성

object 키워드를 싱글턴과 같은 객체를 정의하고 그 객체에 이름을 붙일 때만 사용하지는 않는다. 무명 객체(anonymous object)를 정의할 때도 object 키워드를 쓴다. 무명 객체는 자바의 무명 내부 클래스를 대신한다. 예를 들어 자바에서 흔히 무명 내부 클래스로 구현하는 이벤트 리스터(event Listener)를 코틀린에서 구현해보자.

리스트 4.30 무명 객체로 이벤트 리스터 구현하기

window.addMouseListener(
    object : MouseAdapter() {
        override fun mouseClicked(e: MouseEvent) {
            // ...
        }

        override fun mouseEntered(e: MouseEvent) {
            // ...
        }
    }
}

사용한 구문은 객체 선언에서와 같다. 한 가지 유일한 차이는 객체 이름이 빠졌다는 점이다. 객체 식은 클래스를 정의하고 그 클래스에 속한 인스턴스를 생성하지만, 그 클래스나 인스턴스에 이름을 붙이지는 않는다. 이런 경우 보통 함수를 호출하면서 인자로 무명 객체를 넘기기 때문에 클래스와 인스턴스 모두 이름이 필요하지 않다. 하지만 객체에 이름을 붙여야 한다면 변수에 무명 객체를 대입하면 된다.

val listener = object.MouseAdapter() {
    override fun mouseClicked(e: MouseEvent) { ... }
    override fun mouseEntered(e: MouseEvent) { ... }
}

한 인터페이스만 구현하거나 한 클래스만 확장할 수 있는 자바의 무명 내부 클래스와 달리 코틀린 무명 클래스는 여러 인터페이스를 구현하거나 클래스를 확장하면서 인터페이스를 구현할 수 있다.

객체 선언과 달리 무명 객체는 싱글턴이 아니다. 객체 식이 쓰일 때마다 새로운 인스턴스가 생성된다.

자바의 무명 클래스와 같이 객체 식 안의 코드는 그 식이 포함된 함수의 변수에 접근할 수 있다. 하지만 자바와 달리 final이 아닌 변수도 객체 식 안에서 사용할 수 있다. 따라서 객체 식 안에서 그 변수의 값을 변경할 수 있다. 예를 들어 어떤 윈도우가 호출된 횟수를 리스너에서 누적하게 만들 수 있다.

리스트 4.31 무명 객체 안에서 로컬 변수 사용하기

fun countClickes(window: Window) {
    var clickCount = 0 // 로컬 변수를 정의한다. 

    window.addMouseListener(object: MouseAdapter() {
        override fun mouseClicked(e: MouseEvent) {
            clickCount++ // 로컬 변수의 값을 변경한다. 
        }
    })
    // ...
}

객체 식은 무명 객체 안에서 여러 메서드를 오버라이드해야 하는 경우에 훨씬 더 유용하다. 메서드가 하나뿐인 인터페이스(Runnable등의 인터페이스가 그렇다)를 구현해야 한다면 코틀린의 SAM 변환 지원을 활용하는 편이 낫다. SAM 변환을 사용하려면 무명 객체 대신 함수 리터럴(람다(lambda))을 사용해야 한다. 람다와 SAM 변환에 대해서는 다음장에서 자세히 다룬다.

다음 장에서는 람다와 함수형 프로그래밍에 대해 설명한다.

728x90

'Kotlin > Kotlin In Action' 카테고리의 다른 글

3장 - 함수 정의와 호출  (0) 2021.07.08
2장 - 코틀린 기초  (3) 2021.06.28
1장 - 코틀린이란 무엇이며, 왜 필요한가?  (0) 2021.06.10
Kotlin/Kotlin In Action

3장 - 함수 정의와 호출

728x90

3장에서는 모든 프로그램에서 핵심이라 할 수 있는 함수 정의와 호출 기능을 코틀린이 어떻게 개선했는지 살펴본다. 추가로 확장 함수와 프로퍼티를 사용해 자바 라이브러리를 활용하면 코틀린과 자바를 함께 쓰는 프로젝트에서 코틀린의 장점을 최대한 살릴 수 있다.

이번 장에서 설명하는 내용이 더 유용하기 (그리고 덜 추상적이기 위해)위해서 코틀린 컬렉션, 문자열, 정규식(regulear expression)만으로 문제 영역을 한정한다. 우선 코틀린에서 컬렉션을 만드는 방법을 살펴보자.

코틀린에서 컬렉션 만들기

일단 컬렉션을 만드는 방법을 배워보자.
앞에서 setOf 함수로 집합을 만드는 방법을 살펴본 적이 있다.

val set = hashSetOf(1, 7, 53)

비슷한 방법으로 리스트와 맵도 만들 수 있다.

val list = arrayListOf(1, 7, 53)
val map = hashMapOf(1 to "one", 7 to "seven", 53 to "fifty-three")

여기서 to는 언어가 제공하는 특별한 키워드가 아니라 일반 함수 라는 점에 유의하라. 이에 대해서는 나중에 다룬다.
여기서 만든 객체가 어떤 클래스에 속하는지 추측할 수 있겠는가? 다음 예제를 실행하면 직접 그 결과를 볼 수 있다.

>>> println(set.javaClass) // javaClass는 자바 getClass()에 해당하는 코틀린 코드다. 
class java.util.HashSet
>>> println(list.javaClass)
class java.util.ArrayList
>>> println(map.javaClass)
class java.util.HashMap

이는 코틀린이 자신만의 컬렉션 기능을 제공하지 않는다는 뜻이다. 자바 개발자가 기존 자바 컬렉션을 활용할 수 있다는 뜻이므로 이는 자바 개발자들에게 좋은 소식이다.

코틀린이 자체 컬렉션을 제공하지 않는 이유는 뭘까? 표준 자바 컬렉션을 활용하면 자바 코드와 상호작용하기가 훨씬 더 쉽다. 자바에서 코틀린 함수를 호출하거나 코틀린에서 자바 함수를 호출할 때 자바와 코틀린 컬렉션을 서로 변환할 필요가 없다.

코틀린 컬렉션은 자바 컬렉션과 똑같은 클래스다. 하지만 코틀린에서는 자바보다 더 많은 기능을 쓸 수 있다. 예를 들어 리스트의 마지막 원소를 가져오거나 수로 이뤄진 컬렉션에서 최댓값을 찾을 수 있다.

>>> val strings = listOf("first", "second", "fourteeth");

>>> println(strings.last())
fourteeth

>>> val numbers = setOf(1, 14, 2)

>>> println(numbers.max())
14

이번 장에서는 이런 기능이 어떻게 동작하는지 보여주고, 자바 클래스에 없는 메서드를 코틀린이 어디에 정의하는지 살펴본다.
last나 max가 함수가 자바 컬렉션에 대해 어떻게 마술처럼 작동하는지 살펴보기 전에 함수 선언에 대한 몇 가지 새로운 개념을 살펴보자.

함수를 호출하기 쉽게 만들기

여러 원소로 이뤄진 컬렉션을 만드는 방법을 배웠으므로 간단하게 모든 원소를 찍어보자. 너무 단순하지 않나 걱정할 필요는 없다. 원소를 찍는 과정에서 여러 중요한 개념에 마주치게 된다.

자바 컬렉션에는 디폴트 toString 구현이 들어있다. 하지만 그 디폴트 toString의 출력 형식은 고정돼 있고 우리에게 필요한 형식이 아닐 수도 있다.

>>> val list = listOf(1, 2, 3)
>>> println(list)  <- toString() 호출
[1, 2, 3]

디폴트 구현과 달리 (1; 2; 3) 처럼 원소 사이를 세미콜론으로 구분하고 괄호로 리스트를 둘러싸고 싶다면 어떻게 해야할까? 이를 위해서는 자바 프로젝트에 구아바(Guava)나 아파치 커먼즈(Apache Commons) 같은 서드파티 프로젝트를 추가하거나 직접 관련 로직을 구현해야 한다. 코틀린에는 이런 요구 사항을 처리할 수 있는 함수가 표준 라이브러리에 이미 들어있다.

이번 절에서는 이런 함수를 구현해볼 것이다. 처음에는 함수 선언을 간단하게 만들 수 있게 코틀린이 지원하는 여러 기능을 사용하지 않고 함수를 직접 구현한다. 그 후 좀 더 코틀린답게 같은 함수를 다시 구현한다.

다음 리스트의 joinToString 함수는 컬렉션의 원소를 StringBuilder의 뒤에 덧붙인다. 이때 원소 사이에 구분자(separator)를 추가하고, StringBuilder의 맨 앞과 맨 뒤에는 접두사(prefix)와 접미사(postfix)를 추가한다.

| joinToString() 함수의 초기 구현

fun <T> joinToString(
        collection: Collection<T>,
        separator: String,
        prefix: String,
        postfix: STring
) : String {
    val result = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator) // 첫 원소 앞에는 구분자를 붙이면 안 된다.
        result.append(element)
    }

    result.append(postfix)
    return result.toString()
}

이 함수는 제네릭이다. 즉, 이 함수는 어떤 타입의 값을 원소로 가진 컬렉션이든 처리할 수 있다. 제네릭 함수의 문법은 자바와 비슷하다.

이 함수가 의도대로 작동하는지 검증해보자.

>>> val list = listOf(1, 2, 3)
>>> println(joinToString(list, "; ", "(", ")"))
(1; 2; 3)

잘 작동한다. 이 함수를 그대로 써도 좋을 것이다. 하지만 선언 부분을 좀 더 고민해봐야 한다. 어떻게 하면 이 함수를 호출하는 문장을 더 간편하게 만드는 방법은 없을까? 매번 네 인자를 전달하지 않을 수는 없을까?

이름 붙인 인자

첫 번째 문제는 함수 호출 부분의 가독성이다.
예를 들어 다음과 같이 joinToString 호출을 살펴보자.

joinToString(collection, " ", " ", ".")

인자로 전달한 각 문자열이 어떤 역할을 하는지 구분할 수 있는가? (예르들어 원소는 공백으로 구분될까? 마침표로 구분될까?)
함수의 시그니처를 외우거나 IDE가 함수 시그니처를 표시해서 도움을 줄 수는 있겠지만, 함수 호출 코드 자체는 여전히 모호하다.

이런 문제는 특히 불리언 플래그(flag) 값을 전달해야 하는 경우 흔히 발생한다. 이를 해결하기 위해 일부 자바 코딩 스타일에서는 불리언 대신 enum 타입을 사용하라고 권장한다. 일부 코딩 스타일에서는 다음과 같이 파라미터 이름을 주석에 넣으라고 요구하기도 한다. (Lombok의 @Builder를 떠올리면 될 것 같다!)

joinToString(collection, /* separator */ " ", /* prefix */ " ", /* postfix */ ".");

코틀린에서는 다음과 같이 이름붙인 인자 기능을 제공한다.

joinToString(collection, separator = " ", prefix = " ", postfix = ".")

코틀린으로 작성한 함수를 호출할 때는 함수에 전달하는 인자 중 일부(또는 전부)의 이름을 명시할 수 있다. 호출 시 인자 중 어느 하나라도 이름을 명시하고 나면 혼동을 막기위해 그 뒤에 오는 모든 인자는 이름을 꼭 명시해야 한다.

이름 붙인 인자는 특히 다음 절에 살펴볼 디폴트 파라미터 값과 함께 사용할 때 쓸모가 많다.

디폴트 파라미터 값

자바에서는 일부 클래스에서 오버로딩(overloading)한 메서드가 너무 많아진다는 문제가 있다. java.lang.Thread에 있는 8가지 생성자를 살펴보라. 이런 식의 오버로딩 메서드들은 하위 호완성을 유지하거나 API 사용자에게 편의를 더하는 등의 여러 가지 이유로 만들어진다. 하지만 어느 경우든 중복이라는 결과는 같다. 파라미터 이름과 타입이 계속 반복되며, 우리가 친절한 개발자라면 모든 오버로딩 함수에 대해 대부분의 설명을 반복해 달아야 할 것이다. 그리고 인자 중 일부가 생략된 오버로드 함수를 호출할 때 어떤 함수가 불릴지 모호한 경우가 생긴다.

코틀린에서는 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있으므로 이런 오버로드 중 상당수를 피할 수 있다. 디폴트 값을 사용해 joinToString 함수를 개선해보자. 대부분의 경우 아무 접두사나 접미사 없이 콤마로 원소를 구분한다. 따라서 그런 값을 디폴트로 지정하자.

디폴트 파라미터 값을 사용해 joinToString() 정의하기

fun <T> joinToString(
    collection: Collection<T>,
    separator: String = ", ",
    prefix: String = ""
) : String

이제 함수를 호출할 때 모든 인자를 쓸 수도 있고, 일부를 생략할 수도 있다.

>>> joinToSTring(list, ", ", "", "")
1, 2, 3

>>> joinToString(list) // separator, prefix, postfix 생략
1, 2, 3

>>> joinToString(list, "; ") // separator를 "; "로 지정, prefix, postfix 생략 
1; 2; 3

일반 호출 문법을 사용하려면 함수를 선언할 때와 같은 순서로 인자를 지정해야 한다. 그런 경우 일부를 생략하면 뒷부분의 인자들이 생략된다. 이름 붙인 인자를 사용하는 경우에는 인자 목록의 중간에 있는 인자를 생략하고, 지정하고 싶은 인자만 이름을 붙여서 순서와 관계없이 지정할 수 있다.

>>> joinToString(list, postfix = ";", prefix = "# ")
# 1, 2, 3;

함수의 디폴트 파라미터 값은 함수를 호출하는 쪽이 아니라 함수 선언 쪽에서 지정된다는 사실을 기억하라. 따라서 어떤 클래스 안에 정의된 디폴트 값을 바꾸고 그 클래스가 포함된 파일을 재컴파일하면 그 함수를 호출하는 코드 중에 값을 지정하지 않은 모든 인자는 자동으로 바뀐 디폴트 값을 적용받는다.

디폴트 값과 자바
자바에서는 디폴트 파라미터 값이라는 개념이 없어서 코틀린 함수를 자바에서 호출하는 경우에는 그 코틀린 함수가 디폴트 파라미터 값을 제공하더라도 모든 인자를 명시해야 한다. 자바에서 코틀린 함수를 자주 호출해야 한다면 자바 쪽에서 좀 더 편하게 코틀린 함수를 호출하고 싶을 것이다. 그럴 때 @JvmOverloads 애너테이션을 함수에 추가할 수 있다. @JvmOverloads를 함수에 추가하면 코틀린 컴파일러가 자동으로 맨 마지막 파라미터로부터 파라미터를 하나씩 생략한 오버로딩한 자바 메서드를 추가해준다.

예를들어 joinToStringdp @JvmOverloads를 붙이면 다음과 같은 오버로딩한 함수가 만들어진다.

String joinToString(Collection<T> collection, String separator, String prefix, String postfix);

String joinToString(Collection<T> collection, String separator, String prefix);

String joinToString(Collection<T> collection, String separator);

String joinToString(Collection<T> collection);

각각의 오버로딩한 함수들은 시그니처에서 생략된 파라미터에 대해 코틀린 함수의 디폴트 파라미터 값을 사용한다.

지금까지는 함수를 사용할 때 그 함수를 선언하는 시점의 주위 환경을 신경쓰지 않았다. 지금까지 설명한 함수들은 어떤 클래스 안에 선언해야만 할 것이다. 그러나 실제로 코틀린에서는 함수를 클래스 안에 선언할 필요가 전혀 없다.

정적인 유틸리티 클래스 없애기: 최상위 함수와 프로퍼티

자바를 아는 사람은 객체지향 언어인 자바에서는 모든 코드를 클래스의 메서드로 작성해야 한다는 사실을 알고 있다. 보통 그런 구조는 잘 작동한다. 하지만 실전에서는 어느한 클래스에 포함시키기 어려운 코드가 많이 생긴다. 일부 연산에는 비슷하게 중요한 역할을 하는 클래스가 둘 이상 있을 수도 있다. 중요한 객체는 하나뿐이지만 그 연산을 객체의 인스턴스 API에 추가해서 API를 너무 크게 만들고 싶지는 않은 경우도 있다.

그 결과 다양한 정적 메서드를 모아두는 역할만 담당하며, 특별한 상태나 인스턴스 메서드가 없는 클래스가 생겨난다. JDK의 Collections 클래스가 전형적인 예다. 우리가 작성한 코드에서 비슷한 예를 보고 싶다면 Util이 이름에 들어있는 클래스를 찾으면 된다.

코틀린에서는 이런 무의미안 클래스가 필요 없다. 대신 함수를 직접 소스파일의 최상위 수준, 모든 다른 클래스의 밖에 위치시키면 된다. 그런 함수들은 여전히 그 파일의 맨 앞에 정의된 패키지의 멤버 함수이므로 다른 패키지에서 그 함수를 사용하고 싶을 때는 그 함수가 정의된 패키지를 임포트해야만 한다. 하지만 임포트 시 유틸리티 클래스 이름이 추가로 들어갈 필요는 없다.

joinToString 함수를 strings 패키지에 직접 넣어보자. join.kt라는 파일을 다음과 같이 작성하라.

JoinToString 함수를 최상위 함수로 선언하기

package strings

fun joinToString(...) : String { ... }

이 함수가 어떻게 실행될 수 있는 걸까? JVM이 클래스 안에 들어있는 코드만을 실행할 수 있기 때문에 컴파일러는 이 파일을 컴파일할 때 새로운 클래스를 정의해준다. 코틀린만 사용하는 경우에는 그냥 그런 클래스가 생긴다는 사실만 기억하면 된다. 하지만 이 함수를 자바 등의 다른 JVM 언어에서 호출하고 싶다면 코드가 어떻게 컴파일되는지 알아야 joinToString과 같은 최상위 함수를 사용할 수 있다. 어떻게 코틀린이 join.kt를 컴파일하는지 보여주기 위해 join.kt를 컴파일한 결과와 같은 클래스를 자바 코드로 써보면 다음과 같다.

package strings;

public class JoinKt {
    public static String joinToSTring(...) { ... }

코틀린 컴파일러가 생성하는 클래스의 이름은 최상위 함수가 들어있던 코틀린 소스 파일의 이름과 대응한다. 코틀린 파일의 모든 최상위 함수는 이 클래스의 정적인 메서드가 된다. 따라서 자바에서 joinToString을 호출하기는 쉽다.

import strings.JoinKt;
...

JoinKt.joinToString(list, ", ", "", "");

파일에 대응하는 클래스의 이름 변경하기
코틀린 최상위 함수가 포함되는 클래스의 이름을 바꾸고 싶다면 파일에 @JvmName 애너테이션을 추가하라. @JvmName 애너테이션은 파일의 맨 앞, 패키지 이름 선언 이전에 위치해야 한다.

@file:JvmName("StringFunctions") // 클래스 이름을 지정하는 애너테이션 

package strings // @file:JvmName 애너테이션 뒤에 패키지 문이 와야한다. 

fun joinToString(...) : String { ... }

이제 다음과 같이 joinToString 함수를 호출할 수 있다.

import strings.StringFunctions;

StringFunctions.joinToString(list, ", ", "", "");

(@JvmName 애너테이션 문법에 대해서는 10장에서 설명한다)

최상위 프로퍼티

함수와 마찬가지로 프로퍼티도 파일의 최상위 수준에 놓을 수 있다. 어떤 데이터를 클래스 밖에 위치시켜야 하는 경우는 흔하지는 않지만, 그래도 가끔 유용할 때가 있다. 예를 들어 어떤 연산을 수행한 횟수를 저장하는 var 프로퍼티를 만들 수 있다.

var opCount = 0  // 최상위 프로퍼티를 선언한다. 

fun performOperation() {
    opCount++ // 최상위 프로퍼티의 값을 변경한다. 
    // ...
}

fun reportOperationCount() {
    println("Operation performed $opCount times") // 최상위 프로퍼티의 값을 읽는다. 
}

이런 프로퍼티의 값은 정적 필드에 저장된다.

최상위 프로퍼티를 활용해 코드에 상수를 추가할 수 있다.

val UNIX_LINE_SEPARATOR = "\n"

기본적으로 최상위 프로퍼티도 다른 모든 프로퍼티처럼 접근자 메서드를 통해 자바 코드에 노출된다(val의 경우 게터, var의 경우 게터, 세터). 겉으론 상수처럼 보이는데, 실제로는 게터를 사용해야 한다면 자연스럽지 못하다. 더 자연스럽게 사용하려면 이 상수를 public static final 필드로 컴파일해야 한다. const 변경자를 추가하면 프로퍼티를 public static final 필드로 컴파일하게 만들 수 있다(단, 원시 타입과 String 타입의 프로퍼티만 const로 지정할 수 있다).

const val UNIX_LINE_SEPARATOR = "\n"

앞의 코드는 다음 자바 코드와 동등한 바이트코드를 만들어낸다.

public static final String UNIX_LINE_SEPARATOR = "\n";

메서드를 다른 클래스에 추가: 확장 함수와 확장 프로퍼티

기존 코드와 코틀린 코드를 자연스럽게 통합하는 것은 코틀린의 핵심 목표 중 하나다.
완전히 코틀린으로만 이뤄진 프로젝트조차도 JDK나 안드로이드 프레임워크 또는 다른 서드파티 프레임워크 등의 자바 라이브러리를 기반으로 만들어진다. 또 코틀린을 기존 자바 프로젝트에 통합하는 경우에는 코틀린으로 직접 변환할 수 없거나 미처 변환하지 않은 기존 자바 코드를 처리할 수 있어야 한다. 이런 기존 자바 API를 재작성하지 않고도 코틀린이 제공하는 여러 편리한 기능을 사용할 수 있다면 정말 좋은 일 아닐까? 바로 확장 함수(extension function)가 그런 역할을 해줄 수 있다.

개념적으로 확장 함수는 단순하다. 확장 함수는 어떤 클래스의 멤버 메서드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수다. 확장 함수를 보여주기 위해 어떤 문자열의 마지막 문자를 돌려주는 메서드를 추가해보자.

package strings

fun String.lastChar(): Char = this.get(this.length - 1)

확장 함수를 만들려면 추가하려는 함수 이름 앞에 그 함수가 확장할 클래스의 이름을 덧붙이기만 하면 된다. 클래스 이름을 수신 객체 타입(receiver type)이라 부르며, 확장 함수가 호출되는 대상이 되는 값(객체)을 수신 객체(receiver object)라고 부른다.

예를 들어 위의 코드의 경우 String: '수신 객체 타입', this: '수신 객체' 가 된다.
수신 객체의 타입은 확장이 정의될 클래스의 타입이며, 수신 객체는 그 클래스에 속한 인스턴스 객체다.

이 함수를 호출하는 구문은 다른 일반 클래스 멤버를 호출하는 구문과 똑같다.

>>> println("Kotlin".lastChar())

이 예제에서는 String이 수신 객체 타입이고 "Kotlin"이 수신 객체다.
어떤 면에서 이는 String 클래스에 새로운 메서드를 추가하는 것과 같다. String 클래스가 우리가 직접 작성한 코드가 아니고 심지어 String 클래스의 소스코드를 소유한 것도 아니지만, 우리는 여전히 원하는 메서드를 String 클래스에 추가할 수 있다. 심지어 String이나 자바나 코틀린 등의 언어 중 어떤 것으로 작성됐는가는 중요하지 않다.

일반 메서드의 본문에서 this를 사용할 때와 마찬가지로 확장 함수 본문에도 this를 쓸 수 있다.

package strings

fun String.lastChar(): Char = get(legnth - 1) // 수신 객체 멤버에 this 없이 접근할 수 있다.

확장 함수 내부에서는 일반적인 인스턴스 메서드의 내부에서와 마찬가지로 수신 객체의 메서드나 프로퍼티를 바로 사용할 수 있다. 하지만 확장 함수가 캡슐화를 깨지는 않는다는 사실을 기억하라. 클래스 안에서 정의한 메서드와 달리 확장 함수 안에서는 클래스 내부에서만 사용할 수 있는 비공개(private) 멤버나 보호된(protected) 멤버를 사용할 수 없다.

이제부터는 클래스의 멤버 메서드와 확장 함수를 모두 메서드라고 부를 것이다. 호출하는 쪽에서는 확장 함수와 멤버 메서드를 구분할 수 없다. 그리고 호출하는 메서드가 확장함수인지 멤버 메서드인지 여부가 중요한 경우도 거의 없다.

임포트와 확장 함수

확장 함수를 정의했다고 해도 자동으로 프로젝트 안의 모든 소스코드에서 그 함수를 사용할 수 있지는 않다. 확장 함수를 사용하기 위해서는 그 함수를 다른 클래스나 함수와 마찬가지로 임포트해야만 한다. 확장 함수를 정의하자마자 어디서든 그 함수를 쓸 수 있다면 한 클래스에 같은 이름의 확장 함수가 둘 이상 있어서 이름이 충돌하는 경우가 자주 생길 수 있다. 코틀린에서는 클래스를 임포트할 때와 동일한 구문을 사용해 개별 함수를 임포트할 수 있다.

import strings.lastChar

val c = "Kotlin".lastChar()

물론 *를 사용한 임포트도 잘 작동한다.

as 키워드를 사용하면 임포트한 클래스나 함수를 다른 이름으로 부를 수 있다.

import strings.lastChar as last

val c = "Kotlin".last()

한 파일 안에서 다른 여러 패키지에 속해있는 이름이 같은 함수를 가져와 사용해야 하는 경우 이름을 바꿔서 임포트하면 이름 충돌을 막을 수 있다. 물론 일반적인 클래스나 함수라면 그 전체 이름(FQN, Full Qualifed Name)을 써도 된다. 하지만 코틀린 문법상 확장 함수는 반드시 짧은 이름을 써야 한다. 따라서 임포트할 때 이름을 바꾸는 것이 확장 함수 이름 충돌을 해결할 수 있는 유일한 방법이다.

자바에서 확장 함수 호출

내부적으로 확장 함수는 수신 객체를 첫 번째 인자로 받는 정적 메서드다. 그래서 확장 함수를 호출해도 다른 어댑터(adapter) 객체나 실행 시점 부가 비용이 들지 않는다. 이런 설계로 인해 자바에서는 확장 함수를 사용하기도 편하다. 단지 정적 메서드를 호출하면서 첫 번째 인자로 수신 객체를 넘기기만 하면 된다. 다른 최상위 함수와 마찬가지로 확장 함수가 들어있는 자바 클래스 이름도 확장 함수가 들어있는 파일 이름에 따라 결정된다. 따라서 확장 함수를 StringUtil.kt 파일에 정의했다면 다음과 같이 호출할 수 있다.

/* 자바 */
char c = StringUtilKt.lastChar("Java");

확장 함수로 유틸리티 함수 정의

이제 joinToString 함수의 최종 버전을 만들자. 이제 이 함수는 코틀린 라이브러리가 제공하는 함수와 거의 같아졌다.

joinToString()를 확장으로 정의하기

fun <T> Collection<T>.joinToString( // Collection<T>에 대한 확장 함수를 선언한다. 
    separator: String = ", ",
    prefix: String = "",
    postfix: String = ""
): String (
    val result = StringBuilder(prefix)

    for ((index, element) in this.withIndex())
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}
>>> val list = listOf(1, 2, 3)
>>> println(list.joinToString(separator = "; ",
...      prefix = "(", postfix = ")"))
(1; 2; 3)

원소로 이뤄진 컬렉션에 대한 확장을 만든다. 그리고 모든 인자에 대한 디폴트 값을 지정한다. 이제 joinToString을 마치 클래스의 멤버인 것처럼 호출할 수 있다.

>>> val list = arrayListOf(1, 2, 3)
>>> println(list.joinToSTring(" "))
1 2 3

확장 함수는 단지 정적 메서드 호출에 대한 문법적인 편의(syntatic sugar)일 뿐이다. 그래서 클래스가 아닌 더 구체적인 타입을 수신 객체 타입으로 지정할 수도 있다. 그래서 문자열의 컬렉션에 대해서만 호출할 수 있는 join 함수를 정의하고 싶다면 다음과 같이 하면 된다.

fun Collection<String>.join(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = ""
) = joinToString(separator, prefix, postfix)
>>> println(listOf("one", "two", "eight").join(" "))
one two eight

이 함수를 객체의 리스트에 대해 호출할 수는 없다.

확장 함수가 정적 메서드와 같은 특징을 가지므로, 확장 함수를 하위 클래스에서 오버라이드 할 수는 없다.

확장 함수는 오버라이드할 수 없다.

코틀린의 메서드 오버라이드도 일반적인 객체지향의 메서드 오버라이드와 마찬가지다. 하지만 확장 함수는 오버라이드 할 수 없다. View와 그 하위 클래스인 Button이 있는데, Button이 상위 클래스의 click 함수를 오버라이드하는 경우를 생각해보자.

open class View {
    open fun click() = println("View clicked")
}

class Button: View() { // Button은 View를 확장한다 
    override fun click() = println("Button clicked")
}

Button이 View의 하위 타입이기 때문에 View 타입 변수를 선언해도 Button 타입 변수를 그 변수에 대입할 수 있다. View 타입 변수에 대해 click과 같은 일반 메서드를 호출했는데, click을 Button 클래스가 오버라이드했다면 실제로는 Button이 오버라이드한 click이 호출된다.

>>> val view: View = Button()
>>> view.click() // "view"에 저장된 값의 실제 타입에 따라 호출할 메서드가 결정된다. 
Button clicked

하지만 확장은 이런 식으로 동작하지 않는다. 확장 함수는 클래스의 일부가 아니다. 확장 함수는 클래스 밖에 선언된다. 이름과 파라미터가 완전히 같은 확장 함수를 기반 클래스와 하위 클래스에 대해 정의해도 실제로는 확장 함수를 호출할 때 수신 객체로 지정한 변수의 정적 타입에 의해 어떤 확장함수가 호출될지 결정되지, 그 변수에 저장된 객체의 동적인 타입에 의해 확장 함수가 결정되지 않는다.

다음 예제는 View와 Button 클래스에 대해 선언된 두 showOff() 확장 함수를 보여준다.

확장 함수는 오버라이드할 수 없다.

fun View.showOff() = println("I'm a view!")
fun Button.showOff() = println("I'm a button")

>>> >>> val view: View = Button()
>>> view.showOff() // 확장 함수는 정적으로 결정된다. 
I'm a view!

view가 가리키는 객체의 실제 타입이 Button이지만, 이 경우 view의 타입이 View이기 때문에 무조건 View의 확장 함수가 호출된다.

확장 함수를 첫 번째 인자가 수신 객체인 정적 자바 메서드로 컴파일한다는 사실을 기억한다면 이런 동작을 쉽게 이해할 수 있다. 자바도 호출할 정적(static) 함수를 같은 방식으로 정적으로 결정한다.

/* 자바 */
>>> View view = new Button();
>>> ExtensionsKt.showOff(view); // showOff 함수를 extensions.kt 파일에 정의했다.
I'm a view!

위 예제와 같이 확장 함수를 오버라이들할 수는 없다. 코틀린은 호출될 확장 함수를 정적으로 결정하기 때문이다.

어떤 클래스를 확장함 함수와 그 클래스의 멤버 함수의 이름과 시그니처가 같다면 확장 함수가 아니라 멤버 함수가 호출된다(멤버 함수의 우선순위가 더 높다). 클래스의 API를 변경할 경우 항상 이를 염두에 둬야 한다. 우리가 코드 소유권을 가진 클래스에 대한 확장 함수를 정의해서 사용하는 외부 클라이언트 프로젝트가 있다고 하자. 그 확장 함수와 이름과 시그니처가 같은 멤버 함수를 우리의 클래스 내부에 추가하면 클라이언트 프로젝트를 재컴파일한 순간부터 그 클라이언트는 확장 함수가 아닌 새로 추가된 멤버 함수를 사용하게 된다.

확장 프로퍼티

확장 프로퍼티를 사용하면 기존 클래스 객체에 대한 프로퍼티 형식의 구문으로 사용할 수 있는 API를 추가할 수 있다. 프로퍼티라는 이름으로 불리기는 하지만 상태를 저장할 적절한 방법이 없기 때문에 실제로 확장 프로퍼티는 아무 상태도 가질 수 없다. 하지만 프로퍼티 문법으로 더 짧게 코드를 작성할 수 있어서 편한 경우가 있다.

앞 절에서 lastChar라는 함수를 정의했다. 이제 그 함수를 프로퍼티로 바꾸자.

확장 프로퍼티 선언하기

val String.lastChar: Char
    get() = get(length - 1)

확장 함수의 경우와 마찬가지로 확장 프로퍼티도 일반적인 프로퍼티와 같은데, 단지 수신 객체 클래스가 추가됐을 뿐이다. 뒷받침하는 필드가 없어서 기본 게터 구현을 제공할 수 없으므로 최소한 게터는 꼭 정의를 해야 한다. 마찬가지로 초기화 코드에서 계산한 값을 담을 장소가 전혀 없으므로 초기화 코드도 쓸 수 없다.

StringBuilder에 같은 프로퍼티를 정의한다면 StringBuilder의 맨 마지막 문자는 변경 가능하므로 프로퍼티를 var로 만들 수 있다.

변경 가능한 확장 프로퍼티 선언하기

var StringBuilder.lastChar: Char 
    get() = get(length - 1) // 프로퍼티의 게터 
    set(value: Char) {
        this.setCharAt(length - 1, value) // 프로퍼티의 세터
}

확장 프로퍼티를 사용하는 방법은 멤버 프로퍼티를 사용하는 방법과 같다.

>>> println("Kotlin".lastChar)
n
>>> val sb = StringBuilder("Kotlin?")
>>> sb.lastChar = '!'
>>> println(sb)
Kotlin!

자바에서 확장 프로퍼티를 사용하고 싶다면 항상 StringUiltKt.getLastChar("Java") 처럼 게터나 세터를 명시적으로 호출해야 한다.

지금까지 확장에 대해 전반적으로 다뤘다. 이제는 컬렉션이라는 주제로 돌아가서 컬렉션을 처리할 때 유용한 라이브러리 함수를 몇 가지 살펴본다.

컬렉션 처리: 가변 길이 인자, 중위 함수 호출, 라이브러리 지원

컬렉션을 처리할 때 쓸 수 있는 코틀린 표준 라이브러리 함수 몇 가지를 보여준다. 그 과정에서 다음과 같은 코틀린 언어 특성을 설명한다.

  • vararg 키워드를 사용하면 호출 시 인자 개수가 달라질 수 있는 함수를 정의할 수 있다.
  • 중위(infix) 함수 호출 구문을 사용하면 인자가 하나뿐인 메서드를 간편하게 호출할 수 있다.
  • 구조 분해 선언(destructuring declaration)을 사용하면 복합적인 값을 분해해서 여러 변수에 나눠 담을 수 있다.

자바 컬렉션 API 확장

맨 앞부분에서 코틀린 컬렉션은 자바와 같은 클래스를 사용하지만 더 확장된 API를 제공한다고 언급했었다. 그리고 리스트의 마지막 원소를 가져오는 예제와 숫자로 이뤄진 컬렉션의 최댓값을 찾는 예제를 살펴봤다.

>>> strings.last()
fourteenth
>>> val numbers: Collection<Int> = setOf(1, 14, 2)
>>> numbers.max()
14

이런 코드가 어떻게 작동할 수 있는지 궁금했을 것이다. 어떻게 자바 라이브러리 클래스의 인스턴스인 컬렉션에 대해 코틀린이 새로운 기능을 추가할 수 있을까? 이제 우리는 그 답을 명확히 안다. last와 max는 모두 확장 함수였던 것이다.

last 함수는 앞 절에서 String에 대해 정의했던 lastChar보다 복잡하지 않다.
last는 List 클래스의 확장 함수다. max의 경우에는 더 단순하게 정리한 선언을 살펴봤다.

fun <T> List<T>.last(): T (/* 마지막 원소를 반환함 */}
fun Collection<Int>.max(): Int {/*컬렉션의 최댓값을 찾음 */}

코틀린 표준 라이브러리는 수많은 확장 함수를 포함하므로 여기서 그들을 모두 나열하지는 않을 것이다. 코틀린 표준 라이브러리의 기능을 전부 다 배우는 가장 좋은 방법이 무엇인지 궁금할 수 있지만 코틀린 표준 라이브러리를 모두 다 알 필요는 없다. 컬렉션이나 다른 객체에 대해 사용할 수 있는 메서드나 함수가 무엇인지 궁금할 때마다 IDE의 코드 완성 기능을 통해 그런 메서드나 함수를 살펴볼 수 있다.

IDE가 표시해주는 목록에서 원하는 함수를 선택하기만 하라. 추가로 표준 라이브러리 참조 매뉴얼을 살펴보면 각 라이브러리 클래스가 제공하는 모든 메서드(멤버 메서드와 확장 함수)를 볼 수 있다.

가변 인자 함수: 인자의 개수가 달라질 수 있는 함수 정의

리스트를 생성하는 함수를 호출할 때 원하는 만큼 많이 원소를 전달할 수 있다.

val list = listOf(2, 3, 5, 7, 11)

라이브러리에서 이 함수의 정의를 보면 다음과 같다.

fun listOf<T>(vararg values: T): List<T> { ... }

자바의 가변 길이 인자(varargs)에 익숙한 사람도 많을 것이다. 가변 길이 인자는 메서드를 호출할 때 원하는 개수만큼 값을 인자로 넘기면 자바 컴파일러가 배열에 그 값들을 넣어주는 기능이다. 코틀린의 가변 길이 인자도 자바와 비슷하다. 다만 문법이 조금 다르다. 타입 뒤에 ...를 붙이는 대신 코틀린에서는 파라미터 앞에 vararg 변경자를 붙인다.

이미 배열에 들어있는 원소를 가변 길이 인자로 넘길 때도 코틀린과 자바 구문이 다르다. 자바에서는 배열을 그냥 넘기면 되지만 코틀린에서는 배열을 명시적으로 풀어서 배열의 각 원소가 인자로 전달되게 해야 한다. 기술적으로는 스프레드 연산자가 그런 작업을 해준다. 하지만 실제로는 전달하려는 배열 앞에 *를 붙이기만 하면 된다.

fun main(args: Array<String>) {
    val list = listOf("args: ", *args) // 스프레드 연산자가 배열의 내용을 펼쳐준다. 
    println(list)
}

이 예제는 스프레드 연산자를 통하면 배열에 들어있는 값과 다른 여러 값을 함께 써서 함수를 호출할 수 있음을 보여준다. 이런 기능은 자바에서는 사용할 수 없다. 이제 맵으로 대상을 옮겨서 코틀린 함수 호출의 가독성을 향상시킬 수 있는 다른 방법인 중위 호출에 대해 살펴보자.

값의 쌍 다루기: 중위 호출과 구조 분해 선언

맵을 만들려면 mapOf 함수를 사용한다.

val map = mapOf(1 to "one", 7 to "seven", 53 to "fifty-three")

여기서 to라는 단어는 코틀린 키워드가 아니다. 이 코드는 중위 호출(infix call)이라는 특별한 방식으로 to라는 일반 메서드를 호출한 것이다.
중위 호출 시에는 수신 객체와 유일한 메서드 인자 사이에 메서드 이름을 넣는다(이때 객체, 메서드 이름, 유일한 인자 사이에는 공백이 들어가야 한다).
다음 두 호출은 동일하다.

1.to("one") // "to" 메서드를 일반적인 방식으로 호출함
1 to "one" // "to" 메서드를 중위 호출 방식으로 호출함 

인자가 하나뿐인 일반 메서드인자가 하나뿐인 확장 함수중위 호출을 사용할 수 있다.
함수(메서드)를 중위 호출에 사용하게 허용하고 싶으면 infix 변경자를 함수(메서드) 선언 앞에 추가해야 한다. 다음은 to 함수의 정의를 간략하게 줄인 코드다.

infix fun Any.to(other: Any) = Pair(this, other)

to 함수는 Pair의 인스턴스를 반환한다. Pair는 코틀린 표준 라이브러리 클래스로, 그 이름대로 두 원소로 이뤄진 순서쌍을 표현한다. (실제로 to는 제네릭 함수다)

Pair의 내용으로 두 변수를 즉시 초기화할 수 있다.

val (number, name) = 1 to "one"

이런 기능을 구조 분해 선언(desrtucturing declaration)이라고 부른다.
Pair 인스턴스 외 다른 객체에도 구조 분해를 적용할 수 있다. 예를 들어 key와 value라는 두 변수를 맵의 원소를 사용해 초기화할 수 있다.

루프에서도 구조 분해 선언을 활용할 수 있다. joinToString에서 본 withIndex를 구조 분해 선언과 조합하면 컬렉션 원소의 인덱스의 값을 따로 변수에 담을 수 있다.

for ((index, element) in collection.withIndex()) {
    println("$index: $element")
}

to 함수는 확장 함수다. to를 사용하면 타입과 관계없이 임의의 순서쌍을 만들 수 있다. 이는 to의 수신 객체가 제네릭하다는 뜻이다. 1 to "one", "one" to 1, list to list.size() 등의 호출이 모두 잘 작동한다. mapOf 함수의 선언을 살펴보자.

fun <K,V> mapOf(vararg values: Pair<K, V>) : Map<K, V>

listOf와 마찬가지로 mapOf에도 원하는 개수만큼 인자를 전달할 수 있다. 하지만 mapOf의 경우에는 각 인자가 키와 값으로 이뤄진 순서쌍이어야 한다.

코틀린을 잘 모르는 사람이 보면 새로운 맵을 만드는 구문은 코틀린이 맵에 대해 제공하는 특별한 문법인 것처럼 느낄 수 있다. 하지만 실제로는 일반적인 함수를 더 간결한 구문으로 호출하는 것 뿐이다. 이제는 확장을 통해 문자열과 정규식을 더 편리하게 다루는 방법을 살펴본다.

문자열과 정규식 다루기

코틀린 문자열은 자바 문자열과 같다. 코틀린 코드가 만들어낸 문자열을 아무 자바 메서드에 넘겨도 되며, 자바 코드에서 받은 문자열을 아무 코틀린 표준 라이브러리 함수에 전달해도 문제없다. 특별한 변환도 필요 없고 자바 문자열을 감싸는 별도의 래퍼(wrapper)도 생기지 않는다.

코틀린은 다양한 확장 함수를 제공함으로써 표준 자바 문자열을 더 즐겁게 다루게 해준다. 또한 혼동이 야기될 수 있는 일부 메서드에 대해 더 명확한 코틀린 확장 함수를 제공함으로써 프로그래머의 실수를 줄여준다. 자바와 코틀린 API의 차이를 알아보기위한 첫 번째 예제로 문자열을 구분 문자열에 따라 나누는 작업을 코틀린에서 어떻게 처리하는지 살펴보자.

문자열 나누기

자바 개발자라면 String의 split 메서드를 잘 알고 있을 것이다. 모든 자바 개발자가 그 메서드를 사용하지만 불만을 표시하는 사람도 있다. 스택 오버플로우 질문 중에는 "자바 split 메서드로는 점(.)을 사용해 문자열을 분리할 수 없습니다." 라는 질문이 있다. "12.345-6.A".split(".") 라는 호출의 결과가 [12, 345-6, A] 배열이라고 생각하는 실수를 저지르는 개발자가 많다. 하지만 자바의 split 메서드는 빈배열을 반환한다! split의 구분 문자열을 실제로는 정규식(regular expression)이기 때문이다. 따라서 마침표(.)는 모든 문자를 나타내는 정규식으로 해석된다.

코틀린에서는 자바의 split 대신에 여러 가지 다른 조합의 파라미터를 받는 split 확장 함수를 제공함으로써 혼동을 야기하는 메서드를 감춘다. 정규식을 파라미터로 받는 함수는 String이 아닌 Regex 타입의 값을 받는다. 따라서 코틀린에서는 split 함수에 전달하는 값의 타입에 따라 정규식이나 일반 텍스트 중 어느 것으로 문자열을 분리하는지 쉽게 알 수 있다.

다음 코드는 마침표나 대시(-)로 문자열을 분리하는 예를 보여준다.

>>> println("12.345-6.A".split("//.|-".toRegex())) // 정규식을 명시적으로 만든다. 
[12, 345, 6, A]

코틀린 정규식 문법은 자바와 똑같다. 여기 있는 패턴은 마침표나 대시와 매치된다(정규식 안에서 마침표가 와일드카드 문자가 아닌 문자 자체로 쓰이게 하기 위해 마침표를 이스케이프 시켰다). 정규식을 처리하는 API는 표준 자바 라이브러리 API와 비슷하지만 좀 더 코틀린답게 변경됐다. 예를 들어 코틀린에서는 toRegex 확장 함수를 사용해 문자열을 정규식으로 변환할 수 있다.

이런 간단한 경우에는 꼭 정규식을 쓸 필요가 없다. split 확장 함수를 오버로딩한 버전 중에는 구분 문자열을 하나 이상 인자로 받는 함수가 있다.

>>> println("12.345-6.A".split(".", "-")) // 여러 구분 문자열을 지정한다. 
[12, 345, 6, A]

이 경우 "12.345-6.A".split('.', '-')처럼 문자열 대신 문자를 인자로 넘겨도 마찬가지 결과를 볼 수 있다. 이렇게 여러 문자를 받을 수 있는 코틀린 확장 함수는 자바에 있는 단 하나의 문자만 받을 수 있는 메서드를 대신한다.

정규식과 3중 따옴표로 묶은 문자열

다른 예로 두 가지 구현을 만들어보자. 첫 번째 구현은 String을 확장한 함수를 사용하고 두 번째 구현은 정규식을 사용한다. 우리가 할 일은 파일의 전체 경로명을 디렉터리, 파일 이름, 확장자로 구분하는 것이다. 코틀린 표준 라이브러리에는 어떤 문자열에서 구분 문자열이 맨 나중(또는 처음)에 나타난 곳 뒤(또는 앞)의 부분 문자열을 반환하는 함수가 있다. 이런 함수를 사용해 경로 파싱을 구현한 버전은 다음고 같다.

"/Users/yole/kotlin-book/chapter.adoc"
디렉터리: "/Users/yole/kotlin-book"
파일 이름: "chapter"
확장자: "adoc"

String 확장 함수를 사용해 경로 파싱하기

fun parsePath(path: String) {
    val directory = path.substringBeforeLast("/")
    val fullName = path.substringAfterLast("/")

    val fileName = "fullName.subStringBeforeLast(".")
    val extension = "fullName.substringAfterLast(".")

    println("Dir: $directory, name: $fileName, ext: $extension")
}
>>> parsePath("/Users/yole/kotlin-book/chapter.adoc")
Dir: /Users/yole/kotlin-book, name: chapter, ext: adoc

path에서 처음부터 마지막 슬래시 직전까지의 부분 문자열은 파일이 들어있는 디렉터리 경로다. path에서 마지막 마침표 다음부터 끝까지의 부분 문자열은 파일 확장자다. 파일 이름은 그 두 위치 사이에 있다.

코틀린에서는 정규식을 사용하지 않고도 문자열을 쉽게 파싱할 수 있다. 정규식은 강력하기는 하지만 나중에 알아보기 힘든 경우가 많다. 정규식이 필요할 때는 코틀린 라이브러리를 사용하면 더 편하다. 다음과 같은 작업을 정규식을 활용해 구현한 프로그램이다.

경로 파싱에 정규식 사용하기

fun parsePath(path: String) {
    val regex = """(.+)/(.+)\.(.+)""".toRegex()
    val matchResult = regex.matchEntire(path)
    if (matchResult != null) {
        val (directory, filename, extension) = matchResult.destructured
        println("Dir: $directory, name: $filename, ext: $extension")
    }
}

이 예에제어는... (p 132)

728x90
Kotlin/Kotlin In Action

2장 - 코틀린 기초

728x90

이번 장에서는 모든 프로그램의 필수 요소인 변수(variable), 함수(function), 클래스(class) 등을 코틀린에서 어떻게 선언하는지 살펴보고 프로퍼티(property)라는 개념을 배운다.

또한 코틀린의 여러 제어 구조를 배운다. 대부분의 코틀린 제어 구조는 자바와 비슷하지만 몇 가지 중요한 개선이 이뤄졌다.

그런 다음 스마트 캐스트(smart cast)에 대해 설명한다. 스마트 캐스트타입 검사와 타입 캐스트, 타입 강제 변환을 하나로 엮은 기능이다. 마지막으로 예외 처리(exception handling)를 살펴본다. 이번장을 다 읽고 나면 기본적인 코틀린 개념을 활용해 코드를 작성할 수 있다. (한 가지 주의할 점은 여기서 배운 내용만으로 작성할 수 있는 코드는 코틀린 다운 코드라고 부르기는 어렵다)

기본 요소: 함수와 변수

모든 프로그램을 구성하는 기본 단위인 함수와 변수를 살펴본다. 코틀린에서는 타입 선언을 생략해도 된다고 했는데, 코틀린이 어떻게 불변 데이터 사용을 권장하는지 배운다.

Hello, World!

고전적이면서도 간단한 예제 Hello, World!를 출력해보자.

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

매우 간단한 코드이지만 아래와 같은 코틀린 문법의 특성을 발견할 수 있다.

  • 함수를 선언할 때는 fun 키워드를 사용한다. 실제로도 코틀린 프로그래밍은 수많은 fun을 만드느느 재미있는 일이다.
  • 파라미터 이름 뒤에 그 파라미터의 타입을 쓴다. 나중에 보겠지만 변수를 선언할 때도 마찬가지 방식으로 타입을 지정한다.
  • 함수를 최상위 수준에 정의할 수 있다. 꼭 클래스 안에 함수를 넣어야 할 필요가 없다.
  • 배열도 일반적인 클래스와 마찬가지다. 코틀린에는 자바와 달리 배열 처리를 위한 문법이 따로 존재하지 않는다.
  • System.out.println 대신에 println이라고 쓴다. 코틀린 표준 라이브러리는 여러 가지 표준 자바 라이브러리 함수를 간결하게 사용할 수 있게 감싼 래퍼(Wrapper)를 제공한다. println도 그런 함수 중 하나다.
  • 최신 프로그래밍 언어 경향과 마찬가지로 줄 끝에 세미콜론(;)을 붙이지 않아도 된다.

함수

아무런 값도 반환하지 않는 함수를 어떻게 선언하는지 방금 살펴봤다. 하지만 의미 있는 결과를 반환하는 함수의 경우 반환 값의 타입을 어디에 지정해야 할까? 파라미터 목록 뒤의 어디쯤에 반환 타입을 추가하면 되리라 추측할 수 있다.

fun max(a: Int, b: Int) : Int {
    return if (a > b) a else b
}

println(max(1, 2)) 의 결과는 2가 출력된다.

함수 선언은 fun 키워드로 시작한다. fun 다음에는 함수 이름이 온다. 예제는 max라는 이름의 함수다. 함수 이름 뒤에는 괄회 안에 파라미터 목록이 온다. 함수의 반환 타입은 파라미터 목록의 닫는 괄호 다음에 오는데, 괄호와 반환 타입 사이를 콜론(:)으로 구분해야 한다.
코틀린 if는 (값을 만들어내지 못하는) 문장이 아니고 결과를 만드는 식(expression)이라는 점이 흥미롭다. 이 예제의 코틀린 if 식은 자바 3항 연산자로 작성한 (a > b) ? a : b 식과 비슷하다.

문(statement)과 식(expression)의 구분
코틀린에서는 if는 식이지 문이 아니다. 식은 값을 만들어 내며 다른 식의 하위 요소로 계산에 참여할 수 있는 반면 문은 자신을 둘러싸고 있는 가장 안쪽 블록의 최상위 요소로 존재하며 아무런 값을 만들어내지 않는다는 차이가 있다. 자바에서는 모든 제어 구조가 문인 반면 코틀린에서는 루프를 제외한 대부분의 제어 구조가 식이다. 나중에 이 책에서도 보여주겠지만, 제어 구조를 다른 식으로 엮어낼 수 있으면 여러 일반적인 패턴을 아주 간결하게 표현할 수 있다.

반면 대입문은 자바에서는 식이었으나 코틀린에서는 문이 됐다. 그로 인해 자바와 달리 대입식과 비교식을 잘못 바꿔 써서 버그가 생기는 경우가 없다.

식이 본문인 함수

조금 전에 살펴본 함수를 더 간결하게 표현할 수도 있다. 앞의 함수 본문은 if 식 하나로만 이뤄져 있다. 이런 경우 다음과 같이 중괄호를 없애고 return을 제거하면서 등호(=)를 식 앞에 붙이면 더 간결하게 함수를 표현할 수 있다.

fun max(a: Int, b: Int): Int = if (a > b) a else b

본문이 중괄호로 둘러싸인 함수를 블록이 본문인 함수라 부르고, 등호와 식으로 이뤄진 함수를 식이 본문인 함수라고 부른다.

인텔리J 아이디어 팁 인텔리J 아이디어는 이 두 방식의 함수를 서로 변환하는 메뉴가 있다. 각각은ㅇ 식 본문으로 변환(Convert to expression body)블록 본문으로 변환(Convert to block body)이다.

코틀린에서는 식이 본문인 함수가 자주 쓰인다. 그런 함수의 본문 식에는 단순한 산술식이나 함수 호출 식뿐 아니라 if, when, try 등의 더 복잡한 식도 자주 쓰인다. 잠시후에 when에 대해 설명할 때 그런 함수를 보여준다. 반환 타입을 생략하면 max 함수를 더 간략하게 만들 수 있다.

fun max(a: Int, b: Int) = if (a > b) a else b

여기서 반환 타입을 생략할 수 있는 이유는 무엇일까? 코틀린은 정적 타입 지정 언어이므로 컴파일 시점에 모든 식의 타입을 지정해야 하지 않는가? 실제로 모든 변수나 모든 식에는 타입이 있으며, 모든 함수는 반환 타입이 정해져야 한다. 하지만 식이 본문인 함수의 경우 굳이 사용자가 반환 타입을 적지 않아도 컴파일러가 함수 본문 식을 분석해서 식의 결고 타입을 함수 반환 타입으로 정해준다. 이렇게 컴파일러가 타입을 분석해 프로그래머 대신 프로그램 구성 요소의 타입을 정해주는 기능을 타입 추론(type inference)이라 부른다.

식이 본문인 함수의 반환 타입만 생략 가능하다는 점에 유의하라. 블록이 본문인 함수가 값을 반환한다면 반드시 반환 타입을 지정하고 return문을 사용해 반환 값을 명시해야 한다. 코틀린 언어를 이렇게 설계한 의도가 있다. 실전 프로그램에는 아주 긴 함수에 return 문이 여럿 들어있는 경우가 자주 있다. 그런 경우 반환 타입을 꼭 명시하고 return을 반드시 사용한다면 함수가 어떤 타입의 값을 반환하고 어디서 그런 값을 반환하는지 더 쉽게 알아볼 수 있다. 이제 변수 선언 문법에 대해 살펴보자.

변수

자바에서는 변수를 선언할 때 타입이 맨 앞에 온다. 코틀린에서는 타입 지정을 생략하는 경우가 흔하다. 타입으로 변수 선언을 시작하면 타입을 생략할 경우 식과 변수 선언을 구별할 수 없다. 그런 이유로 코틀린에서는 키워드로 변수 선언을 시작하는 대신 변수 이름 뒤에 타입을 명시하거나 생략하게 허용한다. 변수를 몇 개 선언해보자.

val question = "삶, 우주, 그리고 모든 것에 대한 궁긍적인 질문
val answer = 42

이 예제에서는 타입 표기를 생략했지만 원한다면 타입을 명시해도 된다.

val answer: Int = 42

식이 본문인 함수에서와 마찬가지로 우리가 타입을 지정하지 않으면 컴파일러가 초기화 식을 분석해서 초기화 식의 타입을 변수 타입으로 지정한다. 여기서 초기화 식은 42로 Int 타입이다. 따라서 변수도 Int 타입이 된다.
부동 소수점(floating point) 상수를 사용한다면 변수 타입은 Double이 된다.
val yearsToCompute = 7.5e6 <-- 7.5 x 10^6 = 7500000.0

초기화 식을 사용하지 않고 변수를 선언하려면 변수 타입을 반드시 명시해야한다.

val answer: Int
answer = 42

초기화 식이 없다면 변수에 저장될 값에 대해 아무 정보가 없기 때문에 컴파일러가 타입을 추론할 수 없다. 따라서 그런 경우 타입을 반드시 지정해야 한다.

변경 가능한 변수와 변경 불가능한 변수

변수 선언 시 사용하는 키워드는 다음과 같은 2가지가 있다.

  • val(값을 뜻하는 value에서 따옴) - 변경 불가능한(immutable) 참조를 저장하는 변수다. val로 선언된 변수는 일단 초기화하고 나면 재대입이 불가능하다. 자바로 말하자면 final 변수에 해당한다.
  • var(변수를 뜻하는 variable에서 따옴) - 변경 가능한(mutable) 참조다. 이런 변수의 값은 바뀔 수 있다. 자바의 일반 변수에 해당한다.

기본적으로는 모든 변수를 val 키워드를 사용해 불변 변수로 선언하고, 나중에 꼭 필요할 때에만 var로 변경하라. 변경 불가능한 참조와 변경 불가능한 객체를 부수 효과가 없는 함수와 조합해 사용하면 코드가 함수형 코드에 가까워진다. 1장에서 함수형 스타일의 장점에 대해 간략히 설명했었다. 5장에서 좀 더 자세하게 함수형 프로그래밍에 대해 설명한다.

val 변수는 블록을 실행할 때 정확히 한 번만 초기화해야 한다. 하지만 어떤 블록이 실행될 때 오직 한 초기화 문장만 실행됨을 컴파일러가 확인할 수 있다면 조건에 따라 val 값을 다른 여러 값으로 초기화할 수도 있다.

val message: String
if (canPerformOperation()) {
    message = "Success"
    // ... 연산을 수행한다. 

} else {
    message = "Failed"
}

val 참조 자체는 불변일지라도 그 참조가 가리키는 객체의 내부 값은 변경될 수 있다는 사실을 기억하라. 예를 들어 다음 코드도 완전히 올바른 코틀린 코드다.

val languages = arrayListOf("Java") // 불변 참조를 선언한다. 
languages.add("Kotlin") // 참조가 가리키는 객체 내부를 변경한다. 

6장에서 변경 가능한 객체와 불변 객체에 대해 더 자세히 살펴본다.
var 키워드를 사용하면 변수의 값을 변경할 수 있지만 변수의 타입은 고정돼 바뀌지 않는다. 예를 들어 다음 코드는 컴파일 할 수 없다.

var answer = 42
answer = "no answer" // "Error: type mismatch" 컴파일 오류 발생 

문자열 리터링(stirng literal)에서 컴파일 오류가 발생한다. 이유는 그 타입이 컴파일러가 기대하는 타입과 다르기 때문이다. 컴파일러는 변수 선언 시점의 초기화 식으로부터 변수의 타입을 추론하며, 변수 선언 이후 변수 재대입이 이뤄질 때는 이미 추론한 변수의 타입을 염두에 두고 대입문의 타입을 검사한다.

어떤 타입의 변수에 다른 타입의 값을 저장하고 싶다면 변환 함수를 써서 값을 변수의 타입으로 변환하거나, 값을 변수에 대입할 수 있는 타입으로 강제 형 변환(coerce)해야 한다.

더 쉽게 문자열 형식 지정: 문자열 템플릿

'Hello, World!' 예제로 다시 돌아가자. 다음은 그 예제의 다음 단계로, 사람 이름을 사용해 환영 인사를 출력하는 코틀린 프로그램이다.

fun main(args: Array<String>) {
    val name = if (args.size > 0) args[0] else "Kotlin"
    println("Hello, $name!")
}

이 예제는 문자열 템플릿(string template)이라는 기능을 보여준다. 이 코드는 name이라는 변수를 선언하고 그 다음 줄에 있는 문자열 리터럴 안에서 그 변수를 사용했다. 여러 스크립트 언어와 비슷하게 코틀린에서도 변수를 문자열 안에 사용할 수 있다. 문자열 리터럴의 필요한 곳에 변수를 넣되 변수 앞에 $를 추가해야 한다.

이 문자열 템플릿은 자바의 문자열 접합 연산("Hellom, " + name + "!")과 동일한 기능을 하지만 좀 더 간결하며, 자바 문자열 접합 연산을 사용한 식과 마찬가지로 효율적이다. 물론 컴파일러는 각 식을 정적으로 (컴파일 시점에) 검사하기 때문에 존재하지 않는 변수를 문자열 템플릿 안에서 사용하면 컴파일 오류가 발생한다.

$ 문자를 문자열에 넣고 싶으면 println("$x")와 같이 \를 사용해 $를 이스케이프(escape) 시켜야 한다. println("$x")는 화면에 x의 값을 출력하지 않고 $x를 출력한다.

문자열 템플릿 안에 사용할 수 있는 대상은 간단한 변수 이름만으로 한정되지 않는다. 복잡한 식도 중괄호({})로 둘러싸서 문자열 템플릿 안에 넣을 수 있다.

fun main(args: Array<String>) {
    if (args.size > 0) {
        println("Hello, ${args[0]}!")
    }
}

문자열 템플릿 안에서 변수 이름만 사용하는 경우라도 ${name} 처럼 중괄호로 변수명을 감싸는 습관을 들이면 더 좋다. 필요할 때 정규식 등을 통해 검색하거나 일괄 변환할 때도 중괄호를 쓴 경우 처리가 더 쉽고, 코드를 사람이 읽을 때도 문자열 템플릿 안에서 변수가 쓰인 부분을 더 쉽게 식별할 수 있다.

중괄호로 둘러싼 식 안에서 큰 따옴표를 사용할 수도 있다.

fun main(args: Array<String>) {
    println("Hello, ${if (args.size > 0) args[0] else "someone"}!")
}

함수나 변수를 정의하는 방법을 배웠으므로 이제는 수준을 한 단계 더 높여서 클래스에 대해 살펴보자. 이번에는 코틀린 언어의 새로운 특성을 배울 때 자바

코틀린 변환기를 어떻게 활용할 수 있는지 보여주기 위해 자바

코틀린 변환기를 사용할 것이다.

클래스와 프로퍼티

이 책의 독자라면 객체지향에 대해 알고있으며 클래스라는 추상화도 잘 알기 때문에 코틀린의 클래스 개념도 이미 잘 아는 내용일 것이다. 그런 독자들도 코틀린을 활용하면 더 적은 양의 코드로 클래스와 관련 있는 대부분의 작업을 수행할 수 있다는 사실을 차차 알게 될 것이다. 이번 절은 클래스를 선언하는 기본 문법을 소개한다. 좀 더 자세한 내용은 4장에서 다룬다.

시작하기 위해 간단한 자바빈(JavaBean) 클래스인 Prseon을 정의하자. Person에는 name이라는 프로퍼티(Property)만 들어있다.

/* 자바 */
public class Person {
    private final String name;

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

    public String getName() {
        return name;
    }
}

필드가 둘 이상으로 늘어나면 생성자인 Person(String name)의 본문에서 파라미터를 이름이 같은 필드에 대입하는 대입문의 수도 늘어난다. 자바에서는 생성자 본문에 이 같은 코드가 반복적으로 들어가는 경우가 많다. 코틀린에서는 그런 필드 대입 로직을 훨씬 더 적은 코드로 작성할 수 있다.

자바 - 코틀린 변환기를 써서 방금 Person 클래스를 코틀린으로 변환해보자.

class Person(val name: String)

멋지다. 다른 최신 JVM 언어에서 이와 비슷한 클래스 정의를 이미 본 독자도 있을 것이다. 이런 유형의 클래스(코드가 없이 데이터만 저장하는 클래스)를 값 객체(value Object)라 부르며, 다양한 언어가 값 객체를 간결하게 기술할 수 있는 구문을 제공한다.

자바를 코틀린으로 변환한 결과, public 가시성 변경자(visibility modifier)가 사라졌음을 확인하라, 코틀린의 기본 가시성은 public이므로 이런 경우 변경자를 생략해도된다.

프로퍼티

클래스라는 개념의 목적은 데이터를 캡슐화(encapsulate)하고 캡슐화한 데이터를 다루는 코드를 한 주체 아래 가두는 것이다. 자바에서는 데이터를 필드(field)에 저장하며, 멤버 필드의 가시성은 보통 비공개(private)다. 클래스는 자신을 사용하는 클라이언트가 그 데이터에 접근하는 통로로 쓸 수 있는 접근자 메소드(accessor method)`를 제공한다. 보통은 필드를 읽기 위한 게터(getter)를 제공하고 필드를 변경하게 허용해야 할 경우 세터(setter)를 추가 제공할 수 있다. 이런 예를 Person 클래스에서도 볼 수 있다. 세터는 자신이 받은 값을 검증하거나 필드 변경을 다른 곳에 통지하는 등의 로직을 더 가질 수 있다.

자바에서는 필드와 접근자를 한데 묶어 프로퍼티(property)라고 부르며, 프로퍼티라는 개념을 활용하는 프레임워크가 많다. 코틀린은 프로퍼티를 언어 기본 기능으로 제공하며, 코틀린 프로퍼티는 자바의 필드와 접근자 메서드를 완전히 대신한다. 클래스에서 프로퍼티를 선언할 때는 앞에서 살펴본 변수를 선언하는 방법과 마찬가지로 val이나 var를 사용한다. val로 선언한 프로퍼티는 읽기 전용이며, var로 선언한 프로퍼티는 변경 가능하다.

class Person {
    val name: String,  // 읽기전용 프로퍼티로, 코틀린은(비공개) 필드와 필드를 읽는 단순한 (공개) 게터를 만들어낸다. 
    var isMarried: Boolean // 쓸 수 있는 프로퍼티로, 코틀린은 (비공개) 필드, (공개) 게터, (공개) 세터를 만들어낸다. 
}

기본적으로 코틀린에서 프로퍼티를 선언하는 방식은 프로퍼티와 관련 있는 접근자를 선언하는 것이다(읽기 전용 프로퍼티의 경우 게터만 선언하며 변경할 수 있는 프로퍼티의 경우 게터와 세터를 모두 선언한다). 코틀린은 값을 저장하기 위한 비공개 필드와 그 필드에 값을 저장하기 위한 세터, 필드의 값을 읽기 위한 게터로 이뤄진 간단한 디폴트 접근자 구현을 제공한다.

위의 코드는 간결한 Person 클래스 정의 뒤에는 원래의 자바 코드와 똑같은 구현이 숨어있다. Person에는 비공개 필드가 들어있고, 생성자가 그 필드를 초기화하며, 게터를 통해 그 비공개 필드에 접근한다. 이는 어떤 언어로 정의했느냐와 관계없이 자바 클래스와 코틀린 클래스를 동일한 방식으로 사용할 수 있다는 뜻이다. 사용하는쪽의 코드는 완전히 똑같다. 다음은 Person을 자바 코드에서 사용하는 방법을 보여준다.

/* 자바 */
Person person = new Person("Bob", true);
System.out.println(person.getName());
Bob
System.out.println(person.isMarried());
true

자바와 코틀린에서 정의한 Person 클래스 중 어느 쪽을 사용해도 이 코드를 바꿀 필요가 없다는 사실을 기억하라. 코틀린의 name 프로퍼티를 자바 쪽에서는 getName이라는 이름으로 부를 수 있다. 게터와 세터의 이름을 정하는 규칙에는 예외가 있다. 이름이 is로 시작하는 프로퍼티의 게터에는 get이 붙지 않고 원래 이름을 그대로 사용하며, 세터에는 is를 set으로 바꾼 이름을 사용한다. 따라서 자바에서 isMarried 프로퍼티의 게터를 호출하려면 isMarried()를 사용해야 한다.

코틀린에서는 게터를 호출하신 대신 프로퍼티를 직접 사용한다. 로직은 동일하지만 코드는 더 간결해졌다. 변경 가능한 프로퍼티의 세터도 마찬가지 방식으로 동작한다. 자바에서는 person.setMarried(false)로 어떠 사람이 이혼했다는 사실을 기록하지만, 코틀린에서는 person.isMarried = false를 쓴다.

[Tip] 자바에서 선언한 클래스에 대해 코틀린 문법을 사용해도 된다. 코틀린에서는 자바 클래스의 게터를 val 프로퍼티처럼 사용할 수 있고, 게터 / 세터 쌍이 있는 경우에는 var 프로퍼티처럼 사용할 수 있다. 예를 들어 setName과 getName이라는 접근자를 제공하는 자바 클래스를 코틀린에서 사용할 때는 name이라는 프로퍼티를 사용할 수 있다. 자바 클래스가 isMarried와 setMarried 메서드를 제공한다면 그에 상응하는 코틀린 프로퍼티의 이름은 isMarried다.

대부분의 프로퍼티에는 그 프로퍼티의 값을 저장하기 위한 필드가 있다. 이를 프로퍼티를 뒷받침하는 필드(backing field)라고 부른다. 하지만 원한다면 프로퍼티의 값을 그때그때 계산(예를 들어 다른 프로퍼티들로부터 값을 계산할 수도 있다)할 수도 있다. 커스텀 게터를 작성하면 그런 프로퍼티를 만들 수 있다.

커스텀 접근자

이번 절에서는 프로퍼티의 접근자를 직접 작성하는 방법을 보여준다. 직사각형 클래스인 Rectangle을 정의하면서 자신이 정사각형인지 알려주는 기능을 만들어보자. 직사각형이 정사각형인지를 별도의 필드에 저장할 필요가 없다. 사각형의 너비와 높이가 같은지 검사하면 정사각형 여부를 그때그때 알 수 있다.

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

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

블록을 본문으로 하는 복잡한 구문을 꼭 사용하지 않아도 좋다. 이런 경우 get() = height == width라고 해도 된다. 커스텀 게터를 사용하는 프로퍼티도 앞에서 살펴본 프로퍼티와 마찬가지 방식으로 사용할 수 있다.

val rectangle = Rectangle(41, 43)
println(rectangle.isSquare)
false

이 접근자를 자바에서 사용하려면 isSquare 메서드를 호출하면 된다.
파라미터가 없는 함수를 정의하는 방식과 커스텀 게터를 정의하는 방식 중 어느 쪽이 더 나은지 궁금한 독자도 있을 것이다. 두 방식 모두 비슷하다. 구현이나 성능상 차이는 없다. 차이가 나는 부분은 가독성뿐이다. 일반적으로 클래스의 특성(프로퍼티에는 특성이라는 뜻이 있다)을 정의하고 싶다면 프로퍼티로 그 특성을 정의해야 한다. (4장에서 클래스와 프로퍼티에 대한 예제를 더 보여주고 생성자를 명시적으로 선언하는 문법을 다룬다)

코틀린 소스코드 구조: 디렉터리와 패키지

자바의 경우 모든 클래스를 패키지 단위로 관리한다는 사실을 잘 알고 있을 것이다. 코틀린에도 자바와 비슷한 개념의 패키지가 있다. 모든 코틀린 파일의 맨 앞에 package 문을 넣을 수 있다. 그러면 그 파일 안에 있는 모든 선언(클래스, 함수, 프로퍼티 등)이 해당 패키지에 들어간다. 같은 패키지에 속해 있다면 다른 파일에서 정의한 선언일지라도 직접 사용할 수 있다. 반면 다른 패키지에 정의한 선언을 사용하려면 임포트를 통해 선언을 불러와야 한다. 자바와 마찬가지로 임포트문은 파일의 맨 앞에 와야하며 import 키워드를 사용한다. 다음은 패키지 선언과 임포트문을 보여주는 예제다.

package geometry.shape

import java.util.Random

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

fun createRandomRectangle() : Rectangle {
    val random = Random()
    return Rectangle(random.nextInt(), random.nextInt())
}

코틀린에서는 클래스 임포트와 함수 임포트에 차이가 없으며, 모든 선언을 import 키워드로 가져올 수 있다. 최상위 함수는 그 이름을 써서 임포트할 수 있다.

package geometry.example

import geometry.shapes.createRandomRectangle // 이름으로 함수 임포트하기

fun main(args: Array<String>) {
    println(createRandomRectangle().isSquare) // "true"가 아주 드물게 출력된다.
}

패키지 이름 뒤에 .*를 추가하면 패키지 안의 모든 선언을 임포트할 수 있다. 이런 스타 임포트(star import)를 사용하면 패키지 안에 있는 모든 클래스뿐 아니라 최상위에 정의된 함수나 프로퍼티까지 모두 불러온다는 점에 유의하라. 위의 코드에서 구체적인 임포트문 대신 import geometry.shapes.*를 사용해도 컴파일에 아무 문제가 없다.

자바에서는 패키지의 구조와 일치하는 디렉터리 계층 구조를 만들고 클래스의 소스코드를 그 클래스가 속한 패키지와 같은 디렉터리에 위치시켜야 한다. 예를 들어 shapes라는 패키지 안에 일부 클래스가 들어있다면 각각의 클래스를 자신의 이름과 똑같은 자바 파일로 저장하되 그 모든 파일을 shapes 디렉터리 안에 넣어야 한다.

코틀린에서는 여러 클래스를 한 파일에 넣을 수 있고, 파일의 이름도 마음대로 정할 수 있다. 코틀린에서는 디스크상의 어느 디렉터리에 소스코드 파일을 위치시키든 관계없다. 따라서 원하는 대로 소스코드를 구성할 수 있다. 예를 들어 geometry.shapes라는 패키지가 있다면 그 패키지의 모든 내용을 shapes.kt라는 파일에 넣고, 하위 패키지에 해당하는 별도의 디렉터리를 만들지 않고 geometry라는 폴더 안에 shapes.kt를 넣어도 된다.

하지만 대부분의 경우 자바와 같이 패키지별로 디렉터리를 구성하는 편이 낫다. 특히 자바와 코틀린을 함께 사용하는 프로젝트에서는 자바의 방식을 따르는 게 중요하다. 자바의 방식을 따르지 않으면 자바 클래스를 코틀린 클래스로 마이그레이션할 때 문제가 생길 수도 있다. 하지만 여러 클래스를 한 파일에 넣는 것을 주저해서는 안 된다. 특히 각 클래스를 정의하는 소스 코드 크기가 아주 작은 경우 더욱 그렇다(코틀린에서는 클래스 소스코드 크기가 작은 경우가 자주 있다).

선택 표현과 처리: enum과 when

이번 절에서는 코틀린의 구성 요소 중 when에 대해 설명한다. when은 자바의 switch를 대체하되 훨씬 더 강력하며, 앞으로 더 자주 사용할 프로그래밍 요소라고 생각할 수 있다. when에 대해 설명하는 과정에서 코틀린에서 enum을 선언하는 방법과 스마트 캐스트(smart cast)에 대해서도 살펴본다.

enum 클래스 정의

색을 표현하는 enum을 하나 정의하자.

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

enum은 자바 선언보다 코틀린 선언에 더 많은 키워드를 써야 하는 흔치 않은 예다. 코틀린에서는 enum class를 사용하지만 자바에서는 enum을 사용한다. 코틀린에서 enum은 소프트 키워드(soft keyword)라 부르는 존재다. enum은 class 앞에 있을 때는 특별한 의미를 지니지만 다른 곳에서는 이름에 사용할 수 있다. 반면 class는 키워드다. 따라서 class라는 이름을 사용할 수 없으므로 클래스를 표현하는 변수 등을 정의할 때는 clazz나 aaClass와 같은 이름을 사용해야 한다.

자바와 마찬가지로 enum은 단순히 값만 열거하는 존재가 아니다. 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 // enum 클래스 안에서 메서드를 저의한다. 
}
>>> println(Color.BLUE.rgb())
255

enum에서도 일반적인 클래스와 마찬가지로 생성자와 프로퍼티를 선언한다. 각 enum 상수를 정의할 때는 그 상수에 해당하는 프로퍼티 값을 지정해야만 한다. 이 예제에서는 코틀린에서 유일하게 새미콜론(;)이 필수인 부분을 볼 수 있다. enum 클래스 안에 메서드를 정의하는 경우 반드시 enum 상수 목록과 메서드 정의 사이에 세미콜론을 넣어야 한다. 이제 enum 상수로 할 수 있는 멋진 일을 살펴보자.

when으로 enum 클래스 다루기

무지개의 각 색에 대해 그와 상응하는 연상 단어를 짝지어주는 함수가 필요하다고 상상해보자(그리고 그 연상 단어 정보를 enum 안에 저장하지 않는다고 하자). 자바라면 switch문으로 그런 함수를 작성할 수 있다. switch에 해당하는 코틀린 구성 요소는 when이다.

if와 마찬가지로 when도 값을 만들어내는 식이다. 따라서 식이 본문인 함수에 when을 바로 사용할 수 있다. 2장의 앞부분에서 함수에 대해 이야기할 때 여러 줄 식을 본문으로 하는 함수를 나중에 보여준다고 약속했는데, 여기에 바로 그 함수가있다.

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"
    }

앞의 코드는 color로 전달된 값과 같은 분기를 찾는다. 자바와 달리 각 분기의 끝에 break를 넣지 않아도 된다(자바에서는 break를 빼먹어서 오류가 생기는 경우가 자주 있다). 성공적으로 매치되는 분기를 찾으면 switch는 그 분기를 실행한다. 한 분기 안에서 여러 값을 매치 패턴으로 사용할 수도 있다. 그럴 경우 값 사이클 콤마(,)로 분리한다.

한 when 분기 안에 여러 값을 사용하기

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

지금까지 살펴본 두 예제에서는 Color.YELLOW처럼 Color라는 enum 클래스 이름을 enum 상수 이름 앞에 붙인 전체 이름을 사용했다. 상수 값을 임포트하면 이 코드를 더 간단하게 만들 수 있다.

enum 상수 값을 임포트해서 enum 클래스 수식자 없이 enum 사용하기

import ch02.colors.Color // 다른 패키지에서 정의한 Color 클래스를 임포트한다. 
import ch02.colors.Color.* // 짧은 이름으로 사용하기 위해 enum 상수를 모두 임포트한다. 

fun getWarmth(color: Color) = when(color) {
    RED, ORANGE, YELLOW -> "warm" // 임포트한 enum 상수를 이름만으로 사용한다. 
    GREEN -> "neutral"
    BLUE, INDIGO, VIOLET -> "cold"
}

when과 임의의 객체를 함께 사용

코틀린에서 when은 자바의 switch보다 훨씬 더 강력하다. 분기 조건에 상수(enum 상수나 숫자 리터럴)만 사용할 수 있는 자바 switch와 달리 코틀린 when의 분기 조건은 임의의 객체를 허용한다. 두 색을 혼합했을 때 미리 정해진 팔레트에 들어있는 색이 될 수 있는지 알려주는 함수를 작성하자. 팔레트에 있는 색을 조합할 수 있는 방법이 많지 않기 때문에 모든 경우를 쉽게 열거할 수 있다.

리스트 2.15 when의 분기 조건에 여러 다른 객체 사용하기

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("Dirty color") // 매치되는 분기 조건이 없으면 이 문장을 실행한다. 
    }

>>> println(mix(BLUE, YELLOW))
GREEN

c1과 c2가 RED와 YELLOW라면(또는 반대라면) 그 둘을 혼합한 결과는 ORANGE다. 이를 구현하기 위해 집합 비교를 사용한다. 코틀린표준 라이브러리에는 인자로 전달받은 여러 객체를 그 객체들을 포함하는 집합인 Set 객체로 만드는 setOf라는 함수가 있다. 집합(set)은 원소가 모여있는 컬렉션으로, 각 원소의 순서는 중요하지 않다. 이는 우리가 검사하려는 성질과 일치한다.

when 식은 인자 값과 매치하는 조건 값을 찾을 때까지 각 분기를 검사한다. 여기서는 setOf(c1, c2)와 분기 조건에 있는 객체 사이를 매치할 때 동등성(equility)을 사용한다. 그러므로 앞의 코드는 처음에는 setOf(c1, c2)와 setOf(REDm YELLOW)를 비교하고, 그 둘이 같지 않으면 계속 다음 분기의 조건 객체와 setOf(c1, c2)를 차례로 비교하는 식으로 작동한다. 모든 분기 식에서 만족하는 조건을 찾을 수 없다면 else 분기의 문장을 계산한다.

when의 분기 조건 부분에 식을 넣을 수 있기 때문에 많은 경우 코드를 더 간결하고 아름답게 작성할 수 있다. 이 예제에서는 조건에서 동등성을 검사했다(코틀린 1.3부터는 when()의 괄호 안에서 검사 대상을 변수에 포획해서 새로운 이름으로 부를 수 있다) 다음 예제에서는 임의의 불리언(Boolean)식을 조건으로 사용하는 모습을 살펴본다.

인자 없는 when 사용

앞의 리스트 2.15가 약간 비효율적이라는 것을 쉽게 알 수 있을 것이다. 이 함수는 호출될 때마다 함수 인자로 주어진 두 색이 when의 분기 조건에 있는 다른 두 색과 같은지 비교하기위해 여러 Set 인스턴스를 생성한다. 보통은 이런 비효율성이 크게 문제가 되지 않는다.

하지만 이 함수가 자주 호출된다면 불필요한 가비지 객체가 늘어나는 것을 방지하기 위해 함수를 고쳐 쓰는 편이 낫다. 인자가 없는 when 식을 사용하면 불필요한 객체 생성을 막을 수 있다. 코드는 약간 읽기 어려워지지만 성능을 더 향상시키기 위해 그 정도 비용은 감수해야 하는 경우도 자주 있다.

리스트 2.16 인자가 없는 when

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("Dirty color")
    }

>>> println(mixOptimized(BLUE, YELLOW))
GREEN

when에 아무 인자도 없으려면 각 분기의 조건이 불리언 결과를 계산하는 식이어야 한다. mixOptimized 함수는 앞에서 살펴본 mix 함수와 같은 일을 한다. mixOptimized는 추가 객체를 만들지 않는다는 장점이 있지만 가독성은 더 떨어진다.

이제 when을 사용하는 과정에서 스마트 캐스트가 쓰이는 예를 살펴보자.

스마트 캐스트: 타입 검사와 타입 캐스트를 조합

이번 절에서 사용할 예제로 (1 + 2) + 4 와 같은 간단한 산술식을 계산하는 함수를 만들어보자. 함수가 받을 산술식에서는 오직 두 수를 더하는 연산만 가능하다. 다른 연산(뺄셈, 곱셈, 나눗셈)도 비슷한 방식으로 구현할 수 있다. 연습문제 삼아 한번 풀어볼 것을 권장한다.

우선 식을 인코딩하는 방법을 생각해야한다. 식을 트리 구조로 저장하자. 노드는 합계(Sum)나 수(Num) 중 하나다. Num은 항상 말단(leaf 또는 terminal) 노드지만, Sum은 자식이 둘 있는 중간(non-terminal) 노드다. Sum노드의 두 자식은 덧셈의 두 인자다. 다음 리스트는 식을 표현하는 간단한 클래스를 보여준다. 식을 위한 Expr은 아무 메서드도 선언하지 않으며, 단지 여러 타입의 식 객체를 아우르는 공통 타입 역할만 수행한다. 클래스가 구현하는 인터페이스를 지정하기 위해서 콜론(:) 뒤에 인터페이스 이름을 사용한다.

리스트 2.17 식을 표현하는 클래스 계층

interface Expr
class Num(val value: Int) : Expr // value라는 프로퍼티만 존재하는 단순한 클래스로 Expr 인터페이스를 구현한다. 
class Sum(val left: Expr, val right: Expr) : Expr // Expr 타입의 객체라면 어떤 깃이나 Sum 연산의 인자가 될 수 있다. 따라서 Num이나 다른 Sum이 인자로 올 수 있다. 

Sum은 Expr의 왼쪽과 오른쪽 인자에 대한 참조를 left와 right 프로퍼티로 저장한다. 이 예제에서 left와 right는 각각 Num이나 Sum일 수 있다. (1 + 2) + 4라는 식을 저장하면 Sum(Sum(Num(1), Num(2)), Num(4)) 라는 구조의 객체가 생긴다.

아래의 그림 2.4는 이런 트리 표현을 보여준다.
image

이제 식의 값을 어떻게 계산하는지 살펴보자. 앞에서 살펴본 예를 평가한 값은 7이어야 한다.

>>> println (eval(Sum(Sum(Num(1), Num(2)), Num(4)))
7

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

  • 어떤 식이 수라면 그 값을 반환한다.
  • 어떤 식이 합계라면 좌항과 우항의 값을 계산한 다음에 그 두 값을 합한 값을 반환한다.

자바 스타일로 작성한 함수를 먼저 살펴본 다음 코틀린 스타일로 만든 함수를 살펴보자. 자바였다면 조건을 검사하기 위해 if문을 사용했을 것이다. 따라서 코틀린에서 if를 써서 자바 스타일로 함수를 작성해보자.

리스트 2.18 if 연쇄를 사용해 식을 계산하기

fun eval(e: Expr) : Int {
    if (e is Num) {
        val n = e as Num // 여기서 Num으로 타입을 변환하는데, 이는 불필요한 중복이다. 
        return n.value
    }
    if (e is Sum) {
        return eval(e.right) + eval("Unknown expression") // 변수 e에 대해 스마트 캐스트를 사용한다. 
    }
    throw IllegalArgumentException("Unknown expression")
}
>>> println(eval(Sum(Sum(Num(1), Num(2)), Num(4)))
7

코틀린에서는 is를 사용해 변수 타입을 검증한다. C#을 아는 개발자라면 is가 낯익을 것이다. is 검사는 자바의 instanceof와 비슷하다. 하지만 자바에서 어떤 변수의 타입을 instanceof로 확인한 다음에 그 타입에 속한 멤버에 접근하기 위해서는 명시적으로 변수 타입을 캐스팅해야 한다. 이런 멤버 접근을 여러 번 수행해야 한다면 변수에 따로 캐스팅한 결과를 저장한 후 사용해야 한다. 코틀린에서는 프로그래머 대신 컴파일러가 캐스팅을 해준다. 어떤 변수가 원하는 타입인지 일단 is로 검사하고 나면 굳이 변수를 원하는 타입으로 캐스팅하지 않아도 마치 처음부터 그 변수가 원하는 타입으로 선언된 것처럼 사용할 수 있다. 하지만 실제로는 컴파일러가 캐스팅을 수행해준다. 이를 스마트 캐스트(smart cast)라고 부른다.

eval 함수에서 e의 타입이 Num인지 검사한 다음 부분에서 컴파일러는 e의 타입을 Num으로 해석한다. 그렇기 때문에 Num의 프로퍼티인 value를 명시적 캐스팅 없이 e.value로 사용할 수 있다. Sum의 프로퍼티인 right와 left도 마찬가지다. Sum 타입인지 검사한 다음부터는 e.right와 e.left를 사용할 수 있다. IDE를 사용하면 스마트 캐스트 부분의 배경색을 달리 표시해주므로 이런 변환이 자동으로 이뤄졌음을 쉽게 알 수 있다.

스마트는 캐스트는 is로 변수에 든 값의 타입을 검사한 다음에 그 값이 바뀔 수 없는 경우에만 작동한다. 예를 들어 앞에서 본 예제처럼 클래스의 프로퍼티에 대해 스마트 캐스트를 사용한다면 그 프로퍼티는 반드시 val이어야 하며 커스텀 접근자를 사용하는 경우에는 해당 프로퍼티에 대한 접근이 항상 같은 값을 내놓는다고 확신할 수 없기 때문이다. 원하는 타입으로 명시적으로 타입 캐스팅하려면 as 키워드를 사용한다.

val n = e as Num

이제 eval 함수를 리팩토링해서 더 코틀린다운 코드로 만드는 방법을 살펴보자.

리팩토링: if를 when으로 변경

코틀린의 if와 자바의 if는 어떻게 다를까? 이미 그 차이에 대해 살펴봤다. 2장을 시작하면서 코틀린 if 식을 자바 3항 연산자처럼 쓴 예제를 살펴봤다. 코틀린의 if (a > b) a else b 는 자바의 a > b ? a : b처럼 작동한다. 코틀린에서는 if가 값을 만들어내기 때문에 자바와 달리 3항 연산자가 따로 없다. 이런 특징을 사용하면 eval 함수에서 return문과 중괄호를 없애고 if식을 본문으로 사용해 더 간단하게 만들 수 있다.

리스트 2.19 값을 만들어내는 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("Unknown expression")
    }
>>> println(eval(Sum(Num(1), Num(2)))
3

if의 분기에 식이 하나밖에 없다면 중괄호를 생략해도 된다. if 분기에 블록을 사용하는 경우 그 블록의 마지막 식이 그 분기의 결과 값이다.
이 코드를 when을 사용해 더 다듬을 수도 있다.

리스트 2.20 if 중첩 대신 when 사용하기

fun eval(e: Expr): Int = 
    when (e) {
        is Num ->
            e.value
        is Sum -> 
            eval(e.right) + eval(e.left)
        else ->
            throw IllegalArgumentException("Unknown expression")
    }

when 식을 앞에서 살펴본 값 동등성 검사가 아닌 다른 기능에도 쓸 수 있다. 이 예제는 받은 값의 타입을 검사하는 when 분기를 보여준다. 리스트 2.19의 if 예제와 마찬가지로 타입을 검사하고 나면 스마트 캐스트가 이뤄진다. 따라서 Num이나 Sum의 멤버에 접근할 때 변수를 강제로 캐스팅할 필요가 없다.

when과 if 식을 사용한 eval을 서로 비교해보고, when으로 if를 대신할 수 있는 경우가 언제인지 생각해보라. if나 when의 각 분기에서 수행해야 하는 로직이 복잡해지면 분기 본문에 블록을 사용할 수 있다. 블록을 사용한 if와 when을 한번 살펴보자.

if와 when의 분기에서 블록 사용

if나 when 모두 분기에 블록을 사용할 수 있다. 그런 경우 블록의 마지막 문장이 블록 전체의 결과가 된다. 예제로 봤던 eval 함수에 로그를 추가하고 싶다면 각 분기를 블록으로 만들고 블록의 맨 마지막에 그 분기의 결과 값을 위치시키면 된다.

리스트 2.21 분기에 복잡한 동작이 들어가 있는 when 사용하기

fun evalWithLogging(e: Expr): Int = 
    when (e) {
        is Num -> {
            println("num: ${e.value}")
            e.value // 이 식이 블록의 마지막 식이므로 e의 타입이 Num이면 e.value가 반환된다.
        }
        is Sum -> {
            val left = evalWithLogging(e.left)
            val right = evalWithLogging(e.right)
            println("sum: $left + $right")
            left + right // e의 타입이 Sum이면 이 식의 값이 반환된다. 
        }
        else -> throw IllegalArgumentException("Unknown expression")
}

이제 evalWithLogging 함수가 출력하는 로그를 보면 연산이 이뤄진 순서를 알 수 있다.

>>> println(evalWithLogging(Sum(Sum(Num(1), Num(2)), Num(4))))
num: 1
num: 2
sum: 1 + 2
num: 4
sum: 3 + 4
7

'블록의 마지막 식이 블록의 결과'라는 규칙은 블록이 값을 만들어내야 하는 경우 항상 성립한다. 2장의 마지막에는 try 본문과 catch 절에서 마찬가지 규칙이 쓰이는 예가 있으며, 5장에서는 람다 식에서 이 규칙이 어떻게 쓰이는지 설명한다. 하지만 2.2절에서 설명한 대로 이 규칙은 함수에 대해서는 성립하지 않는다. 식이 본문인 함수는 블록을 본문으로 가질 수 없고 블록이 본문인 함수는 내부에 return문이 반드시 있어야 한다. 지금까지 코틀린에서 여러 선택 중 하나를 고르는 올바른 방법을 배웠다. 이제는 어떤 대상을 이터레이션(iteration)하는 방법을 살펴보자.

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

2장에서 설명한 코틀린 특성 중 자바와 가장 비슷한 것이 이터레이션이다. 코틀린 while 루프는 자바와 동일하므로 간략하게 다루고 넘어간다. for는 자바의 for-each 루프에 해당하는 형태만 존재한다. 코틀린의 for는 C#과 마찬가지로 for <아이템> in <원소들> 형태를 취한다. 이런 for 루프는 자바에서와 마찬가지로 컬렉션에 대한 이터레이션에 가장 많이 쓰인다. 컬렉션을 다루는 경우를 포함하는 여러 반복 시나리오를 살펴보자.

while 루프

코틀린에는 while과 do-while 루프가 있다. 두 루프의 문법은 자바와 다르지 않다.

while (조건) { // 조건이 참인 동안 본문을 반복 실행한다.
    /*...*/
}

do {
    /*...*/
} while (조건) // 맨 처음에 무조건 본문을 한 번 실행한 다음, 조건이 참인 동안 본문을 반복 실행한다. 

이 두 루프를 매우 단순하며 코틀린에서 추가한 새로운 기능도 없다. 이제 주저하지말고 for 루프를 바로 살펴보자. 

### 수에 대한 이터레이션: 범위와 수열 
앞에서 설명했지만 코틀린에는 자바의 for 루프(어떤 변수를 초기화하고 그 변수를 루프를 한 번 실행할 때마다 갱신하고 루프 조건이 거짓이 될 때 반복을 마치는 형태의 루프)에 해당하는 요소가 없다. 이런 루프의 가장 흔한 용례인 초깃값, 증가 값, 최종 값을 사용한 루프를 대신하기 위해 코틀린에서는 범위(range)를 사용한다. 

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

`val oneToTen = 1..10`

코틀린의 범위는 폐구간(닫힌 구간) 또는 양끝을 포함하는 구간이다. 이는 두 번째 값(위 예에서는 10)이 항상 범위에 포함된다는 뜻이다. 
정수 범위로 수행할 수 있는 가장 단순한 작업은 범위에 속한 모든 값에 대한 이터레이션이다. 이런 식으로 어떤 범위에 속한 값을 일정한 순서로 이터레이션하는 경우 `수열`(progression)이라고 부른다. 

피즈버즈(Fizz-Buzz) 게임을 위해 정수 범위를 사용해보자. 피즈버즈 게임은 장거리 여행을 하면서 초등학교 이후 퇴화한 나눗셈 기술을 연마할 때 아주 좋다. 참가자는 순차적으로 수를 세면서 3으로 나눠떨어지는 수에 대해서는 피즈, 5로 나눠떨어지는 수에 대해서는 버즈라고 말해야 한다. 어떤 수가 3과 5로 모두 나눠떨어지면 '피즈버그'라고 말해야 한다. 

다음 리스트는 1부터 100까지의 피즈즈 결과를 보여준다. 인자가 없는 when을 사용해서 조건을 검사한다. 

> 리스트 2.22 when을 사용해 피즈버즈 게임 구현하기 

```kotlin 
fun fizzBuzz(i: Int) = when {
    i & 15 == 0 -> "FizzBuzz " // i가 15로 나눠떨어지면 FizzBuzz를 반환한다. 자바와 마찬가지로 %는 모듈로 연산자다.
    i % 3 == 0 -> "Fizz " // i가 3으로 나눠떨어지면 Fizz를 반환한다.
    i % 5 == 0 -> "Buzz "
    else -> "$i " // 다른 경우에는 그 수 자체를 반환한다. 
}
for (i in 1..100) {
    ... print(fizzBuzz(i))
    ...
}
1 2 Fizz 4 Buzz Fizz 7 ...

같은 게임을 너무 오래해서 지겨워졌으므로 더 어려운 게임을 하고 싶다. 이제는 100부터 거꾸로 세되 짝수만으로 게임을 진행해보자.

리스트 2.23 증가 값을 갖고 범위 이터레이션하기

>>> for (i in 100 downTo 1 step 2) {
... print(fizzBuzz(i))
... }
Buzz 98 Fizz 94 92 FizzBuzz 88 ...

여기서는 증가 값 step을 갖는 수열에 대해 이터레이션한다. 증가 값을 사용하면 수를 건너 뛸 수 있다. 증가 값을 음수로 만들면 정방향 수열이 아닌 역방향 수열을 만들 수 있다. 이 예제에서 100 downTo 1은 역방향 수열을 만든다(역방향 수열의 기본 증가 값은 -1이다). 그 뒤에 step 2를 붙이면 증가 값의 절댓값이 2로 바뀐다(이때 증가 값의 방향은 바뀌지 않는다. 따라서 증가 값은 실제로는 -2와 같다).

앞에서 언급한 대로 ..는 항상 범위의 끝 값(..의 우항)을 포함한다. 하지만 끝 값을 포함한다. 하지만 끝 값을 포함하지 않는 반만 닫힌 범위(half-closed range, 반폐구간 또는 반 개구간)에 대해 이터레이션하면 편할 때가 자주 있다. 그런 범위를 만들고 싶다면 until 함수를 사용하라. 예를 들어 for (x in 0 until size)라는 루프는 for (x in 0..size-1)과 같지만 좀 더 명확하게 개념을 표현한다. 나중에 3.4.3절에서 downTo, step, until에 대해 더 자세히 다룬다.

범위나 수열로 좀 더 복잡한 피즈버즈 게임을 만드는 방법을 살펴봤다. 이제 for 루프를 사용하는 예제를 더 살펴보자.

맵에 대한 이터레이션

앞 절에서 컬렉션에 대한 이터레이션을 위해 for .. in 루프를 자주 쓴다고 말했다. 그런 for 루프는 자바와 마찬가지로 작동하기 때문에 설명한 내용이 많지 않다. 대신 맵(map)에 대한 이터레이션을 살펴보자.

예제로 문자에 대한 2진 표현을 출력하는 프로그램을 살펴보자. 이때 2진 표현을 맵에 저장하자(단지 예제로 쓰기 위한 목적이다). 다음 코드는 맵을 만들고, 몇 글자에 대한 2진 표현으로 맵을 채운 다음, 그 맵의 내용을 출력한다.

리스트 2.24 맵을 초기화하고 이터레이션하기

val binaryReps = TreeMap<Char, String>() // 키에 대해 정렬하기 위해 TreeMap을 사용한다. 
for (c in 'A'..'F') { // A부터 F까지 문자의 범위를 사용해 이터레이션한다. 
    val binary = Integer.toBinaryString(c.toInt()) // 아스키 코드를 2진 표현으로 바꾼다. 
    binaryReps[c] = binary // c를 키로 c의 2진 표현을 맵에 넣는다. 
}

for ((letter, binary) in binaryReps) { // 멥에 대한 이터레이션한다. 맵의 키와 값을 두 변수에 각각 대입한다. 
    println("$letter = $binary")
}

.. 연산자를 숫자 타입의 값뿐 아니라 문자 타입의 값에도 적용할 수 있다. 'A'..'F'는 A부터 F에 이르는 문자를 모두 포함하는 범위를 만든다.
리스트 2.24는 for 루프를 사용해 이터레이션하려는 컬렉션의 원소를 푸는 방법을 보여준다(맵은 키/값 쌍을 원소로 하는 컬렉션이다). 원소를 풀어서 letter와 binary라는 두 변수에 저장한다. letter에는 키가 들어가고, binary에는 2진 표현이 들어간다. (7.4.1절에서 객체를 풀어서 각 부분을 분리하는 구조 분해 문법에 대해 자세히 다룬다) 리스트 2.24은 이 밖에도 키를 사용해 맵의 값을 가져오거나 키에 해당하는 값을 설정하는 멋진 코틀린 기능을 보여준다. get과 put을 사용하는 대신 map[key]나 map[key] = value 를 사용해 값을 가져오고 설정할 수 있다.

binaryReps[c] = binary 라는 코드는 binaryReps.put(c, binary) 라는 자바 코드와 같다.

출력은 다음과 비슷하다(출력을 두 열로 배열했다).

A = 100001 D = 1000100
B = 1000010 E = 1000101
C = 100011 F = 1000110

맵에 사용했던 구조 분해 구문을 맵이 아닌 컬렉션에도 활용할 수 있다. 그런 구조 분해구문을 사용하면 원소의 현재 인덱스를 유지하면서 컬렉션을 이터레이션할 수 있다. 인덱스를 저장하기 위한 변수를 별도로 선언하고 루프에서 매번 그 변수를 증가시킬 필요가 없다.

val list = arrayListOf("10", "11", "1001")
for ((index, element) in list.withIndex()) { // 인덱스와 함께 컬렉션을 이터레이션한다. 
    println("$index: $element")
}

이 코드는 예상대로 다음과 같은 결과를 출력한다.

0: 10
1: 11
2: 1001

withIndex의 정체에 대해서는 3장에서 살펴본다.
컬렉션이나 범위에 대해 in 키워드를 사용하는 방법을 살펴봤다. 한편 어떤 값이 범위나 컬렉션에 들어있는지 알고 싶을 때도 in을 사용한다.

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

in 연산자를 사용해 어떤 값이 범위에 속하는지 검사할 수 있다. 반대로 !in을 사용하면 어떤 값이 범위에 속하지 않는지 검사할 수 있다. 다음은 어떤 문자가 정해진 문자의 범위에 속하는지를 검사하는 방법을 보여준다.

리스트 2.25 in을 사용해 값이 범위에 속하는지 검사하기

fun isLetter(c: Char) = c in 'a'..'z' || c in 'A'..'Z'
fun isNoDigit(c: char) = c ! in '0'..'9'
>>>println(isNotDigit('x'))
true

어떻게 어떤 문자가 글자인지 검사하는 방법은 간단해 보인다. 내부적으로 교묘한 부분은 전혀 없다. 이렇게 코드를 작성해도 여전히 문자의 코드가 범위의 첫 번째 글자의 코드와 마지막 글자의 코드 사이에 있는지를 비교한다. 하지만 그런 비교 로직은 표준 라이브러리의 범위 클래스 구현 안에 깔끔하게 감춰져 있다. in과 !in 연산자를 when 식에서 사용해도 된다.

리스트 2.26 when에서 in 사용하기

fun recognize(c: Char) = when (c) {
    in '0'..'9' -> "It's a digit!" // c 값이 0 부터 9 사이에 있는지 검사한다. 
    in 'a'..'z', in 'A'..'Z' -> "It's a letter!" // 여러 범위 조건을 함께 사용해도 된다. 
    else -> "I don't knwo.."
}
>>>println(recognize('8'))
It's a digit!

범위는 문자에만 국한되지 않는다. 비교가 가능한 클래스라면(java.lang.Comparable 인터페이스를 구현한 클래스라면) 그 클래스의 인스턴스 객체를 사용해 범위를 만들 수 있다. Comparable을 사용하는 범위의 경우 그 범위 내의 모든 객체를 항상 이터레이션하지는 못한다. 예를 들어 'Java'와 'Kotlin' 사이의 모든 문자열을 이터레이션할 수 있을까? 그럴 수 없다. 하지만 in 연산자를 사용하면 값이 범위 안에 속하는지 항상 결정할 수 있다.

>>> println("Kotlin" in setOf("Java", "Scala")) // 이 집합에는 "Kotlin"이 들어있지 않다. 
false

(나중에 7.3.2절에서 범위나 수열과 우리가 직접 만든 데이터 타입을 함께 사용하는 방법에 대해 살펴보고 in 검사를 적용할 수 있는 객체에 대한 일반 규칙을 살펴본다)

2장에서 살펴보려는 코틀린 요소가 하나 더 있다. 바로 예외를 처리하는 문장이다.

코틀린의 예외 처리

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

코틀린의 기본 예외 처리 구문은 자바와 비슷하다. 예외를 던지는 방법은 전혀 놀랍지 않다.

if (percentage !in 0..100) {
    throw IllegalArgumentException(
        "A percentage value must be between 0 and 100: $percentage")
}

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

val percentage = if (number in 0..100)
                     number
                 else
                     throw IllegalArguementException( // 'throw' 는 식이다! 
                         "A percentage value must be between 0 and 100: $number")

이 예제에서는 if의 조건이 참이므로 프로그램이 정상 동작해서 percentage 변수가 number의 값으로 초기화된다. 하지만 조건이 거짓이면 변수가 초기화되지 않는다. throw를 식에 활용할 때의 기술 사항에 대해서는 6.2.6절에서 자세히 설명한다.

try, catch, finally

자바와 마찬가지로 예외를 처리하려면 try, catch, finally 절을 함께 사용한다. 파일에서 각 줄을 읽어 수로 변환하되 그 줄이 올바른 수 형태가 아니면 null을 반환하는 다음 예제에서 그 세 가지 요소를 모두 볼 수 있다.

리스트 2.27 자바와 마찬가지로 try 사용하기

fun readNumber(reader: BufferedReader): Int? { // 함수가 던질 수 있는 예외를 명시할 필요가 없다. 
    try {
        val line = reader.readLine()
        return Integer.parseInt(line)
    }
    catch (e: NumberFormatException) { // 예외 타입을 :의 오른쪽에 쓴다. 
        return null
    }
    finally { // "finally"는 자바와 똑같이 작동한다. 
        reader.close()
    }
}
>>> val reader = BufferedReader(StringReader("239"))
>>> println(readNumber(reader))
239

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

다른 최신 JVM 언어와 마찬가지로 코틀린도 체크 예외나 언체크 예외(unchecked exception)를 구별하지 않는다. 코틀린에서는 함수가 던지는 예외를 지정하지 않고 발생한 예외를 잡아내도 되고 잡아내지 않아도 된다. 실제 자바 프로그래머들이 체크 예외를 사용하는 방식을 고려해 이렇게 코틀린 예외를 설계했다. 자바는 체크 예외 처리를 강제한다. 하지만 프로그래머들이 의미 없이 예외를 다시 던지거나, 예외를 잡되 처리하지는 않고 그냥 무시하는 코드를 작성하는 경우가 흔하다. 그로 인해 예외 처리 규칙이 실제로는 오류 발생을 방지하지 못하는 경우가 자주 있다.

예로 들어 리스트 2.27에서 NumberFormatException은 체크 예외가 아니다. 따라서 자바 컴파일러는 NumberFormatException을 잡아내게 강제하지 않는다. 그에 따라 실제 실행 시점에 NumberFormatException이 발생하는 모습을 자주 볼 수 있다. 하지만 입력 값이 잘못되는 경우는 흔히 있는 일이므로 그런 문제가 발생한 경우 부드럽게 다음 단계로 넘어가도록 프로그램을 설계해야 한다는 점에서 이는 불행한 일이다. 동시에 BufferedReader.close는 IOException을 던질 수 있는데, 그 예외는 체크 예외이므로 자바에서는 반드시 처리해야 한다. 하지만 실제 스트림을 닫다가 실패하는 경우 특별히 스트림을 사용하는 클라이언트 프로그램이 취할 수 있는 의미 있는 동작은 없다. 그러므로 이 IOException을 잡아내는 코드는 그냥 불필요하다.

자바 7의 자원을 사용하는 try-with-resource는 어떨까? 코틀린은 그런 경우를 위한 특별한 문법을 제공하지 않는다. 하지만 라이브러리 함수로 같은 기능을 구현한다. (8.2.5절에서 그 방법을 살펴본다)

try를 식으로 사용

자바와 코틀린의 중요한 차이를 하나 더 살펴보기 위해 방금 살펴본 예제를 고쳐보자. finally 절을 없애고 파일에서 읽은 수를 출력하는 코드를 추가하자.

리스트 2.28 try를 식으로 사용하기

fun readNumber(reader: BufferedReader) {
    val number = try {
        Integer.parseInt(reader.readLine()) // 이 식의 값이 "try" 식의 값이 된다. 
    } catch (e: NumberFormatException) {
        return
    }
    println(number)
}
>>> val reader = BufferedReader(StringReader("not a number"))
>>> readNumber(reader) // 아무것도 출력되지 않는다. 

코틀린의 try 키워드는 if나 when과 마찬가지로 식이다. 따라서 try의 값을 변수에 대입할 수 있다. if와 달리 try의 본문을 반드시 중괄호 {}로 둘러싸야 한다. 다른 문장과 마찬가지로 try의 본문도 내부에 여러 문장이 있으면 마지막 식의 값이 전체 결과 값이다.

이 예제는 catch 블록 안에서 return 문을 사용한다. 따라서 예외가 발생한 경우 catch 블록 다음의 코드는 실행되지 않는다. 하지만 계속 진행하고 싶다면 catch 블록도 값을 만들어야 한다. 역시 catch 블록도 그 안의 마지막 식이 블록 전체의 값이된다. 다음은 그런 동작을 보여준다.

리스트 2.29 catch에서 값 반환하기

fun readNumber(reader: BufferedReader) {
    val number = try {
        Integer.parseInt(reader.readLine()) // 예외가 발생하지 않으면 이 값을 사용한다. 
    } catch (e: NumberFormatException) {
        null // 예외가 발생하면 null 값을 사용한다. 
    }
    println(nuimber)
}
>>> val reader = BufferedReader(StringReader("not a number"))
>>> readNumber(reader)
null // 예외가 발생했으므로 함수가 "null"을 출력한다. 

try 코드 블록의 실행이 정상적으로 끝나면 그 블록의 마지막 식의 값이 결과다. 예외가 발생하고 잡히면 그 예외에 해당하는 catch 블록의 값이 결과다. 리스트 2.29에서는 NumberFormatException이 발생하므로 함수의 결과 값이 null이다.

728x90
Kotlin/Kotlin In Action

1장 - 코틀린이란 무엇이며, 왜 필요한가?

728x90

코틀린(Kotlin)은 무엇인가? 코틀린은 자바 플랫폼에서도 돌아가는 새로운 프로그래밍 언어다.
코틀린은 간결하고 실용적이며, 자바 코드와의 상호운용성을 중시한다. 현재 자바를 사용 중인 곳이라면 거의 대부분 코틀린을 활용할 수 있다.
코틀린을 사용하는 대표적인 분야는 서버 개발, 안드로이드 앱 개발 등의 분야에서 사용한다.
코틀린은 기존 자바 라이브러리나 프레임워크와 함께 잘 작동하며, 성능도 자바와 같은 수준이다.

코틀린 맛보기

data class Person(val name: String, val age: Int? = null) // 데이터 클래스, 널이 될 수 있는 타입(Int?)과 파라미터 디폴트 값 

fun main(args: Array<String>) {
    val persons = listOf(Person("영화"), Person("철수", age = 29)) // 이름 붙인 파라미터 
    val oldest = persons.maxBy { it.age ?: 0 } // 람다 식과 엘비스 연산자 
    println("나이가 가장 많은 사람: $oldest") // 문자열 템플릿 
}

// 결과: 나이가 가장 많은 사람: Person(name=철수, age=29)

영희의 나이는 지정하지 않았기 때문에 null이 대신 쓰인다. 리스트에서 가장 나이가 많은 사람을 찾기 위해 maxBy 함수에 전달한 람다 식은 파라미터를 하나 받는다. it이라는 이름을 사용하면 (별도의 파라미터 이름을 정의하지 않아도) 람다 식의 유일한 인자를 사용할 수 있다. 엘비스 연산자(Elvis operator)라고 부르는 ?:는 age가 null인 경우 0을 반환하고 그렇지 않은 경우 age의 값을 반환한다. (오해를 살 수 있을 것 같은데, 위의 코드가 그렇다는 것이다) 영희의 나이를 지정하지 않았지만 엘비스 연산자가 null을 0으로 변환해주기 때문에 철수가 가장 나이가 많은 사람으로 선정될 수 있다.

코틀린의 주요 특성

코틀린을 통해 어떤 종류의 애플리케이션을 만들 수 있는지 살펴보자.

대상 플랫폼: 서버, 안드로이드 등 자바가 실행되는 모든 곳

코틀린의 주목적은 현재 자바가 사용되고 있는 모든 용도에 적합하면서도 더 간결하고 생산적이며 안전한 대체 언어를 제공하는 것이다. 자바는 아주 유명하며, 스마트카드(자바 카드 기술)로부터 구글, 트위터, 링크드인 등 세계적인 규모의 인터넷 기업이 활용 중인 큰 데이터 센터에 이르기까지 다양한 환경에서 사용되고 있다. 그런 환경 중 대다수는 코틀린을 도입하면 더 적은 코드로 더 편하게 프로그래머의 목표를 달성할 수 있을 것이다.

코틀린을 활용할 수 있는 가장 일반적인 영역은 다음과 같다.

  • 서버상의 코드(특히 웹 애플리케이션의 백엔드)
  • 안드로이드 디바이스에서 실행되는 모바일 애플리케이션

하지만 코틀린은 다른 환경에서도 잘 작동한다. 예를 들어 인텔의 멀티OS 엔진(Multi-OS Engine)을 사용하면 코틀린을 iOS 디바이스에서 실행할 수 있다. 데스크탑 애플리케이션을 작성하고 싶다면 코틀린과 토네이도FX, 자바FX 등을 함께 사용할 수 있다.
자바뿐 아니라 자바스크립트로도 코틀린을 컴파일할 수 있다. 따라서 코틀린 코드를 브라우저나 노드에서 실행할 수 있다. 젯브레인스(JetBrains)는 코틀린 네이티브 백엔드를 개발 중이다.

정적 타입 지정 언어

자바와 마찬가지로 코틀린도 정적 타입(statically typed) 지정 언어다. 정적 타입 지정이라는 말은 모든 프로그램 구성 요소의 타입을 컴파일 시점에 알 수 있고 프로그램 안에서 객체의 필드나 메소드를 사용할 때마다 컴파일러가 타입을 검증해준다는 뜻이다.

이런 점은 다른 동적 타입(dynamically typed) 지정 언어와는 다르다. JVM에서는 그루브(Groovy)나 JRuby가 대표적인 동적 타입 지정 언어다. 동적 타입 지정 언어에서는 타입과 관계없이 모든 값을 변수에 넣을 수 있고, 메소드나 필드 접근에 대한 검증이 실행 시점에 일어나며, 그에 따라 코드가 더 짧아지고 데이터 구조를 더 유연하게 생성하고 사용할 수 있다. 하지만 반대로 이름을 잘못 입력하는 등의 실수도 컴파일 시 걸러내지 못하고 실행 시점에 오류가 발생한다.

한편 자바와 달리 코틀린에서는 모든 변수의 타입을 프로그래머가 직접 명시할 필요가 없다. (자바도 11버전부터는 val와 같이 타입 추론을 할 수 있다)
대부분의 경우 코틀린 컴파일러가 문맥으로부터 변수 타입을 자동으로 유추할 수 있기 때문에 프로그래머는 타입 선언을 생략해도 된다. 가장 간단한 예는 아래와 같다.

var x = 1

여기서는 변수를 정의하면서 정수 값으로 초기화한다. 코틀린은 이 변수의 타입이 Int임을 자동으로 알아낸다. 컴파일러가 문맥을 고려해 변수 타입을 결정하는 이런 기능을 타입 추론(type inference)이라고 부른다.

정적 타입 지정의 장점은 다음과 같다.

  • 성능 : 실행 시점에 어떤 메소드를 호출할지 알아내는 과정이 필요 없으므로 메소드 호출이 더 빠르다.
  • 신뢰성 : 컴파일러가 프로그램의 정확성을 검증하기 때문에 실행 시 프로그램이 오류로 중단될 가능성이 더 적어진다.
  • 유지 보수성 : 코드에서 다루는 객체가 어떤 타입에 속하는지 알 수 있기 때문에 처음 보는 코드를 다룰 때도 더 쉽다.
  • 도구 지원 : 정적 타입 지정을 활용하면 더 안전하게 리팩토링할 수 있고, 도구는 더 정확한 코드 완성 기능을 제공할 수 있으며, IDE의 다른 지원 기능도 더 잘 만들 수 있다.

코틀린은 타입 추론을 지원하므로 정적 타입 지정 언어에서 프로그래머가 직접 타입을 선언해야 함에 따라 생기는 불편함이 대부분 사라진다.
코틀린의 타입 시스템을 더 자세히 살펴보면 우리가(=자바 개발자) 이미 잘 알고 있는 내용을 많이 발견할 수 있다.
클래스, 인터페이스, 제네릭은 모두 자바와 비슷하게 작동한다. 따라서 우리는 자바에 대해 아는 내용을 코틀린에서도 쉽게 적용할 수 있다.

하지만 몇 가지 새로운 점이 있다.

그중 가장 중요한 특징은 코틀린이 널이 될 수 있는 타입(nullable)을 지원한다는 점이다. 널이 될 수 있는 타입을 지원함에 따라 컴파일 시점에 NullPointerException가 발생할 수 있는지 여부를 검사할 수 있어서 좀 더 프로그램의 신뢰성을 높일 수 있다. 널이 될 수 있는 타입에 대해서는 뒤에서 간략히 살펴본다.

코트린의 타입 시스템에 있는 다른 새로운 내용으로는 함수 타입(function type)에 대한 지원을 들 수 있다. 함수 타입이 무엇인가 알아보기 위해 함수형 프로그래밍(Functional Programming)이 어떤 개념인지와 코틀린이 함수형 프로그래밍을 어떻게 지원하는지에 대해 먼저 알아보자.

함수형 프로그래밍과 객체지향 프로그래밍

자바 개발자라면 객체지향 프로그래밍의 핵심 개념을 잘 이해하고 있을 것이다. 하지만 그런 개발자에게도 함수형 프로그래밍은 새로운 개념일 수 있다.

  • 일급 시민인(first-class)함수 : 함수(프로그램의 행동을 나타내는 코드 조각)를 일반 값처럼 다룰 수 있다. 함수를 변수에 저장할 수 있고, 함수를 인자로 다른 함수에 전달할 수 있으며, 함수에서 새로운 함수를 만들어서 반환할 수 있다.
  • 불변성(immutability) : 함수형 프로그래밍에서는 일단 만들어지고 나면 내부 상태가 절대로 바뀌지 않는 불변 객체를 사용해 프로그램을 작성한다.
  • 부수 효과(side effect) 없음 : 함수형 프로그래밍에서는 입력이 같으면 항상 같은 출력을 내놓고 다른 객체의 상태를 변경하지 않으며, 함수 외부나 다른 바깥 환경과 상호작용하지 않는 순수 함수(pure function)를 사용한다.

함수형 스타일로 프로그램을 작성하면 어떤 장점이 있을까?
먼저 1.간결성을 들 수 있다. 함수형 코드는 그에 상응하는 명령형 코드에 비해 더 간결하며 우아하다. 함수를 값처럼 활용할 수 있으며 더 강력한 추상화(abstraction)를 할 수 있고 강력한 추상화를 사용해 코드 중복을 막을 수 있다.

예를들어 비슷한 작업을 수행하는 아주 비슷한 두 개의 코드 조각이 있지만 그 두 코드 조각은 일부 세부 사항에서 차이가 난다. 이 로직에서 공통부분을 따로 함수로 뽑아내고 서로 다른 세부 사항을 인자로 전달할 수 있다. 이런 인자는 그 자체가 함수이다. 하지만 람다 식(lambda expression)이라 불리는 무명 함수(anonymous function) 구문을 사용하면 간결하게 그런 함수를 표현할 수 있다.

fun findAlice() = findPerson { it.name == "Alice" } // findPerson에는 사람을 찾는 일반 로직이 들어가 있다. 
fun findBob() = findPerson { it.name == "Bob" } // 중괄호 { } 사이에 있는 코드 블록은 찾으려는 사람을 식별한다. 

2.다중 스레드를 사용해도 안전하다는 사실이다. 다중 스레드(멀티 스레드라는 표현을 많이 쓰는걸로 알고있다) 프로그램에서는 적절한 동기화 없이 같은 데이터를 여러 스레드가 변경하는 경우 가장 많은 문제가 생긴다. 불변 데이터 구조를 사용하고 순수 함수를 그 데이터 구조에 적용한다면 다중 스레드 환경에서 같은 데이터를 여러 스레드가 변경할 수 없다. 따라서 복잡한 동기화를 적용하지 않아도 된다.

  1. 함수형 프로그램은 테스트하기 쉽다. 부수 효과가 있는 함수는 그 함수를 실행할 때 필요한 전체 환경을 구성하는 준비 코드가 따로 필요하지만, 순수 함수는 그런 준비 코드 없이 독립적으로 테스트할 수 있다.

일반적으로 말하자면 언어와 관계없이 함수형 스타일을 활용할 수 있다. 물론 자바에서도 함수형 프로그래밍이 가능하다. 그리고 함수형 프로그래밍 스타일을 이루는 여러 요소는 좋은 프로그래밍 스타일이며, 장려할 만하다. 하지만 모든 언어가 함수형 프로그래밍을 편하게 사용하기에 충분한 라이브러리와 문법 지원을 제공하지는 않는다. 예를 들어 자바 8 이전의 자바에서는 함수형 프로그래밍을 지원할 수 있는 기능이 거의 없었다. 코틀린은 처음부터 함수형 프로그래밍을 풍부하게 지원해 왔다.

코틀린에서 제공하는 (함수형 프로그래밍을 위한) 지원은 아래와 같다.

  • 함수 타입을 지원함에 따라 어떤 함수가 다른 함수를 파라미터로 받거나 새로운 함수를 반환할 수 있다.
  • 람다 식을 지원함에 따라 번거로운 준비 코드를 작성하지 않아도 코드 블록을 쉽게 정의하고 여기저기 전달할 수 있다.
  • 데이터 클래스는 불변적인 값 객체(Value Object)를 간편하게 만들 수 있는 구문을 제공한다.
  • 코틀린 표준 라이브러리는 객체와 컬렉션을 함수형 스타일로 다룰 수 있는 API를 제공한다.

코틀린은 함수형 스타일로 프로그램을 짤 수 있게 지원하지만 함수형 프로그래밍 스타일을 강제하지는 않는다. 명령형 방식이 더 적합한 경우라면 함수형 프로그래밍으로 번거롭게 코드를 작성할 필요 없이 직접 변경 가능한 데이터와 부수 효과를 활용하는 함수를 사용해도 된다. 당연히 인터페이스와 클래스 계층 구조를 바탕으로 하는 프레임워크도 자바를 사용할 때와 마찬가지로 쉽게 쓸 수 있다. 코틀린으로 코드를 작성할 때는 객체지향과 함수형 접근 방법을 함께 조합해서 문제에 가장 적합한 도구를 사용하면 된다.

무료 오픈소스

코틀린 언어와 컴파일러, 라이브러리 및 코틀린과 관련된 모든 도구는 모두 오픈소스며, 어떤 목적에든 무료로 사용할 수 있다. 코틀린은 아파치(Apache) 2 라이선스하에 제공된다.

개발은 깃허브를 통해 이뤄지고 있으며, 코틀린 프로그래밍 커뮤니티의 기여에 대해 열려있다. 코틀린 애플리케이션을 개발하고 싶은 경우 인텔리J 아이디어 커뮤니티 에디션, 안드로이드 스튜디오, 이클립스 같은 오픈소스 IDE를 활용할 수 있다. (물론 나는 IntelliJ Ultimit 쓸 거지만...)

코틀린 응용

앞에서도 언급했지만, 코틀린은 주로 서버와 안드로이드 개발에서 코틀린이 널리 쓰이고 있다. 이 두 분야를 살펴보고 코틀린이 각 분야에 적합한 언어인 이유를 살펴보자.

코틀린 서버 프로그래밍

서버 프로그래밍은 상당히 광범위한 개념이다. 다음과 같은 응용 분야를 포함하는 여러 분야가 서버 프로그래밍에 포함된다.

  • 브라우저에 HTML 페이지를 돌려주는 웹 애플리케이션
  • 모바일 애플리케이션에서 HTTP를 통해 JSON API를 제공하는 백엔드(backend)애플리케이션
  • RPC(원격 프로시저 호출) 프로토콜을 통해 서로 통신하는 작은 서비스들로 이뤄진 마이크로서비스 (이건 뭔지 잘 모르겠다 🤔)

개발자들은 이런 애플리케이션을 수년간 자바로 개발해 오면서 이런 종류의 애플리케이션 개발에 도움을 줄 수 있는 기술과 프레임워크를 엄청나게 만들어왔다.
그런 애플리케이션을 독립적으로 맨 밑바닥부터 개발하는 경우는 거의 없다. 새로운 기술이나 프레임워크는 언제나 기존 프레임워크나 기술을 확장하고 개선하거나 대치하며, 이미 여러 해 동안 쓰여 온 기존 시스템과 새로운 코드를 통합해야만 한다.

이런 환경에서 자바 코드와 매끄럽게 상호운용할 수 있다는 점이 코틀린의 큰 장점이다. 코틀린은 새로운 컴포넌트를 작성하거나 기존 서비스 코드를 코틀린으로 이식해야하는 경우에 모두 잘 들어맞는다. 자바 클래스를 코틀린으로 확장해도 아무 문제가 없으며, 코틀린 클래스 안의 메소드나 필드에 특정 (자바) 애너테이션을 붙여야하는 경우에도 아무 문제가 없다. 그러면서도 시스템 코드는 더 간결해지고 더 신뢰성이 높아지며, 더 유지 보수하기 쉬워질 것이다.

동시에, 코틀린을 사용하면 몇 가지 새로운 기술을 활용해 서버 시스템을 개발할 수 있다. 예를 들어 코틀린의 빌더 패턴(Builder pattern)을 활용하면 간결한 구문을 사용해 객체로 이뤄진 그래프(graph)를 쉽게 구축하면서도 코틀린이 제공하는 완전한 추상화와 코드 재활용을 지속적으로 누릴 수 있다.

이런 특성을 사용하는 가장 간단한 예로 HTML 생성 라이브러리를 들 수 있다. HTML 생성 라이브러리는 외부 HTML 템플릿 라이브러리를 대신해 더 간결하면서 타입 검사를 완전히 지원하는 안전한 해법을 제공할 수 있다.

아래의 예를 보자.

fun renderPersonList (persons: Collection<Person>) = 
    createHTML().table(
        for (person in persons) {
            tr {
                td { +person.name }
                td { +person.age }
            }
        }
    }
}

이렇게 HTML 태그로 변환될 함수와 일반 코틀린 언어 기능을 쉽게 조합할 수 있다. 별도의 템플릿 언어를 사용할 필요가 없으므로 새로운 언어의 문법을 익힐 필요도 없다. HTML 페이지를 생성하면서 코틀린 루프 등의 일반적인 코틀린 기능을 모두 활용할 수 있기 때문이다.

코틀린이 제공하는 깔끔하고 간결한 DSL 기능을 활용할 수 있는 다른 예로는 영속성(persistence) 프레임워크를 들 수 있다. 예를 들어 익스포즈드(Exposed) 프레임워크는 SQL 데이터베이스의 구조를 기술할 수 있는 읽기 쉬운 DSL을 제공하며, 코틀린 코드만을 사용해 완전한 타입 검사를 지원하면서 데이터베이스 질의를 실행할 수 있다. 다음 코드는 익스포즈드에서 어떤 일이 가능한지 한 가지 예를 보여준다.

object CountryTable : IdTable() { // 데이터베이스 테이블에 대해 기술한다. 
    val name = varchar("name", 250).uniqueIndex()
    val iso = varchar("iso", 2).uniqueIndex()
}

class Country(id: EntityID) : Entity(id) { // 데이터베이스 엔티티(Entity)에 해당하는 클래스를 만든다. 
    var name: String by CountryTable.name
    var iso: String by CountryTable.iso
}

val russia = Country.find { // 오직 코틀린 코드만으로 이 데이터베이스에 질의를 던질 수 있다. 
    CountryTable.iso.eq("ru")
).first()

println(russia.name)

코틀린 안드로이드 프로그래밍

코틀린 학습의 이유가 안드로이드 앱 제작과는 거리가 있기 때문에 이하 생략...

코틀린의 철학

코틀린이 자바와의 상호운용성에 초점을 맞춘 실용적이고 간결하며 안전한 언어라고 설명하는 경우가 자주 있다. 그렇다면 실용성, 간결성, 안전성, 상호운용성은 각각 어떤 뜻일까? 각가에 대해 자세히 알아보자.

실용성

코틀린은 실제 문제를 해결하기 위해 만들어진 실용적인 언어다. 코틀린 설계는 대규모 시스템을 개발해본 다년간의 IT업계 경험을 바탕으로 이뤄졌으며, 수많은 소프트웨어 개발자들의 사용에 잘 들어맞을 수 있게 주의 깊게 언어 특성을 선택했다. 더 나아가 젯브레인즈나 코틀린 커뮤니티 내부의 개발자들이 다년간 코틀린 초기 버전을 사용하면서 전달한 피드백이 현재 발표된 최종 코틀린 버전에 반영돼 있다. 그런 이유로 실제 프로젝트에서 문제를 해결할 때 코틀린이 도움이 되리라고 자신 있게 말할 수 있다. 코틀린은 연구를 위한 언어가 아니다. 코틀린은 다른 프로그래밍 언어가 채택한 이미 성공적으로 검증된 해법과 기능에 의존한다. 이로 인해 언어의 복잡도가 줄어들고 이미 알고 있는 기존 개념을 통해 코틀린을 더 쉽게 배울 수 있다.

거기에 코틀린은 어느 특정 프로그래밍 스타일이나 패러다임을 사용할 것을 강제로 요구하지 않는다. 코틀린을 처음 배우는 사람은 자바에서 사용해 온 익숙한 프로그래밍 스타일이나 기법을 활용할 수 있다.

실용성에 있어 코틀린의 다른 측면은 도구를 강조한다는 점이다. 좋은 언어만큼이나 편리한 개발 환경도 생산성 향상에 필수적이다. 따라서 언어를 먼저 설계한 다음에 IDE 개발이 맞물려 이뤄져 왔다. 그리고 코틀린 언어의 특성은 항상 도구의 활용을 염두하고 설계돼 왔다.

코틀린의 여러 특성을 배울 때도 IDE의 코틀린 언어 지원이 중요한 역할을 한다. 흔히 쓰이지만 더 간결한 구조로 바꿀 수 있는 대부분의 코드 패턴을 도구가 자동으로 감지해서 수정하라고 제안한다. 이런 자동 수정 안내를 살펴보면서 코틀린 언어의 특성을 잘 이해하면 우리는 자신의 코드에 그런 특성을 적용하는 방법을 배울 수 있다.

간결성

개발자가 코드를 새로 작성하는 시간보다 기존 코드를 읽는 시간이 더 길다는 사실이 잘 알려져 있다. 일반적으로 규모가 큰 프로젝트에서는 버그가 발생하면 수정할 부분을 찾고 어떻게 고쳐야 할지 알아내려면 엄청난 양의 코드를 읽는 것이 당연하다. 버그와 관련된 여러 코드를 두루두루 살펴본 뒤에야 코드를 제대로 수정할 수 있을 것이다.

코드가 더 간단하고 간결할수록 내용을 파악하기가 더 쉽다. 물론 설계가 좋고 각 부분의 역할을 잘 표현해주는 적절한 이름이 붙어있다면 내용을 파악할 때 큰 도움이 된다. 그러나 어떤 언어를 사용해 코드를 작성했고 그 언어가 얼마나 간결한 언어인지도 중요하다. 어떤 언어가 간결하다는 말은 그 언어로 작성된 코드를 읽을 때 의도를 쉽게 파악할 수 있는 구문 구조를 제공하고, 그 의도를 달성하는 방법을 이해할 때 방해가 될 수 있는 부가적인 준비 코드가 적다는 뜻이다.

코틀린을 만들면서 프로그래머가 작성하는 코드에서 의미가 없는 부분을 줄이고, 언어가 요구하는 구조를 만족시키기 위해 별 뜻은 없지만 프로그램에 꼭 넣어야 하는 부수적인 요소를 줄이기 위해 많은 노력을 기울였다. getter, setter, 생성자 파라미터를 필드에 대입하기 위한 로직 등 자바에 존재하는 여러 가지 번거로운 준비 코드를 코틀린은 묵시적으로 제공하기 때문에 코틀린 소스코드는 그런 준비 코드로 인해 지저분해지는 일이 없다.

코드가 불필요하게 길어지는 또 다른 이유는 컬렉션에서 원소를 찾는 것과 같은 일반적인 작업을 수행하기 위해 명시적으로 작성해야만 하는 코드의 양이 상당하기 때문이다. 다른 최신 언어와 마찬가지로 코틀린은 기능이 다양한 표준 라이브러리를 제공하기 때문에 반복되거나 길어질 수 있는 코드를 라이브러리 함수 호출로 대치할 수 있다. 코틀린은 람다를 지원하기 때문에 작은 코드 블록을 라이브러리 함수에 쉽게 전달할 수 있다. 따라서 일반적인 기능을 라이브러리 안에 캡슐화하고 작업에 따라 달라져야하는 개별적인 내용을 사용자가 작성한 코드 안에 남겨둘 수 있다.

반면 코틀린 설계 목표에는 소스코드를 가능한 짧게 만든다는 내용은 들어있지 않다. 예를들어 코틀린은 연산자 오버로딩을 지원하지만, 언어가 제공하지 않는 연산자를 프로그래머가 정의할 수 있게 허용하지는 않는다.따라서 라이브러리 개발자들은 어떤 메소드의 이름을 암호문처럼 보이는 기호만으로 이뤄진 연산자 식별자로 대치할 수 없다. 기호로 된 이름보다는 단어로 이뤄진 이름이 훨씬 더 읽거나 검색하기 쉽다.

코드가 더 간결하면 쓰는 데 시간이 덜 걸린다. 더 중요한 것은 읽는 데도 시간이 덜 걸린다는 점이다. 간결성은 우리의 생산성을 향상시켜주고 개발을 더 빠르게 진행할 수 있게 해준다.

안전성

일반적으로 프로그래밍 언어가 안전하다는 말은 프로그램에서 발생할 수 있는 오류 중에서 일부 유형의 오류를 프로그램 설계가 원천적으로 방지해준다는 뜻이다. 물론 절대적이지는 않다. 어떤 언어도 발생할 수 있는 모든 오류를 막을 수는 없다. 추가로 오류를 방지하는 데는 대가가 따르기 마련이다. 컴파일러에게 프로그램 코드와 프로그램의 작동 의도에 대한 정보가 일치하는지를 검증할 수 있다. 따라서 더 큰 안전성을 얻기 위해서는 프로그램에 더 많은 정보를 덧붙여야 하므로 생산성이 하락하는 것을 감수해야하며 안정성과 생산성 사이에는 트레이드오프 관계가 성립한다.

코틀린을 만들면서 자바보다 더 높은 수준의 안전성을 달성하되 전체 비용은 더 적게 지불하고 싶었다. 코틀린은 JVM에서 실행한다는 사실을 이미 상당한 안전성을 보장할 수 있다는 뜻이다. (ex. JVM을 사용함으로써 메모리 안정성, 버퍼 오버플로우를 방지, 동적으로 할당한 메모리를 잘못 사용함으로 발생하는 문제를 에방) JVM에서 실행되는 정적 타입 지정 언어로서 코틀린은 애플리케이션의 타입 안전성을 보장한다. 하지만 자바보다 더 적은 비용으로 타입 안전성을 사용할 수 있다. 대부분의 경우 코틀린 컴파일러가 타입을 자동으로 추론해주기 때문에 우리가 직접 타입 정보를 지정할 필요가 없다.

코틀린은 거기서 한걸음 더 나아가 실행 시점에 오류를 발생시키는 대신 컴파일 시점 검사를 통해 오류를 더 많이 방지해준다. 가장 중요한 내용으로 코틀린은 프로그램의 NullPointerException을 없애기 위해 노력한다. 코틀린의 타입 시스템은 null이 될 수 없는 값을 추적하며, 실행 시점에 NullPointerException이 발생할 수 있는 연산을 사용하는 코드를 금지한다. 이로 인해 추가로 들어가는 비용은 미미하다. 어떤 타입이 널이 될 수 있는지 여부를 표시하기 위해서는 오직 ? 한 글자만 추가하면 된다.

val s: String? = null // null이 될 수 있음
val s2: String = "" // null이 될 수 없음

추가로 코틀린은 널이 될 수 있는 값을 다룰 수 있는 편리한 방법을 다양하게 제공한다.
이런 기능은 애플리케이션이 NullPointerException으로 인해 갑자기 중단되는 경우를 많이 줄여준다.

코틀린이 방지해주는 다른 예외로는 ClassCastException이 있다. 어떤 객체를 다른 타입으로 캐스트(cast)하기 전에 타입을 미리 검사하지 않으면 ClassCastException이 발생할 수도 있다. 자바에서는 타입 검사와 그 직후 이뤄지는 타입 캐스트에서 같은 타입 이름을 반복 사용하는 것이 귀찮아서 타입 검사를 생략하는 개발자가 많다. 반면 코틀린에서는 타입 검사와 캐스트가 한 연산자에 의해 이뤄진다. 어떤 객체의 타입을 검사했고 그 객체가 그 타입에 속한다면 해당 타입의 메소드나 필드 등의 멤버를 별도의 캐스트 없이 사용할 수 있다 (Auto Casting). 따라서 타입 검사를 생략할 이유가 없고, 검사를 생략하지 않으면 검사를 생략해서 생기는 오류가 발생할 일도 없다. 다음은 Auto Casting의 예제이다.

if (value is String) { // 타입을 검사한다.
    println(value.toUpperCase()) // 해당 타입의 메소드를 사용한다. 
}

상호운용성

코틀린은 자바의 기존 라이브러리를 그대로 사용할 수 있다. 라이브러리가 어떤 API를 제공하던 간에 코틀린에서 그 API를 활용할 수 있다. 자바 메소드를 호출하거나 자바 클래스를 상속(확장)하거나 인터페이스를 구현하거나 자바 애너테이션을 코틀린 코드에 적용하는 등의 일이 모두 가능하다.

다른 일부 JVM 언어와 달리 코틀린은 상호운용성 측면에서 훨씬 더 많은 것을 제공한다. 즉, 자바 코드에서 코틀린 코드를 호출할 때도 아무런 노력이 필요 없다. 자바에서 코틀린을 호출할 때 어떤 교묘한 장치도 필요 없다. 코틀린의 클래스나 메소드를 일반적인 자바 클래스나 메소드와 똑같이 사용할 수 있다. 이에 따라 자바와 코틀린 코드를 프로젝트에서 원하는 대로 섞어 쓸 수 있는 궁극적인 유연성을 발휘할 수 있다. 기존 자바 프로젝트에 코틀린을 도입하는 경우 자바를 코틀린으로 변환하는 도구를 코드베이스 안에 있는 자바 클래스에 대해 실행해서 그 클래스를 코틀린 클래스로 변환할 수 있다. 이렇게 변경한 클래스가 프로젝트 안에서 어떤 역할을 하는지와는 관계없이 코틀린으로 바꾼 클래스가 어떤 것이든 프로젝트의 나머지 부분을 전혀 수정하지 않고도 컴파일 및 실행이 가능하다.

상호운용성 측면에서 코틀린이 집중하는 다른 방향으로는 기존 자바 라이브러리를 가능하면 최대한 활용한다는 점을 들 수 있다. 예를 들어 코틀린은 자체 컬렉션 라이브러리를 제공하지 않는다. 코틀린은 자바 표준 라이브러리 클래스에 의존하는데 다마 코틀린에서 컬렉션을 더 쉽게 활용할 수 있게 몇 가지 기능을 더할 뿐이다. 이는 코틀린에서 자바 API를 호출할 때 객체를 감싸거나 변환할 필요 없다는 뜻이다. 코틀린이 제공하는 풍부한 API는 실행시점에 아무런 부가 비용을 야기하지 않는다.

코틀린이 제공하는 도구도 다중 언어 프로젝트를 완전히 지원한다. 코틀린은 자바와 코틀린 소스 파일이 임의로 섞여 있어도 제대로 프로그램을 컴파일할 수 있다. 각 소스 파일 사이의 의존관계가 어떤 식으로 이뤄졌든 관계없이 컴파일할 수 있다. IDE 기능도 언어와 관계없이 제대로 작동한다. 따라서 다음과 같은 동작이 가능하다.

  • 자바와 코틀린 소스 파일을 자유롭게 내비게이션 할 수 있다.
  • 여러 언어로 이뤄진 프로젝트를 디버깅하고 서로 다른 언어로 작성된 코드를 언어와 관계없이 한 단계씩 실행할 수 있다.
  • 자바 메소드를 리팩토링해도 그 메소드와 관련 있는 코틀린 코드까지 제대로 변경된다. 역으로 코틀린 메소드를 리팩토링해도 자바 코드까지 모두 자동으로 변경된다.

코틀린 도구 사용

자바와 마찬가지로 코틀린도 컴파일 언어다. 따라서 코틀린 코드를 실행하기 전에 먼저 코드를 컴파일해야만 한다. 컴파일 과정이 어떻게 이뤄지며 그 과정에서 어떤 도구가 쓰이는지 자세히 살펴보자.

코틀린 코드 컴파일

코틀린 소스코드를 저장할 때는 보통 .kt라는 확장자를 파일에 붙인다. 코틀린 컴파일러는 자바컴파일러가 자바 소스코드를 컴파일할 때와 마찬가지로 코틀린 소스코드를 분석해서 .class파일을 만들어낸다. 만들어진 .class 파일은 개발 중인 애플리케이션의 유형에 맞는 표준 패키징 과정을 거쳐 실행될 수 있다. 가장 간단한 방식은 커맨드라인에서 kotlinc(java가 javac 하듯이!) 명령을 통해 코틀린 코드를 컴파일 다음 java 명령으로 그 코드를 실행하는 것이다.

kotlinc <소스파일 또는 디렉터리> -include-runtime -d <jar 이름>
java -jar <jar 이름>

| 아래는 코틀린 빌드 과정을 간단히 보여준다.
image

코틀린 컴파일러로 컴파일한 코드는 코틀린 런타임 라이브러리에 의존한다. 런타임 라이브러리에는 코틀린 자체 표준 라이브러리의 클래스와 코틀린에서 자바 API의 기능을 확장한 내용이 들어있다. 코틀린으로 컴파일한 애플리케이션을 배포할 때는 런타임 라이브러리도 함께 배포해야 한다.

실제로 개발을 진행한다면 프로젝트를 컴파일하기 위해 메이븐, 그레이들, 앤트 등의 빌드 시스템을 사용할 것이다. 코틀린은 그런 빌드 시스템과 호환된다.(부록 A에서 다룬다) 이런 빌드 시스템은 모두 코틀린과 자바가 코드베이스에 함께 들어있는 혼합 언어 프로젝트를 지원할 수 있다. 메이븐과 그레이들은 애플리케이션을 패키지할 때 알아서 코틀린 런타임을 포함시켜준다.

IntelliJ IDEA와 안드로이드 스튜디오의 코틀린 플러그인

IntelliJ IDEA의 코틀린 플러그인은 코틀린 언어와 함께 개발돼 왔으며, 코틀린을 사용할 수 있는 개발 환경 중에서 가장 다양한 기능을 제공한다. 이 코틀린 플러그인은 안정화 단계이며, 코틀린 개발에 필요한 모둔 도구를 제공한다.
IntelliJ IDEA 15 및 15 이후 버전에는 코틀린 플러그인이 기본 포함돼 있다. 따라서 별도의 플러그인을 설치할 필요가 없다. 무료 오픈소스인 IntelliJ IDEA comunity edition을 사용해도 되고, 상용버전인 얼티밋을 사용해도 된다. 플러그인이 제대로 작동한다면 새 프로젝트 메뉴 선택 시 코틀린을 볼 수 있다. (안드로이드 스튜디오에서는 플러그인 관리자를 통해 코틀린 플러그인을 설치해야한다. 설정 대화상자에서 플러그인을 선택한 다음 젯브레인스 플러그인 설치 버튼을 클릭해서 표시되는 목록에 있는 코틀린을 선택하라)

이 외에도 대화형 셀, 이클립스 플러그인, 온라인 놀이터(구름과 같이 웹상에서 코틀린 코드를 작성하고 컴파일한 후 실행할 수 있는 곳) 등이 있다.

자바 - 코틀린 변환기

새로운 언어를 배워 써먹을 만큼 숙련도를 높이려면 많이 노력해야 한다. 자바에 대해 알고 있는 지식을 바탕으로 코틀린을 더 빠르게 배워서 써먹을 수 있는 지름길은, 이 도구를 사용하는 것이다. 이 도구는 자동으로 자바를 코틀린으로 변환한다.

코틀린을 처음 배웠는데 정확한 코틀린 문법이 기억나지 않는 경우 이 변환기를 유용하게 써먹을 수 있다. 작성하고픈 코드를 자바로 작성해 복사한 후 코틀린 파일에 그 코드를 붙여 넣으면 변환기가 변환기가 자동으로 같은 뜻의 코틀린 코드를 제안한다. 물론 변환기가 항상 가장 코틀린다운 코드를 제안해 주지는 못하지만 잘 작동하는 코틀린 코드를 알려주기 때문에 원하는 바를 코틀린으로 달성할 수 있다.

기존 자바 프로젝트에 코틀린을 도입하고 싶을 때 변환기를 사용하면 쓸모가 있다. 새 클래스를 작성할 필요가 있다면 처음부터 코틀린으로 그 클래스를 만들면 된다. 기존 클래스를 상당 부분 변경해야 한다면 자바 대신 코틀린을 사용하고 싶을 텐데 그런 경우 변환기를 사용하면 도움이 된다. 기존 자바 클래스를 먼저 코틀린으로 변환한 다음에 변환된 코틀린 코드를 변경하면 코틀린 기능을 활용할 수 있다. IntelliJ IDEA에서 변환기를 사용하기는 쉽다. 자바 코드조각을 변환하고 싶을 때는 자바 코드 조각을 복사해서 코틀린 파일에 붙여 넣는다. 자바 파일 하나를 통째로 코틀린으로 변환하고 싶으면 메뉴에도 코드 -> 자바 파일을 코틀린 파일로 변환을 선택하면 된다. 이클립스나 웹에서도 변환기를 사용할 수 있다.

728x90

'Kotlin > Kotlin In Action' 카테고리의 다른 글

4장 - 클래스, 객체, 인터페이스  (0) 2021.08.18
3장 - 함수 정의와 호출  (0) 2021.07.08
2장 - 코틀린 기초  (3) 2021.06.28