Published on

Kotlin IN ACTION | 클래스, 객체, 인터페이스 그리고 람다

Authors
  • avatar
    Name
    이건창
    Twitter

Introduction

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

클래스와 인터페이스 그리고 상속

인터페이스

코틀린에서 클래스 계층을 어떻게 정의하는지 살펴보자. 일반적으로 클래스를 선언하거나 인터페이스, 추상 클래스, 아니면 클래스를 상속받아 선언하게 돼.

인터페이스를 활용해 선언하는 모습을 먼저 살펴보자.

interface Clickable {
    fun click()                         // 일반 메서드 선언
    fun showOff() = println("Click!")   // 디폴트 메서드 선언
}

class Button: Clickable {
    override fun click() {TODO("Not yet implemented")}
}

override 키워드 덕분에 상위 클래스를 상속받는 걸 강제할 수 있고, 상위 클래스를 아직 상속받지 못했음을 컴파일 단계에서 인지할 수 있어.

인터페이스를 두 개 이상 상속받을 때, 동일한 디폴트 메서드 함수를 출력하는 방법은 상위 타입의 힌트를 제공하면 돼.

interface Clickable {
    fun click() = println("clcik!")
}

interface Focusable {
    fun click() = println("focus")
}

class Button: Clickable, Focusable {
    override fun click() = super<Clickable>.click()
}

추상 클래스를 선언하는 모습을 살펴볼게. 인터페이스와 차이점이 있다면 상속할 때 파라미터를 전달해야 한다는 점이야.

abstract class Clickable {
   abstract fun hello()
}

class Button: Clickable(){
    override fun hello() { TODO("Not yet implemented") }
}

마지막으로 클래스를 상속받을 때를 살펴보자. 클래스를 상속하기 위해서는 상위 클래스에 open 키워드를 추가해야 해.

open class Clickable {}

class Button: Clickable() {}

인터페이스, 추상 클래스, 일반 클래스를 어떻게 상속하는지 간단하게 알아봤어. 다음 장부터는 상속했을 때 어떻게 오버라이드 되는지, 어떻게 초기화되는지 알아볼거야.

메서드 오버라이드

코틀린은 기본적으로 상속이 불가능하도록 설정되어 있어. 만약 모든 함수가 상속 가능한다면 취약한 기반 클래스(fragile base class) 문제가 발생해 상위 클래스의 책임을 깨뜨리는 경우가 빈번해지지.

그러나 상속이 가능하도록 open 키워드를 붙일 수 있어. 그 밖에도 어떨 때 상속이 가능해지는지 알아보자구.

open class Button: Clickable {                          // 상속이 가능 하도록 열린 클래스
    fun disable() {TODO("Not yet implemented")}         // override 불가
    open fun animate() {TODO("Not yet implemented")}    // override 가능
    override fun click() {TODO("Not yet implemented")}  // override 가능
}

override로 구현한 메서드는 하위 클래스에서도 override가 가능해. 만약 override된 메서드를 상속 불가능하게 막고 싶다면 final 키워드를 사용해야 해.

open class Button: Clickable {                          // 상속이 가능 하도록 열린 클래스
    final override fun click() {TODO("Not yet implemented")}  // override 가능
}

메서드 오버라이드 여부를 표로 정리해봤어.

변경자오버라이드 여부
final
open
abstract
override
final override

가시성

그럼 선언한 클래스나 프로퍼티에 접근하려는 제어하는 방법을 알아볼게. 코틀린은 클래스 멤버로 선언하거나 최상위 선언하는 방법으로 나눌 수 있어.

클래스 멤버와 최상위 선언 차이를 간단하게 설명하고 갈게.

val hello = "Hello"        // 최상위 선언
class Hello{               // 최상위 선언
    val hello = "Hello"    // 클래스 멤버
}

선언된 클래스와 프로퍼티를 변경자를 통해 가시성을 제어할 수 있게 돼. 변경자에 따른 가시성을 표로 정리해봤어.

변경자클래스 멤버최상위 선언
public모든 곳에서 접근 가능모든 곳에서 접근 가능
internal같은 모듈만 접근 가능같은 모듈만 접근 가능
protected하위 클래스 안 에서만 접근 가능선언 불가 ❌
private같은 클래스 안 에서만 접근 가능같은 파일 안 에서만 접근 가능

