Kotlin - 클래스!

클래스

kotlin 클래스는 java 와 상당부분 다르다.
아래 처음보는 형식의 클래스 정의문을 하나씩 살펴보도록 한다.

// 기본생성자 (primary constructor) constructor 키워드생략 가능
class Person (name: String) { 

    // 멤버변수
    private val firstProperty = "First property: $name"

    // 보조 생성자 (Secondary constructor)
    constructor(i: Int) : this(i.toString()) { 
        println("Secondary constructor block that prints $i")
    }

    // 생성자 초기화 init 블럭
    init { 
        println("First initializer block that pr-ints $name")
    }

    fun printName(): String = this.firstProperty
}

주 생성자: Primary constructor

// Primary constructor
class Person constructor(name: String) {
    val name = name
    fun hello() {
        println("my name is ${name}")
    }
}
...
// 아래처럼 멤버변수 정의 생략 가능  
class Person (val name: String) {
    fun hello() {
        println("my name is ${name}")
    }
}

기본생성자에 initial 데이터를 추가하면 자동으로 입력값에 맞는 생성자를 여러개 생성함.

class Person (val name: String = "kouzie") {
    fun hello() {
        println("my name is ${name}")
    }
}

fun main() {
    var human1 = Person("test")
    var human2 = Person()
    human1.hello() // my name is test
    human2.hello() // my name is kouzie
}

보조 생성자: Secondary constructor

보조 생성자는 주 생성자를 호출하거나, 다른 보조 생성자를 호출해야만 한다.

주로 주 생성자 밖에 정의된 멤버변수를 초기화 할 때 같이 사용한다.

class Person(val name: String = "kouzie") {

    var location: String = "-"

    // Secondary constructor
    constructor(name: String, age: Int, loc: String) : this(name) {
        println("my name is ${name} and ${age} years old, loc is ${loc}")
        location = loc
    }

    fun hello() {
        println("my name is ${name}")
    }
}

fun main() {
    var human = Person("test", 26) // my name is test and 26 years old
}

init 블럭

생성하면서 특정 코드를 클래스 블럭 내부에서 실행하고 싶다면 init 블럭 사용

class Person(val name: String = "kouzie") {

    private var age: Int? = null

    constructor(name: String, age: Int) : this(name) {
        this.age = age;
        println("my name is $name and $age years old")
    }

    init {
        println("my name is $name")
    }

    init {
        println("my age is ${age ?: "unknown"}")
    }
}

fun main() {
    var human = Person("test", 26)
    // my name is test
    // my age is unknown
    // my name is test and 26 years old
}

init 블록은 여러개 정의될 수 있고 보조생성자보다 먼저 실행된다. 그리고 위와 같이 기본생성자에 정의된 멤버변수는 가져와서 사용할 수 있다.

디컴파일 해보면 init 블럭 내부의 코드가 각 생성자 코드 사이사이에 삽입되어있다.

init 블럭은 사용하지 않는것을 권장한다.

private, protected, internal, public

kotlin은 모든 propertypublic 하게 접근 가능하다, 언뜻보면 객체지향의 캡슐화, 은닉 원칙을 무시하는 것처럼 보이지만
사실 바이트 코드로 변경되면서 모든 필드는 private지원필드backing field(get, set) 를 사용하는 코드로 변경된다.

어차피 java 코드도 private 필드 생성후 [getter, setter] 코드를 생성함으로, 이런 보일러 코드를 없에기 위한 kotlin 의 원칙으로 보인다.
kotlin 에선 정말 감추고 싶은 필드만 접근제어자를 통해 감추도록 한다.

kotlin 에선 정말 은닉해야할 데이터만 별도로 private 키워드를 사용하고 나머지는 public 하게 사용할 수 있도록 한다.

클래스 내부에서만 사용되는 propertyprivate 으로 정의하는것을 권장한다.

open class TestClass {
    private val var1 = 100
    protected val var2 = 100
    internal val var3 = 100
    public val var4 = 100
    val var5 = 100
}

java 코드로 디컴파일하면 아래와 같다.

public class TestClass {
    private final int var1 = 100;
    private final int var2 = 100;
    private final int var3 = 100;
    private final int var4 = 100;
    private final int var5 = 100;
    
    protected final int getVar2() { return this.var2; }
    
    public final int getVar3$kotlin_gradle() { return this.var3; }
    
    public final int getVar4() { return this.var4; }
    
    public final int getVar5() { return this.var5;}
}

private 키워드로 정의한 경우만 getter 가 생성되지 않는다.
internal 키워드로 정의한 경우 kotlin 만의 가시성 키워드로 같은 모듈에서만 접근가능하다.

backing field

kotlin에서 propertyprivate field + backing field 가 모두 포함된 개념이라 할 수 있다.

아래와 같이 [name, priority] property 를 정의,
name 은 생성자에서, priority 는 바로 접근해서 초기화 가능하다.

class Task(val name: String) {
    var priority = 3
    fun print() {
        println("${this.name}, ${this.priority}")
    }
}
fun main() {
    var myTask = Task("kouzie")
    var myTask = Task("kouzie").apply { priority = 4 }
    myTask.print() // kouzie, 4
}

priority 에 직접 접근해 초기화 가능하지만 직관적, 함축적인 이유로 apply 블록을 쓰는것을 권장한다.

만약 특정 조건을 통해 backing field 정의를 해야한다면 지원필드backing field 를 재정의 하면 된다.
예로 특정 필드의 값이 반드시 1~5 사이의 값이어야 할 경우 아래와 같이 지정한다.

backing field 블록 내부에선 field 키워드를 통해 property 에 접근할 수 있다.

class Task(val name: String) {
    var priority = 3
        set(value) {
            field = value.coerceIn(1, 5)
        }

    fun print() {
        println("${this.name}, ${this.priority}")
    }
}

fun main() {
    var myTask = Task("kouzie")
    myTask.priority = 7
    myTask.print() // kouzie, 5
    myTask.apply { priority = -1 }
    myTask.print() // kouzie, 1
}

또한 실제 값이 설정되어 있지 않는 property 라 하더라도 backing field 정의가 가능하다.
valproperty를 지정하고 get 을 정의하면된다.

