확장(Extension)

권민주·2025년 6월 17일

코틀린

목록 보기
4/12
post-thumbnail

1. 기본 개념

  • 클래스 상속이나 Decorator와 같은 디자인 패턴을 사용하지 않고도 기존 클래스나 인터페이스의 기능을 확장할 수 있는 개념. 기존의 클래스에 내가 원하는 함수나 프로퍼티를 하나 더 포함시켜 확장하고 싶을 때 사용. 즉, 필요로 하는 대상에 함수나 프로퍼티 추가 가능.
  • 확장 기법을 사용하면 기존 클래스의 선언부나 구현부를 수정하지 않고 외부에서 손쉽게 기능을 확장 가능. 기존의 표준 라이브러리를 수정하지 않고도 확장할 수 있는 유용한 기법.
  • 코틀린은 클래스가 기본적으로 final이라서 open 키워드를 별도로 표시하지 않으면 상속 불가능. 상속 대신 기능 확장 가능
  • 코틀린의 최상위 클래스는 Any이기 때문에 Any 클래스에 확장을 정의한다면 모든 클래스에서 사용 가능
  • 기존 클래스의 멤버처럼 호출할 수 있는 높은 가독성. 기존 클래스의 수정이 없는 유연한 확장성

    ❓❓❓
    Decorator 패턴
    기존 객체를 상속하지 않고, 합성을 통해 기능을 확장하는 패턴. 즉, 기능을 추가한 객체가 기존 객체를 멤버로 포함하는 방식. 기존 코드를 수정하지 않고도 새로운 기능을 동적으로 추가할 수 있지만, 객체가 중첩되는 형태로 구성되므로 구조가 복잡

2. 원리

1) 확장의 의미

  • 확장을 정의해도 실제 클래스에 메서드나 프로퍼티 추가를 의미X
  • 객체 내부에 값을 저장할 수 없으므로 일회성처럼 동작
  • 값을 저장하지 않기 때문에 확장 프로퍼티를 val이나 var으로 선언시 초기화(back field) 사용 불가능
  • 프로퍼티를 정의할 때는 반드시 getter 정의 필요. 이 때문에 마치 함수처럼 동작
  • 프로퍼티는 주로 타입 변환, 문자열 처리, 형식 변환, 조건 처리 등에 사용
  • 예제
//IceCream 클래스에 토핑 추가를 만들고 싶을 때 확장을 통해 쉽게 기능 추가 가능
//지금은 구조가 단순하지만 복잡해지거나 수정이 불가한 경우 유용하게 사용 가능
class IceCream{
    fun order(){
        println("Order Ice Cream")
    }
}

fun IceCream.addCookieTopping()=println("Topping - cookie")

fun main(){
    val iceCream= IceCream()
    iceCream.order()//Order Ice Cream
    iceCream.addCookieTopping()//Topping - Cookie
}
//프로퍼티의 경우 getter 사용 필수. getter를 통해 함수처럼 값 반환 가능

//val IceCream.cookieTopping=""// 초기화 불가

val IceCream.cookieTopping:String
    get()="Topping - cookie"
    
fun main(){
    val iceCream= IceCream()
    iceCream.order() //Order Ice Cream
    println(iceCream.cookieTopping)//Topping - Cookie
}
//확장 프로퍼티로 계산된 정보 반환 가능
class IceCream(val size:Int){
    fun order(){
        println("Order Ice Cream")
    }
}

val IceCream.sizeLabel:String
    get()=when{
        size<=200 -> "small"
        size<500 -> "medium"
        else -> "large"
    }
    
fun main(){
    val iceCream= IceCream(200)
    println(iceCream.sizeLabel)//small
}

2)내부 코드

  • 디컴파일 결과를 보면 확장 함수는 정적 함수로 변환되며 수신 객체는 일반 매개변수처럼 전달
  • 확장 프로퍼티의 경우 해당 내용의 get 메서드로 생성
  • 기존 클래스 구조를 건드리지 않고 외부에서 마치 기능이 추가된 것처럼 사용 가능
  • 예제
//String 확장 함수
fun String.addBang()=println("$this!")

public static final void addBang(@NotNull String $this$addBang) {
     //null-safety 보장을 위해 null 체크 삽입. 컴파일러가 자동으로 추가.
      Intrinsics.checkNotNullParameter($this$addBang, "$this$addBang");
     //호출할 때의 this가 $this$addBang으로 변환되어 확장 함수 내용 처리
      String var1 = $this$addBang + '!';
      System.out.println(var1);
}
//IceCream 클래스의 확장 함수와 프로퍼티

class IceCream

fun IceCream.addCookieTopping()=println("Topping - cookie")

val IceCream.cookieTopping:String
    get()="Topping - cookie"
    