자바와 달리 package-private은 없고 기본 가시성은 public이니 코틀린을 사용할 때 주의가 필요해.

변경자를 통해 가시성을 제어하면 다음과 같아.

private val hello = "Hello"        // 같은 파일 안 에서만 접근 가능
internal class Hello{               // 같은 모듈만 접근 가능
    private val hello = "Hello"    // 같은 클래스 안 에서만 접근 가능
}

private 인 경우 선언된 위치에 따라 달라지니 신경써야 해.

내부 클래스, 중첩 클래스

자바에서는 클래스 내부에 클래스(nested class)를 선언하면 외부 클래스에 접근이 가능하지만 코틀린을 불가능 해.

자바에서 내부에 클래스를 선언하면 inner class 로 선언되지만 코틀린에서는 nested class 로 선언 돼.

코틀린에서 외부 클래스에 대한 참조를 가지려면 inner 키워드를 사용해.

class Outer {
    val hello = "Hello"

    inner class Inner {
        init {
            println(this@Outer.hello)
        }
    }
}

봉인된 클래스

만약 아래처럼 Expr 타입을 인자로 받는다면 when 분기들은 Expr를 상속받아 생성된 인스턴스야.

AS-IS

interface Expr
class Num(val value: Int): Expr
class Sum(val left: Expr, val right: Expr): Expr

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

그런데 Expr 선언은 두 개만 만든다면 else 키워드 이후의 식이 필요하지 않을 수 있어. 그래서 seald 키워드를 활용해 만들 수 있는 인스턴스를 한정지을 수 있어.

TO-BE

sealed class Expr {
    class Num(val value: Int): Expr()
    class Sum(val left: Expr, val right: Expr): Expr()
}

fun eval(e: Expr): Int =
    when (e) {
        is Expr.Num -> e.value
        is Expr.Sum -> eval(e.right) + eval(e.left)
    }

최근에는 sealed interface 도 추가됐으니 편리하게 사용할 수 있어.

sealed interface Expr {
    class Num(val value: Int) : Expr
    class Sum(val left: Expr, val right: Expr) : Expr
}

생성자와 프로퍼티

클래스 주 생성자 초기화

클래스를 초기화 할 때 주 생성자와 부 생성자를 활용해 초기화 할 수 있어.

주 생성자는 생성자(constructor) 파라미터를 지정하고 생성자 파라미터에 의해 초기화(init)되는 프로퍼티를 정의해. 주 생성자를 정의하려면 다음처럼 정의해야 하지.

class User constructor(_nickname: String) {     // 파라미터가 있는 주 생성자
    val nickname: String
    init {                                      // 초기화 블록
        nickname = _nickname                    // 초기화
    }
}

주생성자인 경우 다음처럼 프로퍼티 값을 쉽게 초기화 할 수 있지.

class User(val nickname: String)

함수 파라미터처럼 생성자 파라미터도 디폴트 값을 정의할 수 있어.

class User(val nickname: String, val validated: Boolean = true)

val me = User("건창")
val notMe = User("건창아님", false)

기반 클래스가 존재한다면 기반 클래스의 생성자를 호출해야 해.

open class User(val nickname: String, val validated: Boolean = true)
class ServiceUser(nickname: String) : User(nickname)

만약 주생성자를 비공개하고 싶다면 다음처럼 선언 할 수 있지.

class User private constructor()

클래스 부 생성자 초기화

부생성자는 다음처럼 선언 가능해.

class User {
    constructor(nickname: String) {println("Hi, $nickname")}
    constructor(nickname: String, validate: Boolean) {println("$nickname is $validate")}
}

상위 클래스를 선언하고 싶으면 super를 사용하고, 현재 클래스의 생성자를 선언한다면 this를 사용하면 돼.

open class User(val nickname: String)

class RealUser : User {
    constructor(nickname: String)
        : this(nickname, true)
    constructor(nickname: String, validate: Boolean)
        : super(nickname) {
            println("$nickname is $validate")
        }
}

인터페이스 프로퍼티 구현

