본문 바로가기

코틀린

[코틀린] 확장 (Extension)

안드로이드 개발하는 kancho입니다.

이번 포스팅에서는 코틀린의 확장 기능에 대해 알아보고자 합니다.

'코틀린 완벽 가이드' 책을 참고하였습니다.

 

 

확장 (Extension)

확장은 말 그대로 기존의 것을 더 넓고 크게 사용한다는 것이다.

코드를 작성하다 보면 기존의 클래스를 확장시켜야 하는 경우가 생긴다. 내가 직접 만든 클래스에서는 쉽게 함수를 추가하거나 커스텀하게 원하는 동작을 추가할 수 있다. 하지만 자바나 코틀린에서 기본적으로 제공되는 라이브러리나 외부 라이브러리 같은 경우에는 이미 정의된 것 외에는 동작을 추가하기가 어렵다. 

 

그래서 코틀린에서는 클래스 밖에서 함수나 프로퍼티를 선언할 수 있도록 확장이라는 기능을 제공한다.

확장을 사용하면 기존 클래스를 변경하지 않고 확장할 수 있어 OCP(개방-폐쇄 원칙)를 지킬 수 있다.

 

확장은 크게 확장 함수와 확장 프로퍼티로 나눌 수 있다.

 

확장 함수

확장 함수(Extension function)는 어떤 클래스의 멤버인 것처럼 호출할 수 있는 함수이다.

 

구조 

(수신 객체의 클래스 이름).(함수 이름)

 

/*
String : 클래스 이름 ( nullable한 타입도 가능 )
validateEmail : 함수 이름
*/
fun String?.validateEmail() { }

 

 

위 확장 함수의 코드는 JVM에서 아래와 같이

수신 객체(String?)를 가리키는 파라미터가 추가된 정적 메서드로 표현된다.

 

public final class UtilKt {
   public static final void validateEmail(@Nullable String $this$validateEmail) {
   }
}

 

사용

예를 들어, 어떤 String이 이메일 형식인지 판별하기 위해 기존에는 없던 validateEmail이라는 확장 함수를 만든다고 할 때,

아래와 같이 만들 수 있다.

 

/*
본문 안에서 this로 수신객체(String?) 접근 가능
수신 객체가 nullable한 경우 null check도 필요하다
*/
fun String?.validateEmail() =
    if (this == null) false
    else Pattern.compile(
        "[a-zA-Z0-9+._%\\-]{1,256}" + "@" +
                "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" + "(" +
                "\\." + "[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25}" + ")+"
    ).matcher(this).matches()
    
fun main() {
    println("kancho@tistory.com".validateEmail())	// true
    println("123456789".validateEmail())		// false
    
    /*
    null safety를 사용하지 않고 확장 함수를 호출할 수 있다
    validateEmail 확장 함수가 null check를 담당한다
    */
    val s = readLine()
    println(s.validateEmail())
}

 

 

확장 함수 자체는 수신 객체가 속한 타입의 캡슐화를 깰 수 없다.

하지만 클래스 본문 안에서의 확장 함수는 수신 객체의 멤버인 동시에 확장 함수라 접근이 가능하다.

 

// 비공개 멤버인 name, email에 접근 불가능
class User(private val name: String, private val email: String)
fun User.showInfo() = println("$name $email")   // error


// 비공개 멤버인 name, email에 접근 가능
class User(private val name: String, private val email: String) {
    fun User.showInfo() = println("$name $email")	// ok
}

 

 

확장 함수는 바인딩된 호출 기능 참조(bound callable reference)로 사용 가능하다.

 

class User(val name: String, val email: String)

fun User.hasName(name: String) = name.equals(this.name, ignoreCase = true)

fun main() {
    val a = User("kancho", "kancho@tistory.com")::hasName
    println(a("kancho"))    // true
    println(a("tistory"))   // false
}

 

 

만약 클래스 멤버와 동일한 이름, 파라미터 개수와 타입을 가지는 확장 함수가 있다면 어떻게 될까?

 

// 컴파일러가 항상 멤버 함수를 우선적으로 선택
class User(val name: String, val email: String) {
    fun userInfo() = "$email $name"
}

// 사용되지 않음
fun User.userInfo() = "$name $email"

fun main() {
    val a = User("kancho", "kancho@tistory.com")
    println(a.userInfo())
}

/* result ->
kancho@tistory.com kancho
*/

그래서 확장 함수는 코드의 가독성을 해치지 않으며 확장하는 구조와 의미를 잘 생각하며 정의해야 한다.

 

 

또한, 다른 패키지에 확장 함수가 정의된 경우 확장을 import 해야 한다.

 

// data
package data

fun String.truncate(maxLength: Int) =
    if (this.length <= maxLength) this else substring(0, maxLength)
    

// util
package util

import data.truncate

fun main() {
    "Hello".truncate(3)
}

 

 

 

확장 프로퍼티

확장 프로퍼티(Extension property)는 어떤 클래스의 멤버인 것처럼 호출할 수 있는 프로퍼티이다.

 

구조 

(수신 객체의 타입).(이름)

 

// Long클래스에 대해 getDay라는 확장 프로퍼티 정의
val Long.getDay: Int
    get() = (this / 1000 / 60 / 60 / 24).toInt()

 

사용

확장 프로퍼티에는 backing field를 사용할 수 없다.

따라서 프로퍼티 초기화가 불가능하고 접근자 내에서 field를 사용하지 못한다.

