KB 19 확장함수 — 남의 클래스에 메서드를 붙인다
고친 사람 github-actions[bot]
0. String 에 없는 메서드를 불렀다
자바의 String 에는 shout() 라는 메서드가 없습니다. String 은 JDK 에 들어 있는 final 클래스라서 상속으로 늘릴 수도, 소스를 고칠 수도 없습니다.
그런데 코틀린에서는 이 줄이 컴파일되고 돕니다.
fun String.shout(): String = uppercase() + "!"
fun main() {
println("hi".shout())
val onString = String::class.java.methods
.any { it.name == "shout" }
println(onString)
val m = Class.forName("ShoutKt")
.getMethod("shout", String::class.java)
println(m)
}
fun String.shout(): String— 함수 이름 앞에String.이 붙었습니다. 이것이 확장함수(extension function) 선언입니다.String에shout()를 붙이겠다는 뜻입니다= uppercase() + "!"— 본문이 식 하나면 중괄호와return대신=로 씁니다.uppercase()는 대문자로 바꾼 새 문자열을 돌려줍니다String::class.java.methods— 실행 중에 클래스의 메서드 목록을 꺼내 보는 자바 기능, 리플렉션(reflection)입니다. 자바의String.class.getMethods()와 같습니다.any { it.name == "shout" }— 목록에 이름이shout인 것이 하나라도 있나를 봅니다.{ }는 람다(이름 없는 함수)이고it은 람다가 받은 원소 하나입니다
Shout.kt 로 저장해 코틀린 컴파일러 kotlinc 로 컴파일하고 kotlin 명령으로 돌렸습니다. -d out 은 결과물을 out 폴더에 쓰라는 옵션이고, -cp out 은 실행할 클래스를 out 에서 찾으라는 옵션입니다. 세 줄이 찍혔습니다.
$ kotlinc Shout.kt -d out
$ kotlin -cp out ShoutKt
HI!
false
public static final java.lang.String ShoutKt.shout(java.lang.String)
첫 줄에서 "hi".shout() 가 HI! 를 냈습니다. 둘째 줄이 반전입니다. String 의 메서드 목록에는 shout 가 없습니다. 클래스는 손대지 않은 그대로입니다.
그럼 shout 는 어디 있을까요. 셋째 줄이 답입니다. ShoutKt 라는 클래스에 static 메서드로 들어 있고, String 하나를 파라미터로 받습니다.
ShoutKt 는 소스에 쓴 적 없는 이름입니다. 클래스 밖, 파일 바로 아래에 쓴 함수를 톱레벨 함수라고 부르는데, 코틀린 컴파일러는 톱레벨 함수를 파일 이름 + Kt 클래스의 static 메서드로 넣습니다(KB 02 자바에서 부르고 실행하기 — 파일 이름, const, kotlin-stdlib).
정리하면, 확장함수는 남의 클래스를 고치지 않습니다. 점 앞의 객체를 첫 번째 인자로 받는 static 메서드를 하나 만들고, 부르는 쪽을 메서드 호출처럼 보이게 해 줄 뿐입니다. 이 편의 나머지는 전부 이 한 줄에서 나옵니다.
출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.
1. 선언과 호출 — 자바의 유틸 클래스가 하던 일
이 절에서는 확장함수의 문법을 풀고, 같은 일을 자바로 어떻게 했는지 나란히 놓습니다. 끝으로 확장함수가 할 수 없는 일 하나를 컴파일 에러로 봅니다.
문법 — 점 앞에 붙는 타입이 수신 객체 타입이다
확장함수 선언은 보통 함수 선언에 타입 하나와 점을 더한 모양입니다.
fun String.shout(): String = uppercase() + "!"
// ^^^^^^ 수신 객체 타입
- 점 앞의
String을 수신 객체 타입(receiver type)이라고 부릅니다. 이 함수를 붙일 타입입니다 - 부를 때 점 앞에 오는 실제 값,
"hi".shout()의"hi"를 수신 객체(receiver)라고 부릅니다 - 본문에서
this가 수신 객체입니다.uppercase()는this.uppercase()를 줄여 쓴 것입니다
클래스 안에 쓴 메서드에서 this 를 생략하는 것과 같은 모양이라, 읽을 때는 String 의 멤버처럼 보입니다. 공식 문서도 같은 문법으로 설명합니다(코틀린 문서 「Extensions」).
자바로는 static 유틸 메서드였다
자바에서 String 에 기능을 더하고 싶을 때 쓰던 방법은 유틸 클래스입니다. 대상 객체를 파라미터로 받는 static 메서드를 모아 둔 클래스입니다.
public class Util {
public static String shout(String s) {
return s.toUpperCase() + "!";
}
public static void main(String[] args) {
System.out.println(Util.shout("hi"));
}
}
$ javac -d jout Util.java
$ java -cp jout Util
HI!
jout 은 자바 결과물을 코틀린 쪽과 섞이지 않게 둔 폴더입니다. 출력은 코틀린과 같은 HI! 입니다.
0절에서 본 ShoutKt.shout(String) 이 바로 이 Util.shout(String) 과 같은 모양입니다. 컴파일 결과로는 둘이 같은 물건이고, 다른 것은 부르는 쪽의 모양뿐입니다.
| 자바 유틸 클래스 | 코틀린 확장함수 | |
|---|---|---|
| 선언 | static String shout(String s) |
fun String.shout(): String |
| 대상 객체를 가리키는 이름 | 파라미터 s |
this (생략 가능) |
| 부르기 | Util.shout("hi") |
"hi".shout() |
| 둘을 이어 부르기 | Util.b(Util.a(s)) |
s.a().b() |
| 찾기 | 유틸 클래스 이름을 알아야 한다 | s. 를 치면 IDE 자동 완성에 뜬다 |
마지막 두 줄이 확장함수를 쓰는 까닭입니다. 유틸 메서드를 여럿 이으면 괄호가 안쪽으로 쌓여 안쪽부터 거꾸로 읽어야 합니다. 확장함수는 왼쪽에서 오른쪽으로, 일이 일어나는 순서대로 읽힙니다.
코틀린 표준 라이브러리의 String 함수 상당수가 이 방식으로 붙어 있습니다. 0절의 uppercase() 도 String 클래스의 멤버가 아니라 표준 라이브러리가 붙인 확장함수입니다.
다른 패키지에 선언한 확장함수는 import 해야 보입니다. import com.example.shout 처럼 함수 이름을 가져옵니다. 클래스를 import 하는 것과 같은 문법입니다.
조심 — private 멤버는 못 본다
확장함수가 static 메서드라는 사실이 곧 한계입니다. 클래스 밖에 있는 함수라서 private 멤버에 손이 닿지 않습니다.
class Box(private val secret: Int, val shown: Int)
fun Box.peek(): Int = secret // 컴파일 오류
fun main() {
println(Box(1, 2).peek())
}
class Box(private val secret: Int, val shown: Int)— 클래스 이름 뒤 괄호가 주생성자(primary constructor)입니다. 그 괄호 안에서 프로퍼티 둘을 선언한 한 줄 클래스입니다. 프로퍼티는 자바의 필드와 게터를 묶은 것입니다private은 이 클래스 안에서만 보인다는 뜻입니다. 아무것도 안 붙인shown은public입니다
$ kotlinc Private.kt -d out
Private.kt:3:23: error: cannot access 'val secret: Int': it is private in 'Box'.
fun Box.peek(): Int = secret
^^^^^^
secret 은 Box 안에서만 보이는 private 이라 접근할 수 없다는 에러입니다. 문법은 멤버처럼 생겼어도 권한은 바깥 함수 그대로입니다. 자바 유틸 클래스가 남의 클래스의 private 필드를 못 읽는 것과 같습니다. 가시성 규칙은 KB 15 가시성 — package-private 이 없다에 있습니다.
정리하면, 확장함수는 대상 객체를 첫 인자로 받는 static 유틸 메서드를 멤버 호출 모양으로 부르게 해 주는 문법입니다. 권한도 유틸 메서드와 같아서 공개된 것만 씁니다.
2. 오버라이드가 안 된다 — 변수에 적은 타입이 고른다
상속 관계인 두 클래스에 같은 이름의 확장함수를 하나씩 붙이면 어떻게 될까요. 멤버 메서드였다면 자식 쪽이 이깁니다. 이 절에서는 같은 객체를 부모 타입 변수에 담아 멤버와 확장함수를 한 번씩 부릅니다.
open 은 상속과 오버라이드를 허락하는 표시이고, override 는 부모 것을 다시 정의한다는 표시입니다. 코틀린 클래스와 메서드는 기본이 final 이라 이 둘을 적어야 합니다(KB 14 상속 — 클래스는 기본이 final 이다).
open class Shape {
open fun member() = "Shape.member"
}
class Circle : Shape() {
override fun member() = "Circle.member"
}
fun Shape.ext() = "Shape.ext"
fun Circle.ext() = "Circle.ext"
fun main() {
val c = Circle()
val s: Shape = c
println(c.member()) // Circle.member
println(s.member()) // Circle.member
println(c.ext()) // Circle.ext
println(s.ext()) // Shape.ext
}
class Circle : Shape()—Circle이Shape를 물려받습니다. 자바의extends Shape입니다val s: Shape = c— 같은Circle객체를Shape타입 변수에 담았습니다.c와s는 같은 객체를 가리킵니다Circle()— 객체를 만드는 호출입니다. 코틀린에는new가 없습니다
$ kotlinc Dispatch.kt -d out
$ kotlin -cp out DispatchKt
마지막 줄을 봅니다. 같은 Circle 객체인데 s.ext() 는 Shape.ext 를 골랐습니다. 멤버 메서드는 s 로 불러도 Circle.member 가 나왔는데 말입니다.
가르는 기준은 언제 고르나입니다.
| 고르는 때 | 무엇을 보고 고르나 | s.xxx() 결과 |
|
|---|---|---|---|
멤버 open fun |
실행할 때 | 객체가 실제로 무엇인가 (Circle) |
Circle.member |
| 확장함수 | 컴파일할 때 | 변수에 적은 타입이 무엇인가 (Shape) |
Shape.ext |
0절에서 본 대로 확장함수는 static 메서드 ext(Shape) 와 ext(Circle) 두 개입니다. 컴파일러는 s.ext() 를 만났을 때 s 의 타입이 Shape 라는 것만 알고, 그래서 ext(Shape) 로 못박습니다. 실행 중에 s 에 무엇이 들었는지는 다시 보지 않습니다.
이렇게 컴파일할 때 부를 함수를 정하는 것을 정적 디스패치(static dispatch)라고 부릅니다. 자바에서 오버로딩한 static 메서드 둘 중 하나를 고를 때와 같은 규칙입니다.
그래서 하위 타입마다 다르게 동작해야 하는 기능은 확장함수로 만들지 않습니다. 그런 기능은 클래스 안의 open fun 으로 두고 오버라이드합니다. 확장함수는 타입이 무엇이든 같은 일을 하는 편의 기능에 씁니다.
정리하면, 확장함수는 오버라이드되지 않습니다. 변수에 적은 타입이 부를 함수를 고릅니다.
3. 멤버가 있으면 멤버가 이긴다
클래스에 이미 같은 이름, 같은 파라미터의 멤버가 있는데 확장함수를 또 붙이면 둘 중 누가 불릴까요. 이 절에서는 그 규칙을 보고, 그 규칙이 라이브러리를 올릴 때 결과를 조용히 바꾸는 장면을 재현합니다.
같은 모양이면 멤버, 모양이 다르면 확장함수
Greeter 에 멤버 hello() 가 있고, 확장함수로 hello() 와 hello(name) 을 붙였습니다.
class Greeter {
fun hello() = "member"
}
fun Greeter.hello() = "extension"
fun Greeter.hello(name: String) = "extension $name"
fun main() {
val g = Greeter()
println(g.hello()) // member
println(g.hello("kim")) // extension kim
}
"extension $name"— 문자열 템플릿입니다. 문자열 안에$name을 쓰면 그 변수 값이 끼워집니다
컴파일은 됐고, 경고가 하나 떴습니다.
$ kotlinc Member.kt -d out
Member.kt:5:13: warning: this extension is shadowed by a member: 'fun hello(): String' defined in 'Greeter'.
fun Greeter.hello() = "extension"
^^^^^
$ kotlin -cp out MemberKt
경고는 이 확장함수가 멤버 hello() 에 가려진다는 뜻입니다. 결과도 그렇게 나왔습니다. 파라미터가 똑같은 g.hello() 는 멤버를 불렀고, 멤버에 없는 모양인 g.hello("kim") 은 확장함수를 불렀습니다.
규칙은 하나입니다. 같은 이름, 같은 파라미터면 멤버가 이깁니다. 확장함수는 이미 있는 멤버를 바꿀 수 없고, 멤버에 없는 모양을 더할 수만 있습니다.
함정 — 라이브러리가 나중에 멤버를 추가하면
이 규칙이 문제가 되는 때는 멤버가 나중에 생길 때입니다. 남의 라이브러리 클래스에 확장함수를 붙여 쓰다가, 라이브러리 새 버전에 같은 이름의 멤버가 들어온 상황을 재현했습니다.
라이브러리 쪽 Order 를 두 버전으로 만들었습니다. 1판에는 가격만 있고, 2판에는 total() 멤버가 생겼습니다.
// v1/Lib.kt
class Order(val price: Int)
// v2/Lib.kt
class Order(val price: Int) {
fun total() = price
}
우리 쪽 App.kt 는 부가세 10% 를 붙인 합계를 확장함수로 만들어 씁니다. 이 파일은 두 판 모두 한 글자도 바꾸지 않습니다.
fun Order.total() = price + price / 10
fun main() {
println(Order(1000).total())
}
1판과 함께 컴파일하면 확장함수가 불립니다.
$ kotlinc v1/Lib.kt App.kt -d o1
$ kotlin -cp o1 AppKt
1100
2판으로 올리고 App.kt 를 그대로 다시 컴파일했습니다.
$ kotlinc v2/Lib.kt App.kt -d o2
App.kt:1:11: warning: this extension is shadowed by a member: 'fun total(): Int' defined in 'Order'.
fun Order.total() = price + price / 10
^^^^^
$ kotlin -cp o2 AppKt
1000
우리 코드는 그대로인데 결과가 1100 에서 1000 으로 바뀌었습니다. 부가세가 사라졌습니다. 컴파일은 에러 없이 성공했고, 남은 것은 경고 한 줄뿐입니다.
더 조심할 것은 경고가 뜨는 곳입니다. 경고는 확장함수를 선언한 줄(App.kt:1)에 뜹니다. 호출하는 곳이 수십 군데여도 거기에는 아무 표시가 없습니다.
막는 방법은 경고를 에러로 올리는 것입니다. 같은 두 파일을 kotlinc -Werror 로 컴파일하면 error: warnings found and -Werror specified 를 내고 실패합니다. 경고가 하나라도 있으면 컴파일을 멈추라는 옵션입니다.
-Werror 가 부담스러우면 적어도 라이브러리 버전을 올린 뒤에는 shadowed by a member 경고를 찾아봅니다. 남의 클래스에 붙이는 확장함수에는 total 처럼 흔한 이름보다 totalWithVat 처럼 우리 규칙이 드러나는 이름을 주면 이런 충돌이 덜 납니다.
정리하면, 같은 모양이면 멤버가 이기고, 라이브러리가 그 멤버를 나중에 추가하면 다시 컴파일하는 순간 호출이 조용히 멤버로 넘어갑니다. 신호는 선언한 줄의 경고 하나뿐입니다.
4. 널이어도 부를 수 있는 확장함수
자바에서 null 인 변수에 메서드를 부르면 NullPointerException 이 납니다. 그런데 코틀린에는 null 에 대고 불러도 되는 확장함수가 있습니다. 이 절에서는 수신 객체 타입에 ? 를 붙인 확장함수를 직접 만들고, 표준 라이브러리의 같은 종류 함수를 몇 개 부릅니다.
먼저 용어 둘입니다. 타입 뒤의 ? 는 null 도 담을 수 있다는 표시라서, String? 는 문자열이나 null 을 담습니다. ? 없는 String 에는 null 을 못 넣습니다.
수신 객체 타입에 ? 를 붙인다
fun String?.orDash(): String =
if (this == null) "-" else this
fun String.shout(): String = uppercase() + "!"
fun main() {
val name: String? = null
println(name.orDash()) // -
println("kim".orDash()) // kim
println("[" + name.orEmpty() + "]") // []
println(name.isNullOrEmpty()) // true
println(name.toString()) // null
println(name?.shout()) // null
}
fun String?.orDash()— 수신 객체 타입이String?입니다. 그래서null인 변수에도 점을 찍어 부를 수 있습니다if (this == null) "-" else this— 본문에서this가null일 수 있으니 먼저 검사합니다. 코틀린의if는 값을 돌려주는 식이라 자바의 삼항 연산자? :처럼 씁니다orEmpty()·isNullOrEmpty()— 표준 라이브러리에 있는String?확장함수입니다.null이면 빈 문자열을 주고,null이거나 비었으면true를 줍니다name?.shout()— 안전 호출입니다.name이null이면shout()를 부르지 않고 식 전체가null이 됩니다
$ kotlinc Nullable.kt -d out
$ kotlin -cp out NullableKt
null 인 name 에 orDash()·orEmpty()·isNullOrEmpty() 를 불렀는데 예외가 하나도 나지 않았습니다. 이것도 0절의 한 줄로 설명됩니다. 확장함수는 수신 객체를 인자로 넘기는 static 메서드라서, 인자로 null 을 넘기는 것 자체는 아무 문제가 없습니다. 검사는 함수 본문이 합니다.
name.toString() 도 null 이라는 글자를 찍었습니다. 표준 라이브러리가 모든 타입의 널 가능 버전(Any?)에 toString() 확장함수를 붙여 두었기 때문입니다. 자바의 Objects.toString(o) 가 null 을 받아 "null" 을 돌려주는 것과 같은 일입니다.
? 없는 확장함수는 null 에 못 부른다
반대로 String 에 붙인 shout() 를 String? 변수에 바로 부르면 어떨까요.
fun String.shout(): String = uppercase() + "!"
fun main() {
val name: String? = null
println(name.shout()) // 컴파일 오류
}
$ kotlinc NotNull.kt -d out
NotNull.kt:5:17: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
println(name.shout())
^
String? 인 수신 객체에는 안전 호출 ?. 나 널 아님 단언 !!. 로만 부를 수 있다는 에러입니다. !! 는 null 이 아니라고 컴파일러에게 장담하는 표시이고, 틀리면 실행 중에 예외가 납니다. 위 코드처럼 ?. 로 부르면 컴파일됩니다.
| 수신 객체 타입 | null 인 변수에 . 로 부르기 |
본문의 this |
|---|---|---|
String |
컴파일 오류. ?. 나 !!. 필요 |
null 이 아니다 |
String? |
된다 | null 일 수 있다. 본문이 검사한다 |
정리하면, 수신 객체 타입에 ? 를 붙이면 null 을 받아 주는 유틸 함수가 됩니다. 기본값을 채우거나 비었는지 보는 함수처럼 null 도 정상 입력인 기능에 씁니다.
5. 확장 프로퍼티와 자바에서 부르기
함수만 붙일 수 있는 것은 아닙니다. 이 절에서는 프로퍼티를 붙여 보고, 필드는 왜 못 붙이는지 에러로 확인합니다. 끝으로 자바 코드에서 확장함수를 부릅니다.
게터는 되고 필드는 안 된다
프로퍼티는 자바의 필드와 게터·세터를 묶은 것입니다. 확장 프로퍼티는 게터(와 세터)만 붙입니다.
val String.lastChar: Char
get() = this[length - 1]
var StringBuilder.lastChar: Char
get() = this[length - 1]
set(value) { setCharAt(length - 1, value) }
fun main() {
println("kotlin".lastChar) // n
val sb = StringBuilder("kotlin")
sb.lastChar = 'N'
println(sb) // kotliN
}
val String.lastChar: Char—String에 읽기 전용 프로퍼티lastChar를 붙입니다.Char는 글자 하나, 자바의char입니다get() = ...— 읽을 때마다 부르는 게터입니다.this[length - 1]은 마지막 글자를 꺼냅니다var ... set(value) { ... }—var은 다시 대입할 수 있는 프로퍼티라 세터도 붙였습니다.sb.lastChar = 'N'이 이 세터를 부릅니다StringBuilder("kotlin")— 자바의new StringBuilder("kotlin")입니다
$ kotlinc Prop.kt -d out
$ kotlin -cp out PropKt
"kotlin".lastChar 는 괄호 없이 프로퍼티처럼 읽혀 n 을 냈고, 대입은 세터를 거쳐 kotliN 을 만들었습니다.
그럼 값을 저장해 두는 프로퍼티도 붙일 수 있을까요. String 에 태그 문자열 하나를 달아 봤습니다.
var String.tag: String = "" // 컴파일 오류
$ kotlinc Field.kt -d out
Field.kt:1:26: error: extension property cannot be initialized because it has no backing field.
var String.tag: String = ""
^^
확장 프로퍼티에는 값을 담을 뒷받침 필드(backing field)가 없어서 초깃값을 줄 수 없다는 에러입니다. 뒷받침 필드는 프로퍼티 값이 실제로 저장되는 필드를 말합니다.
까닭은 이번에도 0절입니다. 확장은 클래스를 고치지 않습니다. String 객체 안에 칸을 하나 늘릴 방법이 없으니 값을 둘 곳이 없습니다. 그래서 확장 프로퍼티는 이미 있는 값에서 매번 계산하는 게터로만 만들 수 있습니다.
자바에서는 파일이름Kt.함수(객체) 로 부른다
자바에는 확장함수 문법이 없습니다. 자바에서 보이는 것은 0절에서 본 static 메서드 그 자체입니다. Ext.kt 에 앞의 shout() 와 lastChar 를 담고 자바에서 불렀습니다.
// Ext.kt
fun String.shout(): String = uppercase() + "!"
val String.lastChar: Char
get() = this[length - 1]
public class CallFromJava {
public static void main(String[] args) {
System.out.println(ExtKt.shout("hi"));
System.out.println(ExtKt.getLastChar("kotlin"));
}
}
javac 로 컴파일할 때 -cp eo 로 코틀린 결과물 폴더를 알려 줬습니다. 실행은 kotlin 명령으로 했는데, java 와 같은 일을 하되 코틀린 표준 라이브러리를 스스로 클래스패스에 넣어 줍니다.
$ kotlinc Ext.kt -d eo
$ javac -cp eo -d jout CallFromJava.java
$ kotlin -cp eo:jout CallFromJava
HI!
n
확장함수 shout 는 ExtKt.shout("hi") 로, 확장 프로퍼티 lastChar 는 게터 이름 ExtKt.getLastChar("kotlin") 으로 불렀습니다. 둘 다 수신 객체를 첫 인자로 넘깁니다.
자바에서 코틀린처럼 "hi".shout() 로 쓰면 javac 가 error: cannot find symbol 을 내고 location: class String 을 가리킵니다. String 에는 그런 메서드가 없으니 당연한 결과입니다.
ExtKt 라는 이름이 자바 쪽에서 어색하면 파일 맨 위에 @file:JvmName("StringExt") 를 적습니다. 그러면 클래스 이름이 StringExt 가 되고, 자바에서 StringExt.shout("hi") 로 불러 HI! 를 받았습니다.
정리하면, 확장 프로퍼티는 계산하는 게터만 붙이고 값을 저장하지 못합니다. 자바에서는 확장이 파일이름Kt.함수(수신 객체, 나머지 인자) 모양의 평범한 static 메서드로 보입니다.
6. 성능 — 확장함수는 공짜다
메서드를 덧붙인다고 하면 대상을 한 번 감싸는 객체를 떠올리기 쉽습니다. 자바에서 남의 클래스에 기능을 더하는 다른 방법이 new RichString(s).shout() 처럼 래퍼 객체를 만드는 것이었기 때문입니다. 그 방식은 부를 때마다 객체가 하나씩 생깁니다.
확장함수는 그런 객체를 만들지 않습니다. 이 절에서는 두 가지를 코드로 봅니다. 수신 객체가 감싸이지 않고 그대로 넘어가는지, 그리고 Int 에 붙인 확장함수가 기본형 int 를 받는지입니다.
fun String.self(): String = this
fun Int.twice(): Int = this * 2
fun main() {
val s = "kotlin"
println(s.self() === s)
println(21.twice())
val m = Class.forName("SameKt")
.getMethod("twice", Int::class.javaPrimitiveType)
println(m)
}
===— 같은 객체인지 보는 참조 비교입니다. 자바의==에 해당합니다self()— 받은 수신 객체를 손대지 않고 돌려주는 함수입니다. 돌아온 것이 원래s와 같은 객체인지 보려고 만들었습니다Int::class.javaPrimitiveType— 자바의 기본형int를 가리키는int.class입니다.twice가int를 받는 메서드로 있는지 찾습니다
세 줄의 출력은 한데 모아 바로 아래에 둡니다. 마지막 줄이 길어 코드 옆에 붙이지 못했습니다.
$ kotlinc Same.kt -d out
$ kotlin -cp out SameKt
true
42
public static final int SameKt.twice(int)
s.self() === s 가 true 입니다. 확장함수 안의 this 는 감싼 무언가가 아니라 원래 그 객체입니다. twice 는 int 를 받아 int 를 돌려주는 static 메서드로 있습니다. Int 를 객체로 감싸는 박싱(boxing)이 일어나지 않습니다.
그러니 확장함수를 부르는 비용은 자바 유틸 클래스의 static 메서드를 부르는 비용과 같습니다. 객체 생성이 없고, 오버라이드를 찾는 과정도 없습니다(2절). 작은 static 메서드는 JIT 컴파일러(실행 중에 자주 도는 코드를 기계어로 바꾸는 부품)가 부르는 곳에 본문을 펴 넣기도 합니다. 성능 때문에 확장함수를 피할 까닭은 없습니다.
신경 쓸 조건 — 수신 객체 타입이 Int? 일 때
한 가지 조건에서만 객체가 생길 수 있습니다. 수신 객체 타입을 Int? 처럼 기본형의 널 가능 타입으로 잡을 때입니다.
fun Int?.orZero(): Int = this ?: 0
fun main() {
val n: Int? = null
println(n.orZero())
val m = Class.forName("BoxedKt").getMethod(
"orZero", Int::class.javaObjectType)
println(m)
}
this ?: 0— 엘비스 연산자입니다. 왼쪽이null이면 오른쪽 값을 씁니다Int::class.javaObjectType— 자바의Integer.class입니다
$ kotlinc Boxed.kt -d out
$ kotlin -cp out BoxedKt
0
public static final int BoxedKt.orZero(java.lang.Integer)
이번에는 파라미터가 int 가 아니라 java.lang.Integer 입니다. int 에는 null 을 담을 수 없으니, null 을 받으려면 객체 Integer 여야 합니다. 이것은 확장함수만의 규칙이 아니라 Int? 라는 타입의 규칙입니다(KB 06 숫자 타입 — Int 하나 뒤의 int 와 Integer).
그래서 null 이 아닌 Int 값을 Int? 확장함수에 넘기면 Integer 로 감싸는 일이 생길 수 있습니다. 작은 값은 JDK 가 미리 만들어 둔 Integer 를 재사용하므로 늘 새 객체가 생기는 것은 아닙니다. 신경 쓸 때는 수백만 번 도는 반복문 안에서 Int? 확장함수를 부를 때뿐이고, 그때는 수신 객체 타입을 Int 로 잡은 함수를 따로 두면 됩니다.
정리하면, 확장함수는 static 메서드 호출 하나라서 유틸 메서드와 비용이 같습니다. 객체가 생길 수 있는 것은 Int? 같은 기본형의 널 가능 타입을 수신 객체로 잡을 때뿐이고, 그것도 뜨거운 반복문에서만 따질 일입니다.
7. 한 장 요약
확장함수는 남의 클래스를 고치지 않는다. 점 앞의 객체를 첫 인자로 받는 static 메서드이고, 이 편의 규칙은 전부 여기서 나온다.
| 알고 싶은 것 | 답 | 확인한 방법 |
|---|---|---|
| 클래스가 바뀌나 | 안 바뀐다. 파일이름Kt 의 static 메서드가 생긴다 |
리플렉션 출력 |
| 자바로 치면 | Util.shout(s) 같은 static 유틸 메서드 |
양쪽 실행 |
| private 멤버 | 못 읽는다 | 컴파일 오류 |
| 오버라이드 | 안 된다. 변수에 적은 타입이 고른다 | 실행 출력 Shape.ext |
| 멤버와 이름이 겹치면 | 같은 파라미터면 멤버가 이긴다. 경고 한 줄뿐 | 1100 → 1000 · -Werror |
null 에 부르기 |
수신 객체 타입에 ? 를 붙이면 된다 |
실행 출력 · 컴파일 오류 |
| 확장 프로퍼티 | 게터·세터만. 필드는 없다 | 실행 출력 · 컴파일 오류 |
| 자바에서 | ExtKt.shout("hi") · ExtKt.getLastChar("kotlin") |
javac 후 실행 |
| 비용 | static 호출 하나. Int? 수신 객체만 박싱될 수 있다 |
=== · 리플렉션 출력 |
관련 항목
확장함수가 흉내 내는 것과 대신하는 것
확장 함수 · 멤버 함수 · 유틸리티 클래스 · 정적 메서드 · 래퍼 클래스 · 데코레이터 패턴
확장 선언을 이루는 문법 요소
수신 객체 · 수신 객체 타입 · 확장 프로퍼티 · 뒷받침 필드 · 게터 · 세터 · 톱레벨 함수 · import
부를 함수를 고르는 규칙
정적 디스패치 · 동적 디스패치 · 오버라이드 · 오버로딩 · 섀도잉 · open
확장함수와 함께 쓰이는 널 처리 문법
널 가능 타입 · 안전 호출 연산자 · 엘비스 연산자 · 널 아님 단언 · orEmpty · isNullOrEmpty · Objects.toString
자바에서 코틀린 확장을 부를 때 마주치는 것
@JvmName · 클래스패스 · 코틀린 표준 라이브러리 · 리플렉션 · 컴파일러 경고 · -Werror
확장함수를 부르는 비용과 이어진 것
박싱 · Integer 캐시 · JIT 컴파일 · 인라이닝 · 참조 비교
이 편에 쓰인 언어와 도구
코틀린 · 자바 · JDK · 컴파일러 · kotlinc · 표준 라이브러리 · 패키지 · 필드 · 가시성 변경자