원래 val 은 최상위 property 로 사용하려면 초기화를 해야하지만 backing field 로 사용할거면 상관없다.
실제 디컴파일된 코드도 getter 함수만 정의되어 있다.

class Task(val name: String) {
    var priority = 3
        set(value) {
            field = value.coerceIn(1, 5)
        }

    private val isLowPriority: Boolean
        get() {
            return priority < 3
        }

    fun print() {
        println("${this.name}, ${this.priority}, ${this.isLowPriority}")
    }
}

fun main() {
    var myTask = Task("kouzie").apply { priority = 1 }
    myTask.print() // kouzie, 1, true
}

다음과 같은 지연로딩 비슷한 기능을 구현할 때 아래와 같이 사용할 수 있다.

backing fieldval message: List<String> 정의,
초기화 되지 않는 val 변수에 backing field 만 정의되어 있으면 getter 만 생성되는 특성을 사용해 지연로딩 기능을 구현한다

지연로딩은 null 처리를 하기위한 방법론

class Customer(val name: String) {
    private var _message: List<String>? = null

    val message: List<String>
        get() {
            if (_message == null) {
                _message = loadMessage()
            }
            return _message!!
        }

    private fun loadMessage(): List<String>? {
        return listOf(
            "Initial contact",
            "Convinced them to use Kotlin",
            "Sold training class. Sweet."
        ).also { println("Loaded messages") }
    }
}

fun main() {
    val c1 = Customer("kouzie").apply { messages } // Loaded messages, 지연로딩
    println(c1.messages) // [Initial contact, Convinced them to use Kotlin, Sold training class. Sweet.]
}

private setter 를 설정하고 싶으면 private set 키워드를 사용한다.

class Task(val name: String) {
    var priority = 3
        private set
}

fun main() {
    var myTask = Task("kouzie")
    println(myTask.priority)
}

companion object(동반 객체)

static 형식의 컴파일타임에 정의되는 상수와 메서드를 정의할 때 사용

const 키워드를 사용해 상수를 생성한다.

javastatic final 과 비슷 kotlin 에선 root 레벨 블록이나 companion object 에서만 사용 가능

class Book private constructor(val id: Int, val name: String) {
    companion object {
        const val myBook = "DEFAULT_BOOK"
        fun create(): Book = Book(100, "animal farm")
    }

    override fun toString(): String {
        return "Book(id=$id, name='$name')"
    }
}

fun main() {
    val book = Book.create();
    println(Book.Companion.myBook) // DEFAULT_BOOK
    println(Book.myBook) // DEFAULT_BOOK, companion object 접근 키워드 생략 가능
    println(book) // Book(id=444, name='animal farm')
}

companion object 도 클래스이기 때문에 이름을 지정하거나 interface 의 상속 및 구현 가능하다.

interface IdProvider {
    fun getId(): Int
}

class Book private constructor(val id: Int, val name: String) {
    companion object BookFactory : IdProvider{
        const val myBook = "DEFAULT_BOOK"

        override fun getId(): Int {
            return 444
        }
        fun create(): Book = Book(getId(), "animal farm")
    }

    override fun toString(): String {
        return "Book(id=$id, name='$name')"
    }
}

컴파일된 바이트코드를 보면 companion object 를 위한 static final 클래스가 선언되고
해당 클래스를 위한 싱글톤 객체로 생성하여 상수타입의 필드나 메서드에 접근한다.

data class - dto 클래스

데이터를 담는 클래스로 사용, toString, hashCode, equals, copy(얕은복사), componentN 등 데이터 처리용 함수들이 자동생성됨

data class Product constructor(
    val name: String,
    val price: Double = 0.0,
    val desc: String? = null
)

필드에 실수타입 있다면 closeTo 함수를 사용해 비교한다.

디스트럭쳐링 문법도 제공한다.

fun main() {
    val p1 = Product("product1", 2000.0, true)
    val (name, price) = p1;
    println("$name $price") // product1 2000.0
}

object class - 싱글톤

일반적으로 싱글톤 패턴은 생성자를 private 으로 설정하고 static 으로 정의된 정적 팩토리 메서드를 사용해 구현한다.

object 키워드로 클래스를 정의하면 별도 작업없이 싱글톤 클래스 정의가 가능하다.

object ProductFactory {
    var products = mutableListOf<Product>()

    fun makeProduct(name: String, price: Double, onSale: Boolean): Product {
        val p = Product(name, price, onSale)
        products.add(p)
        return p
    }
}

fun main() {
    val pf = ProductFactory;
    var p1 = pf.makeProduct("pencil", 1000.0, false);
    println(p1) // Product(name=pencil, price=1000.0, onSale=false)
    println(pf.products) // [Product(name=pencil, price=1000.0, onSale=false)]
}

생성된 바이트코드를 디컴파일하면 아래와 같다.
static 영역에서 생성한 ProductFactory.INSTANCE 를 사용한다.

public final class ProductFactory {
   @NotNull
   private static List products;
   @NotNull
   public static final ProductFactory INSTANCE;

   static {
      ProductFactory var0 = new ProductFactory();
      INSTANCE = var0;
      products = (List)(new ArrayList());
   }

   @NotNull
   public final List getProducts() {
      return products;
   }

   public final void setProducts(@NotNull List var1) {
      Intrinsics.checkParameterIsNotNull(var1, "<set-?>");
      products = var1;
   }

   @NotNull
   public final Product makeProduct(@NotNull String name, double price, boolean onSale) {
      Intrinsics.checkParameterIsNotNull(name, "name");
      Product p = new Product(name, price, onSale);
      products.add(p);
      return p;
   }

   private ProductFactory() {
   }
}

object 키워드가 편하긴 하지만 최초생성 시점을 지정할 수 없는점
그로인해 생성자에 인자를 넘길 수 없는점 이 단점이다.

inline class

단순 데이터 래핑용으로 클래스 정의를 하는 경우가 있다. 만약 단 하나의 필드를 위한 래핑 클래스를 정의해야할 경우 inline class 사용을 권장한다.

inline class 는 생성자로 단 하나의 값만 받을 수 있다.
물론 클래스 내에 프로퍼티와 함수를 정의할 수도 있다.