코틀린의 인터페이스는 변수를 선언할 수 있고 다음처럼 초기화가 가능해.

interface User {
    val name: String
}

// 1. 주 생성자에 프로퍼티 추가
class AServiceUser(override val name: String): User

// 2. 커스텀 게터 활용
class BServiceUser: User {
    override val name: String
        get() {
            TODO()
        }
}

게터와 세터

뒷받침하는 필드에 접근하는 방법은 게터와 세터를 사용하는 일이야. 세터에서 값을 바꿀 떄 field 키워드를 사용해야 해.

class User {
    var address: String = "unspecified"
        set(value) {
            field = value
        }
}

하지만 세터를 통해 외부에서 값을 변경하게 되면 문제가 발생할 수 있지. 이런 경우는 세터의 가시성을 제한하면 해결 돼.

class User {
    var address: String = "unspecified"
        private set
}

데이터 클래스

equals

코틀린에서 자바와 다른 부분이 있다면 == 키워드의 동작이 다르다는 점이야. 코틀린에서 == 키워드는 equals 연산을 수행하고 === 키워드는 같은 인스턴스인지를 비교해줘.

fun aEqualsB(a: String, b: String) = a==b    // equals 메서드 실행
fun aSameB(a: String, b: String) = a===b    // 동일한 인스턴스인지 주소값 비교

데이터 클래스와 equals, hashCode, toString

자바에서는 데이터로 사용할 클래스를 구현할 떄마다 equals, hashCode, toString 을 구현해야하는 번잡함이 있어. 코틀린은 data 라는 키워드로 쉽게 해결 할 수 있지.

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

데이터 클래스는 생성된 파라미터를 가지고 equals, hashCode, toString를 오버라이딩 해줘.

데이터 클래스와 copy

프로퍼티가 불변이면 사용하기 까다로워지지. 그러나 멀티 스레드에서 프로퍼티가 불변이 아니라면 thread-safe 못 할 수 있어. 그래서 데이터 클래스에서는 일부 값을 변경한 체 복사 할 수 있는 copy 메서드를 제공하고 있어.

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

val client = Client("건창", 123)
val changedClient = client.copy(postalCode = 321)

by 로 위임

위임하게 되면 상위 클래에 의존하게 되는 코드가 작성 돼. 만약 상위 클래스가 변경되면 하위에 있는 클래스는 대부분 깨지게 되지.

이러한 문제를 막기 위해서 데코레이터 패턴 등을 활용해 깨지지 않도록 수정해야 해. 그런데 데코레이터 패턴은 코드를 엄청 많이 짜야하는 방법이야.

class DelegationCollection<T>: Collection<T> {
    private val inner = arrayListOf<T>()
    override val size: Int
    get() = TODO("Not yet implemented")
    override fun isEmpty(): Boolean {TODO("Not yet implemented")}
    override fun iterator(): Iterator<T> {TODO("Not yet implemented")}
    override fun containsAll(elements: Collection<T>): Boolean {TODO("Not yet implemented")}
    override fun contains(element: T): Boolean {TODO("Not yet implemented")}
}

코틀린에서는 by 키워드를 활용해 쉽게 위임 할 수 있어. 그리고 원하는 메서드만을 쉽게 오버라이딩이 가능하지.

class DelegationCollection<T>(inner: Collection<T> = ArrayList()): Collection<T> by inner {
    override fun contains(element: T): Boolean {TODO("Not yet implemented")}
}

object 키워드

object 키워드는 선언과 동시에 인스턴스를 생성한다는 특징이 있어. 덕분에 싱글톤을 생성하거나 필요한 객체들을 쉽게 생성 할 수 있지.

키워드를 선언하는 상황은 세 가지야.

  • 객체 선언(object declaration) : 싱글턴을 얻기 위해
  • 동반 객체(companion object) : 팩토리 메서드 선언을 위해
  • 익명 내부 클래스(anonymous inner class) : 객체를 바로 생성하기 위해

객체 선언

동일한 객체를 재활용한다면 다음처럼 선언해서 활용할 수 있어.

data class Person(val name: String) {
    object NameComparator: Comparator<Person> {
        override fun compare(p0: Person?, p1: Person?): Int {TODO("Not yet implemented")}
    }
}