immutable 변수는 getter를 명시해야 하고 mutable 변수는 setter, getter를 명시해야 한다.

또한 확장 프로퍼티에 위임을 사용할 수 있다.

 

// 초기화 할 수 없다
val Long.getDay: Int = 10	// error

// 불변 변수에 getter 명시
val Long.getDay: Int
    get() = (this / 1000 / 60 / 60 / 24).toInt()

// 가변 변수에 setter, getter 명시
var IntArray.midValue
    get() = this[lastIndex/2]
    set(value) {
        this[lastIndex/2] = value
    }

fun main() {
    val numbers = IntArray(6) { it * it } // 0, 1, 4, 9, 16, 25

    println(numbers.midValue)   // 4
    numbers.midValue *= 10
    println(numbers.midValue)   // 40
}


// 위임 사용 가능
val String.message by lazy { "Hello" }

 

 

 

동반 확장

동반 객체(companion object)에 대해 확장할 수 있다.

 

구조 

(수신 객체).(Companion).(이름)

 

fun IntRange.Companion.singletonRange(n: Int) = n..n

 

사용

클래스 이름을 사용해 호출한다.

 

fun IntRange.Companion.singletonRange(n: Int) = n..n

val String.Companion.HELLO
    get() = "Hello"

// 완전한 동반 객체 이름을 사용해도 된다
fun main() {
    println(IntRange.singletonRange(5)) // 5..5
    println(IntRange.Companion.singletonRange(3))   // 3..3
    
    println(String.HELLO)
    println(String.Companion.HELLO)
}

 

 

동반 객체가 존재하는 경우에만 동반 객체에 대한 확장을 정의할 수 있다.

 

class User(val name: String, val email: String) {
    companion object { }
}

val User.Companion.UNKNOWN by lazy { 
    User("kancho", "kancho@tistory.com")
}

// Any는 동반객체가 존재하지 않는다.
fun Any.Companion.hello() = println("Hello")	// error

 

 

람다, 익명 함수 확장

코틀린에서는 람다나 익명 함수에 대해 확장 수신 객체를 활용할 수 있다.

수신 객체 지정 함수 타입(functional type with receiver)으로 표현된다.

 

/*
2개의 parameter를 받는 aggregate 함수
Int.(Int) -> Int 수신객체 타입
*/
fun aggregate(numbers: IntArray, op: Int.(Int) -> Int): Int {
    var result = numbers.firstOrNull()
        ?: throw IllegalArgumentException("Empty Array")

    numbers.forEach {
        result = result.op(it) 
    }
    
    return result
}

// aggregate에 전달한 람다는 수신객체를 가져 this 사용 가능
fun sum(numbers: IntArray) = aggregate(numbers) { op -> this + op }

// 익명 함수에서는 수신 객체 타입을 함수의 파라미터 목록 앞에 추가하면 된다
fun sum(numbers: IntArray) = aggregate(numbers, fun Int.(op: Int) = this + op)

 

 

또 하나의 특징은

수신 객체가 있는 함숫값을 호출할 때는 수신 객체를 처음 파라미터로 넣어

확장 함수가 아닌 일반 함수 형태로 호출할 수 있다. (비확장 함수 호출)

 

val min1: Int.(Int) -> Int = { if (this < it) this else it }
val min2: (Int, Int) -> Int = min1  // 수신 객체가 첫 번째 파라미터인 일반 함수 타입
val min3: Int.(Int) -> Int = min2   // 수신 객체가 있는 함수 타입

/*
수신 객체가 있는 함숫값은 확장, 비확장 형태 중 선택 가능하지만
수신 객체가 없는 함숫값은 비확장 형태로만 호출 가능하다
*/
fun main() {
    val min1: Int.(Int) -> Int = { if (this < it) this else it }
    val min2: (Int, Int) -> Int = min1
    
    println(3.min1(2))  // min1을 확장 함수로 호출
    println(min1(1, 2)) // min1을 비확장 함수로 호출
    println(3.min2(2))  // error
    println(min2(1, 2)) // min2를 비확장 함수로 호출
}

 

이렇게 수신 객체가 있는 함수 타입 값과 수신 객체가 첫 번째 파라미터인 일반 함수 타입 값을 바꿔 사용할 수 있는 이유는

두 타입의 값이 런타임에 똑같이 표현되기 때문이다.

위 코드의 min1, min2, min3는 모두 같은 타입으로 컴파일되는 것을 볼 수 있다. 

 

public final class UtilKt {
   @NotNull
   private static final Function2 min1;
   @NotNull
   private static final Function2 min2;
   @NotNull
   private static final Function2 min3;

   @NotNull
   public static final Function2 getMin1() {
      return min1;
   }

   @NotNull
   public static final Function2 getMin2() {
      return min2;
   }

   @NotNull
   public static final Function2 getMin3() {
      return min3;
   }

   static {
      min1 = (Function2)null.INSTANCE;
      min2 = min1;
      min3 = min2;
   }
}

 

 

'코틀린' 카테고리의 다른 글

[코틀린] 람다와 고차함수  (0) 2022.10.17
[코틀린] Scope function (영역 함수)  (0) 2022.10.10
[코틀린] Lazy 초기화  (0) 2022.10.09
코틀린 정리 - (3)  (0) 2022.10.03
코틀린 정리 - (2)  (0) 2022.09.30