//위 예제와 달리 호출객체(this)를 사용하지 않았기에 
//단순히 확장 함수 값을 그대로 사용(해당 값 출력)
public static final void addCookieTopping(@NotNull IceCream $this$addCookieTopping){
   Intrinsics.checkNotNullParameter($this$addCookieTopping,"$this$addCookieTopping");
   String var1 = "Topping - cookie";
   System.out.println(var1);
}
//확장 프로퍼티 getter를 static으로 변환 후 해당 값을 반환
@NotNull
public static final String getCookieTopping(@NotNull IceCream $this$cookieTopping){
    Intrinsics.checkNotNullParameter($this$cookieTopping, "$this$cookieTopping");
    return "Topping - cookie";
}

3. 특징

1)스코프와 사용 조건

  • 다른 파일에서 확장을 사용하려면 명시적으로 import 해야 가능함
  • 전역처럼 사용 불가능
  • 예제
package org.example.declarations
fun List<String>.getLongestString() { /*...*/}
package org.example.usage
import org.example.declarations.getLongestString
fun main() {
    val list = listOf("red", "green", "blue")
    list.getLongestString()
}

2)접근 제한자 제한

  • 일반 함수와 동일한 접근 제한자 사용. public 멤버에만 접근 가능.
  • 확장이 파일의 최상위 수준에 선언된 경우, 같은 파일 내의 최상위 수준의 private에 접근 가능
  • 외부에 선언된 경우, private 또는 protected 접근 불가. 이런 경우에는 클래스 내부 멤버로 구현 필요
  • 예제
//FileA.kt
private fun printFileA()="FileA"
fun String.showFileA():String{
    return printFileA()//접근 가능
}

//FileB.kt
fun String.showFileB():String{
    return printFileA()//접근 불가능
}

3)this 키워드의 의미

  • 확장 내부의 this는 수신 객체(receiver object)를 의미. 해당 확장을 호출하는 실제 객체
  • 확장은 수신 타입(receiver type)을 기반으로 정의. 결론적으로 this는 해당 타입의 인스턴스를 가리킴.
  • 예제
fun MutableList<Int>.swap(index1: Int, index2: Int) {
    val tmp = this[index1] // 이때 this는 아래 변수 list를 의미
    this[index1] = this[index2]
    this[index2] = tmp
}

fun main(){
    //변수 list가 수신 객체. 수신 타입은 MutableList<Int>
    val list = mutableListOf(1, 2, 3)
    list.swap(0, 2) 
}

4)멤버와의 우선순위

  • 동일한 이름의 멤버 함수가 클래스에 존재할 경우, 확장 함수보다 멤버 함수가 우선 호출. 프로퍼티도 동일
  • 예제
class IceCream(val size:Int){
    fun order(){
        println("Order Ice Cream")
    }
}

fun IceCream.order()=println("아이스크림 주문")

fun main(){
    val iceCream= IceCream(200)
    //기존 함수 메서드인 Order Ice Cream 출력
    iceCream.order()
}

5)클래스 내부에서의 확장

  • 어떤 클래스 내부에서 다른 클래스에 대한 확장 함수를 선언 가능
  • 디스패치 수신자(dispatch receiver)
    • 확장 함수가 선언된 클래스의 인스턴스
    • 외부 클래스의 멤버에 접근 가능
  • 확장 수신자(extension receiver)
    • 확장 함수의 수신 대상 클래스의 인스턴스
    • 확장 함수 안에서 this 키워드는 확장 수신자를 의미
    • this에 qualifier 없이 접근 가능해 암시적 수신자라고도 불림.
  • 예제
class Host(val hostname: String) {
    fun printHostname() { print(hostname) }
}

class Connection(val host: Host, val port: Int) {
    fun printPort() { print(port) }

    fun Host.printConnectionString() {
        printHostname()   //Host.printHostname() 호출(확장 수신자)
        print(":")
        printPort()   // Connection.printPort() 호출(디스패치 수신자)
    }

    fun connect() {
        /*...*/
        host.printConnectionString()   // 확장 함수 호출
    }
   fun Host.getConnectionString() {
        this.toString()         // Host.toString() 호출(확장 수신자)
        this@Connection.toString()  // Connection.toString() 호출(디스패치 수신자)
    }

}

fun main() {
    Connection(Host("kotl.in"), 443).connect()
    // 에러 발생. Connection 외부에서 확장 함수 호출 불가능
    //Host("kotl.in").printConnectionString()  
}

6)동적 바인딩

  • 일반 멤버는 동적 바인딩. 런타임에 호출 함수 결정.
  • 확장은 정적 바인딩. 컴파일 시점에 호출 함수 결정.
  • 런타임 타입과 관계없이 확장 정의 때의 타입 기준으로 동작
  • 예제
open class Shape
class Rectangle: Shape()

fun Shape.getName() = "Shape"
fun Rectangle.getName() = "Rectangle"

