Kotlin

[Kotlin] 컬렉션과 범위에 대해 쓸 수 있는 convention(관례)

sh1mj1 2024. 1. 24. 13:55

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

컬렉션에는 인덱스 연산자(`a[b]`) 를 사용항 인덱스로 원소를 설정하거나 가져올 수 있다.

`in` 연산자는 원소가 컬렉션이나 범위에 속하는지 검사하거나 원소를 iteration 할 때 사용한다.

커스텀 클래스에서 이러한 연산들을 추가할 수 있다.

인덱스로 원소에 접근: get & set

코틀린이나 자바에서 맵이나 배열 원소에 접근할 때 모두 `[]` 을 사용한다.

코틀린에서는 인덱스 연산자도 convention 을 따른다.

  • 인덱스 연산자를 사용해 원소를 읽는 연산 -> `get` 연산자 메서드로 변환
  • 인덱스 연산자를 사용해 원소를 쓰는 연산 -> `set` 연산자 메서드로 변환

`Point` 클래스에 점의 좌표를 읽을 때 인덱스 연산을 사용할 수 있다.

 

`Point` 클래스 `get` convention 구현하기

data class Point(var x: Int, var y: Int)

// get 연산자 함수를 정의
operator fun Point.get(index: Int): Int = when (index) {
    0 -> x
    1 -> y
    else -> throw IndexOutOfBoundsException("Invalid coordinate $index")
}

val p = Point(10, 20)
assert(p[1] == 20)

 `get` 메서드를 만들고 `operator` 변경자를 붙이기만 하면 된다.

  • `get` 메서드의 파라미터로 `Int` 가 아닌 타입도 사용할 수 있다. (맵 인덱스 연산에서 키 타입을 인덱스로 사용)
  • 여러 파라미터를 사용하는 `get` 을 정의할 수도 있다.
    • 예: 2차원 행렬이나 배열을 표현하는 클래스에 `operator fun get(rowIndex: Int, colIncex: Int)` 를 정의하면 `matrix[row, col]` 로 그 메서드를 호출할 수 있다.

인덱스에 해당하는 컬렉션 원소를 쓰고(write) 싶을 때는 `set` 이라는 이름의 함수를 정의하면 된다.

위에서 사용한 `Point` 는 immutable 클래스이므로 새 클래스를 정의해서 `set` 을 구현해보자.

 

`MutablePoint` 에 convention 을 따르는 `set` 구현

data class MutablePoint(var x: Int, var y: Int)

// set 연산자 함수 정의
operator fun MutablePoint.set(index: Int, value: Int) {
    when (index) {
        0 -> x = value
        1 -> y = value
        else -> throw IndexOutOfBoundsException("Invalid coordinate $index")
    }
}

val p = MutablePoint(10, 20)
p[1] = 42
assert(p == MutablePoint(10, 42))

 이 예제도 간단하다.

in 관례

`in` 은 객체가 컬렉션에 들어있는지 검사(멤버십 검사: membership test)한다.

`in` 연산자와 대응하는 함수는 `contains` 이다.

 

`in` convention 구현하기: 어떤 점이 사각형 안에 들어가는지 판단 

data class Rectangle(val upperLeft: Point, val lowerRight: Point)

operator fun Rectangle.contains(p: Point): Boolean =
    p.x in upperLeft.x until lowerRight.x && p.x in upperLeft.y until lowerRight.y

val rect = Rectangle(Point(10, 20), Point(50, 50))
assertTrue { Point(20, 30) in rect }
assertFalse { Point(5, 5) in rect }

 `in` 의 우항에 있는 객체는 `contains` 메서드의 수신 객체(receiver)가 되고, `in` 의 좌항에 있는 객체는 `contains` 메서드에 인자로 전달된다.

`10..20` 이라는 식을 사용하면 닫힌 범위가 만들어져서 10 이상 20 이하인 범위가 생긴다

`10 until 20` 식을 사용하면 열린 범위가 만들어져서 10 이상 19 이하인 범위가 생긴다.

코틀린 1.9 부터는 `raungeUnitl` 이라는 operator 함수를 사용하는 것이 권고된다.
/**
 * Creates a range from this value up to but excluding the specified [other] value.
 *
 * If the [other] value is less than or equal to `this` value, then the returned range is empty.
 */
@SinceKotlin("1.9")
@WasExperimental(ExperimentalStdlibApi::class)
public operator fun rangeUntil(other: Int): IntRange​
그래서 `until` 대신 `..<` 을 사용할 수 있다.
p.x in upperLeft.x..<lowerRight.x && p.x in upperLeft.y..<lowerRight.y​

rangeTo 관례

