Kotlin - 12. 유연하게 작성하는 코드

2021/11/15
language

연산자 오버로딩

코틀린에는 자바에서는 지원하지 않는 몇 가지 기능이 있습니다. 그 중 하나는 연산자 오버로딩입니다.
이름 그대로 연산자를 오버로딩하는 기능입니다. C++에서 지원하지만 자바에서는 가독성을 떨어뜨린다는 이유로 추가되지 않았었습니다. 다만 제대로 사용한다면 코드를 간결하게 작성 할 수 있다는 장점이 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
data class Matrix(private val m: Array<IntArray>) {
operator fun plus(o: Matrix): Matrix {
if (m.size != o.m.size) throw IllegalArgumentException("Size not matched")
if (!(m zip o.m).all { (a1, a2) -> a1.size == a2.size }) throw IllegalArgumentException("Size not matched")

return Matrix((m zip o.m).map { (a1, a2) -> (a1 zip a2).map {(i1, i2) -> i1 + i2}.toIntArray() }.toTypedArray())
}

override fun toString(): String {
return m.map { it.joinToString(",") }.joinToString("\n")
}
}

val m1 = Matrix(listOf(listOf(1,2), listOf(2,3)))
val m2 = Matrix(listOf(listOf(1,2), listOf(2,3)))
println(m1 + m2) // 2,4
// 4,6

예제코드에서는 행렬을 클래스화여 구현 했습니다.
여기서는 plus 라는 연산자를 오버로딩하여 두 행렬을 더하는 연산을 만들었습니다. 연산자 오버로딩을 위해 함수 앞에 operator 라는 지시어를 필요로 하고 컴파일러에서는 이렇게 선언된 메소드를 확인하여 사전에 정의된 형태와 다를 경우 에러를 발생시킵니다.
그리고 실제 사용은 println 에서 처럼 + 로 우리에게 익숙한 형태로 표현이 가능합니다.

연산자 오버로딩은 클래스 내부에 선언하거나 확장함수를 통해 구현이 가능합니다. 확장함수를 통해서도 구현이 가능하기 때문에, 외부 라이브러리(심지어 다른 언어로 작성되어도)에서 정의된 클래스도 구현이 가능합니다.
연산자를 실행하는 객체는 이항식의 좌항이거나 단항식의 아규먼트 그자체가 됩니다.

JVM 에서는 연산자 오버로딩을 지원하지 않기 때문에, 코틀린에서는 아래의 표 같이 특별한 이름의 함수에 연산자를 정의된 메소드 이름에 매핑하여 구현되어 있습니다.

매핑된 연산자

단항 연산자

표현 매핑된 이름
+a a.unaryPlus()
-a a.unaryMinus()
!a a.not()
a++ a.inc()
a– a.dec()

이러한 연산자를 코틀린의 기본 타입들에 대해 사용하는 경우 추가 오버헤드를 발생하지 않습니다.

++, – 연산자의 경우 내부의 값을 변환하지 않고 새롭게 생성된 객체를 리턴해야 합니다.
그리고 증/감 연산자는 앞/뒤 위치에 따라 연산 방식이 바뀌는데 전위연산의 경우 아래의 형태로 실행됩니다.

  • 어떤 객체 a 에 대해 a.inc() 연산 실행 후 a에 할당
  • a가 어떤 표현식에 대해 연산된 새로운 객체를 리턴

후위 연산의 경우 아래와 같이 연산됩니다.

  • 초기값 a를 a0 에 저장
  • a 에 a0.inc() 한 값을 저장
  • 값은 a0을 리턴

이항 연산자

표현 매핑된 이름
a + b a.plus(b)
a - b a.minus(b)
a * b a.times(b)
a / b a.div(b)
a % b a.rem(b)
a..b a.rangeTo(b)
a in b b.contains(a)
a !in b !b.contains(a)
a[i] a.get(i)
a[i, j] a.get(i, j)
a[i_1, …, i_n] a.get(i_1, …, i_n)
a[i] = b a.set(i, b)
a[i, j] = b a.set(i, j, b)
a[i_1, …, i_n] = b a.set(i_1, …, i_n, b)
a() a.invoke()
a(i) a.invoke(i)
a(i, j) a.invoke(i, j)
a(i_1, …, i_n) a.invoke(i_1, …, i_n)
a += b a.plusAssign(b)
a -= b a.minusAssign(b)
a *= b a.timesAssign(b)
a /= b a.divAssign(b)
a %= b a.remAssign(b)
a == b a?.equals(b) ?: (b === null)
a != b !(a?.equals(b) ?: (b === null))
a > b a.compareTo(b) > 0
a < b a.compareTo(b) < 0
a >= b a.compareTo(b) >= 0
a <= b a.compareTo(b) <= 0
println(a) (변수 사용) getValue()
a = * (변수 대입) setValue(*)