동반 객체

코틀린에서는 정적 메서드를 제공하지 않아. 그래서 companion object 키워드를 활용해 인스턴스 없이 접근할 수 있도록 설정해야 해.

class User private constructor(val nickname: String) {
    companion object {
        fun newSubscriber(email: String) = User(email.substringBefore('@'))
    }
}

User.newSubscriber("건창@email.com")

object가 내부 정적 클래스를 선언한다면 companion은 인스턴스 없이 접근할 수 있도록 도와준다고 생각하면 좋아.

동반 객체에서 인터페이스를 구현 할 수 있으니 잘 활용해보자.

interface Person<T> {
    fun showName(name: String): T
}

class ServiceUser(val name: String) {
    companion object: Person<ServiceUser> {
        override fun showName(name: String): ServiceUser {TODO("Not yet implemented")}
    }
}

만약 외부 모듈에서 동반 객체를 확장해서 사용하고 싶다면 Companion 을 활용하면 돼.

class ServiceUser(val firstName: String, val lastName: String) {
    companion object
}

fun ServiceUser.Companion.newUser(name: String):ServiceUser  {
    val strings = name.split(' ')
    return ServiceUser(strings[0], strings[1])
}

익명 내부 클래스

익명 내부 클래스도 object 로 쉽게 생성할 수 있어. 덕분에 클래스 이름을 선언 할 필요가 없지.

interface Clickable {
    fun click()
}

val event = object : Clickable{
    override fun click() {TODO("Not yet implemented")}
}

익명 내부 클래스는 싱글턴이 아니니 주의할 필요가 있어.

자바와 달리 코틀린은 변수말고도 같은 블록에 있ㄴ느 객체에 접근 할 수 있어.

interface Clickable {
    fun click()
}

fun count() {
    var count = 0

    object : Clickable{
        override fun click() {
            count++
        }
    }
}

이 부분은 오히려 조심해야 할 부분 같아. 동일한 라이프 사이클이 아닌데 접근하는 건 위험해보여.

5장 : 람다로 프로그래밍

람다 식과 멤버 참조

람다 선언

람다식을 활용하면 익명 내부 클래스로 선언을 다음처럼 줄일 수 있어.

AS-IS

button.click(new ClickListener( {
    override fun onClick() {
        /* 수행할 동작 */
    }
}))

TO-BE

button.click { /* 수행할 동작 */ }

람다 선언은 다음처럼 하는게 전부야.

// 인자가 없는 경우
service.execute { /* 수행할 동작 */ }
// 인자가 하나인 경우
service.execute { a:Int -> /* 수행할 동작 */ }
// 인자가 하나인 경우
service.execute { it.get() }
// 인자가 두 개인 경우
service.execute { a: Int, b: Int -> /* 수행할 동작 */ }

만약 블록으로 작성하게 되면 마지막 식이 결과 값이 될 수 있도록 구성 할 수도 있지.

service.execute { a: Int, b: Int ->
    /* 수행할 동작 */
    println("a + b is ${a + b}")
    a + b
}

람다가 비용이 많이 들 건지 걱정한다면 걱정 안해도 돼. 람다 호출 하지 않는 것과 비슷한 성능을 내니 너무 걱정하지 말자.

람다 외부 변수 접근

자바와 달리 람다는 final 키워드와 연관되지 않은 외부 변수에 접근 할 수 있어. final 이 아닌 키워드에 접근하고 프로퍼티를 변경하면 다른 호출자들은 변경된 프로퍼티를 사용하기 때문에 주의가 필요해.

fun print(messages: Collection<String>, prefix: String){
    messages.forEach {
        print("$it $prefix")
    }
}

멤버 참조

메서드 참조와 달리 코틀린은 멤버 참조가 가능 해. 멤버 참조는 함수를 넘기는 기능이야.

fun hi() = println("hi!")
run(::hi)

생성자 참조를 활용하면 생성 작업을 연기 할 수도 있어.

data class Person(val name: String)
val createPerson = ::Person
val AUser = createPerson("User A")

확장 함수도 멤버 함수와 똑같은 방식으로 참조 가능해.

fun Person.isAdult() = age > 19
val predicate = Person::isAdult