fun printClassName(s: Shape) {
    println(s.getName())
}

//실제 인스턴스는 Rectangle이지만 정적 타입은 Shape.
//정적 바인딩이기에 런타임 타입인 Shape이 아닌 Retangle 출력
printClassName(Rectangle())//Shape

7)오버라이딩 제한

  • 동적 바인딩 때문에 하위 클래스에서 확장 함수 오버라이딩 불가
  • 단, 같은 함수 이름으로 오버로드(다중 정의)는 가능
  • 예제
//오버라이딩
open class Parent{
    open fun print(){
        println("Parent")
    }
}

fun Parent.say()=println("Hello Parent")

class Child: Parent() {
    override fun print() {
        super.print()
    }
    /*오버라이딩 불가능
    override fun say() {
        super.print()
    }*/
}
//오버로드
open class Parent{
    open fun print(){
        println("Parent")
    }
}

fun Parent.print(str:String)= println("$str Parent")

fun main(){
    val parent= Parent()
    parent.print()
    parent.print("Thank you")
}

8)Companion Object 확장 가능

  • 클래스의 companion object에도 확장 함수 정의 가능
  • 확장 함수는 클래스 인스턴스를 외부 함수의 매개변수로 처리하는 구조이므로, Companion Object도 일반 객체처럼 확장
  • 예제
class MyClass {
    companion object { } 
}

fun MyClass.Companion.printCompanion() { println("companion") }

fun main() {
    MyClass.printCompanion()
}

9)제네릭 타입 확장 가능

  • 제네릭 클래스에 대해서도 확장 함수 정의 가능
  • 제네릭은 기본적으로 컴파일 시점에 타입이 결정되는 정적 시스템이기에 사용 가능
  • 예제
fun <T> MutableList<T>.swap(index1: Int, index2: Int) {
    val tmp = this[index1] 
    this[index1] = this[index2]
    this[index2] = tmp
}

4. 사용법

1)클래스 확장

  • 컬렉션, 특히 일급 컬렉션에 자주 사용
  • 컬렉션의 필터링, 변환 등의 코드 작성과 재사용이 쉬워짐
  • 일급 컬렉션의 경우 불필요한 유틸 클래스를 피하고 도메인 로직을 모델에 가깝게 유지 가능
  • 예제
 //컬렉션
fun List<Int>.sumEven(): Int = this.filter { it % 2 == 0 }.sum()
//일급 컬렉션
data class Name(val value: String)
class NameList(val names: List<Name>)

fun NameList.toCommaSeparated(): String = names.joinToString { it.value }

❓❓❓
일급 컬렉션이란 컬렉션을 직접 노출하지 않고 컬렉션을 감싼 별도의 클래스로 포장(wrapping)하는 설계 방식. 로직 캡슐화와 불변성 보장 가능

2)문자열

  • 문자열 검사 또는 처리에 자주 사용
  • 자주 쓰이는 유효성 검사(이메일, 전화번호 등)를 명확한 이름의 함수로 캡슐화하여 코드 중복 제거
  • 예제
 fun String.isEmail(): Boolean = this.matches(Regex("^[A-Za-z0-9+_.-]+@(.+)$"))

3)Nullable타입

  • 수신 객체가 nullable이라도 확장 가능하기에 컴파일러 오류 방지 필요
  • 반복적인 null 체크를 줄일 수 있음
  • 예제
fun Any?.toString(): String {
    if (this == null) return "null"
    // null 확인 이후에 'this'는 자동으로 non-nullable 타입으로 캐스팅
    //  아래의 toString() 호출은 기존의 Any 클래스의 멤버 함수로 해석
    return toString()
}

4)고차함수와 함께 사용

  • 조건부 적용이나 공통된 연산을 동적으로 조합 가능
  • 데이터 처리 파이프라인을 구현할 때 코드 가독성 향상 가능
  • 예제
fun <T> List<T>.applyIf(condition: Boolean, block: List<T>.() -> List<T>): List<T> =
    if (condition) this.block() else this
    
val result = listOf(1, 2, 3).applyIf(true) { filter { it > 1 } }

5)외부 라이브러리 확장

  • 직접 수정할 수 없는 클래스(예: Retrofit Response)에 의미 있는 기능을 추가 가능
  • 예제
fun <T> Response<T>.isSuccessWithBody(): Boolean 
    = this.isSuccessful && this.body() != null

6)바인딩 어댑터 확장

  • View에 맞는 로직을 외부에서 확장하여 쉽게 동작 정의
  • 예제
fun View.setVisibleIf(condition: Boolean) {
    visibility = if (condition) View.VISIBLE else View.GONE
}

7)안드로이드

해당 예제 코드들의 코틀린 공식 문서와 ChatGPT의 도움을 받아 작성되었습니다

참고

profile
안드로이드 개발자:D

0개의 댓글