래핑용 클래스가 많아지면 new 연산자가 필요 이상으로 호출되고 힙 영역에 악영향을 끼치게 되는데
아래와 같이 inline class 를 사용하면 컴파일된 바이트 코드에서 new 연산자를 호출하지 않는다.

inline class SSN (val id: String)

fun receiveSSN(ssn: SSN) {
    println("Received $ssn")
}

fun main() {
    var ssn = SSN("1234567-1111111")
    receiveSSN(ssn) // Received SSN(id=1234567-1111111)
    println(ssn.id) // 1234567-1111111
}

컴파일된 바이트코드를 보면 final static classSSN 클래스가 정의되고
ssn 객체가 사용되는 영역은 래핑한 원시타입 필드 혹은 static 함수 호출로 변경된다.

아래와 같이 inline class 내부에 init 블록을 통한 검증코드를 추가하거나 별도의 property 추가가 가능하다.

inline class SSN(val id: String) {
    init {
        val isContainBar: Boolean = (id.contains("-"))
        val firstBlockIsSix: Boolean = id.split("-")[0].length == 6
        val secondBlockIsSeven: Boolean = id.split("-")[1].length == 7
        if (!(isContainBar && firstBlockIsSix && secondBlockIsSeven)) {
            throw IllegalArgumentException("invalid id value")
        }
    }

    val isMan: Boolean
        get() {
            val start: Char = id.split("-")[1][0]
            return if (start == '1' || start == '3') true else false
        }
}

내포클래스, 이너클래스, 지역클래스

class Outer {
    private val bar: Int = 1

    // 내포클래스
    class Nested {
        private val nestVar = 999
        fun foo(): Int {
            // return nestVar + bar // 내포클래스에선 외부클래스의 접근 불가
            return nestVar
        }
    }

    // 이너클래스
    inner class Inner {
        private val innerVar = 999
        fun foo(): Int {
            return innerVar + bar // private 키워드임에도 접근 가능
        }
    }
}

fun main() {
    println(Outer.Nested().foo()) // 999
    // 이너클래스는 내부맴버취급이기에 외부클래스 생성후 사용 가능하다.
    println(Outer().Inner().foo()) // 1000
}
fun localClasses() {
    // 지역클래스, 함수내부에서 클래스 선언
    open class Amphibian {
        open fun foo() = "foo"
    }

    class Frog : Amphibian() {
        override fun foo() = "bar"
    }

    val amphibian = Frog()
    println(amphibian.foo())
}


fun main() {
    localClasses()
}

익명클래스, 함수형 인터페이스

인터페이스를 구현하는 익명클래스 문법은 아래와 같다.

interface SomeInterface {
    fun someMethod()
}

interface HelloInterface {
    fun helloMethod()
}

fun main() {
    val anonymousObject = object : SomeInterface, HelloInterface {
        override fun someMethod() {
            println("some")
        }

        override fun helloMethod() {
            println("hello world")
        }
    }
    anonymousObject.someMethod() // some
    anonymousObject.helloMethod() // hello world
}

java 에선 구현메서드가 하나라면 람다형식으로 익명클래스 정의가 가능한데 kotlin 에서도 가능하다.
단 아래와 같이 함수형 인터페이스를 명시하도록 fun interface 키워드를 붙여야 한다.

fun interface SomeInterface {
    fun someMethod()
}

fun main() {
    val anonymousObject = SomeInterface {
        println("Hello, World!")
    }
    anonymousObject.someMethod()
}
fun main() {
    val anonymousObject = Runnable {
        println("Hello, World!")
    }
    anonymousObject.run() // Hello, World!
}

enum

enum class Suit(val symbol: Char) {
    CLUBS('\u2663'),
    DIAMONDS('\u2666') {
        override fun display(): String = super.display() + " ${this.symbol}"
    },
    HEARTS('\u2665'),
    SPADES('\u2660');

    open fun display() = "$symbol $name"
}

fun main() {
    println(Suit.CLUBS.symbol) // ♣
    println(Suit.CLUBS) // CLUBS
    println(Suit.valueOf("DIAMONDS").symbol) // ♦
    for (value in Suit.values()) {
        println(value.display())
        // ♣ CLUBS
        // ♦ DIAMONDS ♦
        // ♥ HEARTS
        // ♠ SPADES
    }
}

확장함수, 확장속성

확장함수를 정의해서 기존 클래스 재정의 없이 메서드를 확장할 수 있는 방법을 소개함.

// 람다형식 확장함수, 매개변수로 Int, 반환값으로 String 반환
val addInteger1: String.(Int) -> (String) = {
    "$this extends String $it"
}

// 확장함수 정의
fun String.addInteger2(num: Int): String {
    return "$this extends String $num"
}

fun main() {
    println("helloWorld!".addInteger1(1)) // helloWorld! extends String 1
    println("helloWorld!".addInteger2(2)) // helloWorld! extends String 2
}

String 에 대한 확장함수를 정의한다.

람다형식 확장함수 는 함수 정의가 아닌 함수 참조 property 를 추가하는 문법.
fun 키워드 확장함수 는 일반적인 함수를 추가로 정의하는 문법.

만약 매개변수가 하나가 아닌 여러개라면 fun 키워드 확장함수를 사용하길 권장
람다형식 확장함수 매개변수가 여러개라면 it 하나로 모든 매개변수를 표현할 수 없다.

기존에 Double 타입에서만 존재하던 pow 함수를 Int 타입에서도 사용할 수 있도록 설정

// 확장함수
fun Int.pow(x: Int) = toDouble().pow(x).toInt()

fun main() {
    var a = 3;
    var b = a.pow(2);
    println(b) // 9
}

확장함수를 통해 클래스에 메서드를 추가한것처럼, 확장속성을 통해 클래스에 마치 속성이 추가된것 처럼 구성할 수 있다.

아래의 경우 area 를 설정하면 radius 를 해당 area 에 맞춰서 변경하도록 설정했다.

data class Circle(val cx: Int, val cy: Int, var radius: Double)

// 확장속성
var Circle.area: Double
    get() = PI * radius * radius
    set(value) {
        val radius = sqrt(value / PI)
        this.radius = radius
    }