주의사항

위에서도 언급했듯이 자바에서는 난해한 부분에서의 연산자 오버로딩의 악용으로 인해 연산자 오버로딩를 제한하였습니다. 그만큼 양날의 검이라고 할 수 있는 기능입니다. 따라서 아래의 규칙을 지키도록 미리 프로젝트 전에 논의해야합니다.

  • 최대한 사용하지 않습니다.
  • 상식적인 선에서 사용합니다.
  • 오버로딩된 연산자는 연산자가 의미하는 그대로 구현되어야 합니다.
  • 변수이름을 의미있게 만들어 다른사람이 보기에 읽기 좋게 만들어야 합니다.

확장 함수 & 속성

코틀린에서는 확장함수를 통해 기존에 작성된 클래스에 대해서 기능을 확장할 수 있습니다. 이는 타언어로 작성된 라이브러리에 정의된 클래스에서도 마찬가지 이고 final 클래스 역시 마찬가지 입니다.

구현하기 전에 내가 작성하고자 하는 메소드가 기존에 있는 메소드인지 확인이 필요합니다. 기존에 있는 메소드일 경우 해당 메소드를 우선적으로 먼저 실행합니다.

1
2
3
4
fun String?.isEmpty() = when(this) {
null -> true
else -> length == 0
}

위에 nullable 한 String에 isEmpty 라는 메소드를 추가했습니다.
선언시에 메소드 이름 앞에 확장할 클래스의 이름을 먼저 표시한 후에 .로 구분합니다. 그리고 함수 바디 내부에서 this 라는 레퍼런스에 접근하여 현재 이 메소드를 실행하는 객체에 접근 할 수 있습니다.

다만 이런 확장함수의 한계가 있습니다. 사실 확장 함수는 static 메소드를 문법적으로 편하게 사용할 수 있게 해주는 것입니다. 따라서 확장 대상 객체의 private 메소드나 필드에 대해서는 접근 할 수 없습니다.

필드 역시 확장 할 수 있는데, 아래와 같이 선언 할 수 있습니다.

1
2
3
val List<Int>.numOfOdds: Int = this.filter { it % 2 == 1}.count()

listOf(1,2,3,4,5).numOfOdds // 3

이걸 자바에서 사용할 땐 아래처럼 사용합니다.

1
TestKt.getNumOfOdds(Arrays.asList(1,2,3,4,5))

보이는 것 처럼 static 한 형태로 사용하는걸 확인 할 수 있습니다.

주의사항

기존에 정의된 메소드의 동작을 오버로딩하면 안됩니다. 인스턴스 메소드로 정의된 메소드는 항상 우선순위를 가지지만 다른 라이브러리(예를 들어 코틀린 stdlib)에서 정의된 확장함수와 동일한 형태로 확장 함수를 추가 정의하면 덮어씌울 수 있게 됩니다.

1
fun <T> List<T>.binarySearch(element:T) = this.filter { it == element }.first() // ?

이는 협업자들에게 많은 문제의 소지가 있기 때문에 꼭 피해야 합니다.

static method 인젝팅

1
fun String.Companion.parseUrl(link: String) = URL(link)

클래스 내부의 companion 객체도 역시 객체이기 때문에 확장함수를 추가할 수 있습니다.

클래스 내부에서의 인젝팅

확장함수의 적용 범위를 클래스 내부로 제한 할 수도 있습니다.
Matrix 에서 정의한 allSize 를 외부에서 사용하려면 컴파일 에러가 나게 됩니다.

조심해야 할 부분은 this 인데, 일반적인 this 는 클래스자체의 this도 있고 확장함수의 this 도 있습니다. 따라서 이를 명확하게 표시하기 위해 라벨을 통해 표시 할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
data class Matrix(val m: List<List<Int>>) {
infix operator fun plus(o: Matrix): Matrix {
if (m.size != o.m.size) throw IllegalArgumentException("Size not matched")
if (!(m zip o.m).all { (a1, a2) -> a1.size == a2.size }) throw IllegalArgumentException("Size not matched")

return Matrix((m zip o.m).map { (a1, a2) -> (a1 zip a2).map {(i1, i2) -> i1 + i2} })
}

override fun toString(): String {
return """${m.allSize()}\n${m.map { it.joinToString(",") }.joinToString("\n")}"""
}

fun List<List<Int>>.allSize() = this@allSize.sumOf { it.count() } // use labeled this
}

함수 확장

코틀린에서는 함수 역시 객체로 취급합니다. 따라서 여기에도 확장함수가 가능합니다.

1
inline fun <V, T, R> ((T) -> R).compose(crossinline l: (V) -> T): (V) -> R = { t: V ->  this(l(t)) }

