본문으로 건너뛰기

1. Extension Function(확장 함수) 개념

  • 코틀린은 클래스를 상속하거나 데코레이터와 같은 디자인 패턴을 사용하지 않고도 클래스나 인터페이스에 새로운 기능을 확장할 수 있는 강력한 기능을 제공합니다.
  • 이 기능은 '확장(extensions)'이라고 불리는 특별한 선언을 통해 이루어집니다.
  • 확장 함수를 사용하면 수정할 수 없는 서드파티 라이브러리의 클래스나 인터페이스에도 새로운 함수를 추가할 수 있습니다.
  • 이러한 함수들은 마치 원래 클래스의 메서드인 것처럼 일반적인 방식으로 호출됩니다.
  • 확장 함수는 코틀린 표준 라이브러리의 핵심적인 부분으로, String, List, Collection 등 많은 클래스에 풍부한 기능을 제공합니다.
노트

코틀린 확장 함수는 실제로 클래스를 수정하지 않습니다. 대신 해당 타입의 객체에 대해 호출할 수 있는 새로운 함수를 만듭니다. 이는 Open-Closed Principle(개방-폐쇄 원칙)을 자연스럽게 지원하는 방식입니다.

1.1 확장 함수의 장점

  • 코드의 간결성: 기존 클래스에 유틸리티 메서드를 추가하여 코드를 더 읽기 쉽고 간결하게 만듭니다.
  • 기존 코드 수정 없음: 원본 클래스의 코드를 수정하지 않고도 새로운 기능을 추가할 수 있습니다.
  • API 확장: 라이브러리나 프레임워크 API를 확장하여 특정 도메인이나 프로젝트에 맞게 맞춤화할 수 있습니다.
  • 명확한 네임스페이스: 함수는 특정 타입에 연결되어 있어 글로벌 유틸리티 함수보다 발견하기 쉽습니다.
  • 명시적인 수신 객체: 코드의 가독성과 명확성을 높입니다.

1.2 확장 프로퍼티

  • 확장 함수와 유사하게, 코틀린은 확장 프로퍼티(Extension Properties)도 지원합니다.
  • 확장 프로퍼티는 기존 클래스에 새로운 프로퍼티를 추가할 수 있게 해줍니다.
  • 실제로는 프로퍼티처럼 보이지만 내부적으로는 getter와 setter를 사용합니다.
val String.lastIndex: Int
get() = this.length - 1

val String.lastChar: Char
get() = this[lastIndex]

var StringBuilder.lastChar: Char
get() = this[this.length - 1]
set(value) {
this.setCharAt(this.length - 1, value)
}

2. 확장 함수 만들기

2.1 기본 문법과 구조

fun 수신타입.함수이름(매개변수): 반환타입 {
// 함수 본문
// 여기서 'this'는 수신 객체를 가리킵니다
}

가장 기본적인 확장 함수 예제로, MutableList<Int>에 요소 교환 기능을 추가하는 코드를 살펴보겠습니다:

fun MutableList<Int>.swap(index1: Int, index2: Int) {
val tmp = this[index1] // 여기서 'this'는 리스트에 해당합니다
this[index1] = this[index2]
this[index2] = tmp
}
  • 확장 함수를 선언하려면, 함수 이름 앞에 '수신 타입(receiver type)'을 붙이는데, 이는 확장하려는 타입을 가리킵니다.
    • 위 예시에서 MutableList<Int>가 수신 타입입니다.
  • 확장 함수 내부의 this 키워드는 수신 객체(점 앞에 위치한 객체)에 해당합니다.
val list = mutableListOf(1, 2, 3)
list.swap(0, 2) // 호출 후: [3, 2, 1]

2.2 제네릭을 사용한 확장 함수

특정 타입에 한정되지 않고 여러 타입에 적용할 수 있는 확장 함수를 만들려면 제네릭을 사용합니다:

fun <T> MutableList<T>.swap(index1: Int, index2: Int) {
val tmp = this[index1]
this[index1] = this[index2]
this[index2] = tmp
}
  • 이제 어떤 타입의 MutableList든 swap 함수를 호출할 수 있습니다.
  • 수신 타입 표현식에서 제네릭 타입 파라미터를 사용하려면 함수 이름 앞에 제네릭 타입 파라미터를 선언해야 합니다.
val numbers = mutableListOf(1, 2, 3)
numbers.swap(0, 2) // [3, 2, 1]

val names = mutableListOf("Alice", "Bob", "Charlie")
names.swap(0, 2) // [Charlie, Bob, Alice]

2.3 확장 함수 제약 조건 설정

타입 제약을 사용하여 특정 조건을 만족하는 타입에만 확장 함수를 적용할 수도 있습니다:

// Comparable을 구현하는 타입에만 적용되는 확장 함수
fun <T : Comparable<T>> MutableList<T>.sortIfNotEmpty() {
if (this.isNotEmpty()) {
this.sort()
}
}

2.4 널러블(Nullable) 타입 확장

널러블 타입에 대한 확장 함수를 정의할 수도 있습니다:

fun String?.isNullOrBlank(): Boolean {
// this는 nullable이므로, null 체크가 필요합니다
return this == null || this.isBlank()
}

val name: String? = null
println(name.isNullOrBlank()) // true

3. 확장 함수의 내부 구현

  • 확장 함수는 실제로 확장하는 클래스를 수정하지 않습니다.
  • 내부적으로 확장 함수는 수신 객체를 첫 번째 인자로 받는 정적 메서드로 컴파일됩니다.
  • 확장 함수는 정적으로 처리되므로 어떤 확장 함수가 호출될지는 수신 타입에 기반하여 컴파일 시간에 이미 결정됩니다.
  • 이러한 구현 방식은 런타임 오버헤드가 없다는 장점이 있습니다.

3.1 코틀린 확장 함수의 자바 변환

코틀린 확장 함수가 내부적으로 어떻게 구현되는지 이해하기 위해, 다음 코틀린 코드가 자바로 어떻게 변환되는지 살펴보겠습니다:

코틀린 코드 (KotlinExtensions.kt):

package strings

// String이 수신 객체 타입이고 this가 수신 객체입니다
fun String.lastChar(): Char = this.get(this.length - 1)

// 일반 메서드와 마찬가지로 this를 생략할 수 있습니다
fun String.lastChar2(): Char = get(length - 1)

// "Kotlin"이 수신 객체입니다
fun main() {
println("Kotlin".lastChar())
}

자바로 변환된 코드:

// 자바로 변환된 정적 메서드
public class StringExtensionsKt {
public static char lastChar(String receiver) {
return receiver.charAt(receiver.length() - 1);
}

public static char lastChar2(String receiver) {
return receiver.charAt(receiver.length() - 1);
}

public static void main(String[] args) {
System.out.println(lastChar("Kotlin"));
}
}

3.2 바이트코드 레벨에서의 오버헤드

  • 확장 함수는 정적 메서드로 컴파일되므로 가상 메서드 호출(virtual method call)이 발생하지 않습니다.
  • 이는 일반 인스턴스 메서드에 비해 성능상의 이점이 있을 수 있습니다.
  • 하지만 확장 함수가 오버라이드된 메서드를 호출할 경우, 여전히 동적 디스패치(dynamic dispatch)가 발생합니다.

4. 확장 함수와 가시성

4.1 캡슐화와 접근 제한

  • 확장 함수는 캡슐화를 깨트리지 않습니다!
  • 클래스 안에서 정의한 메서드와 달리 확장 함수 안에서는 클래스 내부에서만 사용할 수 있는 private, protected 멤버를 사용할 수 없습니다.
  • 확장 함수는 클래스의 public API만 접근할 수 있습니다.
class SecretClass {
private val secret = "비밀 정보"

fun revealSecret(): String = secret
}

// 확장 함수는 private 멤버에 접근할 수 없습니다
fun SecretClass.tryToExposeSecret(): String {
// return this.secret // 컴파일 오류! private 멤버 접근 불가
return this.revealSecret() // 가능: public 메서드 호출
}

4.2 확장 함수의 가시성 제어

확장 함수 자체의 가시성은 일반 함수와 동일한 방식으로 제어됩니다:

// 패키지 내부에서만 사용 가능한 확장 함수
internal fun String.internalFunction(): String = this.toUpperCase()

// 모듈 외부에서도 사용 가능한 확장 함수
public fun String.publicFunction(): String = this.toLowerCase()

// 선언된 파일 내에서만 사용 가능한 확장 함수
private fun String.privateFunction(): String = this.capitalize()

5. 확장 함수 임포트

  • 확장 함수를 정의하고 자동으로 프로젝트 안의 모든 소스코드에서 해당 함수를 사용할 수 없습니다.
  • 다른 클래스나 함수와 마찬가지로 임포트가 필요합니다.
  • 이는 네임스페이스 충돌을 방지하고 코드 가독성을 높이는 데 도움이 됩니다.

5.1 기본 임포트

// 파일 상단에 임포트
import strings.lastChar

fun main() {
val c = "Kotlin".lastChar()
}

5.2 별칭을 사용한 임포트

// as 키워드로 별칭 부여
import strings.lastChar as last

fun main() {
val c = "Kotlin".last()
}
  • as 키워드를 사용하면 임포트한 클래스나 함수를 다른 이름으로 부를 수 있습니다.
  • 같은 이름을 가진 확장 함수를 한 파일에서 사용할 때 as를 사용해 다른 이름을 부여해 주어야 합니다.

5.3 임포트 충돌 해결

다른 패키지에서 같은 이름의 확장 함수를 제공할 경우, 충돌이 발생할 수 있습니다:

// 두 패키지에 동일한 이름의 확장 함수가 있을 경우
package com.example.package1
fun String.process(): String = this.toUpperCase()

package com.example.package2
fun String.process(): String = this.toLowerCase()

// 사용하는 코드
import com.example.package1.process as processUpper
import com.example.package2.process as processLower

fun main() {
val s = "Test"
println(s.processUpper()) // TEST
println(s.processLower()) // test
}

참고 자료