fun main() {
    val circle = Circle(100, 100, 10.0)
    println("Area is ${circle.area}") // Area is 314.1592653589793
    println("Radius is ${circle.radius}") // Radius is 10.0
    circle.area = 10.0
    println("Area is ${circle.area}") // Area is 10.000000000000002
    println("Radius is ${circle.radius}") // Radius is 1.7841241161527712
}

보다싶이 물리적으로 필드를 추가했다기 보단 추가한것 처럼 보이는 기법이라 할 수 있다.

리시버

kotlin 에서 리시버란 함수에서 해당 코드를 실행시키는 context 라 할 수 있다.
대부분의 경우 this 키워드가 가리키는 객체를 리시버로 생각하면 된다.

암시적 리시버

가리키는 리시버 를 명확하게 알고있을 경우 this 키워드를 생략하는 경우가 많은데, 이런 경우를 암시적 리시버 라 한다.

val printIt: String.(Int) -> Unit = { n: Int ->
    println("n is $n, length is $length") // this 없이 String 의 length 속성 참조
}

fun main() {
    "hello world".printIt(10) // n is 10, length is 11
}

디스패치 리시버, 익스텐션 리시버

일반적인 경우 리시버는 하나이지만, 클래스 내부에서 확장함수를 정의하면 디스패치 리시버, 익스텐션 리시버 2개의 리시버를 사용할 수 있다.

class Point(var x: Int,
            var y: Int) {
    fun Pair<Int, Int>.checkSame(): Boolean {
        // this : 익스텐션 리시버
        // this@Point: eltmvocl fltlqj
        return this.first == this@Point.x && this.second == this@Point.y
    }

    fun printIsSame(p: Pair<Int, Int>) {
        println(p.checkSame())
    }
}

fun main() {
    val p = Point(1, 2)
    p.printIsSame(Pair(1, 2)) // true
    p.printIsSame(Pair(3, 4)) // false
}

멀티플 스코프 리시버

2개 메서드를 정의한다.

  • String 객체에 대한 확장함수를 매개변수로 전달하고 실행
  • Double 객체에 대한 확장함수를 매개변수로 전달하고 실행
fun top(func: String.() -> Unit) = "hello".func()
fun nested(func: Int.() -> Unit) = (-2).func()

fun main() {
    top {
        println("In outer lambda $this and $length") // In outer lambda hello and 5
        nested {
            println("in inner lambda $this and ${toDouble()}") // in inner lambda -2 and -2.0
            println("from inner through receiver of outer: $length") // from inner through receiver of outer: 5
            println("from inner to outer receiver ${ this@top }") // from inner to outer receiver hello
        }
    }
}

정의된 확장함수의 리시버가 어떤 객체를 가리키는지 확인할 수 있다.
$length 키워드의 경우 현재 스코프의 리시버에 필드가 없기에 바로 부모 스코프의 리시버를 찾아간다.

코드구조상 스코프의 깊이가 끝없이 깊어질 수 있는데 this@... 키워드를 통해 직접 지정할 수 있다.

클래스 상속

기본적으로 kotlin 에서 class 는 항상 더이상의 상속이 진행되지 않도록 final 클래스로 정의된다.

만약 클래스 상속을 하고싶다면 다음처럼 open 키워드로 상속 가능한 클래스임을 알려야 한다.

open class Vehicle(val year: Int, open var color: String) {

    open val km = 0

    // 이후에 더이상 toString 메서드를 override 하지 못하도록 final 설정
    final override fun toString() = "year: $year, Color: $color, KM: $km"

    // repain 역시 기본 final 임으로 override 하지 못함
    fun repaint(newColor: String) {
        color = newColor
    }
}

open class Car(year: Int, color: String) : Vehicle(year, color) {
    override var km: Int = 0
        set(value) {
            if (value < 1) throw RuntimeException("can't set negative value")
            field = value
        }

    fun drive(distance: Int) {
        km += distance
    }
}

fun main() {
    val car = Car(2019, "Blue")
    car.drive(10)
    car.repaint("Red")
    println(car) // year: 2019, Color: Red, KM: 10
}

open 키워드를 property 에도 적용가능한데, 각 클래스에 해당 필드는 각각 존재하지만 [getter, setter] 가 자동으로 override 되면서 필드를 상속한것 처럼 사용할 수 있다.

sealed classopen class 클래스처럼 상속가능한 키워드이다.
하지만 동일한 파일에 작성된 클래스에서만 상속을 지원함으로 정확히 어떤 클래스들이 sealed class 를 상속하고 있는지 한눈에 파악할 수 있다.

Any, Unit, Nothing

java 에서 모든 객체의 최상위 부모객체가 Object 인것처럼
kotiln 에선 모든 객체의 최상위 부모객체가 Any 이다.

Any 클래스는 너무 많은 유연성을 제공하다 보니 사용을 자제하는 것이 좋다.

public open class Any {
    public open operator fun equals(other: Any?): Boolean
    public open fun hashCode(): Int
    public open fun toString(): String
}

...

val str: Any = "Hello, World!"

kotlin 에선 반환값이 없을때 void 대신 Unit 객체를 반환한다.
Unit 은 싱글톤 인스턴스로 void 를 포함한 모든 데이터를 객체로 표기하기 위한 kotlin 의 의지가 보이는 클래스이다.

// public object Unit {
//     override fun toString() = "kotli.unit"
// }

fun returnVoid(): Unit { // 명시적 반환 생략 가능  
    println("hello world")
    return
}

fun main() {
    val u = returnVoid() // hello world
    println(u) // kotlin.Unit
}

정말 반환하지 않는 함수를 표현하기 위해서는 Nothing 클래스를 사용한다.
무한반복이나 Exception 이 발생할 경우 반환값이 Nothing 이 된다.

fun doNothing(): Nothing {
    throw Exception("Nothing at all")
}

변수에 null 을 할당하면 Nothing? 타입으로 추론된다.

val n = null // Nothing? type
println(n is Nothing?)

또한 if...else 문을 사용하여 아래와같이 반환할 때에도 Nothing 을 사용하며
그다음 줄부턴 컴파일러가 해당 변수를 String 으로 추론한다.

// true 일경우 문자열, false 일경우 Nothing 반환
val x = if (Random.nextBoolean()) "T" else throw Exception("F")
// 이후 코드부턴 String? 으로 추론 
println(x::class.simpleName)

interface