특정 객체만 바운드해서 사용할 수 있어서 활용 가능성이 높아.

data class Person(val name: String, val age: Int)
val a = Person("A", 32)

val age = Person::age
var aAge = a::age

println(age(a))
println(aAge())

컬렉션 함수형 API

람다 활용

코틀린은 컬렉션과 관련한 람다 함수를 엄청 많이 제공해. 중괄호를 사용해 람다로 활용하거나 파라미터를 전달해 멤버 참조로 활용 할 수 있어.

class Person(val age: Int)

val elder = listOf(Person(23), Person(22)).maxBy { it.age }
val sameElder = listOf(Person(23), Person(22)).maxBy(Person::age)
println(elder.age) // 23
println(sameElder.age) // 23

컬렉션의 joinToString처럼 코틀린을 더 우아하게 사용 할 수 있어.

class Person(val age: Int)

val ages = listOf(Person(23), Person(22)).joinToString(separator = ", ", transform = {it.age.toString()})
val sameAges = listOf(Person(23), Person(22)).joinToString(", ") {it.age.toString()}
println(ages) // 23, 22
println(sameAges) // 23, 22

그 외에도 filter, map을 활용하면 원하는 원소를 만들거나 찾을 수 있어. 그리고 groupBy를 활용하면 리스트를 맵으로 만들 수 있지. 마지막으로 flatMap과 flatten을 활용하면 중첩된 컬렉션을 단일 컬렉션으로 모을 수 있어.

지연 컬렉션 연산

여러 컬렉션 함수를 사용하게 되면 결과 컬렉션을 즉시 생성하게 돼. 즉, 연산마다 결과를 새로운 컬렉션에 담게 되는 거지.

그럼 사용하지 않는 인스턴스들이 생성되니 아깝잖아. 그리고 매번 재정렬하는 경우라면 오버헤드가 더 커질거야. 그래서 시퀀스를 사용해 임시 컬렉션을 사용하지 않고 연쇄 할 수 있어.

data class Greeting(val message: String)

listOf("hello", "hi", "welcome")
    .asSequence() // 시퀀스로 변환
    .map { Greeting(it) }
    .filter { it.message.startsWith("w") }
    .toList() // 마지막에만 최종 리스트 반환

시퀀스를 사용하지 않으면 다음처럼 즉시 연산으로 전체 컬렉션에 걸쳐 연산하게 되고 시퀀스를 사용한다면 원소마다 처리하게 돼.

그림 1

자바에서 스트림과 동일한 동작을 수행한다고 생각하면 돼.

자바 함수형 인터페이스 활용

코틀림 람다를 Runnable 과 같은 람다형 인터페이스에 적용 할 수 있을지 궁금했을거야. 코틀린은 자바의 함수형 인터페이스 덕분이야.

메서드가 하나이기 때문에 유추가 가능하지.

AS-IS

void post(int delay, Runnable runnable) //...
post(1000) {println("delay 1000")}

수신 객체 지정 람다

코틀린에는 수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메서드를 호출 할 수 있는 기능을 제공해. 이를 수신 객체 지정 람다(lambda with receiver)라고 불러.

with 함수

with 함수를 활용하면 객체 이름을 반복해서 선언하지 않고 활용 할 수 있어.

fun alphabet() = with(StringBuilder()) {
        for (letter in 'A' .. 'Z') {
            append(letter)
        }
        append("\n This is Alphabet!!")
        toString()
    }

만약 외부 인스턴스의 toString 을 호출하고 싶으면 this@OuterClass.toString()를 호출해서 접근해야 해.

apply 함수

apply는 반환할 때 수신 객체가 필요할 떄 사용해. with 랑 유사하지만 수신 객체를 반환 한다는 점에서 차이가 있어.

fun alphabet() = StringBuilder().apply {
    for (letter in 'A' .. 'Z') {
        append(letter)
    }
 }.toString()

통찰

이번주는 통찰보다 학습한 내용이 많아. 아마 코틀린의 활용도가 떨어지는 게 원인이야. 마침 내일부터 테스트 코드 작성을 위해 단기간 피트 스탑이 진행될거야. 그 과정에서 코틀린과 코테스트를 작성해보려 해.