범위를 만들 때 `..` 구문을 사용한다. (`1 .. 10` 은 1 부터 10까지 모든 수의 범위)

`..` 연산자는 `rangeTo` 함수를 간략하게 표현하는 방법이다.

  • `rangeTo` 함수는 범위를 리턴한다.
  • 아무 클래스에나 정의할 수 있다.
  • `Comparable` 인터페이스를 구현한 클래스는 `rangeTo` 를 정의할 필요가 없다.
/**
 * Creates a range from this [Comparable] value to the specified [that] value.
 *
 * This value needs to be smaller than or equal to [that] value, otherwise the returned range will be empty.
 * @sample samples.ranges.Ranges.rangeFromComparable
 */
public operator fun <T : Comparable<T>> T.rangeTo(that: T): ClosedRange<T> = ComparableRange(this, that)

이 함수는 범위를 리턴하며 어떤 원소가 범위 안에 들어있는지 `in` 을 통해 검사할 수 있다.

 

날짜의 범위 다루기: `LocalDate` 클래스 사용

val now = LocalDate.now()
val vacation = now..now.plusDays(10)
assertTrue { now.plusWeeks(1) in vacation }

 `now..nowPlusDays(10)` 이라는 식은 컴파일러에 의해 `now.rangeTo(now.plusDays(10))` 으로 변환된다.

  • `rangeTo` 함수는 `LocalDate` 의 멤버가 아닌 `Comparable` 에 대한 확장 함수이다.
  • `rangeTo` 함수는 다른 산술 연산자보다 우선순위가 낮다. 
    혼동을 피하기 위해 괄호로 인자를 감싸주자.
    • `0..n.forEach { }` 식은 컴파일되지 않는다. 
      `(0..n).forEach { }` 처럼 범위를 괄호로 둘러싸자.

for 루프를 위한 iterator 관례

`for (x in list) { ... }` 처럼 코틀린의 for 루프는 `in` 연산자를 사용한다.

하지만 `in` 의 의미는 범위 검사와 다르다.

위와 같은 문장은 `list.iterator()` 를 호출해서 `iterator` 를 얻은 다음, 자바와 마찬가지로 그 `iterator` 에 대해 `hasNext` 와 `next` 호출을 반복하는 식으로 변환된다.

 

`Iterator` 인터페이스

/**
 * An iterator over a collection or another entity that can be represented as a sequence of elements.
 * Allows to sequentially access the elements.
 */
public interface Iterator<out T> {
    /**
     * Returns the next element in the iteration.
     */
    public operator fun next(): T

    /**
     * Returns `true` if the iteration has more elements.
     */
    public operator fun hasNext(): Boolean
}

 코틀린에서는 이 또한 관례이므로 iterator 메서드를 확장 함수로 정의할 수 있다.

그래서 일반 자바 문자열에 대한 for 루프가 가능하다.

 

코틀린 stlib 의 `CharSequence` 에 대한 `iterator` 확장함수

public operator fun CharSequence.iterator(): CharIterator = object : CharIterator() {
    private var index = 0

    public override fun nextChar(): Char = get(index++)

    public override fun hasNext(): Boolean = index < length
}

 

클래스 안에 직접 iterator 메서드를 구현할 수도 있다.

날짜 범위에 대한 `iterator` 구현하기

operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =
    object : Iterator<LocalDate> { // 이 객체는 LocalDate 원소에 대한 iterator 를 구현
        var current = start // start 는 ClosedRange 의 프로퍼티임
        override fun hasNext(): Boolean = current <= endInclusive // compareTo 관례를 사용하여 날짜를 비교

        override fun next(): LocalDate = current.apply { // 현재 날짜를 저장한 후 날짜를 변경. 그 후 저장해둔 날짜를 리턴
            current = plusDays(1) // 현재 날짜를 1일 뒤로 변경
        }
    }

val newYear = LocalDate.ofYearDay(2017, 1)
val daysOff = newYear.minusDays(1)..newYear
for (dayOff in daysOff) { println(dayOff) }
// print
/* 
2016-12-31
2017-01-01
*/

 hasNext 와 next 호출의 반복으로 이루어진다.

`hasNext` 와 `next` 호출 과정
처음 `current` 는 `2016-12-31`
`hasNext` 호출 -> `2016-12-31 <= 2017-01-01` 이므로 `true`
`next` 호출 -> `2016-12-31` 리턴. `current` 가 `2017-01-01` 이 됨.
`hasNext` 호출 -> `2017-01-01 <= 2017-01-01` 이므로 `true`
`next` 호출 -> `2017-01-01` 리턴. `current` 가 `2017-01-02` 이 됨.
`hasNext` 호출 -> `2017-01-02 > 2017-01-01` 이므로 `false`