kotlin interfacedefault 키워드 없이 메서드 정의가 가능하다.
실제로 컴파일된 바이트코드를 보면 javadefault 키워드를 사용하기 보단 클래스를 추가적으로 생성해 doubleUp 역할을 하는 static 메서드를 운영하는 방식으로 구성된다.
만약 바이트 코드를 생성시 default 키워드를 사용하는 방식으로 하고 싶다면 빌드옵션에 -Xjvm-default=all 을 적용하면 된다.

interface Remote {
    fun up()
    fun down()

    fun doubleUp() {
        up()
        up()
    }
}
/* 
Decomplied Java Byte Code
public interface Remote {
   void up();
   void down();
   void doubleUp();
   public static final class DefaultImpls {
      public static void doubleUp(@NotNull Remote $this) {
         $this.up();
         $this.up();
      }
   }
} 
*/

class TVRemote(private val tv: TV) : Remote {
    override fun up() {
        tv.volume++
    }

    override fun down() {
        tv.volume--
    }
}

static 메서드, 필드를 정의할 땐 companion object 를 사용하면 된다.

interface Remote {
    ...
    companion object {
        fun combine(first: Remote, second: Remote): Remote = object : Remote {
            override fun up() {
                first.up()
                second.up()
            }

            override fun down() {
                first.down()
                second.down()
            }
        }
    }
}

fun main() {
    val tv = TV()
    val remote: Remote = TVRemote(tv)
    println("Volume: ${tv.volume}") // Volume: 0
    remote.up()
    println("Volume: ${tv.volume}") // Volume: 1
    remote.doubleUp()
    println("Volume: ${tv.volume}") // Volume: 3

    val anotherTV = TV()
    val combinedRemote = Remote.combine(remote, TVRemote(anotherTV))
    combinedRemote.up()
    println(tv.volume) //4
    println(anotherTV.volume) //1
}

abstract class

java 와 유사하게 abstract 키워드를 사용하여 추상클래스 선언이 가능하다.

abstract class Musician(val name: String, val activeFrom: Int) {
    abstract fun instrumentType(): String
}

class Cellist(name: String, activeFrom: Int) : Musician(name, activeFrom) {
    override fun instrumentType() = "String"
}

제너릭

컬렉션에서 필수적으로 사용되는 문법으로 제너릭을 사용하면 입력받는 파라미터, 선언된 클래스 필드에 변성(variance) 기능을 추가할 수 있다.
kotlin 에서도 제너릭의 다양한 디자인을 제공하기 위한 여러 문법이 존재한다.

아래는 java와일드카드(?) 역할을 하는 스타 프로젝션(*) 문법이다.

// java 와일드 카드 문법
interface Fruit {  }

class Banana implements Fruit { }

public class TestMainJava {

    public static void receiveFruits(List<?> fruits) {
        System.out.println("Number of fruits:" + fruits.size());
    }

    public static void main(String[] args) {
        List<Banana> bananas = new ArrayList<>();
        receiveFruits(bananas);
    }
}
// kotlin 스타 프로젝션 문법
interface Fruit

class Banana : Fruit

fun receiveFruits(fruits: Array<*>) =
    println("Number of fruits: ${fruits.size}")

fun main() {
    val bananas: Array<Banana> = arrayOf()
    receiveFruits(bananas) //OK
}

java 에서 extends 를 사용해 제너릭 타입에 제약조건을 걸듯이, kotlin 에서도 아래와 같이 제약조건 지정이 가능하다.

interface AutoCloseable {
    void close();
}

public class TestMain {
    public static <T extends AutoCloseable> void useAndClose(T input) {
        input.close();
    }
}
interface AutoCloseable {
    fun close()
}

fun <T : AutoCloseable> useAndClose(input: T) {
    input.close()
}

java 에선 하나의 타입제약만 가능하지만 kotlin 에선 where 키워드를 사용하면 여러 타입에 대해 제약설정할 수 있다.

fun <T> useAndAppendAndClose(input: T) where T : AutoCloseable, T : Appendable {
    input.append()
    input.close()
}

공병성, 반공병성

기존 java 코드의 경우 와일드카드 를 사용해 클래스간 공변성(covariance), 반공변성(contravariance) 기능을 제공했었다.

// java

// 공변성
void receiveCovarianceFruits(List<? extends Fruit> fruits) { ... }
// 반공변성
void receiveContravarianceFruits(List<? super Fruit> fruits) { ... }

kotlin 에서도 out, in 키워드를 사용해 공변성 기능을 제공한다.

// 공변성
fun receiveCovarianceFruits(fruits: Array<out Fruit>) =
    println("Number of fruits: ${fruits.size}")

// 반공변성
fun receiveCovarianceFruits(fruits: Array<in Fruit>) =
    println("Number of fruits: ${fruits.size}")

물론 반공변성 매개변수는 어디까지의 상위클래스가 참조로 들어올지 모름으로, 해당 요소를 참조할 때 java 의 경우 Object, kotlin 의 경우 Any? 타입을 사용해 로컬변수로 사용한다.
별도의 타입캐스팅 과정을 거쳐야 한다보니 반공변성을 자주 사용하지 않는다.

kotlin 에선 선언처 가변성(Declaration-site variance) 이라는 java 에는 없는 기능을 제공한다.
List 의 경우 선언문에 이미 out 키워드를 작성해두어서, 함수 매개변수에 out 키워드를 작성하지 않아도 공변성 기능을 제공한다.

일반적인 제너릭 문법을 통한 가변성 기능은 사용처 가변성이라 부름

// List 선언문
// public interface List<out E> : Collection<E> { ... }

// out 키워드 없이 함수 매개변수 선언
fun receiveFruits(fruits: List<Fruit>) =
    println("Number of fruits: ${fruits.size}")

fun main() {
    val bananas: MutableList<Banana> = mutableListOf()
    receiveFruits(bananas) //OK
}

요소 E 의 하위클래스라면 언제든 매개변수로 입력받을 수 있도록 공변성을 제공한다.

클래스 델리게이션(class delegate)

kotlin 에선 Delgate(대리자) 클래스를 이용해 여러 편의기능을 지원한다.