crossinline 으로 자바버전의 compose와 다르게 최적화 할 수도 있습니다.

infix를 이용한 중위표현식

코틀린에서는 메소드를 중위표현식으로도 표현이 가능합니다. 앞서 봤던 중위표현식으로 to가 있습니다.

to는 두개의 객체를 하나의 Pair로 묶는 함수로 아래와 같이 정의되어 있습니다.

1
2
3
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

1 to 2

infix 지시어를 사용하기 위해서는 메소드 혹은 확장함수여야 하고 파라메터도 하나만 해야합니다.

Scope functions

코틀린에서는 Any 를 통해 정의된 확장함수가 있습니다.
기본적으로 also, apply, let, run, with 라는 함수가 있습니다. 이 함수들은 람다를 하나 파라메터로 받고 전달받은 람다를 실행하고 객체를 리턴해주는 구조를 가지지만 약간의 디테일이 다릅니다.

Function Object reference Return value Is extension function
let it Lambda result Yes
run this Lambda result Yes
run - Lambda result No: called without the context object
with this Lambda result No: takes the context object as an argument.
apply this Context object Yes
also it Context object Yes

위는 공식 문서에서 제시한 각각의 차이점입니다.
간략하게 설명하면 다음과 같습니다.

  • let
    • null 이 아닌 객체에 대해 람다를 실행 할 때
    • 표현식을 로컬 범위로 사용할 때
  • apply
    • 객체 설정을 할 때
  • run
    • 객체를 설정하고 결과를 계산할 때
    • 비확장함수에서 표현식이 필요할 때
  • also
    • 객체를 사용할 때
  • with
    • 어떤 하나의 객체에 대하여 묶어서 호출 하고 싶을 때

차이점

  1. this or it

scope function 사용상의 차이점 중 하나는 파라메터입니다.
대표적으로 run의 경우 람다에서 별다른 파라메터 없이 자기 자신에 대한 확장 함수로서 람다가 취급되기 때문에 this로 접근이 가능하지만 let 의 경우 자기 자신에 대한 객체 레퍼런스를 it(혹은 파라메터)로 받기 때문에, 사용 방식에 차이가 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
fun main() {
val str = "Hello"
// this
str.run {
println("The receiver string length: $length")
//println("The receiver string length: ${this.length}") // does the same
}

// it
str.let {
println("The receiver string's length is ${it.length}")
}
}

this 를 사용하는 함수는 run, with, apply 로 컨텍스트 객체에 접근할 때 this를 사용하기 때문에 별도의 객체 참조가 필요하지 않다는 장점이 있습니다.
하지만 이 함수를 실행하는 부분이 클래스의 내부라면 this에 대한 참조가 겹치기 때문에 이로인한 혼란이 생길 수 있습니다.

it를 사용하는 함수는 let, also 입니다. 이들은 앞서 소개한 함수들과는 다르게 파라메터의 형태로 넘어가기 때문에 it(혹은 정의된 파라메터변수)를 통해 접근해야 합니다.
당연히 객체 접근을 위해 명시적으로 표시해야 하지만 여러 코드블록이 겹친상황에서는 더 편하게 사용 할 수 있다는 장점이 있습니다.

  1. 리턴

apply, also 의 경우 객체 자체를 리턴값으로 가지며 파라메터인 람다의 경우 리턴값이 Unit 임을 알 수 있습니다.
이는 내부에서 값을 처리하고 람다의 리턴값을 사용하지 않고 객체 자체를 내리는 것을 알 수 있습니다.
let, run, with 는 람다에서 리턴된 값을 실제 함수에서로 리턴하는 구조입니다.

이러한 특성을 사용해 코드를 작성할 때 다음에 사용할 코드에 따라 두 가지 형태의 함수들 중에서 원하는 것을 선택하게 됩니다.

활용

위 함수는 우리의 코드를 좀 더 간결하게 만들어줍니다.
맵 객체를 하나 선언하다고 해봅시다.

1
2
3
4
5
@Bean
open fun someProperty() = HashMap<String, String>().apply {
put("test1", getData("test1"))
put("test2", parse(get("test1")))
}.toMap()

일반적으로 맵을 정의하는데에는 mapOf, mutableMapOf를 사용하는게 일반적입니다.

여기서는 apply 를 사용했는데 이 함수를 이용해 put, get의 호출을 간소화 했으며 또한 이 전반적인 프로세스를 하나의 람다로 담았기 때문에, 하나의 표현식으로 인식되어 함수 바디를 별도로 선언하지 않고 = 표시법으로 선언함을 볼 수 있습니다. 이렇게 적절한 scope function 으로 코드의 생산성을 늘릴 수 있습니다.


참고자료