요새는 상속보다 대리자를 통해 클래스 위임(Delegation) 하는것이 대세, 대부분 디자인 패턴에서도 상속보단 위임을 통해 클래스를 확장하는 것을 권장한다.
아래와 같이 by 키워드로 위임한 인스턴스의 메서드들을 그대로 사용할 수 있다.

만약 합성할 클래스의 메서드명이 중복될 경우 재정의를 강제하도록 컴파일 에러를 일으킨다.

interface Dialable {
    fun dial(number: String): String
    fun hello(): String
}

class Phone : Dialable {
    override fun dial(number: String) = "Dialing $number..."
    override fun hello(): String {
        return "world"
    }
}

interface Snappable {
    fun takePicture(): String
    fun hello(): String
}

class Camera : Snappable {
    override fun takePicture() = "Taking picture..."
    override fun hello(): String {
        return "hello"
    }
}

// 델리게이션을 위한 키워드 by
class SmartPhone(
        private val phone: Dialable = Phone(),
        private val camera: Snappable = Camera()
) : Dialable by phone, Snappable by camera {

    // 중복 메서드 재정의 필수
    override fun hello(): String {
        return camera.hello() + " " + phone.hello()
    }
}

fun main() {
    val smartPhone: SmartPhone = SmartPhone()
    println(smartPhone.dial("555-1234")) // Dialing 555-1234...
    println(smartPhone.takePicture()) // Taking picture...
    println(smartPhone.hello()) // hello world
}

변환된 바이트코드를 보면 아래와 같다.
알아서 위임한 객체들의 메서드를 상위 레벨로 재정의하고 매핑시킨다.

// java byte code
public final class SmartPhone implements Dialable, Snappable {
   private final Dialable phone;
   private final Snappable camera;

   public SmartPhone(@NotNull Dialable phone, @NotNull Snappable camera) {
      super();
      this.phone = phone;
      this.camera = camera;
   }
   ...
   public SmartPhone() {
      this((Dialable)(new Phone()), (Snappable)(new Camera()));
   }

   @NotNull
   public String hello() {
      return this.camera.hello() + " " + this.phone.hello();
   }

   @NotNull
   public String dial(@NotNull String number) {
      Intrinsics.checkParameterIsNotNull(number, "number");
      return this.phone.dial(number);
   }

   @NotNull
   public String takePicture() {
      return this.camera.takePicture();
   }
}

다음과 같이 by 키워드를 사용해 객체 필드명과 mapkey 값이 일치할 경우 value 에 존재하는 객체를 위임한다.

data class Project(val map: MutableMap<String, Any?>) {
    val name: String by map
    var priority: Int by map
    var completed: Boolean by map
}

fun main() {
    var map: MutableMap<String, Any?> = mutableMapOf("name" to "Learn Kotlin", "priority" to 5, "completed" to true)
    var p = Project(map)
    println(p) // Project(map={name=Learn Kotlin, priority=5, completed=true})
}

변수 델리게이션

변수에 by 키워드로 위임자 설정을 하면 [get, set] 에 대한 접근을 모두 가로챌 수 있다.

[operator fun getValue, operator fun setValue] 를 객체 내부에 정의한다.

class PoliteString(private var content: String) {

    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return content.replace("stupid", "s****************************")
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        content = value
    }
}

fun main() {
    var comment: String by PoliteString("Some nice message")
    println(comment) // Some nice message
    comment = "This is stupid"
    println(comment) // This is s****************************
    println("comment is of length: ${comment.length}") // comment is of length: 37
}

java 에는 존재하지 않는 문법이다 보니 당황스럽지만 컴파일된 바이트코드를 뜯어보면 대략 아래와 같다.

// java byte code
public final class PoliteString {
   private String content;

   public PoliteString(@NotNull String content) {
      this.content = content;
   }

   @NotNull
   public final String getValue(@Nullable Object thisRef, @NotNull KProperty property) {
      Intrinsics.checkNotNullParameter(property, "property");
      return StringsKt.replace$default(this.content, "stupid", "s****************************", false, 4, (Object)null);
   }

   public final void setValue(@Nullable Object thisRef, @NotNull KProperty property, @NotNull String value) {
      Intrinsics.checkNotNullParameter(property, "property");
      Intrinsics.checkNotNullParameter(value, "value");
      this.content = value;
   }

   public static void main(String[] var0) {
      PoliteString var10000 = new PoliteString("Some nice message");
      KProperty var1 = $$delegatedProperties[0];
      PoliteString comment = var10000;
      String var2 = comment.getValue((Object)null, var1);
      System.out.println(var2);
      comment.setValue((Object)null, var1, "This is stupid");
      var2 = comment.getValue((Object)null, var1);
      System.out.println(var2);
      var2 = "comment is of length: " + comment.getValue((Object)null, var1).length();
      System.out.println(var2);
   }
}

결국 String 대신 PoliteString 객체가 사용되고 String 내용을 참조할때 마다 [getValue, setValue] 를 통해 참조한다.

좀더 명확한 대리자 클래스 선언을 위해 [ReadWriteProperty, ReadOnlyProperty] 를 구현하는 방식으로 사용 가능하다.

object myReadDelegate : ReadOnlyProperty<Example, String> {
    override fun getValue(thisRef: Example, property: KProperty<*>): String {
        TODO("Not yet implemented")
    }
}

object myReadWriteDelegate : ReadWriteProperty<Example, String> {
    override fun getValue(thisRef: Example, property: KProperty<*>): String {
        TODO("Not yet implemented")
    }

    override fun setValue(thisRef: Example, property: KProperty<*>, value: String) {
        TODO("Not yet implemented")
    }
}

class Example {
    val readOnly: String by myReadDelegate
    var readWrite: String by myReadWriteDelegate
}


fun main() {
    val e = Example()
    println(e.readOnly)
    e.readWrite = "NEW"
}

lazy 빌트인 델리게이션

kotlin 에선 null 처리를 좀더 효율적으로 하기 위한 lazy 빌트인 델리게이션 키워드를 제공한다.
접근이 일어날 때 값이 null 이라면 () -> T 형식의 초기화 람다를 호출한다.

class Customer(val name: String) {
    val message: List<String> by lazy {
        mutableListOf("hi", "hello", "by")
            .also { println("initial complete") }
    }
}

fun main() {
    val c1 = Customer("kouzie")
    println(c1.message)
    // initial complete 
    // [hi, hello, by]
    println(c1.message) // [hi, hello, by]
}

mode 를 추가하여 lazy 코드의 락 처리가 가능하다.

val message: List<String> by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED, initializer = {
    mutableListOf("hi", "hello", "by")
        .also { println("initial complete") }
})

SYNCHRONIZED(defaulit): 오직 하나의 스레드만 Lazy 인스턴스를 초기화할 수 있게 락을 사용 PUBLICATION: 초기화 함수가 여러 번 호출될 수 있지만 첫 번째 리턴값만 사용됨 NONE: 락이 사용되지 않음

아래와 같이 if 문의 단축 평가같은 상황에서도 lazy 키워드를 사용하면 효율적인 코드작성이 가능하다.
실제 temperature 변수 참조가 이루어지기 전까지는 getTemperature 메서드가 호출되지 않는다.

fun getTemperature(city: String): Double {
    println("fetch from webservice for $city")
    return 30.0
}

fun main() {
    // val temperature = getTemperature("seoul")
    val temperature: Double by lazy { getTemperature("seoul") }
    val showTemperature = false
    if (showTemperature && temperature > 20)
        println("Warm")
    else
        println("Nothing to report")
}

// 출력값
// Nothing to report

observable, vetoable, notNull 델리게이션

변경 감지에는 observable
변경 감지 + 적용 여부를 결정에는 vetoable

fun main() {
    // init by 1
    var watched: Int by Delegates.observable(1) { prop, old, new ->
        println("${prop.name} changed from $old to $new")
    }

    // init by 0
    var checked: Int by Delegates.vetoable(2) { prop, old, new ->
        println("Trying to change ${prop.name} from $old to $new")
        new >= 0
    }

    watched = 10 // watched changed from 1 to 10
    checked = -10 // Trying to change checked from 2 to -10
    println(watched) // 10
    println(checked) // 2
}

checked 변수의 경우 vetoable 적용 여부 조건에 부합하지 않아 값이 변경되지 않았다.

observable, vetoableObservableProperty 래핑클래스로 관리되며 primitive type 도 체크 가능하다.

public abstract class ObservableProperty<T>(initialValue: T) : ReadWriteProperty<Any?, T> {
    protected open fun beforeChange(property: KProperty<*>, oldValue: T, newValue: T): Boolean = true
    protected open fun afterChange(property: KProperty<*>, oldValue: T, newValue: T): Unit {}
    public override fun getValue(thisRef: Any?, property: KProperty<*>): T {
        return value
    }

    public override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
        val oldValue = this.value
        if (!beforeChange(property, oldValue, value)) {
            return
        }
        this.value = value
        afterChange(property, oldValue, value)
    }
}

개발자가 정의한 람다식에 의해 beforeChange, afterChange 가 재정의 되는 구조이다.

Delegates.notNull() 대리자를 사용하면 초기화 전에 접근시 예외를 발생시킨다.

비슷한 키워드로 lateinit 가 있다.

fun main() {
    var str1: String by Delegates.notNull()
    println(str1) // IllegalStateException at kotlin.properties.NotNullVar.getValue
}

fun main() {
    lateinit var str2: String
    println(str2) // UninitializedPropertyAccessException
}

변환된 바이트코드는 아래와 같다.

// java byte code
public static final void main() {
    ReadWriteProperty var10000 = Delegates.INSTANCE.notNull();
    KProperty var1 = $$delegatedProperties[0];
    ReadWriteProperty str1 = var10000;
    Object var2 = str1.getValue((Object)null, var1);
    System.out.println(var2);
}

public static final void main() {
    String str2 = null;
    Intrinsics.throwUninitializedPropertyAccessException("str2");
    System.out.println(str2);
}

notNull 대리자의 경우 ReadWriteProperty 클래스로 한번 감싸기 때문에 primitive type 도 체크 가능하지만
lateinit 의 경우 변수에 null 을 대입하기에 primitive type은 사용할 수 없다.

var test:Int = null; // 컴파일 오류
lateinit var str2: Int // 컴파일 오류
public interface ReadWriteProperty<in R, T> {
    public operator fun getValue(thisRef: R, property: KProperty<*>): T
    public operator fun setValue(thisRef: R, property: KProperty<*>, value: T)
}

영역함수(scope function)

참고: https://kotlinlang.org/docs/scope-functions.html

객체 컨텍스트 안에서 코드 블록을 실행할 목적으로 만든 영역함수(scope function) 가 있다.
영역함수 를 사용하면 반복적이고 무의미한 객체 접근 코드를 생략해서 코드노이즈를 줄일 수 있다.

inline fun <T> T.apply(block: T.() -> Unit): T
inline fun <T, R> T.run(block: T.() -> R): R
inline fun <T> T.also(block: (T) -> Unit ): T
inline fun <T, R> T.let(block: (T) -> R ): R

내부의 T.() -> R 과 같은 형식이 낯설지만 제너릭 타입 T 의 람다 확장함수 라 할 수 있다.

대략 아래와 같은 형식으로 선언하고 사용할 수 있다.

data class Person(
    var name: String = "kouzie",
    var age: Int = 0
)

fun main() {
    val extFunc1: Person.() -> Unit = {
        name = "func1"
        age = 20
    }
    val extFunc2: (Person) -> Unit = {
        it.name = "func2"
        it.age = 21
    }
    val p1 = Person()
    val p2 = Person()
    p1.extFunc1()
    extFunc2(p2)
    println(p1) // Person(name=func1, age=20)
    println(p2) // Person(name=func2, age=21)
}

T.() -> Unit, (T) -> Unit 의 형태가 거의 비슷하다.

alsoapply 구현 또한 반환값을 this 를 사용해 객체 컨텍스트를 사용할지, block 함수를 반환하여 함수 컨텍스트를 사용할지 차이이다.

public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}
public inline fun <T> T.also(block: (T) -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block(this)
    return this
}
public inline fun <T, R> T.run(block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}
public inline fun <T, R> T.let(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}

일부 영역함수 들은 대체 가능할 정도로 기능이 겹치는 경우가 많다.
기능은 비슷하지만 위 url 에서 어떤 상황에서 어떤 영역함수를 사용해야하는지 불문율? 같은 권장사항을 작성해두었다.

사용하는 상황을 요약하면 아래와 같다.

  • apply 는 객체의 속성을 초기화할 때 사용
  • also 는 객체를 가공하지 않고 부가적인 코드를 실행할 때 사용
  • run 원하는 결과값을 생성하고 반환할 때 주로 사용.
  • let 분기에 따라 다른값을 반환, null 체크에 많이 사용.

apply, also

apply 블록 내부에서 객체 컨텍스트 를 가져와 속성을 초기화하거나 메서드를 호출할 때 사용한다.

fun main() {
    val person = Person()
    .apply {
        name = "test"
        age = 29
    }
    println(person) // Person(name=test, age=29)
}

also 블록 내부에서 함수 컨텍스트 를 가져와 코드 흐름을 방해하지 않고 부가적인 코드를 실행시킬 때 사용한다.

fun main() {
    val person = Person()
    .apply {
        this.name = "test"
        this.age = 29
    }
    .also { ps -> println(ps) } // Person(name=test, age=29)
    .also { println("my name is ${it.name} and ${it.age}") } // my name is test and 29
}

run, let

객체를 유지하지 않고 표현식의 결과를 유지하고 싶을 때 run 영역함수를 사용한다.

fun main() {
    val greetMsg: String = Person().run {
        name = "hello"
        age = 29
        "my name is $name and $age"
    }
    println(greetMsg) // my name is hello and 29
}

let 영역함수는 주로 아래와 같이 조건별 분기처리, :? 연산자와 같이 사용해 null 조건을 처리하는데 많이 사용한다.

fun processString(str: String?): String = str?.let {
    when {
        it.isEmpty() -> "Empty"
        it.isBlank() -> "Blank"
        else -> it.uppercase()
    }
} ?: "Null"

fun main() {
    println(processString("")) // Empty
    println(processString(null)) // Null
    println(processString("test")) // TEST
}

[run, let] 또한 서로 유사하며 대체가능하지만 개발자가 정해둔 두 영역함수 사용의 일반적 룰이 있다.

with

with 영역함수[let, run] 과 같이 표현식의 결과를 반환하지만 아래처럼 매개변수로 람다형식의 확장함수를 실행시킬 객체를 전달받는다.

public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return receiver.block()
}
fun main() {
    val p = Person("test", 20)
    with(p) {
        println("my name is $name and $age")
    }
}

with 은 표현식을 반환하지만 also 처럼 코드 흐름을 방해하지 않고 부가적인 코드를 실행시 사용한다.

대부분 반환할 표현식을 with 코드안에 적지 않는다.

DSL

DSL(Domain Specific Languages: 도메인 특화언어)컨텍스트 주도적(context driven) 패턴을 가진 언어이다.
DSL 내부에서 같은 컨텍스트를 공유하여 유창하고 간결하게 코드를 작성할 수 있다. 컨테스트 공유과정에서 에러발생 확률도 줄일 수 있다.

kotlin 은 개발초기부터 간결한 함수표현식, 리터럴표현식, infix 표현식, 영역함수 등을 제공하여 코드노이즈를 최소화 하고 DSL 패턴을 사용하기 쉽게 구성된 언어이다.

아래는 리터럴표현식을 사용해 DSL 패턴을 사용한 예이다.

class Node(private val name: String) {
    // xml 속성
    private var attributes: Map<String, String> = mutableMapOf()

    // 자식 목록
    private var children: List<Node> = mutableListOf()

    // text value
    private var textValue: String = ""

    fun text(value: String) {
        textValue = value
    }

    // 리터럴 표현식 사용
    fun element(childName: String,
                vararg attributesValues: Pair<String, String>,
                block: Node.() -> Unit): Node {
        val child = Node(childName)
        attributesValues.forEach { child.attributes += it }
        children += child
        return child.apply(block)
    }

    private fun toString(indentation: Int): String {
        val attributesValues =
                if (attributes.isEmpty()) ""
                else attributes
                        .map { "${it.key}=${it.value}" }
                        .joinToString(" ", " ")
        val DEPTH = 2
        val indent = " ".repeat(indentation)
        return if (textValue.isNotEmpty())
            "$indent<$name$attributesValues>$textValue</$name>"
        else
            """|$indent<$name$attributesValues>
                |${children.joinToString("\n") { it.toString((indentation + DEPTH)) }}
                |$indent<$name>""".trimMargin()
    }

    override fun toString(): String {
        return toString(0)
    }
}

class XMLBuilder {
    fun root(rootElementName: String, block: Node.() -> Unit): Node =
            Node(rootElementName).apply(block)
}

fun xml(block: XMLBuilder.() -> Node): Node = XMLBuilder().run(block)

fun main() {
    val langsAndAuthors = mapOf(
            "JavaScript" to "Eich",
            "Java" to "Gosling",
            "Ruby" to "Matz")

    val xmlString = xml {
        root("languages") {
            langsAndAuthors.forEach { (name, author) ->
                element("language", "name" to name) {
                    element("author") {
                        text(author)
                    }
                }
            }
        }
    }
    println(xmlString)
}
/*
<languages>
  <language name=JavaScript>
    <author>Eich</author>
  <language>
  <language name=Java>
    <author>Gosling</author>
  <language>
  <language name=Ruby>
    <author>Matz</author>
  <language>
<languages>
*/

@DslMarker

위 코드의 경우 리터럴 표현식에 맞기만 하면 어떠한 람다함수도 모두 허용한다.
그래서 아래와 같은 level 맞지 않는 DSL 표현식도 허용한다.

val xmlString = xml {
    root("languages") {
        langsAndAuthors.forEach { (name, author) ->
            element("language", "name" to name) {
                root("author") { // error 가 발생하지 않음
                    text(author)
                }
            }
        }
    }
}

이런 코드가 가능한 이유는 부모 스코프인 XMLBuilder 객체의 리시버에 접근가능하기 때문인데
@DslMarker 어노테이션을 사용하면 현재 스코프의 리시버에만 접근하도록 강제한다.

@DslMarker
annotation class XMLMarker

@XMLMarker
class Node(private val name: String) { ... }

@XMLMarker
class XMLBuilder { ... }

카테고리:

업데이트: